leetcode【数据结构简介】《链表》卡片——双指针技巧

链表中的双指针

让我们从一个经典问题开始:

给定一个链表,判断链表中是否有环。

你可能已经使用哈希表提出了解决方案。但是,使用双指针技巧有一个更有效的解决方案。在阅读接下来的内容之前,试着自己仔细考虑一下。

想象一下,有两个速度不同的跑步者。如果他们在直路上行驶,快跑者将首先到达目的地。但是,如果它们在圆形跑道上跑步,那么快跑者如果继续跑步就会追上慢跑者。

这正是我们在链表中使用两个速度不同的指针时会遇到的情况:

  1. 如果没有环,快指针将停在链表的末尾。
  2. 如果有环,快指针最终将与慢指针相遇。

所以剩下的问题是:

这两个指针的适当速度应该是多少?

一个安全的选择是每次移动慢指针一步,而移动快指针两步。每一次迭代,快速指针将额外移动一步。如果环的长度为 M,经过 M 次迭代后,快指针肯定会多绕环一周,并赶上慢指针。

相关编程题

1. 环形链表

给定一个链表,判断链表中是否有环。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos-1,则在该链表中没有环。

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

输入:head = [1], pos = -1
输出:false
解释:链表中没有环。

进阶:你能用 O(1)(即,常量)内存解决此问题吗?

快慢指针法

思路:
使用上文中的快慢指针方法:每次移动慢指针一步,而移动快指针两步。若quick->next!=NULLquick->next->next!=NULLNULL则返回false,否则直到quick追上slow。(而slow->next!=NULL是为了加一重保险毕竟循环体内出现了slow = slow->next;,但是删去了也没问题,毕竟他走的慢,即使有礁石,先触礁的也不是他)

/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
bool hasCycle(struct ListNode *head) {
    if(head==NULL) return false;	//特例判断
    struct ListNode *quick = head, *slow = head;
    while(quick->next!=NULL && quick->next->next!=NULL && slow->next!=NULL){
        if(quick->next==slow || quick->next->next==slow) return true;
        quick = quick->next->next;
        slow = slow->next;
    }
    return false;
}

在这里插入图片描述

2. 环形链表II

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

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。如果 pos-1,则在该链表中没有环。
说明:不允许修改给定的链表。

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

输入:head = [1], pos = -1
输出:no cycle
解释:链表中没有环。

【进阶】你是否可以不用额外空间解决此题?

双指针法

思路:

  1. 特例判断
  2. 进入循环,快指针每次移动两个结点,慢指针每次移动一个结点,直到快指针不能继续(quick->next==NULL || quick->next->next==NULL)或者快指针追上慢指针(quick==slow)
  3. 慢指针slow不动,给快指针重新赋值为头指针quick = head;
  4. 快指针每次移动一个结点,慢指针每次移动一个结点,等快指针和慢指针相遇,此时就是我们要寻找的链表开始入环的第一个结点。

代码实现:

struct ListNode *detectCycle(struct ListNode *head) {
    if(head==NULL ) return NULL;
    if(head->next==NULL || head->next->next==NULL) return NULL;

    struct ListNode *quick = head, *slow = head;
    while(quick->next!=NULL && quick->next->next!=NULL){
        quick = quick->next->next;
        slow = slow->next;
        if(quick->next==NULL || quick->next->next==NULL) return NULL;
        if(quick==slow) break;
    }
    quick = head;

    while(quick!=slow){
        quick = quick->next;
        slow = slow->next;
    }
    return quick;
}

在这里插入图片描述

对于小白来说,很可能看到这个函数的返回值类型struct ListNode *detectCycle(struct ListNode *head);会有点发懵。因为以前遇到的都是诸如int a(...);char a(...);以及void a(...);之类,从来没有遇到过别的,但现在,不就遇到了嘛~
记住:函数的返回值类型类型是什么,咱就返回什么!比如对于这个函数来说我们就应该返回一个节点,而非一个索引(索引类型为int)。

3. 相交链表

编写一个程序,找到两个单链表相交的起始节点。
在这里插入图片描述
注意:

  • 如果两个链表没有交点,返回 null.
  • 在返回结果后,两个链表仍须保持原有的结构。
  • 可假定整个链表结构中没有循环。
  • 程序尽量满足 O(n) 时间复杂度,且仅用 O(1) 内存。

输入:intersectVal = 8, listA = [4,1,8,4,5], listB = [5,0,1,8,4,5], skipA = 2, skipB = 3
输出:Reference of the node with value = 8
输入解释:相交节点的值为 8 (注意,如果两个列表相交则不能为 0)。从各自的表头开始算起,链表 A 为 [4,1,8,4,5],链表 B 为 [5,0,1,8,4,5]。在 A 中,相交节点前有 2 个节点;在 B 中,相交节点前有 3 个节点。
在这里插入图片描述

输入:intersectVal = 0, listA = [2,6,4], listB = [1,5], skipA = 3, skipB = 2
输出:null
输入解释:从各自的表头开始算起,链表 A 为 [2,6,4],链表 B 为 [1,5]。由于这两个链表不相交,所以 intersectVal 必须为 0,而 skipA 和 skipB 可以是任意值。
解释:这两个链表不相交,因此返回 null。
在这里插入图片描述

暴力法

