【数据结构】链表

链表基础操作

链表逆序打印

使用递归将链表逆序打印,最先打印的是链表的尾结点,他是从后往前打印的:

1
2
3
4
5
6
private void printListNode(ListNode head) {
if (head == null)
return;
printListNode(head.next);
System.out.println(head.val);
}

说明要想实现链表的逆序操作,可以使用递归实现。链表的题目常常可以用递归解决。

反转链表

方法一:使用栈结构

因为栈是先进后出的。实现原理就是把链表节点一个个入栈,当全部入栈完之后再一个个出栈,出栈的时候在把出栈的结点串成一个新的链表。但是效率较低。原理如下:

image.png

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public ListNode reverseList(ListNode head) {
Stack<ListNode> stack = new Stack<>();
// 把链表节点全部摘掉放到栈中
while (head != null) {
stack.push(head);
head = head.next;
}
if (stack.isEmpty())
return null;
ListNode node = stack.pop();
ListNode dummy = node;

// 栈中的结点全部出栈,然后重新连成一个新的链表
while (!stack.isEmpty()) {
ListNode tempNode = stack.pop();
node.next = tempNode;
node = node.next;
}

// 最后一个结点就是反转前的头结点,一定要让他的next
// 等于空,否则会构成环
node.next = null;
return dummy;
}

方法二:双链表求解(推荐)

双链表求解是把原链表的结点一个个摘掉,每次摘掉的链表都让他成为新的链表的头结点,然后更新新链表。下面以链表1→2→3→4为例画个图来看下。

image.png

他每次访问的原链表节点都会成为新链表的头结点。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public ListNode reverseList(ListNode head) {
//新链表
ListNode newHead = null;
while (head != null) {
//先保存访问的节点的下一个节点,保存起来
//留着下一步访问的
ListNode temp = head.next;
//每次访问的原链表节点都会成为新链表的头结点,
//其实就是把新链表挂到访问的原链表节点的
//后面就行了
head.next = newHead;
//更新新链表
newHead = head;
//重新赋值,继续访问
head = temp;
}
//返回新链表
return newHead;
}

方法三:递归解决

递归调用是要从当前节点的下一个结点开始递归。逻辑处理这块是要把当前节点挂到递归之后的链表的末尾。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public static Node reverse03(Node head) {
//终止条件
if (head == null || head.next == null)
return head;

//保存当前节点的下一个结点
Node next = head.next;

// 从当前节点的下一个结点开始递归调用
// reverse 指向反转后链表的头节点, 其被赋值的时机为递归到底时reverse = return head,
// 因此指向的是原链表的尾节点
Node reverse = reverse03(next);
// reverse是反转之后的链表,因为函数reverseList
// 表示的是对链表的反转,所以反转完之后next肯定
// 是链表reverse的尾结点,然后我们再把当前节点
// head挂到next节点的后面就完成了链表的反转。
next.next = head;

// 这里head相当于变成了尾结点,尾结点都是为空的,
// 否则会构成环
// 这里的赋值操作是为了全部递归调用完成后, 当前的链表尾节点指向null
// 除此之外, 在每次递归返回时, head.next指向的null又会被调用它的方法栈里的next.next = head 所覆盖
// 因此只有全部递归调用完成后,这句话才有意义
head.next = null;
return reverse;
}

快慢指针遍历链表

常常可以用快慢指针解决链表问题,其思想是使用两个指针,fast 和 slow。

每次移动时 fast 移动两步,slow 移动一步,这样只要设置好终止条件,就可以实现跳出循环时,slow 指向不同的位置。下面是三种不同判断条件下跳出循环时,slow指向的位置:

  • while (fast.next != null):奇数长度链表的正中间节点
  • while (fast != null):偶数长度链表的右侧中间节点
  • while (fast.next.next != null) :偶数长度链表的左侧中间节点

链表

奇数长度链表的判断条件必须要和偶数长度链表的判断条件进行组合(不组合的后果见下文分析),从而达到不同的终止位置:

  • while (fast != null && fast.next != null):奇数长度链表的正中间节点 + 偶数长度链表的右侧中间链表
  • while (fast.next != null && fast.next.next != null):奇数长度链表的正中间节点 + 偶数长度链表的左侧中间链表

上述二者的区别就在于——当 fast 的位置指向偶数长度链表的倒数第二个节点并进入判断时:

  • 第一种情况,只判断了 fast.next != null,这时 fast.next 是指向的最后一个节点,不为null,可以进入循环体,比第二种情况多执行了一次 fast = fast.next.next;slow = slow.next;,令 slow 多移动了一步,因此指向了右侧的位置。
  • 第二种情况,不仅判断了 fast.next != null,还判断了 fast.next.next != null,这时该条件显然不满足(因为倒数第二个节点的下下个节点就是null),因此没有再进入一次循环体。导致比情况一少移动一步,指向了左侧的位置;

