一、DFS 与 BFS
DFS : 一条路走到尽头,走不通再往回退(深度优先)
BFS :找完同层后继续往外(下一层)去找(广度优先)
DFS(深搜)和 BFS(宽搜)在网格图(后面简称图)中与树中进行搜索最大不同在于图中可以向8个方向进行搜索,而树只需要关注当前节点的左右孩子。
对于这类题,大致分为以下几类:
1、连通区域统计(Flood Fill 洪水填充)
2、可达性问题
3、无权图最短路(单源BFS)
4、多源BFS
5、带状态的BFS
6、回溯DFS(枚举所有路径)
1、连通区域统计(Flood Fill 洪水填充)
这一类题形如网格中有1(可走)、0等数字组成的区域,求岛屿数量、最大岛屿面积、封闭岛屿等。
解题方法 : DFS / BFS
DFS:代码篇幅短,但是网格很大(1000 x 1000)递归会栈溢出,此时需要用迭代DFS或者BFS
BFS :更加稳定,但是代码篇幅稍长
标记方式 :1、vis二维数组(不修改原数组)
2、原地修改 :将访问过的1改为0,节省空间
访问一个格子后,进行标记后就不需要撤销标记,这是与回溯最大的区别
实现示例:
#include <iostream>
#include <vector>
using namespace std;
//方向数组
int dx[4] = {-1, 0, 0, 1};
int dy[4] = {0, 1, -1, 0};
int m, n;
vector<vector<char>> grid;
vector<vector<bool>> vis;
void dfs(int x, int y) {
vis[x][y] = true; //标记已经访问过的格子(也可以进行原地修改)
for(int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if(nx < 0 || nx >= m || ny < 0 || ny >= n) continue; //判断是否越界
if(grid[nx][ny] == '1' && !vis[nx][ny]){ //跳过已访问的格子和非1格子
dfs(nx, ny);
}
}
}
int main() {
cin >> m >> n;
grid.resize(m, vector<char>(n));
vis.resize(m, vector<bool>(n, false));
for(int i = 0; i < m; i++) {
for(int j = 0; j < n; j++) {
cin >> grid[i][j];
}
}
int ans = 0;
for(int i = 0; i < m; i++) {
for(int j = 0; j < n; j++) {
if(grid[i][j] == '1' && !vis[i][j]) {
dfs(i,j);
ans++;
}
}
}
cout << ans << endl;
return 0;
}
非递归(迭代DFS)写法:
当数据规模极大,递归层数过深,导致超出了系统的默认栈容量(通常只有几MB),这个时候选择自己手动实现递归(手写Stack),而自己在堆上进行模拟栈(堆上空间可达数百MB甚至GB),故可以支持更深层次的递归。
//实现手写栈还需引入头文件<stack>
void dfs(int x, int y) {
stack<pair<int, int>> st;
st.push({x, y});
vis[x][y] = true;
while(!st.empty()){ //判断栈是否为空,不为空进行操作
pair<int, int> cur = st.top(); //获取栈顶元素
int cx = cur.first;
int cy = cur.second;
st.pop();
for(int i = 0; i < 4; i++) {
int nx = cx + dx[i];
int ny = cy + dy[i];
if(nx < 0 || nx >= m || ny < 0 || ny >= n) continue;
if(grid[nx][ny] == '1' && !vis[nx][ny]){
vis[nx][ny] = true;
st.push({nx, ny});
}
}
}
}
BFS写法:
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
int dx[4] = {-1, 0, 0, 1};
int dy[4] = {0, 1, -1, 0};
int m, n;
vector<vector<char>> grid;
vector<vector<bool>> vis;
void bfs(int x, int y) {
queue<pair<int, int>> q;
q.push({x,y});
vis[x][y] = true;
while(!q.empty()) {
pair<int, int> t = q.front(); //获取队头元素
q.pop(); //弹出队头元素,为下一次入队做准备
int x = t.first;
int y = t.second;
for(int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if(nx < 0 || nx >= m || ny < 0 || ny >= n) continue;
if(grid[nx][ny] == '1' && !vis[nx][ny]) {
vis[nx][ny] = true;
q.push({nx, ny});
}
}
}
}
int main() {
int sx, sy;
cin >> m >> n;
cin >> sx >> sy;
grid.resize(m, vector<char>(n));
vis.resize(m,vector<bool>(n, false));
for(int i = 0; i < m; i++) {
for(int j = 0; j < n; j++) {
cin >> grid[i][j];
}
}
int ans = 0;
for(int i = 0; i < m; i++) {
for(int j = 0; j < n; j++) {
if(grid[i][j] == '1' && !vis[i][j]){
ans++; //启动bfs时进行ans计数,若在bfs中计数则会变成统计岛屿面积
bfs(i, j);
}
}
}
cout << ans << endl;
return 0;
}
3619. 总价值可以被 K 整除的岛屿数目 - 力扣(LeetCode)
2658. 网格图中鱼的最大数目 - 力扣(LeetCode)
1254. 统计封闭岛屿的数目 - 力扣(LeetCode)
2、可达性问题
这类问题的核心是搜索 + 标记,从起点出发,能否通过合法路径到达目标区域是这类问题的常见形式。
如:
1.起点固定,问能否到达某一点(迷宫出口问题)
2.从边界/内部出发,问最大覆盖区域
实现代码与连通区域统计中相似,对合法的格子进行题目所要求的更改或标记。
例题中733的实现:
```
#include <iostream>
#include <vector>
using namespace std;
int m, n;
int originColor;
int dx[4] = { -1,1,0,0 };
int dy[4] = { 0,0,1,-1 };
void dfs(vector<vector<int>>& image, int x, int y, int newColor) {
//判断初始点是否越界
if (x < 0 || x >= m || y < 0 || y >= n) return;
//判断是否是同型连通块
if (image[x][y] != originColor) return;
//标记为渲染后的新颜色
image[x][y] = newColor;
for (int i = 0; i < 4; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
dfs(image, nx, ny, newColor);
}
}
vector<vector<int>> floodFill(vector<vector<int>>& image, int sr, int sc, int color) {
m = image.size();
n = image[0].size();
//记录初始颜色
originColor = image[sr][sc];
//要渲染的颜色与初始相同,直接返回,避免死循环
if (originColor == color) return image;
dfs(image, sr, sc, color);
return image;
}
int main() {
cin >> m >> n;
vector<vector<int>> image(m, vector<int>(n));
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
cin >> image[i][j];
}
}
vector<vector<int>> res = floodFill(image, 1, 1, 2);
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
cout << res[i][j];
if (j < n - 1) cout << " ";
}
cout << endl;
}
return 0;
}
相关例题 :733. 图像渲染 - 力扣(LeetCode)
3、无权图最短路(单源BFS)
此类最短路问题中使用的BFS需要加入“层”,由近及远。
相关题目:1091. 二进制矩阵中的最短路径 - 力扣(LeetCode)(下附实现)
代码实现:
#include <iostream>
#include <vector>
#include <queue>
using namespace std;
int m, n;
int dx[8] = { -1, -1, -1, 0, 0, 1, 1, 1 };
int dy[8] = { 0, 1, -1, 1, -1, 1, 0, -1 };
int shortestPathBinaryMatrix(vector<vector<int>>& grid) {
m = grid.size();
n = grid[0].size();
queue<pair<int, int>> q;
if (grid[0][0] != 0 || grid[m - 1][n - 1] != 0)
return -1;
q.push({ 0, 0 });
grid[0][0] = 1; //原地标记
//记录当前层数
int steps = 1;
while (!q.empty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
auto t = q.front();
q.pop();
int x = t.first;
int y = t.second;
if (x == m - 1 && y == n - 1)
return steps;
for (int i = 0; i < 8; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if (nx < 0 || nx >= m || ny < 0 || ny >= n)
continue;
if (grid[nx][ny] != 0)
continue;
grid[nx][ny] = 1;
q.push({ nx, ny });
}
}
steps++; //搜完一层steps自增进入下一层搜索
}
return -1;
}
int main() {
cin >> m >> n;
vector<vector<int>> grid(m,vector<int>(n));
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
cin >> grid[i][j];
}
}
int res = shortestPathBinaryMatrix(grid);
cout << res << endl;
return 0;
}
4、多源BFS
顾名思义,多源(即起点),把多个起点放入队列中进行BFS,同时扩散,其写法与单源BFS大致相同,只在入队处稍稍改变。
核心 : 多源BFS = 多个起点入队 + 层数统计 + 正确标记
相关题目:994. 腐烂的橘子 - 力扣(LeetCode)
腐烂橘子实现:
#include <iostream>
#include <queue>
using namespace std;
int m, n;
int dx[4] = { 0, 1, -1, 0 };
int dy[4] = { -1, 0, 0, 1 };
int orangesRotting(vector<vector<int>>& grid) {
m = grid.size();
n = grid[0].size();
int fresh = 0; //新鲜橘子的个数
queue<pair<int, int>> q;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 2) {
q.push({ i, j }); //将所有腐烂橘子入队
}
else if (grid[i][j] == 1) {
fresh++;
}
}
}
if (fresh == 0) return 0;
int minutes = 0;
while (!q.empty()) {
int sz = q.size(); //当前层数
bool infected = false; //标记当前层是否有感染发生
for (int i = 0; i < sz; i++) {
auto t = q.front();
q.pop();
int x = t.first;
int y = t.second;
for (int k = 0; k < 4; k++) {
int nx = x + dx[k];
int ny = y + dy[k];
if (nx < 0 || nx >= m || ny < 0 || ny >= n || grid[nx][ny] != 1) continue;
grid[nx][ny] = 2;
infected = true;
fresh--;
q.push({ nx, ny });
}
}
if (infected) minutes++;
}
return fresh ? -1 : minutes;
}
int main() {
cin >> m >> n;
vector<vector<int>> grid(m, vector<int>(n));
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
cin >> grid[i][j];
}
}
int res = orangesRotting(grid);
cout << res << endl;
return 0;
}
对上面代码一些地方进行一些说明:
int sz = q.size(); 这一行要写在while循环中,如果写在外面,则sz的值被固定了,无法实现正确的层序变更。
如:
2 1 1
1 1 2
1 1 1
这个例子来进行模拟实现: 一开始将(0,0)和(1,2)的腐烂橘子入队,此时sz = 2,进行第一轮感染(while第一次循环),(0,1),(1,0),(1,1),(0,2),(2,2)位置会被感染,此时布局变更为:
2 2 2
2 2 2
1 1 2
minutes = 1
在infected标记中为: (T表示true,F表示flase, 图中不加粗F代表初始感染源)
F T T
T T F
F F T
此时腐烂橘子(保留感染能力)为5,即正确的sz也应等于5,可sz在while外,无法正确更新,下一轮while按队列顺序弹出队头2个((0,1)和(1,0)),得到:
2 2 2
2 2 2
2 1 2
(0,1)无感染,(1,0)感染(2,0), 此时:
· 队列:[(1, 1), (0, 2), (2, 2), (2, 0)]
minutes = 2
同理,下一轮while取前两个队头元素,最后minutes为3(实际上答案应为2,正确实现在第2次while循环就已经完成)。
这里还是在程序没有崩溃的前提下,模拟出的结果。
这是将sz放在while外面产生的报错(front() called on empty deque),试图在一个空队列上调用q.front()。
原因在于while(!q.empty())只能保证循环开始时队列不为空,无法保证在循环过程中for (int i = 0; i < sz; i++)执行时队列不为空,当执行到某个地方时,由于q中元素不足2个,会触发assert断言
!!!所以要把sz放入while中,让每一轮只处理当前层 !!!
5、带状态的BFS
相比于其他常规的BFS,这里引入了一个新的维度——“状态”,所以我们要把标记数组visited进行升维(变成三维数组)visited[][][],前两维仍表示位置,第三维表示状态,这需要我们记录一定要用到的量,其余BFS过程亦可套用模板。
相关题目:864. 获取所有钥匙的最短路径 - 力扣(LeetCode)
做这道题之前,需要了解一些基本的位运算符,如:
1. &(按位与):两个都是1,结果才是1
1 0 1 & 0 1 1
1 0 1
0 1 1
0 0 1
每位按位对齐后可知第一位不全是1,第二位不全是1,第三位全是1, 得到结果0 0 1
2. |(按位异或):只要有一位是1,结果就是1
1 0 1 | 0 1 1
对齐后得到结果1 1 1
3. <<(左移):将二进制整体向左移()位
1 << 2
1的二进制数是0001,左移2位后为0100(4),所以结果为4
相关代码实现:
#include <iostream>
#include <vector>
#include <queue>
#include <tuple>
#include <bitset>
#include <string>
using namespace std;
int m, n;
int DIRS[4][2] = { {0, 1}, {0, -1}, {-1, 0}, {1, 0} }; // 上下左右
int shortestPathAllKeys(vector<string>& grid) {
m = grid.size();
n = grid[0].size();
int sx = 0, sy = 0; //起点坐标
int totalKeys = 0; //钥匙总数
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '@') {
sx = i;
sy = j;
}
else if (grid[i][j] >= 'a' && grid[i][j] <= 'f') { //将其转为掩码来记录是第几把钥匙
totalKeys |= (1 << (grid[i][j] - 'a'));
}
}
}
queue<tuple<int, int, int>> q;
//将totalKeys其转为32位的二进制形式,方便后面使用.count()方法
int keyCount = bitset<32>(totalKeys).count();
//数掩码中有多少个1
vector<vector<vector<int>>> visited(m, vector<vector<int>>(n, vector<int>(1 << keyCount, 0)));
q.push({ sx,sy,0 });
visited[sx][sy][0] = 1;
int steps = 0;
while (!q.empty()) {
int sz = q.size();
for (int i = 0; i < sz; i++) {
auto [x, y, keys] = q.front();
q.pop();
if (keys == totalKeys) return steps; //钥匙数与总数相等时,说明已找到全部钥匙,返回步数
for (auto& d : DIRS) {
int nx = x + d[0];
int ny = y + d[1];
if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue; //越界跳过
if (grid[nx][ny] == '#') continue; //碰墙跳过
int nk = keys;
if (grid[nx][ny] >= 'A' && grid[nx][ny] <= 'F') { //遇到锁
if (!(nk & (1 << (grid[nx][ny] - 'A')))) continue; //但是没有对应钥匙,跳过
}
if (grid[nx][ny] >= 'a' && grid[nx][ny] <= 'f') { //遇到钥匙
nk |= (1 << (grid[nx][ny] - 'a')); //改变对应掩码,更新钥匙对应情况
}
if (!visited[nx][ny][nk]) { //该坐标及钥匙状态没被访问
visited[nx][ny][nk] = 1; //标记已访问
q.push({ nx,ny,nk });
}
}
}
steps++; //更新步数
}
return -1; //无法拿到全部钥匙,返回-1
}
int main() {
cin >> m;
vector<string> grid(m);
for (int i = 0; i < m; i++) {
cin >> grid[i];
}
int ans = shortestPathAllKeys(grid);
cout << ans << endl;
return 0;
}
代码中的一些说明:
1.tuple的说明:tuple是C++标准库中的元组(能够将不同类型的数据打包为一个对象)
与pair的差别:pair是一个二元组(两个成员),tuple是多元组(2、3、4……)
2.bitset的说明:bitset是固定大小 位集合,大小必须是常量!
int n = 1000;
bitset<n> bs; //这里会编译报错!因为n是变量
.count()方法是用来数二进制数中有多少个1
3.totalKeys的含义:这里通过 |= 运算将其转为一个二进制数(位掩码)
totalKeys |= (1 << (grid[i][j] - 'a'));
将遇到的钥匙记录到掩码totalKeys中(比如遇到‘a’则第0位变为1)。
这道题难点在于弄清楚有哪些必需量,通过钥匙-锁的对应状态来设计BFS中的实现步骤,与DP(动态规划) 有共同之处,都需要想清楚状态,不过DP中难点在于理清状态的转移,写出相应的递推式 。
6、回溯DFS(枚举所有路径)
上面的5种题型都用到了vis标记数组(或原地修改),回溯与之不同的是标记是可以撤销的(即恢复现场),当有多种选择都能够实现目标时,则选择其一分支(将vis标记为1,表示已访问),完成后将vis改为0(恢复未访问状态),实现回溯。
void dfs(int x, int y) {
······(前面一些判断逻辑)
vis[nx][ny] = 1; //标记为已访问(1)
dfs(nx, ny); //递归进入下一轮
vis[nx][ny] = 0; //恢复现场,改为未访问状态(0)
}
相关题目:2684. 矩阵中移动的最大次数 - 力扣(LeetCode)
实现:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int m, n;
int dx[3] = { -1, 0, 1 };
int dy[3] = { 1, 1, 1 };
int maxStep;
void dfs(vector<vector<int>>& grid, int x, int y, int step, vector<vector<bool>>& vis) {
maxStep = max(maxStep, step);
for (int i = 0; i < 3; i++) {
int nx = x + dx[i];
int ny = y + dy[i];
if (nx < 0 || nx >= m || ny < 0 || ny >= n) continue;
if (vis[nx][ny]) continue;
if (grid[nx][ny] <= grid[x][y]) continue;
vis[nx][ny] = true;
dfs(grid, nx, ny, step + 1, vis);
}
}
int maxMoves(vector<vector<int>>& grid) {
m = grid.size();
n = grid[0].size();
maxStep = 0;
vector<vector<bool>> vis(m, vector<bool>(n, false));
//回溯实现
for (int i = 0; i < m; i++) {
vis[i][0] = true;
dfs(grid, i, 0, 0, vis);
vis[i][0] = false;
}
return maxStep;
}
int main() {
int m, n;
cin >> m >> n;
vector<vector<int>> grid(m, vector<int>(n));
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
cin >> grid[i][j];
}
}
int ans = maxMoves(grid);
cout << ans << endl;
return 0;
}
二、Dijkstra(迪杰斯特拉算法)
Dijkstra适用于单源带权有向图最短路(所有边的权重必须是非负数),算法本质是利用“优先队列”来实现 。
一般的队列满足FIFO(先进先出),而优先队列则是弹出当前队列中最小的元素,这么看来就是:
优先队列 = 贪心 + 队列
在此就不赘述贪心了,我们来聊聊为什么边的权重为什么不能是负数 。
如果存在负权边,则会出现下列情况:
A -> B (10)
A -> C (1)
C -> B (-5)
我们看上面这个例子,A作为起点,刚开始优先队列中只有A,弹出A后将邻居B、C进行入队,AC边权小,C出队,后从C出发,此时负权边最小,弹出。
站在优先队列角度看,队列变化如下:
{ (0, A) } ->弹A,松弛 -> { (10, B), (1, C) } ->弹C,松弛B(1-5=-4 ) -> { (-4, B), (10, B) } -> 弹B(-4),确定最短
距离:0 -> 1 -> -4
但是注意Dijkstra是存在贪心的,即只选取当前情况下代价最小元素弹出,弹出即锁定 。
但是在弹完C后(距离为1)算法锁定,如果后续还存在能够绕回C的负权路径,C会被错误锁定,这里B的-4在C弹出后才进行入队,-4小于10,使B被更新为一个小值,这个时候未弹出节点会被负权边持续刷新,导致贪心锁被破坏,Dijkstra失效
代码实现:
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
using namespace std;
//邻接表:graph[u] = {v, weight}
vector<vector<pair<int, int>>> graph; //用来存所有从u出发的边
vector<int> dist; //距离表,用来存从起点到u的当前最短距离, 后续会不断被更新
void dijkstra(int start) {
int n = graph.size(); //节点总数
dist.assign(n, INT_MAX); //将初始距离初始化为无穷大
dist[start] = 0; //起点距自己距离为0
//优先队列:{距离,节点},默认大顶堆,用greater变成小顶堆
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
pq.push({0, start});
while(!pq.empty()) {
auto [d, u] = pq.top();
pq.pop();
// 如果弹出的距离大于当前记录的距离,说明这个节点已经处理过了
if(d > dist[u]) continue;
//松弛处理(不断用更短的路径去更新邻居)
for(auto [v, w] : graph[u]) { //遍历u的所有出边{v,w}
if(dist[u] + w < dist[v]) { //dist[u] + w是过u达v的距离
dist[v] = dist[u] + w;
pq.push({dist[v], v});
}
}
}
}
补充一下为什么会锁定,即当start弹出后,将邻居带有的边权值加入队列中距离要素, 此时贪心弹出队列中代价最小的元素,弹出后,即便后续能够通过另一条路径连通(前提无负权边),但在贪心弹出这一步就已经锁定,后续无法把这最短路径变得更小
相关题目:743. 网络延迟时间 - 力扣(LeetCode)
代码实现:
#include <iostream>
#include <vector>
#include <queue>
#include <climits>
#include <algorithm>
using namespace std;
int networkDelayTime(vector<vector<int>>& times, int n, int k) {
vector<vector<pair<int, int>>> g(n + 1); //建立邻接表,g[x]存所有从x出发的边
for (auto& t : times) {
int x = t[0], y = t[1], wt = t[2];
g[x].emplace_back(y, wt);
}
vector<int> dis(n + 1, INT_MAX); //dis[i]表示从起点到节点i的最短距离,初始化为无穷大
//priority_queue<元素类型,实现容器,比较器>
priority_queue<pair<int, int>, vector<pair<int, int>>, greater<>> pq;
dis[k] = 0; //起点到自己的距离为0
pq.emplace(0, k); //把起点丢进队列
while (!pq.empty()) {
auto [dis_x, x] = pq.top();
pq.pop();
//同一个节点可能会重复入队,如果弹出的dis_x大于之前记录的dis[x],说明之前的最小距离已经处理过了,跳过
if (dis_x > dis[x]) continue;
//松弛操作
for (auto& [y, wt] : g[x]) { //遍历x的所有出边{y,wt}
int new_dis_y = dis_x + wt; //从起点过x再到y的距离
if (new_dis_y < dis[y]) {
dis[y] = new_dis_y;
pq.emplace(new_dis_y, y);
}
}
}
int ans = 0;
for (int i = 1; i <= n; i++) {
if (dis[i] == INT_MAX) return -1;
ans = max(ans, dis[i]);
}
return ans;
}
int main() {
int n, k, m;
cin >> n >> k >> m; //节点数、起点、边数
vector<vector<int>> times(m, vector<int>(3));
for (int i = 0; i < m; i++) {
cin >> times[i][0] >> times[i][1] >> times[i][2];
}
int ans = networkDelayTime(times, n, k);
cout << ans << endl;
return 0;
}
补充:
1.对priority_queue的解释:priority_queue的三个参数是"存什么","用什么存","怎么排序"
在这里存{距离, 节点},优先队列的内部要一个容器用来存储数据,至于为什么用vector,可以理解为队列的底层是用数组实现的,priority_queue默认是大顶堆,而Dijkstra则需要每次弹出队列中最小的元素,所以要用greater<>转为小顶堆
查阅了一些资料,如果存在负权边,Dijkstra失效,此时要用Bellman-Ford或SPFA解决
好了,近3个星期对这一块内容的总结和理解,还可能有很多地方表述不是很准确,一些地方表述不准确使用了AI进行纠错和润色,第一次写对某一个算法的全面总结,有不好的地方欢迎大家指出和补充