前端仔的“数据结构与算法”之路——链表

401 阅读27分钟

链表篇的学习,前半段可以说是极客时间“数据结构与算法之美”的理解和快速总结。方便没有学习过的同学快速过一遍关键内容。也适合学习过的同学巩固复习,肯定存在不足,希望大家能补充喔。

链表结构

存储结构是怎么样的?

数组和链表是很基础且常用的数据结构。我们首先得掌握好!
与数组存在一个很大的差异,存储结构不同。数组需要申请一段连续的内存空间来存储数据。而链表不需要连续的内存空间,它完全可以是零散的,不需要计算内容地址去访问数据。而是通过“指针”去联系这些零散的内存空间。👇
WechatIMG97.png

多样的链表

链表结构也分为很多种,下面重点介绍最常见的3种(当然也存在其他的结构),并讲解清除他们结构上的差异。

单链表

单链表是最基础的链表类型。链表结构是通过“指针”将这些零散的内存空间联系起来的。**对于单链表,每个链表节点除了存储本身数据外,还需要一个next“指针”指向下一个链表节点。这样一个接一个的,我们就将整个链表联系起来了。**看图理解👇。
WechatIMG99.png
黄块和蓝块为一个节点,第一个称为头节点,通过它我们可以遍历整个链表。最后一个节点称为尾节点,它的next指针指向null,表示结尾。

循环链表

表如其名,**循环链表是循环的,没有真正的尾节点。它的最后一个节点的next指针也会指向链表的头节点。**正因为尾节点链接头节点的特点,就很适合处理带有循环数据的问题。
注意⚠️,有些循环链表的尾指针并不一定执行头部,也可能是链表中的任意节点。
WechatIMG264.png

双向链表

一般链表的指向是下一个节点。双向链表的特点是有个prev前指针指向前一个节点。它的优势就是能在O(1)时间复杂度内访问前驱节点。
WechatIMG265.png

链表的操作

对于前面的3种链表,我们从访问、插入、删除、三个角度讨论以下链表的操作,从而也能感受到结构的差异带来的区别。

访问

  • 根据节点数据属于某个值,或第k个节点,去访问。

在这种情况下,对于链表,我们只能从头节点根据指针一步一步去访问节点,直到访问到。这种查找和随机访问链表的时间复杂度为O(n),如果数组它随机访问是可以直接访问第k个节点的,比链表快。

删除、插入

链表的删除、插入不像数组,需要维护一段连续的内存空间,需要数据搬迁。链表只需要维护节点前后的关系即可。
**如果只考虑指定节点的删除、插入,链表的时间复杂度是可以O(1)。**但是一般我们都需要先找到这个节点和前一个节点(维护next指向),所以查找的复杂度已经是O(n)了,再加上删除的O(1),综合还是O(n)。

WechatIMG266.png
WechatIMG267.png
对于删除、插入也存在两种情况:

  • 删除、插入指定数据值的节点

这种情况下,链表都需要经历一遍查找操作。然后才可以找到节点。时间复杂度总和起来O(n)。数组结构的话找到后,还需要数据搬迁。

  • 删除、插入指定节点

对于单向链表,我们删除指定节点,还要获取前一个节点来维护链表,所以单向链表还有个查找遍历操作。**而双向链表,天生保存了前驱节点,可以直接操作删除,时间复杂度O(1)。**插入操作同理。还有一个优势是在有序链表中,我们可以已知的节点,获得前后节点的大小,决定寻找的方向。可以比单向链表平均少寻找一半的节点。
综合来说双向链表的删除插入查找操作比单向链表高效。尽管它每个节点比单链表多一个内存空间,在实际的软件开发中,还是运用了很多双向链表的结构。相当与用空间换时间。这也是一种很关键的设计思想,根据实际情况我们可以空间换时间,或者时间换空间,达到效率的最大化。

链表与数组

