万字长文一网打尽链表翻转相关技巧(挑战字节hard面试题)

温馨提示:

  1. 本文不会教授读者最为基础的链表常识,仅分享本人在链表翻转相关方面打怪升级的心得体会。欢迎修习过算法数据结构相关功课者或是积累有一定题量渴望提升者批评指正、共同研讨、共同进步。
  2. 本人经验水平有限,目前仍在不断充电中。网上大佬相关类似的题解有很多,本文无意模仿更无意冒犯,仅作为本人刷题总结以及学习博客书写的有效手段。如有与实际不符的标题党行为还请谅解!(笑)
  3. 本文精选LeetCode平台上的链表翻转类典型题,按照题目之间的相关性组织展示,难度逐渐增加。注重题目之间的联系,一题多解,支持用后习得的方法解决之前的问题。推崇又快又优雅地写出压轴题的大体框架,考验面试者的设计能力
  4. 本文所有题目题解均为本人二刷时独立解出(一刷在今年3月左右)。所有借鉴内容都已标明出处,保证尊重原作者意愿。

首先回顾一下408科目重要考点:逆置问题 (见天勤数据结构高分笔记)

这也是线性表翻转的元老级题目

图中的解法展现出重要的思想:

  1. 一前一后交换值,从两端向中间移动。可惜只适用于顺序存储结构,对于单链表找节点的前驱是要付出代价的。而且,LeetCode链表题大多数不允许直接交换节点的值,而是要修改其节点之间的结构-------->解决:用多个节点变量按顺序边扫描边修改(迭代),也可以选用递归方式完成反转
  2. 用reverse()封装的习惯。这也为后文的递归方法、myReverse()做铺垫。但在时间有限高度紧张的状态下还是建议中规中矩,因为封装要考虑多个子函数之间的“协议联调”。

下面来看链表的类似逆置问题。


一:LeetCode206、剑指offer24 反转链表【简单】

问题描述:

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

思路分析:

迭代:       

        在遍历链表时,将当前节点的 next 指针改为指向前一个节点。由于节点没有引用其前一个节点,因此必须事先存储其前一个节点。在更改引用之前,还需要存储后一个节点。最后返回新的头引用。没什么好说的。就是注意pre指针初始时不能指向哑结点(笑,是不是受了之前文章的影响?因为翻转过来后需要把哑结点给排除掉。那这不多此一举吗?让pre初始时为nullptr!)

初遇递归

        递归版本稍微复杂一些,其关键在于反向工作。假设链表的其余部分已经被反转,现在应该如何反转它前面的部分?假设链表为:n1→…→nk−1→nk→nk+1→…→nm→∅。若从节点 nk+1​ 到 nm​ 已经被反转,而我们正处于 nk​ 即n1→…→nk−1→nk→nk+1←…←nm​,则我们希望 nk+1​ 的下一个节点指向 nk​。所以,nk->next->next=nk​。需要注意的是 n1​ 的下一个节点必须指向 ∅。如果忽略了这一点,链表中可能会产生环。

——by 力扣官方题解

       个人理解就是一种宏观大局思想,这种思想的结构大致为如下

{
    ->判断临界条件,是递归函数返回的关键

    ->根据算法的逻辑结构(以本题为例)
      先逆置之后的节点,递归函数的接口,有返回值

    ->处理当前节点与【之后节点逆序后的返回值(递归的内容此时已经处理完毕,将封装的最终结果final传了进来)】之间的逻辑关系

    ->return final
}

但毕竟这种反向工作是有点逆我们的日常思维习惯的,所以递归不好理解。你可能独立写不出来但是一看哇塞,好有道理!弄懂也不是一件费力的事!(笑)

接下来的代码还会从栈的角度逐层带入分析

参考代码:

迭代:

class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        if(!head||!head->next)
            return head;
        ListNode *pre = null;
        ListNode *p = head;
        //aft用来记录p->next被覆盖前的内容
        ListNode *aft = nullptr;
        while(p&&p->next){
            aft = p->next;
            p->next = pre;
            pre = p;
            p = aft; 
        }
        aft = p->next;
        p->next = pre;
        pre = p;
        p = aft;
        return pre;
    }
};

递归:

/**
 * 以链表1->2->3->4->5举例
 * @param head
 * @return
 */