具体代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 第一种情况:
// 1. 奇数的情况下, 每次进循环需要使用 fast.next != null 判断
// 因为奇数情况下, 最后一次进循环时的 fast 指向的是最后一个节点, 其 .next 为 null
// 因此判断条件需要使用 fast.next != null 才能保证进入循环后的fast.next有值
// 循环结束时, slow 指向正中间的节点
// 2. 偶数的情况下, 每次进循环需要使用 fast != null 判断
// 因为偶数情况下, 最后一次进循环时的 fast 指向的是倒数第二个节点, 再移动两次就指向了 null
// 因此判断条件需要用 fast != null 保证 fast 指向倒数第二个节点后就停止循环
// 循环结束时, slow 指向右侧中间的节点
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}

// 第二种情况:
// 1. 奇数的情况下, 每次进循环需要使用 fast.next != null 判断
// 因为奇数情况下, 最后一次进循环时的 fast 指向的是最后一个节点, 其 .next 为 null
// 因此判断条件需要使用 fast.next != null 才能保证进入循环后的fast.next有值
// 循环结束时, slow 指向正中间的节点
// 2. 偶数的情况下, 每次进循环需要使用 fast.next.next != null 判断
// 因为偶数情况下, 最后一次进循环时的 fast 指向的是倒数第二个节点, 再移动两次就指向了 null
// 因此判断条件需要用 fast.next.next != null 保证 fast 指向倒数第二个节点时就停止循环
// 比情况一少进入了一次循环
// 循环结束时, slow 指向左侧中间的节点
while (fast.next != null && fast.next.next != null) {
fast = fast.next.next;
slow = slow.next;
}

注意:绝对不能只写 while (fast.next != null),因为这样奇数长度虽然正常,但是偶数长度的最后一次 fast 将指向倒数第二个节点,这时while判断是成立的,还会进入循环中,但这样在其内进行 fast = fast.next.next; 操作时就会报 NullPointerException

fast.next.next != null 的终止条件和 fast.next != null 的终止条件对于奇数长度的链表肯定是同时满足的,这是因为其奇数长度的特性,但对于偶数长度的链表并不是同时满足的。因为当偶数长度时,最后一次 fast 将指向倒数第二个节点,这时如果不加 fast.next.next != null 判断的话,还会进入循环中,这样在其内进行 fast = fast.next.next; 操作时就会报 NullPointerException

应用

通常可以在定位到中间位置后,将 slow 指向右半部分链表的第一个节点,这种需求下:

  • 偶数长度链表使用第一种情况的判断,跳出循环时自然就是右半部分的第一个节点
  • 奇数长度链表还需要将 slow 再移动一步:
1
2
3
4
5
// 如果是奇数情况, 将 slow 移动一位指向右半侧的第一个节点
// 如果是偶数情况, 上面循环完就已经是指向的右半侧的第一个节点了
if (fast != null) {
slow = slow.next;
}

总结

image-20211011194935036

链表常见题目

从尾到头打印链表

剑指 Offer 06. 从尾到头打印链表:输入一个链表的头节点,从尾到头反过来返回每个节点的值(用数组返回)。

方法一:递归

在递归过程中不断将节点值存到 list 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public int[] reversePrint(ListNode head) {
if (head == null) {
return new int[0];
}

List<Integer> list = new ArrayList<>();
reverse(head, list);
int[] ans = new int[list.size()];
for (int i = 0; i < list.size(); i++) {
ans[i] = list.get(i);
}
return ans;
}

public void reverse(ListNode head, List<Integer> list) {
if (head == null) {
return;
}
reverse(head.next, list);
list.add(head.val);
}

方法二:辅助栈法

借助一个辅助栈,依次将节点的值入栈,最后依次弹出保存到数组中:

1
2
3
4
5
6
7
8
9
10
11
public int[] reversePrint(ListNode head) {
LinkedList<Integer> stack = new LinkedList<Integer>();
while(head != null) {
stack.addLast(head.val);
head = head.next;
}
int[] res = new int[stack.size()];
for(int i = 0; i < res.length; i++)
res[i] = stack.removeLast();
return res;
}

打印两个有序链表的公共部分

image-20211011193524728

思路:两个链表一起遍历,遇到相等时就打印,并且两个指针一起后移一步。如果不相等,较小的指针后移一步进入下一次循环判断。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void printCommonPart(ListNode l1, ListNode l2) {
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
l1 = l1.next;
} else if (l1.val > l2.val) {
l2 = l2.next;
} else {
System.out.println(l1.val);
// 两个指针一起后移
l1 = l1.next;
l2 = l2.next;
}
}
}

删除链表的倒数第 N 个节点

方法一:非递归解决

先求出链表的长度 length,然后就可以找到要删除链表的前一个结点,让他的前一个结点指向要删除结点的下一个结点即可。

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode cur = head;
// 计算倒数第 N 个节点所处的位置
int last = length(head) - n;

// 注意:如果last等于0表示删除的是头结点, 直接返回下一个节点即可
if (last == 0)
return head.next;

// 这里首先要找到要删除链表的前一个结点
for (int i = 0; i < last - 1; i++) {
cur = cur.next;
}

// 然后让前一个结点的next指向要删除节点的next
cur.next = cur.next.next;
return head;
}

// 求链表的长度
private int length(ListNode head) {
int len = 0;
while (head != null) {
len++;
head = head.next;
}
return len;
}

