【图解算法】链表(下)
笔试时,链表的题能过尽快过,不考虑空间复杂度;面试时,则尽量考虑如何将空间复杂度降到
O(1)
。
问题描述
- 将单向链表按某值划分成左边小、中间相等、右边大的形式。
- 复制含有随机指针节点的链表。
- 两个单链表相交的系列问题。
##将单向链表按某值划分成左边小、中间相等、右边大的形式
这道题实际上就是荷兰国旗问题
的单链表版本,所谓荷兰国旗问题:(leetcode中的颜色分类
问题)
给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。
此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。
实际上,这里是0、1、2或者是随便给一些数字+一个阈值K,小于K的放左边,大于K的放右边,二者是一样的。
注意:
不能使用代码库中的排序函数来解决这道题。且空间复杂度为O(1)
。
示例:
输入: [2,0,2,1,1,0]
输出: [0,0,1,1,2,2]
这道题如果知道怎么做,那么一个直观的做法就是先将链表存储在一个节点数组中,然后使用数组版本的荷兰国旗解法解决,然后再重新连接成链表,但是这样需要一个O(N)
的空间复杂度,推荐在笔试过程中使用该方法快速过掉这道题;在面试中,我们则应该尽量优化,体现我们的思考深度。一个是使用O(1)
的空间复杂度,另一个则是保持链表原来的相对顺序。
荷兰国旗问题解法
这里我们先来说下荷兰国旗怎么解,首先要注意,我们使用的是数组;我们只需三个变量来记录数组中的位置,left
、right
记录分割的位置(整个数组需要划分为三个部分),使用index
记录扫描到的数组元素位置。
从上图中可以看到,left = -1
、index = 0
、right = arr.length
。
接下来我们将对index
向右进行遍历,每次遍历需要注意一些条件,如果当前的值arr[index]
小于K
,说明这个值应该放左边区域,如果arr[index]
大于K
,说明这个值应该放右边,我们来看下代码就很容易明白:
辅助函数:交换数组中的值
void swap(int *arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
排序代码:
void colorSort(int *arr, int len, int K) { // 荷兰国旗问题排序
int left = -1;
int right = len;
int index = 0;
while (index < right) {
if (arr[index] < K) { // 当前值小于阈值 K
swap(arr, ++left, index++);
} else if (arr[index] > K) { // 当前值大于阈值 K
swap(arr, --right, index);
} else { // 当前值等于阈值 K
index++;
}
}
}
理解了荷兰国旗问题,再来理解我们的单链表版本就好理解很多了,事实上,除了使用额外的数组空间外,我们可以使用几个变量来解决这个问题。
单链表版本的荷兰国旗问题
我们可以考虑使用三对Head
和End
来记录三个区域的链表的头和尾,如下图所示:
如上图所示,我们利用上述的六个变量,将一个单链表分成三个链表,最后将它们串起来:
好,我们知道最终结果长啥样后,接下来就是考虑如何得到这样的结果,实际上代码思路很简单,首先遍历一次链表,用三个head
指针分别标记第一个小于K、第一个等于K、和第一个大于K的数的指针,当然,我们要处理边界问题,比如这里面没有等于K的,那么中间的链表就是空的,连接的时候就要十分注意。
代码:
ListNode *colorSort(ListNode *head, int K) {
ListNode *leftHead = nullptr, *leftEnd = nullptr;
ListNode *midHead = nullptr, *midEnd = nullptr;
ListNode *rightHead = nullptr, *rightEnd = nullptr;
ListNode *next = nullptr;
while (head != nullptr) { // 将一个链表拆分为三个链表
next = head->next; // 保存下一个节点
head->next = nullptr;
if (head->val < K) {
if (leftHead == nullptr) {
leftHead = head;
leftEnd = head;
} else {
leftEnd->next = head;
leftEnd = leftEnd->next;
}
} else if (head->val > K) {
if (rightHead == nullptr) {
rightHead = head;
rightEnd = head;
} else {
rightEnd->next = head;
rightEnd = rightEnd->next;
}
} else {
if (midHead == nullptr) {
midHead = head;
midEnd = head;
} else {
midEnd->next = head;
midEnd = midEnd->next;
}
}
head = next;
}
// 连接左链表和中间的链表
if (leftEnd != nullptr) {
leftEnd->next = midHead;
// 如果中间链表为空,midEnd 应指向 leftEnd
midEnd = (midEnd == NULL) ? leftEnd : midEnd;
}
// 连接中间链表和右链表
if (midEnd != nullptr) {
midEnd->next = rightHead;
}
// 返回三个链表串起来后的头节点
return (leftHead != NULL) ? leftHead : ((midHead != NULL) ? midHead : rightHead);
}
可以看到,虽然思路简单,但是整个代码细节非常多,面试过程中一不小心就会出错,大家写代码前一定要捋清后再写。
复制含有随机指针节点的链表
【题目】 一种特殊的链表节点类描述如下:
struct ListNode {
int val;
ListNode *next; // 和普通的单链表一样
ListNode *rand; // 随机指向某个节点
ListNode(int x) : val(x), next(nullptr), rand(nullptr) {}
};
rand
指针是这种链表中新增的指针,这个指针可能指向链表中的任意一个节点,也可能指向NULL
。 给定一个由含有随机指针节点的链表的头节点head,请实现一个函数完成这个链表中所有结构的复制,并返回复制的新链表的头节点。
进阶:不使用额外的数据结构,只用有限几个变量,且在时间复杂度为 O(N)
内完成原问题要实现的函数。
【题意解析】
我们首先画个图来理解一下这种含有随机指针节点的链表:
如上图所示,黄线即为rand
指针的指向,可以看到指向是完全随机没有规律的。
【普通解法】
接下来我们说说简单的解法,以节点1
为例子,我们知道它的rand
指针指向节点2
,但是拷贝后的新节点1
如何找到新节点2
呢?这里我们就会想到使用哈希表,我们可以将 <节点
,新节点
> 的映射结构存入哈希表中。
我们首先遍历一遍链表,拷贝一份只复制了next
指针的链表,同时建立哈希表;再对这个新链表进行一次遍历,每遍历一个节点,将这个节点在哈希表中对应的节点取出,用rand
指针去连接即可。
我们先来看看单纯的拷贝单链表:
ListNode *copyByHash(ListNode *head) {
ListNode *newHead = new ListNode(head->val);
ListNode *q = newHead;
ListNode *p = head;
while (p->next != nullptr) { // 拷贝单链表
p = p->next;
q->next = new ListNode(p->val);
q = q->next;
}
return newHead;
}
接下来我们加入哈希表部分:
ListNode *copy(ListNode *head) {
if (head == nullptr) { // 空指针判断
return nullptr;
}
ListNode *newHead = new ListNode(head->val);
ListNode *q = newHead;
ListNode *p = head;
map<ListNode *, ListNode *> nodeMap;
nodeMap[head] = newHead;
while (p->next != nullptr) { // 拷贝单链表
p = p->next;
q->next = new ListNode(p->val);
q = q->next;
nodeMap[p] = q; // 第一次遍历的过程中即可同时进行哈希映射
}
p = head;
q = newHead;
while (q != nullptr) { // 拷贝 rand 指针
q->rand = p->rand == nullptr ? nullptr : nodeMap[p->rand];
q = q->next;
p = p->next;
}
return newHead;
}
使用哈希表的方法固然简单,但需要
O(N)
的空间复杂度,有没有O(1)
的解决办法呢?继续看我们的优化方法。
【空间优化】
实际上,我们的解题关键就是节点
和新节点
之间的联系,假如我们可以使用某种方法将它们联系起来,那么也就不需要哈希表了。这种办法就是将新节点
连接在原节点
后面,这样通过节点->next
就可以获取到新节点
了。如下图所示:
我们首先通过一次遍历达成上述效果:
蓝色箭头处为cur->next
的指向变更
将图结合代码看更加直观:
ListNode *cur = head;
ListNode *next = nullptr;
while (cur != nullptr) { // 拷贝单链表
next = cur->next;
cur->next = new ListNode(cur->val);
cur->next->next = next;
cur = next;
}
接下来,在拷贝rand
指针的时候,我们只需要拷贝节点->rand->next
即可:
cur = head;
ListNode *curCopy = nullptr;
while (cur != nullptr) { // 拷贝 rand 指针
next = cur->next->next;
curCopy = cur->next;
curCopy->rand = cur->rand == nullptr ? nullptr : cur->rand->next;
cur = next;
}
最终还需要对它们进行拆分:
完整代码:
ListNode *copy(ListNode *head) {
if (head == nullptr) { // 边界判断
return nullptr;
}
ListNode *cur = head;
ListNode *next = nullptr;
while (cur != nullptr) { // 拷贝单链表
next = cur->next;
cur->next = new ListNode(cur->val);
cur->next->next = next;
cur = next;
}
cur = head;
ListNode *curCopy = nullptr;
while (cur != nullptr) { // 拷贝 rand 指针
next = cur->next->next;
curCopy = cur->next;
curCopy->rand = cur->rand == nullptr ? nullptr : cur->rand->next;
cur = next;
}
ListNode* res = head->next;
cur = head;
while (cur != nullptr) { // 拆分并还原
next = cur->next->next;
curCopy = cur->next;
cur->next = next;
curCopy->next = next == nullptr ? nullptr : next;
cur = next;
}
return res;
}
两个单链表相交的系列问题
【题目】 在本题中,单链表可能有环,也可能无环。给定两个单链表的头节点 head1
和head2
,这两个链表可能相交,也可能不相交。请实现一个函数, 如果两个链表相交,请返回相交的第一个节点;如果不相交,返回null
即可。
【 要求】如果链表1 的长度为N
,链表2的长度为M
,时间复杂度请达到 O(N+M)
,额外空间复杂度请达到O(1)
。
【题意解析】
注意了,本题实际上将链表相交的常见问题都糅合在一起了,是有一定难度的,我们需要先将问题切分开来:
-
如何判断单链表有环无环?
我们写一个函数来实现返回一个链表的环判定,有环返回入环的第一个点,无环则返回
null
; -
如何判断单链表相交与否?
这个需要分开讨论,需要意识到,一个链表有环 ,另一个链表无环,是不可能相交的。
- 两个链表都无环
- 两个链表都有环
对于有环相交2
,我们在返回环的起点的时候,从两个起点中任意返回一个即可。
判断单链表有环无环?
这里也是用到我们前面讲过的快慢指针,如果快慢指针有机会相等,那说明这个链表有环,两个指针在环里面兜圈,否则,任何一个指针到达了NULL
,说明这个链表无环。这里理解了后,我们再考虑如何返回入环的第一个节点。
如下方的代码:
- 不要忘了处理边界,要形成环至少有三个节点;
- 第一次循环中,如果快指针遇到了
null
,说明链表无环,直接返回null
即可;如果快指针和慢指针相遇,即可确定链表有环,接下来就是确定环的入口(或起点); - 在第二次循环中,我们先将快指针重新指向头节点,然后从一次走两步变为一次走一步,当快慢指针再次相遇时,相遇的节点必然是环的入口节点。
关于第三点,我们如何证明呢?
如下图所示,整个链表的长度为L
,其中环部分的长度为R
,非环的部分长度为x
,当快慢指针相遇时,相遇点到入环节点的距离为y
,假设相遇时慢指针走的距离为s
,所以s = x + y
;同时,对于快指针来说,快指针的速度是慢指针的2
倍,我们假设了慢指针走了s
,那么快指针就走了2s
,且快指针走的距离等价于s
加上比慢指针在环中多走了n
圈,所以2*s = s + n*R
,化简得到s = n * R
;另外,整个链表的长度等价于非环部分的长度x
加上环的长度R
。
我们根据上述得到的三条式子进行代换,可以得到下图中x = (n-1)*R + L-x-y
,这条式子表明,我们将快指针重新放到起点,慢指针则不变,快指针从非环部分移动的距离x
等价于慢指针从第一次相遇点再次到达入环点的距离(n-1)*R + L-x-y
。其中的(n-1)*R
表明在这个过程中,慢指针可能会在环中转n-1
圈,但不影响快慢指针同时到达入口点;
ListNode *getCircleEntry(ListNode *head) {
// 处理边界,至少要有三个点才能有环
if (head == nullptr || head->next == nullptr || head->next->next == nullptr) {
return nullptr;
}
ListNode *slow = head->next;
ListNode *fast = head->next->next;
while (slow != fast) {
if (fast == nullptr || fast->next == nullptr) { // 遇到 null,说明无环
return nullptr;
}
fast = fast->next->next;
slow = slow->next;
}
fast = head; // 快指针回到头节点,慢指针不变
while (slow != fast) {
slow = slow->next;
fast = fast->next; // 快指针每次只走一步
}
return fast;
}
如何判断单链表相交与否?
我们根据前面判断有无环的函数,可以将单链表相交问题分为两个部分进行讨论:
ListNode *getIntersectNode(ListNode *head1, ListNode *head2) {
ListNode *p1 = getCircleEntry(head1);
ListNode *p2 = getCircleEntry(head2);
if (p1 == nullptr && p2 == nullptr) {
// 两个无环单链表相交问题
} else if (p1 != nullptr && p2 != nullptr) {
// 两个有环单链表相交问题
}
}
接下来我们看如何解决无环单链表的相交问题:
- 先各自遍历两个链表,记录长度,同时判断最后一个节点是否相等,若不等,则一定不相交,返回
null
,否则,二者相交,到下一步; - 将链表1的长度记录为
len1
,将链表2的长度记录为len2
,如果len1
大于len2
,那么链表1先移动len1-len2
步,然后两个指针一起走,判断两个指针是否相等,相等返回当前节点即可。 - 在第二点的基础上,我们可以做一个小优化,使用一个变量
count
来记录二者的长度差值,对链表1,每次自增,对链表2,每次自减,即可根据最终count
的正负来判断链表1、2何者更长。
ListNode *getNoLoopNode(ListNode *head1, ListNode *head2) {
ListNode *p = head1;
ListNode *q = head2;
int count = 0;
while (p->next != nullptr) { // 计算链表1长度
count++;
p = p->next;
}
while (q->next != nullptr) { // 计算链表2长度
count--;
q = q->next;
}
if (p != q) {
return nullptr;
}
p = count > 0 ? head1 : head2; // 将较长的链表赋给 p
q = count > 0 ? head2 : head1; // 将较短的链表赋给 q
for (int i = 0; i < abs(count); ++i) { // 因为 p 较长,所以移动 p
p = p->next;
}
while (p != q) { // 直到找到第一个 p 和 q 相等的节点
p = p->next;
q = q->next;
}
return p;
}
接下来,我们看如何解决有环链表的相交问题:
我们前面已经得到了获取一个有环链表的入环节点的函数:getCircleEntry(ListNode *head)
,因此我们可以获取两个链表的入环节点loop1
和loop2
;
如果loop1 == loop2
,说明是第一种有环相交:
我们只需要把前面的判断无环链表的相交节点的函数的边界从null
改为loop1
/loop2
即可:
if (loop1 == loop2) {
ListNode *p = head1;
ListNode *q = head2;
int count = 0;
while (p->next != loop1) { // 计算链表1长度
count++;
p = p->next;
}
while (q->next != loop2) { // 计算链表2长度
count--;
q = q->next;
}
p = count > 0 ? head1 : head2; // 将较长的链表赋给 p
q = count > 0 ? head2 : head1; // 将较短的链表赋给 q
for (int i = 0; i < abs(count); ++i) { // 因为 p 较长,所以移动 p
p = p->next;
}
while (p != q) { // 直到找到第一个 p 和 q 相等的节点
p = p->next;
q = q->next;
}
return p;
}
如果loop1 != loop2
,那么两个有环链表有可能不相交,也可能如第二种有环相交情况所示:
解决办法:
我们让链表1从loop1
的位置开始行动,如果在再次回到loop1
的过程中没有遇到loop2
,说明两个链表不相交,直接返回null
,否则,返回loop1
或者loop2
都可以。
else {
ListNode *p = loop1->next;
while (p != loop1) {
if (p == loop2) {
return loop2;
}
p = p->next;
}
return nullptr;
}
整合之后:
ListNode *getLoopNode(ListNode *head1, ListNode *head2) {
ListNode *loop1 = getCircleEntry(head1);
ListNode *loop2 = getCircleEntry(head2);
if (loop1 == loop2) {
ListNode *p = head1;
ListNode *q = head2;
int count = 0;
while (p->next != loop1) { // 计算链表1长度
count++;
p = p->next;
}
while (q->next != loop2) { // 计算链表2长度
count--;
q = q->next;
}
p = count > 0 ? head1 : head2; // 将较长的链表赋给 p
q = count > 0 ? head2 : head1; // 将较短的链表赋给 q
for (int i = 0; i < abs(count); ++i) { // 因为 p 较长,所以移动 p
p = p->next;
}
while (p != q) { // 直到找到第一个 p 和 q 相等的节点
p = p->next;
q = q->next;
}
return p;
} else {
ListNode *p = loop1->next;
while (p != loop1) {
if (p == loop2) {
return loop2;
}
p = p->next;
}
return nullptr;
}
}
主函数:
ListNode *getIntersectNode(ListNode *head1, ListNode *head2) {
ListNode *p1 = getCircleEntry(head1);
ListNode *p2 = getCircleEntry(head2);
if (p1 == nullptr && p2 == nullptr) { // 两个无环单链表相交问题
return getNoLoopNode(head1, head2);
} else if (p1 != nullptr && p2 != nullptr) { // 两个有环链表相交问题
return getLoopNode(head1, head2);
}
}
【优化】
我们前面求两个无环单链表的相交节点时,先各遍历一遍得到长度,再遍历第二次去获取位置,实际上我们可以使用更加简洁的写法:
ListNode *getNoLoopNode2(ListNode *head1, ListNode *head2) {
if (head1 == nullptr || head2 == nullptr)
return nullptr;
ListNode *p = head1;
ListNode *q = head2;
while (p != q) {
if (p == nullptr)
p = head2;
else
p = p->next;
if (q == nullptr)
q = head1;
else
q = q->next;
}
return p;
}
原理如下图所示:
p
点从带圆圈的橙线(上面那条)出发,到达NULL
之后跳转到head2
继续,而q
从带圆圈的蓝线(下面那条)出发,达到NULL
之后跳转到head1
继续,二者会在相交处相遇,此时p == q
,跳出循环,返回p
,即当前的相交节点。
这种方法在两个链表长度相同时,只需遍历相交前的部分即可。
在loop1 == loop2
中整合该方法也非常容易,跟之前的方法一样,将对NULL
的判断改为对loop1
/loop2
的判断即可。