经过前面的认识,对链表和数组对操作都大概有了印象,我们简单从性能上画图总结以下。
WechatIMG268.png
但也不能单纯根据这个去决定在实际应用中选取什么数据结构。它们还是各有优缺点的。

  • 数组虽然在插入删除上效率表面不算很高。但是实际中,简单易用,而且由于是连续的内存空间,可以利用cpu的缓存机制(cpu在缓存连续内存上更友好),读取效率更高。而链表不是连续空间,所以表现没那么好。
  • 但也由于数组的内存连续,再申请很大一片空间后,如果再去申请,容易出现内存不足的情况,内存的利用率也不高。如果申请的空间小,在扩容时又会涉及数据搬迁的问题。这些是需求权衡开发情况的,不能一概而论。

怎么写好链表代码

**道理我都懂,可是我就是写不出来。**原理和结构都了解了,怎么写代码就各种报错实现不了呢?我也是一脸懵逼的,所以也学来了几个技巧。利用真题反复写,总能找到自己的感觉。

1⃣️理解指针或引用的含义

将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

链表结构是很容易理解的,值得注意的是指针的概念。在JavaScript中没有指针概念,这里属于引用链表节点中保存着下一个或上一个节点的引用。

理解这个引用就完事了,(不然会被绕晕)通过几个简单的例子讲解一下。

p.next = q
// p节点的next存储了q节点的内存地址,就是p节点的next保存了q的引用。

p.next = p.next.next
// p节点的next被赋值为,p的下下个节点。p节点的next存储了p下下个节点引用。

2⃣️小心留意指针,别弄丢了

链表的操作,基本都在处理指针问题,有时赋值赋值,就没了。我们必须要注意⚠️。👇下面有些例子说明。
nodeA=>nodeB // 需要将nodeC插入AB之间

// 常见错误
A.next = C // A节点next指向C
C.next = A.next // C节点的next本意是想指向A原本的next B的。
// 但是在我们操作第一步时,B节点已经丢失了,A的next已经变成C了,第二步就会造成C的next指向自己
// 对于C语言来说,B节点已经游离,我们如果不手动释放内存,会造成内存泄露。
// 但是对于JavaScript来说,环境上会有垃圾回收机制,就不需要担心太多。

// 正确写法
C.next = A.next
A.next = C
// 先将C的next指向B(A的next),然后再将A的next指向C。保证链表不断开。

3⃣️利用哨兵简化代码

我们先看一下链表的删除、插入操作

// 如果删除和插入,是在链表“中”的节点(不是头尾)
// 删除
p.next = p.next.next
// 插入
newNode.next = p.next
p.next = newNode



// 删除头节点
head = head.next
// 头节点进行插入
newNode.next = head
// 在空节点插入
if(head === null)head = newNode

// 删除尾节点
if(p.next===null)p=null
// 在尾节点插入
if(p.next===null)p.next = newNode


// 哨兵链表,node不保存数据,它的next节点指向真正的链表头节点
node=>head

从这些简单的操作中,可以看出,对于链表的删除、插入操作,需要针对头尾节点做特殊处理。

哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑。

哨兵相当于空节点,添加在链表的头或者尾。在处理操作时,我们的代码就不需要做太多的边界判断。
哨兵这种编程技巧在很多链表的插入排序、归并排序、动态规划中应用都特别方便。在复杂的逻辑中简化我们对链表的边界判断。现在你可能没什么感觉,后续在实战题中,你能深刻感受到它的作用。
当你不确定要不要使用哨兵节点时,如果有删除或插入操作,你可以直接使用哨兵技巧。

4⃣️重点留意边界情况

开发中,无论处理什么数据结构或算法。我们都需要考虑边界情况。

  • 链表为空时
  • 链表只有一个节点
  • 在处理链表头节点和尾节点时的情况

我们在代码处理到这几点时,要测试运行结果是否如预期。异常情况是否预料到。

5⃣️画图理解

正如之前文章画的链表结构图,其实画图能让人更容易接受。所以当遇到问题时,思路没想通可以在纸上画图辅助思考,再按图写码,会更容易跑通。

