环形链表和反转链表——leetcode 141 & 142 & 206 & 92

环形链表和反转链表——leetcode 141 & 142 & 206 & 92

今天为什么把这么多题目放到一起去讲呢?我们先看题,等等就知道这几道题之间有什么联系了。

环形链表

先看141题:

在这里插入图片描述
这个题难度是easy,说明如果想要做出来的话,其实是很简单的。如果链表中存在环,那么我们从头结点出发,一路往下走,总会有某个时候到达一个之前经过的结点。因此,我们把经历过的结点都保存下来,每到达一个结点都检查这个结点是否到达过,就可以了。在这道题里,我们只需要记录每个结点的地址就可以了(千万不要想着记录结点的值,有可能不同结点的值是相同的)。而有记录并且查重的数据结构,比较常用的有set和map。这里没有什么映射关系,就用不着map了,用set就可以了。实现的代码如下:

class Solution 
{
public:
    bool hasCycle(ListNode *head) 
    {
        set<ListNode*> s;
        while (head)
        {
            if (s.find(head) != s.end()) { return true; }
            s.insert(head);
            head = head->next;
        }
        return false;       
    }
};

同样的,142题也是一样的想法:

在这里插入图片描述

这个题目在前一个题目的基础上,要求如果链表存在环,就找出进入环的第一个结点。用之前的思路一样是可行的:

class Solution 
{
public:
    ListNode *detectCycle(ListNode *head) 
    {
        set<ListNode*> s;
        while (head && s.find(head) == s.end())
        {
            s.insert(head);
            head = head->next;
        }
        return head;
    }
};

好的,今天这篇文章到这里就结束了,更多精彩内容敬请期待…

其实,不知道你们有没有看到题目的彩蛋。两道题都提到了,有不使用额外空间(或者使用常数额外空间)的解决办法。如果做过另外一个题的话,这种解决方法也就不难想到了。所以,我们先看另外两道题。

反转链表

206 反转链表:

反转一个单链表。

示例:

输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
进阶:
你可以迭代或递归地反转链表。你能否用两种方法解决这道题?

92 反转链表 2:

反转从位置 m 到 n 的链表。请使用一趟扫描完成反转。

说明:
1 ≤ m ≤ n ≤ 链表长度。

示例:

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

反转链表这个题难度不大,我们只需要遍历链表,每次都把当前结点cur的next指向cur的前驱结点。因此我们遍历链表的时候,每趟循环需要保留两个结点,一个是当前结点cur,另一个是cur的前驱结点before,然后把cur的next指向before就可以了。所以如果我们采用递归的写法,写出来的代码是这样的:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution 
{
public:
    ListNode* reverseList(ListNode* head) 
    {
        return recursive(head, NULL);
    }
    ListNode* recursive(ListNode* cur, ListNode* before)
    {
        if (!cur) { return before; }         
        cur->next = before;
        return recursive(cur->next, cur);
    }
};

这样写出来的代码有没有问题呢?我们的本意是把cur的next指向before,然后before和cur都往前移动一个结点。但事实上,在recursive这个函数中,cur的next指向before以后,我们弄丢了原本cur的next指向的那个结点,这个时候再去调用recursive函数就没有意义了。因此,我们还需要保留cur原来的后驱结点ori_next,这样写出来的递归也还是很简单的:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution 
{
public:
    ListNode* reverseList(ListNode* head) 
    {
        return recursive(head, NULL);
    }
    ListNode* recursive(ListNode* cur, ListNode* before)
    {
        if (!cur) { return before; }        
        ListNode* ori_next = cur->next;
        cur->next = before;
        return recursive(ori_next, cur);
    }
};

同理,迭代的写法也差不多:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution 
{
public:
    ListNode* reverseList(ListNode* head) 
    {
        if (!head) { return NULL; }
        ListNode* cur = head, *ori_next = head->next, *before = NULL;
        while (cur)
        {
            ori_next = cur->next;    //保留cur原来的后驱结点
            cur->next = before;
            before = cur;
            cur = ori_next;
        }
        return before;    //before这个时候是头结点
    }
};