方法二:双指针求解(推荐)

上面是先计算链表的长度,其实不计算链表的长度也是可以,我们可以使用两个指针,一个指针fast先走n步,然后另一个指针slow从头结点开始,找到要删除结点的前一个结点,这样就可以完成结点的删除了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode fast = head;
ListNode slow = head;
// fast移n步,
for (int i = 0; i < n; i++) {
fast = fast.next;
}

// 如果fast为空,表示删除的是头结点
if (fast == null)
return head.next;

while (fast.next != null) {
fast = fast.next;
slow = slow.next;
}

// 这里最终slow不是倒数第n个节点,他是倒数第n+1个节点,
// 他的下一个结点是倒数第n个节点,所以删除的是他的下一个结点
slow.next = slow.next.next;
return head;
}

方法三:递归解决

我们知道获取链表的长度除了上面介绍的一种方式以外,还可以写成递归的方式,比如:

1
2
3
4
5
6
// 求链表的长度
private int length(ListNode head) {
if (head == null)
return 0;
return length(head.next) + 1;
}

上面计算链表长度的递归其实可以把它看做是从后往前计算,当计算的长度是n的时候就表示遍历到了倒数第n个节点了,这里只要求出倒数第n+1个节点,问题就迎刃而解了,来看下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public ListNode removeNthFromEnd(ListNode head, int n) {
int pos = length(head, n);
// 说明删除的是头节点
if (pos == n)
return head.next;
return head;
}

// 获取节点所在位置,逆序
public int length(ListNode node, int n) {
if (node == null)
return 0;
int pos = length(node.next, n) + 1;
// 获取要删除链表的前一个结点,就可以完成链表的删除
if (pos == n + 1)
node.next = node.next.next;
return pos;
}

合并两个有序链表

因为链表是升序的,我们只需要遍历每个链表的头,比较一下哪个小就把哪个链表的头拿出来放到新的链表中,一直这样循环,直到有一个链表为空,然后我们再把另一个不为空的链表挂到新的链表中。

该过程类似于归并排序里的 merge 操作。

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public static ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 下面4行是空判断
if (l1 == null)
return l2;
if (l2 == null)
return l1;

// dummy 指向合并后的链表的头节点的前一个节点
// 其本身只起标志位, 最后返回时记得返回 dummy.next
ListNode dummy = new ListNode(-1);
ListNode cur = dummy;

while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
cur.next = l1;
l1 = l1.next;
} else {
cur.next = l2;
l2 = l2.next;
}
cur = cur.next;
}

//然后把那个不为空的链表挂到新的链表中
cur.next = l1 == null ? l2 : l1;

// 以下归并排序里的方式可以直接被上面的便捷方式替代
// while (l1 != null) {
// tmp.next = l1;
// l1 = l1.next;
// tmp = tmp.next;
// }
// while (l2 != null) {
// tmp.next = l2;
// l2 = l2.next;
// tmp = tmp.next;
// }

return dummy.next;

}

还可以把它改为递归的形式,看下递归的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方法二: 递归
public static ListNode mergeTwoListsV2(ListNode l1, ListNode l2) {
if (l1 == null)
return l2;
if (l2 == null)
return l1;
if (l1.val < l2.val) {
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}

递归代码我们还可以再改一下:

1
2
3
4
5
6
7
8
9
public ListNode mergeTwoLists(ListNode linked1, ListNode linked2) {
//只要有一个为空,就返回另一个
if (linked1 == null || linked2 == null)
return linked2 == null ? linked1 : linked2;
//把小的赋值给first
ListNode first = (linked2.val < linked1.val) ? linked2 : linked1;
first.next = mergeTwoLists(first.next, first == linked1 ? linked2 : linked1);
return first;
}

回文链表

image-20211011194950806

方法一:反转后半部分链表

所谓的回文链表就是以链表中间为中心点两边对称。我们常见的有判断一个字符串是否是回文字符串,这个比较简单,可以使用两个指针,一个最左边一个最右边,两个指针同时往中间靠,判断所指的字符是否相等。

但因为这里是单向链表,只能从前往后访问,不能从后往前访问,所以使用判断字符串的那种方式是行不通的。但我们可以通过找到链表的中间节点然后把链表后半部分反转最后再用后半部分反转的链表和前半部分一个个比较即可

这种方式效率较高,时间复杂度为 O(N),空间复杂度为 O(1)。

找到中间节点的思路是使用快慢指针

image.png

image.png

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class IsPalindrome {
// 方法一: 先使用快慢指针定位到后半部分的链表, 然后反转后半部分链表
// 再同时遍历前半部分和后半部分, 判断当前元素是否相同
public static boolean isPalindrome01(Node head) {
Node fast = head;
Node slow = head;

// fast != null ---> 奇数情况
// fast.next != null ---> 偶数情况
// 循环结束后, 奇数长度的 slow 指向正中间的节点
// 偶数长度的 slow 指向右侧中间的节点
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}


// 如果是奇数情况, 将 slow 移动一位指向右半侧的第一个节点
// 如果是偶数情况, 上面循环完就已经是指向的右半侧的第一个节点了
if (fast != null) {
slow = slow.next;
}

// 反转后半部分链表, 此时 slow 指向的就是右半部分链表的头节点
Node newHead = reverseList(slow);

// newHead 指向反转后的头结点
while (newHead != null) {
if (newHead.value != head.value)
return false;
newHead = newHead.next;
head = head.next;
}

// 如果题目要求链表保持不动,最后还需要再把链表给反转回去
return true;
}

public static Node reverseList(Node head) {
Node newHead = null;
Node tmp = null;

while (head != null) {
//先保存访问的节点的下一个节点,保存起来
//留着下一步访问的
tmp = head.next;
//每次访问的原链表节点都会成为新链表的头结点,
//其实就是把新链表挂到访问的原链表节点的
//后面就行了
head.next = newHead;
//更新新链表
newHead = head;
//重新赋值,继续访问
head = tmp;
}

return newHead;
}

}