leetcode实战

根据极客时间课程,针对下面几种链表操作类型。筛选出适当的题目刻意练习。链表写起来没那么舒服,题型也很多,但一定要下决心迈过去!!!看完!!!!!!多写几遍!!!!!

  • 单链表反转
  • 链表中环的问题
  • 删除链表指定节点
  • 查找链表指定节点
  • 链表的合并

206. 反转链表👈

由简入深,先熟悉反转操作。很关键关键关键!!!!

问题:

反转一个单链表。

示例:

输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL

思路、代码:

反转链表的核心就是想办法把下一个节点的next指向本节点。又保证不会丢失节点。

  • 数组解法
    一开始不熟悉链表操作的同学,可能都会想到。遍历一遍节点,将节点保存到数组中。最后将数组反向遍历,重新拼凑链表节点。
    这么操作的缺点就是会多申请空间去保存链表节点的引用。空间复杂度O(n)
var reverseList = function (head) {
  // 无脑解,模拟数组
  const arr = []
  // 先保存节点
  while (head) {
      arr.push(head)
      head = head.next
  }
  // 保存新的头节点,注意空节点的情况,设置为null
  const nhead = arr[arr.length - 1] || null
  // 保存前一个节点
  let p = arr[arr.length - 1]
  // 循环将前一个节点的next指向当前节点
  for (let i = arr.length - 2; i >= 0; i--) {
      p.next = arr[i]
      // 原本的头节点变成尾节点,所以要指向null
      if (i === 0) arr[i].next = null
      p = arr[i]
  }
  return nhead
};
  • 迭代。
    核心就是边遍历节点,边反转。我们通过指针遍历链表,改变当前节点和下一个节点的指向关系,保存好下下个节点,防止丢失。(或者也可以改变前一个节点和当前节点的指向关系,保存好下一个节点,防止丢失)一步一步下去直到链表结束。
    申请的空间是常数级,所以空间复杂度O(1)

WechatIMG274.png

var reverseList = function (head) {
  // 迭代
  // pre前节点,初始为null,因为反转后,头节点变成尾节点指向null
  // 这里也相当于哨兵,简化代码
  let pre = null
  while (head) {
    // 保存下一个节点
    let next = head.next
    // 当前节点指向前节点
    head.next = pre
    // 改变前节点和本节点,进入下一个循环
    pre = head
    head = next
  }
  return pre
};
  • 递归。
    递归是一种算法,它将在后面的章节出现。如果了解它的人,就会知道,递归是将问题拆解成一个个的子问题。这里我们可以将反转链表,拆解成反转两个节点的问题,将下一个节点指向本身。至于下一个节点怎么操作又是另一个子问题了。我们只需要考虑好终止条件就可以了。
    但是看了下面代码后你会发现,递归的过程中,函数的调用栈是一直保存在内存中的,直到递归结束。所以空间复杂度是O(n)
var reverseList = function (head) {
  // 递归
  let p = null
  // 递归函数,就解决一个问题,将下个节点的next指向本身,返回本节点
  function re (node) {
    if (node.next) {
      let next = re(node.next)
      next.next = node
      // 走到尾节点,此时已经变成链表头节点,保存给p去返回
    } else p = node
    return node
  }
  re(head)
  // 处理最开始的头节点指向null
  head.next = null
  return p
};
  • 尾递归

如果一个函数中所有递归形式的调用都出现在函数的末尾,我们称这个递归函数是尾递归的。当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。

简单理解就是函数的递归调用后不再执行任何操作,递归的结果也别作为表达式再去计算。
这样在整个过程中,它的调用栈会在下一个函数执行时出栈。节省了多余的空间。
怎么写?它的逻辑和迭代是一致的,处理好当前节点和前节点的关系后,交给下一个函数继续处理。逻辑图如👆所示。

