深度优先搜索
广度优先搜索
深搜与广搜的区别
深搜 dfs——回溯——“不撞南墙不回头”
思路
总的来说是不撞南墙不回头,相当于一个人严格按照固定的行为模式。
例如走方格,依次走上下左右,每次走到一个新格子记录自己已经走过的方向,当到达终点或是所有方向走完之后,会重新回到上一格,代表上一格此方向【之前按照哪个方向到达此格的方向】的遍历完成。
形成效果
一个dfs其实是对一个问题的结果集的所有解题情况的遍历【不管这个解题情况是否复合题意】。
dfs 搜索树与回溯
一个dfs其实是对一个问题的结果集的所有解题情况的遍历,在遍历过程中,前面解题情况的选择会影响到后面的解题情况。
例如走迷宫,解题情况其实就是主人公走到哪个格子,他会影响到之后走过的路径。
所以我们其实可以将解题情况进行串联,将前面的选择作为一个图的前驱节点,而后面的结果作为后继节点,那么所有的解题情况的遍历便可以组成一个树,我们叫做搜索树。树上的根节点到 某个叶节点的一条路径就是一条解题过程。
那迷宫来进行距离,我们把主人公当前正在走的点当作前驱,而之后依照行为模式工作后所走到的下一个点作为后继。那么所有所走过的路径的集合便可构成一个树。
在dfs遍历解题结果的过程中,会存储并改变一些解题的状态,由于我们是会对所有情况依次进行遍历,当一种情况遍历完成后,dfs应该回到上一个节点进行行为模式的继续遍历。
例如迷宫,走到死胡同或者终点时,应该回到上一次走过的点,看其他方向能否到达终点。
注意,dfs是进行所有结果的遍历,所以在到达终点后并不会停止,而是会继续回到上一步运行。
而此时回到上一个节点就意味着,之前走过的一些点,在这个时刻应该是没有走过的,所以的话在写代码的时候应该进行一个状态的回溯。在回到上一个节点之前,本节点对于上一个节点应该是没有遍历过的,所以要进行状态改变。
代码模板
人走迷宫,迷宫情况使用0、1表示,1可行,0不可行。先给定起点与终点,求所有路径中最少要走多少格子。
递归实现
#include<stdio.h>
_Bool map[100][100] = { 0 };//1代表可行,0不可行
_Bool visit[100][100] = { 0 };//0未走,1走过
int mov[4][2] = { {0,1},{1,0},{0,-1},{-1,0} };//右下左上
int endx, endy, startx, starty;
int col, row, t;
_Bool can = 0;
int min = 0x3f3f3f3f;//代表正无穷
void dfs(int x,int y, int value) {//如果value没有被设为参数,回溯的时候需要改变value值
if (value > min)//最优剪枝1
return;
if (x<0 || y<0 || x>=row || y>=col)//判断是否出界
return;
if (x == endx && y == endy) {//结束条件,找到终点
can = 1;
if (value < min)
min = value;
return;//回退,防止继续走下去,已经到达终点了,没必要再走了
}
if (value >= min)//最优剪枝2,未到达终点,便value=min,那么到达终点时,一定回value>min
return;
for (int i = 0;i < 4;i++) {
int xx = x + mov[i][0], yy = y + mov[i][1];//人物偏移
if (map[xx][yy] && !visit[xx][yy]) {//判断是否可以进入下一格,可行性剪枝
visit[xx][yy] = 1;//先标记再访问
dfs(xx, yy, value+1);//让人物移动到下一格
visit[xx][yy] = 0;//回溯操作,当递归回到这一层的时候,(xx,yy)应该未访问,所以置为0
//回溯操作是一层递归一层递归进行回溯,每次只会回溯到上一层,所以每次只用将当前位置的下一个操作的数据回溯
}
}
}
int main() {
scanf("%d", &t);
while (t--) {//t轮数据,每一次数据进行操作时,都要保证所有的状态都为最初状态,不被上一次操作所影响
scanf("%d%d", &col, &row);
for (int i = 0;i < row;i++)
for (int j = 0;j < col;j++) {
scanf("%d", &map[i][j]);
visit[i][j] = 0;
}
scanf("%d%d%d%d", &startx, &starty, &endx, &endy);
visit[startx][starty] = 1;
dfs(startx, starty, 0);
if (can)
printf("能,min=%d\n", min);
else
printf("不能\n");
_Bool can = 0;
}
return 0;
}
栈实现
#include<iostream>
#include<cstdio>
#include<stack>
using namespace std;
struct Point {
int x, y, value, dir;
Point(int a,int b,int value):x(a),y(b),value(value),dir(0){}
};
typedef struct Point TYPE;
int main(void) {
stack<TYPE> Stack;//这个类可以用<>指定里面的内容的类型,类似于一个栈
bool map[100][100] = { 0 }, visited[100][100] = { 0 };
int mov[4][2] = { {0,1},{0,-1},{1,0},{-1,0} };
int m, n;
cin >> m >> n;
int i, j;
for (i = 0;i < m;i++)
for (j = 0;j < n; j++)
cin >> map[i][j];
int startx, starty, endx, endy;
scanf("%d%d%d%d", &startx, &starty, &endx, &endy);
Stack.emplace(startx, starty, 0);
visited[startx][starty] = 1;
while (!Stack.empty()) {//栈非空进行循环
TYPE* cur = &Stack.top();
if (cur->x == endx && cur->y == endy) {//判断是否到达终点
printf("%d\n", cur->value);
visited[cur->x][cur->y] = 0;
Stack.pop();
continue;//如果到达,弹栈,回到上一次位置,进行下一轮循环
}
if (cur->dir <= 3) {//遍历
int xx = cur->x + mov[cur->dir][0];
int yy = cur->y + mov[cur->dir][1];
cur->dir++;//进入一个新的结点的时候,dir=0,当遍历之后要自增
if (xx < m && yy < n && xx >= 0 && yy >= 0 && !visited[xx][yy] && !map[xx][yy]) {
Stack.emplace(xx, yy, cur->value + 1);//压栈
visited[xx][yy] = 1;
}
}
else {//弹栈
visited[cur->x][cur->y] = 0;
Stack.pop();
}
}
return 0;
}
与模板差异
- 是否回溯
- 遍历方向
- 结束条件
- 结束处理
超时后的解决方法
- 换思路:主要换遍历的方式【mov之类的】
- 剪枝
普通无回溯 dfs
例题
海战 - 洛谷
该题与走迷宫不同,走迷宫是从一个点为起点,然后一直走下去,每走完一种情况后需要回过头检查是否有其他路可走,需要进行回溯。该题是需要遍历每一个点,若该点是船只的一部分,需要判断该点是否可以与其它点相连构成船只,且每个点只能使用一次【使用过之后就不能再次使用】,走到头后,不需要回过头检查其他的路径,所以不需要回溯。
#include<stdio.h>
int R, C, S = 0;
char map[1005][1005] = { 0 };
int mov[4][2] = { {-1,0},{1,0},{0,-1},{0,1} };
void dfs(int x, int y) {
map[x][y] = '.';//因为每只船只能使用一次,所以在使用之后要将‘#’标记为‘.’,后期不可以再使用
for (int i = 0;i < 4;i++) {
int xx = x + mov[i][0], yy = y + mov[i][1];
if (xx >= 0 && xx < R && yy >= 0 && yy < C && map[xx][yy] == '#') {
dfs(xx, yy);
}
}
return;
}
//判断船的位置是否合法,即在一个正方形中,四个角上如果有三个角有船就是不合理的,那样子会出现一个三角形,不是方形
_Bool d(int i, int j) {
int c = 0;
if (map[i][j] == '#')
c++;
if (map[i + 1][j] == '#')
c++;
if (map[i][j + 1] == '#')
c++;
if (map[i + 1][j + 1] == '#')
c++;
if (c == 3)
return 0;
return 1;
}
int main() {
scanf("%d%d", &R, &C);
for (int i = 0;i < R;i++) {
scanf("%s", map[i]);
}
for (int i = 0;i < R;i++) {
for (int j = 0;j < C;j++) {
if (d(i, j) == 0) {
printf("Bad placement.");
return 0;
}
}
}
for (int i = 0;i < R;i++) {
for (int j = 0;j < C;j++) {
if (map[i][j] == '#') {
S++;
dfs(i, j);
}
}
}
printf("There are %d ships.", S);
return 0;
}
剪枝
在搜索树中剪去多余枝干。
另一个结束递归的条件
- 可行性剪枝(判断可不可以走,包括有无障碍物和是否走过,……)
- 最优剪枝(消耗的能量,若目前未到终点,但是使用的能量已经超过min)
- 奇偶性剪枝
- 优化搜索顺序(人眼看)
- 冗余剪枝
- 记忆化搜索
- α-β剪枝(博弈算法,可能被人工智能使用)
优化搜索顺序
靠数学功底
可行性剪枝
就像上面的迷宫问题,能否走这个格子就是一个可行性剪枝。这个是根据题意来进行判断,看下一步路线是否可能符合题意,若直接违背则就不用走。
最优剪枝
适用于求最短路径
例题
[USACO2.1] 健康的荷斯坦奶牛 Healthy Holsteins - 洛谷
#include<stdio.h>
int v, g, need[30], food[20][30], ans[20], min = 0x3f3f3f3f, temp[20], len;//v所需要v种营养 g有g种饲料 need每种营养成分需要多少 food二维数组,低维:每种营养的含量,高维:饲料总类 ans答案数组 temp存储现在选择的饲料 len目前选了len种饲料 min做优情况,即选最少的种类便可达到所需的营养
_Bool visited[20];//状态数组,表示是否被选过
_Bool check(int len) {//检查是否达到所需的营养
int nutrition[30] = { 0 };//每种营养现在的含量
for (int i = 1;i <= len;i++) {
for (int l = 1;l <= v;l++) {
nutrition[l] += food[temp[i]][l];
}
}
for (int i = 1;i <= v;i++) {
if (need[i] > nutrition[i])//只要有一种营养成分含量不合格,就返回0
return 0;
}
return 1;
}
void dfs() {//搜索
//最优剪枝
if (len > g || len > min)//现在的饲料种类超过最优或超过总的饲料种类就退出递归
return;
if (check(len) == 1) {//检验是否达到所需的营养含量,若达到,与最优解进行比较,若所需的种类少于目前的最小的,则代替最小成为新的最少,即新的最优情况
if (min > len) {
min = len;
for (int i = 1;i <= min;i++)
ans[i] = temp[i];
}
return;
}
for (int i = temp[len]+1;i <= g;i++) {//遍历,注意遍历的开始是上一次选择下一个【遍历选择每一种饲料,不需要每次都从头开始选】
if (!visited[i]) {
visited[i] = 1;
len++;
temp[len] = i;
dfs();
visited[i] = 0;//回溯
temp[len] = 0;
len--;
}
}
}
int main() {
scanf("%d", &v);
for (int i = 1;i <= v;i++) {
scanf("%d", &need[i]);
}
scanf("%d", &g);
for (int i = 1;i <= g;i++) {
for (int j = 1;j <= v;j++) {
scanf("%d", &food[i][j]);
}
}
dfs();
printf("%d ", min);
for (int i = 1;i <= min;i++)
printf("%d ", ans[i]);
return 0;
}
奇偶性剪枝
横纵坐标从1开始,横纵坐标相加为奇数赋为0,偶数赋为1
1->1:最近的1走两步,任何一个1走偶数步,0->0也是【同偶异奇】
冗余剪枝
重复的删除去
[USACO2.1] 健康的荷斯坦奶牛 Healthy Holsteins - 洛谷
#include<stdio.h>
int v, g, need[30], food[20][30], ans[20], min = 0x3f3f3f3f, temp[20], len;//v所需要v种营养 g有g种饲料 need每种营养成分需要多少 food二维数组,低维:每种营养的含量,高维:饲料总类 ans答案数组 temp存储现在选择的饲料 len目前选了len种饲料 min做优情况,即选最少的种类便可达到所需的营养
_Bool visited[20];//状态数组,表示是否被选过
_Bool check(int len) {//检查是否达到所需的营养
int nutrition[30] = { 0 };//每种营养现在的含量
for (int i = 1;i <= len;i++) {
for (int l = 1;l <= v;l++) {
nutrition[l] += food[temp[i]][l];
}
}
for (int i = 1;i <= v;i++) {
if (need[i] > nutrition[i])//只要有一种营养成分含量不合格,就返回0
return 0;
}
return 1;
}
void dfs() {//搜索
//最优剪枝
if (len > g || len > min)//现在的饲料种类超过最优或超过总的饲料种类就退出递归
return;
if (check(len) == 1) {//检验是否达到所需的营养含量,若达到,与最优解进行比较,若所需的种类少于目前的最小的,则代替最小成为新的最少,即新的最优情况
if (min > len) {
min = len;
for (int i = 1;i <= min;i++)
ans[i] = temp[i];
}
return;
}
for (int i = temp[len]+1;i <= g;i++) {//遍历,注意遍历的开始是上一次选择下一个【遍历选择每一种饲料,不需要每次都从头开始选】
if (!visited[i]) {
visited[i] = 1;
len++;
temp[len] = i;
dfs();
visited[i] = 0;//回溯
temp[len] = 0;
len--;
}
}
}
int main() {
scanf("%d", &v);
for (int i = 1;i <= v;i++) {
scanf("%d", &need[i]);
}
scanf("%d", &g);
for (int i = 1;i <= g;i++) {
for (int j = 1;j <= v;j++) {
scanf("%d", &food[i][j]);
}
}
dfs();
printf("%d ", min);
for (int i = 1;i <= min;i++)
printf("%d ", ans[i]);
return 0;
}
记忆化搜索
将之前计算的结果存储在数组中,当计算同样的数据时,可以直接使用之前计算过的答案。
冗余:重复的直接被删除
记忆化搜索:借用之前重复的数据,而不是舍去
(x,y) data[x][y]
dfs(x,y)
if(data[x][y]算过){
return data[x][y];
}
dfs函数返回值类型不是void
使用条件
1.有限个 2.有重复值
例题
int dp[25][25][25];
int dfs(int a, int b, int c) {
if (a <= 0 || b <= 0 || c <= 0)
return 1;
if (a > 20 || b > 20 || c > 20) {
return dfs(20, 20, 20);
}
if (dp[a][b][c]) //先判断该值是否计算过,如果算过,直接调结果,不需要再次计算,直接返回即可
return dp[a][b][c];
if (a < b && b < c) {
dp[a][b][c] = dfs(a, b, c - 1) + dfs(a, b - 1, c - 1) - dfs(a, b - 1, c);
}
else
dp[a][b][c] = dfs(a - 1, b, c) + dfs(a - 1, b - 1, c) + dfs(a - 1, b, c - 1) - dfs(a - 1, b - 1, c - 1);
return dp[a][b][c];
}
例题——全排列
给定一个整数 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<stdio.h>
int n;
int a[10],state[10]={0};
void dfs(int step){
if(step==n+1){
for(int i=1;i<=n;i++)
printf("%d ",a[i]);
printf("\n");
return;
}
for(int i=1;i<=n;i++){
if(state[i]==0){
a[step]=i;
state[i]=1;
dfs(step+1);
a[step]=0;
state[i]=0;
}
}
return;
}
int main(){
scanf("%d",&n);
dfs(1);
return 0;
}
例题——八皇后
[USACO1.5] 八皇后 Checker Challenge - 洛谷
n*n的正方形,i表示行,j表示列,i+j表示其中一条对角线,i-j+n表示另外一条对角线
#include<stdio.h>
int a[15]={0},b[15]={0},c[30]={0},d[30]={0};
int n,sum=0;
void dfs(int i){
if(i>n){
if(sum<3){
for(int k=1;k<=n;k++){
printf("%d ",a[k]);
}
printf("\n");
}
sum++;
return;
}
for(int j=1;j<=n;j++){
if((!b[j])&&(!c[i+j])&&(!d[i-j+n])){
a[i]=j;//行,第i行是第j个
b[j]=1;//列
c[i+j]=1;//对角线1,根据截距给对角线命名
d[i-j+n]=1;//对角线2
dfs(i+1);
b[j]=0;//回溯
c[i+j]=0;
d[i-j+n]=0;
}
}
}
int main(){
scanf("%d",&n);
dfs(1);
printf("%d",sum);
return 0;
}
广搜 bfs——一层一层走
一层一层搜索,首先,先把所有第一层的点,即距离为1的点搜完,然后进入下一层,直到搜完。
边权相同时,可以用bfs求最短路。
实现思路
使用队列来实现,每次将要搜索的点放在队列中,刚开始搜索时,队列中只有一个点,然后每次将所有与队头元素相连的且没有访问过的,符合条件的点都放入队列,之后对 队头元素进行处理然后出队。当队列为空则说明搜索停止。
这样就可以保证一层一层遍历,遍历整体深度逐渐增大。
而由这样遍历出来的合法路径,若是两点间权值相同的话,是具有最优性质的。
框架
queue 初始状态
while queue 不空
{
t<--每次取队头
扩展队头t
}
例题——走迷宫
给定一个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
//bfs,使用手写队列实现
//输出路径,只需要再开一个数组prev,记录这个点是由那个点扩展出来即可
#include<iostream>
#include<cstring>
using namespace std;
typedef pair<int, int> PII;
const int N = 110;
int n, m;
char map[N][N];
int len[N][N];//每个点到起点的距离
PII q[N * N];//实现队列
//PII Prev[N][N]; //输出路径
int bfs() {
int hh = 0, tt = 0;
q[0] = { 0,0 };
memset(len, -1, sizeof len);
//初始化函数,将某一块内存中的内容全部设置为指定的值,通常为新申请的内存做初始化工作
//参数1,指针或数组;参数2,赋给参数1的值;参数3,参数1的长度
len[0][0] = 0;
int dx[4] = { -1,0,1,0 }, dy[4] = { 0,1,0,-1 };
while (hh <= tt) {
auto t = q[hh++];//每次取出队头元素
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 && map[x][y] == '0' && len[x][y] == -1) {
len[x][y] = len[t.first][t.second] + 1;
//Prev[x][y] = t;
q[++tt] = { 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 len[n - 1][m - 1];
}
int main() {
cin >> n >> m;
for (int i = 0;i < n;i++)
cin >> map[i];
cout << bfs() << endl;
return 0;
}
有向图的遍历
宽度优先遍历
一层一层搜索
框架
queue <—— 将起始状态插入队列中,即将1号点插入队列
while (queue 不空){
t 每次取队头元素
拓展 t 所有能到的点 x
if(x 未被遍历){
queue <——x 将 x 入队
d[x]=d[t]+1
}
}
例题——图中点的层次
给定一个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
代码
#include<iostream>
#include<cstring>
using namespace std;
const int N = 100010;
int n, m;
int h[N], e[N], ne[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);//初始化距离,-1代表未被初始化
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 >> 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;
}
深度优先遍历
找到一个起点,然后从这个 起点开始,一条路走向黑
邻接表的深度优先遍历
主函数:
for(int i=0;i<n;i++) dfs(i); //枚举起点
dfs:
利用图中结点的编号进行搜索,e存图中结点的编号
int h[N], e[M], ne[M], idx;//h存n个链表的链表头,e存每个结点的值是多少,ne存每个结点的next值
bool st[N];
//树和图的深度优先搜索
void dfs(int u) {//u是当前dfs到的点
st[u] = true;//标记一下,已经被搜过了
for (int i = h[u];i != -1;i = ne[i]) {//遍历u的所有出边
int j = e[i];//链表中该点在图中的编号
if (!st[j])//如果j没有被搜过,那么就进行搜索
dfs(j);
}
例题——树的重心
给定一颗树,树中包含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