几乎刷完了力扣全部的链表题,我发现了这些东西。。。

先上下本文的提纲,这个是我用 mindmap 画的一个脑图,以后我后继续完善,将其余专题逐步完善起来。java

你们也可使用 vscode blink-mind 打开源文件查看,里面有一些笔记能够点开查看。源文件能够去个人公众号《力扣加加》回复脑图获取,之后脑图也会持续更新更多内容。vscode 插件地址: https://marketplace.visualstu...

你们好,我是 lucifer。今天给你们带来的专题是《链表》。不少人以为链表是一个很难的专题。实际上,只要你掌握了诀窍,它并没那么难。接下来,咱们展开说说。node

链表标签在 leetcode 一共有 54 道题。 为了准备这个专题,我花了几天时间将 leetcode 几乎全部的链表题目都刷了一遍。git

能够看出,除了六个上锁的,其余我都刷了一遍。而实际上,这六个上锁的也没有什么难度,甚至和其余 48 道题差很少。github

经过集中刷这些题,我发现了一些有趣的信息,今天就分享给你们。算法

<!-- more -->数组

简介

各类数据结构,无论是队列,栈等线性数据结构仍是树,图的等非线性数据结构,从根本上底层都是数组和链表。无论你用的是数组仍是链表,用的都是计算机内存,物理内存是一个个大小相同的内存单元构成的,如图:数据结构

(图 1. 物理内存)dom

而数组和链表虽然用的都是物理内存,都是二者在对物理的使用上是很是不同的,如图:函数

(图 2. 数组和链表的物理存储图)post

不难看出,数组和链表只是使用物理内存的两种方式。

数组是连续的内存空间,一般每个单位的大小也是固定的,所以能够按下标随机访问。而链表则不必定连续,所以其查找只能依靠别的方式,通常咱们是经过一个叫 next 指针来遍历查找。链表其实就是一个结构体。 好比一个可能的单链表的定义能够是:

interface ListNode<T> {
  data: T;
  next: ListNode;
}

data 是数据域,存放数据,next 是一个指向下一个节点的指针。

链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是经过链表中的指针连接次序实现的。链表由一系列结点(链表中每个元素称为结点)组成,结点能够在运行时动态生成。

从上面的物理结构图能够看出数组是一块连续的空间,数组的每一项都是紧密相连的,所以若是要执行插入和删除操做就很麻烦。对数组头部的插入和删除时间复杂度都是$O(N)$,而平均复杂度也是$O(N)$,只有对尾部的插入和删除才是$O(1)$。简单来讲”数组对查询特别友好,对删除和添加不友好“。为了解决这个问题,就有了链表这种数据结构。链表适合在数据须要有必定顺序,可是又须要进行频繁增删除的场景,具体内容参考后面的《链表的基本操做》小节。

(图 3. 一个典型的链表逻辑表示图)

后面全部的图都是基于逻辑结构,而不是物理结构

链表只有一个后驱节点 next,若是是双向链表还会有一个前驱节点 pre。

有没有想过为啥只有二叉树,而没有一叉树。实际上链表就是特殊的树,即一叉树。

链表的基本操做

要想写出链表的题目, 熟悉链表的各类基本操做和复杂度是必须的。

插入

插入只须要考虑要插入位置前驱节点和后继节点(双向链表的状况下须要更新后继节点)便可,其余节点不受影响,所以在给定指针的状况下插入的操做时间复杂度为O(1)。这里给定指针中的指针指的是插入位置的前驱节点。

伪代码:

temp = 待插入位置的前驱节点.next
待插入位置的前驱节点.next = 待插入指针
待插入指针.next = temp

若是没有给定指针,咱们须要先遍历找到节点,所以最坏状况下时间复杂度为 O(N)

提示 1: 考虑头尾指针的状况。

提示 2: 新手推荐先画图,再写代码。等熟练以后,天然就不须要画图了。

删除

只须要将须要删除的节点的前驱指针的 next 指针修正为其下下个节点便可,注意考虑边界条件

伪代码:

待删除位置的前驱节点.next = 待删除位置的前驱节点.next.next
提示 1: 考虑头尾指针的状况。

提示 2: 新手推荐先画图,再写代码。等熟练以后,天然就不须要画图了。

遍历

遍历比较简单,直接上伪代码。