var reverseList = function (head) {
  // 尾递归
  function re (pre, head) {
    // 如果当前节点为null,则返回pre就是反转后的头节点
    if (head === null) return pre
    let next = head.next
    head.next = pre
    // 或者也可以这么判断
    // if(next===null)return head
    return re(head, next)
  }
  return re(null, head)
};

92. 反转链表 II👈

熟悉了反转链表,我们来个升级版

问题:

反转从位置 mn 的链表。请使用一趟扫描完成反转。 说明: 1 ≤ mn ≤ 链表长度。

示例:

输入: 1->2->3->4->5->NULL, m = 2, n = 4
输出: 1->4->3->2->5->NULL

思路:

其实对于这类反转部分链表,我们也只需要关注两点。

  • 反转链表,需要反转的部分还是个链表,用上一题的逻辑反就完事了。
  • 维护好反转部分和原链表的指向。

示例中,我们需要反转2->3->4,那我们还需要注意1->,5->NULL。把反转后的链表与原链表连接就可以了

代码:

var reverseBetween = function (head, m, n) {
    // 反转链表
    let count = -1
    // 需要记录反转前的节点,之后需要连接反转后的头
    let prem = null
    // 需要反转的尾节点,反转后的头节点
    let end = null
    // 哨兵节点,可能m=1时,方便处理
    let p = new ListNode()
    p.next = head
    head = p
    while(head){
        count++
        if(count===m-1){
            prem = head
            // 接下来需要反转
            head = head.next
            // 单链表反转---------------
            let pre = null
            let next = null
            while(head){
                count++
                next = head.next
                head.next = pre
                if(count==n){
                    end = head
                    break
                }
                pre = head
                head = next
            }
            // 单链表反转---------------
            // 连接反转后的尾节点和链表(反转前的头节点)
            prem.next.next = next
            // 连接链表和反转后的头节点
            prem.next = end
            break
        }
        head = head.next
    }
  	// 返回哨兵后的节点
    return p.next
};

141. 环形链表👈

环形链表问题也是我们必须掌握的,它也很简单,请看。

问题:

给定一个链表,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

如果链表中存在环,则返回 true 。 否则,返回 false 。

进阶: 你能用 O(1)(即,常量)内存解决此问题吗?

提示:

  • 链表中节点的数目范围是 [0, 10]
  • -10 <= Node.val <= 10
  • pos-1 或者链表中的一个 有效索引

示例:

circularlinkedlist.png

输入:head = [3,2,0,-4], pos = 1
输出:true
解释:链表中有一个环,其尾部连接到第二个节点。

circularlinkedlist_test3.png

输入:head = [1], pos = -1
输出:false
解释:链表中没有环。

思路:

如果不考虑内存空间,我们直接可以用指针遍历链表,将链表节点作为key存储在map结构中,这样我们每次遍历都判断map中存不存在节点,存在则是环形链表。如果链表遍历结束,则不是环形链表。但是题目考虑了空间问题。
我们可以用双指针大法
快指针每次走2步,慢指针1步,如果是环形链表,快指针肯定会追上与慢指针相遇。easy

代码:

var hasCycle = (head) => {
    let fast = head
    while (fast && fast.next) { // 快指针没有指向null
        head = head.next // 快的前面都有节点,慢的前面当然有
        fast = fast.next.next // 推进2个节点
        if (head === fast) return true // 快慢指针相遇,有环
    }
    return false
}

142. 环形链表 II👈

环形链表判断很简单,接着写一个稍微难一点点的。

问题:

给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。

说明:不允许修改给定的链表。

进阶: 你是否可以不用额外空间解决此题?

示例:

circularlinkedlist (1).png

输入:head = [3,2,0,-4], pos = 1
输出:tail connects to node index 1
解释:链表中有一个环,其尾部连接到第二个节点。

circularlinkedlist_test3.png

输入:head = [1], pos = -1
输出:no cycle
解释:链表中没有环。

思路:

