leetcode链表专题


对数组头部的插入和删除时间复杂度都是,而平均复杂度也是,只有对尾部的插入和删除才是。简单来说”数组对查询特别友好,对删除和添加不友好“。

四个技巧

虚拟头,快慢指针,穿针引线,先穿再排后判空

三个注意

环,边界,前后序

插入

插入只需要考虑要插入位置前驱节点和后继节点(双向链表的情况下需要更新后继节点)即可,其他节点不受影响,因此在给定指针的情况下插入的操作时间复杂度为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

题目推荐

  1. 合并两个有序链表
  2. 删除排序链表中的重复元素 II
  3. 删除排序链表中的重复元素
  4. 分隔链表
  5. 反转链表 II
  6. 复制带随机指针的链表
  7. 环形链表
  8. 环形链表 II
  9. 重排链表
  10. 排序链表
  11. 反转链表
  12. 回文链表
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Rust 是一种现代的编程语言,特别适合处理内存安全和线程安全的代码。在 LeetCode 中,链表是经常出现的题目练习类型,Rust 语言也是一种非常适合处理链表的语言。接下来,本文将从 Rust 语言的特点、链表的定义和操作,以及 Rust 在 LeetCode链表题目的练习等几个方面进行介绍和讲解。 Rust 语言的特点: Rust 是一种现代化的高性能、系统级、功能强大的编程语言,旨在提高软件的可靠性和安全性。Rust 语言具有如下几个特点: 1. 内存安全性:Rust 语言支持内存安全性和原语级的并发,可以有效地预防内存泄漏,空悬指针以及数据竞争等问题,保证程序的稳定性和可靠性。 2. 高性能:Rust 语言采用了“零成本抽象化”的设计思想,具有 C/C++ 等传统高性能语言的速度和效率。 3. 静态类型检查:Rust 语言支持静态类型检查,可以在编译时检查类型错误,避免一些运行时错误。 链表的定义和操作: 链表是一种数据结构,由一个个节点组成,每个节点保存着数据,并指向下一个节点。链表的定义和操作如下: 1. 定义:链表是由节点组成的数据结构,每个节点包含一个数据元素和一个指向下一个节点的指针。 2. 操作:链表的常用操作包括插入、删除、查找等,其中,插入操作主要包括在链表首尾插入节点和在指定位置插入节点等,删除操作主要包括删除链表首尾节点和删除指定位置节点等,查找操作主要包括根据数据元素查找节点和根据指针查找节点等。 Rust 在 LeetCode链表题目的练习: 在 LeetCode 中,链表是常见的题目类型,而 Rust 语言也是一个非常适合练习链表题目的语言。在 Rust 中,我们可以定义结构体表示链表的节点,使用指针表示节点的指向关系,然后实现各种操作函数来处理链表操作。 例如,针对 LeetCode 中的链表题目,我们可以用 Rust 语言来编写解法,例如,反转链表,合并两个有序链表,删除链表中的重复元素等等,这样可以更好地熟悉 Rust 语言的使用和链表的操作,提高算法和编程能力。 总之,在 Rust 中处理链表是非常方便和高效的,而 LeetCode 中的练习也是一个非常好的机会,让我们更好地掌握 Rust 语言和链表数据结构的知识。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值