迭代伪代码:

当前指针 =  头指针
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 方法的。想一下,若是当你实现这个,你怎么作?你能够先本身想一下,再往下看。

3...2...1

ok,其实很简单。

// 假设 cur 是链表的尾部节点
tail.next = new ListNode('lucifer')
tail = tail.next

通过上面两行代码以后, tail 仍然指向尾部节点。是否是很简单,你学会了么?

这有什么用?好比有的题目须要你复制一个新的链表, 你是否是须要开辟一个新的链表头,而后不断拼接(push)复制的节点?这就用上了。

对于数组的底层也是相似的,一个可能的数组 push 底层实现:

arr.length += 1
arr[arr.length - 1] = 'lucifer'

总结一下, 数组和链表逻辑上两者有不少类似之处,不一样的只是一些使用场景和操做细节,对于作题来讲,咱们一般更关注的是操做细节。关于细节,接下来给你们介绍,这一小节主要让你们知道两者在思想和逻辑的神类似

有些小伙伴作链表题先把链表换成数组,而后用数组作,本人不推荐这种作法,这等因而否定了链表存在的价值,小朋友不要模仿。

链表题难度几何?

链表题真的不难。说链表不难是有证据的。就拿 LeetCode 平台来讲,处于困难难度的题目只有两个。

其中 第 23 题基本没有什么链表操做,一个常规的“归并排序”便可搞定,而合并两个有序链表是一个简单题。若是你懂得数组的归并排序和合并两个有序链表,应该轻松拿下这道题。

合并两个有序数组也是一个简单题目,两者难度几乎同样。

而对于第 25 题, 相信你看完本节的内容,也能够作出来。

不过,话虽这么说,可是仍是有不少小朋友给我说 ”指针绕来绕去就绕晕了“, ”总是死循环“ 。。。。。。链表题目真的那么难么?咱们又该如何破解? lucifer 给你们准备了一个口诀 一个原则, 两种题型,三个注意,四个技巧,让你轻松搞定链表题,不再怕手撕链表。 咱们依次来看下这个口诀的内容。

一个原则

一个原则就是 画图,尤为是对于新手来讲。无论是简单题仍是难题必定要画图,这是贯穿链表题目的一个准则。

画图能够减小咱们的认知负担,这其实和打草稿,备忘录道理是同样的,将存在脑子里的东西放到纸上。举一个不太恰当的例子就是你的脑子就是 CPU,脑子的记忆就是寄存器。寄存器的容量有限,咱们须要把不那么频繁使用的东西放到内存,把寄存器用在真正该用的地方,这个内存就是纸或者电脑平板等一切你能够画图的东西。

画的好看很差看都不重要,能看清就好了。用笔随便勾画一下, 能看出关系就够了。

两个考点

我把力扣的链表作了个遍。发现一个有趣的现象,那就是链表的考点很单一。除了设计类题目,其考点没法就两点:

  • 指针的修改
  • 链表的拼接

指针的修改

其中指针修改最典型的就是链表反转。其实链表反转不就是修改指针么?

对于数组这种支持随机访问的数据结构来讲, 反转很容易, 只须要头尾不断交换便可。

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;
}

而对于链表来讲,就没那么容易了。力扣关于反转链表的题简直不要太多了。

今天我给你们写了一个最完整的链表反转,之后碰到能够直接用。固然,前提是你们要先理解再去套。

接下来,我要实现的一个反转任意一段链表

reverse(self, head: ListNode, tail: ListNode)。

其中 head 指的是须要反转的头节点,tail 是须要反转的尾节点。 不难看出,若是 head 是整个链表的头,tail 是整个链表的尾,那就是反转整个链表,不然就是反转局部链表。接下来,咱们就来实现它。

首先,咱们要作的就是画图。这个我在一个原则部分讲过了。

以下图,是咱们须要反转的部分链表:

而咱们指望反转以后的长这个样子:

不难看出, 最终返回 tail 便可

因为链表的递归性,实际上,咱们只要反转其中相邻的两个,剩下的采用一样的方法完成便可。

链表是一种递归的数据结构,所以采用递归的思想去考虑每每事半功倍,关于递归思考链表将在后面《三个注意》部分展开。

对于两个节点来讲,咱们只须要下修改一次指针便可,这好像不难。

cur.next = pre

