有环链表及以此为基础的一些问题

个人博客:有环链表及以此为基础的一些问题

链表有环及其延伸问题

        首先,问题涉及的有环链表是指链表的尾节点不是null,而是指向链表中的其中一个节点,从而使得链表的其中一段是循环的,如果用图的话可以得到这样的数据结构:

那么基于这样的数据结构有一系列问题需要处理。最基础的则是判定是否有环。也就是说判定一个链表是否是有环链表。判定是否有环之后,又可以要求返回到底是链表的哪一个节点开始进入环的,然后环的长度是多少,链表的总长度是多少。反向思考,如果链表是无环的,那么给出两个链表的话,如何判定两个链表是否相交,如果相交的话交点是哪个节点。

        接下来就给出解决这些问题的算法。然后,这篇博文用的语言是C++,然后测试用的链表就是上图,一样的结构。而关于反向思考判定两个链表相交以及交点的问题,就不再写了,只给出代码。链表节点的定义以及创建链表、测试正确性的代码如下:

struct Node {
    int val;
    struct Node *next;
}head,a,b,c,d,e,f;

void create_list() {
    a.val = 1;
    b.val = 2;
    c.val = 3;
    d.val = 4;
    e.val = 5;
    f.val = 6;
    head.next = &a;
    a.next = &b;
    b.next = &c;
    c.next = &d;
    d.next = &e;
    e.next = &f;
    f.next = &c;
}

void test_link_list() {
    Node *temp = head.next;
    for (int i = 0; i < 10; i++) {
        cout << temp->val << endl;
        temp = temp->next;
    }
}

这个。。。写的比较省事,不过也比较直白= =。结构体中val的值是指节点的编号,从1开始。

判定链表是否有环

        如果是无环,那么在O(n)复杂度下遍历到null就说明无环。有环的情况下遍历是死循环的,这个时候我们考虑利用遍历的速度差。例如高中物理经典问题“追及问题”,A、B两者的速度不同,那么在某个时间两者必定相遇。如果是在操场上赛跑,那么因为周期性,在n圈以后必定存在快的追及到慢的情况。链表同理,如果同时从头开始用两个指针开始遍历,一个遍历速度慢(一次跳一个结点),一个遍历速度快(一次跳多个结点),那么只要保证:快速的能够整除慢速,也就是满足周期T的情况,那么一定能够在环上追及到。

        那么,我们设定两个指针,最简单的方法慢指针每次跳1,快指针每次跳2,如果碰见null,说明链表无环,如果快指针指向结点与慢指针的一样,那么说明有环。这样在O(n)的时间复杂度内就可以得到解。代码如下:

int have_ring() {
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) return 1;
        slow = slow->next;
        if (fast->next == NULL) return 0;
        fast = fast->next->next;
    }
    return 0;
}

返回值是1说明有环,返回值是0说明无环。

快指针的速度问题

        我个人认为快指针的速度是无关紧要的,对于一个固定的链表能在几次移动中判定是否有环,主要跟慢指针的速度有关,因为只有以最快的速度进环,才能保证相遇的可能。而在环中,移动几次能够相遇又与慢指针的速度、环长有关。增加慢指针的速度,在一次就移动到相遇点的情况下,再快也至少要移动一次。所以速度推荐慢指针移动1快指针移动2。相遇点这个,因为在环上运动的话是周期性的,第一次两者相遇的话,那么下一次相遇必定还是在这个点。而如果交换两者移动的先后顺序到达的则是对面的点。

判定入环结点

        既然判定有环,那么就能引申一系列环上操作,判定入环结点就是比较重要的。不过算法过程更偏向于数学推导验证。

        还是上面的链表为例,图可参考最上面的。我们假设链表的总长为L,链表头到入环点的距离为A,入环点到快慢指针追及点的长度为X,环长为M。如下图所示:

