【算法系列(四)】:双指针

目录

一、左右指针的常用算法

 1.1、二分查找

35. 搜索插入位置

1.2、两数之和

1. 两数之和

15. 三数之和

 1.3、滑动窗口算法

76. 最小覆盖子串

438. 找到字符串中所有字母异位词

3. 无重复字符的最长子串

二、快慢指针的常用算法

206. 反转链表

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

141. 环形链表

142. 环形链表 II

 148. 排序链表


一般双指针算法主要分为两类:(1)快慢指针(2)左右指针前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。

  • 快慢指针:快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。
  • 左右指针:左右指针在数组中实际是指两个索引值,一般初始化为左指针指向数组首地址,尾指针指向数组尾部。

 另外,左右指针的另一种进阶用法叫做滑动窗口算法。这是双指针技巧的最高境界了,此类算法主要解决一大类子字符串匹配的问题,不过「滑动窗口」比左右指针的算法更复杂些。

一、左右指针的常用算法

 1.1、二分查找

查找在算法题中是很常见的,但是怎么最大化查找的效率和写出bugfree的代码才是难的部分。一般查找方法有顺序查找、二分查找和双指针,推荐一开始可以直接用顺序查找,如果遇到TLE的情况再考虑剩下的两种,毕竟AC是最重要的。

一般二分查找的对象是有序或者由有序部分变化的(可能暂时理解不了,看例题即可),但还存在一种可以运用的地方是按值二分查找,之后会介绍。

  • 基本框架
int binarySearch(int[] nums, int target) {
    int left = 0, right = ...;

    while(...) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            ...
        } else if (nums[mid] < target) {
            left = ...
        } else if (nums[mid] > target) {
            right = ...
        }
    }
    return ...;
}
  • while(left<right):到底是<还是<=

我们在写二分查找时,肯定会非常困惑,到底是“<”还是应该写成“<=”。要理解这个概念需要先明白一个区间概念【left,right】。如果写成“<”,那么结束的条件为:right=left;如果写成“<=”,那么结束的条件为:【right+1,right】。也就是说如果是查找是否存在某个值,那么“<”条件下,可能漏检掉一个区间相等的值,如果是“<=”,那么刚好全部遍历完成。所以,如果right=nuns.size(),那么我们需要用“<”,对于right=nuns.size()-1,我们需要使用“<=”。

  • left到底要不要加1,right到底要不要减1,什么时候加1或减1

这里主要与left和right的设置有关,如果right=nums.size(),也就是说搜索区间的范围为:【left,right);如果right=nums.size()-1,搜索范围为:【left,right】。如果是第一种情况,那么当我们判断mid后的,新的搜索区间为:【right,mid)和【mid+1,right),这里也可以看出,我们需要在<mid时,对left=mid+1,而>mid时,对right=mid,就不用加1了。如果是第二种情况,那么我们需要对,left分别进行mid+1,对right进行mid-1。

  • 左边界和有边界二分查找时的注意事项、

如果有【1,2,2,2,3】这样的数组,普通的二分查找可能返回2,如果想找最左或最右的位置的目标值,那么需要在普通二分查找下进行一定的修改。如果我们想要查找左边界,我们需要在nums[mid]==target时,将right=mid-1,来继续搜索,同理,查找右边界,需要将left=mid+1,来继续搜索。

最终二分查找的模板总结如下:

int binary_search(int[] nums, int target) {
    int left = 0, right = nums.length - 1; 
    while(left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1; 
        } else if(nums[mid] == target) {
            // 直接返回
            return mid;
        }
    }
    // 直接返回
    return -1;
}

int left_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定左侧边界
            right = mid - 1;
        }
    }
    // 最后要检查 left 越界的情况
    if (left >= nums.length || nums[left] != target)
        return -1;
    return left;
}


int right_bound(int[] nums, int target) {
    int left = 0, right = nums.length - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定右侧边界
            left = mid + 1;
        }
    }
    // 最后要检查 right 越界的情况
    if (right < 0 || nums[right] != target)
        return -1;
    return right;
}

35. 搜索插入位置

给定排序数组和目标值,如果找到目标,则返回索引。如果不是,则返回按顺序插入索引的位置的索引。 您可以假设数组中没有重复项。

Example 1:
Input: [1,3,5,6], 5
Output: 2

Example 2:
Input: [1,3,5,6], 2
Output: 1

Example 3:
Input: [1,3,5,6], 7
Output: 4

Example 4:
Input: [1,3,5,6], 0
Output: 0