就是这一个操做,不只硬生生有了环,让你死循环。还让不该该一刀两断的它们分道扬镳。

关于分道扬镳这个不难解决, 咱们只须要反转前,记录一下下一个节点便可:

next = cur.next
cur.next = pre

cur = next

那么环呢? 实际上, 环不用解决。由于如何咱们是从前日后遍历,那么实际上,前面的链表已经被反转了,所以上面个人图是错的。正确的图应该是:

至此为止,咱们能够写出以下代码:

# 翻转一个子链表,并返回新的头与尾
    def reverse(self, head: ListNode, tail: ListNode):
        cur = head
        pre = None
        while cur != tail:
            # 留下联系方式
            next = cur.next
            # 修改指针
            cur.next = pre
            # 继续往下走
            pre = cur
            cur = next
        # 反转后的新的头尾节点返回出去
        return tail, head

若是你仔细观察,会发现,咱们的 tail 其实是没有被反转的。解决方法很简单,将 tail 后面的节点做为参数传进来呗。

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,再好比合并有序链表等。

为啥链表总喜欢穿来穿去呢?实际上,这就是链表存在的价值,这就是设计它的初衷呀!

链表的价值就在于其没必要要求物理内存的连续性,以及对插入和删除的友好。这在文章开头的链表和数组的物理结构图就能看出来。

所以链表的题目不少拼接的操做。若是上面我讲的链表基本操做你会了,我相信这难不倒你。除了环,边界 等 。。。 ^\_^。 这几个问题咱们后面再看。

三个注意

链表最容易出错的地方就是咱们应该注意的地方。链表最容易出的错 90 % 集中在如下三种状况:

  • 出现了环,形成死循环。
  • 分不清边界,致使边界条件出错。
  • 搞不懂递归怎么作

接下来,咱们一一来看。

环的考点有两个:

  • 题目就有可能环,让你判断是否有环,以及环的位置。
  • 题目链表没环,可是被你操做指针整出环了。

这里咱们只讨论第二种,而第一种能够用咱们后面提到的快慢指针算法

避免出现环最简单有效的措施就是画图,若是两个或者几个链表节点构成了环,经过图是很容易看出来的。所以一个简单的实操技巧就是先画图,而后对指针的操做都反应在图中

可是链表那么长,我不可能所有画出来呀。其实彻底不用,上面提到了链表是递归的数据结构, 不少链表问题天生具备递归性,好比反转链表,所以仅仅画出一个子结构就能够了。这个知识,咱们放在后面的先后序部分讲解。

边界

不少人错的是没有考虑边界。一个考虑边界的技巧就是看题目信息。

  • 若是题目的头节点可能被移除,那么考虑使用虚拟节点,这样头节点就变成了中间节点,就不须要为头节点作特殊判断了。
  • 题目让你返回的不是本来的头节点,而是尾部节点或者其余中间节点,这个时候要注意指针的变化。

以上二者部分的具体内容,咱们在稍微讲到的虚拟头部分讲解。老规矩,你们留个印象便可。

先后序

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

能够看出,这两种写法无论是边界,入参,仍是代码都不太同样。为何会有这样的差别呢?

回答这个问题也不难,你们只要记住一个很简单的一句话就行了,那就是若是是前序遍历,那么你能够想象前面的链表都处理好了,怎么处理的不用管。相应地若是是后续遍历,那么你能够想象后面的链表都处理好了,怎么处理的不用管。这句话的正确性也是毋庸置疑。

以下图,是前序遍历的时候,咱们应该画的图。你们把注意力集中在中间的框(子结构)就好了,同时注意两点。

  1. 前面的已经处理好了
  2. 后面的还没处理好

据此,咱们不难写出如下递归代码,代码注释很详细,你们看注释就行了。

def dfs(head, pre):
    if not head: return pre
    # 留下联系方式(因为后面的都没处理,所以能够经过 head.next 定位到下一个)
    next = head.next
    # 主逻辑(改变指针)在进入后面节点的前面(因为前面的都已经处理好了,所以不会有环)
    head.next = pre
    dfs(next, head)

dfs(head, None)

若是是后序遍历呢?老规矩,秉承咱们的一个原则,先画图

不难看出,咱们能够经过 head.next 拿到下一个元素,而后将下一个元素的 next 指向自身来完成反转。

用代码表示就是:

