【图文并茂】小白也可以玩转算法——链表篇

993 阅读11分钟

前言

近期,着手开始了写算法系列的文章,出发点是以算法小白的角度总结分享,所以,文章的阅读门槛不高。并且,文中的大部分题解会附带图例来抽象程序执行的过程,简而言之,看不懂代码,你可以看图 😇。其中,相比较普通的迭代解法,对于递归的解法,本文会秉持递归三要素的原则分析,即返回值、调用单元、终止条件。

至于为什么是三要素,是因为递归本就是一个固定的思维模式,每一个递归的实现都是基于这三个要素。

链表

首先,我们先简单认识一下什么是链表 😎

链表(linked list)是一种物理存储单元上非连续、非顺序的存储结构,其顺序是由各个节点的指针决定的。

链表操作特点:

  • 查找的时间复杂度为 O(n)
  • 插入和删除的时间复杂度为 O(1)

1. 反转链表

leetcode-cn.com/problems/re…

题目描述:

反转一个单链表。

示例:

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

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

解法一:双指针(使用解构赋值)

思路:

创建两个指针,prev 指向链表前一个节点,cur 指向链表后一个节点,迭代不断移动两个指针。

需要注意的是,如果需要这两个指针同时移动(赋值),则需要借助解构赋值

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
* @param {ListNode} head
**/
var reverseList = function(head) {
    let [cur, prev] = [head, null]
    while(cur) [cur.next, prev, cur] = [prev, cur, cur.next]
    return prev;
};

解法二:双指针(不使用解构赋值)

思路:

创建三个指针,一个指针 temp 充当中间赋值的作用,每次迭代将它指向当前节点(cur)的下一个节点(cur.next),移动完 prev 节点后,将 cur 节点指向 temp。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
* @param {ListNode} head
**/
var reverseList = function(head) {
    let cur = head, prev = null;
    while(cur !== null) {
        let temp = cur.next;
        cur.next = prev;
        prev = cur;
        cur = temp;
    }
    return prev
};

2. 链表交换相邻元素

leetcode-cn.com/problems/sw…

题目描述:

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。

示例:

输入:head = [1,2,3,4]

输出:[2,1,4,3]

解法一:迭代

思路:

需要四个指针 prev、start、end、temp。其中,prev 指针指向 head,temp 指针指向 prev。每次迭代,start 指针指向 temp.next(第一次是 head),end 指向 temp.next.next(第一次是 head.next),最后 temp 移到第二个节点,此时是交换后的,即 start。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
* @param {ListNode} head
**/
var swapPairs = function(head) {
    let dum = new LinkNode();
    dum.next = head;
    let temp = dum;
    while(temp.next !== null && temp.next.next !== null) {
        let start = temp.next; // head
        let end = temp.next.next; // head.next
        temp.next = end;
        start.next = end.next;
        end.next = start;
        temp = start
    }
    return dum.next
}

解法二:递归

抽象模型:

  • 返回值:交换完成的子链表(头节点,注意此时不是 head,是 next!)。
  • 调用单元:定义 head 的下一个节点 next,head 指向后面交换好的子链表,next 指向 head。
  • 终止条件:head 为 null 或者 head.next 为 null,返回 head,即当子链表只剩下一个节点或没有节点。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(n)

代码实现:

/**
* @param {ListNode} head
**/
var swapPairs = function(head) {
    if (head === null || head.next === null) {
        return head;
    }
    let next = head.next;
    head.next = swapPairs(next.next);
    next.next = head;
    return next;
};

3. 合并两个有序链表,形成一个新的有序链表

leetcode-cn.com/problems/he…

题目描述:

输入两个递增排序的链表,合并这两个链表并使新链表中的节点仍然是递增排序的。

示例:

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

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

解法一:迭代

思路:

创建一个节点 dum 和指针 cur。每次迭代 cur.next 指向两个有序链表中较小的节点并移动该链表,结束迭代后,需要考虑链表不等长的问题,不等长时,cur.next 指向长的那一个(即还存在的链表)

复杂度分析:

时间复杂度:O(M + N)

空间复杂度:O(1)

代码实现:

/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}
 */
var mergeTwoLists = function(l1, l2) {
    let dum = new ListNode();
    let cur = dum;
    while(l1 && l2) {
        if (l1.val < l2.val) {
            cur.next = l1
            l1 = l1.next
        } else {
            cur.next = l2
            l2 = l2.next
        }
        // 移动 cur
        cur = cur.next
    }
    // 考虑不等长的情况
    cur.next = l1 ? l1 : l2;
    return dum.next
};

解法二:分治算法 + 递归

抽象模型:

  • 返回值:连接好的排完序的子链表。
  • 调用单元:定义一个头节点 dum,获取两个子链表中较小的节点,将 dum 指向它,dum.next 则指向接下来的子链表。
  • 终止条件:当 l1 或 l2 为没有节点的时候,前者返回 l2,后者返回 l1。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(n)

代码实现:

/**
 * @param {ListNode} l1
 * @param {ListNode} l2
 * @return {ListNode}
 */