方法二:使用栈结构

我们知道栈是先进后出的一种数据结构,这里还可以使用栈先把链表的节点存储的值全部存放到栈中,然后再一个个出栈,这样就相当于链表从后往前访问了,通过这种方式也能解决(但是效率远不如上面的方式)。这种方式效率较低,时间复杂度为 O(N),空间复杂度为 O(N)。

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 方法二: 使用栈结构存储链表元素, 再依次弹栈后判读是否相等
public static boolean isPalindrome02(ListNode head) {
Stack<Integer> stack = new Stack<>();
ListNode tmp = head;
int length = 0;

// 链表内元素依次压栈 (这里的优化可以使用快慢指针只存储后半部分的内容)
while (tmp != null) {
stack.push(tmp.val);
tmp = tmp.next;
length++;
}

// 当过半时停止遍历
int count = 0;
while (head != null && count < length) {
if (head.val != stack.pop())
return false;
head = head.next;
count++;
}

return true;
}

当然也可以直接将链表的每个节点存储到栈中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static boolean isPalindrome02(ListNode head) {
Stack<Integer> stack = new Stack<>;
ListNode cur = head;
int length = 0;

while (cur != null) {
stack.push(cur);
cur = cur.next;
length++;
}

// 用于计数,在到达一半时跳出循环
int count = 0;
while (head != null && count < length) {
if (head.val != stack.pop().val)
return false;
}

return true;
}

方法三:递归方式解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ListNode temp;

public boolean isPalindrome03(ListNode head) {
temp = head;
return check(head);
}

private boolean check(ListNode head) {
if (head == null)
return true;
boolean res = check(head.next) && (temp.val == head.val);
temp = temp.next;
return res;
}

链表分区

image-20211011195009886

方法一:将链表转为数组后分区

先将链表内的节点放到一个数组里,然后用归并排序中的数组的分区方式将链表节点进行分区,然后再将节点数组中的节点依次串起来组成新的链表(注意将最后一个节点的 next 置空)。缺点是节点间的顺序不能保证,并且算法效率较低。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 方法一:先将链表内的节点放到一个数组里,然后用数组的分区方式将链表分区
// 缺点是节点间的顺序不能保证
public static ListNode listPartition1(ListNode head, int pivot) {
if (head == null) {
return head;
}

ListNode cur = head;
int length = 0;
while (cur != null) {
cur = cur.next;
length++;
}

// 创建一个数组,将节点依次存入其中
ListNode[] nodeArr = new ListNode[length];
int index = 0;
while (head != null) {
nodeArr[index++] = head;
head = head.next;
}

arrPartition(nodeArr, pivot);

// 将数组中前一个节点的 next 指向当前节点
int i = 0;
for (i = 1; i != nodeArr.length; i++) {
nodeArr[i - 1].next = nodeArr[i];
}

// 记得要把最后一个节点的尾结点置空
nodeArr[i - 1].next = null;

printLinkedList(nodeArr[0]);
return nodeArr[0];
}

public static void arrPartition(ListNode[] nodeArr, int pivot) {
int left = -1;
int right = nodeArr.length - 1;
int i = 0;

while (i <= right) {
if (nodeArr[i].val < pivot) {
swap(nodeArr, i++, ++left);
} else if (nodeArr[i].val > pivot) {
swap(nodeArr, i, right--);
} else {
i++;
}
}
}

public static void swap(ListNode[] nodeArr, int i, int j) {
ListNode tmp = nodeArr[i];
nodeArr[i] = nodeArr[j];
nodeArr[j] = tmp;
}

public static void printLinkedList(ListNode node) {
System.out.print("Linked List: ");
while (node != null) {
System.out.print(node.val + " ");
node = node.next;
}
System.out.println();
}

public static void main(String[] args) {
ListNode head1 = new ListNode(7);
head1.next = new ListNode(9);
head1.next.next = new ListNode(1);
head1.next.next.next = new ListNode(8);
head1.next.next.next.next = new ListNode(5);
head1.next.next.next.next.next = new ListNode(2);
head1.next.next.next.next.next.next = new ListNode(5);
printLinkedList(head1);
listPartition1(head1, 5);
}

方法二:六个指针