然后,我们再看看反转链表2 这个题。这个题要求反转链表中的一段,思路还是一样的,只是要注意段的临界值,因此有点繁琐:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution 
{
public:
    ListNode* reverseBetween(ListNode* head, int m, int n) 
    {
        if (m == n) { return head; }    //不需要反转
        ListNode* ori_next = NULL,    //cur在未反转之前的后继结点 
        	*before = NULL,    //cur在未反转之前的前驱结点 
        	*cur = head,    //通过cur遍历链表 
            *l_m = NULL,    //第m个结点 
            *l_n = NULL, 	//第m个结点
            *l_m_before = NULL,    //第m个结点的前驱结点 
            *l_n_next = NULL;    //第n个结点的后继结点
        for (int i = 1; i <= n; ++i)    
        {
            if (i < m) 
            { 
                if (i == m - 1)    //找到第m个结点的前驱结点
                { 
                    l_m_before = cur;
                    l_m = cur->next;    
                }
                cur = cur->next; 
            }
            else if (i == m)    //找到第m个结点 
            { 
                l_m = before = cur;    
                	//注意before此时引入,因为此时开始执行链表反转的工作
                cur = cur->next;
            }
            else 
            { 
                if (i == n)    //找到第n个结点以及它的后继结点 
                {
                    l_n = cur; 
                    l_n_next = cur->next;
                }
                ori_next = cur->next; 
                cur->next = before;
                before = cur;
                cur = ori_next;
            }             
        }
        if (l_m_before) { l_m_before->next = l_n; }
        	//m == 1时,l_m就是头结点,l_m_before为空,无需理会
        l_m->next = l_n_next; 
        return m == 1? l_n : head;
        	//m == 1时,反转过后的l_n就是头结点
    }
};

相比起反转链表,反转链表2多了很多需要记录的结点。比如说l_m,l_n,这是合情合理的,这是我们需要反转的链表段的头尾结点;而l_m_before,l_n_next又是怎么回事呢?想象一下,l_m_before原来的后继结点是l_m,链表反转以后它的后继结点变成了l_n;而l_m的后继结点从l_m->next变成了l_n_next。因此这些结点都是需要记录的。

还有,要注意的是before这个结点什么时候开始记录。当我们到达第m个节点的时候,就要开始反转链表了,因此我们在这个时候引入before这个结点,记录cur原来的前驱结点。

格外要注意的是,假如m == 1,反转过后的链表,头结点是会发生变动的,原来的结点l_n变成了头结点,这种情况要单独讨论;假如m == n,说明链表不需要反转。

就是因为以上这些特殊情况,这道题的代码会有些繁琐,一开始写的时候有些情况没考虑到,也是很正常的。

反转链表和环形链表

之前我们说,如果做过反转链表的话,环形链表的不占用额外空间的解法就很容易想到了。对于141 环形链表这个题目来说,的确是这么回事。想象一下,假如链表当中存在环,那么我们用遍历的方法去反转这个链表的时候,总会回到头结点;如果没有环的话,那我们最后会顺利地遍历完整个链表。因此,我们还可以用这种解法做这道题:

class Solution 
{
public:
    bool hasCycle(ListNode *head) 
    {
        if (!head) { return false; }    //链表为空
        ListNode* cur = head->next, *before = head, *ori_next;
        while (cur)
        {
            cout << cur->val << endl;
            if (cur == head) { return true; }
            ori_next = cur->next;
            cur->next = before;
            before = cur;
            cur = ori_next;
        }       
        return false;
    }
};

这个解法,用来判断是否有环是很有效的。但是它并不能判断这个环的起点在哪(有人可能会觉得环怎么会有起点和终点的说法,在这个链表中,我们把环的起点理解成从head出发,进入环的第一个结点)。所以我们还需要引入新的解法。

龟兔赛跑(双指针)