class Solution {
public:
    ListNode* reverseList(ListNode* head) {
        /*
          直到当前节点的下一个节点为空时返回当前节点
          由于5没有下一个节点了,所以此处返回节点5
         */
        if(!head||!head->next){
            return head;
        }
        //递归传入下一个节点,目的是为了到达最后一个节点
        ListNode *final = reverseList(head->next);
        /*
            第一轮出栈,head为5,head->next为空,返回5
            
            第二轮出栈,head为4,head->next为5,执行head->next->next=head也就是5->next=4,把当前节点的子节点的子节点指向当前节点。此时链表为1->2->3->4<->5,由于4与5互相指向,所以此处要断开4->next=null。此时链表为1->2->3->4<-5,返回节点5
            
            第三轮出栈,head为3,head->next为4,执行head->next->next=head也就是4->next=3,此时链表为1->2->3<->4<-5,由于3与4互相指向,所以此处要断开3->next=null。此时链表为1->2->3<-4<-5,返回节点5
            
            第四轮出栈,head为2,head->next为3,执行head->next->next=head也就是3->next=2,此时链表为1->2<->3<-4<-5,由于2与3互相指向,所以此处要断开2->next=null。此时链表为1->2<-3<-4<-5,返回节点5
            
            第五轮出栈,head为1,head->next为2,执行head->next->next=head也就是2->next=1,此时链表为1<->2<-3<-4<-5,由于1与2互相指向,所以此处要断开1->next=null。此时链表为1<-2<-3<-4<-5,返回节点5
            
            出栈完成,最终头节点5->4->3->2->1
         */
        head->next->next = head;
        head->next = nullptr;
        return final;
    }
};

二:LeetCode92 反转链表II【中等】

好的,趁热打铁,再来看上一题的升级版。目的是通过以上的案例彻底掌握链表翻转问题的迭代方法有的平台称为双指针或多指针方法),书写时熟练不卡壳,边界问题要能及时反馈到。

相信到这,已经有很多读者能十分清晰地理解指针之间、内存之间的关系、有目的性地设计变量和链表结构框架、熟练地debug各种细节问题,使用迭代法基本上都能在15分钟内出结果

问题描述:

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

提示:

  • 链表中节点数目为 n
  • 1 <= n <= 500
  • -500 <= Node.val <= 500
  • 1 <= left <= right <= n

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

思路分析:

上一题是完整地翻转整个链表,即left=1,right=ListNode.length,所以是一定要通过pre修改head节点的,情况比较单一。本题首先还是尝试用迭代法,规定1 <= left <= right <= n,因此可以吧[left,right]中间的部分按照上一题方式解决。多插一嘴,其实这个范围条件就已经帮我们把许多刁钻的情况给排除在外了,极大地简化了我们思考的难度。而难点在于需要多考虑寻找left前驱和right后继、尚未遍历到left位置时指针的移动方式、中间部分的逆置以及与left前驱right后继的连接“协议联调”等情况。到这,浅尝过链表题逻辑与细节的可怕之处的读者已经开始脑补代码的规模了......建议还是,整体设计思想是好事,具体细节还是走一步看一步。

初始化的工作不用多说了,只要是一路走来的,相信我们都基础扎实

if(!head || !head->next)
    return head;
ListNode *curr = new ListNode();
ListNode *cur = curr;
curr->next = head;
ListNode *node1 = cur->next, *node2 = cur->next->next, *node3 = nullptr;

接下来就是尚未遍历到left位置时指针的移动方式。这也很好想,不用耗费啥宝贵的时间的。

for(int i = 0; i <= left-2; ++i){
    node2 = node2->next;
    node1 = node1->next;
    cur = cur->next;
    }

关键是[left,right]中间部分

int sum = 0;
while(sum<=(right-left-1)&&node1&&node2){
    node3 = node2->next;
    node2->next = node1;
    node1 = node2;
    node2 = node3;
    ++sum;
    }
cur->next->next = node2;
cur->next = node1;

sum<=(right-left-1)即控制循环次数,这个结论应该在初始化完成后,在草稿纸上选取某一案例不断移动前后指针的预演中得出,你还能通过本次预演,确定node1最终指向逆置部分的第一个节点,那我们的cur->next = node1就出来了;还能确定node2最终指向right后继,那么cur->next->next也呼之欲出了,此时,“协议联调”就解决了。而node3变量,则是在我们发现node2->next需要被记录时,及时地添加到前面去的。

剧透一下,以上两题对于本文压轴题的启示是

由于压轴题虽然类似【反转链表】是从头head就开始修改的,并非从链表中途开始。但是“每K个一组”要求在【反转链表II】处理[left,right]中间部分算法基础上,用一个for循环包裹它,即进行多次的“处理[left,right]中间部分”,并进行多次连接“left前驱和right后继

补充:如果细心的读者看过本题的官方题解,其实你会发现,本人讲解的其实与官解的第二种解法:《一次遍历「穿针引线」反转链表(头插法)》 相差不大。但是本人的侧重点不是手把手教授你这题如何做出来,而更像是期望你掌握这种设计、思考的方式,所以在此就不贴官方题解了。官解第一种解法没有很多的参考价值,弊端也很显然,极端情况一头一尾则会遍历两次,唯一值得注意的是它出现了封装reverse()部分,为后压轴题作铺垫。