分析: 这里要注意的点是 high 要设置为 len(nums) 的原因是像第三个例子会超出数组的最大值,所以要让 lo 能到 这个下标。

  • C++算法实现
int searchInsert(vector<int>& nums, int target) {
	int l = 0, r = nums.size();
	while (l < r) {
		int mid = l + (l - r) / 2;
		if (nums[mid] == target) return mid;
		else if (nums[mid] > target) r = mid;
		else if (nums[mid] < target) l = mid + 1;
	}

	return l;
}

1.2、两数之和

一般对于二分查找或两数之和,以及后续的三数或四数之和,最主要一点就是看数组是否有序,如果遇到数组无序的话需要转化为有序,所以,一般来说我们会对无序的数组进行排序。其次,对于两数之和这个类型的一般套路为:

  • 左右指针指向数组两边
  • while遍历数组
    • ==target,直接返回
    • >target,右指针向前移动
    • <target,左指针向后移动

总之,两数之和的模板如下:

# 对撞指针套路
l,r = 0, len(nums)-1
while l < r:
    if nums[l] + nums[r] == target:
        return nums[l],nums[r]
    elif nums[l] + nums[r] < target:
        l += 1
    else:
        r -= 1

1. 两数之和

  • 题目描述

给出一个整型数组nums,返回这个数组中两个数字的索引值i和j,使得nums[i] + nums[j]等于一个给定的target值,两个索引不能相等。如:nums= [2,7,11,15],target=9 返回[0,1]

  • 解题思路

需要考虑:

  1. 开始数组是否有序;
  2. 索引从0开始计算还是1开始计算?
  3. 没有解该怎么办?
  4. 有多个解怎么办?保证有唯一解。

在排序前先使用一个额外的数组拷贝一份原来的数组,对于两个相同元素的索引问题,使用一个bool型变量辅助将两个索引都找到,总的时间复杂度为O(n)+O(nlogn) = O(nlogn)

  • C++算法实现
vector<int> twoSum(vector<int>& nums, int target) {
    vector<pair<int,int>> dict;
    for(int i=0;i<nums.size();++i){
        dict.push_back(pair<int,int>(nums[i],i));
    }

    sort(dict.begin(),dict.end(),
         [](pair<int,int> &a,pair<int,int>&b){return a.first<b.first;});

    vector<int> res;
    int i=0,j=dict.size()-1;
    while(i<j){
        if(dict[i].first+dict[j].first>target){
            j--;
        }else if(dict[i].first+dict[j].first<target){
            i++;
        }else if(dict[i].first+dict[j].first==target){
            res.push_back(dict[i].second);
            res.push_back(dict[j].second);
            return res;
        }
    }

    return res;
}

15. 三数之和

  • 题目描述

给出一个整型数组,寻找其中的所有不同的三元组(a,b,c),使得a+b+c=0

注意:答案中不可以包含重复的三元组。

如:nums = [-1, 0, 1, 2, -1, -4],

结果为:[[-1, 0, 1],[-1, -1, 2]]

  • 解题思路

审题

  1. 数组不是有序的;
  2. 返回结果为全部解,多个解的顺序是否需要考虑?--不需要考虑顺序
  3. 什么叫不同的三元组?索引不同即不同,还是值不同?--题目定义的是,值不同才为不同的三元组
  4. 没有解时怎么返回?--空列表

开始时对nums数组进行排序,排序后,当第一次遍历的指针k遇到下一个和前一个指向的值重复时,就将其跳过。为了方便计算,在第二层循环中,可以使用双指针的套路。

其中需要注意的是,在里层循环中,也要考虑重复值的情况,因此当值相等时,再次移动指针时,需要保证其指向的值和前一次指向的值不重复。

  • C++算法实现
vector<vector<int>> threeSum(vector<int>& nums) {
    vector<vector<int>> res;
    if(nums.size()<3) return res;

    sort(nums.begin(),nums.end());

    for(int i=0;i<nums.size()-2;++i){
        if(nums[i]>0) break;
        if(i>0&&nums[i]==nums[i-1]) continue;

        int l=i+1,r=nums.size()-1;
        while(l<r){
            int sum=nums[i]+nums[l]+nums[r];
            if(sum==0){
               vector<int> tmp={nums[i],nums[l],nums[r]};
               res.push_back(tmp);
               l+=1;
               r-=1;
               while(l<r&& nums[l]==nums[l-1]) l++;
               while(l<r&&nums[r]==nums[r+1]) r--;
            }else if(sum<0){
                l+=1;
            }else if(sum>0){
                r-=1;
            }
        }
    }

    return res;
}

 1.3、滑动窗口算法