head.next.next = head

画出图以后,是否是很容易看出图中有一个环? 如今知道画图的好处了吧?就是这么直观,当你很熟练了,就不须要画了,可是在此以前,请不要偷懒。

所以咱们须要将 head.next 改成不会形成环的一个值,好比置空。

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

值得注意的是,前序遍历很容易改形成迭代,所以推荐你们使用前序遍历。我拿上面的迭代和这里的前序遍历给你们对比一下。

那么为何前序遍历很容易改形成迭代呢?实际上,这句话我说的不许确,准确地说应该是前序遍历容易改为不须要栈的递归,然后续遍历须要借助栈来完成。这也不难理解,因为后续遍历的主逻辑在函数调用栈的弹出过程,而前序遍历则不须要。

这里给你们插播一个写递归的技巧,那就是想象咱们已经处理好了一部分数据,并把他们用手挡起来,可是还有一部分等待处理,接下来思考”如何根据已经处理的数据和当前的数据来推导尚未处理的数据“就好了。

四个技巧

针对上面的考点和注意点,我总结了四个技巧来应对,这都是在平时作题中很是实用的技巧。

虚拟头

来了解虚拟头的意义以前,先给你们作几个小测验。

Q1: 以下代码 ans.next 指向什么?

ans = ListNode(1)
ans.next = head
head = head.next
head = head.next

A1: 最开始的 head。

Q2:以下代码 ans.next 指向什么?

ans = ListNode(1)
head = ans
head.next = ListNode(3)
head.next = ListNode(4)

A2: ListNode(4)

彷佛也不难,咱们继续看一道题。

Q3: 以下代码 ans.next 指向什么?

ans = ListNode(1)
head = ans
head.next = ListNode(3)
head = ListNode(2)
head.next = ListNode(4)

A3: ListNode(3)

若是三道题你都答对了,那么恭喜你,这一部分能够跳过。

若是你没有懂也不要紧,我这里简单解释一下你就懂了。

ans.next 指向什么取决于最后切断 ans.next 指向的地方在哪。好比 Q1,ans.next 指向的是 head,咱们假设其指向的内存编号为 9527

以后执行 head = head.next (ans 和 head 被切断联系了),此时的内存图:

咱们假设头节点的 next 指针指向的节点的内存地址为 10200

不难看出,ans 没变。

对于第二个例子。一开始和上面例子同样,都是指向 9527。然后执行了:

head.next = ListNode(3)
head.next = ListNode(4)

ans 和 head 又同时指向 ListNode(3) 了。如图:

head.next = ListNode(4) 也是同理。所以最终的指向 ans.next 是 ListNode(4)。

咱们来看最后一个。前半部分和 Q2 是同样的。

ans = ListNode(1)
head = ans
head.next = ListNode(3)

按照上面的分析,此时 head 和 ans 的 next 都指向 ListNode(3)。关键是下面两行:

head = ListNode(2)
head.next = ListNode(4)

指向了 head = ListNode(2) 以后, head 和 ans 的关系就被切断了,当前以及以后全部的 head 操做都不会影响到 ans,所以 ans 还指向被切断前的节点,所以 ans.next 输出的是 ListNode(3)。

花了这么大的篇幅讲这个东西的缘由就是,指针操做是链表的核心,若是这些基础不懂, 那么就很难作。接下来,咱们介绍主角 - 虚拟头。

相信作过链表的小伙伴都听过这么个名字。为何它这么好用?它的做用无非就两个:

  • 将头节点变成中间节点,简化判断。
  • 经过在合适的时候断开连接,返回链表的中间节点。

我上面提到了链表的三个注意,有一个是边界。头节点是最多见的边界,那若是咱们用一个虚拟头指向头节点,虚拟头就是新的头节点了,而虚拟头不是题目给的节点,不参与运算,所以不须要特殊判断,虚拟头就是这个做用。

若是题目须要返回链表中间的某个节点呢?实际上也可借助虚拟节点。因为我上面提到的指针的操做,实际上,你能够新建一个虚拟头,而后让虚拟头在恰当的时候(恰好指向须要返回的节点)断开链接,这样咱们就能够返回虚拟头的 next 就 ok 了。25. K 个一组翻转链表 就用到了这个技巧。