可以使用六个指针,分为三组:小于区域两个指针,等于区域两个指针,大于区域两个指针。在遍历链表时,遇到小于目标值的节点,则先断开其与链表的连接,然后使用小于区域的尾结点指向它;大于和等于的情况类似,最后将三个区域进行合并即可得到保持原有顺序的分区结果。

image-20211011210623585

这种方法的时间复杂度为 O(N),空间复杂度为 O(1)。同时能保证原节点间的相对顺序。

需要特别注意的是:

  • 遍历链表时,需要将当前节点与原先链表断开连接,因为如果不断开的话,每个区域的尾结点都不是null,而是可能会连着其他节点这样再串起来时就不对了,因此必须要在分区前先断开连接,就会比较麻烦
  • 最后合并三个区域时使用的技巧是交叉两两合并的:先合并小于区域和等于区域,再合并等于区域和大于区域,可以看到等于区域是重叠的,这是因为小于区域和大于区域都需要和等于区域进行连接。所以,分两步进行连接,先连接小于区域和等于区域,再将二者连接后的结果与大于区域连接。
  • 最后方法返回新链表的头结点时,需要将三个区域的头结点中第一个不为空的返回

详细见代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
// 方式二:使用六个指针,分为三组:小于区域两个指针,等于区域两个指针,大于区域两个指针
public static ListNode listPartition2(ListNode head, int pivot) {
ListNode sH = null; // small head
ListNode sT = null; // small tail
ListNode eH = null; // equal head
ListNode eT = null; // equal tail
ListNode bH = null; // big head
ListNode bT = null; // big tail

ListNode next = null; // save next node

// 遍历一次链表,将整个链表拆成三个区域,尾指针不断更新,头指针保持不动
while (head != null) {
// 注意:需要将当前节点与原先链表断开连接
// 如果不断开的话,每个区域的尾结点都不是null,而是可能会连着其他节点
// 这样再串起来时就不对了,因此必须要在分区前先断开连接
next = head.next;
head.next = null;

if (head.val < pivot) {
if (sH == null) {
sH = head;
sT = head;
} else {
sT.next = head;
sT = head;
}
} else if (head.val > pivot) {
if (bH == null) {
bH = head;
bT = head;
} else {
bT.next = head;
bT = head;
}
} else {
if (eH == null) {
eH = head;
eT = head;
} else {
eT.next = head;
eT = head;
}
}
head = next;
}

// 如果上面不断开连接,这里就必须得手动断开,但是这样还必须保证 sT eT bT 不为空
// sT.next = null;
// eT.next = null;
// bT.next = null;


// 将三个区域的子链表进行合并
// 下面两个判断是交叉合并的,先合并小于区域和等于区域,再合并等于区域和大于区域
// 可以看到等于区域是重叠的,因为小于区域和大于区域都需要和等于区域进行连接
// 所以,分两步进行连接,先连接小于区域和等于区域,再将二者连接后的结果与大于区域连接
// small and equal reconnect
if (sT != null) {
sT.next = eH;
// 这一步的目的是,如果等与区域为空,则将 eT 指向 sT
// 这样在下一步连接大于区域时,如果等于区域为空则直接将大于区域连在小于区域后面
eT = eT == null ? sT : eT;
}
// all reconnect
if (eT != null) {
eT.next = bH;
}

// 返回头结点时,将三个区域的头结点中第一个不为空的返回
return sH != null ? sH : eH != null ? eH : bH;
}

复制含有随机节点的链表

image-20211012132838017

方法一:使用哈希表

使用哈希表开辟一段额外空间,将原链表中的节点作为 map 中的 key,new一个新的节点作为 map 中的 value。但这需要额外空间复杂度 O(N)。代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static ListNode copyListWithRand1(ListNode head) {
HashMap<ListNode, ListNode> map = new HashMap<>();
ListNode cur = head;

// 将数据放到哈希表中
while (cur != null) {
map.put(cur, new ListNode(cur.val));
cur = cur.next;
}

// map.get(cur) 返回的是原链表中 cur 节点对应的复制节点
cur = head;
while (cur != null) {
// 注意后面要用 map.get(cur.xxx)
// 这样取出来的才是复制出的节点,不然的话取出的就是原节点了,就不符合题意了
map.get(cur).next = map.get(cur.next);
map.get(cur).rand = map.get(cur.rand);
cur = cur.next;
}

// 返回复制出的头结点
return map.get(head);
}

方法二:原地插入复制节点

先在原链表结构中的每一个节点后面插入其对应的复制节点,再依次为复制节点赋上 rand 值,最后将新旧链表分离开。

下图中的 1 2 3 代表原节点,1’ 2’ 3’ 代表复制节点。先遍历一次原链表,将复制节点依次插入到其对应的原节点的后面;再遍历一次组合后的链表,将每个复制节点的 next 和 rand 进行设置;最后遍历一次组合后的链表,将新旧节点拆分开,组合成新的链表。