当然,在解决压轴题之前,我们再看一道关系与本题同级别的题目,然后在解决压轴题后,我们用最新习得的方法回头来解决LeetCode24.

参考代码:

class Solution {
public:
    ListNode* reverseBetween(ListNode* head, int left, int right) {
        if(!head || !head->next)
            return head;
        ListNode *curr = new ListNode();
        ListNode *cur = curr;
        curr->next = head;
        ListNode *node1 = cur->next, *node2 = cur->next->next, *node3 = nullptr;
        int sum = 0;
        for(int i = 0; i <= left-2; ++i){
            node2 = node2->next;
            node1 = node1->next;
            cur = cur->next;
        }
        while(sum<=(right-left-1)&&node1&&node2){
            node3 = node2->next;
            node2->next = node1;
            node1 = node2;
            node2 = node3;
            ++sum;
        }
        cur->next->next = node2;
        cur->next = node1;
        return curr->next; 
    }
};

三:LeetCode24 两两交换链表中的节点【中等】

问题描述:

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

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

思路分析:

本题与第三题属于同一级别。我们借此题再复习一下递归和迭代。

这题递归仍然不好写。让我们梳理一下递归结构的几个特征:

  1. 终止条件是链表中没有节点,或者链表中只有一个节点,此时无法进行交换。
  2. 递推公式证明:

    如果链表中至少有两个节点,则在两两交换链表中的节点之后,原始链表的头节点变成新的链表的第二个节点,原始链表的第二个节点变成新的链表的头节点。链表中的其余节点的两两交换可以递归地实现。在对链表中的其余节点递归地两两交换之后,更新节点之间的指针关系,即可完成整个链表的两两交换。

  3. 用 head 表示原始链表的头节点(新的链表的第二个节点),用 newHead 表示新的链表的头节点(原始链表的第二个节点),则原始链表中的其余节点的头节点是 newHead->next。令 head->next = 递归函数(newHead->next),表示将其余节点进行两两交换,交换后的新的头节点为 head 的下一个节点。然后令 newHead->next = head,即完成了所有节点的交换。最后返回新的链表的头节点 newHead

迭代直接看代码。等解决完压轴题记得返回来练习新习得的解法

好了,相信你已经学会了压轴题并返回这里了,我们直接贴出将k改为2后的代码

class Solution {
public:
    ListNode* swapPairs(ListNode* head) 
    {
        if(!head||!head->next) return head;
        ListNode *dummy= new ListNode(-1);
        dummy->next=head;
        ListNode *pre=dummy;
        ListNode *end=dummy;
        while(end)
            {
                for(int i=0;i<2;i++)
                    {
                        end=end->next;
                        if(!end)
                            return dummy->next;
                    }
                ListNode *next=end->next; //先记录下一组的头结点再断开
                end->next=nullptr;
                ListNode *start=pre->next;
                pre->next=reverse(start);
                start->next=next;
                pre=start;
                end=start;
            }
        return head;
    }

    ListNode *reverse(ListNode *head)
    {
        if(!head||!head->next)
            return head;
        ListNode *preNode=nullptr,*curNode=head,*nextNode=nullptr;
        while(curNode)
            {
                nextNode=curNode->next;
                curNode->next=preNode;
                preNode=curNode;
                curNode=nextNode;
            }
        return preNode;  
    }
};

参考代码:

迭代:

class Solution {
public:
    ListNode* swapPairs(ListNode* head) {
        if(!head||!head->next)
            return head;
        ListNode *p = head, *pre = new ListNode(), *ne = head, *sign = pre;
        pre->next = head;
        while(ne&&p&&p->next){
            ne = p->next->next;
            pre->next = p->next;
            p->next->next = p;
            pre = p;
            p = ne;
        }
        pre->next = p;
        return sign->next;
    }    
};

递归:

class Solution {
public:
    ListNode* swapPairs(ListNode* head) {
        if (head == nullptr || head->next == nullptr) {
            return head;
        }
        ListNode* newHead = head->next;
        head->next = swapPairs(newHead->next);
        newHead->next = head;
        return newHead;
    }
};

四:20秋字节技术面>>LeetCode25 K个一组翻转链表【困难】

问题描述:

        给你链表的头节点 head ,每 k 个节点一组进行翻转,请你返回修改后的链表。k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。(你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换)。

 

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

你会发现本题多多少少都融入了前面所有题的“影子”,所以先解决它的前置问题,对于深刻理解本题具有重要作用。你以为本题会讲解许多?nonono基本上就是前置的知识点,该讲的都讲了。现在我们就需要把这些“零件”给组装起来!!!

思路分析:

本题虽然类似【反转链表】是从头head就开始修改的,并非从链表中途开始。但是“每K个一组”要求在【反转链表II】处理[left,right]中间部分算法基础上,用一个for循环包裹它,即进行多次的“处理[left,right]中间部分”,并进行多次连接“left前驱和right后继”。

