一、链表的基础知识
1.1 链表的结构
上图是链表的数据结构,其中head只存储指针,head在C语言中是指针,在Java中相当于一个引用,它本身不是一个对象,也不是一个链表节点
-
节点
- 数据域
- 指针域
- 实现方式包括:地址(C语言)、下标(相对地址,数组下标)、引用(Java、JS、Python)
-
链状结构
- 通过指针域的值形成了一个线性结构
总结:只要在相关的结构中增加了一项指针域,则该结构就可以串成一个链表结构
1.2 访问链表的时间复杂度
链表不适合快速的定位数据,适合动态的插入和删除的应用场景。
- 查找节点O(n)
- 插入节点O(1)
- 删除节点O(1)
1.3 几种经典的链表实现方法
-
传统方法(节点+指针)
class ListNode { int val; ListNode next; ListNode() { } ListNode(int val) { this.val = val; } } public class LinkedListImplementation1 { public static void main(String[] args) { //构建一个链表 ListNode head = null; head = new ListNode(1); head.next = new ListNode(2); head.next.next = new ListNode(3); head.next.next.next = new ListNode(4); //定义一个指针pre遍历链表 ListNode pre = head; while (pre != null) { System.out.print(String.format("%d->", pre.val)); pre = pre.next; } //输出结果1->2->3->4-> } }
-
使用数组模拟
- 指针域和数据域分离
- 利用数组存放下标进行索引
public class LinkedListImplementation2 { static int[] data = new int[10];//数据域 static int[] pointer = new int[10];//指针域 public static void main(String[] args) { int head = 4; data[head] = 1; add(4, 3, 2); add(3, 6, 3); add(6, 8, 4); add(8, 2, 5); //定义指针pre遍历链表 int pre = head; while (pre > 0) { System.out.print(String.format("%d->", data[pre])); pre = pointer[pre]; } } /** * @param cur 当前节点指针 * @param next 下个节点指针 * @param nextValue 下个节点值 */ public static void add(int cur, int next, int nextValue) { pointer[next] = pointer[cur]; pointer[cur] = next; data[next] = nextValue; } }
-
……
二、链表的典型应用场景
-
操作系统内的动态内存分配
申请了1GB 后内存产生了两块内存碎片,操作系统是如何维护这两块内存碎片的?
其中一种实现就是用链表,剩余的内存碎片就会形成一个链表结构
-
LRU缓存淘汰算法
LRU = Least Recently Used(近期最少使用)物理设备间数据传输存在速度差异,可以通过将使用较多的数据(热点数据)存放在高速区域,而将使用较少的内容存放在相对低速的区域的方式,来对系统进行优化。通常高速区域广义可以称为缓存。
那么缓存这部分的存储空间内部是怎么维护的呢?最简单的一种维护方式就是链表结构(当然真实情况为了加快查找效率,加了hash表结构,就是hash链表)
如图1G的有限空间内,添加数据向数据4后加,淘汰数据从头部删
-
还有很多场景:
- 链表+数组,块状链表,用于大多数语言中可以动态扩容的线性表(例如Java中的HashMap)
- …
三、经典算法题
以下算法题均摘自力扣,读者可自行去力扣答题,并有详细题解,我只是简单介绍了下我的解题思路,并记录一下自己的答案,算法学习个人认为光看、思考是远远不够的,还需要多加练习,以下是链表相关经典算法题答题的推荐顺序:141、142、202、206、92、25、61、24、19、83、82。
3.1 链表的访问
3.1.1 LeetCode #141 环状链表
题目描述:
给定一个链表,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
如果链表中存在环,则返回 true 。 否则,返回 false 。
进阶:你能用 O(1)(即,常量)内存解决此问题吗?
解题思路
- 思路1:使用哈希表(额外的存储区)存储已经遍历过的节点
- 思路2:双指针做法
使用快慢指针 快指针一次向前2个节点 慢指针一次向前1个节点- 有环的链表中 快指针和慢指针最终一定会在环中相遇
- 无环的链表中 快指针会率先访问到链表尾 从而终结检测过程
解法一:使用哈希表
遍历链表的过程中记录遍历过的节点,如果遇到next节点为null节点,说明没有环,如果遇到我们以前遍历过的节点说明有环。
这种做法需要额外的存储区才能完成,而这块存储区域想高效的存储查找节点,就需要hashMap
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public boolean hasCycle(ListNode head) {
if (head == null) {
return false;
}
//hashSet底层就是hashMap
HashSet<ListNode> hashSet = new HashSet<>();
do {
boolean exist = hashSet.add(head);//存在返回false
if (!exist) {
return true;
}
head = head.next;
} while (head != null);
return false;
}
}
解法二:使用快慢指针
定义两个指针,一开始都指向head节点,然后慢指针每次向前移动一步,快指针每次向前移动两步,进行遍历整个链表:
- 当快指针走到尾部,即快指针的next节点为null或者快指针本身节点为null时,说明链表没有环
- 如果链表有环,那么快慢指针一定会相遇,指向同一个节点,当指向同一个节点时,遍历结束
什么情况下快指针永远追不上慢指针?
- 快指针每次走的步数 % 环的总长度 = 慢指针的步数
- 比如上图,快指针每次走9步,慢指针每次走一步
- 因为链表形成环最少要3个节点,所以如果慢指针是1步,快指针可以是2步、3步永远也不会有问题
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) {
return false;//空或者只有一个节点,肯定没有环
}
ListNode slow = head;
ListNode fast = head;
do {
slow = slow.next;
fast = fast.next.next;
} while (slow != fast && fast != null && fast.next != null);
//退出循环两个条件:
// 快慢节点相遇
// 快节点走到尾部,因为fast每次走两步,如果链表是单数,刚好走到最后一个节点,如果是双数,会走到null
return slow == fast;
}
}
3.1.2 LeetCode #142 环状链表II
题目描述
给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。
说明:不允许修改给定的链表。
进阶:你是否可以使用 O(1) 空间解决此题?
解题思路:
- 思路1:使用哈希表,和上面题目解题思路完全一样。略
- 思路2:使用快慢指针,利用一个结论:”相遇点到环起始点的距离”和”链表头节点到环起始点到距离” 永远相等
解法:使用快慢指针
如果有环,观察可以发现:
”相遇点到环起始点的距离”和”链表头节点到环起始点到距离” 永远相等
证明:
设链表中环外部分的长度为 a。slow 指针进入环后,又走了 b 的距离与fast 相遇。此时,fast 指针已经走完了环的 n 圈,因此它走过的总距离为 a+n(b+c)+b=a+(n+1)b+nc。
根据题意,任意时刻,fast 指针走过的距离都为 slow 指针的 2 倍。因此,我们有
a+(n+1)b+nc=2(a+b) ⟹ a=nb-b+nc=(n-1)b+nc=c+(n-1)(b+c)
有了 a=c+(n-1)(b+c) 的等量关系,我们会发现:从相遇点到入环点的距离加上 n-1 圈的环长,恰好等于从链表头部到入环点的距离。
而实际上这个n我们实际上是不需要关心的,因此,当发现 slow 与 fast 相遇时,我们再额外使用一个指针 ptr(或者复用快指针)。起始,它指向链表头部;随后,它和 slow 每次向后移动一个位置。最终,它们会在入环点相遇。
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode detectCycle(ListNode head) {
if(head == null || head.next == null){
return null;//null或者一个节点肯定没有环
}
//先判断是否有环
ListNode fast = head;
ListNode slow = head;
do{
fast = fast.next.next;
slow = slow.next;
}while (fast != slow && fast!= null && fast.next != null);
if(fast == slow){
//说明有环
fast = head;//快指针回到头节点
while (fast != slow){
fast = fast.next;
slow = slow.next;
}
return fast;
}
return null;
}
}
3.1.3 LeetCode #202 快乐数
题目描述:
编写一个算法来判断一个数 n 是不是快乐数。
「快乐数」定义为:
- 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
- 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
- 如果 可以变为 1,那么这个数就是快乐数。
如果 n 是快乐数就返回 true ;不是,则返回 false 。
解题思路:
核心思想是转化为判断链表是否有环的问题
这道题考察的就是逻辑思维结构上的
链表思维
,即链表唯一指向变换的思维,利用链表唯一指向性的特征
,因此本质就是一个链表判环的问题:每一个数字都看成是链表中的节点,每个节点之间转换规则看成是链表的指针,1看成是链表中的尾节点
按照这样的思维,我们怎么证明要么最终遍历到节点1,要么就是无限循环呢?
收敛性的证明
- 32位int的表示正整数大概是21亿
- 在这个范围内 各位数字平方和最大的数是1999999999,和为730,
意味着构成这个链表中的数字不可能超过730,假设所有数字都出现了,那最多也只有731个节点,1(开始的int数)+ 730
- 根据鸽巢原理(pigeonhole’s principle,也译作抽屉原理)在730次循环后必定出现重复
class Solution {
/**
* 获取下一个数
*
* @param value
* @return
*/
public int getNext(int value) {
int result = 0;
while (value > 0) {
result += (value % 10) * (value % 10);
value = value / 10;
}
return result;
}
public boolean isHappy(int n) {
if (n < 1) {
return false;
}
int slow = n;
int fast = n;
do {
slow = getNext(slow);
fast = getNext(getNext(fast));
} while (slow != fast && fast != 1);
return fast == 1;
}
}