链表专题
对数组头部的插入和删除时间复杂度都是,而平均复杂度也是,只有对尾部的插入和删除才是。简单来说”数组对查询特别友好,对删除和添加不友好“。
四个技巧
虚拟头,快慢指针,穿针引线,先穿再排后判空
三个注意
环,边界,前后序
插入
插入只需要考虑要插入位置前驱节点和后继节点(双向链表的情况下需要更新后继节点)即可,其他节点不受影响,因此在给定指针的情况下插入的操作时间复杂度为O(1)。这里给定指针中的指针指的是插入位置的前驱节点。
伪代码:
temp = 待插入位置的前驱节点.next
待插入位置的前驱节点.next = 待插入指针
待插入指针.next = temp
如果没有给定指针,我们需要先遍历找到节点,因此最坏情况下时间复杂度为 O(N)。
删除
只需要将需要删除的节点的前驱指针的 next 指针修正为其下下个节点即可,注意考虑边界条件。
伪代码:
待删除位置的前驱节点.next = 待删除位置的前驱节点.next.next
遍历
当前指针 = 头指针
while 当前节点不为空 {
print(当前节点)
当前指针 = 当前指针.next
}
一个前序遍历的递归的伪代码:
dfs(cur) {
if 当前节点为空 return
print(cur.val)
return dfs(cur.next)
}
链表和数组到底有多大的差异?
数组的遍历:
for(int i = 0; i < arr.size();i++) {
print(arr[i])
}
链表的遍历:
for (ListNode cur = head; cur != null; cur = cur.next) {
print(cur.val)
}
数组是索引 ++
链表是 cur = cur.next
逆序遍历
for(int i = arr.size() - 1; i > - 1;i--) {
print(arr[i])
}
如果是链表,通常需要借助于双向链表。而双向链表在力扣的题目很少,因此大多数你没有办法拿到前驱节点,这也是为啥很多时候会自己记录一个前驱节点 pre 的原因。
for (ListNode cur = tail; cur != null; cur = cur.pre) {
print(cur.val)
}
如果往数组末尾添加一个元素就是:
arr.push(1)
链表的话,很多语言没有内置的数组类型。比如力扣通常使用如下的类来模拟。
public class ListNode {
int val;
ListNode next;
ListNode() {}
ListNode(int val) { this.val = val; }
ListNode(int val, ListNode next) { this.val = val; this.next = next; }
}
我们是不能直接调用 push 方法的。想一下,如果让你实现这个,你怎么做?你可以先自己想一下,再往下看。
// 假设 tail 是链表的尾部节点
tail.next = new ListNode('lucifer')
tail = tail.next
经过上面两行代码之后, tail 仍然指向尾部节点。是不是很简单,你学会了么?
这有什么用?比如有的题目需要你复制一个新的链表, 你是不是需要开辟一个新的链表头,然后不断拼接(push)复制的节点?这就用上了。
对于数组的底层也是类似的,一个可能的数组 push 底层实现:
arr.length += 1
arr[arr.length - 1] = 'lucifer'
两个考点
指针的修改-链表反转
对于数组这种支持随机访问的数据结构来说, 反转很容易, 只需要头尾不断交换即可
function reverseArray(arr) {
let left = 0;
let right = arr.length - 1;
while (left < right) {
const temp = arr[left];
arr[left++] = arr[right];
arr[right--] = temp;
}
return arr;
}
由于链表的递归性,实际上,我们只要反转其中相邻的两个,剩下的采用同样的方法完成即可。
class Solution:
# 翻转一个子链表,并且返回新的头与尾
def reverse(self, head: ListNode, tail: ListNode, terminal:ListNode):
cur = head
pre = None
while cur != terminal:
# 留下联系方式
next = cur.next
# 修改指针
cur.next = pre
# 继续往下走
pre = cur
cur = next
# 反转后的新的头尾节点返回出去
return tail, head
链表的拼接-反转链表 II,再比如合并有序链表
环
环的考点有两个:
题目就有可能环,让你判断是否有环,以及环的位置。
题目链表没环,但是被你操作指针整出环了。
这里我们只讨论第二种,而第一种可以用我们后面提到的快慢指针算法。
避免出现环最简单有效的措施就是画图,如果两个或者几个链表节点构成了环,通过图是很容易看出来的。因此一个简单的实操技巧就是先画图,然后对指针的操作都反应在图中。
但是链表那么长,我不可能全部画出来呀。其实完全不用,上面提到了链表是递归的数据结构, 很多链表问题天生具有递归性,比如反转链表,因此仅仅画出一个子结构就可以了。这个知识,我们放在后面的前后序部分讲解。
边界
很多人错的是没有考虑边界。一个考虑边界的技巧就是看题目信息。
如果题目的头节点可能被移除,那么考虑使用虚拟节点,这样头节点就变成了中间节点,就不需要为头节点做特殊判断了。
题目让你返回的不是原本的头节点,而是尾部节点或者其他中间节点,这个时候要注意指针的变化。
以上两者部分的具体内容,我们在稍后讲到的虚拟头部分讲解。老规矩,大家留个印象即可。
前后序
ok,是时候填坑了。上面提到了链表结构天生具有递归性,那么使用递归的解法或者递归的思维都会对我们解题有帮助。
在 二叉树遍历 部分,我讲了二叉树的三种流行的遍历方法,分别是前序遍历,中序遍历和后序遍历。
前中后序实际上是指的当前节点相对子节点的处理顺序。如果先处理当前节点再处理子节点,那么就是前序。如果先处理左节点,再处理当前节点,最后处理右节点,就是中序遍历。后序遍历自然是最后处理当前节点了。
实际过程中,我们不会这么扣的这么死。比如:
def traverse(root):
print('pre')
traverse(root.left)
traverse(root.righ)
print('post')
如上代码,我们既在进入左右节点前有逻辑, 又在退出左右节点之后有逻辑。这算什么遍历方式呢?一般意义上,我习惯只看主逻辑的位置,如果你的主逻辑是在后面就是后序遍历,主逻辑在前面就是前序遍历。 这个不是重点,对我们解题帮助不大,对我们解题帮助大的是接下来要讲的内容。
绝大多数的题目都是单链表,而单链表只有一个后继指针。因此只有前序和后序,没有中序遍历。
如果是前序遍历,我们的代码是这样的:
def dfs(head, pre):
if not head: return pre
next = head.next
# # 主逻辑(改变指针)在后面
head.next = pre
dfs(next, head)
dfs(head, None)
后续遍历的代码是这样的:
def dfs(head):
if not head or not head.next: return head
# 不需要留联系方式了,因为我们后面已经走过了,不需走了,现在我们要回去了。
res = dfs(head.next)
# 主逻辑(改变指针)在进入后面的节点的后面,也就是递归返回的过程会执行到
head.next.next = head
# 置空,防止环的产生
head.next = None
return res
前序遍历很容易改造成迭代,因此推荐大家使用前序遍历
前序遍历,那么你可以想象前面的链表都处理好了,怎么处理的不用管。相应地如果是后序遍历,那么你可以想象后面的链表都处理好了,怎么处理的不用管。这句话的正确性也是毋庸置疑。
那么为什么前序遍历很容易改造成迭代呢?实际上,这句话我说的不准确,准确地说应该是前序遍历容易改成不需要栈的递归,而后续遍历需要借助栈来完成。这也不难理解,由于后续遍历的主逻辑在函数调用栈的弹出过程,而前序遍历则不需要。
虚拟头
将头节点变成中间节点,简化判断。
通过在合适的时候断开链接,返回链表的中间节点。
我上面提到了链表的三个注意,有一个是边界。头节点是最常见的边界,那如果我们用一个虚拟头指向头节点,虚拟头就是新的头节点了,而虚拟头不是题目给的节点,不参与运算,因此不需要特殊判断,虚拟头就是这个作用。
如果题目需要返回链表中间的某个节点呢?实际上也可借助虚拟节点。由于我上面提到的指针的操作,实际上,你可以新建一个虚拟头,然后让虚拟头在恰当的时候(刚好指向需要返回的节点)断开连接,这样我们就可以返回虚拟头的 next 就 ok 了。25. K 个一组翻转链表 就用到了这个技巧。
不仅仅是链表, 二叉树等也经常用到这个技巧。 比如我让你返回二叉树的最左下方的节点怎么做?我们也可以利用上面提到的技巧。新建一个虚拟节点,虚拟节点 next 指向当前节点,并跟着一起走,在递归到最左下的时候断开链接,最后返回 虚拟节点的 next 指针即可。
快慢指针
判断链表是否有环,以及环的入口都是使用快慢指针即可解决。这种题就是不知道不会,知道了就不容易忘。不多说了,大家可以参考我之前的题解 https://github.com/azl397985856/leetcode/issues/274#issuecomment-573985706 。
除了这个,求链表的交点也是快慢指针,算法也是类似的。不这都属于不知道就难,知道了就容易。且下次写不容易想不到或者出错。
这部分大家参考我上面的题解理一下, 写一道题就可以掌握。接下来,我们来看下穿针引线大法。
另外由于链表不支持随机访问,因此如果想要获取数组中间项和倒数第几项等特定元素就需要一些特殊的手段,而这个手段就是快慢指针。比如要找链表中间项就搞两个指针,一个大步走(一次走两步),一个小步走(一次走一步),这样快指针走到头,慢指针刚好在中间。 如果要求链表倒数第 2 个,那就让快指针先走一步,慢指针再走,这样快指针走到头,慢指针刚好在倒数第二个。这个原理不难理解吧?这种技巧属于会了就容易,且不容易忘。不会就很难想出的类型,因此大家学会了拿几道题练一下就可以放下了。
穿针引线
这是链表的第二个考点 - 拼接链表。我在 25. K 个一组翻转链表,61. 旋转链表 和 92. 反转链表 II 都用了这个方法。穿针引线是我自己起的一个名字,起名字的好处就是方便记忆。
反转前面我们已经讲过了,于是我假设链表已经反转好了,那么如何将反转好的链表拼后去呢?
a.next = c
b.next = d
先穿再排后判空
cur = head
pre = None
while cur != tail:
# 留下联系方式
next = cur.next
# 修改指针
cur.next = pre
# 继续往下走
pre = cur
cur = next
# 反转后的新的头尾节点返回出去
先穿
我给你的建议是:先穿。这里的穿是修改指针,包括反转链表的修改指针和穿针引线的修改指针。先别管顺序,先穿。
再排
穿完之后,代码的总数已经确定了,无非就是排列组合让代码没有 bug。 因此第二步考虑顺序,那上面的两行代码哪个在前?应该是先 next =
cur.next ,原因在于后一条语句执行后 cur.next 就变了。由于上面代码的作用是反转,那么其实经过 cur.next = pre
之后链表就断开了,后面的都访问不到了,也就是说此时你只能返回头节点这一个节点。
实际上,有假如有十行穿的代码,我们很多时候没有必要全考虑。我们需要考虑的仅仅是被改变 next 指针的部分。比如 cur.next =
pre 的 cur 被改了 next。因此下面用到了 cur.next 的地方就要考虑放哪。其他代码不需要考虑。
后判空
和上面的原则类似,穿完之后,代码的总数已经确定了,无非就是看看哪行代码会空指针异常。
和上面的技巧一样,我们很多时候没有必要全考虑。我们需要考虑的仅仅是被改变 next 指针的部分。 比如这样的代码
while cur:
next = cur.next
if not next: break
n_next = next.next
题目推荐
- 合并两个有序链表
- 删除排序链表中的重复元素 II
- 删除排序链表中的重复元素
- 分隔链表
- 反转链表 II
- 复制带随机指针的链表
- 环形链表
- 环形链表 II
- 重排链表
- 排序链表
- 反转链表
- 回文链表