一文弄清楚链表技巧

开发 前端
如何判断单链表存在环呢 ?我们可以借用 单链表的中点 问题的思路。快慢指针同从头节点同时向前移动,在没有环的单链表中,它们依次达到尾节点,但对于有环的链表来说,快慢指针最后会一直在环中移动,永远停不下来。

单链表的常见操作比较多,而且有些操作比较有技巧,本文就来聊聊这些不容易想到操作。

单链表倒数第 k 个节点

单链表正向第 k 个节点很容易获得,直接一个 for 循环遍历一遍链表就能得到,但是如果是逆向第 k 个节点,也就是倒数第 k 个节点呢 ?

你也许很快就想到了,逆向第 k 个节点相当于正向第 n - k 个节点, 这里的 n 是链表长度,对于单链表来说,需要遍历一遍链表才能计算出 n 的值,然后再次遍历 n - k 个节点, 才最终获得链表倒数第 k 个节点。

上面整个过程总共遍历了 n + n - k 个节点,能否只遍历 n 个节点就能得到倒数第 n 个节点呢 ?

答案是肯定的,这种解法称作 双指针法,它比较巧妙,如果以前没接触过过的话,不容易想到,下面就来详细介绍它。

假如 k = 2, 现在让指针 p1 指向 head 节点,然后移动 k 步,结果如下图:

此时,如果 p1 再移动 n - k 步就到达链表结尾的 null 节点了。

再用一个指针 p2 指向 head 节点,指针 p1 和 p2 的指向如下图所示:

最后,指针 p1 和 p2 同时向前移动,直到 p1 到达链表结尾的 null 节点,此时两个指针的指向如下图所示:

当 p1 到达 null 节点时,p2 走了 n - k 步,也就是倒数第 k 个节点。

这样,利用 p1 和 p2 两个指针,只需要遍历一遍链表就能得到倒数第 k 个节点,也即 p2 指向的节点。

在整个过程中,使用了两个指针,所以这个技巧称作 双指针法,很多链表相关的算法题都可以用这个技巧解决,理解了这个套路,以后再遇到类似的问题就可以手到擒来了。

好了,流程讲完了,下面给出完整代码供大家参考:

// 单链表节点结构
struct ListNode
{
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *pnext) : val(x), next(pnext) {}
};
// 返回单链表倒数第 k 个节点
ListNode *findFromEnd(ListNode *head, int k)
{
if(nullptr == head || k <= 0) return nullptr;
ListNode *p1 = head;
// p1 指针先移动 k 步
int step = 0;
while (step < k && nullptr != p1)
{
p1 = p1->next;
++step;
}
//p1 移动的步数小于 k, 表示链表长度小于 k
//因此链表不存在倒数第 k 个节点
if (step < k) return nullptr;
//p1 和 p2 同时移动,直到 p1 到达尾节点的下一节点
ListNode *p2 = head;
while (nullptr != p1)
{
p2 = p2->next;
p1 = p1->next;
}
return p2;
}

单链表中点

想得到单链表的中点,首先想到的是先得到链表的长度 n,然后从链头开始往前走,每走一步,计数就加一,直到计数达到链表长度的一半儿 n / 2,此时所在的节点即为链表的中点了。

但是这个方法需要先遍历整个链表,然后再从链表头遍历到链表的中间节点,整个过程共遍历了 n + n / 2 个节点。

这里介绍一种方法,只需要遍历 n 个节点就可以得到链表的中间节点。

我们让指针 p1 和 p2 都指向 head 节点,两个指针同时向前移动,p1 每次向前走两步,p2 每次向前走一步,这样,当 p1达到链表尾节点时,p2 刚好到达链表的中间节点。

在这个过程中,p1 指针每次走两步,称为快指针,p2 指针每次走一步,称为慢指针,所以,这个小技巧叫做 快慢指针。

根据上面的思路,我们就可以写出算法的实现代码了。

// 单链表节点结构
struct ListNode
{
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *pnext) : val(x), next(pnext) {}
};
// 返回单链表中间节点
ListNode * middleNode(ListNode *head)
{
if(nullptr == head) return nullptr;
//快指针和慢指针初始都指向head
ListNode *pslow = head;
ListNode *pfast = head;
while (nullptr != pfast && nullptr != pfast->next)
{
//每次快指针走两步,慢指针走一步
//当快指针的下一个节点为 null时
//表示快指针达到了末尾节点,此时退出循环
pfast = pfast->next->next;
pslow = pslow->next;
}
return pslow;
}

