目录
一般双指针算法主要分为两类:(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]
- 解题思路
需要考虑:
- 开始数组是否有序;
- 索引从0开始计算还是1开始计算?
- 没有解该怎么办?
- 有多个解怎么办?保证有唯一解。
在排序前先使用一个额外的数组拷贝一份原来的数组,对于两个相同元素的索引问题,使用一个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]]
- 解题思路
审题
- 数组不是有序的;
- 返回结果为全部解,多个解的顺序是否需要考虑?--不需要考虑顺序
- 什么叫不同的三元组?索引不同即不同,还是值不同?--题目定义的是,值不同才为不同的三元组
- 没有解时怎么返回?--空列表
开始时对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、滑动窗口算法
滑动窗口算法的思路是这样:
- 用双指针中的左右指针技巧,初始化 left = right = 0,把索引闭区间 [left, right] 称为一个「窗口」。
- 我们先不断地增加 right 指针扩大窗口 [left, right],直到窗口符合要求。
- 此时,我们停止增加 right,转而不断增加 left 指针缩小窗口 [left, right],直到窗口中不再符合要求(不包含 T 中的所有字符了)。同时,每次增加 left,我们都要更新一轮结果。
- 重复第 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;
}
参考链接: