用俄罗斯套娃解「单链表反转」类问题


涉及LeetCode题目:206. 反转链表25. K 个一组翻转链表;(英文都是reverse,为啥中文翻译一个是“反”一个是“翻”?)

所有题目默认的先决条件:已定义了链表节点类ListNode,类成员有val和指向下一个节点的next变量(指针)。

反转链表

完整题目描述劳驾跳转至LeetCode阅读,这里不再重复。图中数字可视为每个节点的val值。
在这里插入图片描述

迭代

反转链表题,一般很快能想到的题解是迭代,使用while配合prev, cur指针进行线性迭代的方式来修改每个节点的next指向。不是本文重点,一笔带过。

function reverse(head) {
    if (!head) return head;
    let prev = null, cur = head;
    while (cur) {
        const next = cur.next;
        cur.next = prev;
        prev = cur;
        cur = next;
    }
    return cur;
}

递归

个人觉得理解比较吃力的是利用递归来解决问题。对,标题里的俄罗斯套娃就是它!后面会详解。

递归粗浅理解,即一个函数执行时内部再调用执行自身,将一个大问题,分解成多个同样的子问题。写一个递归函数,主要有3点要确定:入参、退出调用函数自身的条件、出参

假设当前节点用cur(指针)变量指向,在反转链表中,

  • 为了让cur指针沿链表前行,递归的入参,就是当前节点的下一个节点,cur.next;
  • 退出条件,当前节点如果没有后续节点,就无法再向下递归,即cur.next不存在就不再调用函数自身。
  • 出参是什么?我们整个函数最终的目的是返回反转后链表的头节点,即原链表的最后一个节点(记为last)。那就是在上面说的退出条件那一步时返回last节点,而它的上一层递归也要把它返回出去,即每层递归直接返回内部自身函数执行的结果。

此时得到函数,

function reverse(node) {
    let cur = node;
    if (!cur.next) return cur;
    const last = reverse(cur.next); // 第α行
    return last;
}

接着要解决反转这个动作,即让节点的next指针指向上一个节点。

当前函数中,已有cur指向的节点,那如何拿到它的上个节点?作为单链表节点,貌似拿不到上一个,但能拿到后续节点们。它的下个节点即是cur.next,我们能先反转这个节点,即将它的next指向当前节点cur。可得代码:cur.next.next = cur;

接着出现了新问题,这行代码应该放在第几行?

递归执行和将一个完整嵌套的俄罗斯套娃整个拆开的过程一样。

(P.S.本来想举例大家常说的洋葱模型,但突然意识到这个比喻有问题啊,谁会没事把洋葱一层层剥开后再一层层包起来?)(又P.S.本来为自己能想出套娃这个比喻而感到沾沾自喜,但搜索引擎一搜,发现已有一堆前辈们比喻过了,哭。)

一个娃分上半身下半身2部分,假设每次只能拿一个部分。最开始拿的是最外层的上半部分,接着是第二大的上半部分……直到最小的上半身,然后最里面有个最小的不能拆分的娃,它就相当于退出递归逻辑的那段小小的代码。接着开始拿最小的下半身、次小的下半身……最后到最大的下半身。

回到上面那个半成品代码块,以注释“第α行”为分界,它的上半段代码就是套娃的上半部分,在递归过程中,所有的α行上半部分代码(包括reverse(cur.next)这句)执行完毕后,才会开始执行到α行下面的代码。

再回到修改指针的代码放在第几行这个问题,问题就转变成了代码应该插入到α行的上面还是下面。

这个通过模拟假设就能推出。如果代码放在α行的上面,从头开始执行,会出现1、2两个节点互指,形成死循环,相当于不断的在拿放最外层俩套娃的上半身。

STEP1: 1(cur)—>2—>… => STEP2: 1<—>2(cur) …

所以只能放在α行的下面。

当前代码更新为:

function reverse(node) {
    let cur = node;
    if (!cur.next) return cur;
    const last = reverse(cur.next); // 第α行
    cur.next.next = cur;
    return last;
}

接着我们从头开始完整执行一遍函数,头节点(val为1)作为参数传入。类比拆解俄罗斯套娃,从外向内开始不断拿上半身(执行上半段代码),想象几个关键节点:

当拿取到最里层最小不可拆分娃时对应的链表处于什么情况?
即代码执行完了所有递归的上半段,到了退出递归逻辑的位置,此时cur变量(指针)指向最后一个结点,并正要返回它。

1—>2—>3—>4—>5(cur)—>null

拿取的第一个下半身,对应的链表处于什么情况?
此时调用栈第一次执行到下半段代码,last变量被赋值为最小不可拆娃返回的节点,即val为5的节点,cur指向val为4的节点。执行cur.next.next = cur后,

1—>2—>3—>4(cur)<—>5(last) x null

接着就是不断的一层层从内向外取出下半身。

1—>2—>3(cur)<—>4<—5(last)

1—>2(cur)<—>3<—4<—5(last)

1(cur)<—>2<—3<—4<—5(last)