这是一个很有趣的想法,也是一个比较难想到的解法(反正我是想不出来的)。想象一下,有两个人从head结点同时出发,有个人跑得快一些,每秒钟前进两个单位;另一个人腿脚不太好,每秒钟前进一个单位。如果这个链表有环的话,那么跑得快的那个会在环的某个位置追上跑得慢的那个人,而且两人相遇的时候,跑得慢的人在环上行走的距离不会大于一个环的长度(这一点很重要,算时间复杂度的时候会用到);而如果这个链表没有环,那么跑得快的人会率先抵达终点。

讲到这里,这个算法的实现其实就很简单了。

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution 
{
public:
    bool hasCycle(ListNode *head) 
    {
        ListNode* l1 = head, *l2 = head;
        while (l1 && l1->next)
        {
            l1 = l1->next->next;
            l2 = l2->next;
            if (l1 == l2) { return true; }
        }
        return false;
    }
};

而如果要找出最开始进入环的那个结点,需要对这个解法做进一步处理。做法也很简单,就是当这两个人相遇时,把跑得快的那个人请出去,然后让另一个速度也是每秒1个单位的人从起点出发,这两个人同时启动(也就是说一个人从head结点出发,另一个人从之前相遇的那个结点出发),以同样的速度往下走,而这两个人相遇的结点就是进入环的第一个结点。

为什么?这个解法的理论基础在哪?也就是说,为什么两个人从不同的结点出发,最终相遇的结点就是环的第一个结点?我们用下面这张图推导一下就很清楚了。

在这里插入图片描述
我们需要得到什么结论?如果说,x1和x3的差值,是x2+x3的整数倍,那么两个人从头结点和相遇点出发,最终会在环的入口处相遇。想象一下,x1比x3大,那么从相遇点出发的那个人除了走x3距离外,还绕着环走了几圈,最后和从头结点出发的人相遇在环的入口处。

那我们怎么证明这个结论呢?我们需要回到之前一块一慢两个人从头结点同时出发的情况。快慢者相遇以前,快者走过的距离为s1=x1+x2+k1(x1+x2),慢者走过的距离为s2=x1+x2+k2(x1+x2)(k1,k2都为正整数),而快者走过的距离为慢者的两倍,因此s1=2s2,想办法把x1-x3移到等式左边,可以得到x1-x3=(k1-2k2-1)*(x2+x3)。也就是说,这个结论是正确的,所以这个解法也是可行的。实现的代码如下:

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     ListNode *next;
 *     ListNode(int x) : val(x), next(NULL) {}
 * };
 */
class Solution 
{
public:
    ListNode *detectCycle(ListNode *head) 
    {
        if (!head) { return NULL; }
        ListNode *l1 = head, *l2 = head;
        while (l1 && l1->next)
        {
            l1 = l1->next->next;
            l2 = l2->next;
            if (l1 == l2) { break; }
        }
        if (!l1 || !l1->next) { return NULL; }    //注意判断l1->next
        l1 = head;
        while(l1 != l2)
        {
            l1 = l1->next;
            l2 = l2->next;
        }
        return l1;
    }
};

那么,解法的时间复杂度是多少呢?我们算一下,首先假设有环的情况,从快慢者同时出发到两者相遇这段时间里,慢者在环上走的距离最多不超过一个环的长度,也就是说慢者最多也只能把链表遍历一遍。因此这个过程的时间复杂度是O(n);而两个慢者同时出发的情况是,从头结点出发的那个,第一次到达头结点就会和另一个人相遇,因此这个人也是最多能把整个链表遍历一遍,因此时间复杂度还是O(n),最后总的时间复杂度是O(n)。而没有环的情况就更简单了,时间复杂度肯定是O(n)。

所以,无论有没有环,时间复杂度是都是O(n),并且只有常数的额外空间。

今天我们做了环形链表和反转链表的四道题,难度都不算特别大,但是也有很多值得学习的地方。更多精彩内容,敬请期待(这回是真结束了)。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值