如果真的不考虑空间,那多方便呀,直接用map结构存储节点,每走一个节点判断一下是否出现,如果存在这就是入环处。
但考虑到空间问题,我们还是用我们的双指针,再加上一个简单的事实依据。我们指定快指针每次走两步,慢指针走一步,如果是环,它们会在某点相遇。依据这个点,和链表的方向,我们想办法推断出环的位置。
WechatIMG279.png
假设链表图如上。
头节点我们确定,相遇节点也可以求出。入环节点未知。我们需要找到路径的关系,对于链表我们只能通过移动指针操作去找到某个节点,所以想办法让指针指向环处时我们可以感知。

  • 慢指针走过的路径:A+B
  • 快指针走过的路径:A+n*(B+C)+B, 快指针至少走超过一圈才能遇上慢指针,n为圈数
  • 快指针走的路径是慢指针的2倍
  • 所以 2*(A+B) = A+n*(B+C)+B
    A+B = n*(B+C)
    A = n圈 - B
    A = C + (n-1)圈

由此,我们可以让两个同速度指针,p1从头节点,p2从相遇节点出发,我不必关心p2走了多少圈,因为它不管走几圈,只能在入环节点和p1相遇,因为路程相同。
当然,这道理还是挺难悟出来的,感觉要有点脑路的人才比较好从零想出这种方法。但我们可以总结环形问题的路径是存在这种规律的。

代码:

var detectCycle = function (head) {
    let fast = head
    let slow = head
    let meet = 0
    while (fast && fast.next) {
        slow = slow.next
        // 相遇后改变速度
        fast = meet === 0 ? fast.next.next : fast.next
        if (fast === slow) {
            // 第一次相遇,则改变slow节点位置去到头节点
            if (meet === 0) {
                meet = 1
                slow = head
                // 头节点就是环入口的情况
                if (fast === slow) return slow
            } else {
                meet = 2
                return slow
            }
        }
    }
    return null
};

剑指 Offer 52. 两个链表的第一个公共节点👈

也是环形链表的变种题。

问题:

输入两个链表,找出它们的第一个公共节点。 如下面的两个链表**:**

160_statement.png

在节点 c1 开始相交。
注意:

如果两个链表没有交点,返回 null. 在返回结果后,两个链表仍须保持原有的结构。 可假定整个链表结构中没有循环。 程序尽量满足 O(n) 时间复杂度,且仅用 O(1) 内存。 本题与主站 160 题相同:leetcode-cn.com/problems/in…

示例:

160_example_1.png

输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,0,1,8,4,5], skipA = 2, skipB = 3
输出:Reference of the node with value = 8
输入解释:相交节点的值为 8 (注意,如果两个列表相交则不能为 0)。从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,0,1,8,4,5]。在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。

思路:

这题和环形链表类似,如果不考虑空间,直接在链表上新建变量去标记节点。指针遍历两个链表,每访问一个节点标记一下,如果第一次遇到节点被标记了,那就是第一个公共节点。
如果想优化空间。我们一样可以用之前环形链表的套路。想办法找到路径的规律,移动指针到相遇处时,我们能感知。
如果公共节点前的节点数都一样,那两个指针同速度遍历,会在公共节点相遇。问题就是两个链表的公共节点前的节点数不一致。这样遍历节点不可能相遇。
WechatIMG280.png
红色节点为共同节点。由图可知。
我们将两个链表拼起来,弥补了公共节点前节点数不同的问题。这样我们就能依据这种逻辑移动同速指针,它们会在公共节点相遇。

代码:

var getIntersectionNode = function (headA, headB) {
    // 边界判断 如果其中一个节点为空,则返回null
    if(headA===null||headB===null)return null
    // 其中一个指针遇到null的次数,拼接A、B链表,只能遇到2次
    // 2次null之后都没有公共节点则返回null
    let count = 0
    const a = headA
    const b = headB
    while (true) {
        if(headA===null){
            count++
            headA = b
            if(count===2)break
        }
        if(headB===null)headB = a
        // 相遇
        if(headA===headB)return headA
        headA = headA.next
        headB = headB.next
    }
    return null
};