image-20211012133623570

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public static ListNode copyListWithRand2(ListNode head) {
if (head == null) {
return null;
}

ListNode cur = head;
ListNode next = null;

// 在原链表的每个节点后插入一个其复制出的节点
while (cur != null) {
next = cur.next;
cur.next = new ListNode(cur.val);;
cur.next.next = next;
cur = next;
}

cur = head;
// 设置 rand 值:cur 每次移动两步(遍历每一对原/新节点)
// 令当前的复制节点和原节点的 rand 节点指向一致(但注意,复制节点指向的是原节点的 rand.next 节点)
while (cur != null) {
// 注意需要判断当前原节点的 rand 是否为空,不然会报空指针异常
cur.next.rand = cur.rand != null ? cur.rand.next : null;
cur = cur.next.next;
}

// 将新节点和原节点断开连接,并且同时恢复原链表的顺序
ListNode newHead = head.next;
ListNode copycur;
cur = head;
while (cur != null) {
// 先将接下来要连接的两个新旧节点给保存下来
next = cur.next.next;
copycur = cur.next;
// 连接新节点
copycur.next = copycur.next != null ? copycur.next.next : null;
// 恢复原节点的顺序
cur.next = next;
cur = next;
}


// 下面这种做法无法将原链表恢复,原先的结构被破坏了
// ListNode newHead = head.next;
// cur = newHead;
// while (cur.next != null) {
// cur.next = cur.next.next;
// cur = cur.next;
// }

return newHead;
}

环形链表

给定一个链表,判断链表中是否有环,若有环,返回入环节点,若无环,则返回 null。

思路:使用快慢指针,快指针每次走两步,慢指针每次走一步。

  • 外层循环中判断若快指针已经为 null 或 fast.next == null,则代表肯定没有环,直接返回 null
  • 每次循环体中都判断一下 slow 是否和 fast 相等,若相等,说明二者相遇了,代表肯定有环
  • 这时保持 slow 位置不动,将 fast 移回 head 位置
  • 再创建一个内层循环,令 fast 和 slow 一起每次移动一步,二者相遇时即在头结点位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public ListNode hasCycle(ListNode head) {
// 边界条件
// 如果链表节点的长度小于3,则直接返回Null
if (head == null || head.next == null) {
return null;
}

// 使用快慢指针
ListNode slow = head.next;
ListNode fast = head.next.next;

// 如果跳出了 while 循环,说明肯定无环
while (fast != null && fast.next != null) {
// 如果二者相遇了,说明肯定有环
// 这时保持 slow 位置不动,将 fast 移回 head
// 再和 slow 一起每次移动一步,二者相遇时即在头结点位置
if (slow == fast) {
fast = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}

slow = slow.next;
fast = fast.next.next;
}

return null;
}

两条单链表相交的一系列问题

image-20211013202906408

两条单链表,可能有环也可能无环,求其相交部位的节点。该问题需要分类讨论:

  • 两条链表都无环:可能相交也可能不相交,若相交则入环节点相同
  • 一条有环一条无环:不可能相交
  • 两条都有环:又分为三种情况(见下图)
    • 两条链表的环不相交
    • 两条链表的环相交并且入环节点相同
    • 两条链表的环相交并且入环节点不同

image-20211013203304962

程序思路:

  • 首先判断两条链表是否都有环,如果一条有环一条无环,不可能相交,直接返回 null
  • 如果都无环,则可能相交也可能不相交;
    • 先从头到尾遍历一次链表,计算出各自的长度。并且判断一下两条链表的尾结点是否相等,如果不相同代表肯定不相交,可以直接返回。
    • 若二者尾结点相等,则代表相交,让长的链表的指针先移动 n 步(n 为长的链表长度减去短的链表长度)
    • 然后两条链表的指针一起移动:
      • 若在二者遍历到链表尾部时还未相遇,说明无相交,返回 null
      • 若在遍历过程中二者相遇了,说明找到了相交节点
  • 如果都有环,则:
    • 如果两条链表的入环节点相同,则说明是上图的情况2,此情况下想求相交的节点就退化为了求两条无环链表的相交节点问题,上文已分析过
    • 若想区分情况1和情况3,则只需要让第一条链表的入环节点绕着自己的环在走一圈,如果能在途中遇到第二条链表的入环节点,即代表是情况3,否则就是情况1
    • 情况1时,返回 null
    • 情况3时,任意返回一个节点即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
