总览:
1.深度优先搜索 DFS
2.宽度优先搜索 BFS
3.树与图的存储
4.树与图的深度优先遍历
5.树与图的宽度优先遍历
6.拓扑排序
1.深度优先搜索:
简称DFS,暴搜
不撞南墙不回头,一条路走到黑,走到头再回溯,往深处搜
特点:
数据结构使用栈 stack 空间:O(h) 不具有最短路性质
重要概念:
回溯, 剪枝
使用情况: 一般算法思路比较奇怪的
过程:
【注】回溯要恢复现场, 比如填满123了,回到上面3是没有被填过的
例题模版:
1.排列数字
给定一个整数 n,将数字 1∼n排成一排,将会有很多种排列方法。
现在,请你按照字典序将所有的排列方法输出。
输入格式
共一行,包含一个整数 n
输出格式
按字典序输出所有排列方案,每个方案占一行。
数据范围
1≤n≤7
输入样例:
3
输出样例:
1 2 3
1 3 2
2 1 3
2 3 1
3 1 2
3 2 1
代码:
//此题就是暴搜,把所有情况遍历一遍
#include<iostream>
using namespace std;
const int N = 10;
int n;
int path[N]; // 存储序列方案
bool st[N]; // 数字是否被用过了
void dfs(int u) // u是第几个位置
{
if (u > n) // 说明位置填满了,这是一种方案
{
for (int i = 1; i <= n; i ++ ) cout << path[i] << " "; // 输出方案
cout << endl;
}
for (int i = 1; i <= n; i ++ ) // 如果u小于n,位置没填满,从数字一开始看哪个数没被用
if (!st[i])
{
path[u] = i; // 没被用的数字放在u这个位置上
st[i] = true; // i这个数字就被使用过了
dfs(u + 1); // 遍历,看下一个位置
st[i] = false; // 回溯,i这个数字回到上一层没被使用,一旦从递归里出来,就要回溯啦,就赶快把这个i变成false,未使用
}
}
int main()
{
cin.tie(0);
ios::sync_with_stdio(false);
cin >> n;
dfs(1); // 从第一个位置开始
return 0;
}
2.n-皇后问题(一个非常经典的dfs问题)
n−皇后问题是指将 n个皇后放在 n×n的国际象棋棋盘上,使得皇后不能相互攻击到,即任意两个皇后都不能处于同一行、同一列或同一斜线上。
现在给定整数 n,请你输出所有的满足条件的棋子摆法。
输入格式
共一行,包含整数 n。
输出格式
每个解决方案占 n行,每行输出一个长度为 n的字符串,用来表示完整的棋盘状态。
其中 . 表示某一个位置的方格状态为空,Q 表示某一个位置的方格上摆着皇后。
每个方案输出完成后,输出一个空行。
注意:行末不能有多余空格。
输出方案的顺序任意,只要不重复且没有遗漏即可。
数据范围
1≤n≤9
输入样例:
4
输出样例:
.Q..
...Q
Q...
..Q.
..Q.
Q...
...Q
.Q..
思路1:
每一行必定有一个皇后,对行进行深度优先遍历,对每一行,遍历每一列,如果可以放置就继续递归进行下一行,每一列都不可以回溯到上一行,再继续下一列,这样遍历所有的方案
代码1:
#include<iostream>
using namespace std;
const int N = 20; // 下面的斜方向的判断中会出现2n,所以设成20,比上一题多了一倍
int n;
char g[N][N]; // 存储棋盘
int col[N], dg[N], udg[N]; // col[i]对应第i列有没有皇后,dg是对角线,udg是反对角线是否有皇后
void dfs(int u)
{
if (u > n) // 超出棋盘,说明放满了,输出这个方案
{
for (int i = 1; i <= n; i ++ )
{
for (int j = 1; j <= n; j ++ ) cout << g[i][j];
cout << endl;
}
cout << endl;
return;
}
for (int i = 1; i <= n; i ++ ) // 若没有放满,在第u行,从第一列开始,遍历这一行看哪一列可以放
{
if (!col[i] && !dg[u + i] && !udg[u - i + n]) // 判断是否可以放置皇后,如果这个点的列,正反对角线都没有
{
g[u][i] = 'Q'; // 将皇后放在这
col[i] = dg[u + i] = udg[u - i + n] = true; //这个点的列,正反对角线就有皇后了
dfs(u + 1); // 递归下一行
col[i] = dg[u + i] = udg[u - i + n] = false; // 从递归出来,回溯,恢复现场
g[u][i] = '.'; // 注意,要变回点
}
}
}
int main()
{
cin.tie(0);
cout.tie(0);
ios::sync_with_stdio(false);
cin >> n;
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
g[i][j] = '.';
dfs(1); // 从第1行开始
return 0;
}
思路2:
原始的做题思路,枚举第一个格子,有两个分支,放皇后是一个分支,不放皇后是第二个分支,再枚举第二个格子,也有两个格子,放皇后是一个,不放皇后是一个,挨个枚举所有格子,当我们枚举完所有格子,就找到了答案
代码2:
//方法2
// 不同搜索方法 时间复杂度不一样, 所以搜索顺序很重要
#include<iostream>
using namespace std;
const int N = 20; // 下面的斜方向的判断中会出现2n,所以设成20,比上一题多了一倍
int n;
char g[N][N]; // 存储棋盘
int row[N], col[N], dg[N], udg[N]; // row[i]对应第i行有没有皇后,col[i]对应第i列有没有皇后,dg是对角线,udg是反对角线是否有皇后
void dfs(int x, int y, int s) // 第x行,第y列,目前放置了s个皇后
{
if (y == n) y = 0, x ++ ; // 这一行到了边界了,将点变成下一行的开头
if (x == n) // 最后一行
{
if (s == n) // 说明每行在合法的情况下都放了皇后,这就是一种方案
{ // 输出
for (int i = 0; i < n; i ++ )
{
for (int j = 0; j < n; j ++ ) cout << g[i][j];
cout << endl;
}
cout << endl;
}
return; // 结束
}
//分支1 放皇后
if (!row[x] && !col[y] && !dg[x + y] && !udg[x - y + n]) // 确保这个点的行列对角线和反对角线都没有皇后
{
g[x][y] = 'Q'; // 放置皇后
row[x] = col[y] = dg[x + y] = udg[x - y + n] = true; // 这个点的行列对角线和反对角线就有皇后了
dfs(x, y + 1, s + 1); // 递归下一个点
row[x] = col[y] = dg[x + y] = udg[x - y + n] = false; // 回溯,恢复现场
g[x][y] = '.';
}
//分支2 不放皇后
dfs(x, y + 1, s);
}
int main()
{
cin.tie(0);
cout.tie(0);
ios::sync_with_stdio(false);
cin >> n;
for (int i = 0; i < n; i ++ )
for (int j = 0; j < n; j ++ )
g[i][j] = '.';
dfs(0, 0, 0);
return 0;
}
剪枝:
第一种:可行性剪枝:提前判断不合法,下面的子序列就没必要进行了,直接回溯
第二种:最优性剪枝:判断当前的路径肯定不如最优解,就可以剪枝了
正反对角线:
2.宽度优先搜索
简称BFS
眼观六路耳听八方
一层一层的搜,第一层搜到的是所有距离为1的点,第二层把所有距离为2的点全部搜到,第三层把所有距离为3的点全部搜到......(图里面的边权重是1)
特点:
数据结构使用队列 queue 空间:O(2^h) 有最短路的性质:第一次搜索到的一定是离的最近的
使用情况:
一般要求最小步数,最短距离 ,最少次数
优势就是可以搜到最短路
基础框架:
例题模版:
例题:
844. 走迷宫
给定一个 n×m 的二维整数数组,用来表示一个迷宫,数组中只包含 0 或 1,其中 0
表示可以走的路,1 表示不可通过的墙壁。
最初,有一个人位于左上角 (1,1)处,已知该人每次可以向上、下、左、右任意一个方向移动一个位置。
请问,该人从左上角移动至右下角 (n,m) 处,至少需要移动多少次。
数据保证 (1,1) 处和 (n,m) 处的数字为 0,且一定至少存在一条通路。
输入格式
第一行包含两个整数 n 和 m
接下来 n 行,每行包含 m个整数(0 或 1),表示完整的二维数组迷宫。
输出格式
输出一个整数,表示从左上角移动至右下角的最少移动次数。
数据范围
1 ≤ n, m ≤ 100
输入样例:
5 5
0 1 0 0 0
0 1 0 1 0
0 0 0 0 0
0 1 1 1 0
0 0 0 1 0
输出样例:
8
思路:
代码:
// 宽度优先搜索
#include<iostream>
#include<queue>
#include<cstring> // memset头文件
using namespace std;
const int N = 110;
typedef pair<int, int> PII;
int n, m;
int g[N][N]; // 地图
int d[N][N]; // 每个点到起点的距离
int bfs()
{
queue<PII> q; // 创建一个坐标队列
memset(d, -1, sizeof d); // 初始化各个点到起点的距离都是-1
d[0][0] = 0; // 起点到起点的距离是0
q.push({0, 0}); // 把起点放进队列里面
int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1}; // 往四个方向的数组,比如往左x不变,y减1(x是行,y是列,我的是按上,右,下,左)
while (!q.empty()) // 队列不是空的
{
auto t = q.front(); // 把队头取出来
q.pop(); // 把队头删了,弹出队头
for (int i = 0; i < 4; i ++ ) // 遍历四个方向,看看哪个方向可以走哇
{
int x = t.first + dx[i], y = t.second + dy[i]; // 下个点(叫它p点吧)的xy坐标是这个点的x加上x方向的向量,y加上y方向的向量
// 假如,p点没出界(x不小于0,没大于n,用不小于0,没大于m),并且p点可以走(地图上是0,因为1是墙嘛),同时p点之前没走过(之前每个点到起点距离为-1,所以没走过的都是-1,不走回头路!)
if (x >= 0 && x < n && y >= 0 && y < m && g[x][y] == 0 && d[x][y] == -1)
{
d[x][y] = d[t.first][t.second] + 1; // 如果p点合法可以走,那p点到起点的坐标就是p点上一个点到起点的距离加1
q.push({x, y}); // 把p点放坐标队列里
}
}
} // 只要队列里有点就一直重复上面的,最后我们就得到了所有点到起点的距离了,存储在d数组里面了
return d[n - 1][m - 1]; // 输出出口那个点就行
}
int main()
{
cin >> n >> m;
for (int i = 0; i < n; i ++ )
for (int j = 0; j < m; j ++ )
cin >> g[i][j];
cout << bfs() << endl;
return 0;
}
如果知道路径是什么?,我们只需要记住这个点是从哪个点来的就行
开一个额外的数组
#include<iostream>
#include<cstring>
#include<queue>
using namespace std;
const int N = 110;
int n, m;
int g[N][N], d[N][N];
typedef pair<int, int> PII;
PII Prev[N][N]; // 记录哪个点来的
int bfs()
{
queue<PII> q;
memset(d, -1, sizeof d);
d[0][0] = 0;
q.push({0, 0});
int dx[4] = {0, -1, 0, 1}, dy[4] = {-1, 0, 1, 0};
while (!q.empty())
{
auto t = q.front();
q.pop();
for (int i = 0; i < 4; i ++ )
{
int x = t.first + dx[i], y = t.second + dy[i];
if (x >= 0 && x < n && y >= 0 && y < m && d[x][y] == -1 && g[x][y] == 0)
{
d[x][y] = d[t.first][t.second] + 1;
Prev[x][y] = t;
q.push({x, y});
}
}
}
int x = n - 1, y = m - 1;
while (x || y)
{
cout << x << ' ' << y << endl;
auto t = Prev[x][y];
x = t.first, y = t.second;
} // 输出路径,这个是倒着的
return d[n - 1][m - 1];
}
int main()
{
cin >> n >> m;
for (int i = 0; i < n; i ++ )
for (int j = 0; j < m; j ++ )
cin >> g[i][j];
cout << bfs() << endl;
return 0;
}
3.树与图的深度优先遍历
树是无环连通图++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
图:
分为有向图和无向图
无向图:
边没有方向,假如有边ab,可以从a走到b也可以从b走到a,因此在算法题里面,我们建造两个边就行了,建一条a到b的建一条b到a的, 所以无向图就是一种特殊的有向图
有向图:
边有方向,给定ab这条边,a只能到b或者b只能走向a
因此只要考虑有向图怎么存储的就行了
有向图的存储:
1.邻接矩阵(使用较少)
开一个二维数组,g[a][b] 就存储a到b的信息,有权重的话g[a][b]就是这个边的权重,没有权重g[a][b]就是个bool值,true就是有边,false就是没有边
不能存储重边, 浪费空间,比较适合存储稠密图
2.邻接表
每个节点都开了一个单链表,存储这个点可以到达哪些点,类似与哈希表的拉链法
插入跟单链表的插入一样的
有向图的遍历:
深度搜索,宽度搜索
例题代码
例题:
846. 树的重心
给定一颗树,树中包含 n 个结点(编号 1∼n)和 n−1 条无向边。
请你找到树的重心,并输出将重心删除后,剩余各个连通块中点数的最大值。
重心定义:重心是指树中的一个结点,如果将这个点删除后,剩余各个连通块中点数的最大值最小,那么这个节点被称为树的重心。
输入格式
第一行包含整数 n,表示树的结点数。
接下来 n−1 行,每行包含两个整数 a 和 b,表示点 a和点 b 之间存在一条边。
输出格式
输出一个整数 m,表示将重心删除后,剩余各个连通块中点数的最大值。
数据范围
1 ≤ n ≤ 10^5
输入样例
9
1 2
1 7
1 4
2 8
2 5
4 3
3 9
4 6
输出样例:
4
代码:
// 邻接矩阵
#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
const int N = 100010, M = N * 2; // 数据范围是10^5, 以有向图的方式存储无向图,要多开二倍
int n; // n个节点
int h[N], e[M], ne[M], idx;
/* h数组是队列的头结点,因为有n个节点,所以要n个头结点, e数组存储元素的值,
ne数组就是指针指向下一个节点, idx是用到第几个节点了*/
bool st[N]; // 记录节点是否遍历过了true是遍历过了,false是没遍历过
int ans = N; // 表示重心的所有子树中,最大子树的节点数,最小的最大值
// 插入一个数,跟单链表的插入一样
void add(int a, int b)// a是根,b是插入的
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}
// 返回以u为根的子树中节点的数量
int dfs(int u)
{
int res = 0; // res是删除某个节点后,剩下的连通块中最大的连通块中节点的数量
st[u] = true; // 标记一下,已经被搜过了
int sum = 1; // sum是以u为根节点的树的节点数,因为包括u这个点,所以从一开始
// 遍历以u为根节点的子树,跟单链表一样
for (int i = h[u]; i != -1; i = ne[i])
{
int j = e[i];
// 因为每个节点的值都不一样,所以把节点的值当作下标
if (!st[j])
{
int s = dfs(j); // 以u为根的子树中其中一个子树的节点数量
res = max(res, s); // 要子树的节点数量的最大值
sum += s; // sum是以u为根节点的树的节点数,包括这个子树,要加上
}
}
res = max(res, n - sum); // 假如以u为重心,下面的连通块和上面的比较,得到以此节点为重心时的各个连通块的节点数最大的值
ans = min(res, ans); //
return sum;
}
int main()
{
cin >> n;
memset(h, -1, sizeof h);
for (int i = 0; i < n - 1; i ++ )
{
int a, b;
cin >> a >> b;
add(a, b), add(b, a); // 无向图,a可以到b,b可以到a
}
dfs(1);
cout << ans << endl;
return 0;
}
4.树与图的宽度优先遍历
例题代码:
例题:
847.图中点的层次:
给定一个 n个点 m条边的有向图,图中可能存在重边和自环。
所有边的长度都是 1,点的编号为 1∼n
请你求出 1 号点到 n 号点的最短距离,如果从 1 号点无法走到 n号点,输出 −1
输入格式
第一行包含两个整数 n 和 m
接下来 m 行,每行包含两个整数 a 和 b,表示存在一条从 a 走到 b 的长度为 1 的边。
输出格式
输出一个整数,表示 1 号点到 n 号点的最短距离。
数据范围
1 ≤ n, m ≤ 10^5
输入样例:
4 5
1 2
2 3
3 4
1 3
1 4
输出样例:
1
代码:
stl队列:
// 宽搜
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
const int N = 1e5 + 10;
int n, m;
int e[N], ne[N], h[N], idx;
int d[N];
void add(int a, int b) // 插入
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
int bfs()
{
memset(d, -1, sizeof d); // 距离到起点初始化为-1
queue<int> q;
d[1] = 0; // 1到起点的距离是0,起点是1嘛
q.push(1); // 把1放队列里
while (!q.empty()) // 框架
{
int t = q.front(); // 取队头
q.pop(); // 删队头
for (int i = h[t]; i != -1; i = ne[i]) // 沿着边(链表)遍历
{
int j = e[i]; // 这个点e数组存的是值也是编号
if (d[j] == -1) // 说明没遍历过
{
d[j] = d[t] + 1; // j这个点到起点的距离是t到起点的距离加上1,j是从t来的嘛
q.push(j); // 把j放队列里,后面还要从j开始看往哪可以走,这样把所有点到起点的距离都算出来了
}
}
}
return d[n];
}
int main()
{
cin.tie(0);
ios::sync_with_stdio(false);
cin >> n >> m;
memset(h, -1, sizeof h); // 队头为-1
for (int i = 0; i < m; i ++ )
{
int a, b;
cin >> a >> b;
add(a, b); // 有向图
}
cout << bfs() << endl;
return 0;
}
数组模拟队列:
(看不懂hh和tt加加什么意思的可以转到最底下有演示)
// 宽搜(数组模拟队列)
#include<iostream>
#include<cstring>
#include<algorithm>
#include<queue>
using namespace std;
const int N = 1e5 + 10;
int n, m;
int e[N], ne[N], h[N], idx;
int d[N], q[N];
/*框架
*/
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ;
}
int bfs()
{
int hh = 0, tt = 0;
q[0] = 1;
memset(d, -1, sizeof d);
d[1] = 0;
while (hh <= tt)
{
int t = q[hh ++];
for (int i = h[t]; i != -1; i = ne[i])
{
int j = e[i];
if (d[j] == -1)
{
d[j] = d[t] + 1;
q[++ tt] = j;
}
}
}
return d[n];
}
int main()
{
cin.tie(0);
ios::sync_with_stdio(false);
cin >> n >> m;
memset(h, -1, sizeof h);
for (int i = 0; i < m; i ++ )
{
int a, b;
cin >> a >> b;
add(a, b);
}
cout << bfs() << endl;
return 0;
}
拓扑序列:
(图的宽搜的应用)
概念:
拓扑序列:
针对有向图来说的,有向图才有拓扑序列无向图没有拓扑序列,
定义:对于每条边xy,对于每个序列x在y的左边
对于这个的一个有向图,123就是一个拓扑序列,看12这条边1在2前面,23这条边,2在3前面,13这条边,1在3前面
当我们把一个图用拓扑序排好序后所有的边都是从前指向后的
并不是所有图都有拓扑序列 比如一个环 一定有一条边从后指向前
可以证明有向无环图一定有一个拓扑序列,因此有向无环图又叫拓扑图
度数:
有向图的每个点有两个度 入度 和 出度
一个点有几条边指向自己就是它的入度
一个点有几条边出去就是它的出度
因此所有入度为0的点都可以当作起点,排在最前面的位置
思路:
首先把所有入度为0的点入队,后面进行宽搜
删掉t到j的边,到后面判断d[j]为0,这一段意思就是判断j的入度为1,也就是只有起点的t指向它,保证了拓扑排序的进行,就是没有其他点指向j并且出现在j后面的情况了,把唯一的入度删掉,j就可以当做剩下点里的起点了,以此把所有点都排序,一个点一个点蚕食
逐个击破的顺序就是这个拓扑排序了
环上的点一定不会入队,一个有向无环图一定存在一个入度为0的点
一个有向无环图的拓扑序不一定唯一
这个图123和132都是拓扑序
例题代码:
例题;
有向图的拓扑序列:
给定一个 n个点 m条边的有向图,点的编号是 1 到 n,图中可能存在重边和自环。
请输出任意一个该有向图的拓扑序列,如果拓扑序列不存在,则输出 −1
若一个由图中所有点构成的序列 A满足:对于图中的每条边 (x,y),x在 A中都出现在 y之前,则称 A是该图的一个拓扑序列。
输入格式
第一行包含两个整数 n 和 m。
接下来 m行,每行包含两个整数 x 和 y,表示存在一条从点 x 到点 y的有向边 (x,y)
输出格式
共一行,如果存在拓扑序列,则输出任意一个合法的拓扑序列即可。
否则输出 −1
数据范围
1 ≤ n, m ≤ 10^5
输入样例:
3 3
1 2
2 3
1 3
输出样例:
1 2 3
代码:
#include <cstring>
#include <iostream>
#include <algorithm>
#include <queue>
using namespace std;
const int N =100010;
int n, m;
int h[N], e[N], ne[N], idx;
int d[N], q[N]; // q数组模拟队列,存储入度为0的点, d数组存储各个点的入度
void add(int a, int b)
{
e[idx] = b, ne[idx] = h[a], h[a] = idx ++;
}
bool topsort()
{
int hh = 0, tt = -1; // hh头, tt尾
for (int i = 1; i <= n; i ++ ) // 遍历一遍,把入度为0的点装队列里
if (!d[i])
q[++ tt] = i;
while (hh <= tt) // 循环处理队列中的点,条件的意思是队列不空
{
int t = q[hh ++]; // hh加加就是弹出队头元素,相当于t = q.front(); q.pop();这两步
for (int i = h[t]; i != -1; i = ne[i]) // 沿着链表走,以入度为0的点(假设a)走到下一个点(假设是b),把这条边删除
{
int j = e[i];
d[j] --; // 删除边后,b的入度减1
if (d[j] == 0) q[++ tt] = j; // 如果b的入度减去1后是0,在剩下的节点中,b可以当作新起点了,入队列,可以输出
}
}
return tt == n - 1; // 当全部都进过队了说明是无环的,出队的顺序就是拓扑序,如果不相等说明有环
}
int main()
{
cin >> n >> m;
memset(h, -1, sizeof h);
for (int i = 0; i < m; i ++ )
{
int a, b;
cin >> a >> b;
add(a, b); // 添加一条a到b的边
d[b] ++; // 节点b的入度加1
}
if (topsort())
{
for (int i = 0; i < n; i ++ ) cout << q[i] << " ";
}
else cout << -1 << endl;
return 0;
}
数组模拟队列的演示:
假设我们有一个初始状态的队列如下,使用数组 `q` 来模拟:
```
q: [ , , , , , , , , , , ...]
0 1 2 3 4 5 6 7 8 9 ...
hh: 0
tt: -1
```
初始状态下,`hh` 指向队列的第一个位置(0),`tt` 为 -1 表示队列为空。
###入队操作
假设我们依次入队元素 5, 10, 15:
1. 入队 5:
```
q: [5, , , , , , , , , , ...]
0 1 2 3 4 5 6 7 8 9 ...
hh: 0
tt: 0
```
2. 入队 10:
```
q: [5, 10, , , , , , , , , ...]
0 1 2 3 4 5 6 7 8 9 ...
hh: 0
tt: 1
```
3. 入队 15:
```
q: [5, 10, 15, , , , , , , , ...]
0 1 2 3 4 5 6 7 8 9 ...
hh: 0
tt: 2
```
### 出队操作
现在我们依次出队元素:
1. 出队 5:
```
q: [5, 10, 15, , , , , , , , ...]
0 1 2 3 4 5 6 7 8 9 ...
hh: 1
tt: 2
```
队列中的元素为 [10, 15]。
2. 出队 10:
```
q: [5, 10, 15, , , , , , , , ...]
0 1 2 3 4 5 6 7 8 9 ...
hh: 2
tt: 2
```
队列中的元素为 [15]。
3. 出队 15:
```
q: [5, 10, 15, , , , , , , , ...]
0 1 2 3 4 5 6 7 8 9 ...
hh: 3
tt: 2
```
队列为空。
通过这种方式,`hh` 和 `tt` 分别维护队列的头和尾指针,实现入队和出队操作。