思路:

  1. 特例判断:两个指针为空
  2. A链表只有一个节点
  3. A链表不止一个节点(分为 B链表是否是一个节点 两种情况)
    因为while的条件判断为a->next!=NULL,而如果相交的起始节点为A的最后一个节点的情况(此时B链表只有一个节点),我们需要在while循环外面return a;
/**
 * Definition for singly-linked list.
 * struct ListNode {
 *     int val;
 *     struct ListNode *next;
 * };
 */
struct ListNode *getIntersectionNode(struct ListNode *headA, struct ListNode *headB) {
    if(headA==NULL || headB==NULL) return NULL;
    struct ListNode *a=headA, *b=headB;
    
    if(a->next==NULL){
        while(b->next!=NULL && b!=a) b = b->next;
        if(a==b) return a;
        else return NULL;
    }

    while(a->next!=NULL){
        b = headB;
        if(b->next!=NULL){
            while(b->next!=NULL && b!=a) b = b->next;
            if(b==a) return a;
        }
        else if(b==a) return a;
        a = a->next;
    }
    if(b==a) return a;
    return NULL;
}

在这里插入图片描述

简单直接,应该是大家脑子跳出来的第一种方法,但是从数据可以看出,该种方法执行用时长得不得了,定然不是最优算法(不然那些95%的宝贝怎么就执行时间那么短呢~)。下面将介绍另外一种方法:双指针法。

双指针法

思路详解-传送门

struct ListNode *getIntersectionNode(struct ListNode *headA, struct ListNode *headB) {
    if(headA==NULL || headB==NULL) return NULL;
    if(headA==headB) return headA;
    struct ListNode *a=headA, *b=headB;
    while(a!=b){
        a = a==NULL? headB: a->next;
        b = b==NULL? headA: b->next;
        if(a==b) return a;
    }
    return NULL;
}

在这里插入图片描述

4. 删除链表的倒数第N个节点

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

给定一个链表: 1->2->3->4->5, 和 n = 2.
当删除了倒数第二个节点后,链表变为 1->2->3->5.

【说明】给定的 n 保证是有效的。

【进阶】尝试使用一趟扫描实现。

想法:

首先,链表具有单方向性,所以只能从前往后到达倒数第N个节点。

那么思路跳上心头:先遍历一遍得到节点的总个数N,然后减一减,不就可以计算出待删除点的正序索引了吗?

当然啦,这种方法自然是OK的,就是执行时间长了些。

尤其是在看到题目里还给了【进阶】的提示,结合本章标题“双指针技巧”以及之前在《数组与字符串》卡片的相关学习经历,一个极妙的想法涌上心头~这就是下面将要介绍的 固定长度的窗口滑动法

固定长度的窗口滑动法

思路:
我们让两个指针在链表上滑动,其中一个指针right(美其名曰”先行者“),先沿着链表滑动n个节点。接下来会遇到两种情况:

  1. 此时滑动到了最后一个节点right->next==NULL,那么我们就删除头节点;
  2. 此时如果先行者right并不是链表的最后一个节点,那么我们就在链表头最开始的地方,添加一个落后者left,先行者right和落后者left以同样速率在跑道链表上奔跑,直到先行者right到达终点right->next==NULL。此时,落后者left所在节点的下一个节点就是我们需要删除的节点。
struct ListNode* removeNthFromEnd(struct ListNode* head, int n){
    if(!n) return head;
    struct ListNode* left=head, * right=head;
    for(int i=0; i<n; i++) right = right->next;
    if(right==NULL){
        struct ListNode* p=head;
        head = head->next;
        free(p);
        return head;
    }
    while(right->next!=NULL){
        left = left->next;
        right = right->next;
    } 
    struct ListNode* p=left->next;
    left->next = p->next;
    free(p);
    return head;
}

在这里插入图片描述
需要注意的是:不要忘记考虑先行者right跑到终点而落后者left还没出发的情况。
吐槽:这是我目前为止遇到中等难度题目中最简单的了。

小结 - 链表中的双指针

提示

它与我们在数组中学到的内容类似。但它可能更棘手而且更容易出错。你应该注意以下几点:

  1. 在调用 next 字段之前,始终检查节点是否为空
    获取空节点的下一个节点将导致空指针错误。例如,在我们运行 fast = fast.next.next 之前,需要检查 fastfast.next 不为空。
  2. 仔细定义循环的结束条件。

复杂度分析

空间复杂度分析容易。如果只使用指针,而不使用任何其他额外的空间,那么空间复杂度将是 O(1)。但是,时间复杂度的分析比较困难。为了得到答案,我们需要分析运行循环的次数

在前面的查找循环示例中,假设我们每次移动较快的指针 2 步,每次移动较慢的指针 1 步。

  1. 如果没有循环,快指针需要 N/2 次才能到达链表的末尾,其中 N 是链表的长度。
  2. 如果存在循环,则快指针需要 M 次才能赶上慢指针,其中 M 是列表中循环的长度。

显然,M <= N 。所以我们将循环运行 N 次。对于每次循环,我们只需要常量级的时间。因此,该算法的时间复杂度总共为 O(N)

可以通过分析其他问题以提高分析能力。别忘了考虑不同的条件。如果很难对所有情况进行分析,请考虑最糟糕的情况。

发布了13 篇原创文章 · 获赞 2 · 访问量 273

猜你喜欢

转载自blog.csdn.net/AuthurWhywat/article/details/105132546