public class FindFirstIntersectNode {

public static ListNode getIntersectNode(ListNode head1, ListNode head2) {
if (head1 == null || head2 == null)
return null;

ListNode loop1 = getLoopNode(head1);
ListNode loop2 = getLoopNode(head2);

// 如果都无环
if (loop1 == null && loop2 == null) {
// 先让长的链表先走 n 步,再和短的链表一起移动,判断是否会相遇
return noLoop(head1, head2);
} else if (loop1 != null && loop2 != null) {
return bothLoop(head1, head2, loop1, loop2);
}


return null;
}


public static ListNode getLoopNode(ListNode head) {
// 边界条件
// 如果链表节点的长度小于3,则直接返回Null
if (head == null || head.next == null) {
return null;
}

// 使用快慢指针
ListNode slow = head.next;
ListNode fast = head.next.next;

// 如果跳出了 while 循环,说明肯定无环
while (fast != null && fast.next != null) {
// 如果二者相遇了,说明肯定有环
// 这时保持 slow 位置不动,将 fast 移回 head
// 再和 slow 一起每次移动一步,二者相遇时即在头结点位置
if (slow == fast) {
fast = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}

slow = slow.next;
fast = fast.next.next;
}

return null;
}

// 都无环分两种情况:
// 1. 两条链表的尾结点若不相等,则不相交
// 2. 若尾结点相等,则肯定相交,先让长的链表走 n 步,然后二者再一起走,就能相遇
public static ListNode noLoop(ListNode head1, ListNode head2) {
if (head1 == null || head2 == null) {
return null;
}

ListNode cur1 = head1;
ListNode cur2 = head2;
int length1 = 0;
int length2 = 0;

// 先统计出各自的长度
while (cur1.next != null) {
cur1 = cur1.next;
length1++;
}
while (cur2.next != null) {
cur2 = cur2.next;
length2++;
}

// 在这里先判断一下两条链表的尾结点是否相等
// 注意:处于这种考虑,上面的 while 循环里要使用 cur.next 而不是 cur,
// 否则跳出循环时必定为都是 null
if (cur1 != cur2) {
return null;
}
// 能通过上面的判断,说明肯定相交

// 找出长的链表和短的链表
int n = length1 - length2;
ListNode longNode = n > 0 ? head1 : head2;
ListNode shortNode = longNode == head1 ? head2 : head1;
n = Math.abs(n);

// 长的链表先走 n 步
for (int i = 0; i < n; i++) {
longNode = longNode.next;
}

// 长短链表一起移动,判断在哪里相遇
// ps: 因为上面提前断定了肯定相交,所以可以直接这么写
while (longNode != shortNode) {
longNode = longNode.next;
shortNode = shortNode.next;
}

return longNode;
}

// 都有环分三种情况
// 1. 让其中一个入环节点 loop1 绕着自己的环走一圈,若始终没遇到 loop2 则说明不相交
// 2. 若两个入环节点相同 loop1 == loop2,则相交的点退化为 nonLoop 的情况
// 3. 让其中一个入环节点 loop1 绕着自己的环走一圈,若中途遇到了 loop2 则说明相交,返回任意一个节点即可
public static ListNode bothLoop(ListNode head1, ListNode head2, ListNode loop1, ListNode loop2) {
ListNode cur1 = null;
ListNode cur2 = null;

if (loop1 == loop2) {
// 如果相等,说明是情况2,退化为 nonLoop 情况
cur1 = head1;
cur2 = head2;
int length1 = 0;
int length2 = 0;

// 注意:这里不能写 cur1.next != loop1 !!!
// 因为 cur1 自身可能就等于 loop1,这种极端情况下就会永远也遍历不到 loop1
while (cur1 != loop1) {
cur1 = cur1.next;
length1++;
}
while (cur2 != loop2) {
cur2 = cur2.next;
length2++;
}
int n = length1 - length2;
ListNode longNode = n > 0 ? head1 : head2;
ListNode shortNode = longNode == head1 ? head2 : head1;
n = Math.abs(n);

for (int i = 0; i < n; i++) {
longNode = longNode.next;
}

while (longNode != shortNode) {
longNode = longNode.next;
shortNode = shortNode.next;
}

return longNode;
} else {
// 为区分情况1 3,只需将 loop1 沿着自己的环走一圈
// 若在再次走回到 loop1 自己前如果遇到了 loop2 说明是情况3,否则就是情况1
cur1 = loop1.next;
while (cur1 != loop1) {
if (cur1 == loop2)
return loop2;
cur1 = cur1.next;
}

// 别忘了这里
return null;
}

}

public static void main(String[] args) {
// 1->2->3->4->5->6->7->null
ListNode head1 = new ListNode(1);
head1.next = new ListNode(2);
head1.next.next = new ListNode(3);
head1.next.next.next = new ListNode(4);
head1.next.next.next.next = new ListNode(5);
head1.next.next.next.next.next = new ListNode(6);
head1.next.next.next.next.next.next = new ListNode(7);

// 0->9->8->6->7->null
ListNode head2 = new ListNode(0);
head2.next = new ListNode(9);
head2.next.next = new ListNode(8);
head2.next.next.next = head1.next.next.next.next.next; // 8->6
System.out.println(getIntersectNode(head1, head2).val);

// 1->2->3->4->5->6->7->4...
head1 = new ListNode(1);
head1.next = new ListNode(2);
head1.next.next = new ListNode(3);
head1.next.next.next = new ListNode(4);
head1.next.next.next.next = new ListNode(5);
head1.next.next.next.next.next = new ListNode(6);
head1.next.next.next.next.next.next = new ListNode(7);
head1.next.next.next.next.next.next = head1.next.next.next; // 7->4

// 0->9->8->2...
head2 = new ListNode(0);
head2.next = new ListNode(9);
head2.next.next = new ListNode(8);
head2.next.next.next = head1.next; // 8->2
System.out.println(getIntersectNode(head1, head2).val);

// 0->9->8->6->4->5->6..
head2 = new ListNode(0);
head2.next = new ListNode(9);
head2.next.next = new ListNode(8);
head2.next.next.next = head1.next.next.next.next.next; // 8->6
System.out.println(getIntersectNode(head1, head2).val);
}
}

