前端算法小结 | 链表篇

1,545 阅读17分钟

写在前面

这是本系列小结的第三篇,今天来讲讲数据结构中的链表。

大家如果刚好与我一样正在学(努)习(力)算(刷)法(题),不妨关注下我的专栏: 龙飞的前端算法小结

我们可以一起探讨,一起学习~

前端开(mian)发(shi)需要掌握的几种数据结构

还是先罗列一下前端开发所需要的掌握的数据结构:

  • 数组
  • 队列
  • 链表
  • 树(二叉树)

本文将跟大家一起聊聊链表。

链表

链表常常用来与数组作比较。它与数组相似,但是最大的区别在于它是一种非连续的存储结构。数组在内存中一般是占据一段连续的存储空间,而链表中的节点则是分散在存储空间的各个角落里。

对于链表中的节点,我们一般需要关注它以下两部分内容:

  1. 该节点本身的数据
  2. 该节点的指针指向的下一个节点

在JS中我们常常这样表示链表的数据结构:

{
    value: 'aaa',
    next: {
        value: 'bbb',
        next: {...}
    }
}

为了确保链表的起始节点可以被访问,我们有时还会设定一个 head 指针来专门指向链表的开始位置

JS 中链表的常规操作

创建

链表的创建关键在于链表节点的创建:

class ListNode {
    // 链表节点本身的数据
    value: any;
    // 链表节点的指针指向
    next: ListNode | null;
    
    constructor (val: any) {
        this.value = val;
        this.next = null;
    }
}

// 创建新节点
const node = new ListNode('aaa');

添加与删除

在链表中,无论是添加还是删除节点,其实都是在对节点 next 指针进行操作。

添加链表节点分为两种情况:

在链表后面添加节点

这种场景比较简单,不过就是将 next 指向新的节点

// ...
const node = new ListNode('aaa');
node.next = new ListNode('bbb');

在两个节点中间插入节点

在两个已有的节点中间插入新的节点,其实就是把第一个节点的 next 指向新的节点,再把新节点的 next 指向第二个节点

const node1 = new ListNode('aaa');
const node2 = new ListNode('bbb');
node1.next = node2;

// 在 node1 node2 中间插入 node3
const node3 = new ListNode('ccc');
node3.next = node1.next;
node1.next = node3;

删除节点

删除节点其实就是把被删除节点的前一个节点的next,指向被删除节点原本的next(即被删除节点原本的下一个节点)

node1.next = node3.next;

// 在只有 node1 的情况下也可以实现
const temp = node1.next;
node1.next = temp.next;

访问节点

链表节点的访问就不像节点操作那么轻松了。你必须要明确你要找的是第几个节点,然后再遍历链表去获取到该节点

const target = 3;

for (let i = 0; i < target && node; i++) {
    node = node.next;
}

// return node;

小结

由上述可知:

  1. 链表中操作节点的复杂度是 O(1),因为它只需要改变被操作节点前后节点的指针指向,不涉及到其他节点的操作
  2. 链表中访问节点的复杂度是 O(n),因为它不像数组一样可以通过下标直接访问节点,而是需要遍历链表才能找到指定的节点

JS 中链表的骚操作

哨兵节点

通常,我们对链表节点的操作都是对节点本身以及其 next 指针的操作。但是在某些特殊场景下,我们还需要对当前节点的前驱节点进行操作。

这中情况下,为了确保链表的第一个节点也能按我们预期的算法去运行,我们就必须要为其创造一个前驱节点。这个思路与上一节 栈和队列篇 中介绍过的哨兵的原理是一样的,因此我也称之为 哨兵节点。(在很多题解中也称之为 dummy 节点)

// ...
// 创建哨兵节点
const sentry: ListNode = new ListNode();
// 将其做为第一个节点的前驱节点
sentry.next = head;
// 从哨兵节点开始运行
let curNode = sentry;
// ...

快慢指针

快慢指针其实是双指针的其中一种形态。通常情况下,我们借助双指针是为了解决一些比较耗时的操作,比如需要多次遍历的操作,可以借助双指针一次遍历搞定。

而在链表中,比较耗时的操作就是涉及到与链表位置相关的操作了:比如删除指定位置的节点 反转指定位置的链表等等。当遇到这种题时,我们要下意识的往双指针的方向去思考。

快慢指针特指同时从同一方向出发(链表第一个节点出发)的两个指针,其中慢指针走的慢一些(一次向后走一个节点),而快指针走的快一些(一次向后走 n 个节点,n 视情况而定)