滑动窗口算法的思路是这样:

  1. 用双指针中的左右指针技巧,初始化 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。
  2. 我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口符合要求。
  3. 此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
  4. 重复第 2 和第 3 步,直到 right 到达尽头。

76. 最小覆盖子串

  • 题目描述

给你一个字符串 S、一个字符串 T 。请你设计一种算法,可以在 O(n) 的时间复杂度内,从字符串 S 里面找出:包含 T 所有字符的最小子串。

示例:
输入:S = "ADOBECODEBANC", T = "ABC"
输出:"BANC"
  • 解题思路

滑动窗口算法的思路是这样:

1、我们在字符串 S 中使用双指针中的左右指针技巧,初始化 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。

2、我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口中的字符串符合要求(包含了 T 中的所有字符)。

3、此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中的字符串不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。

4、重复第 2 和第 3 步,直到 right 到达字符串 S 的尽头。

这个思路其实也不难,**第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解。**左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动。

  • C++算法实现
string minWindow(string s, string t){
    int left=0,right=0,start=0;
    int min=INT_MAX;

    unordered_map<char,int> windows;
    unordered_map<char,int> needs;

    for(int i=0;i<t.size();++i) needs[t[i]]++;

    int match=0;
    while(right<s.size()){
        char c1=s[right];
        if(needs.count(c1)){
            windows[c1]++;
            if(windows[c1]==needs[c1]) match++;
        }
        ++right;

        while(match==needs.size()){
            if(right-left<min){
                min=right-left;
                start=left;
            }
            char c2=s[left];
            if(needs.count(c2)){
                windows[c2]--;
                if(windows[c2]<needs[c2]){
                    match--;
                }
            }
            ++left;
        }
    }

    return min == INT_MAX ? "" : s.substr(start, min);
}

438. 找到字符串中所有字母异位词

  • 题目描述

给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。

字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。

说明:字母异位词指字母相同,但排列不同的字符串。
不考虑答案输出的顺序。

示例 1:
输入:
s: "cbaebabacd" p: "abc"
输出:
[0, 6]
解释:
起始索引等于 0 的子串是 "cba", 它是 "abc" 的字母异位词。
起始索引等于 6 的子串是 "bac", 它是 "abc" 的字母异位词。
  • 解题思路
  • C++算法实现
vector<int> findAnagrams(string s, string p) {
    int left=0,right=0;
    vector<int> res;

    unordered_map<char,int> windows;
    unordered_map<char,int> needs;

    for(int i=0;i<p.size();++i) needs[p[i]]++;

    int match=0;
    while(right<s.size()){
        char c1=s[right];
        if(needs.count(c1)){
            windows[c1]++;
            if(windows[c1]==needs[c1]) match++;
        }
        right++;

        while(match==needs.size()){
            if((right-left)==p.size()) res.push_back(left);

            char c2=s[left];
            if(needs.count(c2)){
                windows[c2]--;
                if(needs[c2]>windows[c2])match--;
            }
            left++;
        }
    }

    return res;
}

3. 无重复字符的最长子串

  • 题目描述

给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

示例 1:
输入: "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。

示例 2:
输入: "bbbbb"
输出: 1
解释: 因为无重复字符的最长子串是 "b",所以其长度为 1。

示例 3:
输入: "pwwkew"
输出: 3
解释: 因为无重复字符的最长子串是 "wke",所以其长度为 3。
     请注意,你的答案必须是 子串 的长度,"pwke" 是一个子序列,不是子串。
  • 解题思路
  • C++算法实现
int lengthOfLongestSubstring(string s) {
    int left=0,right=0,res=0;

    unordered_map<char,int> windows;
    while(right<s.size()){
        char c1=s[right];
        windows[c1]++;
        right++;

        while(windows[c1]>1){
            char c2=s[left];
            windows[c2]--;
            left++;
        }

        res=max(res,right-left);
    }

    return res;
}

二、快慢指针的常用算法

快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。

206. 反转链表

  • 题目描述

反转一个单链表。

示例:
输入: 1->2->3->4->5->NULL
输出: 5->4->3->2->1->NULL
  • 解题思路

p1作为前面的指针探路,p2作为后面的指针跟进,顺着链表跑一圈,搞定问题。

  • C++算法实现
ListNode* reverseList(ListNode* head) {
    ListNode *p1, *p2;
    p1 = head;
    p2 = NULL;

    while (p1 != NULL) {
        ListNode *tmp=p1->next;
        p1->next = p2;
        p2 = p1;
        p1 = tmp;
    }

    return p2;
}

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

  • 题目描述

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

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