如果不论A这一段距离的话,从慢指针到达环上开始,到快慢指针相遇,因为慢指针绝对还没有绕环一周,也就是说X < M,如果快指针的速度是慢指针的两倍,那么慢指针移动长度2*X < M,也就是说快指针一定是没有超过两周。而慢指针想要和快指针相遇,至少要绕环一周才可以。所以对于快指针而言,在环上的第二周碰到了慢指针。

        这里分2种情况,A < M。此时,2*A < M,所以在慢指针进入环的时候,块指针一周还没有遍历完成,那么一定是在第二周与慢指针相遇,就像上图的情况,那么有:

L + X = 2*(A + X)

转化为:L - A - X = A

这里可以看到,L - A - X是相遇点到入环点的距离,和A是相同的。

        第二种情况,A >= M,此时快指针至少跑一周了,此时我们考虑一下截取,即按照周期M将多出来的部分给截取掉。例如这样的图:

我们将其中多出来的P个结点,只要满足2*P mod M == 0的情况,就可以想象成多出来的部分,慢指针还没有进环,而块指针在环上周期运动。步数向抵消。那么就可以将P个结点截取,红线右边的部分就是上面的图了,就转换成了第一种情况。同理,所有满足A >= M的链表都可以转换成A < M的情况。换而言之,都满足:L - A -X = A的情况。

        既然满足这个条件,那么算法就非常简单了,设定两个指针,一个从head开始移动,另一个从相遇点开始移动,那么最终相遇的地方就是入节点了。代码如下:

Node * get_ring_node() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast){
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
    }
    return p_head;
}

有部分代码是重复的,这里是为了展示= =实际上最好是在判定是否有环的时候,就返回这个相遇点,如果相遇点是null说明无环,否则为有环。当做参数传递进函数就可以省略一部分代码了。

环长与链表总长

        上面得到了这样的表达式:L - A - X = A。那么我们求的实际上是L与L - A。首先在上面函数求入环结点的时候,遍历的数量其实就是A,这样我们就得到了A值。然后如果我们继续遍历,从入环口开始,直到相遇点,统计这个遍历的次数就可以得到X值。这样通过表达式的变式:L = 2*A + X就可以得到L了。那么L - A也就可以直接获得。

        这里注意链表总长的定义:一般我们指的是结点的数量,我们实际上计算的时候入环结点是计算了2次的,所以答案应该把多出来的给减去。代码:

int get_length() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) {
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    int a = 0;
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
        a++;
    }
    int x = 0;
    // 此时slow指针是指向相遇点没有变的
    while (p_head != slow) {
        p_head = p_head->next;
        x++;
    }
    return 2*a + x - 1;
}

同上,还是将多余的写出来了。

        环上长度就基本跟上面差不多了,将最后的2*a+x-1换成a+x就可以了。代码:

int get_ring_length() {
    Node *p_head = &head;
    Node *p_meet;
    Node *slow = &head;
    Node *fast = &head;
    if (fast->next != NULL) fast = fast->next->next;
    slow = slow->next;
    while (slow != NULL && fast != NULL) {
        if (slow == fast) {
            p_meet = slow;
            break;
        }
        slow = slow->next;
        fast = fast->next->next;
    }
    int a = 0;
    while (p_head != p_meet) {
        p_head = p_head->next;
        p_meet = p_meet->next;
        a++;
    }
    int x = 0;
    // 此时slow指针是指向相遇点没有变的
    while (p_head != slow) {
        p_head = p_head->next;
        x++;
    }
    return a + x;
}

两个链表是否相交及交点

        也是一个据说很经典的题,可以百度搜,原理以及解法可以看博主的另一篇博客:

LeetCode-160 intersection-of-two-linked-lists 相交链表_intersection of two linked lists 啥意思-优快云博客

这里就不多赘述了。

代码的重复问题

       可以看到,上面很多代码是重复了很多的,因为这一系列的问题有很多重合点。但是如果我们能将入环点、相遇点给记录下来的话,就可以节省非常多的代码。只用将这两个指针存进全局变量即可。这也比较简单,就是加两句代码的事也就不再写了(主要是比较懒= =)。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值