不只仅是链表, 二叉树等也常常用到这个技巧。 好比我让你返回二叉树的最左下方的节点怎么作?咱们也能够利用上面提到的技巧。新建一个虚拟节点,虚拟节点 next 指向当前节点,并跟着一块儿走,在递归到最左下的时候断开连接,最后返回 虚拟节点的 next 指针便可。

快慢指针

判断链表是否有环,以及环的入口都是使用快慢指针便可解决。这种题就是不知道不会,知道了就不容易忘。很少说了,你们能够参考我以前的题解 https://github.com/azl3979858...

除了这个,求链表的交点也是快慢指针,算法也是相似的。不这都属于不知道就难,知道了就容易。且下次写不容易想不到或者出错。

这部分你们参考我上面的题解理一下, 写一道题就能够掌握。接下来,咱们来看下穿针引线大法。

另外因为链表不支持随机访问,所以若是想要获取数组中间项和倒数第几项等特定元素就须要一些特殊的手段,而这个手段就是快慢指针。好比要找链表中间项就搞两个指针,一个大步走(一次走两步),一个小步走(一次走一步),这样快指针走到头,慢指针恰好在中间。 若是要求链表倒数第 2 个,那就让快指针先走一步,慢指针再走,这样快指针走到头,慢指针恰好在倒数第二个。这个原理不难理解吧?这种技巧属于会了就容易,且不容易忘。不会就很难想出的类型,所以你们学会了拿几道题练一下就能够放下了。

穿针引线

这是链表的第二个考点 - 拼接链表。我在 25. K 个一组翻转链表61. 旋转链表92. 反转链表 II 都用了这个方法。穿针引线是我本身起的一个名字,起名字的好处就是方便记忆。

这个方法一般不是最优解,可是好理解,方便书写,不易出错,推荐新手用。

仍是以反转链表为例,只不过此次是反转链表的中间一部分,那咱们该怎么作?

反转前面咱们已经讲过了,因而我假设链表已经反转好了,那么如何将反转好的链表拼后去呢?

咱们想要的效果是这样的:

那怎么达到图上的效果呢?个人作法是从作到右给断点编号。如图有两个断点,共涉及到四个节点。因而我给它们依次编号为 a,b,c,d。

其实 a,d 分别是须要反转的链表部分的前驱和后继(不参与反转),而 b 和 c 是须要反转的部分的头和尾(参与反转)。

所以除了 cur, 多用两个指针 pre 和 next 便可找到 a,b,c,d。

找到后就简单了,直接穿针引线

a.next = c
b.next = d

这不就行了么?我记得的就有 25 题,61 题 和 92 题都是这么作的,清晰不混乱。

先穿再排后判空

这是四个技巧的最后一个技巧了。虽然是最后讲,但并不意味着它不重要。相反,它的实操价值很大。

继续回到上面讲的链表反转题。

cur = head
pre = None
while cur != tail:
    # 留下联系方式
    next = cur.next
    # 修改指针
    cur.next = pre
    # 继续往下走
    pre = cur
    cur = next
# 反转后的新的头尾节点返回出去

何时须要判断 next 是否存在,上面两行代码先写哪一个呢?

是这样?

next = cur.next
    cur.next = pre

仍是这样?

cur.next = pre
    next = cur.next

先穿

我给你的建议是:先穿。这里的穿是修改指针,包括反转链表的修改指针和穿针引线的修改指针。先别管顺序,先穿

再排

穿完以后,代码的总数已经肯定了,无非就是排列组合让代码没有 bug。

所以第二步考虑顺序,那上面的两行代码哪一个在前?应该是先 next = cur.next ,缘由在于后一条语句执行后 cur.next 就变了。因为上面代码的做用是反转,那么其实通过 cur.next = pre 以后链表就断开了,后面的都访问不到了,也就是说此时你只能返回头节点这一个节点

实际上,有假若有十行穿的代码,咱们不少时候没有必要全考虑。咱们须要考虑的仅仅是被改变 next 指针的部分。好比 cur.next = pre 的 cur 被改了 next。所以下面用到了 cur.next 的地方就要考虑放哪。其余代码不须要考虑。

后判空

和上面的原则相似,穿完以后,代码的总数已经肯定了,无非就是看看哪行代码会空指针异常。

和上面的技巧同样,咱们不少时候没有必要全考虑。咱们须要考虑的仅仅是被改变 next 指针的部分