使用两个指针,p1前面的指针先走n步,然后让后面的指针p2与同步p1走,p1走到终点,p2即走到要删除的结点位置。

  • C++算法实现
ListNode* removeNthFromEnd(ListNode* head, int n) {
    ListNode *p1 = head;
    ListNode *p2 = head;

    int j = n;
    while (p1 != NULL && j>=0) {
        p1 = p1->next;
        j--;
    }
    if (p1 == NULL && j==0) {
        head = head->next;
    }
    else {
       while (p1 != NULL) {
           p1 = p1->next;
           p2 = p2->next;
       }

       ListNode *tmp = p2->next;
       p2->next = p2->next->next;
   }

    return head;
}

141. 环形链表

  • 题目描述

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

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

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

通常情况下,判断是否包含重复的元素,我们使用Hash的方式来做。对于单链表的这种场景,我们也可以使用双指针的方式。

第一个指针p1每次移动两个计时器,第二个指针p2每次移动一个计时器,如果该链表存在环的话,第一个指针一定会再次碰到第二个指针,反之,则不存在环。

  • C++算法实现
bool hasCycle(ListNode *head) {
    ListNode *p1=head;
    ListNode *p2=head;
    
    while(p1!=NULL&& p1->next!=NULL&& p1->next->next!=NULL){
        p1=p1->next->next;
        p2=p2->next;
        
        if(p1==p2){
            return true;
        }
    }
    
    return false;
}

142. 环形链表 II

  • 题目描述

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

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

说明:不允许修改给定的链表。

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

第一次相遇时,假设慢指针 slow 走了 k 步,那么快指针 fast 一定走了 2k 步,也就是说比 slow 多走了 k 步(环长度的倍数)。

设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k - m,也就是说如果从 head 前进 k - m 步就能到达环起点。巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。

所以,只要我们把快慢指针中的任一个重新指向 head,然后两个指针同速前进,k - m 步后就会相遇,相遇之处就是环的起点了。

  • C++算法实现
ListNode *detectCycle(ListNode *head) {
    ListNode* fast = head;
     ListNode* slow = head;
     while(fast != NULL && fast->next != NULL) {
         slow = slow->next;
         fast = fast->next->next;
         // 快慢指针相遇,此时从head 和 相遇点,同时查找直至相遇
         if (slow == fast) {
             ListNode* index1 = fast;
             ListNode* index2 = head;
             while (index1 != index2) {
                 index1 = index1->next;
                 index2 = index2->next;
             }
             return index2; // 返回环的入口
         }
     }
     return NULL;
}

148. 排序链表

  • 题目描述

在 O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序。

示例 1:
输入: 4->2->1->3
输出: 1->2->3->4

示例 2:
输入: -1->5->3->4->0
输出: -1->0->3->4->5
  • 解题思路

模仿并归排序的思路,典型的回溯算法。

如果待排的元素存储在数组中,我们可以用并归排序。而这些元素存储在链表中,我们无法直接利用并归排序,只能借鉴并归排序的思想对算法进行修改。

并归排序的思想是将待排序列进行分组,直到包含一个元素为止,然后回溯合并两个有序序列,最后得到排序序列。

对于链表我们可以递归地将当前链表分为两段,然后merge,分两段的方法是使用双指针法,p1指针每次走两步,p2指针每次走一步,直到p1走到末尾,这时p2所在位置就是中间位置,这样就分成了两段。

  • C++算法实现
 ListNode* sortList(ListNode* head) {
       if(!head||!head->next) return head;
        ListNode *pre=head,*slow=head,*fast=head;
        while(fast&&fast->next)
        {
            pre=slow;
            slow=slow->next;
            fast=fast->next->next;
        }
        pre->next=NULL;
        return merge(sortList(head),sortList(slow));
    }
    ListNode* merge(ListNode* l1,ListNode* l2)
    {
        ListNode* dummy=new ListNode(-1);
        ListNode* cur=dummy;
        while(l1&&l2)
        {
            if(l1->val>l2->val)
            {
                cur->next=l2;
                l2=l2->next;
            }
            else 
            {
                cur->next=l1;
                l1=l1->next;
            }
            cur=cur->next;
        }
        if(l1) cur->next=l1;
        if(l2) cur->next=l2;
        return dummy->next;
    }

参考链接:

双指针技巧总结

双指针技术在求解算法题中的应用

猜你喜欢

转载自blog.csdn.net/wxplol/article/details/108267441