删除排序链表中的重复元素 II

82. 删除排序链表中的重复元素 II:给定一个已排序的链表的头 head删除原始链表中所有重复数字的节点,只留下不同的数字 。返回 已排序的链表

思路:使用两个指针 curpre 一起右移

  • 首先进行 while 循环,一直进行到 curcur.next 的值不相等,代表其后不存在与之重复的节点
  • 跳出循环后,cur 的下一个节点就和其不重复,此时需要再判断 curpre 的值是否相等
    • 如果相等,代表 cur 是个重复节点,不能保存
    • 如果不相等,代表 cur 不是重复节点,需要保存(注意保存后 newcur 也要一起移动)
  • 链表遍历完毕后,记得将最后一个节点的尾结点设置为 null
  • 返回 dummy.next

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public ListNode deleteDuplicates(ListNode head) {
if (head == null) {
return null;
}
ListNode dummy = new ListNode(-1);
ListNode newcur = dummy;
ListNode cur = head;
ListNode pre = dummy;

while (cur != null) {
// 1. 保证退出循环的 cur 和 cur.next 是不重复的
while (cur.next != null && cur.val == cur.next.val) {
pre = cur;
cur = cur.next;
}

// 2. 出循环时,cur 肯定和 cur.next 不相同,此时判断一下 pre 和 cur 是否相同
if (pre.val == cur.val && pre != dummy) {
// 如果相等,就是重复的节点,不保存该节点,开始下一个节点
cur = cur.next;
} else {
// 如果不等,则 cur 不重复,可以保存
newcur.next = cur;
// 别忘了移动 newcur
newcur = cur;
// 别忘了也要更新 cur
cur = cur.next;
}
}
// 还需要断开原始连接
newcur.next = null;
return dummy.next;
}

题解思路(更加优雅):currdummy 开始

  • 判断 cur.next.val == cur.next.next.val(注意 cur 是不重复的链表上的最新节点,其包括自己在内的之前的节点都不重复):
    • 如果相等,说明 cur 的下一个节点是重复节点,不能考虑在内。此时保存其值 x(用于在 while 循环中不断找到和该节点重复的节点),并开启一个新的 while 循环,不断跳过 x 重复的节点 cur.next = cur.next.next(一直操作下去直到退出 while 循环时,cur 将指向与 x 重复的节点的下一个节点上)
    • 如果不相等,说明 cur 的下一个节点肯定不重复,进行保存
  • 这样退出循环后,也不需要额外设置 null 了,因为在上面的 while 循环里会将 null 保存进去

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public ListNode deleteDuplicates(ListNode head) {
if (head == null) {
return head;
}
ListNode dummy = new ListNode(0, head);

ListNode cur = dummy;
while (cur.next != null && cur.next.next != null) {
// cur 是不重复的链表的最新节点
if (cur.next.val == cur.next.next.val) {
int x = cur.next.val;
// 一直将与 x 重复的节点都跳过,退出循环时,cur.next 将指向这些重复节点后面的一个节点(或者为null)
while (cur.next != null && cur.next.val == x) {
cur.next = cur.next.next;
}
} else {
cur = cur.next;
}
}

return dummy.next;
}

K 个一组翻转链表

给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。

k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。

img

思路:

  • 首先遍历一遍链表,统计出节点总个数 N
  • 根据 N 和 k 计算出应该有多少个分组:group = N / k
  • 重新遍历链表,但是要按照组的形式遍历,一次遍历一个组内的 k 个节点,将其翻转后存储新的 headtail 到一个二维数组中 record[group][2]
  • 遍历完毕后,根据 record[group][2] 中的节点开始连接每一组的头和尾

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56

public ListNode reverseKGroup(ListNode head, int k) {
if (head == null) {
return null;
}
// 先遍历一遍链表确定总长度 N
int N = 0;
ListNode cur = head;
while (cur != null) {
N++;
cur = cur.next;
}

// 分组个数
int group = N / k;
if (group < 1) {
return head;
}

cur = head;

ListNode next = null;
ListNode newHead = null;

// 维护一个二维数组,保存每一组的头和尾
ListNode[][] record = new ListNode[group][2];
// 需要记录第一组翻转后的头结点
for (int i = 0; i < group; i++) {
// 先保存原本的头(变为新的尾)到数组
record[i][1] = cur;
// 翻转链表
for (int j = 0; j < k; j++) {
next = cur.next;
cur.next = newHead;
newHead = cur;
// 这一步会不断保存下一个节点,从而在当前组的末尾 tail 时保存了下一个组的头节点
// 从而可以在下一个 for 循环中成功 record[i][1] = cur 记录到新的组的头节点
cur = next;
}
// 保存翻转后的头节点 cur
record[i][0] = newHead;

// 下一组的 head 就是 cur
// 准备开始下一组的遍历
}

// 将每一组的连接起来
for (int i = 0; i < group - 1; i++) {
// i组的尾指向i+1组的头
record[i][1].next = record[i + 1][0];
}
// 最后一组的尾指向cur的下一个
record[group - 1][1].next = cur;

return record[0][0];
}