需要说明一点,如果单链表长度为偶数,也就是中间节点有两个,上面的解法返回的是靠后一个节点。

例如: 单链表 1 -> 2 -> 3 -> 4 -> 5 -> 6,长度为 6,它的中间节点是 3 和 4,那么,用上面的解法得到的中间节点是 4 而不是 3。

链表是否包含环

如下图所示,图中是一个长度为5的链表,最后一个节点指向了第三个节点,构成了一个环。

给你一个单链表的头节点,判断该链表是否含有环。

如何判断单链表存在环呢 ?我们可以借用 单链表的中点 问题的思路。

快慢指针同从头节点同时向前移动,在没有环的单链表中,它们依次达到尾节点,但对于有环的链表来说,快慢指针最后会一直在环中移动,永远停不下来。

由于两个指针一块一慢,并且快指针每次比慢指针多走一步,因此,快指针总有一天会追上慢指针,此时它俩就指向同一个节点,明白了这一点,就有办法判断单链是否存在表环了,具体做法如下:

初始时,快慢指针都指向头节点,它们同时向前移动,快指针每次走两步,慢指针每次走一步,当快指针和慢指针相等时,说明链表有环,如果快指针的下一个节点是 null 节点,表示链表没有环。

根据上面的思路,就可以写出实现代码了。

//返回单链表是否存在环
bool hasCycle(ListNode *head)
{
if(nullptr == head) return false;
//快指针和慢指针初始都指向head
ListNode *pslow = head;
ListNode *pfast = head;
while (nullptr != pfast && nullptr != pfast->next)
{
//快指针每次走两步,慢指针每次走一步
//当快指针的下一个节点为 null时
//表示快指针达到了末尾节点
pfast = pfast->next->next;
pslow = pslow->next;
if (pfast == pslow)
{
return true;
}
}
return false;
}

链表环的起始节点

我们再深入下,既然解决了链表是否有环的问题,那能不能知道环的起始节点呢 ?

比如: 对于下图中的链表来说,环的起始节点是 3。

解决的方法依然是使用快慢指针,快指针每次走两步,慢指针每次走一步。

下图是一个简易的环形链表图,其中 A 为链表头节点,B 为环的起始节点,C为 快指针和慢指针在环中相遇的节点(至于快指针和慢指针为什么一定会在环中相遇,在上一小节有解释,这里不在赘述了)。

A 到 B 的距离是 S1。

B 到 C 的距离是 S2。

C 到 B 的距离是 S3。

假设,当快指针和慢指针在 C点相遇时,快指针已经绕着环走了 n 圈,则相遇时各自走过的路程如下:

快指针: S1 + n * ( S2 + S3 ) + S2。

慢指针:S1 + S2。

由于快指针走过的路程是慢指针的 2 倍,则有:

S1 + n * ( S2 + S3 ) = 2 * (S1 + S2)

简化下上面的等式可以得到:

S1 = (n - 1) * (S2 + S3) + S3

S2 + S3 刚好是环的长度,所以,上面的结果可以理解为,从 A 到 B 的距离,等于从相遇点 C 出发,绕着环走 n - 1圈 ( 此时会回到 C 点 ) ,然后再走 S3 就到达了 B 点,也即环的起始点。

因此,当快慢指针相遇后,再额外使用一个指针指向链表头节点,然后这个额外指针和慢指针同时移动,它们最终一定会在环的起始节点相遇。

搞明白了上面的推理过程之后,实现代码直接在 链表是否存在环 那一小节的基础上稍作修改即可,具体的代码如下:

//返回单链表环的起始节点
ListNode *entrynodeInLoop(ListNode *head)
{
if (nullptr == head || nullptr == head->next)
return nullptr;
ListNode *pslow = head; //慢指针
ListNode *pfast = head; //快指针
ListNode *ppoint = nullptr;//指向快慢指针相遇时的节点
while (nullptr != pfast && nullptr != pfast->next)
{
pslow = pslow->next;
pfast = pfast->next->next;
if (pslow == pfast)
{
ppoint = pslow;
break;
}
}
//相遇节点为null,表示没有环
if (nullptr == ppoint) return nullptr;
pslow = head; //慢指针指向头节点
while (pslow != ppoint)
{
pslow = pslow->next;
ppoint = ppoint->next;
}
return ppoint;
}