剑指 Offer 22. 链表中倒数第k个节点👈

获取链表中某个节点,也是经典题。掌握它,遇到链表节点问题基本都可以解决。

问题:

输入一个链表,输出该链表中倒数第k个节点。为了符合大多数人的习惯,本题从1开始计数,即链表的尾节点是倒数第1个节点。例如,一个链表有6个节点,从头节点开始,它们的值依次是1、2、3、4、5、6。这个链表的倒数第3个节点是值为4的节点。

示例:

给定一个链表: 1->2->3->4->5, 和 k = 2.

返回链表 4->5.

思路:

如果是数组,我们可以直接随机访问。但是链表的特性,我们无法知道链表的长度。只能通过遍历。当然简单的做法就是遍历链表,然后将节点的引用存在数组,之后直接通过数组访问。但是这样会多申请空间,空间复杂度O(n)。
还有一个经典的做法就是:
双指针
假设k=2,先让快指针先走k步,慢指针再出发,知道快指针走到null,慢指针的位置就是链表倒数k个节点。

(寻找链表中间点,同样也可以使用双指针法,快指针每次比慢指针多走一步)
WechatIMG277.png
WechatIMG276.png
......

WechatIMG278.png
代码:

var getKthFromEnd = function (head, k) {
    let fast = head
    // 快指针比慢指针多走的步数
    let count = 0
    while (fast) {
        fast = fast.next
        count += 1
        if (count > k) {
            // k步后再出发
            head = head.next
        }
    }
    return head
};

19. 删除链表的倒数第N个节点👈

问题:

给定一个链表,删除链表的倒数第 _n _个节点,并且返回链表的头结点。 说明: 给定的 n 保证是有效的。 进阶: 你能尝试使用一趟扫描实现吗?

示例:

给定一个链表: 1->2->3->4->5, 和 n = 2.

当删除了倒数第二个节点后,链表变为 1->2->3->5.

思路:

经历过查找倒数第k个节点,同理这个问题只是多了一个删除操作。我们一样可以用双指针逻辑去做,特别的是这一题我们需要保存好倒数n+1节点,因为删除n节点时,我们需要它来连接后续的节点。

代码:

var removeNthFromEnd = function(head, n) {
    // 因为我们需要保存前一个节点,所以新建一个哨兵,慢指针可以从哨兵节点开始
    let start = new ListNode()
    start.next = head
    let fast = head
    // 慢指针
    head = start
    while(fast){
        fast = fast.next
        n--
        // 当快指针走了n步 以后
        if(n<0){
            head = head.next
        }
        if(fast===null){
            // 此时的 head其实是倒数n+1节点
            head.next = head.next.next
        }
    }
    return start.next
};

234. 回文链表👈

这个问题涉及到了反转链表,寻找中间点等技巧。

问题:

请判断一个链表是否为回文链表。 进阶: 你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

示例:

输入: 1->2
输出: false

输入: 1->2->2->1
输出: true

思路:

当然,最简单的思路还是用数组存储链表,再首尾依次向中间判断,是否相等。同样这也会多申请n个空间存储节点。空间复杂度会高。
进阶的做法,反转前半部分链表,用两个指针从头和中间节点开始一一对比。
反转链表我们已经掌握了,这次我们只需要反转一半,就要借助快慢指针,找到中间节点。快指针每次移动两步,慢指针移动一步。
需要⚠️⚠️注意的是,链表节点单双数时,比较起始位置的问题:
1->2->3->4->51->2->3->4,单数链表的中间节点不参与比较。

代码:

var isPalindrome = function (head) {
    // 快慢指针,找到链表中点,并反转前半部分链表
    let slow = head
    let pre = null
    while (head && head.next) {
        head = head.next.next
        let next = slow.next
        // 反转
        slow.next = pre
        pre = slow
        slow = next
        if(head&&head.next===null){
            // 单数情况,要越过中间节点
            slow = slow.next
        }
    }
    // pre为反转后头节点,slow为中间点后的链表
    while(pre&&slow){
        if(pre.val!==slow.val)return false
        pre = pre.next
        slow = slow.next
    }
    return true
};

