链表题目是手撕常见题,这里针对链表题做一些总结。主要关注Easy和Medium难度,实际遇到Hard就认命吧。
在链表题目中有这么几个注意点:
- 经常发生的指针丢失错误;
- 很多细节问题或者边界情况多考虑哨兵节点;
- 解完题后需要检查一下边界情况;
- 现场做题时积极画图推演,没什么不好意思的,说明我严谨;
指针丢失
由于链表是不断用指针指向下一跳,也就是说next指针是维系链表结构的唯一必要关键零件。所以,任何针对next指针的操作(通常也就是对某node->next赋值)原则上讲都有可能造成所谓指针丢失,其实也就是丢失next指针所指向的node开始的一段链表。
计算机视角不是特别符合常规理解,咋们拿钓鱼做比喻,鱼竿和鱼线是一个node1,上钩的鱼是next node2,那么鱼钩就是node1–>next,大家钓到鱼把鱼钩从鱼上取下来的时候,是不是一定要一直手(比如左手拿鱼钩),另外只手捉着鱼(比如右手),如果右手不捉着鱼的话那鱼不就直接掉回水里了吗。所以一般的接发就是用一个额外的指针(右手)暂时捉住(记录)鱼,稍后再链接入鱼桶(理解为操作目的)中。
典型场景就是节点插入,例如在a和b节点间插入x
错误的写法:
// a->b->c->d, 准备把x插入到a和b中间
a->next = x; // 从b开始的这条大鱼就已经滑落回鱼塘了
x->next = a->next;
这里继续用日常生活打比方:
这比较符合人脑本能,我们人肉插队也是这么个思考逻辑,大家想想每次排队干个啥又企图插队的时候是不是大多数人总是本能的先确定我要插在哪个哥们后面(我一般选美女),看准了那哥们儿后背直接快准狠贴上去,然后再回头给后面的b找个借口。
有些朋友说素质高从来不插队,那挤过地铁吧,是不是每次挤的时候都是想的从某个人后面开始往前挤?这样不好,社会主义国家讲究文明礼貌,挤地铁也是一种插队,选中位置后先给身后的人来个微笑世界就和谐多了。
在代码里也是一样的道理,插入的时候如果也没有礼貌就出错了,实际第一步贴到a背后这里链表已经断裂,b节点开始的后半截链表已经被丢失了。
正确的写法应该是先询问后面的b是否介意我插个队(当然代码里就算你介意我也是一定要插的):
// step1: 应该先搞定b,也就是a-->next,告诉他不好意思我要插你前面了
x->next = a->next;
// step2: 后面的b搞定了,再开始挤前面的a
a->next = x;
其实就是反过来,先礼貌的给后面的人一个解释,后面的b表示接受插队才贴到a后面,所以如果做人有礼貌有素质很多原理都是相通的。
哨兵节点
常说链表最常见的操作就是删除和插入,常见的代码写法如下
// insert Node x after Node a
insert(ListNode* a, ListNode* x) {
x->next = a->next;
a->next = x;
}
// delete Node x, pre Node is a
delete(ListNode* a, ListNode* x) {
a->next = x->next; // a->next = a->next->next
}
对于insert操作,设想如果x是链表的第一个结点,那么Node a其实就是NULL,显然这里的a–>next就引用NULL指针了;
对于delete操作,设想如果x是链表的唯一一个结点,也有同样的问题。
通常的解决方法就是简单的判断一下被操作对象x是不是链表的唯一节点
// insert
if (!head) head = x;
// delete
if (!head->next) head = NULL;
其实这个“被操作对象是链表的唯一node”的边界条件可以用哨兵节点的方法解决,也就是说链表总是在头部包含一个fake节点,该节点不包含任何有效的卫星data,链表为空时该节点也存在,而有效节点总是从fake–>next开始。这样的话“被操作对象是链表的唯一node”这种边界条件就不需要考虑了。
但是客观来看这种做法在实际项目中一般可能不会太有效果,原因不是说方法不好,而是实际项目中的链表一般使用了开源实现,或者链表作为基础设施已经在项目common中被实现了,为了自己的习惯去加一个fake哨兵去改原始实现似乎也没有必要。
边界条件
当然逃不了面试手撕代码的情况,面试时在紧张压力下好不容易攒出代码,经常因为边界条件考虑不周而扣分是非常遗憾的。对于链表题,王争总结了四个需要检查的边界条件,再压缩下其实只要考虑2种边界条件即可:
- 原始链表的节点数为0, 1,2个时实现的func是否依然正常;
- 代码逻辑是否能够通用的处理头尾节点;
积极画图推演
链表问题(特别是快慢指针解法),积极在纸上画图推演,对解法的整体逻辑和边界条件的判断都有很大的帮助。
两个私货
这里总结加两个私货:
- 大部分题目都可以考虑在代码第一句句if (!head || !head->next) return xxx,基本上囊括了七八成题目的所有边界条件判断,本来嘛,空链接和单个节点的链表其实就等于不是链表,那肯定大部分情况下就不用浪费表情啦;
- 链表题目,优先尝试递归思路解决,很多链表题目都可以以递归的方式快速解决,递归法不行或者太难,先看看快慢指针法,很多题目都不会跳出这两个范围,这是链表结构的特点决定的----链表是一种遍历友好的线性结构;
毕业题
大家都是一步步参加中考高考研究生考试过来的,特别在教改早期,这些考试总是喜欢在试卷上区分基本题和附加题,比如150分的试卷前100分决定这门课是否毕业,后50分才是决定能否升入下一级的关键。
对于链表,王争总结的5个基本题是比较准确的,基本涵盖了链表题中最基本的几个操作,再难一点的题目,或者说后50分的难题很大程度上也是这五道基本题包含的操作的各种组合。
- 单链表反转(leetcode 206 easy);
- 链表中环的检测(leetcode 141 easy);
- 两个有序链表合并(leetcode 21 easy);
- 删除链表倒数第n个节点(leetcode 19 medium);
- 求链表中间节点(leetcode 876);
单链表反转
讲道理,这个链表最基本的操作,逻辑很简单,就是处理起来真的是有点绕,这里有个非常之简单的递归写法,理解了原理就直接背吧。
ListNode* reverseList(ListNode* head) {
// 边界条件,空链表或者只有1个节点的链表,直接不用反转了
if (!head || !head->next) return head;
// Step1: 先把后面的反了
ListNode* newHead = reverseList(head->next);
// Step2: 把后面做为一个整体,和当前节点做反转
head->next->next = head;
head->next = NULL;
return newHead;
}
当然,实际面试中涉及到需要执行链表反转操作的大部分是其他复杂题目中涉及到一个局部翻转,那么还是用常规解法比较合适。
class Solution {
public:
ListNode* reverseList(ListNode* head) {
ListNode* p1 = NULL;
ListNode* p2 = head;
ListNode* p3 = head;
while(p2) {
p3 = p2->next;
p2->next = p1;
p1 = p2;
p2 = p3;
}
return p1;
}
};
最后return为什么是p1?不用想那么多,稍加观察便知while循环跳出的条件是p2 == NULL,而同时p3 == p2,显然只有p1是可能的合法值。
链表中检测环
清晰记得在某付费知识app上有网友留言说平时练习时遇到实在想不出解法的就不要死磕,要大胆的看答案,有些题目的解法说想不出来就真想不出来,但是一旦知道思路写起代码来就非常简单。很多大佬也同意这种说法,毕竟在国内面试撕代码时一般出的题主要的考察点是代码能力,不是脑筋急转弯。即使运气好碰到了想不出解法的题目,完全可以和面试官交流,通常情况下面试官会提示思路,毕竟主要还是考察“用代码描述思路”的能力。
例如这个链表检测环快慢指针的解法也确实不是每个人都能轻易想到。
bool hasCycle(ListNode *head) {
if (!head || !head->next) return false;
ListNode *fast = head;
ListNode* slow = head;
while(fast->next && fast->next->next){
slow = slow->next;
fast = fast->next->next;
if (slow == fast) return true;
}
return false;
}
两个有序链表合并
链表题,优先尝试递归。
ListNode* mergeTwoLists(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;
}
}
删除链表倒数第n个节点
比较明显的快慢指针的解法,当然按照之前的总结用递归也可以解决,不过这个题递归反而比较绕了,还是快慢指针比较直接。
说到快慢指针这里就一点需要选择:slow和fast初始值的选择:head?null?
其实选择NULL的意思就是把head节点也算到fast领先的步数里,这个大家画个图就明白了,比较麻烦,两种选择后面的细节有些区别,我的做法是类似的快慢指针的题目一律把快慢指针都初始化为head。这个题目第一次写下来是酱紫的:
// n <= list.size()
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* fast = head;
ListNode* slow = head;
for (int i = 0; i < n; ++i) {
fast = fast->next;
}
while(fast->next) {
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return head;
}
其实是有问题的,记得前面说过写完了我们要检查一下两个边界条件,这里显然当链表节点数为1和2的情况,以及被删除的是头节点的情况都会出问题(链表为空和尾节点都是ok的),反复尝试一下只需要加入一句即可解决
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode* fast = head;
ListNode* slow = head;
for (int i = 0; i < n; ++i) {
fast = fast->next;
}
// 解决删除的是头节点,链表只有一个节点,只有两个节点 三种情况
if (fast == NULL) return head->next;
while(fast->next) {
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return head;
}
求链表中间节点
在leetcode 876中两个条件:
- 链表必不为空;
- 偶数节点数,则返回第二个中间节点
ListNode* middleNode(ListNode* head) {
if(!head->next) return head;
ListNode* slow = head;
ListNode* fast = head;
while (fast && fast->next) {
fast = fast->next->next;
slow = slow->next;
}
return slow;
}
这里快慢指针,依然选择slow和fast均初始化为head的方式,比较特殊的是while的判断条件,可能一开始会写成while(fast->next && fast->next->next),这个大家只需要用两个边界条件来判断即可,对于链表只有2个节点的情况就可轻松判断应该采用while (fast && fast->next)。
另外,也可以先把while条件留白,写完while语句内容后,根据fast = fast->next->next这一句就可以写出条件。
五十分的附加题:K 个一组翻转链表
这是个经典题目,也很适合被用于bat面试现场手撕,
- 思路比较清晰,不会落下“想不出思路”的圈套,没有所谓“读不懂题”的借口;
- 链表的难点关键点都能考到;
- 代码实现难度适中,能有效挑战到基本功又不至于过于难
总而言之,是一道能够全面真实反映码农基本功的题目。
网上有很多精妙的解决方法,但是感觉上只要利用递归这一点,加一个单链表反转也就够出去混了。
ListNode* reverseKGroup(ListNode* head, int k) {
int len = 0;
ListNode* h = head;
while(h) {
h = h->next;
len++;
}
if(k > len) return head;
h = head;
ListNode* p1;
ListNode* p2 = head;
ListNode* p3 = head;
for(int i = 0;i < k;i++){
p3 = p2->next;
p2->next = p1;
p1 = p2;
p2 = p3;
}
h->next = reverseKGroup(p2, k);
return p1;
}