const sentry: ListNode = new ListNode();
sentry.next = head;

// 快慢指针初始位置都是第一个节点
let fast: ListNode = sentry;
let slow: ListNode = sentry;

while (fast && fast.next) {
    // ...
    fast = fast.next.next;
    slow = slow.next;
}

反转链表

"我直接反转链表"

相信刷过题的各位都看到过这句话哈哈。这充分说明了反转链表在链表题中的地位。

反转链表顾名思义,就是操作一个链表中某段区间内的结点,把这些节点间的 next 指针指向进行反转,将原本指向下一个节点的指针改为指向其前驱结点。

A-> B -> C ===> A <- B <- C

而反转一个链表,我们必须知道三个结点:

  1. 当前结点(currentNode)
  2. 当前结点的前驱结点(preNode)
  3. 当前结点的后继结点(nextNode)

常规的反转操作如下:

let preNode = null;
let currentNode = head;
let nextNode = currentNode.next;

currentNode.next = preNode;
preNode = currentNode;
currentNode = nextNode;

而反转链表的操作远不止这些,这后面的真题解析中我会继续深入反转链表。

小结

链表相关的题目,几乎 90% 的题解思路都逃不出上述的 常规操作 & 骚操作。

下面就让我们通过真题解析,看看是如果在真题中运用这些操作的。

真题解析

合并两个有序链表

序号:21

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

示例1:

输入: l1 = [1,2,4], l2 = [1,3,4]
输出: [1,1,2,3,4,4]

合并两个升序链表,其实就是创建一个新链表,然后根据两个升序链表节点的大小顺序,将它们依次添加到新链表中。

而添加链表节点,要处理的就是 next 指针的指向问题:

  1. 首先,创建一个新链表,我们需要创建一个 head
  2. 同时遍历 l1 和 l2 进行大小比较:当 l1 的值小于 l2 时,新链表 的 next 将指向 l1,同时再拿 l1 的 next 和 l2 做比较
  3. 直到 l1 和 l2 中有一个被遍历完全时,退出遍历
  4. 剩余还没遍历完全的 list,直接整个插入到新链表的 next 即可
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function mergeTwoLists(list1: ListNode | null, list2: ListNode | null): ListNode | null {
    // 通常,我们需要创建一个 head
    const head: ListNode = new ListNode();

    let currentNode: ListNode = head;

    while(list1 && list2) {
        if (list1.val <= list2.val) {
            // next 指向值更小的节点
            currentNode.next = list1;
            list1 = list1.next;
        } else {
            currentNode.next = list2;
            list2 = list2.next;
        }

        currentNode = currentNode.next;
    }

    // 完成遍历后,如果还有 list 没遍历完,则将其插入到 next 即可
    currentNode.next = list1 !== null ? list1 : list2;
    return head.next;
};

环形链表

序号:141

给你一个链表的头节点 head ,判断链表中是否有环。

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

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

示例:

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

题目很好理解,当一个链表为环形链表的时候,那么这个列表是一直循环,没有终点的。

前面我们提到过快慢指针,这道题就是快慢指针的经典应用之一。如果一个链表是环形链表,那么当快指针和慢指针从 head 节点出发后,在后续的某一个时刻,它们肯定会再次相遇。

具体实现如下:

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function hasCycle(head: ListNode | null): boolean {
    let slow: ListNode = head;
    let fast: ListNode = head;

    while (fast && fast.next) {
        if (slow.next === fast.next.next) {
            return true;
        }

        slow = slow.next;
        fast = fast.next.next;
    }

    return false;
};

反转链表

序号:206

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。 

示例:

image.png

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

这道题就是最基础的反转链表了,我们要实现的是整个链表的反转。按照前面提到的套路,可以轻松实现:

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function reverseList(head: ListNode | null): ListNode | null {
    let preNode: ListNode = null;
    let currentNode: ListNode = head;

    while (currentNode) {
        let nextNode = currentNode.next;
        currentNode.next = preNode;
        preNode = currentNode;
        currentNode = nextNode;
    }

    return preNode;
};

回文链表

序号:234

给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false 。

示例 1:

输入: head = [1,2,2,1]
输出: true

示例 2:

输入: head = [1,2]
输出: false

回文链表其实就是前半部分与后半部分完全相同的一种链表。如果我们能将链表分为前后俩部分,然后再反转前半部分链表,最后再拿前半部分反转后的链表与后半部分的链表的结点一一进行比较,即可得出该链表是否为回文俩表