剑指 Offer 25. 合并两个排序的链表👈

问题:

输入两个递增排序的链表,合并这两个链表并使新链表中的节点仍然是递增排序的。 限制: 0 <= 链表长度 <= 1000

示例:

输入:1->2->4, 1->3->4
输出:1->1->2->3->4->4

思路:

合并链表也是必考操作!!!!!
因为两个链表都是排好序的,所以我们可以从两个链表的头节点开始比较。这样总是能拿到当前最小值。
**
可以通过迭代和递归的操作去解题,题目要求并没有空间限制,递归虽然会增加空间复杂度。但也是一种很好理解思路。

迭代,**我们可以新建一个哨兵节点,指针指向哨兵。迭代过程中将指针的next指向遍历结果较小的节点,然后移动指针,不断循环,直到其中一个链表遍历结束。然后将next指向另一个链表就可以了。**逻辑比较简单,直接看代码👇

代码:

var mergeTwoLists = function (l1, l2) {
  if (l1 === null) return l2
  if (l2 === null) return l1
  // 迭代
  // 哨兵节点
  let sentry = new ListNode()
  let start = sentry
  while (l1 && l2) {
    let next
    // 指向较小的节点
    if (l1.val <= l2.val) {
      next = l1.next
      start.next = l1
      l1 = next
    } else {
      next = l2.next
      start.next = l2
      l2 = next
    }
    // 移动指针到最新节点
    start = start.next
  }
  if (l1 && l2 === null) start.next = l1
  if (l2 && l1 === null) start.next = l2
  return sentry.next
};
var mergeTwoLists = function (l1, l2) {
  if (l1 === null) return l2
  if (l2 === null) return l1
  // 递归
  if (l1.val < l2.val) {
    // 将问题分解为获取下一个节点,
    // 获取的逻辑为较小的节点
    // 终止条件为,其中一个链表遍历结束
      l1.next = mergeTwoLists(l1.next, l2)
      return l1
  } else {
      l2.next = mergeTwoLists(l1, l2.next)
      return l2
  }
};

148. 排序链表👈

问题:

在 O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序。

示例:

输入: 4->2->1->3
输出: 1->2->3->4

思路:

这题目很关键很关键很关键!!!
根据题目的时间限制,排序算法中符合O(n log n)时间复杂度的常见算法有归并排序、快速排序。对于链表,归并排序更适合操作一些。
[图解] 归并排序 👈有动图,更好了解排序概念。
重点的知识知识点是,划分链表,合并两个有序链表。(合并不会的看上一题)

  • 第一步,将链表的每两个节点分为合并组,合并组前一半和后一半进行合并排序。
  • 第二步我们获得了每两个节点都是排好序的链表,我们再将每四个分为合并组,继续进行归并操作。
  • 直到最后一步,链表只能分为一个合并组,合并排序。

看代码👇

代码:

var sortList = function (head) {
  // 归并排序 迭代
  let sentry = new ListNode()
  sentry.next = head
  // 先计算链表长度
  let len = 0
  while (head) {
    len += 1
    head = head.next
  }
  // 分割节点的数量,从1开始,然后到2...
  for (let i = 1; i < len; i *= 2) {
    let pre = sentry
    let p = pre.next
    while (p) {
      // 将链表按数量i分割,然后排序合并
      let left = p
      let right = slice(left, i)
      // 分割right的长度,如果长度不够i,则分割到结尾即可
      p = slice(right, i)
      let merg = merge(left, right)
      pre.next = merg[0]
      pre = merg[1]
    }
  }
  return sentry.next
  function slice (p, i) {
    // 分割链表,返回分割前尾节点的下一个节点
    while (i > 1 && p) {
      i--
      p = p.next
    }
    let next = p ? p.next : null
    if (p) p.next = null
    return next
  }
  function merge (left, right) {
    // 合并有序链表
    let sentry = new ListNode()
    let p = sentry
    while (left && right) {
      if (left.val <= right.val) {
        let next = left.next
        p.next = left
        left = next
      } else {
        let next = right.next
        p.next = right
        right = next
      }
      p = p.next
    }
    p.next = left ? left : right
    // 返回排序后链表尾节点
    let end = null
    while (p) {
      end = p
      p = p.next
    }
    return [sentry.next, end]
  }
};

