双指针算法
快慢指针
类似于龟兔赛跑,两个链表上的指针从同一节点出发,其中一个指针前进速度是另一个指针的两倍。利用快慢指针可以用来解决某些算法问题,比如
- 计算链表的中点:快慢指针从头节点出发,每轮迭代中,快指针向前移动两个节点,慢指针向前移动一个节点,最终当快指针到达终点的时候,慢指针刚好在中间的节点。
- 判断链表是否有环:如果链表中存在环,则在链表上不断前进的指针会一直在环里绕圈子,且不能知道链表是否有环。使用快慢指针,当链表中存在环时,两个指针最终会在环中相遇。
- 判断链表中环的起点:当我们判断出链表中存在环,并且知道了两个指针相遇的节点,我们可以让其中任一个指针指向头节点,然后让它俩以相同速度前进,再次相遇时所在的节点位置就是环开始的位置。
- 求链表中环的长度:只要相遇后一个不动,另一个前进直到相遇算一下走了多少步就好了
- 求链表倒数第k个元素:先让其中一个指针向前走k步,接着两个指针以同样的速度一起向前进,直到前面的指针走到尽头了,则后面的指针即为倒数第k个元素。(严格来说应该叫先后指针而非快慢指针)
碰撞指针
一般都是排好序的数组或链表,否则无序的话这两个指针的位置也没有什么意义。特别注意两个指针的循环条件在循环体中的变化,小心右指针跑到左指针左边去了。常用来解决的问题有
-
二分查找问题
-
n数之和问题:比如两数之和问题,先对数组排序然后左右指针找到满足条件的两个数。如果是三数问题就转化为一个数和另外两个数的两数问题。以此类推。
滑动窗口法
类似于这种形式
两个指针,一前一后组成滑动窗口,并计算滑动窗口中的元素的问题。
这类问题一般包括
-
字符串匹配问题
-
子数组问题
引入例子
下面的例子是从知乎上看到的一篇文章
滑动窗口法可以用来解决一些查找满足一定条件的连续区间的性质(长度等)的问题。由于区间连续,因此当区间发生变化时,可以通过旧有的计算结果对搜索空间进行剪枝,这样便减少了重复计算,降低了时间复杂度。
给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的连续子数组。如果不存在符合条件的连续子数组,返回 0。
示例:
输入: s = 7, nums = [2,3,1,2,4,3]
输出: 2
解释: 子数组 [4,3] 是该条件下的长度最小的连续子数组。
这道题目最简单的解法自然是枚举每个数组起点和终点,这种解法的时间复杂度是O(N^2)
(python写法)
def solver(nums, s):
optim = len(nums) + 1
for start in range(len(nums)):
summation = 0
for end in range(start, len(nums)):
summation += nums[end]
if summation >= s:
optim = min(optim, end - start + 1)
break
return optim
通过分析可以发现,这种解法进行了很多重复计算,首先是对于状态的重复计算,比如当start为0时,我们计算了区间[0,0], [0,1], [0,2],…等的和,但当start为1时,我们又重新计算了区间[1,1], [1,2], …,的和,但事实上,这些区间的值是可以根据上一次计算的结果直接得到的,如区间[1,2]等于区间[0,2]减去nums[0]的值。换句话说,我们可以根据之前计算得到的结果来推断还未进行计算的结果,这也为剪枝带来了可能。
考虑这样一个例子,给定数组为[2,3,1,2,4,3],并给定要求的最小和s为7,通过第一次枚举,我们得知子数组[2,3],[2,3,1]都是小于7的,那我们也就没有必要在接下来的阶段对子数组[3],子数组[3,1]进行枚举检查了,因为他们的和一定是小于7的。若实现了这种剪枝,时间复杂度便可以得到大幅的优化。
那么如何实现这样的剪枝呢?考虑这样一种情形,数轴上存在一个滑动窗口,假设其左右端点分别为L和R。首先我们移动R,使得滑动窗口的区间满足给定的条件,然后我们再移动L,直到滑动区间不再满足给定的条件,如此循环往复,并在其过程中记录最优值。继续之前的例子,如图所示,过程如下:
- 滑动窗口的长度为0,位于数轴的最左端
- 滑动窗口右端R开始移动,直到区间满足给定的条件,也就是和大于7,停止于第三个元素2,记录下来当前的最优长度为4
- 滑动窗口左端L开始移动,并停止于第一个元素3,此时区间和为6,使得区间和不满足给定的条件
- 滑动窗口右端R继续移动,停止于第四个元素4,在过程中,最优长度仍然为4
- 滑动窗口左端L移动至第三个元素2,过程中更新最优长度为3
- 滑动窗口右端R移动至最后一个元素3,
- 滑动窗口左端L移动至最后一个元素,并在过程中更新最优长度为2
总结:
通过上述分析可知,滑动区间的两个指针i,j是只增大不减小的,适用于解决一个动态区间的问题,此问题一般可以用多重循环进行枚举解决,但是在枚举过程中会出现很多重复计算,或者是很多不必要的计算,这时就可以考虑一下滑动窗口法。在上述问题中,找出该数组中满足其和 ≥ s 的长度最小的连续子数组,有两个条件,一个是满足其和 ≥ s ,另一个是最小的连续子数组,我们可以利用滑动窗口来解决,先满足第一个条件,再去求解第二个条件。滑动窗口问题中一般是窗口里面的状态代表了窗口已经滑过区域的状态,无需再重复计算已划过的区间。
例题
小明维护着一个程序员论坛。现在他收集了一份”点赞”日志,日志共有 N 行。
其中每一行的格式是:
ts id
表示在 ts 时刻编号 id 的帖子收到一个”赞”。
现在小明想统计有哪些帖子曾经是”热帖”。
如果一个帖子曾在任意一个长度为 D 的时间段内收到不少于 K 个赞,小明就认为这个帖子曾是”热帖”。
具体来说,如果存在某个时刻 T 满足该帖在 [T,T+D) 这段时间内(注意是左闭右开区间)收到不少于 K 个赞,该帖就曾是”热帖”。
给定日志,请你帮助小明统计出所有曾是”热帖”的帖子编号。
输入格式
第一行包含三个整数 N,D,K。
以下 N 行每行一条日志,包含两个整数 ts 和 id。
输出格式
按从小到大的顺序输出热帖 id。
每个 id 占一行。
数据范围
1≤K≤N≤10^5,
0≤ts,id≤10^5,
1≤D≤10000
输入样例:
7 10 2
0 1
0 10
10 10
10 1
9 1
100 3
100 3
输出样例:
1
3
分析
暴力枚举,每次只确定当前id
#include <iostream>
#include <cstdio>
#include <algorithm>
#define x first
#define y second
using namespace std;
typedef pair<int, int> PII;
const int N = 100010;
// 日志数, 时间间隔(左闭右开), 热度标准, 日志格式为: 时刻 id, 用一个pair来存
int n, d, k;
// 判断某条日志是否为热度日志
bool st[N];
// 存所有的日志
PII logs[N];
int main()
{
cin >> n >> d >> k;
for (int i = 0; i < n; i ++ )
// 分别读入时刻和日志的id
scanf("%d%d", &logs[i].x, &logs[i].y);
// 排序, pair首先会根据first来排序然后是根据second来排序(所以时刻放前面)
sort(logs, logs + n);
//i表示一次只确定一个id
for (int i = 0; i < n; i ++ )
{
//因为统计的是当前logs[i].y,当前已经点赞,所以cnt从1开始
int cnt = 1;
// 记录第i条日志的出现的时刻(我们的日志已经按时刻排过序了)
int t = logs[i].x;
// 枚举后面d个时间段内的日志是否存在id相同的日志
for (int j = i + 1; j < n; j ++ )
{
// 存在符合时间段内且id相同的日志
if (logs[j].x - t < d && logs[j].y == logs[i].y)
cnt ++ ;
// 不符合题意直接break
if (logs[j].x - t >= d) break;
}
// 符合热度日志标记一下
if (cnt >= k)
st[logs[i].y] = true;
}
// id可能为0, 也可能为1e5
for (int i = 0; i <= N; i ++ )
if (st[i])
cout << i << endl;
return 0;
}
滑动窗口法
#include <cstdio>
#include <cstring>
#include <iostream>
#include <algorithm>
#define x first
#define y second
using namespace std;
typedef pair<int, int> PII;
const int N = 100010;
int n, d, k;
PII logs[N];
int cnt[N];
bool st[N]; // 记录每个帖子是否是热帖
int main()
{
scanf("%d%d%d", &n, &d, &k);
for (int i = 0; i < n; i ++ ) scanf("%d%d", &logs[i].x, &logs[i].y);
sort(logs, logs + n);
for (int i = 0, j = 0; i < n; i ++ )
{
int id = logs[i].y;
cnt[id] ++ ;
while (logs[i].x - logs[j].x >= d)
{
cnt[logs[j].y] -- ;
j ++ ;
}
// 必须要有判断
// 有可能会出现前面已经是热帖,后面因为时间问题cnt[id]<k的情况
if (cnt[id] >= k) st[id] = true;
}
for (int i = 0; i <= 100000; i ++ )
if (st[i])
printf("%d\n", i);
return 0;
}
BFS
与深度优先搜索相比,DFS是深度优先、BFS是广度优先即一层一层的搜索。
广度优先搜索使用队列(queue)来实现
1、把根节点放到队列的末尾。
2、每次从队列的头部取出一个元素,查看这个元素所有的下一级元素,把它们放到队列的末尾。并把这个元素记为它下一级元素的前驱。
3、找到所要找的元素时结束程序。
4、如果遍历整个树还没有找到,结束程序。
模拟搜索过程
例题
阿尔吉侬是一只聪明又慵懒的小白鼠,它最擅长的就是走各种各样的迷宫。
今天它要挑战一个非常大的迷宫,研究员们为了鼓励阿尔吉侬尽快到达终点,就在终点放了一块阿尔吉侬最喜欢的奶酪。
现在研究员们想知道,如果阿尔吉侬足够聪明,它最少需要多少时间就能吃到奶酪。
迷宫用一个 R×C 的字符矩阵来表示。
字符 S 表示阿尔吉侬所在的位置,字符 E 表示奶酪所在的位置,字符 # 表示墙壁,字符 . 表示可以通行。
阿尔吉侬在 1 个单位时间内可以从当前的位置走到它上下左右四个方向上的任意一个位置,但不能走出地图边界。
输入格式
第一行是一个正整数 T,表示一共有 T 组数据。
每一组数据的第一行包含了两个用空格分开的正整数 R 和 C,表示地图是一个 R×C 的矩阵。
接下来的 R 行描述了地图的具体内容,每一行包含了 C 个字符。字符含义如题目描述中所述。保证有且仅有一个 S 和 E。
输出格式
对于每一组数据,输出阿尔吉侬吃到奶酪的最少单位时间。
若阿尔吉侬无法吃到奶酪,则输出“oop!”(只输出引号里面的内容,不输出引号)。
每组数据的输出结果占一行。
数据范围
1<T≤10,
2≤R,C≤200
输入样例:
3
3 4
.S..
###.
..E.
3 4
.S..
.E..
....
3 4
.S..
####
..E.
输出样例:
5
1
oop!
代码
#include<iostream>
#include<cstring>
#include<cstdio>
#include<algorithm>
#include<queue>
#define x first
#define y second
using namespace std;
typedef pair<int,int> PII;
const int N = 210;
int n,m;
char g[N][N];
int dist[N][N];
int dx[4] = {-1,0,1,0},dy[4] = {0,1,0,-1};
int bfs(PII start, PII end)
{
queue<PII> q;
memset(dist, -1, sizeof dist);
dist[start.x][start.y] = 0;
q.push(start);
int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
while (q.size())
{
auto t = q.front();
q.pop();
for (int i = 0; i < 4; i ++ )
{
int x = t.x + dx[i], y = t.y + dy[i];
if (x < 0 || x >= n || y < 0 || y >= m) continue; // 出界
if (g[x][y] == '#') continue; // 障碍物
if (dist[x][y] != -1) continue; // 之前已经遍历过
dist[x][y] = dist[t.x][t.y] + 1;
if (end == make_pair(x, y)) return dist[x][y];
q.push({x, y});
}
}
return -1;
}
int main()
{
int T;
scanf("%d", &T);
while (T -- )
{
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i ++ ) scanf("%s", g[i]);
PII start, end;
for (int i = 0; i < n; i ++ )
for (int j = 0; j < m; j ++ )
if (g[i][j] == 'S') start = {i, j};
else if (g[i][j] == 'E') end = {i, j};
int distance = bfs(start, end);
if (distance == -1) puts("oop!");
else printf("%d\n", distance);
}
return 0;
}
最短路径问题
dijkstra算法
模板
int g[N][N]; // 存储每条边
int dist[N]; // 存储1号点到每个点的最短距离
bool st[N]; // 存储每个点的最短路是否已经确定
// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n; i ++ )//每次确定一个点
{
int t = -1; // 在还未确定最短路的点中,寻找距离最小的点
for (int j = 1; j <= n; j ++ )
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
// 用t更新其他点的距离
for (int j = 1; j <= n; j ++ )
dist[j] = min(dist[j], dist[t] + g[t][j]);
st[t] = true;
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
作者:yxc
链接:https://www.acwing.com/blog/content/405/
来源:AcWing
例题
给定一个 n 个点 m 条边的有向图,图中可能存在重边和自环,所有边权均为正值。
请你求出 1 号点到 n 号点的最短距离,如果无法从 1 号点走到 n 号点,则输出 −1。
输入格式
第一行包含整数 n 和 m。
接下来 m 行每行包含三个整数 x,y,z,表示存在一条从点 x 到点 y 的有向边,边长为 z。
输出格式
输出一个整数,表示 1 号点到 n 号点的最短距离。
如果路径不存在,则输出 −1。
数据范围
1≤n≤500,
1≤m≤105,
图中涉及边长均不超过10000。
输入样例:
3 3
1 2 2
2 3 1
1 3 4
输出样例:
3
代码
#include<iostream>
#include<cstring>
using namespace std;
const int N = 510;
int g[N][N];//存储图
int dist[N];//到源点的距离
bool st[N];//判断某点是否已经确定到原点的最短路
int n,m;
int dijkstra(){
memset(dist,0x3f,sizeof dist);//初始化
dist[1] = 0;//此时将源点的距离更新
for(int i=0;i<n;i++){
int t = -1;
for(int j = 1;j<=n;j++){
if(!st[j] && (t == -1 || dist[t]>dist[j])) t=j;
}
// 用t更新其他点的距离
for(int j=1;j<=n;j++){
dist[j] = min(dist[j],dist[t]+g[t][j]);
}
st[t] = true;//将t点加入到st集合
}
if(dist[n] == 0x3f3f3f3f) return -1;
else return dist[n];
}
int main()
{
cin>>n>>m;
memset(g,0x3f,sizeof g);
while(m--){
int x,y,z;
cin>>x>>y>>z;
g[x][y] = min(z,g[x][y]);//多条边取最小
}
cout<<dijkstra();
return 0;
}