前言
近期,着手开始了写算法系列的文章,出发点是以算法小白的角度总结分享,所以,文章的阅读门槛不高。并且,文中的大部分题解会附带图例来抽象程序执行的过程,简而言之,看不懂代码,你可以看图 😇。其中,相比较普通的迭代解法,对于递归的解法,本文会秉持递归三要素的原则分析,即返回值、调用单元、终止条件。
至于为什么是三要素,是因为递归本就是一个固定的思维模式,每一个递归的实现都是基于这三个要素。
链表
首先,我们先简单认识一下什么是链表 😎
链表(linked list)是一种物理存储单元上非连续、非顺序的存储结构,其顺序是由各个节点的指针决定的。
链表操作特点:
- 查找的时间复杂度为 O(n)
- 插入和删除的时间复杂度为 O(1)
1. 反转链表
题目描述:
反转一个单链表。
示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
解法一:双指针(使用解构赋值)
思路:
创建两个指针,prev 指向链表前一个节点,cur 指向链表后一个节点,迭代不断移动两个指针。
需要注意的是,如果需要这两个指针同时移动(赋值),则需要借助解构赋值
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
**/
var reverseList = function(head) {
let [cur, prev] = [head, null]
while(cur) [cur.next, prev, cur] = [prev, cur, cur.next]
return prev;
};
解法二:双指针(不使用解构赋值)
思路:
创建三个指针,一个指针 temp 充当中间赋值的作用,每次迭代将它指向当前节点(cur)的下一个节点(cur.next),移动完 prev 节点后,将 cur 节点指向 temp。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
**/
var reverseList = function(head) {
let cur = head, prev = null;
while(cur !== null) {
let temp = cur.next;
cur.next = prev;
prev = cur;
cur = temp;
}
return prev
};
2. 链表交换相邻元素
题目描述:
给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
示例:
输入:head = [1,2,3,4]
输出:[2,1,4,3]
解法一:迭代
思路:
需要四个指针 prev、start、end、temp。其中,prev 指针指向 head,temp 指针指向 prev。每次迭代,start 指针指向 temp.next(第一次是 head),end 指向 temp.next.next(第一次是 head.next),最后 temp 移到第二个节点,此时是交换后的,即 start。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
**/
var swapPairs = function(head) {
let dum = new LinkNode();
dum.next = head;
let temp = dum;
while(temp.next !== null && temp.next.next !== null) {
let start = temp.next; // head
let end = temp.next.next; // head.next
temp.next = end;
start.next = end.next;
end.next = start;
temp = start
}
return dum.next
}
解法二:递归
抽象模型:
- 返回值:交换完成的子链表(头节点,注意此时不是 head,是 next!)。
- 调用单元:定义 head 的下一个节点 next,head 指向后面交换好的子链表,next 指向 head。
- 终止条件:head 为 null 或者 head.next 为 null,返回 head,即当子链表只剩下一个节点或没有节点。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(n)
代码实现:
/**
* @param {ListNode} head
**/
var swapPairs = function(head) {
if (head === null || head.next === null) {
return head;
}
let next = head.next;
head.next = swapPairs(next.next);
next.next = head;
return next;
};
3. 合并两个有序链表,形成一个新的有序链表
题目描述:
输入两个递增排序的链表,合并这两个链表并使新链表中的节点仍然是递增排序的。
示例:
输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4
解法一:迭代
思路:
创建一个节点 dum 和指针 cur。每次迭代 cur.next 指向两个有序链表中较小的节点并移动该链表,结束迭代后,需要考虑链表不等长的问题,不等长时,cur.next 指向长的那一个(即还存在的链表)
复杂度分析:
时间复杂度:O(M + N)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} l1
* @param {ListNode} l2
* @return {ListNode}
*/
var mergeTwoLists = function(l1, l2) {
let dum = new ListNode();
let cur = dum;
while(l1 && l2) {
if (l1.val < l2.val) {
cur.next = l1
l1 = l1.next
} else {
cur.next = l2
l2 = l2.next
}
// 移动 cur
cur = cur.next
}
// 考虑不等长的情况
cur.next = l1 ? l1 : l2;
return dum.next
};
解法二:分治算法 + 递归
抽象模型:
- 返回值:连接好的排完序的子链表。
- 调用单元:定义一个头节点 dum,获取两个子链表中较小的节点,将 dum 指向它,dum.next 则指向接下来的子链表。
- 终止条件:当 l1 或 l2 为没有节点的时候,前者返回 l2,后者返回 l1。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(n)
代码实现:
/**
* @param {ListNode} l1
* @param {ListNode} l2
* @return {ListNode}
*/
var mergeSubLists = function (l1, l2) {
if (!l1) return l2;
if (!l2) return l1;
let dum = new ListNode();
if (l1.val < l2.val) {
dum = l1;
dum.next = mergeSubLists(l1.next, l2);
} else {
dum = l2;
dum.next = mergeSubLists(l1, l2.next);
}
return dum;
}
var mergeTwoLists = function(l1, l2) {
if (!l1 && !l2) return null;
let head = new ListNode();
head = mergeSubLists(l1, l2);
return head;
};
4. 删除链表的节点
题目描述:
给定单向链表的头指针和一个要删除的节点的值,定义一个函数删除该节点。
返回删除后的链表的头节点。
示例:
输入: head = [4,5,1,9], val = 5
输出: [4,1,9]
解法一:迭代
思路:
定义一个哨兵节点 dum 和指针 cur。其中,dum.next 指向 head,cur 指向 dum。每次迭代,判断 cur.next.val 是否等于 val,等于则令 cur.next 指向 cur.next.next,返回 dum.next,否则移动 cur。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
* @param {number} val
* @return {ListNode}
*/
var deleteNode = function(head, val) {
let dum = new ListNode();
dum.next = head;
let cur = dum;
while(cur) {
if (cur.next.val === val) {
cur.next = cur.next.next;
return dum.next;
}
cur = cur.next;
}
return dum.next;
};
解法二:递归
抽象模型:
- 返回值:删除该节点后的子链表
- 调用单元:重新对链表进行赋值,即 head.next 指向删除后的子链表
- 终止条件:节点的 val 等于要删除的节点的 val,返回节点的 next
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(n)
代码实现:
/**
* @param {ListNode} head
* @param {number} val
* @return {ListNode}
*/
var deleteNode = function(head, val) {
if (head.val === val) return head.next;
head.next = deleteNode(head.next, val);
return head;
};
5. 判断链表是否有环
题目描述:
给定一个链表,判断链表中是否有环。
示例:
输入:head = [3,2,0,-4], pos = 1
输出:true
其中 pos 代表环的入口,如果为 -1 则代表没环
解法一:Set 判重
思路:
定义一个指针 cur 和 set 集合。每次迭代,用 set 来记录该节点,如果在节点在 set 中已存在则返回 true,否则继续。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(n)
代码实现:
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function(head) {
let cur = head, set = new Set();
while(cur) {
if (set.has(cur)) {
return true
}
set.add(cur)
cur = cur.next
}
return false
};
解法二:快慢指针
思路:
定义两个指针 fast 和 slow。每次迭代,fast 移动两个节点,slow 移动一个节点,判断 fast 是否等于 slow。
我们可以这样理解快慢指针,两个人(A、B)同时起跑,A 以 B 两倍的速度跑,那么 A 和 B 肯定会相遇,并且第一次相遇是 A 刚好超过 B 一圈。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function(head) {
let fast = head, slow = head;
while(slow && fast && fast.next) {
slow = slow.next
fast = fast.next.next
if (slow === fast) {
return true
}
}
return false
};
解法三:JSON.stringify() 循环引用
思路:
JSON.stringify 转化存在循环引用的对象的时候会抛出异常。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(n)
JSON.strigify 内部是一个不断递归的过程,不过性能极差...
代码实现:
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function(head) {
try {
JSON.stringify(head)
} catch(e) {
return true
}
return false
}
解法四:手动标示节点是否为访问过
思路:
与 Set 记录不同的是,它是通过直接在节点上绑定一个属性 flag 来标识是否访问过该节点。即每次迭代,判断该节点是否存在 flag 属性(为 true),是则返回 true,不是则标识 flag 为 true。
究其本质和 Set 大同小异,但是这种写法改变了节点的结构,理论上是不能改变的。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function(head) {
while (head) {
if (head.flag) {
return true
}
head.flag = true
head = head.next
}
return false
}
6. 判断链表是否有环 II
题目描述:
给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
这题是判断链表是否有环的升级版,即不仅要判断是否有环,还需要找到环的入口。
示例:
输入:head = [3,2,0,-4], pos = 1
输出:返回索引为 1 的链表节点
解法一:Set 集合判重
思路:
定义一个 set 集合。每次迭代,判断 set 中是否有这个节点,没有则存入 set,然后移动 head。
Set 的解法可以很简单的解决这个问题,因为移动 head 的过程,第一次在 Set 中找到已存在的节点,那么肯定是环的入口节点。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(n)
代码实现:
/**
* @param {ListNode} head
* @return {ListNode}
*/
var detectCycle = function(head) {
let set = new Set();
while(head) {
if (set.has(head)) {
return head
}
set.add(head)
head = head.next
}
return null
};
解法二:快慢指针
思路:
同样地,也可以使用快慢指针解决这个问题,但是快慢指针相遇只能代码它有环,因为此时相遇的节点并不一定是环的入口节点!
快慢指针解这道题需要两个步骤。首先,用快慢指针判断是否有环。有环,则需要找到环的入口节点,而这个过程需要借助方程理解:
假设头节点到入环点距离为 a,快指针走了 x 圈、慢指针走了 y 圈,它们在环内的某个节点相遇,并且从入环点到它距离为 b,从它到入环点距离为 c,那么快、慢指针分别对应走过的路程为:
快指针:s(fast) = a + x(b + c) + b 慢指针:s(slow) = a + y(b + c) + b
由于快指针的速度是慢指针速度的两倍,所以 s(fast) = 2s(slow),则有:
a + x(b + c) + b = 2[a + y(b + c) + b]
我们再对它化简一下:
(b + c)(x - 2y) = a + b
那么,很显然当快慢指针相遇的场景是快指针比慢指针多走了 n 圈,假设此时 n = 1,即 x - 2y = 1,所以有:
a = c
而 c 刚好是此时快慢指针距离入环节点的距离,因此,我们定义一个指针 temp 指向头节点,不断移动慢指针和 temp,直至两者相遇,就找到了入环节点。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
* @return {ListNode}
*/
var detectCycle = function(head) {
let fast = head, slow = head;
while(slow && fast && fast.next) {
fast = fast.next.next;
slow = slow.next;
if (fast === slow) {
let temp = head;
while(temp !== slow) {
temp = temp.next;
slow = slow.next;
}
return temp;
}
}
return null;
};
解法三:手动标示节点是否被访问过
思路:
在判断链表时已经讲解了思路,这里就不再论述,同上。
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function(head) {
while(head) {
if (head.flag) {
return head
}
head.flag = true
head = head.next
}
return null
}
7. K 个一元组翻转链表
题目描述:
给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。
示例:
链表:1->2->3->4->5
k = 2 时,返回: 2->1->4->3->5
k = 3 时,返回: 3->2->1->4->5
解法一:递归
思路:
这道题逻辑较多,需要分为四个步骤进行:
-
定义一个哨兵节点 dum 和指针 prev。其中,dum.next 指向 head,prev 指向 dum
-
迭代 head,首先定义一个指针 tail 指向 prev,不断移动 tail 判断当前链表的长度是否大于等于 k,是则此时 tail 指针会指向 k 节点形成的子链表的尾节点,否则直接返回原来的链表 dummy.next(因为此时没有 k 个节点,原样返回)
-
定义 myReverse 函数,用于反转具备 k 组节点的子链表,该函数接收两个参数 head 和 tail,函数会交换两个参数的位置返回(即交换首位节点指向)。和简单的反转链表不同的是,这个子链表的尾节点并不是 null,而是 tail。所以,我们需要定义两个指针,prev 指向 tail.next,然后 p 指向 head,每次迭代用一个临时指针 temp 指向 p.next,然后 p.next 指向 prev,prev 移动到 p,p 移动到 temp,直至 prev 等于 tail 则退出迭代
-
反转完子链表后,需要将子链表接入原来它在链表中的位置。首先定一个指针 temp 指向 tail.next,然后将 prev.next 指向 head,tail.next 指向 temp。其次,移动指针,即 prev 等于 tail,head 等于 temp
复杂度分析:
时间复杂度:O(n)
空间复杂度:O(1)
代码实现:
/**
* @param {ListNode} head
* @param {number} k
* @return {ListNode}
*/
const myReverse = (head, tail) => {
let prev = tail.next;
let p = head;
// 移动 prev
while(prev !== tail) {
let temp = p.next;
p.next = prev;
prev = p;
p = temp;
}
return [tail, head]
}
var reverseKGroup = function(head, k) {
const dum = new ListNode(0);
dum.next = head;
let prev = dum;
while(head) {
let tail = prev;
// 匹配 k 组
for(let i = 0; i < k; ++i) {
tail = tail.next
if (!tail) {
return dum.next;
}
}
let temp = tail.next;
// 反转 k 组子链表
[head, tail] = myReverse(head, tail)
// 将子链表接回原链表中
prev.next = head;
tail.next = temp;
prev = tail;
head = temp;
}
return dum.next;
};
结语
公欲善其事,必先利其器。正如文章题目所言,我是从零开始刷 LeetCode 的,这个过程需要克服很多东西...不过,回归本质,只要坚持保持一颗学习的心,一切将会水到渠成。而接下来,我也会花很多时间写《小白也可以玩转算法》系列的文章。如果,文章中可能存在不当或错误的表达,欢迎各位同学提 Issue,共同进步😊~
❤️ 爱心三连击
写作不易,可以的话麻烦点个赞,这会成为我坚持写作的动力!!!
我是五柳,喜欢创新、捣鼓源码,专注于 Vue3 源码、Vite 源码、前端工程化等技术分享,欢迎关注我的微信公众号:Code center。