大纲
1.四种方法解决两个链表第一个公共子节点
题目:输入两个链表,找出它们的第一个公共结点。
例如下面的两个链表:
两个链表的头结点都是已知的,相交之后成为一个单链表,但是相交的位置未知,并且相交之前的结点数也是未知的,请设计算法找到两个链表的合并点。
1.1 哈希和集合
先将一个链表元素全部存到一个Map里,然后一边遍历第二个链表,一边监测Hash中是否存在当前结点,如果有交点,那么一定能监测出来。对于本题,使用集合更合适,而且代码也更整洁。
完整代码:
public ListNode findFirstCommonNodeBySet(ListNode headA, ListNode headB) {
Set<ListNode> set = new HashSet<>();
while (headA != null) {
set.add(headA);
headA = headA.next;
}
while (headB != null) {
if (set.contains(headB)) {
return headB;
}
headB = headB.next;
}
return null;
}
1.2 使用栈
这里需要用到两个栈,分别将两个链表的结点入两个栈,然后分别出栈,如果相等就继续出栈,一直找到最晚出栈的那一组。这种方法需要两个O(n)的空间,所以在面试时不占优势,但是能够很好锻炼我们的基础能力,所以写一个吧:
public ListNode findFirstCommonNodeByStack(ListNode headA, ListNode headB) {
Stack<ListNode> stackA = new Stack<>();
Stack<ListNode> stackB = new Stack<>();
while (headA != null) {
stackA.push(headA);
headA = headA.next;
}
while (headB != null) {
stackB.push(headB);
headB = headB.next;
}
ListNode preNode = null;
while (stackB.size() > 0 && stackA.size() > 0) {
/**
* stackA.peek()是获取栈顶元素的操作,它会返回栈顶的元素,并不会将该元素从栈中移除。这个操作类似于查看栈顶元素,但是不会对栈做任何修改。
* stackA.pop() 是移除栈顶元素的操作,它会将栈顶的元素从栈中移除,并返回被移除的元素。这个操作会修改栈结构,使栈的大小减一。
*/
if (stackA.peek() == stackB.peek()) {
preNode = stackA.pop();
stackB.pop();
} else {
break;
}
}
return preNode;
}
1.3 差和双指针
我们再看另一个使用差和双指针来解决问题的方法。假设公共子节点一定存在,第一轮遍历,假设La的长度为L1,Lb的长度为L2,则 |L2-L1| 就是两个的差值。第二轮遍历,长的先走 |L2-L1| ,然后两个链表同时向前走,结点一样的时候就是公共结点了。
public ListNode findFirstCommonNode(ListNode pHead1, ListNode pHead2) {
if (pHead1 == null || pHead2 == null) {
return null;
}
ListNode current1 = pHead1;
ListNode current2 = pHead2;
int l1 = 0, l2 = 0;
// 分别统计两个链表的长度
while (current1 != null) {
current1 = current1.next;
l1++;
}
while (current2 != null) {
current2 = current2.next;
l2++;
}
current1 = pHead1;
current2 = pHead2;
int sub = l1 > l2 ? l1 - l2 : l2 - l1;
// 长的先走sub步
if (l1 > l2) {
int a = 0;
while (a < sub) {
current1 = current1.next;
a++;
}
}
if (l1 < l2) {
int a = 0;
while (a < sub) {
current2 = current2.next;
a++;
}
}
// 同时遍历两个链表
while (current2 != current1) {
current2 = current2.next;
current1 = current1.next;
}
return current1;
}
2.判断一个链表是否为回文序列
判断一个链表是否为回文链表.
示例1:
输入:1=>2=>2=>1
输出:true
进阶:你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?
这里看一下比较基本的全部压栈的解法。
将链表元素全部压栈,然后一边出栈,一边重新遍历链表,一边比较,只要有一个不相等,那就不是回文链表了,代码:
public boolean isPalindrome(ListNode head) {
ListNode temp = head;
Stack<Integer> stack = new Stack<>();
// 把链表结点的值存放到栈中
while (temp != null) {
stack.push(temp.val);
temp = temp.next;
}
// 之后一边出栈,一边比较
while (head != null) {
if (head.val != stack.pop()) {
return false;
}
head = head.next;
}
return true;
}
3.合并有序链表
3.1 合并两个有序链表
将两个升序链表合并为一个新的升序链表并返回,新链表是通过拼接给定的两个链表的所有结点组成的。
本题虽不复杂,却是很多题目的基础,解决思路与数组一样,一般有两种。一种是新建一个链表,然后分别遍历两个链表,每次选最小的结点接到新链表上,最后排完。
另外一个就是将一个链表结点拆下来,逐个合并到另外一个对应位置上去。
这个过程本身就是链表插入和删除操作的扩展,难度不算大,这时候代码是否优美就比较重要了。
先看下面这种:
public static ListNode mergeTwoLists1(ListNode list1, ListNode list2) {
ListNode newHead = new ListNode(-1);
ListNode res = newHead;
while (list1 != null || list2 != null) {
// 情况1: 都不为空的情况
if (list1 != null && list2 != null) {
if (list1.val < list2.val) {
newHead.next = list1;
list1 = list1.next;
} else if (list1.val > list2.val) {
newHead.next = list2;
list2 = list2.next;
} else { // 相等的情况,分别接两个链
newHead.next = list2;
list2 = list2.next;
newHead = newHead.next;
newHead.next = list1;
list1 = list1.next;
}
newHead = newHead.next;
// 情况2: 假如还有一个链表为空
} else if (list1 != null && list2 == null) {
newHead.next = list1;
list1 = list1.next;
newHead = newHead.next;
} else if (list1 == null && list2 != null) {
newHead.next = list2;
list2 = list2.next;
newHead = newHead.next;
}
}
return res.next;
}
上面这种方式能完成基本的功能,但是所有的处理都在一个大while循环里,代码过于臃肿,我们可以将其变得苗条一些:第一个while只处理两个list都不为空的情况,之后单独写while分别处理list1或者list2不为null的情况,也就是这样:
public static ListNode mergeTwoLists2(ListNode list1, ListNode list2) {
ListNode newHead = new ListNode(-1);
ListNode res = newHead;
while (list1 != null && list2 != null) {
if (list1.val < list2.val) {
newHead.next = list1;
list1 = list1.next;
} else if (list1.val > list2.val) {
newHead.next = list2;
list2 = list2.next;
} else { // 相等的情况,分别接两个链
newHead.next = list2;
list2 = list2.next;
newHead = newHead.next;
newHead.next = list1;
list1 = list1.next;
}
newHead = newHead.next;
}
// 情况2: 假如还有一个链表为空
while (list1 != null) {
newHead.next = list1;
list1 = list1.next;
newHead = newHead.next;
}
while (list2 != null) {
newHead.next = list2;
list2 = list2.next;
newHead = newHead.next;
}
return res.next;
}
第二个优化就是后面两个小的while循环,这两个while最多只有一个会执行,而且链表只要将链表头接好,后面的自然就接上了,因此循环都不用写,也就是这样:
图解:
代码:
public static ListNode mergeTwoLists3(ListNode list1, ListNode list2) {
ListNode prehead = new ListNode(-1);
ListNode prev = prehead;
while (list1 != null && list2 != null) {
if (list1.val <= list2.val) { //① 1 1 //② 2 1 //③ 2 3 //④ 4 3 //⑤ 4 4
prev.next = list1; //① -1 1 2 4 //③ 1 2 4 //⑤ 3 4
list1 = list1.next; //① 2 4 //③ 4 //⑤ null
} else {
prev.next = list2; //② 1 1 3 4 //④ 2 3 4
list2 = list2.next; //② 3 4 //④ 4
}
prev = prev.next; //① 1 2 4 //② 1 3 4 //③ 2 4 //④ 3 4 //⑤ 4
}
// 最多只有一个还未被合并完,直接接上去就行了,这是链表合并比数组合并方便的地方
prev.next = list1 == null ? list2 : list1;
return prehead.next;
}
3.2 合并K个链表
合并K个链表,有多种方式,例如堆、归并等等。如果面试遇到,我倾向将前两个合并,之后再将后面的逐步合并进来,这样的好处是只要将两个合并的写清楚,合并K个就容易很多,现场写最稳妥:
private static ListNode mergeTwoLists(ListNode[] lists) {
ListNode res = null;
for (ListNode list : lists) {
res = mergeTwoLists(res, list);
}
return res;
}
3.3 一道很无聊的好题
给你两个链表 list1 和 list2,它们包含的元素分别为 n 个和 m 个。请你将 list1 中下标从a到b的节点删除,并将list2接在被删除节点的位置。
具体到这个题,按部就班遍历找到链表1保留部分尾结点和链表2的尾结点,将两链表连接起来即可。
public static ListNode mergeInBetween(ListNode list1, int a, int b, ListNode list2) {
ListNode pre1 = list1, post1 = list1, post2 = list2;
int i = 0, j = 0;
while (pre1 != null && post1 != null && j < b) {
if (i != a - 1) {
pre1 = pre1.next;
i++;
}
if (j != b) {
post1 = post1.next;
j++;
}
}
post1 = post1.next;
// 寻找list2的尾结点
while (post2.next != null) {
post2 = post2.next;
}
// 链1尾接链2头,链2尾接链1后半部分的头
pre1.next = list2; // 0,1,2,1000000,1000001,1000002
post2.next = post1; // post2: 1000002 post2.next: null
return list1;
}
4.双指针专题
在数组中我们介绍过双指针的思想,可以简单有效的解决很多问题,而所谓的双指针只不过是两个变量而已。在链表中同样可以使用双指针来轻松解决一部分算法问题。这类题目的整体难度不大,但是在面试中出现的频率很高,我们集中看一下。
4.1 寻找中间结点
给定一个头结点为 head 的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点。
示例1:
输入:[1,2,3,4,5]
输出:此列表中的结点 3
示例2:
输入:[1,2,3,4,5,6]
输出:此列表中的结点 4
这个经典用问题的快慢指针可以轻松搞定,用两个指针 slow 与 fast 一起遍历链表。slow 一次走一步,fast 一次走两步。那么当 fast 到达链表的末尾时,slow 必然位于中间。
代码:
public static ListNode middleNode(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next; //① 2 //② 3
fast = fast.next.next; //① 3 //② 5
}
return slow;
}
4.2 寻找倒数第K个元素
这也是经典的快慢双指针问题,先看要求。
输入一个链表,输出该链表中倒数第k个结点。本题从1开始计数,即链表的尾结点是倒数第1个结点。
示例:
给定一个链表:1=>2=>3=>4=>5,和 k = 2
返回链表:4=>5
这里也可以使用快慢双指针,我们先将fast 向后遍历到第 k+1 个节点, slow仍然指向链表的第一个节点,此时指针fast 与slow 二者之间刚好间隔 k 个节点。之后两个指针同步向后走,当 fast 走到链表的尾部空节点时,slow 指针刚好指向链表的倒数第k个节点。
这里需要特别注意的是链表的长度可能小于k,寻找k位置的时候必须判断fast是否为null,这是本题的关键问题之一。
图解:
代码:
public static ListNode getKthFromEnd(ListNode head, int k) {
ListNode fast = head;
ListNode slow = head;
while (fast != null && k > 0) {
fast = fast.next;
k--;
}
while (fast != null) {
fast = fast.next;
slow = slow.next;
}
return slow;
}
4.3 旋转链表
先看题目要求:给你一个链表的头结点 head,旋转链表,将链表每个节点向右移动 k 个位置。
示例1:
输入:head = [1,2,3,4,5], k = 2
输出:[4,5,1,2,3]
第一种是将整个链表反转变成{5,4,3,2,1},然后再将前K和N-K两个部分分别反转,也就是分别变成了{4,5}和{1,2,3},这样就轻松解决了。这个在后面学习了链表反转之后,请读者自行解决。
第二种思路就是先用双指针策略找到倒数K的位置,也就是{1,2,3}和{4,5}两个序列,之后再将两个链表拼接成{4,5,1,2,3}就行了。
代码:
public static ListNode rotateRight(ListNode head, int k) {
if (head == null || k == 0) {
return head;
}
// 这里三个变量都指向链表头结点
ListNode temp = head;
ListNode fast = head;
ListNode slow = head;
int len = 0;
// 这里head先走一遍,统计出链表的元素个数,完成之后head就变成null了
while (head != null) {
head = head.next;
len++;
}
if (k % len == 0) {
return temp;
}
// 从这里开始fast从头结点开始向后走
// 这里使用取模,是为了防止k大于len的情况
// 例如,如果len=5,那么k=2和7,效果是一样的
while ((k % len) > 0) {
k--;
fast = fast.next; // 3 4 5
}
// 快指针走了k步了,然后快慢指针一起向后执行
// 当fast到尾结点的时候,slow刚好在倒数第k个位置上
while (fast.next != null) {
fast = fast.next; // 4 5 // 5
slow = slow.next; // 2 3 4 5 // 3 4 5
}
ListNode res = slow.next; // 4 5
slow.next = null; // 3 此处导致temp变为 1 2 3
fast.next = temp; // 5 1 2 3
return res; // 4 5 1 2 3
}
5.删除链表元素专题
示例题目:
LeetCode 237:删除某个链表中给定的(非末尾)节点。传入函数的唯一参数为要被删除的节点 。
LeetCode 203:给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回新的头节点 。
LeetCode 19. 删除链表的倒数第 N 个节点。
LeetCode 1474. 删除链表 M 个节点之后的 N 个节点。
LeetCode 83 存在一个按升序排列的链表,请你删除所有重复的元素,使每个元素只出现一次。
LeetCode 82 存在一个按升序排列的链表,请你删除链表中所有存在数字重复情况的节点,只保留原始链表中没有重复出现的数字。
我们在链表基本操作部分介绍了删除的方法,至少需要考虑删除头部,删除尾部和中间位置三种情况的处理。而上面这些题目就是这个删除操作的进一步拓展。
5.1 删除特定的结点
先看一个简单的问题:给你一个链表的头结点 head 和一个整数 val,请你删除链表中所有满足 Node.val == val 的结点,并返回新的头结点。
示例1:
输入:head = [1,2,6,3,4,5,6],val = 6
输出:[1,2,3,4,5]
我们前面说过,我们删除结点cur时,必须知道其前驱pre结点和后继next结点,然后让pre.next=pre.next.next。这时候cur就脱离链表了,cur会在某个时刻被gc回收掉。
对于删除,我们注意到首元素的处理方式与后面的不一样。为此,我们可以先创建一个虚拟结点 dummyHead,使其指向 head,也就是 dummyHead.next=head,这样就不用单独处理首节点了。
完整的步骤是:
- 我们创建一个虚拟链表头dummyHead,使其next指向head。
- 开始循环链表寻找目标元素,注意这里是通过cur.next.val来判断的。
- 如果找到目标元素,就使用 cur.next = cur.next.next 来删除。
- 注意最后返回的时候要用 dummyHead.next,而不是dummyHead。
代码实现过程:
public ListNode removeElement(ListNode head, int val) {
ListNode dummyHead = new ListNode(0);
dummyHead.next = head;
ListNode cur = dummyHead;
if (cur.next != null) {
if (cur.next.val == val) {
cur.next = cur.next.next;
} else {
cur = cur.next;
}
}
return dummyHead.next;
}
5.2 删除倒数第n个结点
题目要求:给你一个链表,删除链表的倒数第n个结点,并且返回链表的头结点。
进阶:你能尝试使用一趟扫描实现吗?
示例1:
输入:head = [1,2,3,4,5],n = 2
输出:[1,2,3,5]
我们前面说过,遇到一个题目可以先在脑子里快速过一下常用的数据结构和算法思想,看看哪些看上去能解决问题。为了开拓思维,我们看看能怎么做:
第一种方法:先遍历一遍链表,找到链表总长度L,然后重新遍历,位置L-N+1的元素就是我们要删的。
第二种方法:貌似栈可以,先将元素全部压栈,然后弹出第N个的时候就是我们要的是不?OK,又搞定一种方法。
第三种方法:我们前面提到可以使用双指针来寻找倒数第K个元素,那这里同样可以用来寻找要输出的元素。
上面三种方法,第一种方法比较常规,第二种方法需要开辟一个O(n)的空间,还要考虑栈与链表的操作等,不中看也不中用。第三种方法一次遍历就行,用双指针也有逼格。接下来我们详细看一下第一和第三这两种。
方法1:计算链表长度
首先从头结点开始对链表进行一次遍历,得到链表的长度L。随后我们再从头结点开始对链表进行一次遍历,当遍历到第 L-n+1 个节点时,它就是我们需要删除的结点。
代码:
public static ListNode removeNthFromEnd1(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
int length = getLength(head);
ListNode cur = dummy;
for (int i = 1; i < length - n + 1; ++i) {
cur = cur.next;
}
cur.next = cur.next.next;
ListNode ans = dummy.next;
return ans;
}
public static int getLength(ListNode head) {
int length = 0;
while (head != null) {
++length;
head = head.next;
}
return length;
}
方法2:双指针
我们定义first和second两个指针,first先走N步,然后second再开始走,当first走到队尾的时候,second就是我们要的结点。
代码:
public static ListNode removeNthFromEnd2(ListNode head, int n) {
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode first = head;
ListNode second = dummy; // 0 1 2 3 4 5
for (int i = 0; i < n; ++i) {
first = first.next; // 3 4 5
}
while (first != null) {
first = first.next; // null
second = second.next; // 3 4 5
}
second.next = second.next.next; // 3 5
ListNode ans = dummy.next; // 1 2 3 5
return ans;
}
5.3 删除重复元素
我们继续看关于结点删除的题:
LeetCode 83 存在一个按升序排列的链表,请你删除所有重复的元素,使每个元素只出现一次。
LeetCode 82 存在一个按升序排列的链表,请你删除链表中所有存在数字重复情况的节点,只保留原始链表中没有重复出现的数字。
两个题其实是一个,区别就是一个要将出现重复的保留一个,一个是只要重复都不要了,处理起来略有差别。LeetCode 1836是在82的基础上将链表改成无序的了,难度要增加不少,感兴趣的同学请自己研究一下。
5.3.1 重复元素保留一个
LeetCode 83
题目要求:存在一个按升序排列的链表,给你这个链表的头结点 head,请你删除所有重复的元素,使每个元素只出现一次。返回同样按升序排列的结果链表。
示例1:
输入:head = [1,1,2,3,3]
输出:[1,2,3]
注意,当我们遍历到链表的最后一个结点时,cur.next为空结点,此时要加以判断。
代码:
public ListNode deleteDuplicates1(ListNode head) {
if (head == null) {
return head;
}
ListNode cur = head;
while (cur.next != null) {
if (cur.val == cur.next.val) {
cur.next = cur.next.next;
} else {
cur = cur.next;
}
}
return head;
}
5.3.2 重复元素都不要
LeetCode 82
题目要求:与题83的区别仅仅是重复的元素都不要了。
输入:head = [1,2,3,3,4,4,5]
输出:[1,2,5]
当一个都不要时,链表只要直接对cur.next以及cur.next.next两个node进行比较就行了,这里要注意两个node可能为空,稍加判断就行了。
代码:
public ListNode deleteDuplicates2(ListNode head) {
if (head == null) {
return head;
}
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode cur = dummy;
while (cur.next != null && cur.next.next != null) {
if (cur.next.val == cur.next.next.val) {
int x = cur.next.val;
while (cur.next != null && cur.next.val == x) {
cur.next = cur.next.next;
}
} else {
cur = cur.next;
}
}
return dummy.next;
}