好比这样的代码

while cur:
    cur = cur.next

咱们考虑 cur 是否为空呢? 很明显不可能,由于 while 条件保证了,所以不需判空。

那如何是这样的代码呢?

while cur:
    next = cur.next
    n_next = next.next

如上代码有两个 next,第一个不用判空,上面已经讲了。而第二个是须要的,由于 next 多是 null。若是 next 是 null ,就会引起空指针异常。所以须要修改成相似这样的代码:

while cur:
    next = cur.next
    if not next: break
    n_next = next.next

以上就是咱们给你们的四个技巧了。相信有了这四个技巧,写链表题就没那么艰难啦~ ^\_^

题目推荐

最后推荐几道题给你们,用今天学到的知识解决它们吧~

总结

数组和栈从逻辑上没有大的区别,你看基本操做都是差很少的。若是是单链表,咱们没法在 $O(1)$ 的时间拿到前驱节点,这也是为何咱们遍历的时候总是维护一个前驱节点的缘由。可是本质缘由实际上是链表的增删操做都依赖前驱节点。这是链表的基本操做,是链表的特性天生决定的。

可能有的同窗有这样的疑问”考点你只讲了指针的修改和链表拼接,难道说链表就只会这些就够了?那我作的题怎么还须要我会前缀和啥的呢?你是否是坑我呢?“

我前面说了,全部的数据结构底层都是数组和链表中的一种或两种。而咱们这里讲的链表指的是考察链表的基本操做的题目。所以若是题目中须要你使用归并排序去合并链表,那其实归并排序这部分已经再也不本文的讨论范围了。

实际上,你去力扣或者其余 OJ 翻链表题会发现他们的链表题大都指的是入参是链表,且你须要对链表进行一些操做的题目。再好比树的题目大多数是入参是树,你须要在树上进行搜索的题目。也就是说须要操做树(好比修改树的指针)的题目不多,好比有一道题让你给树增长一个 right 指针,指向同级的右侧指针,若是已是最右侧了,则指向空。

链表的基本操做就是增删查,牢记链表的基本操做和复杂度是解决问题的基本。有了这些基本还不够,你们要牢记个人口诀”一个原则,两个考点,三个注意,四个技巧“。

作链表的题,要想入门,无它,惟画图尔。能画出图,并根据图进行操做你就入门了,甭管你写的代码有没有 bug 。

而链表的题目核心的考察点只有两个,一个是指针操做,典型的就是反转。另一个是链表的拼接。这两个既是链表的精髓,也是主要考点。

知道了考点确定不够,咱们写代码哪些地方容易犯错?要注意什么? 这里我列举了三个容易犯错的地方,分别是环,边界和先后序。

其中环指的是节点之间的相互引用,环的题目若是题目自己就有环, 90 % 双指针能够解决,若是自己没有环,那么环就是咱们操做指针的时候留下的。如何解决出现环的问题?那就是画图,而后聚焦子结构,忽略其余信息。

除了环,另一个容易犯错的地方每每是边界的条件, 而边界这块链表头的判断又是一个大头。克服这点,咱们须要认真读题,看题目的要求以及返回值,另一个颇有用的技巧是虚拟节点。

若是你们用递归去解链表的题, 必定要注意本身写的是前序仍是后序。

  • 若是是前序,那么只思考子结构便可,前面的已经处理好了,怎么处理的,不用管。非要问,那就是一样方法。后面的也不需考虑如何处理,非要问,那就是用一样方法
  • 若是是后续,那么只思考子结构便可,后面的已经处理好了,怎么处理的,不用管。非要问,那就是一样方法。前面的不需考虑如何处理。非要问,那就是用一样方法

若是你想递归和迭代都写, 我推荐你用前序遍历。由于前序遍历容易改为不用栈的递归。

以上就是链表专题的所有内容了。你们对此有何见解,欢迎给我留言,我有时间都会一一查看回答。更多算法套路能够访问个人 LeetCode 题解仓库:https://github.com/azl3979858... 。 目前已经 37K star 啦。你们也能够关注个人公众号《力扣加加》带你啃下算法这块硬骨头。

我整理的 1000 多页的电子书已经开发下载了,你们能够去个人公众号《力扣加加》后台回复电子书获取。

相关文章
相关标签/搜索