for循环次数即为完整的“k个元素为一组”的组数,而每一组我们都需要用while(是不是很熟悉)来解决本组的逆置问题。看吧,前后就这样联系在一起了。代码也无需我过多解读了吧!

int total = sum / k;
for(int i = 1; i <= total; ++i){
    int num = 0;
    ListNode *node1 = cur->next; 
    ListNode *node2 = cur->next->next;
    ListNode *node3;   
    if(k==1)
        continue;
    【while(num<=(k-2)&&node1&&node2){
        node3 = node2->next;
        node2->next = node1;
        node1 = node2;
        node2 = node3;
        ++num;
    }
    cur->next->next = node2;
    ListNode *sign = cur->next;
    cur->next = node1;
    cur = sign; 
    】  
}

 再来分享另一个解法,来自力扣官方题解。就不文字描述了,详情请点击链接:力扣

class Solution {
public:
    // 翻转一个子链表,并且返回新的头与尾
    pair<ListNode*, ListNode*> myReverse(ListNode* head, ListNode* tail) {
        ListNode* prev = tail->next;
        ListNode* p = head;
        while (prev != tail) {
            ListNode* nex = p->next;
            p->next = prev;
            prev = p;
            p = nex;
        }
        return {tail, head};
    }

    ListNode* reverseKGroup(ListNode* head, int k) {
        ListNode* hair = new ListNode(0);
        hair->next = head;
        ListNode* pre = hair;

        while (head) {
            ListNode* tail = pre;
            // 查看剩余部分长度是否大于等于 k
            for (int i = 0; i < k; ++i) {
                tail = tail->next;
                if (!tail) {
                    return hair->next;
                }
            }
            ListNode* nex = tail->next;
            // 这里是 C++17 的写法,也可以写成
            // pair<ListNode*, ListNode*> result = myReverse(head, tail);
            // head = result.first;
            // tail = result.second;
            tie(head, tail) = myReverse(head, tail);
            // 把子链表重新接回原链表
            pre->next = head;
            tail->next = nex;
            pre = tail;
            head = tail->next;
        }

        return hair->next;
    }
};

当然这种方法不论是写法还是思想,都值得学习但也没必要一味追求炫技。掌握一种方法把它吃透即可。

对上述这种解法进行简化

class Solution {
public:
    ListNode* reverseKGroup(ListNode* head,int k) 
    {
        if(!head||!head->next) return head;
        ListNode *dummy= new ListNode(-1);
        dummy->next=head;
        ListNode *pre=dummy;
        ListNode *end=dummy;
        while(end)
            {
                for(int i=0;i<k&&end!=nullptr;i++)
                    {
                        end=end->next;
                    }
                if(!end) break;
                ListNode *next=end->next; //先记录下一组的头结点再断开
                end->next=nullptr;
                ListNode *start=pre->next;
                pre->next=reverse(start);
                start->next=next;
                pre=start;
                end=start;
            }
        return dummy->next;
    }

    ListNode *reverse(ListNode *head)
    {
        if(!head||!head->next)
            return head;
        ListNode *preNode=nullptr,*curNode=head,*nextNode=nullptr;
        while(curNode)
            {
                nextNode=curNode->next;
                curNode->next=preNode;
                preNode=curNode;
                curNode=nextNode;
            }
        return preNode;  
    }
};

而这,就是我们新习得的方法,不作过多解释,读者不妨仔细阅读代码。只要将k值改为2,就是LeetCode24.的题解!!!

参考代码:

迭代自解:

class Solution {
public:
    ListNode* reverseKGroup(ListNode* head, int k) {
        if(!head||!head->next)
            return head;
        int sum = 0;
        ListNode *curr = new ListNode();
        ListNode *cur = curr;
        curr->next = head;
        ListNode *p = head;
        while(p){
            ++sum;
            p = p->next;
        }
        int total = sum / k;
        for(int i = 1; i <= total; ++i){
            int num = 0;
            ListNode *node1 = cur->next; 
            ListNode *node2 = cur->next->next;
            ListNode *node3;   
            if(k==1)
                continue;
            while(num<=(k-2)&&node1&&node2){
                node3 = node2->next;
                node2->next = node1;
                node1 = node2;
                node2 = node3;
                ++num;
            }
            cur->next->next = node2;
            ListNode *sign = cur->next;
            cur->next = node1;
            cur = sign;   
        }
        return curr->next;
    }
};

写在后面:

到此为止,基本已经掌握了解常见链表题的通用方法。链表这个专题就可以告一段落了。

接下来我可能将会跳过“栈和队列”,更多地分享“图”“树”方面的心得。

愿我们日拱一卒,功不唐捐!

  • 3
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

清风微浪又何妨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值