所以这道题的关键在于以下两点:

  1. 如何定位前半部分链表?
  2. 如何反转前半部分链表?

答案依然是前面提到过的 快慢指针 + 反转链表 骚操作了。

利用快慢指针,假定快指针一次走两步,慢指针一次走一步,那么当快指针走完最后一个结点时,慢指针所在的位置就是链表前半部分的结尾位置。

然后再利用反转链表,把链表前半部分进行反转,反转后再与链表后半部分进行比较。

具体实现如下:

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function isPalindrome(head: ListNode | null): boolean {
    if (!head) return true;

    let fast: ListNode = head;
    let slow: ListNode = head;
    let preNode: ListNode | null = null;

    while (fast && fast.next) {
        // 快指针一次走两步
        fast = fast.next.next;
        
        // 反转前半部分链表
        let nextNode = slow.next;
        slow.next = preNode;
        preNode = slow;
        slow = nextNode;
    }

    // 当 fast 结点不为 null 时,表示输入链表结点数为奇数个,此时 slow 需要往后走一个结点再进行比较
    if (fast) {
        slow = slow.next;
    }

    // 剩余的链表与反转后的链表进行结点一一比较
    while (preNode && slow) {
        if (preNode.val !== slow.val) {
            return false;
        }

        preNode = preNode.next;
        slow = slow.next;
    }

    return true;
};

相交链表

序号:160

给你两个单链表的头节点 headA 和 headB ,请你找出并返回两个单链表相交的起始节点。>如果两个链表不存在相交节点,返回 null 。

图示两个链表在节点 c1 开始相交:

image.png

这道题题意不难理解,同样的我们可以使用双指针来解决。这次用到的不是快慢指针,而是两个同步的指针,分别从 headA 和 headB 出发。

指针1 遍历完 headA 后,就遍历 headB;同样的,指针2 遍历完 headB 后,就遍历 headA。这样当 指针1 和 指针 2 相等的时候,代表此时就是 headA 和 headB 相交的结点。

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function getIntersectionNode(headA: ListNode | null, headB: ListNode | null): ListNode | null {
    if(headA === null || headB === null) {
        return null;
    }

    let p1: ListNode = headA;
    let p2: ListNode = headB;

    while(p1 !== p2) {
        p1 = p1 === null ? headB : p1.next;
        p2 = p2 === null ? headA : p2.next;
    }

    return p1;
};

删除链表的倒数第 N 个节点(medium)

序号:19

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

示例 1:

image.png

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

示例 2:

输入:head = [1], n = 1
输出:[]

首先这道题的目标很直接:删除某个结点。

那思路就很明确了,首先我们要找到被删除结点的前置结点,接下来就是把前置结点的 next 指针向被删除结点的 next 指针即可。

那么如何找到被删除结点的前置结点?

这个场景下快慢指针又可以闪亮登场了~

  1. 快指针先向前走 n 步,慢指针仍在 head 结点处;
  2. 然后快慢指针同时出发,当快指针遍历结束时,此时慢指针所在位置就是倒数第 n+1 的位置,即被删除结点的前置结点
  3. 最后就是慢指针所在结点的 next 指针向后移一位
PS: 为了能覆盖到 head 结点被删除的情况,这里不要忘了使用哨兵结点哦
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
    // 设置哨兵结点,确保 head 结点被删除场景下也能正常运行
    let sentry: ListNode = new ListNode();
    sentry.next = head;
    let fast: ListNode = sentry;
    let slow: ListNode = sentry;
    
    // 快指针先走 n 步
    while (n) {
        fast = fast.next;
        n--;
    }

    // 快慢指针一起走,直到快指针走完
    while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next;
    }

    slow.next = slow.next.next;

    return sentry.next;
};

环形链表2(medium)

序号:142

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

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

不允许修改 链表。

示例 1:

image.png

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

这道题是前面环形链表题目的升级版,我们前面的快慢指针解题方法只能确定这是一个环形链表,而不能确定环的起点。

题目中特别强调了不允许修改链表。这是为什么呢?我们先来看下如果允许修改链表,可以怎么实现:

我们只需要给遍历过的结点加上一个 flag,标识它已经被遍历过一次。那么等到我们第一次遍历到存在 flag 的结点时,就表明它将被第二次遍历,即该结点就是环的起点。

/**
* @param {ListNode} head
* @return {ListNode}
*/
const detectCycle = function(head) {
    while(head) {
        if (head.flag) {
            return head;
        } else {
            head.flag = true; 
            head = head.next; 
         } 
    }
  return null;
};

