写在前面
写这篇文章的起因是暑假闲来无事想刷一下算法题,看着牛客面试必刷TOP101系列觉得不错就开刷。写着写着就觉得题目都挺眼熟,而且题目的难度设置也会循序渐进。而我自己之前也会整理写笔记,但内容很多都没什么自己的想法也不够系统,不如就借此写个系列文章,既加深了自己的理解,万一还能对其他学算法的朋友起到帮助岂不美哉。
注意:
- 题目很多思路题解参考了对应牛客力扣链接内的官方题解或者其他优秀小伙伴的题解,但不是照抄照搬,都是基于自己的理解用更通俗的话重新说明
- 在这里非常感谢labuladong老师!labuladong老师算是我的算法入门启蒙老师,以下很多题目都在他的链表系列文章中有讲解,第一次接触这些题目也是通过labuladong老师的文章。如果有看过labuladong老师文章的朋友不用担心我是不是照抄照搬,因为我比较笨,虽然老师已经讲得很清楚了,但我总会在一两个我自认的难点卡住,然后借助这次机会细致表述。虽然解题思路一样,但我尽自己所能根据自己的理解重新复述,一些没有提及的点也会掰开来说明。 如果文章有哪里讲解不当的地方欢迎各位指出!
- 长文预警 本文适合有一定算法基础的小伙伴(知道链表的基础结构和操作),如果不会的话在阅读本文时会有些困难。
如果文章有哪里说明不当的地方欢迎各位指出!
基础
以下为刷题常见的链表结构,经常刷算法的朋友应该也很熟悉,这里就简单提下。
单链表结构
java
public class ListNode {
public T val; //数据域
public ListNode next; //下一结点
public ListNode() {}
public ListNode(T val) {this.val = val;}
public ListNode(T val, ListNode next) {this.val = val; this.next = next;}
}
双链表结构
java
public class ListNode {
public T val; //数据域
public ListNode pre; //头指针
public ListNode next; //尾指针
public ListNode() {}
public ListNode(T val) {this.val = val;}
public ListNode(T val, Node pre, Node next) {
this.val = val;
this.pre = pre;
this.next = next;
}
}
题目类型
以下题目囊括了牛客面试必刷TOP101的链表部分,并在此基础上进行分类归纳并扩充了一些相关题型。
反转链表
反转链表顾名思义就是把一条链表的元素进行顺序的颠倒,例如将A->B->C
变为C->B->A
。换我初学时第一反应:欸这不是简简单单吗,只要找到最后一个结点然后返回,途中不断获取当前结点元素作为新链表的下一节点元素,但理想很丰满,现实很骨感,操作起来细节炸裂,一不小心就空指针异常。除此之外细想一下这样操作的时间复杂度和空间复杂度也很炸裂。
所以面对这类题目,我们通常采用迭代和递归的方法来解决。以下题目大多数用递归的方法解决,为后面解决更复杂难懂的递归题目做铺垫。
- 反转整个链表
牛客:反转链表_牛客题霸_牛客网 (nowcoder.com)
力扣:206. 反转链表 - 力扣(LeetCode)
首先让我们看看题目描述
-
单看图形,其实我们只要反转每个结点间的箭头,最后返回原链表的末结点就能得到题目所求. 那我们先解决第一个问题,如何反转箭头?
假设我们当前指向了结点1,为了翻转箭头,我们要让结点2指向1,然后将1指向2的箭头给取消掉
翻译成代码如下:
javahead.next.next = head; // 使子节点的下一个节点指向父节点,达到翻转效果 head.next = null; // 断开父节点与子节点原先的关系,即顺序指向
-
现在我们知道怎么反转箭头了,那就来解决第二个问题,如何返回原链表的末结点?
在这里我们就要利用递归的思想,递归本质上来说就是将一个大问题不断拆分为小问题,通过不断解决小问题从而解决大问题。可以想象成一个俄罗斯套娃,我们不断获得一个更小的套娃直至不能拆分(递归终止条件),然后开始解决这个小套娃的问题再一步步返回,最后解决最大套娃的问题。
如以下代码所示,我们通过不断调用reverse函数达到不断深入链表直至达到末结点,就是将长链表多个箭头变为两个结点一个箭头的问题(拆分问题),然后由后往前逐个翻转箭头(解决问题),最后就完成反转链表的操作。
javapublic ListNode reverseList(ListNode head) { returnreverse(head); } ListNode reverse(ListNode head){ // 当前结点 if(head==null||head.next==null){ return head; } //从尾到头处理数据 ListNode last=reverse(head.next); /*在这里翻转箭头*/ return last; //反转处理好的部分返回 }
-
第一次看递归代码可能会有个疑惑
if(head==null||head.next==null)
这个if语句是干什么的呢?这个就是递归代码的终止条件。在俄罗斯套娃中,你看到套娃里面空了你就知道它是最后一个套娃可以结束拆分操作,但机器没那么聪明,这个if语句就是告诉机器到哪个位置就套娃完毕了,可以开始解决问题操作。看回代码,因为调用reverse函数中参数有head和head.next,这两者都可能为空,即说明上一操作的结点已经为末节点,可以开始反转链表操作。
-
重新看回代码,可能有部分人会比较纳闷,这个last到底是什么啊?
通过前面的问题,可以比较简单知道第一次last值就是我们所需的末结点,而后续代码没有对last进行更新操作,所以最后last依旧是我们所要求的末结点。
解决了反转箭头+返回末结点两个问题,我们就能正确书写本题的代码了,如下所示
javapublic ListNode reverseList(ListNode head) { returnreverse(head); } ListNode reverse(ListNode head){ // 当前结点 if(head==null||head.next==null){ return head; } //从尾到头处理数据 ListNode last=reverse(head.next); //子节点地址: //子节点地址:head.next head.next.next=head; //使子节点的下一个节点指向父节点,达到翻转效果 head.next=null; //断开父节点与子节点原先的关系,即顺序指向 return last; //反转处理好的部分返回 }
- 反转链表前N个结点
让我们循序渐进一下,现在思考一下如何反转前N个结点?
对比反转整个链表,两道题的区别就是终止条件变化了,由原先的链表末结点变为题目给定的结点。所以现在的整体思路 是:找到需要开始反转的节点位置,将问题转化为反转全链表
进一步分析,看图可以直观感受到需要解决两个问题,如何找到目标结点并返回?如何将原链表的头结点指向开始反转结点的下一结点?
-
我们先来解决第一个问题,如何找到目标结点并返回?
回想一下"反转整个链表",我们是怎么找到链表末节点呢?我们是通过递归的方式层层深入,直到if(head==null||head.next==null)
这个终止条件来判断当前结点或者上一结点是否为末节点,如果是则返回,而这个返回结点赋值给last并一直用于后续小问题的解决。
同理!我们也用递归的方式层层深入,只要改变下终止条件,让程序判断何时到达目标结点。我们只要稍微思考一下就能发现进入递归的层数=题目所给的结点位置
但看文字可能抽象了点,让我们看看这部分代码,我们每次进入一层递归就n-1,直到n==1
说明我们已经指向了目标结点可以返回了。java// 反转以 head 为起点的 n 个节点,返回新的头结点 ListNode reverseN(ListNode head, int n) { // 终止条件 if (n == 1) { return head; } // 以 head.next 为起点,需要反转前 n - 1 个节点 reverseN(head.next, n - 1); ...... return xxx; }
-
我们顺利的解决了第一个问题,现在来解决第二个问题如何将原链表的头结点指向开始反转结点的下一结点?
在这里我们用到了后驱结点来记录我们开始反转结点的下一结点,所以稍微修改一下base case也就是终止代码javaListNode successor = null; // 后驱节点 ListNode reverseN(ListNode head, int n) { // 终止条件 if (n == 1) { // 记录开始反转结点的下一结点 successor = head.next; return head; } reverseN(head.next, n - 1); ...... return xxx; }
那么如何将原链表的头结点指向这个successor呢?
其实只需要在反转全链表的翻转代码上稍微修改下就能得到最后结果javahead.next.next = head; // 让反转之后的 head 节点和后面的节点连起来 head.next = successor;
head.next.next = head
这行代码在上一问已经回答过了,如果忘了的朋友只要往上翻一翻就行。 而head.next = successor
只是将当前结点的指向改为successor,如图所示
因为head.next的指向对上一步翻转箭头的操作毫不影响,而到了最后head指向头结点,由于后续没有箭头反转,头结点自然而然地指向的successor,从而实现我们的目的。
让我们对比一下反转全链表的翻转箭头操作,head.next = null
改为了head.next = successor
,两者其实是一样的,只是全链表末节点的下一结点肯定为空,是successor的一个特殊情况。 综上所述,我们可以非常顺利的写下最终的完整代码javaListNode successor = null; // 后驱节点 // 反转以 head 为起点的 n 个节点,返回新的头结点 ListNode reverseN(ListNode head, int n) { // base case if (n == 1) { // 记录第 n + 1 个节点 successor = head.next; return head; } // 以 head.next 为起点,需要反转前 n - 1 个节点 ListNode last = reverseN(head.next, n - 1); head.next.next = head; // 让反转之后的 head 节点和后面的节点连起来 head.next = successor; return last; }
-
反转链表[M,N]间的结点
牛客:链表内指定区间反转_牛客题霸_牛客网 (nowcoder.com)
力扣:92. 反转链表 II - 力扣(LeetCode)
让我们再再循序渐进一下,现在如何反转[M,N]间的结点。对比上一题,现在两道题的区别就是开始结点变化了,由链表头变为指定结点M。
有前两道题的铺垫,这道题可以借用上一题寻找结点N的思路可以一下类比出找到开始反转结点M所处位置,然后问题就转换为反转链表前N个结点,最后再拼接下结点就是题目所求啦。
配合注释食用本题的最终代码javaListNode reverseBetween(ListNode head, int m, int n) { // 前进到反转的起点触发 base case if (m == 1) { return reverseN(head, n); } // 当head指向下一个结点,m值和n值相对于节点位置向前移动1位,所以都-1 head.next = reverseBetween(head.next, m - 1, n - 1); return head; }
-
K个一组反转链表
牛客:链表中的节点每k个一组翻转_牛客题霸_牛客网 (nowcoder.com)
力扣:25. K 个一组翻转链表 - 力扣(LeetCode)
让我们看看问题描述,其实要我们做的就是将链表拆分为若干个[M,N]区间并反转
有了思路我们现在就来尝试解题,反转链表[M,N]区间内的结点这个问题已经解决了,现在唯一的问题只有怎么将链表拆分为若干个个[M,N]区间?
这个问题其实很好解决,题目限制了一组只要k个结点,我们只要用简单的for循环不断操作链表就行。让我们直接看最终代码javapublic ListNode reverseKGroup(ListNode head, int k) { if (head == null) return null; ListNode node1 = head, node2 = head; // 通过for循环将链表分为k个和剩余链表 for (int i = 0; i < k; i++) { if (node2 == null) return head; node2 = node2.next; } // newHead获取到反转完毕pre ListNode newHead = reverse(node1, node2); // 递归反转后续链表并连接起来 node1.next = reverseKGroup(node2, k); return newHead; }
可能有些朋友不太理解
node1.next = reverseKGroup(node2, k)
是什么意思,让我们看看下图再结合之前讲的应该就能理解了!每当一组结点反转完毕,我们需将小链表重新链接回
-
链表相加
牛客:链表相加(二)_牛客题霸_牛客网 (nowcoder.com)
力扣:LCR 025. 两数相加 II - 力扣(LeetCode)
让我们看看问题描述,如果抛开链表,这道题只是简单的逐位运算,唯一比较需要注意的就是记得进一。但再把链表加回来看,如果是顺序链表我们只需要同时遍历两条链表进行值的相加拼接到新的结果链表上就行,但是题目所给是链表末节点对齐运算,而我们想要头结点对其运算。而这两条链表的区别无非就是反转链表后的结果。所以思路一下就打开了,我们只要将题目所给的链表反转,然后最后再将结果的链表反转,就是题目所要求的答案了。
知道思路其他的问题只是细节处理,所以我们直接看最终代码
代码中的dummy
为虚拟头结点,这个技巧就在下一专题开头有说明javapublic ListNode addInList (ListNode head1, ListNode head2) { ListNode dummy = new ListNode(-1); ListNode cur = dummy; ListNode p1 = reverse(head1), p2 = reverse(head2); int plus = 0; // 进位数记录 // 同时遍历两条链表 while (p1 != null && p2 != null) { int sum = p1.val + p2.val + plus; plus = 0; if (sum >= 10) { sum -= 10; plus++; } cur.next = new ListNode(sum); cur = cur.next; p1 = p1.next; p2 = p2.next; } // 如果两条链表仍有剩余部分,直接处理完进位拼接到结果链表上 while (p1 != null ) { int sum = p1.val + plus; plus = 0; if (sum >= 10) { sum -= 10; plus++; } cur.next = new ListNode(sum); cur = cur.next; p1 = p1.next; } while (p2 != null ) { int sum = p2.val + plus; plus = 0; if (sum >= 10) { sum -= 10; plus++; } cur.next = new ListNode(sum); cur = cur.next; p2 = p2.next; } if(plus == 1){ cur.next = new ListNode(1); } // 返回反转完的结果链表 return reverse(dummy.next); } // 反转全链表 ListNode reverse(ListNode head) { if (head == null || head.next == null) return head; ListNode last = reverse(head.next); head.next.next = head; head.next = null; return last; }
双指针与链表
根据个人的刷题经验,链表题目中运用到双指针的概率是非常高的,就像上面提到的链表相加,用双指针的方法会使复杂度更低,希望看完下面部分的朋友们可以试着用双指针的方法解决链表相加问题。
在进行题目讲解前,让我先介绍一个小技巧------虚拟头结点,这个技巧我也是学自labuladong老师,下面我简单总结概括下这个技巧。
「虚拟头结点」技巧,也就是
dummy
节点。 有了dummy
节点这个占位符,可以避免处理空指针的情况,降低代码的复杂性 如果不使用dummy
虚拟节点,代码会复杂一些,需要额外处理指针p
为空的情况。而有了dummy
节点这个占位符,可以避免处理空指针的情况,降低代码的复杂性。
什么时候需要用虚拟头结点? 当你需要创造一条新链表的时候,可以使用虚拟头结点简化边界情况的处理。
可能现在看你还是懵懵的,但做完下面的问题你应该就能有进一步的理解啦。
合并链表
-
合并两个已排列链表
牛客:合并两个排序的链表_牛客题霸_牛客网 (nowcoder.com)
力扣:21. 合并两个有序链表 - 力扣(LeetCode)
看问题描述,创建一条新链表存储答案远比基于题目所给链表进行操作来得容易。
而看到创建新链表 我们要想到什么技巧呢?就是上面说到的 「虚拟头结点」 !!!
首先我们创建结点dummy
,该结点后面连接的链表为我们的结果,即最后结果return dummy.next
。这样可以减少一些空指针异常情况,不然很容易在代码比较复杂的时候在某一处发生null.next,增加排错的时间。然后再创建cur指针用于遍历链表,通常有多少条链表就创建多少个cur指针。javapublic ListNode Merge (ListNode pHead1, ListNode pHead2) { ListNode dummy = new ListNode(-1); ListNode cur = dummy, cur1 = pHead1, cur2 = pHead2; ...... return dummy.next; }
那接下来就很简单啦,借助指针来遍历两条链表,没当有较小元素添加至dummy链表上,就移动游标直至有一条链表为空退出循环。如果仍有链表不为空,剩下部分直接链接至dummy链表上就行。完整代码如下所示,基础题应该还是可以很快理解的。
javapublic ListNode Merge (ListNode pHead1, ListNode pHead2) { ListNode dummy = new ListNode(-1); ListNode cur = dummy, cur1 = pHead1, cur2 = pHead2; while (cur1 != null && cur2 != null) { int val1 = cur1.val; int val2 = cur2.val; if (val1 <= val2) { cur.next = cur1; cur1 = cur1.next; } else { cur.next = cur2; cur2 = cur2.next; } cur = cur.next; } if (cur1 != null) { cur.next = cur1; } if (cur2 != null) { cur.next = cur2; } return dummy.next; }
-
合并k个已排列链表
牛客:合并k个已排序的链表_牛客题霸_牛客网 (nowcoder.com)
力扣:23. 合并 K 个升序链表 - 力扣(LeetCode)
对比上一题,这道题不论是力扣还是牛客都定到了困难的程度,直接难度飞升,但你先别急,这道题没有想象中那么困难,让我们继续往下看。
在上道题目,我们已经解决了任意两条升序链表之间的合并问题,而本题只是数量上增多了,我们只需要不断将这k个链表两两合并,合并到最后唯一的链表就是题目所求。你再细品一下这句话,这不是很像归并排序吗?!
归并排序: 是由递归实现的,主要是分而治之的思想,也就是通过将问题分解成多个容易求解的局部性小问题来解开原本的问题的技巧。我们只需要创建一个函数不断递归调用自己,直到只有一个元素递归结束,开始回溯,调用 merge 函数,合并两个有序序列
所以我们就可以创建一个划分合并区间的函数如下javaListNode divideMerge(ArrayList<ListNode> lists, int left, int right) { // 该区间范围不正确返回空 if (left > right) return null; // 中间一个的情况返回自身 else if (left == right) return lists.get(left); // 从中间分成两段,再将合并好的两段合并 int mid = (left + right) / 2; return Merge(divideMerge(lists, left, mid), divideMerge(lists, mid + 1, right)); }
综上所述,最终代码如下
javapublic ListNode mergeKLists (ArrayList<ListNode> lists) { return divideMerge(lists, 0, lists.size() - 1); } ListNode divideMerge(ArrayList<ListNode> lists, int left, int right) { if (left > right) return null; //中间一个的情况 else if (left == right) return lists.get(left); //从中间分成两段,再将合并好的两段合并 int mid = (left + right) / 2; return Merge(divideMerge(lists, left, mid), divideMerge(lists, mid + 1, right)); } public ListNode Merge (ListNode pHead1, ListNode pHead2) { ListNode dummy = new ListNode(-1); ListNode cur = dummy, cur1 = pHead1, cur2 = pHead2; while (cur1 != null && cur2 != null) { int val1 = cur1.val; int val2 = cur2.val; if (val1 <= val2) { cur.next = cur1; cur1 = cur1.next; } else { cur.next = cur2; cur2 = cur2.next; } cur = cur.next; } if (cur1 != null) { cur.next = cur1; } if (cur2 != null) { cur.next = cur2; } return dummy.next; }
结点处理
在讲解结点处理问题前,我们先说一个概念,如果有基础的朋友其实应该非常熟悉,它就是"快慢指针"!快慢指针顾名思义就是两个指针的速度一个快一个慢,通常慢指针前进一步的同时快指针前进两步,此时应该能很快想到它的一个应用------寻找中间结点。后面还会使用快慢指针解决若干问题,让我们先通过解决"寻找中间结点"来熟悉快慢指针的应用。
-
链表的中间结点
力扣:876. 链表的中间结点 - 力扣(LeetCode)
先根据快慢指针的操作定义"慢指针前进一步的同时快指针前进两步"写出基本代码模板javaListNode middleNode(ListNode head) { // 快慢指针初始化指向 head ListNode slow = head, fast = head; while (?) { // 慢指针走一步,快指针走两步 slow = slow.next; fast = fast.next.next; } return ?; }
现在我们来确定while循环条件,当快指针达到链表末端,此时行走距离只有快指针一半的慢指针指向的就为链表中间结点,所以只要快指针不为空或者其next指向不为空,就说明还不是末指针,需要继续移动快慢指针。综上所述,非常容易得到完整代码如下。
javaListNode middleNode(ListNode head) { // 快慢指针初始化指向 head ListNode slow = head, fast = head; while (fast != null && fast.next != null) { // 慢指针走一步,快指针走两步 slow = slow.next; fast = fast.next.next; } // 慢指针指向中点 return slow; }
-
链表中倒数最后k个结点
牛客:链表中倒数最后k个结点_牛客题霸_牛客网 (nowcoder.com)
在上道题我们基本知道了快慢指针是什么,现在开始要用快慢指针灵活做题。
快慢指针通俗讲是快指针每次移动得比慢指针快,但个人 觉得有时候这个"快"有时候可以指领先一步,先行一步的意思,即快指针移动的路程先一步慢指针,即使两者速度相同,但快指针的起点前于慢指针,慢指针也依旧追不上快指针,有种视觉上的"快"。
回归本题,假设是让我们找第k个结点应该很容易吧,只要一个for循环就能解决。但本题是要找到倒数最后k个结点,如果假设链表有n个结点,其实是要我们找第n-k+1个结点。
此时我们就运用视觉上"快"的快慢指针,让快指针走k个节点,快指针现在还剩下n-k个节点未走,此时让慢指针也同速出发,当快指针到达尾指针,慢指针也到达倒数第k个结点。
明白了原理其实代码部分没什么坑点,可以一下子写出来java// 返回链表的倒数第 k 个节点 ListNode FindKthToTail (ListNode phead, int k) { ListNode slow = pHead, fast = pHead; for(int i = 0; i < k; i++){ i if(fast == null) return null; fast = fast.next; } while(fast != null){ fast = fast.next; slow = slow.next; } return slow; }
-
删除链表的倒数第n个结点
牛客:删除链表的倒数第n个节点_牛客题霸_牛客网 (nowcoder.com)
力扣:19. 删除链表的倒数第 N 个结点 - 力扣(LeetCode)
这道题只需要在上道题的基础上做些处理就能简单得出。
题目要求返回删除结点后的链表头结点,所以直接无脑虚拟头结点,然后快慢指针在此基础上初始化。由于题目描述说明了"保证n一定是有效的",所以可以去掉空判断。因为虚拟头结点创建的链表在原链表的基础上多了个-1
头结点,所以最后slow指针会指向倒数第k+1个结点,所以删掉倒数第k个结点,只要将next指针指向被删除结点的下一结点,即slow.next = slow.next.next
。
最终代码如下,应该还是比较容易理解的。javapublic ListNode removeNthFromEnd (ListNode head, int n) { ListNode dummy = new ListNode(-1); dummy.next = head; ListNode fast = dummy, slow = dummy; for (int i = 0; i < n + 1; i++) { fast = fast.next; } while (fast != null) { fast = fast.next; slow = slow.next; } slow.next = slow.next.next; return dummy.next; }
-
两个链表的第一个公共结点
牛客:两个链表的第一个公共结点_牛客题霸_牛客网 (nowcoder.com)
力扣:160. 相交链表 - 力扣(LeetCode)
咋一看好像完全没有头绪,先不说存不存在公共部分,就算有也不清楚两条链表何时进入公共部分,而且两条链表的长度也不一定一致。假如两条链表一样长且同时进入公共部分这道题是不是就迎刃而解啦。所以我们可以这样操作,如图所示将两条链表进行拼接,即 pHead1 后接上 pHead2,pHead2 后接上 pHead1,这样就可以让 pHead1 和 pHead2 同时进入公共部分,也就是同时到达公共部分头节点。
借助指针分别遍历两条链表,让p1
遍历完链表 pHead1 之后开始遍历链表 pHead2,让p2
遍历完链表 pHead2之后开始遍历链表 pHead1,这样相当于「逻辑上」两条链表接在了一起。而如果两条链表有公共部分,那么会有一个时刻 p1 == p2,此刻退出循环返回结果。如果没有公共部分,因为拼接完毕的两条链表长度相等,遍历到最后同时为空退出循环,也会返回题目所要求的空值。javaListNode getIntersectionNode(ListNode pHead1, ListNode pHead2) { // p1 指向 A 链表头结点,p2 指向 B 链表头结点 ListNode p1 = pHead1, p2 = pHead2; while (p1 != p2) { // p1 走一步,如果走到 A 链表末尾,转到 B 链表 p1 = p1 == null ? pHead2 : p1.next; // p2 走一步,如果走到 B 链表末尾,转到 A 链表 p2 = p2 == null ? pHead1 : p2.next; } return p1; }
链表与环
首先让我们看什么叫做链表中有环 如图所示,当链表的指向形成一个闭环,则说明该链表中存在环
所以可以很快知道在遍历有环的链表,该链表永远不会有空结点且进入环部后会进入元素的循环遍历,即12(3456)(3456)......而解决下列题目就需要利用快慢指针和环的特性来解决。再留一个小问题,前面有道题"两个链表的第一个公共结点"也可以转化为链表与环,有兴趣的朋友可以尝试做一下。
-
判断链表中是否有环
牛客:判断链表中是否有环_牛客题霸_牛客网 (nowcoder.com)
让我们看看问题描述,进一步加深环这个概念
在这道题我们依旧要用到快慢指针的技巧。假设链表中存在环,当快慢指针都进入环时会开始"追逐",而快指针速度>慢指针速度,所以总有某个时刻快指针会追上慢指针,即快慢指针会相遇。综上所述,可以利用这一特性来判断链表中是否有环javaboolean hasCycle(ListNode head) { // 初始化快慢指针 ListNode slow = head, fast = head; // 快指针走到末尾时停止,说明该链表不含有环 while (fast != null && fast.next != null) { // 慢指针走一步,快指针走两步 slow = slow.next; fast = fast.next.next; // 快慢指针相遇,说明含有环 if (slow == fast) { return true; } } // 不包含环 return false; }
-
链表中环的入口
牛客:链表中环的入口结点_牛客题霸_牛客网 (nowcoder.com)
根据问题描述,我们第一步要先判断该链表是否有环,如果没有环的话返回空,有的话再继续处理。javaListNode EntryNodeOfLoop(ListNode pHead) { // 代码类似于 hasCycle 函数 ListNode slow = head, fast = head; while (fast != null && fast.next != null) { fast = fast.next.next; slow = slow.next; if (fast == slow) break; } // fast 遇到空指针说明不存在环,则返回空 if (fast == null || fast.next == null) { return null; } /*后续处理*/ return X; }
处理完判断是否有环,我们现在就在链表存在环的条件下去找环的入口结点。
让我们先分析一下现在的参数,此时快慢指针在同一位置且都在环内,如图所示(图片引用自labuladong老师),此时快指针和慢指针的路程差k为且为环的路径的整数倍。
这个很容易证明,就相当于现在慢指针在相遇点(已经走了k)再走k又回到相遇点(因为快指针走了2k也是到相遇点),而相遇点在环内,肯定说明k为环的路径的整数倍。
设环起点到相遇点距离为m,此时设置慢指针重新指向起点,快慢指针同步前进,当快慢指针再次相遇时,如图所示(图片引用自labuladong老师),此时的相遇点即为环起点。
现在理解了为什么这么操作,将其翻译成代码就很容易啦,最终代码如下所示:javaListNode EntryNodeOfLoop(ListNode pHead) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { fast = fast.next.next; slow = slow.next; if (fast == slow) break; } // 上面的代码类似 hasCycle 函数 if (fast == null || fast.next == null) { // fast 遇到空指针说明没有环 return null; } // 重新指向头结点 slow = pHead; // 快慢指针同步前进,相交点就是环起点 while (slow != fast) { fast = fast.next; slow = slow.next; } return slow; }
回文链表
在做与回文结构相关的题目前,我们先了解什么是回文?
如下图所示,回文是数据上呈左右对称,123321
,12321
,同时
- 判断一个链表是否为回文结构
牛客:判断一个链表是否为回文结构_牛客题霸_牛客网 (nowcoder.com)
力扣:234. 回文链表 - 力扣(LeetCode)
因为回文结构的特性,其数据左右对称,如果是数组只需要两个指针分别指向头和尾,然后同时向中间靠拢比较元素是否相同就行。但题目给的数据结构是链表,如果还采用这种做法就非常困难,首先要花时间找到尾结点,其次单向链表你要让尾结点向中间靠拢的操作很难。所以我们要换种思路,既然我们不能定位到末节点,我们为什么不干脆拆为两条链表?我们只需要将后半边的链表拆出来并反转,最后对比两条链表是否相同不是轻而易举的吗!
- 首先我们解决第一个问题,怎么找到后半边的链表?
由于回文结构的对称性,后半部分链表的头结点其实就是原先链表的中间结点,直接套用前面"链表的中间结点"代码就可。但有一个需要注意的是,链表长度如果是奇数,slow还要再前进一步。如果所示,因为链表长度为奇数,中间结点的元素只有一个,我们只要比较此结点前后两段链表就行。
- 那现在来解决第二个问题,如何判断链表长度是奇数还是偶数呢?
因为我们使用的快慢指针,快指针每次前进两步,那么其路程为2n步为偶数,所以如果最后快指针指向为空说明链表长度是奇数,否则为偶数。最后翻译为代码如下所示。
java
boolean isPail(ListNode head) {
// 通过快慢指针找到链表的中点
ListNode slow, fast;
slow = fast = head;
// 让 slow 指针现在指向链表中点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 如果fast指针没有指向null,说明链表长度为奇数,slow还要再前进一步
if (fast != null)
slow = slow.next;
/*......*/
return ?;
}
然后我们找到了后半边链表的头结点,只要套用前面的反转链表代码,再对两条链表进行比较就可,最终代码和注释如下所示
java
boolean isPail(ListNode head) {
// 通过快慢指针找到链表的中点
ListNode slow, fast;
slow = fast = head;
// 让 slow 指针现在指向链表中点
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// 如果fast指针没有指向null,说明链表长度为奇数,slow还要再前进一步
if (fast != null)
slow = slow.next;
// 从slow开始反转后面的链表,开始比较回文串
ListNode left = head;
ListNode right = reverse(slow);
while (right != null) {
if (left.val != right.val)
return false;
left = left.next;
right = right.next;
}
}
return true;
}
// 反转链表
ListNode reverse(ListNode head) {
ListNode pre = null, cur = head;
while (cur != null) {
ListNode next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
return pre;
}
链表的排列去重
-
单链表的排序
牛客:单链表的排序_牛客题霸_牛客网 (nowcoder.com)
力扣:148. 排序链表 - 力扣(LeetCode)
题目要求将一个无序链表变为有序,最简单最直观的做法就是将元素提取出来排序完毕后,再将排序好的元素转化为一条链表。"将元素提取出来排序完毕"的操作有很多种,比如借助数组或者List列表辅助,在这里用到了优先级队列来帮助我们实现这一操作。javapublic ListNode sortInList (ListNode head) { // 创建优先级队列并定义优先规则 PriorityQueue<ListNode> pq = new PriorityQueue<>( (n1, n2) -> n1.val - n2.val ); // 将链表内的结点依次调价到队列 while (head != null) { pq.add(head); head = head.next; } ListNode dummy = new ListNode(-1); ListNode cur = dummy; while (!pq.isEmpty()) { // 队列返回的结点会按照规定的优先顺序依次抛出 cur.next = pq.poll(); cur = cur.next; } // 由于添加进队列的为原结点,next值也被保留,所以需要将尾结点设为null避免出现环 cur.next = null; return dummy.next; }
-
链表的奇偶重排
牛客:链表的奇偶重排_牛客题霸_牛客网 (nowcoder.com)
力扣:328. 奇偶链表 - 力扣(LeetCode)
题目的描述其实可以等同于把原链表拆为两条链表再重新拼接。我们可以让原链表存储奇数位节点,然后重新创建一个新链表存储偶数位结点。主要的操作还是利用指针遍历链表,最后返回头结点,只要操作细心这道题还是可以很快解出来的,最终代码如下所示。javapublic ListNode oddEvenList (ListNode head) { if(head == null) return null; // 创建新链表存储偶数位结点 ListNode evenHead = head.next; ListNode odd = head, even = evenHead; while (even != null && even.next != null) { odd.next = even.next; odd = odd.next; even.next = odd.next; even = even.next; } // 链接两条链表 odd.next = evenHead; return head; }
-
删除有序链表中重复的元素-I
牛客:删除有序链表中重复的元素-I_牛客题霸_牛客网 (nowcoder.com)
力扣:83. 删除排序链表中的重复元素 - 力扣(LeetCode)
这道题题目所给的链表是已排序的,让题目难度大大下降,我们只要找到这段重复元素链表的头结点和末结点的下一结点,就能一下子解决本问题。而这里涉及查找两个结点,并且两结点存在距离差,所以我们可以很快想到使用快慢节点。在这里慢指针用于操控原链表最终展示的数据,快指针用于查找重复元素javapublic ListNode deleteDuplicates (ListNode head) { if(head == null) return null; ListNode slow = head, fast = head; // 快指针不断前进,直至遍历完链表 while (fast != null){ // 一但快慢指针的值不相同,slow.next指向快指针,慢指针前进 if(fast.val != slow.val){ slow.next = fast; slow = slow.next; } fast = fast.next; } slow.next=null; return head; }
-
删除有序链表中重复的元素-II
牛客:删除有序链表中重复的元素-II_牛客题霸_牛客网 (nowcoder.com)
力扣:82. 删除排序链表中的重复元素 II - 力扣(LeetCode)
这道题在上一题的基础上难度加大了一点,要求"只保留原链表中只出现一次的元素"。因为这个要求导致可能出现最后结果为空,此时若使用快慢指针反而会加大难度(很容易细节考虑不到导致空指针异常)。所以使用虚拟头结点创建新链表用于存储最终结果,原链表head
结点用于遍历链表将不重复元素拼接到结果链表上。
那如何去掉重复元素呢?
因为题目所给的是有序链表,所以一旦有某个结点达到这个条件head.val == head.next.val
,则说明当前head指向为一个重复结点,我们可以通过while循环跳过所有相同项,这样就可以达到去除重复元素的效果。
综上所述最终代码和注释如下:javapublic ListNode deleteDuplicates (ListNode head) { ListNode dummy = new ListNode(-1); ListNode cur = dummy; while(head != null){ // 查找重复结点 if(head.next != null && head.val == head.next.val){ // 跳过所有相同项,此时fast指向重复链表的末节点 while(head.next != null && head.val==head.next.val){ head = head.next; } // 此时head为重复元素链表的末结点,所以需要移动到下一结点完全去除重复元素 head = head.next; } // 此时head结点的值为"只出现一次的元素",将其拼接到结果链表上 else{ cur.next = head; cur = cur.next; head = head.next; } } // 断开末结点与无关链表的连接 cur.next = null; return dummy.next; }
写在最后
一千个读者就有一千个哈姆雷特,每个人对一份代码的初次理解都是不一样的,所以遇到的问题也是不一样的。而如何理解透代码就需要我们针对自己的问题来进行debug或者向其他小伙伴寻求帮助。第一次写文章可能在一些细节上表述不清楚甚至有误导,非常欢迎交流指正!