var mergeSubLists = function (l1, l2) {
    if (!l1) return l2;
    if (!l2) return l1;

    let dum = new ListNode();
    if (l1.val < l2.val) {
        dum = l1;
        dum.next = mergeSubLists(l1.next, l2);
    } else {
        dum = l2;
        dum.next = mergeSubLists(l1, l2.next);      
    }
    return dum;
}

var mergeTwoLists = function(l1, l2) {
    if (!l1 && !l2) return null;

    let head = new ListNode();
    head = mergeSubLists(l1, l2);
    return head;
};

4. 删除链表的节点

leetcode-cn.com/problems/sh…

题目描述:

给定单向链表的头指针和一个要删除的节点的值,定义一个函数删除该节点。

返回删除后的链表的头节点。

示例:

输入: head = [4,5,1,9], val = 5

输出: [4,1,9]

解法一:迭代

思路:

定义一个哨兵节点 dum 和指针 cur。其中,dum.next 指向 head,cur 指向 dum。每次迭代,判断 cur.next.val 是否等于 val,等于则令 cur.next 指向 cur.next.next,返回 dum.next,否则移动 cur。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
 * @param {ListNode} head
 * @param {number} val
 * @return {ListNode}
 */
var deleteNode = function(head, val) {
    let dum = new ListNode();
    dum.next = head;
    let cur = dum;
    while(cur) {
        if (cur.next.val === val) {
            cur.next = cur.next.next;
            return dum.next;
        }
        cur = cur.next;
    }
    return dum.next;
};

解法二:递归

抽象模型:

  • 返回值:删除该节点后的子链表
  • 调用单元:重新对链表进行赋值,即 head.next 指向删除后的子链表
  • 终止条件:节点的 val 等于要删除的节点的 val,返回节点的 next

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(n)

代码实现:

/**
 * @param {ListNode} head
 * @param {number} val
 * @return {ListNode}
 */
var deleteNode = function(head, val) {
    if (head.val === val) return head.next;

    head.next = deleteNode(head.next, val);
    return head;
};

5. 判断链表是否有环

leetcode-cn.com/problems/li…

题目描述:

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

示例:

输入:head = [3,2,0,-4], pos = 1

输出:true

其中 pos 代表环的入口,如果为 -1 则代表没环

解法一:Set 判重

思路:

定义一个指针 cur 和 set 集合。每次迭代,用 set 来记录该节点,如果在节点在 set 中已存在则返回 true,否则继续。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(n)

代码实现:

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function(head) {
    let cur = head, set = new Set();
    while(cur) {
        if (set.has(cur)) {
            return true
        }
        set.add(cur)
        cur = cur.next
    }
    return false
};

解法二:快慢指针

思路:

定义两个指针 fast 和 slow。每次迭代,fast 移动两个节点,slow 移动一个节点,判断 fast 是否等于 slow。

我们可以这样理解快慢指针,两个人(A、B)同时起跑,A 以 B 两倍的速度跑,那么 A 和 B 肯定会相遇,并且第一次相遇是 A 刚好超过 B 一圈。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function(head) {
    let fast = head, slow = head;
    while(slow && fast && fast.next) {
        slow = slow.next
        fast = fast.next.next
        if (slow === fast) {
            return true
        }
    }
    return false
};

解法三:JSON.stringify() 循环引用

思路:

JSON.stringify 转化存在循环引用的对象的时候会抛出异常。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(n)

JSON.strigify 内部是一个不断递归的过程,不过性能极差...

代码实现:

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function(head) {
    try {
        JSON.stringify(head)    
    } catch(e) {
        return true
    }
    return false
}

解法四:手动标示节点是否为访问过

思路:

与 Set 记录不同的是,它是通过直接在节点上绑定一个属性 flag 来标识是否访问过该节点。即每次迭代,判断该节点是否存在 flag 属性(为 true),是则返回 true,不是则标识 flag 为 true。

究其本质和 Set 大同小异,但是这种写法改变了节点的结构,理论上是不能改变的。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function(head) {
    while (head) {
        if (head.flag) {
            return true
        }
        head.flag = true
        head = head.next
    }
    return false
}

6. 判断链表是否有环 II

leetcode-cn.com/problems/li…

题目描述:

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

这题是判断链表是否有环的升级版,即不仅要判断是否有环,还需要找到环的入口。

示例:

输入:head = [3,2,0,-4], pos = 1

输出:返回索引为 1 的链表节点

解法一:Set 集合判重

思路:

定义一个 set 集合。每次迭代,判断 set 中是否有这个节点,没有则存入 set,然后移动 head。

Set 的解法可以很简单的解决这个问题,因为移动 head 的过程,第一次在 Set 中找到已存在的节点,那么肯定是环的入口节点。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(n)

代码实现:

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var detectCycle = function(head) {
    let set = new Set();
    while(head) {
        if (set.has(head)) {
            return head
        }
        set.add(head)
        head = head.next
    }
    return null
};

解法二:快慢指针

思路:

同样地,也可以使用快慢指针解决这个问题,但是快慢指针相遇只能代码它有环,因为此时相遇的节点并不一定是环的入口节点!

