本文是作者刷算法题之余,将刷题的经验分享出来,欢迎和我交流探讨。
(Easy) —— 合并两个有序链表
将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
示例 1:
输入: l1 = [1,2,4], l2 = [1,3,4]
输出: [1,1,2,3,4,4]
示例 2:
输入: l1 = [], l2 = []
输出: []
示例 3:
输入: l1 = [], l2 = [0]
输出: [0]
分析
首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。
这道题目的关键词是:
- 链表
- 合并
- 排序
两个链表本身就是有序的,我们只需要同时比较两个链表开头的一个结点的 val,比较大小,较小的一个结点就跳过,继续往后面寻找。
两个链表的结点数量未知,存在一个链表查找完,另一个链表还有剩余的情况,需要对这种情况特殊处理。因为剩余的链表本身有序,剩余链表所有结点的 val 可以确定是更大的,所以直接连接即可。
指针穿针引线,合并链表
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} list1
* @param {ListNode} list2
* @return {ListNode}
*/
var mergeTwoLists = function (list1, list2) {
// 定义dummy结点,方便比较两个链表的头部
const head = new ListNode()
let cur = head
while (list1 && list2) {
if (list1.val >= list2.val) {
cur.next = list2
list2 = list2.next
} else {
cur.next = list1
list1 = list1.next
}
cur = cur.next
}
// 存在某个链表剩余的情况
cur.next = list1 === null ? list2 : list1
return head.next
}
总结
- 无论是数组的合并,还是链表的合并,一般都可以使用 指针
- 链表的操作,经常要创建一个伪结点
dummy 结点,有了dummy结点,其他的结点想怎么玩怎么玩,最终dummy.next还是会指向头结点
(Easy) —— 删除排序链表中的重复元素
给定一个已排序的链表的头 head , 删除所有重复的元素,使每个元素只出现一次 。返回 已排序的链表 。
示例 1:
输入: head = [1,1,2]
输出: [1,2]
示例 2:
输入: head = [1,1,2,3,3]
输出: [1,2,3]
分析
首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。
这道题目的关键词是:
- 链表
- 删除
- 排序
删除是链表的常规操作,要删除某个结点,只需要把他跳过就好了。所以只需要考虑怎么判断结点是重复的。
如果 当前结点 val === 下个结点 val,那么这两个结点就是重复的,我们选择把下一个节点跳过,保留当前结点,重复执行这一操作,直至遍历结束。
判断结点重复并删除
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var deleteDuplicates = function (head) {
let cur = head // 给head创建一个帮手(指针),让他去查找删除,确保最后可以无脑 return head
// 既然要比较当前结点和下个结点,那么两个结点都必须是有值的
while (cur !== null && cur.next !== null) {
if (cur.val === cur.next.val) {
cur.next = cur.next.next // 跳过重复的结点
} else {
cur = cur.next // 不重复,指针往后移动
}
}
return head
}
总结
- 链表结点的删除,其实就是结点的跳过
- 确保头结点不变的时候,可以借助指针来进行 增删,不用创建
dummy结点
(Easy) —— 反转链表
给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
示例 1:
输入: head = [1,2,3,4,5]
输出: [5,4,3,2,1]
示例 2:
输入: head = [1,2]
输出: [2,1]
示例 3:
输入: head = []
输出: []
分析
首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。
这道题目的关键词是:
- 链表
- 反转
很容易想到,实现链表的反转,就是反转每个结点的指向关系,将本该的指向下一个结点变成指向上一个结点,每个结点都这样做,整个链表就反转了。
那么对于每个结点,要做的操作是一样的,那么就需要遍历来实现,思路如下:
- 需要三个指针
cur、pre、next - 断开
原有结点 next的指向(断开前要先记录一下next = cur.next,不然不知道下一个要遍历哪个结点了) - 将结点的
next指向pre,第一个结点反转后变成了最后一个结点,所以pre初始值为null,随着遍历更新pre,之后的每个结点的next都需要指向pre - 循环终止条件不能再用
cur.next(因为next被主动断开了),而是将cur更新为记录好的next,判断cur是否存在,来判断是否遍历到原有链表尾部
三个指针实现链表反转
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var reverseList = function (head) {
// 初始化pre、cur指针
let pre = null
let cur = head
while (cur) {
// 先记录原有的next结点
let next = cur.next
cur.next = pre
// 往后移动遍历
pre = cur
cur = next
}
// 最后 pre就到了原有链表的末尾,它成为了头结点
return pre
}
总结
- 链表的反转
1. 反转链表有固定的套路,需要三个指针。cur指向当前结点,next保证遍历方向,pre记录反转后的next指向
2. 循环条件不能再用 cur.next,而是将 cur 更新为记录好的 next,判断 cur 是否存在,来判断是否遍历到原有链表尾部
(Easy) —— 环形链表
给你一个链表的头节点 head ,判断链表中是否有环。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。
如果链表中存在环 ,则返回 true 。 否则,返回 false 。
示例 1:
输入: head = [3,2,0,-4], pos = 1
输出: true
解释: 链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入: head = [1,2], pos = 0
输出: true
解释: 链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入: head = [1], pos = -1
输出: false
解释: 链表中没有环。
分析
首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。
这道题目的关键词是:
- 链表
- 成环
成环链表也是链表问题中的常见类型,成环链表的一大特征是在遍历中有结点可以重复被访问。这道题判断链表是否成环的思路有点类似于 哈希表。
本身链表每一个结点用 JavaScript 模拟就是一个 对象,只需要边遍历边存储,这里是给结点加一个 flag: true,一旦发现当前结点的 flag 是 true,说明该结点已被遍历过,链表就是包含环的,反之,则没有环。
哈希表思路判断链表是否成环
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function (head) {
while (head) {
if (head.flag) {
return true
} else {
head.flag = true
head = head.next
}
}
return false
}
总结
- 哈希表 可以判断链表是否有环
(Medium) —— 环形链表 II
给定一个链表的头节点 head ,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。
如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。
不允许修改 链表。
示例 1:
输入: head = [3,2,0,-4], pos = 1
输出: 返回索引为 1 的链表节点
解释: 链表中有一个环,其尾部连接到第二个节点。
示例 2:
输入: head = [1,2], pos = 0
输出: 返回索引为 0 的链表节点
解释: 链表中有一个环,其尾部连接到第一个节点。
示例 3:
输入: head = [1], pos = -1
输出: 返回 null
解释: 链表中没有环。
分析
首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。
这道题目的关键词是:
- 链表
- 成环
这道题跟上一道题【Easy题 环形链表】类似,加了点花。
我们沿用上一道题的解法,成环链表的第一个入环元素必定是 flag 为 true的元素,一旦发现,直接 return 即可。
哈希表思路找到第一个入环元素
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function (head) {
while (head) {
if (head.flag) {
return head
} else {
head.flag = true
head = head.next
}
}
return false
}
另一种解法,是使用快慢指针。
使用两个指针 fast, slow,fast 每次走 2 步,slow 每次走1步。
- 如果没有环,
fast和slow永远不会相遇 - 如果有环,
fast和slow必定相遇
我们来分析一下他们相遇的细节。
- 首先,必定是
fast先入环,之后slow入环,之后fast每走一步他和slow的距离就减少1,直至相遇。但是相遇点并不一定是入环结点,我们需要找到入环结点。 - 我画了一个图,如下。假设
head到 入环结点 的距离是x,入环结点 到 相遇点 的距离是y,相遇点 到 入环结点 的距离是z,那么可以得到:
// fast走过路程,n指fast在环内转过了几圈
x + n(y + z) + y
// slow走过路程
x + y
// 由于 fast 的路程是 slow 的两倍
2(x + y) = x + y + n(y + z)
x + y = n(y + z)
// 我们要求入环结点,也就是x
x = n(y + z) - y
// n(y + z)指沿着环转了n圈很好理解,关键是 -y,负数不好比较
// 尝试把他转化为正数,找n借一个
x = (n - 1 + 1)(y + z) - y
x = (n - 1)(y + z) + y + z - y
x = (n - 1)(y + z) + z
// 现在就好理解了,(n - 1)(y + z)同样指在环上转了整数圈。
// 那么只需同时从 head 和 slow 遍历,那么他们必定在入环结点相遇!
快慢指针转圈圈最终相遇
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {boolean}
*/
var hasCycle = function (head) {
// 极端情况,确认无环,return null
if (!head) return null
// 初始化快慢指针,快指针每次走2步,慢指针每次走1步
let slow = head
let fast = head
while (fast && fast.next) {
slow = slow.next
fast = fast.next.next
// 相遇
if (slow == fast) {
// 头结点和slow同时走起,最终会在入环结点相遇
while (slow !== head) {
slow = slow.next
head = head.next
}
return head
}
}
return null
}
总结
- 哈希表 可以判断链表是否有环,第一个入环结点在
哈希表中会第一个匹配到 - 快慢指针 用于成环链表,两指针相遇后,只需同时从
head和slow遍历,那么他们必定在入环结点相遇
(Medium) —— 删除排序链表中的重复元素 II
给定一个已排序的链表的头 head , 删除原始链表中所有重复数字的节点,只留下不同的数字 。返回 已排序的链表 。
示例 1:
输入: head = [1,2,3,3,4,4,5]
输出: [1,2,5]
示例 2:
输入: head = [1,1,1,2,3]
输出: [2,3]
分析
首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。
这道题目的关键词是:
- 链表
- 删除
- 排序
这道题是上一道题【Easy题 删除排序链表中的重复元素】的升级版。
上一道题是删除重复的结点,但保留了一重复元素的一个结点,意思是 head 结点是不受影响必定会留下来的。
而这道题要把所有重复的结点都删掉,这意味着 head 结点可能也会被删掉,那么就必须 dummy结点 登场了。
从 dummy结点 出发,一旦发现后面 2 个结点的 val 相同,那就把这 2 个结点删除,为了更加优化算法,只删这两个结点还不算完,我们把他们后面所有相同的结点(如有)都删掉。
有了dummy结点,随便玩不怕玩坏
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
var deleteDuplicates = function (head) {
// 极端情况,0,1个结点,直接返回
if (head === null || head.next === null) {
return head
}
let dummy = new ListNode()
// dummy 用于固定head,或者说固定链表的头部结点,即时head被删,dummmy.next 一定也是头部结点
dummy.next = head
let cur = dummy // cur指针用于更改结点next指向
while (cur.next && cur.next.next) {
if (cur.next.val === cur.next.next.val) {
// 记录重复的val
const val = cur.next.val
while (cur.next && cur.next.val === val) {
// 跳过所有值为val的结点
cur.next = cur.next.next
}
} else {
cur = cur.next
}
}
return dummy.next
}
总结
dummy结点的重要性在于,即使cur 指针怎么增删链表元素,只要dummy.next = head,那么头结点永远都是dummy.next
(Medium) —— 删除链表的倒数第 N 个结点
给定一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。
示例 1:
输入: head = [1,2,3,4,5], n = 2
输出: [1,2,3,5]
示例 2:
输入: head = [1], n = 1
输出: []
示例 3:
输入: head = [1,2], n = 1
输出: [1]
分析
首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。
这道题目的关键词是:
- 链表
- 删除
- 倒数第N
只需拿到 倒数第 N+1 个结点,就可以解决这道题。
链表不能反着遍历,只能正向一个个找。如果要获取倒数第 N 个元素,一般的思路是:先遍历一遍,求出链表的 len,这样就定位到了倒数第 N 个就是正数的第 len - n + 1个,由于题目要求是删除该结点(而不是仅仅取结点的 val),所以还需要遍历一次,遍历到该结点的前驱结点,将其删除。
这道题可以用 快慢双指针 的思路来创造一个 卡口来实现一次遍历就定位到 倒数第 N+1 个结点,思路如下:
-
如果必须要使用一次遍历,而第一次遍历又无法确定
倒数第 N 个结点是哪个,那么,我们希望在遍历到尾部的时候,有另一个指针就正好在倒数第 N+1 的位置; -
那么,我们假设现在就有两个指针,在遍历结束时,一个在
链表尾部、一个在倒数第N+1位置。我们把他们后退,看怎么能在遍历结束的时候正好实现现在的两个指针的效果。可以看出,只需要一对快慢指针fast、slow。fast早走n步,之后fast、slow并驾齐驱,直至fast到达尾部时停止,这时候slow就正好在倒数第 N+1的位置。
快慢指针营造卡口定位倒数第N+1个结点
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @param {number} n
* @return {ListNode}
*/
var removeNthFromEnd = function (head, n) {
const dummy = new ListNode()
dummy.next = head
let fast = dummy
let slow = dummy
// 快指针先走
while (n > 0) {
fast = fast.next
n--
}
// 快慢指针一起走
while (fast.next) {
fast = fast.next
slow = slow.next
}
// 倒数第N+1个结点删除倒数第N个结点
slow.next = slow.next.next
return dummy.next
}
总结
- 快慢指针 可以创造一个 卡口,定位 倒数第N 个结点
(Medium) —— 反转链表 II
给你单链表的头指针 head 和两个整数 left 和 right ,其中 left <= right 。请你反转从位置 left 到位置 right 的链表节点,返回 反转后的链表 。
示例 1:
输入: head = [1,2,3,4,5], left = 2, right = 4
输出: [1,4,3,2,5]
示例 2:
输入: head = [5], left = 1, right = 1
输出: [5]
分析
首先给题目进行关键词分类,这可以帮助我们以后遇到其他算法题时快速检索这类题的解题思路。
这道题目的关键词是:
- 链表
- 局部反转
局部反转链表的题还是挺恶心的,我们先来实例一下思路。
题目要求要反转 [left, right] 区间里的链表,具体过程可以拆分为下面几步:
- 先遍历到
left的前驱结点,将其记录下来,方便后面使用 - 反转
[left, right]部分的结点 - 连接前驱结点 + 反转后的
[left, right]+ 后面结点
定位前驱 + 局部反转 + 连接
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @param {number} left
* @param {number} right
* @return {ListNode}
*/
var reverseBetween = function (head, left, right) {
const dummy = new ListNode()
dummy.next = head
let p = dummy // 指针,为了区别于反转链表的cur,起名叫p
// 定位反转区间的前驱结点
for (let i = 0; i < left - 1; i++) {
p = p.next
}
// 此时的p就是反转区间的前驱结点
// 定位反转区间的开始结点
let start = p.next
// 链表反转常规操作,初始化pre和cur
let pre = p.next
let cur = pre.next
// 开始反转([2,4]区间3个元素只需要遍历两次即可实现局部反转,所以i<4)
for (let i = left; i < right; i++) {
const next = cur.next
cur.next = pre
pre = cur
cur = next
}
// 反转后,pre是局部链表的头部,cur是局部链表后的第一个结点
// 开始拼接
p.next = pre
start.next = cur
return dummy.next
}
总结
-
局部反转链表:除了反转链表的操作,关键在于定位
4个结点:- 局部区间的前驱结点
- 局部区间的后继结点
- 局部区间两头的两个结点