最终执行到最大一个下半身,此时cur是val为1的节点,但我们发现它的next并没被修改,导致链表循环引用了。此刻我们期望cur.next = null; 那是否有必要增加执行条件——必须是原头节点才执行该代码呢?通过代入演绎执行,结果是没必要。

根据LeetCode测试用例集中经常会有空节点的用例情况,代码还需增加一条对空节点的判断处理。所以整理下最终代码差不多是这样:

function reverse(node) {
    let cur = node;
    if (!cur || !cur.next) return cur;
    const last = reverse(cur.next);
    cur.next.next = cur;
    cur.next = null;
    return last;
}

俄罗斯套娃小结

写一个递归函数要根据最终目的确定好入参、出参、以及退出递归的条件;

整个代码的执行分为上下2部分,分界点是调用执行函数自身的那条代码语句。所有的上半部分执行完才会开始执行下半部分,所以最里层最小不可拆分娃(后文简称为“最小娃”)及第一个最小下半身是关键(即退出递归条件及其后面的第一次下半段执行),写代码时可以将他们作为突破口。

K个一组翻转链表

接着我们尝试用俄罗斯套娃解K个一组翻转链表。题目描述见链接

示例,k=2时,
在这里插入图片描述
k=3时,
在这里插入图片描述
第一步,根据目标确定递归函数的三要素。

以k=2这个示例来分析,入参即是每组的原头节点1、3,出参即是每组新的头节点2、4。退出递归的条件——当这组节点个数小于k时直接返回该组的原头节点。

// 半成品 1
function reverseK(head, k) {
    // 如何写退出条件?
    // ...
    const newHead = reverseK(?, k); // 如何传入后续每组的头节?
    return newHead;
}
// -----------------------------------------------------------------------------
// WIP 2
function reverseK(head, k) {
    let end = head;
    for (let i = 1; i < k; i++) {
        if (!end.next) return head; // 不满足k个节点,直接返回当前的头节点
        end = end.next;
    }
    const newHead = reverseK(end.next, k);
    return newHead;
}

接着考虑如何实现反转每组节点。

将套娃操作快进到拿最小娃步骤,即进入了退出递归的条件,并返回了最后一组的头节点(val为5的节点)。下一步是拿最小下半身,(看注释为“WIP 2”的代码块)此时上下文中,newHead是节点5,head是3,end是4。开始反转,我们要将3—>4变为3<—4。

已知头尾节点,写个反转应该没啥问题吧,如本文开头的代码思路一样——利用prev、cur指针+while迭代修改cur的next指向。为了能复用,这里我们把它抽离成单独函数。

function reverse(head, end) { // 联系当前调用上下文,这里准备传入的参数分别是节点3和节点4
    let prev = null, cur = head;
    const endNext = end.next; // 为了能反转最后的end,需要借助end.next来判断
    while (cur !== endNext) {
        const next = cur.next;
        cur.next = prev; // 联系当前调用上下文,第一次进循环时cur是3,其next被改为指向null
        prev = cur;
        cur = next;
    }
    // 联系当前调用上下文,迭代反转后此时prev为4,cur为5,所以prev即是当前组的新头节点
    return prev;
}

所以reverse(3, 4)执行后,返回的prev指针指向了头节点4,当前这组链表变为:4—>3—>null (5是指向4的,此处被省略)

接着考虑的问题是这个反转函数的调用应该放在递归函数的上面还是下面。这里通过假设演绎的方式得出结论,放上放下都行。姑且放在下面。代码更新如下,

// WIP 3
function reverseK(head, k) {
    // 省略上半部分
    // ...
    const newHead = reverseK(end.next, k);
    const realNewHead = reverse(head, end);
    return newHead;
}

发现reverse返回的才是真的新头节点,reverseK返回的其实是上一组的头节点。我们修正下代码,

// WIP 4
function reverseK(head, k) {
    let end = head;
    for (let i = 1; i < k; i++) {
        if (!end.next) return head;
        end = end.next;
    }    const lastHead = reverseK(end.next, k);
    const newHead = reverse(head, end);
    return newHead;
}

接着要解决的是让当前的新尾节点连上上一组的头节点。在当前上下文中,就是让节点3的next指向5。如何拿到3?它是原先的头节点,变量head还指向着它。那翻译成代码就是 head.next = lastHead;

至此,逻辑上差不多都连上了。最后按照惯例,补上对空的判断。

最终代码,完成!!

function reverseK(head, k) {
    if (!head) return head;
    let end = head;
    for (let i = 1; i < k; i++) {
        if (!end.next) return head;
        end = end.next;
    }
    const lastHead = reverseK(end.next, k);
    const newHead = reverse(head, end);
    head.next = lastHead;
    return newHead;
}

function reverse(head, end) {
    let prev = null, cur = head;
    const endNext = end.next;
    while (cur !== endNext) {
        const next = cur.next;
        cur.next = prev;
        prev = cur;
        cur = next;
    }
    return prev;
}

结束语

本文2道题目本质都是借助递归来解决的,在解答过程中我试图将自己的思考步骤一步步展开讲解,并借助套娃比喻来描述递归行为。希望大家能通过本文加深对递归的理解。如果文章或代码有啥理解不对的地方,也请积极予以指正,谢谢彼此的认真。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值