当然,这个解法违背了题目的不允许修改链表的要求,同时也不满足 ts 的要求(因为根据 ListNode 的 ts 定义,它只有 val 和 next 两个属性),因此是行不通的。

在这里再次强烈建议各位,养成使用 ts 的好习惯

那么如果继续沿用快慢指针的思路,这道题应该怎么下手呢?

我们先假定几个参数:

  1. head 结点到 入环结点 的结点距离为 a
  2. 快慢指针第一次相遇时,入环结点 到 慢指针所在结点 距离为 b
  3. 快慢指针第一次相遇时,慢指针 再走回到 入环结点 的距离为 c

以下图为例子:

image.png 初始时,快慢指针都在 head 处,此时只可知 a = 2; 同样的,慢指针每次向前走一步,快指针每次向前走两步,等到它们第一次相遇时:

image.png

如图所示,此时快慢指针在结点 4 相遇,此时 b = 1; c = 2

我们再来看此时快慢指针分别走过的距离:

  1. slow 走过的距离为 a + b
  2. fast 走过的距离为 a + b + c + b

而因为 fast 指针所走距离是 slow 指针的两倍,因此可得: a + b + c + b = 2(a + b)

继而可以得出 a = c

这意味着此时如果有一个指针从 head 结点出发,同时 slow 从快慢指针第一次相遇的结点出发,那么等到这两个指针相遇时,该节点必定就是入环的结点了。

因此我们的算法可以这么设计:

  1. 快慢指针同时遍历,当它们第一次相遇时,推出遍历
  2. 此时将快指针重新指回 head 结点,慢指针不变
  3. 快慢指针再次同时出发,这次两个指针都是每次只走一步
  4. 直到它们再次相遇时,此时的结点就是入环的结点了
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function detectCycle(head: ListNode | null): ListNode | null {
    let fast: ListNode = head;
    let slow: ListNode = head;

    while (fast && fast.next) {
        slow = slow.next;
        fast = fast.next.next;

        if (slow === fast) {
            break;
        }
    }

    // 考虑非环的场景
    if (!fast || !fast.next) {
        return null;
    }

    fast = head;

    while (fast !== slow) {
        fast = fast.next;
        slow = slow.next;
    }

    return fast;
};

反转链表2(medium)

序号:92

给你单链表的头指针 head 和两个整数 left 和 right ,其中 left <= right 。请你反 转从位置 left 到位置 right 的链表节点,返回 反转后的链表 。

示例 1:

输入: head = [1,2,3,4,5], left = 2, right = 4
输出: [1,4,3,2,5]

这道题也是前面反转整个链表的升级版,只要求反转某个区间的链表。反转的思路和前面是一模一样的,而这题的关键在于如何将反转后的链表,与反转区间前、后两个结点连接起来。

因此,我们在进行反转之前,要先将反转区间的前一个结点暂存起来,待反转结束后,将该结点的 next 指针指向反转后的链表。

而同时我们还需要暂存反转区间的第一个结点,等到反转结束后,需要将其 next 指针指向反转区间的后一个结点。

具体实现如下:

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function reverseBetween(head: ListNode | null, left: number, right: number): ListNode | null {
    // 确保 head 反转能正常运行,需要设置哨兵结点
    let sentry: ListNode = new ListNode();
    sentry.next = head;

    // 获取反转区间的前一个结点
    let preReverseNode: ListNode = sentry;
    for (let i = 0; i < left -1; i ++) {
        preReverseNode = preReverseNode.next;
    }

    // 暂存区间起始结点,用于最后连接区间后一个结点
    let temp: ListNode = preReverseNode.next;
    
    // 反转区间链表
    let preNode: ListNode = preReverseNode.next;
    let currentNode: ListNode = preNode.next;
    for (let i = left; i < right; i ++) {
        let nextNode: ListNode = currentNode.next;
        currentNode.next = preNode;
        preNode = currentNode;
        currentNode = nextNode;
    }

    // 反转完成后,反转区间前一个结点 next 指针指向反转后的链表
    preReverseNode.next = preNode;
    // 暂存的区间起始节点 next 指针指向区间后一个结点 
    temp.next = currentNode;
    
    return sentry.next;
};

两两交换链表中的节点(medium)

序号:24

给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。

示例 1:

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

示例 2:

输入: head = []
输出: []

这道题与前面的链表操作很不相同,它是有规律的把相邻两个节点进行交换,这种有规律的算法就非常适合用递归来实现。