还有一个递归版本的归并排序。思路是一样的,写法不同。

var sortList = function (head) {
  // 递归
  // 1、找到中间点
  // 2、根据中间点拆分链表、直至单个
  // 3、合并链表
  function sort (head) {
    if (head === null || head.next === null) return head
    // 寻找中间点进行分组,直到分单个节点
    // 一分为二
    let mid = findMid(head)
    let right = mid.next
    mid.next = null
    let left = head
    left = sort(left)
    right = sort(right)
    return merge(left, right)
  }
  return sort(head)
  function merge (left, right) {
    // 递归合并有序链表
    if (left === null) return right
    if (right === null) return left
    if (left.val < right.val) {
      left.next = merge(left.next, right)
      return left
    } else {
      right.next = merge(left, right.next)
      return right
    }
  }
  function findMid (head) {
    let slow = head
    let fast = head
    // 指针不能超出链表,不然慢指针会走过中间点一步
    while (slow && slow.next && fast && fast.next && fast.next.next) {
      slow = slow.next
      fast = fast.next.next
    }
    return slow
  }
};

147. 对链表进行插入排序👈

经历过了归并排序,那么复杂的操作都搞掂了,插入就简单啦。

问题:

Insertion-sort-example-300px.gif

插入排序的动画演示如上。从第一个元素开始,该链表可以被认为已经部分排序(用黑色表示)。 每次迭代时,从输入数据中移除一个元素(用红色表示),并原地将其插入到已排好序的链表中。

插入排序算法:

插入排序是迭代的,每次只移动一个元素,直到所有元素可以形成一个有序的输出列表。 每次迭代中,插入排序只从输入数据中移除一个待排序的元素,找到它在序列中适当的位置,并将其插入。 重复直到所有输入数据插入完为止。

示例:

输入: 4->2->1->3
输出: 1->2->3->4

输入: -1->5->3->4->0
输出: -1->0->3->4->5

思路:

动图已经演示得很清晰了。
其实我们相当于对链表不断的删除节点,插入节点。

  • 插入操作,我只需要两个节点的信息,插入前的和插入后的。
  • 删除操作我们需要有三个节点的信息,删除前,删除节点,删除后。

这样才能维系链表**。而且越到插入删除,可以直接上哨兵节点**,方便头节点的操作。没毛病的。

代码:

var insertionSortList = function (head) {
  let start = new ListNode()
  start.next = head
  while (head) {
    // 当前比较的主节点
    let p = head.next
    // 如果主节点小于尾节点,进行插入
    if (p && p.val < head.val) {
      // 主节点后的 节点
      let next = p.next
      // 将主节点插入已排序链表中,相当于删除了p节点,然后将p插入
      insert(start, p)
      // 节点已经插入,指针不需要移位
      head.next = next
    } else head = head.next
  }
  function insert (list, node) {
    while (list && list.next) {
      if (list.next.val >= node.val) {
        node.next = list.next
        list.next = node
        break
      }
      list = list.next
    }
  }
  return start.next
};

总结

对于链表的操作,心别慌。牢记这几点,反复做题找感觉。哪怕最后写得慢,但至少感觉到位。

  • 熟悉指针、引用的概念和感觉。
  • 操作时,小心链表断开,指针丢失,维系好链表本身。
  • 哨兵节点,遇到插入、删除直接上。
  • 注意边界情况,空链表、一个节点、指针指向null等情况。
  • 画图,多写题。