快慢指针解这道题需要两个步骤。首先,用快慢指针判断是否有环。有环,则需要找到环的入口节点,而这个过程需要借助方程理解:

假设头节点到入环点距离为 a,快指针走了 x 圈、慢指针走了 y 圈,它们在环内的某个节点相遇,并且从入环点到它距离为 b,从它到入环点距离为 c,那么快、慢指针分别对应走过的路程为:

快指针:s(fast) = a + x(b + c) + b 慢指针:s(slow) = a + y(b + c) + b

由于快指针的速度是慢指针速度的两倍,所以 s(fast) = 2s(slow),则有:

a + x(b + c) + b = 2[a + y(b + c) + b]

我们再对它化简一下:

(b + c)(x - 2y) = a + b

那么,很显然当快慢指针相遇的场景是快指针比慢指针多走了 n 圈,假设此时 n = 1,即 x - 2y = 1,所以有:

a = c

而 c 刚好是此时快慢指针距离入环节点的距离,因此,我们定义一个指针 temp 指向头节点,不断移动慢指针和 temp,直至两者相遇,就找到了入环节点。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
 * @param {ListNode} head
 * @return {ListNode}
 */
var detectCycle = function(head) {
    let fast = head, slow = head;
    while(slow && fast && fast.next) {
        fast = fast.next.next;
        slow = slow.next;
        if (fast === slow) {
            let temp = head;
            while(temp !== slow) {
                temp = temp.next;
                slow = slow.next;
            }
            return temp;
        }
    }
    return null;
};

解法三:手动标示节点是否被访问过

思路:

在判断链表时已经讲解了思路,这里就不再论述,同上。

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
 * @param {ListNode} head
 * @return {boolean}
 */
var hasCycle = function(head) {
    while(head) {
        if (head.flag) {
            return head
        }
        head.flag = true
        head = head.next
    }
    return null
}

7. K 个一元组翻转链表

leetcode-cn.com/problems/re…

题目描述:

给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。

示例:

链表:1->2->3->4->5

k = 2 时,返回: 2->1->4->3->5

k = 3 时,返回: 3->2->1->4->5

解法一:递归

思路:

这道题逻辑较多,需要分为四个步骤进行:

  • 定义一个哨兵节点 dum 和指针 prev。其中,dum.next 指向 head,prev 指向 dum

  • 迭代 head,首先定义一个指针 tail 指向 prev,不断移动 tail 判断当前链表的长度是否大于等于 k,是则此时 tail 指针会指向 k 节点形成的子链表的尾节点,否则直接返回原来的链表 dummy.next(因为此时没有 k 个节点,原样返回)

  • 定义 myReverse 函数,用于反转具备 k 组节点的子链表,该函数接收两个参数 head 和 tail,函数会交换两个参数的位置返回(即交换首位节点指向)。和简单的反转链表不同的是,这个子链表的尾节点并不是 null,而是 tail。所以,我们需要定义两个指针,prev 指向 tail.next,然后 p 指向 head,每次迭代用一个临时指针 temp 指向 p.next,然后 p.next 指向 prev,prev 移动到 p,p 移动到 temp,直至 prev 等于 tail 则退出迭代

  • 反转完子链表后,需要将子链表接入原来它在链表中的位置。首先定一个指针 temp 指向 tail.next,然后将 prev.next 指向 head,tail.next 指向 temp。其次,移动指针,即 prev 等于 tail,head 等于 temp

复杂度分析:

时间复杂度:O(n)

空间复杂度:O(1)

代码实现:

/**
 * @param {ListNode} head
 * @param {number} k
 * @return {ListNode}
 */
const myReverse = (head, tail) => {
    let prev = tail.next;
    let p = head;
    // 移动 prev
    while(prev !== tail) {
        let temp = p.next;
        p.next = prev;
        prev = p;
        p = temp;
    }
    return [tail, head]
}
var reverseKGroup = function(head, k) {
    const dum = new ListNode(0);
    dum.next = head;
    let prev = dum;

    while(head) {
        let tail = prev;
        // 匹配 k 组
        for(let i = 0; i < k; ++i) {
            tail = tail.next
            if (!tail) {
                return dum.next;
            }
        }
        let temp = tail.next;
        // 反转 k 组子链表
        [head, tail] = myReverse(head, tail)
        // 将子链表接回原链表中
        prev.next = head;
        tail.next = temp;
        prev = tail;
        head = temp;
    }
    return dum.next;
};

结语

公欲善其事,必先利其器。正如文章题目所言,我是从零开始刷 LeetCode 的,这个过程需要克服很多东西...不过,回归本质,只要坚持保持一颗学习的心,一切将会水到渠成。而接下来,我也会花很多时间写《小白也可以玩转算法》系列的文章。如果,文章中可能存在不当或错误的表达,欢迎各位同学提 Issue,共同进步😊~

❤️ 爱心三连击

写作不易,可以的话麻烦点个赞,这会成为我坚持写作的动力!!!

我是五柳,喜欢创新、捣鼓源码,专注于 Vue3 源码、Vite 源码、前端工程化等技术分享,欢迎关注我的微信公众号:Code center