递归最重要的就是你所要重复的事情,而在这道题目里我们要重复做的就是:

  1. 把 head 的 next 指针指向其 next 指针的 next
  2. 把 head 的 next 指针的 next 指针指回 head

而递归终止的条件就是 head 或者 head 的 next 为空的时候,即没法进行两两交换的时候。

完整代码如下:

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

function swapPairs(head: ListNode | null): ListNode | null {
    if (head === null || head.next === null) {
        return head;
    }
    let nextNode: ListNode = head.next;
    head.next = swapPairs(nextNode.next);
    nextNode.next = head;
    return nextNode;
};

合并 k 个升序链表(hard)

序号:23

给你一个链表数组,每个链表都已经按升序排列。

请你将所有链表合并到一个升序链表中,返回合并后的链表。

示例 1:

输入:lists = [[1,4,5],[1,3,4],[2,6]]
输出:[1,1,2,3,4,4,5,6]
解释:链表数组如下:
[
  1->4->5,
  1->3->4,
  2->6
]
将它们合并到一个有序链表中得到。
1->1->2->3->4->4->5->6

示例 2:

输入:lists = []
输出:[]

示例 3:

输入:lists = [[]]
输出:[]

在最开始我们就讲过一道题,叫做合并两个有序链表。而这道题就是把 2 变成了 k,有了前面那道题的思路,这道题显然并没有那么 hard。

我们只需采取分治的思路,遍历 lists,并按顺序将链表两两进行合并,最终就能得到一个升序的链表。

当然了,不要忘了兼容 lists 长度小于 2 的场景哦~
/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */
 
// 前面第一道题就讲过的合并两个升序链表
// function mergeTwoLists(list1: ListNode | null, list2: ListNode | null): ListNode | null 

function mergeKLists(lists: Array<ListNode | null>): ListNode | null {
    // 提前截断 lists 小于 2 的情况
    if (!lists.length) {
        return null;
    }
    if (lists.length < 2) {
        return lists[0];
    }

    // 暂存第一个链表,从第二个开始遍历
    let temp: ListNode = lists[0];
    for (let i = 1; i < lists.length; i ++) {
        // 更新两两合并后的结果
        temp = mergeTwoLists(temp, lists[i]);
    }

    return temp;
};

k 个一组翻转链表(hard)

序号:25

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

k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。

你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。

示例 1:

image.png

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

这道题属于反转链表的升级版,我们要做的是按顺序对每 k 个节点进行一次反转。

既然是这种按区间的反转,那么首先我们就需要记录区间的前驱结点 pre 以及后继结点 next。

其次我们还需要直到区间的结尾结点 end。因为当 end === null 时,代表已经到达末尾,就可以结束循环了。

这里还有一个小技巧:当我们在处理一个区间的反转时,可以先将该区间与下一个区间隔离(即 end.next = null)。这样在处理反转时就无需要过多关心区间之间的指针关系。只需要在开始反转之前记录下区间的起点 start,在反转结束后再把 start.next 指向原本的 end.next 即可。

/**
 * Definition for singly-linked list.
 * class ListNode {
 *     val: number
 *     next: ListNode | null
 *     constructor(val?: number, next?: ListNode | null) {
 *         this.val = (val===undefined ? 0 : val)
 *         this.next = (next===undefined ? null : next)
 *     }
 * }
 */

// 反转链表
// function reverseList(head: ListNode | null): ListNode | null 

function reverseKGroup(head: ListNode | null, k: number): ListNode | null {
    const sentry: ListNode = new ListNode();
    sentry.next = head;

    let pre: ListNode = sentry;
    let end: ListNode = sentry;

    while(end.next !== null) {
        // end 走到区间结束的位置 
        for (let i = 0; i < k && end !== null; i ++) {
            end = end.next;
        }
        if (end === null) break;

        // 暂存 start 结点 以及 end.next
        let start: ListNode = pre.next;
        let next: ListNode = end.next;

        // 区间隔离
        end.next = null;
        
        // 反转区间内链表
        pre.next = reverseList(start);
        
        // 连接区间
        start.next = next;

        // 进行下一个区间反转处理
        pre = start;
        end = start;
    }

    return sentry.next;
};

写在最后

其实链表相关的算法题是非常独立的一个种类,绝大部分都可以不依赖其他数据结构来实现。

链表说白了就是要想清楚结点及其指针的指向关系,只要理清了,就没有解不开的链表题。

只要对上面总结过的几种套路足够熟悉(哨兵结点、多指针、反转链表等等),相信大家以后遇到链表的题目都可以顺利迎刃而解了~