刷题的过程关键是通过 语言刷算法解题的思维过程。
只要把题目搞清晰,会做coding好了、这一类都会了,目的就达到了。
当然, C++一方面很多书籍在用其表达;另一方面自己之前也学过相关的数据结构,灵活运用在算法方面的能力很强。
其它,大项目可以用别的语言。<工具 暂不是大问题。>
算法思维解释
本文通常将双指针可以分为两类:一类是【快慢指针】,另一类是【左右指针】;
- 快慢指针:通常解决链表中的问题,比如典型的判定链表中是否包含环;
- 左右指针:主要解决数组(或字符串)中的问题比如:二分查找、滑动窗口、nSum问题。
左右指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多 个数组的多个指针。
若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的 区域即为当前的窗口),经常用于区间搜索。
若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是 排好序的。
快慢指针
快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。
1、判定链表中是否含有环
这应该属于链表最基本的操作了,如果读者已经知道这个技巧,可以跳过。
单链表的特点是每个节点只知道下一个节点,所以一个指针的话无法判断链表中是否含有环的。
如果链表中不含环,那么这个指针最终会遇到空指针 null 表示链表到头了,这还好说,可以判断该链表不含环。
boolean hasCycle(ListNode head) {
while (head != null)
head = head.next;
return false;
}
但是如果链表中含有环,那么这个指针就会陷入死循环,因为环形数组中没有 null 指针作为尾部节点。
经典解法就是用两个指针,一个每次前进两步,一个每次前进一步。如果不含有环,跑得快的那个指针最终会遇到 null,说明链表不含环;如果含有环,快指针最终会超慢指针一圈,和慢指针相遇,说明链表含有环。
注意:要深挖追逐的本质.
【LeetCode】142.LinkedListCycle
题目既需要找出是否存在环路; 还需要返回链中的节点。
-
首先,快指针和慢指针 如果只快一步的话,每走一次,快指针将快于慢指针 —— 而且如果两者相遇,在相遇点之前假设 slow走了 k步,【一圈的长度也是k步】,那么 因为相遇了——所以 fast是走了 2k步。
-
而且,最终第一次相遇必然是在 链尾。但是,第一次相遇不一定是在链尾, 也可能在中间——因为 设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k - m,也就是说如果从 head 前进 k - m 步就能到达环起点。
巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。 -
所以,我们 要求链中,只需要把 slow==head,
然后找到 fast->next == slow++;的那个slow 即可。
101书中:对于链表找环路的问题,有一个通用的解法——快慢指针(Floyd 判圈法)。给定两个指针,
分别命名为 slow 和 fast,起始位置在链表的开头。每次 fast 前进两步,slow 前进一步。如果 fast
可以走到尽头,那么说明没有环路;如果 fast 可以无限走下去,那么说明一定有环路,且一定存
在一个时刻 slow 和 fast 相遇。当 slow 和 fast 第一次相遇时,我们将 fast 重新移动到链表开头,并
让 slow 和 fast 每次都前进一步。当 slow 和 fast 第二次相遇时,相遇的节点即为环路的开始点。
ListNode *detectCycle(ListNode *head) {
ListNode *slow = head, *fast = head;
// 判断是否存在环路
do {
if (!fast || !fast->next) return nullptr;
fast = fast->next->next;
slow = slow->next;
} while (fast != slow);
// 如果存在,查找环路节点
fast = head;
while (fast != slow){
slow = slow->next;
fast = fast->next;
}
return fast;
}
2、已知链表中含有环,返回这个环的起始位置
这个问题其实不困难,有点类似脑筋急转弯,先直接看代码:
可以看到,当快慢指针相遇时,让其中任一个指针重新指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。这是为什么呢?
第一次相遇时,假设慢指针 slow 走了 k 步,那么快指针 fast 一定走了 2k 步,也就是说比 slow 多走了 k 步(也就是环的长度)。
设相遇点距环的起点的距离为 m,那么环的起点距头结点 head 的距离为 k - m,也就是说如果从 head 前进 k - m 步就能到达环起点。
巧的是,如果从相遇点继续前进 k - m 步,也恰好到达环起点。
所以,只要我们把快慢指针中的任一个重新指向 head,然后两个指针同速前进,k - m 步后就会相遇,相遇之处就是环的起点了。
3、寻找链表的中点
类似上面的思路,我们还可以让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。
ListNode slow, fast;
slow = fast = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
// slow 就在中间位置
return slow;
当链表的长度是奇数时,slow 恰巧停在中点位置;如果长度是偶数,slow 最终的位置是中间偏右:
寻找链表中点的一个重要作用是对链表进行归并排序。
回想数组的归并排序:求中点索引递归地把数组二分,最后合并两个有序数组。对于链表,合并两个有序链表是很简单的,难点就在于二分。
但是现在你学会了找到链表的中点,就能实现链表的二分了。关于归并排序的具体内容本文就不具体展开了。
4、寻找链表的倒数第 k 个元素
我们的思路还是使用快慢指针,让快指针先走 k 步,然后快慢指针开始同速前进。这样当快指针走到链表末尾 null 时,慢指针所在的位置就是倒数第 k 个链表节点(为了简化,假设 k 不会超过链表长度):
ListNode slow, fast;
slow = fast = head;
while (k-- > 0)
fast = fast.next;
while (fast != null) {
slow = slow.next;
fast = fast.next;
}
return slow;
5、其它单链表基本操作 - 非快慢指针
定义
链表:由一系列结点(链表中每一个元素称为结点)组成,每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
在这里插入图片描述
typedef struct ListNode{
int val;
struct ListNode* next;
ListNode(int x) :
val(x), next(NULL){
}
};
基本操作
- 创建单链表
后面的操作均在其基础上进行修改
步骤:
创建头节点head,并且将当前结点p指向头结点(p=head)
创建下一个结点q,当前结点p的下一结点为q(p->next=q)
结点p后移一位(p = p->next)
#include <iostream>
#include<vector>
using namespace std;
struct ListNode{
int val;
struct ListNode* next;
ListNode(int x) :
val(x), next(NULL){
}
};
int main(){
int num;
cin >> num;
ListNode* head = new ListNode(num);
ListNode* p = head;
//利用尾插法创建一个链表
while (cin >> num){
ListNode* q = new ListNode(num);
p->next = q;
p = p->next;
}
//遍历这个链表,并输出每个结点的元素
ListNode* m = head;
while (m != nullptr){
cout << m->val << endl;
m = m->next;
}
return 0;
}
- 插入节点
判断原链表是否是空链表,如果是,将head指向新增结点
如果不是空链表,向链表尾部插入新结点
ListNode* insertNode(ListNode* head, int data){
ListNode* newNode = new ListNode(data);
ListNode* p = head;
if (p == nullptr){
head = newNode;
}
else{
while (p->next != nullptr){
p = p->next;
}
p->next = newNode;
}
return head;
}
- 删除节点
在这里插入图片描述
ListNode* deleteNode(ListNode* head, int data){
ListNode* p = head;
//首先判断是不是空链表
if (p == nullptr){
return head;
}
else{
//判断是不是删除头节点
if (p->val == data){
head = p->next;
delete p;
return head;
}
else{
//如果有该结点,遍历到待删除节点的前一节点
while (p->next != nullptr && p->next->val != data){
p = p->next;
}
//遍历完整个链表都没有待删除节点
if (p->next == nullptr){
return head;
}
else{
ListNode* deleteNode = p->next;
p->next = deleteNode->next;
delete deleteNode;
return head;
}
}
}
}
- 反转链表
在这里插入图片描述
如上图,假设在I开始反转,那么需要知道i前面的h,还要保留i后面的j,防止链表断裂
假设pNode是当前的节点,pPrev是pNode前面的节点,PNext是PNode后面的节点,那么:
当pNode不为nullptr,且pNext不为nullptr的时候
1 将pNode指向pPrev(pNode->next = pPrev)
2 将pNode给pPrev(pPrev= pNode)
3 将pNext给pNode(pNode = pNext)
当pNode不为nullptr,且pNext==nullptr的时候,把反转后的头部指向pNode
注意:判断边界条件
#include <iostream>
#include<vector>
using namespace std;
struct ListNode{
int val;
struct ListNode* next;
ListNode(int x) :
val(x), next(NULL){
}
};
//反转链表
ListNode* reverse(ListNode* head){
ListNode* pPrev = nullptr;
ListNode* p = head;
ListNode* pReverseHead = nullptr;
while (p != nullptr){
ListNode* pNext = p->next;
if (pNext == nullptr){
pReverseHead = p;
}
p->next = pPrev;
pPrev = p;
p = pNext;
}
return pReverseHead;
}
int main(){
int num;
cin >> num;
ListNode* head = new ListNode(num);
ListNode* p = head;
while (cin >> num){
ListNode* q = new ListNode(num);
p->next = q;
p = p->next;
}
p->next = nullptr;
ListNode* result = reverse(head);
ListNode* node = result;
while (node != nullptr){
cout << node->val << endl;
node = node->next;
}
return 0;
左右指针的常用算法
左右指针主要用于遍历数组,两个指针指向不同的元素,从而协同完成任务。也可以延伸到多 个数组的多个指针。
若两个指针指向同一数组,遍历方向相同且不会相交,则也称为滑动窗口(两个指针包围的 区域即为当前的窗口),经常用于区间搜索。【子字符串的匹配问题——一个大的话题,在下一篇写出来。】
若两个指针指向同一数组,但是遍历方向相反,则可以用来进行搜索,待搜索的数组往往是 排好序的。
- KSum问题–双指针去除重复思维
- 二分查找
- 翻转数组
- 滑动窗口【下一节】
1、KSum类型思维
TwoSum总结
对于 TwoSum 问题,⼀个难点就是给的数组⽆序。对于⼀个⽆序的数组, 我们似乎什么技巧也没有,只能暴⼒穷举所有可能。
**⼀般情况下,我们会⾸先把数组①排序②再考虑双指针技巧。**TwoSum 启发我 们,HashMap 或者 HashSet 也可以帮助我们处理⽆序数组相关的简单问题。 另外,设计的核⼼在于权衡,利⽤不同的数据结构,可以得到⼀些针对性的 加强。
最后,如果 TwoSum I 中给的数组是有序的,应该如何编写算法呢?答案很 简单,前⽂「双指针技巧汇总」写过:
int[] twoSum(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
return new int[]{left, right};
} else if (sum < target) {
left++; // 让 sum ⼤⼀点
} else if (sum > target) {
right--; // 让 sum ⼩⼀点
}
}
// 不存在这样两个数
return new int[]{-1, -1};
}
ThreeSum
15题 三数之和解题思路
题目描述:
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0
?请你找出所有满足条件且不重复的三元组。 注意:答案中不可以包含重复的三元组。 示例:给定数组 nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为: [ [-1, 0, 1], [-1, -1, 2] ]
思路:
标签:数组遍历
- 首先对数组进行排序,排序后固定一个数 nums[i],再使用左右指针指向 nums[i]后面的两端,数字分别为 nums[L]和 nums[R],计算三个数的和 sum判断是否满足为 0,满足则添加进结果集
- 如果 nums[i]大于 0,则三数之和必然无法等于 0,结束循环
- 如果 nums[i] == nums[i−1],则说明该数字重复,会导致结果重复,所以应该跳过
- 当 sum== 0 时,nums[L] == nums[L+1]则会导致结果重复,应该跳过,L++
- 当 sum== 0 时,nums[R] == nums[R−1] 则会导致结果重复,应该跳过,R−−
- 时间复杂度:O(n^2),n 为数组长度
代码:
# Solution2:
# 关键之处在于边界,以及去重
from typing import List
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
if( not nums or n < 3):
return []
res = []
sort_num = sorted(nums)
for i,v in enumerate(sort_num):
if(sort_num[i] > 0): # 遗漏了!如果当前数字大于0,则三数之和一定大于0,所以结束循环
return res
# 去重
if i >= 1 and sort_num[i] == sort_num[i-1]:
continue
L = i+1; R = n-1
while L<R:
sum = sort_num[i] + sort_num[L] + sort_num[R]
if sum == 0:
res.append([sort_num[i], sort_num[L], sort_num[R]])
# 去重并寻找下一个不是次数的数值
L+=1; R-=1; # 可以这样 因为排好序了,已经不可能只移动一边了! YES!【关键这里!】
while(L<R and sort_num[L] == sort_num[L-1]): L+=1
while(R>L and sort_num[R] == sort_num[R+1]): R-=1
elif sum>0:
R-=1
elif sum < 0 :
L+=1
return res
if __name__ == "__main__":
a = Solution()
# print(a.threeSum([-1,0,1,2,-1,-4]))
# print(a.threeSum([0,0,0,0]))
print(a.threeSum([1,0, -1]))
# print(a.threeSum([-4,-2,-2,-2,0,1,2,2,2,3,3,4,4,6,6]))
引用图解:
关键之处与C++实现
#include <iostream>
#include <vector>
#include <windows.h>
#include <algorithm>
using namespace std;
// 方法一: 去重,双指针大法
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
// 三数之和; 关键是边界限制,防止重复!
// 首先写出算法思想
// 首先 无数或者<3返回null
// 然后 排序数组
// head = 0
// 最后:for all
// 边界:others > 0 returen;
// 类边界:避免重复,和下一个是否重复 则break;
// 最后,
vector<vector<int>> answer = {};
if(nums.empty() || nums.size() < 3){
return answer;
}
sort(nums.begin(), nums.end());
for(int i = 0; i < nums.size()-2 ;i++){
// 去重操作
if( i > 0 && nums[i] == nums[i-1] ){
continue;
}
// 【答案】边界限制应该在这! —— 加速时间
if(nums[i] > 0){
return answer; // 【改进】直接返回就好了,节省时间.
}
int head = i+1, tail = nums.size()-1;
int target = 0-nums[i];
while(head < tail){
int others = nums[head]+nums[tail];
// 边界限制
// if(others < 0){
// return answer;
// }
//采用后 去重方法!否则容易 漏项。
if(others > target){
tail--;
// while(head<tail && nums[tail] == nums[tail+1]) tail--; //多余了
}else if(others < target){
head++;
// while(head<tail && nums[head] == nums[head-1]) head++; //多余了
}else if(others == target){
answer.push_back(vector<int> {nums[i], nums[head], nums[tail]});
tail--;
while(head<tail && nums[tail] == nums[tail+1]) tail--; //head<tail &&
head++;
while(head<tail && nums[head] == nums[head-1]) head++;
}
}
}
return answer;
}
};
//方法二: 3Sum--变2Sum 两层去 count()
int main(){
Solution s;
// vector<int> c = {-1, 0, 1, 2, -1, -4};
// vector<int> c = {0,0,0,0};
vector<int> c = {34,55,79,28,46,33,2,48,31,-3,84,71,52,-3,93,15,21,-43,57,-6,86,56,94,74,83,-14,28,-66,46,-49,62,-11,43,65,77,12,47,61,26,1,13,29,55,-82,76,26,15,-29,36,-29,10,-70,69,17,49};
vector<vector<int>> a = s.threeSum(c);
for (int i = 0; i < a.size(); i++)
{
for(int j = 0;j<3;j++){
cout << a[i][j] << " ";
}
cout << endl;
}
printf("Hello World!\n");
system("pause");
return 0;
总结与提升:
- 总体思想——在TwoSum 时使用【左右指针】进行快速遍历,其它KSum 部分使用暴力法——并注意 去除重复。
- 首先,判定是否 不存在或 <K ,则返回。
- 然后,对数组进行排序,排序后固定K-2个数暴力遍历nums[i],num[j] ……
- 再使用左右指针指向 除去nums[i],num[j] …… 后面的两端,数字分别为 nums[L]和 nums[R]
- 如果 nums[i]大于 0,则三数之和必然无法等于 0,结束循环 - 如果 nums[i] == nums[i−1],则说明该数字重复,会导致结果重复,所以应该跳过
- 计算K个数的和 sum判断是否满足为 target,当 sum== target 时,满足则添加进结果集。nums[L] == nums[L+1]则会导致结果重复,应该跳过,L++; nums[R] == nums[R−1] 则会导致结果重复,应该跳过,R−− ;
- 当 sum < target 时,L++
- 当 sum > target 时,R−− - 时间复杂度:O(n^(k-1) ),n 为数组长度
- 关键伪代码和思考: 在于去除重复。
//采用后 去重方法!否则容易 漏项。
//
if(others > target){
tail--;
// while(head<tail && nums[tail] == nums[tail+1]) tail--; //多余了
}else if(others < target){
head++;
// while(head<tail && nums[head] == nums[head-1]) head++; //多余了
}else if(others == target){
answer.push_back(vector<int> {nums[i], nums[head], nums[tail]});
tail--;
while(head<tail && nums[tail] == nums[tail+1]) tail--; //head<tail &&
head++;
while(head<tail && nums[head] == nums[head-1]) head++;
}
而且要结合两数相加的特性—— 不会重复,两个加和唯一,所以直接两边 双指针一起同时加减即可 没有遗漏了。
3. 然后锦上添花:边界限制——想不对的就不加:
// 【答案】边界限制应该在这! —— 加速时间
if(nums[i] > 0){
return answer; // 【改进】直接返回就好了,节省时间.
}
2、二分查找【扩展见 另一篇】
/**
* @description:
* @param {*}
* @return {*}
* @notes: 已经排好序的数组
*/
int binarySearch(int nums[], int target){
int n = sizeof(nums) / sizeof(nums[0]);
int left = 0;
int right = n -1;
// 查找所以等于
while(left <= right){
int mid = (left+right) / 2;
if( nums[mid] == target ){
return mid;
}else if (nums[mid] > target)
{
left = mid+1;
}else if (nums[mid] < target){
right = mid - 1;
}
}
return -1; // 表示未查找到。
}
3、翻转数组
int reverse(int nums[]){
int n = sizeof(nums) / sizeof(nums[0]);
int left = 0;
int right = n-1;
while(left<right){
int temp = nums[left];
nums[left]=nums[right];
nums[right]=temp;
left++, right--;
}
}
4、滑动窗口【扩展 见另一篇总结】
这也许是双指针技巧的最高境界了,如果掌握了此算法,可以解决一大类子字符串匹配的问题,不过「滑动窗口」算法比上述的这些算法稍微复杂些。
幸运的是,这类算法是有框架模板的,下篇文章就准备讲解「滑动窗口」算法模板,帮大家秒杀几道 LeetCode 子串匹配的问题
总结
以上,谢谢朋友们!
加油!