其实,链表是否有环 以及 链表环的起始节点 还有一种比较直观的求解方法。

额外增加一个记录节点指针的集合,然后从头节点开始往前遍历,每经过一个节点,查询集合中是否已存在该节点,如果不存在,节点指针加入到集合中,如果存在,则表示该节点就是环的入口节点。

这种方法比较好理解,但是它的空间复杂度是 O(N), 时间复杂度跟 快慢指针 相同,这里也给出实现代码供参考。

//返回链表环的起始节点
ListNode *entrynodeInLoop(ListNode *head)
{
if(nullptr == head) return nullptr;
//链表节点的集合
unordered_set<ListNode*> setnode;
ListNode *p = head;
while (nullptr != p)
{
auto iter = setnode.find(p);
if( iter != setnode.end())
{
//集合中找到了相同的节点
//此节点即为环的入口
return p;
}
//节点不在集合中,则加入集合
setnode.insert(p);
//移动到下一个节点
p = p->next;
}
//不存在环,返回 nullptr
return nullptr;
}

两个链表是否相交

给你两个链表的头节点,如果这两个链表相交,则返回相交的节点,如果没相交,返回 null。

比如下图中,两个链表相交的节点是 3。

初看这个问题,最容易想到的方法是分别遍历两个链表,用一个集合记录遍历过程中的节点,如果存在相交的节点,肯定会多次访问该节点,当第二次访问该节点的时候,集合中已经存在该节点了,如果没有相交,则集合中不会出现两个相同的节点。

但利用集合临时存储遍历过的节点的方法,空间复杂度是 O(N),如何在 O(1) 的空间复杂度内解决呢?

解决方法是利用两个指针p1 和 p2,初始时 p1 指向第一个链表的头节点,p2指向第二个链表的头节点,然后同时开始遍历链表,p1 遍历完链表之后,指向第二个链表的头节点,p2 遍历完链表之后,指向第一个链表的头节点。

实际上相当于两个链表连在一起了,具体看下图:

图中绿色的节点是当前链表的节点,蓝色的节点是对方链表的节点,红色的是遍历过程中相遇的节点,也即两个链表相交的节点,当两个链表没有相交时,最后p1 和 p2 都指向了空节点,这也是我们想要的结果,大家可以自己模拟下这个过程。

因此,此题的关键是找到一种办法,使得 p1 和 p2 能同时到达两个链表相交的节点。

根据上述的方法,可以写出实现代码 :

// 单链表节点结构
struct ListNode
{
int val;
ListNode *next;
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *pnext) : val(x), next(pnext) {}
};
//返回两个链表相交的节点
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB)
{
if (nullptr == headA || nullptr == headB)
return nullptr;
ListNode *pa = headA;
ListNode *pb = headB;
while (pa != pb)
{
pa = (nullptr != pa) ? pa->next : headB;
pb = (nullptr != pb) ? pb->next : headA;
}
return pa;
}

小结

本文讲解了链表相关的一系列操作,有些方法比较有技巧性,一时不太容易想到,需要大家多去体会和练习,假以时日,我相信大家一定能掌握并熟练使用这些套路的

责任编辑:武晓燕 来源: Linux开发那些事儿
相关推荐

2022-03-11 07:59:09

容器代码元素

2012-05-28 10:06:05

项目开发项目管理开发

2017-03-31 15:30:09

2022-05-30 08:05:11

架构

2023-02-26 21:33:49

混合云架构模式

2024-01-12 08:26:16

Linux磁盘文件系统

2023-01-09 08:38:22

大数据架构师YARN

2020-02-18 16:48:48

大脑CPU包装

2021-03-11 15:49:44

人工智能深度学习

2017-10-28 23:00:52

多云混合云云计算

2021-05-09 22:26:36

Python函数变量

2021-03-19 14:12:24

2018-10-25 09:26:07

VLANVXLAN网络

2017-09-26 10:36:52

云端部署内部

2021-10-29 11:30:31

补码二进制反码

2020-08-03 08:01:50

爬虫技巧

2020-11-17 08:32:22

存储器链接

2020-07-01 08:07:33

Redis

2018-05-21 07:08:18

行为驱动开发BDD编码

2022-06-15 08:18:30

自动化测试数据驱动
点赞
收藏

51CTO技术栈公众号