文章目录
搜索与图论(一)
本节讲的是,普通的DFS和BFS,树和图的存储,拓扑排序。
DFS和BFS
概述
DFS:深度优先搜索(Depth-First-Search)
BFS:宽度优先搜索(Breadth-First-Search)
DFS和BFS的对比
- DFS使用栈(stack)来实现,BFS使用队列(queue)来实现
- DFS所需要的空间是树的高度h,而BFS需要的空间是2h (DFS的空间复杂度较低)
- DFS不具有最短路的特性,BFS具有最短路的特性
通常来说,求“最短”的操作,都可以用BFS来做,而其他一些奇怪的操作,或者对空间复杂度要求比较高,则用DFS来做
DFS
DFS中的2个重要概念:
- 回溯:回溯的时候,一定要记得恢复现场
- 剪枝:提前判断某个分支一定不合法,直接剪掉该分支
DFS练习题:
经典DFS问题,对于数n,其排列一共有n个位置,我们深搜从第一个位置开始枚举,直到第n个位置,每个位置放一个数字,而每个数字只能使用一次,所以对于1~n这n个数字,我们需要维护一个状态,来表示某个数字是否已经被使用过。当我们深搜到某个位置x时,若在这个位置之前,某个数k已经被使用过,则位置x上不能再放数k。即,每次在某一个位置上枚举数字,选择一个当前还未被使用过的数字,放置在该位置上,然后深搜下一个位置。记得回溯时,需要还原这个数字的状态。将这个数字从已被使用,还原为未被使用。另外需要一个path
变量,来存储当前深搜所经过的路径,当深搜到叶子节点时,这个path
即是一种排列,输出即可。
#include<iostream>
using namespace std;
const int N = 10;
bool visited[N];
int n, path[N];
// x 代表当前是第几个位置的数, x 可能取值为1,2,3....,n
void dfs(int x) {
if(x == n + 1) {
// 到达根节点, 深搜结束, 输出
for(int i = 1; i <= n; i++) printf("%d ", path[i]);
printf("\n");
return ;
}
// 否则, 枚举1~n, 找到一个可用数字, 放到当前位置
for(int i = 1; i <= n; i++) {
if(!visited[i]) {
// 当数字i还未被使用过时, 将i放到当前位置x上, 并标记其为 已被使用, 随后深搜下一个位置x + 1
path[x] = i;
visited[i] = true;
dfs(x + 1);
visited[i] = false; // 这里只需要还原状态变量即可, path[x]会在下一个循环被覆盖掉
}
}
}
int main() {
scanf("%d", &n);
dfs(1); // 先从第一个位置的数开始深搜
return 0;
}
解法一:
首先经过思考和提炼,容易发现,每一行只能放一个皇后,则问题被精简成了,从第一行开始,到第n行,依次确定每一行的皇后的摆放位置。我们需要针对行,列,对角线,反对角线,维护状态变量。比如col[i]
表示第i
列上的状态,若col[i] = true
,表示该列已经被一个皇后占用,该列上无法再放多一个皇后,同理,dg[i]
表示第i
条对角线的状态,rdg[i]
表示第i
条反对角线的状态。而由于我们是依次在每一行上摆放一个皇后,这就变相地保证了,每一行只有一个皇后,则行是不可能冲突的,所以就无需维护针对行的状态。另外需要注意,若棋盘是n × n,则一共有n列,n行,但对角线有2n - 1条,反对角线也是2n - 1条,可自行画图验证。我们需要确定对于一个点[x, y]
,其所在的是第几条对角线,以及第几条反对角线,以及对角线,反对角线的编号从哪里开始。
#include<iostream>
using namespace std;
const int N = 20;
char ans[N][N]; // 结果矩阵
bool col[N], dg[2 * N], rdg[2 * N]; // 状态变量
int n;
// 从第一行开始摆放皇后, x代表当前需要摆放的是第几行
void dfs(int x) {
if(x == n + 1) {
// 深搜到达叶子节点, 输出
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) printf("%c", ans[i][j]);
printf("\n");
}
printf("\n");
return ;
}
for(int y = 1; y <= n; y++) {
// 枚举的是列, 看这一行的1~n列中, 哪一列可以放皇后
if(!col[y] && !dg[x + y - 1] && !rdg[y - x + n]) {
// 若当前位置[x,y] 所在的列, 对角线, 反对角线, 皆没有被占用, 则当前位置可以放皇后
col[y] = dg[x + y - 1] = rdg[y - x + n] = true; // 更新状态变量
ans[x][y] = 'Q'; // 放皇后
dfs(x + 1); // 深搜下一行
ans[x][y] = '.'; // 还原
col[y] = dg[x + y - 1] = rdg[y - x + n] = false; // 还原状态变量
}
}
}
int main() {
scanf("%d", &n);
// 初始化结果矩阵
for(int y = 1; y <= n; y++) {
for(int j = 1; j <= n; j++) ans[y][j] = '.';
}
dfs(1);
return 0;
}
解法二:
若不经过思考提炼,则更一般的做法(更暴力的做法)是:从棋盘的第一个位置[1, 1]
依次枚举到最后一个位置[n,n]
。每个位置都有两种选择:放或者不放皇后(这就对应了两个分支)。对比解法一,此时我们就需要多维护一个针对行的状态变量了。其余逻辑和解法一类似,只是需要对每个位置的点进行枚举
#include<iostream>
using namespace std;
const int N = 20;
char ans[N][N]; // 结果矩阵
bool row[N], col[N], dg[2 * N], rdg[2 * N]; // 状态变量
int n;
// x 是第几行, y 是第几列, s 是当前已经放了几个皇后了
void dfs(int x, int y, int s) {
// 若某一行枚举结束, 移动到下一行第一个元素
if(y == n + 1) {
x++;
y = 1;
}
// 所有点已经枚举完毕, 此时只有当摆放了n个皇后时, 才是一个可行的解
// 因为dfs的全部叶子节点,是对n^2个位置,每个位置选择放或者不放的一种排列,
// 则每个叶子节点到根节点的路径, 可能一共摆放了不到n个皇后, 极端一点, 每个位置都选择不放, 也形成了一个叶子节点
if(x == n + 1) {
if(s == n) {
for(int i = 1; i <= n; i++) {
for(int j = 1; j <= n; j++) printf("%c", ans[i][j]);
printf("\n");
}
printf("\n");
}
return ; // 已经枚举完所有位置, 直接返回
}
// 两种情况
// 1. 当前位置不放皇后, 则直接走到下一个位置
dfs(x, y + 1, s);
// 2. 尝试在当前位置放皇后
if(!row[x] && !col[y] && !dg[x + y - 1] && !rdg[y - x + n]) {
// 当前位置可以放皇后
ans[x][y] = 'Q'; // 放皇后
row[x] = col[y] = dg[x + y - 1] = rdg[y - x + n] = true; // 更新状态变量
dfs(x, y + 1, s + 1); // 放下去的皇后数量加一, 走到下一个位置
// dfs完毕, 恢复现场
ans[x][y] = '.';
row[x] = col[y] = dg[x + y - 1] = rdg[y - x + n] = false;
}
}
int main() {
scanf("%d", &n);
for(int y = 1; y <= n; y++) {
for(int j = 1; j <= n; j++) ans[y][j] = '.';
}
dfs(1, 1, 0); // 从[1,1]开始深搜, 初始放的皇后数量为0
return 0;
}
显然,解法二由于枚举了棋盘上的所有位置,其性能相比解法一要差一些
BFS
从左到右,一层一层的搜,所以叫宽度优先搜索。宽搜的基本框架:
1. 插入一个初始状态到queue中 2. while(queue非空) 2.1 把队头拿出来 2.2 扩展队头 3. end
BFS练习题
#include<cstring>
#include<iostream>
#include<queue>
using namespace std;
typedef pair<int,int> PII;
const int N = 110;
int n, m;
int a[N][N], d[N][N]; // a用来存矩阵数组, d用来存从起点走到该点的距离
int main() {
scanf("%d%d", &n, &m);
for(int i = 0; i < n; i++) {
for(int j = 0; j < m; j++) {
scanf("%d", &a[i][j]);
}
}
// -1表示该点还未走到
memset(d, -1, sizeof d);
queue<PII> q;
// 初始化
q.push({0,0});
d[0][0] = 0;
// 每次往下走需要调整的x和y
int dx[4] = {0, 1, 0, -1};
int dy[4] = {1, 0, -1, 0};
while(!q.empty()) {
PII p = q.front();
q.pop();
for(int i = 0; i < 4; i++) {
int x = p.first + dx[i];
int y = p.second + dy[i];
// 当这个点在边界内, 且这个点为0, 且该点还未被走到过, 则走过去
if(x >= 0 && x < n && y >= 0 && y < m && a[x][y] == 0 && d[x][y] == -1) {
q.push({x,y});
d[x][y] = d[p.first][p.second] + 1;
}
}
}
printf("%d\n", d[n - 1][m - 1]);
return 0;
}
课后题:acwing - 845: 八数码
求的其实是个最小的操作步数,考虑用BFS来做。把每一种棋盘的状态,当作树中的一个节点,其实就是寻找初始状态,到最终状态,是否存在一个路径,并找出最短路。难点在于棋盘的状态表示,以及如何将状态进行存储,并记录每种状态之间的距离。
由于这道题涉及到的一些C++语法不是特别熟,所以就写了一版java的题解
import java.util.*;
/**
* @Author yogurtzzz
* @Date 2021/6/30 16:42
**/
public class Main {
static Scanner scanner = new Scanner(System.in);
public static void main(String[] args) {
// 读入初始状态
StringBuilder sb = new StringBuilder();
for(int i = 0; i < 9; i++) {
String c = readString();
sb.append(c);
}
String initialState = sb.toString();
System.out.println(find(initialState));
}
static int find(String initialState) {
String finalState = "12345678x";
Queue<String> queue = new LinkedList<>();
Map<String, Integer> stepCount = new HashMap<>();
// 将初始状态push进去
queue.offer(initialState);
stepCount.put(initialState, 0);
int[] dx = {1, 0, -1, 0};
int[] dy = {0, 1, 0, -1};
// 使用BFS
while (!queue.isEmpty()) {
String s = queue.poll();
int currentCount = stepCount.get(s);
int pos = s.indexOf('x'); // 获取x的坐标
// 由于是 3 × 3 的矩阵, 转化一下, 得到二维坐标
int x = pos / 3;
int y = pos % 3;
// 枚举下一个状态(最多可能有4种)
for(int j = 0; j < 4; j++) {
int newX = x + dx[j];
int newY = y + dy[j];
if (newX >= 0 && newX < 3 && newY >= 0 && newY < 3) {
// 新的坐标没有超出边界, 则该状态是一种可能的状态
int newPos = newX * 3 + newY; // 二维坐标还原为一维坐标
char[] chars = s.toCharArray();
// 交换x和另一个数, 即交换pos和newPos
char temp = chars[pos];
chars[pos] = chars[newPos];
chars[newPos] = temp;
String nextState = new String(chars);
if (Objects.equals(nextState, finalState)) {
// 找到最终的状态
return currentCount + 1;
}
if (!stepCount.containsKey(nextState)) {
// 若先前没有出现过这种状态, 则加入, 因为是BFS, 若在先前出现过该状态, 则先前出现该状态时的步数一定更小
queue.offer(nextState);
stepCount.put(nextState, currentCount + 1);
}
}
}
}
return -1;
}
static String readString() {
return scanner.next();
}
}
C++题解:
#include <iostream>
#include <cstring>
#include <unordered_map>
#include <queue>
using namespace std;
int main() {
string ini;
char c;
for(int i = 0; i < 9; i++) {
cin >> c;
ini += c;
}
// bfs
unordered_map<string, int> step;
step[ini] = 0;
queue<string> q;
q.push(ini);
int dx[4] = {1, -1, 0, 0};
int dy[4] = {0, 0, 1, -1};
string end = "12345678x";
while(!q.empty()) {
string state = q.front();
q.pop();
int pos = state.find('x'); // coordinate for 'x'
int d = step[state]; // current distance
int x = pos / 3;
int y = pos % 3;
for(int i = 0; i < 4; i++) {
int nx = x + dx[i], ny = y + dy[i];
if(nx >= 0 && nx < 3 && ny >= 0 && ny < 3) {
int nPos = nx * 3 + ny;
swap(state[pos], state[nPos]);
if(step[state] == 0) {
q.push(state);
step[state] = d + 1;
}
swap(state[pos], state[nPos]);
}
}
}
if(step[end] == 0) printf("-1");
else printf("%d", step[end]);
}
树与图的存储
首先,树是一种特殊的图(无环连通图)。所以,这里只说图的存储即可。
首先,图分为2种,有向图和无向图。
有向图中2个点之间的边是有方向的,比如a -> b
,则只能从a
点走到b
点,无法从b
点走到a
点。
无向图中2个点之间的边是没有方向的,比如a - b
,则可以从a
走到b
,也可以从b
走到a
。
通常,我们可以将无向图看成有向图。比如上面,对a
到b
之间的边,我们可以建立两条边,分别是a
到b
的,和b
到a
的。
所以,我们只需要考虑,有向图如何存储,即可。通常有2种存储方式
- 邻接矩阵
用一个二维数组来存,比如g[a,b]
存储的就是a
到b
的边。邻接矩阵无法存储重复边,比如a
到b
之间有2条边,则存不了。(用的较少,因为这种方式比较浪费空间,对于有n个点的图,需要n2的空间,这种存储方式适合存储稠密图) - 邻接表
使用单链表来存。对于有n个点的图,我们开n个单链表,每个节点一个单链表。单链表上存的是该节点的邻接点(用的较多)
树和图遍历有2种方式,深度优先遍历和宽度优先遍历。
我们只需要考虑有向图的遍历即可。
树与图的深度优先遍历
由于遍历时,每个点最多只会被遍历一次,所以深度优先遍历的时间复杂度是O(n+m),
n是节点数,m是边数
练习题:acwing - 846: 树的重心
(深度优先遍历,可以算出每个子树所包含的节点个数)
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 1e5 + 10;
int e[2 * N], ne[2 * N], idx; // 单链表
int h[N]; // 用邻接表, 来存树
int n, ans = N;
bool visited[N];
void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
// 返回节点x所在子树的节点个数
int dfs(int x) {
visited[x] = true;
// sum 用来存 x 为根节点的子树的节点个数
// res 来存, x 的子树的节点个数的最大值
int sum = 1, res = 0;
for(int i = h[x]; i != -1; i = ne[i]) {
int j = e[i]; // 取节点
if(visited[j]) continue;
int s = dfs(j);
res = max(res, s);
sum += s;
}
res = max(res, n - sum);
ans = min(ans, res);
return sum;
}
int main() {
memset(h, -1, sizeof h);
scanf("%d", &n);
for(int i = 1; i <= n - 1; i++) {
int a, b;
scanf("%d%d", &a, &b);
add(a, b);
add(b, a);
}
dfs(1);
printf("%d", ans);
return 0;
}
树与图的宽度优先遍历
练习题:acwing - 847: 图中点的层次
#include<iostream>
#include<queue>
#include<cstring>
using namespace std;
const int N = 1e5 + 10;
int h[N], e[N], ne[N], idx; // 用邻接表来表示
int d[N]; // 从1号点走到i号点的最短距离
int n, m;
bool visited[N]; // 其实可以用d[i] = -1来判断
void add(int a, int b) {
e[idx] = b;
ne[idx] = h[a];
h[a] = idx++;
}
void bfs() {
queue<int> q;
q.push(1);
d[1] = 0;
visited[1] = true;
while(!q.empty()) {
int t = q.front();
q.pop();
for(int i = h[t]; i != -1; i = ne[i]) {
int u = e[i];
if(!visited[u]) {
d[u] = d[t] + 1;
visited[u] = true;
q.push(u);
}
}
}
}
int main() {
memset(h, -1, sizeof h);
memset(d, -1, sizeof d);
scanf("%d%d", &n, &m);
int a, b;
while(m--) {
scanf("%d%d", &a, &b);
add(a, b);
}
bfs();
printf("%d", d[n]);
return 0;
}
拓扑排序
图的宽度优先搜索的应用,求拓扑序(拓扑序是针对有向图的)
什么是拓扑序:将一个图的很多节点,排成一个序列,使得图中的所有边,都是从前面的节点,指向后面的节点。则这样一个节点的排序,称为一个拓扑序。
若图中有环,则一定不存在拓扑序。
可以证明,一个有向无环图,一定存在一个拓扑序列。有向无环图,又被称为拓扑图。
对于每个节点,存在2个属性,入度和出度。
入度,即,有多少条边指向自己这个节点。
出度,即,有多少条边从自己这个节点指出去。
所有入度为0的点,可以排在当前最前面的位置。
算法流程:
将所有入度为0的点入队。 while(queue非空) { t = queue.pop(); // 获取队头 枚举t的全部出边 t->j 删掉边t->j, j节点的入度减一 if(j的入度为0) 将j入队 }
一个有向无环图,一定存在至少一个入度为0的点,
练习题:acwing - 848: 有向图的拓扑序列
#include<cstring>
#include<iostream>
using namespace std;
const int N = 1e5 + 10;
int h[N], e[N], ne[N], idx; // 邻接表来存图
int in[N]; // 每个点的入度
int q[N], hh, tt = -1; // 数组模拟队列
int n, m;
void add(int x, int y) {
// y的入度加一
in[y]++;
// 邻接表
e[idx] = y;
ne[idx] = h[x];
h[x] = idx++;
}
int main() {
memset(h, -1, sizeof h);
scanf("%d%d", &n, &m);
int x, y;
while(m--) {
scanf("%d%d", &x, &y);
add(x, y);
}
// 将所有入度为0的点加入到队列中
for(int i = 1; i <= n; i++) {
if(in[i] == 0) q[++tt] = i;
}
while(tt >= hh) {
int t = q[hh++]; // 弹出队头
for(int i = h[t]; i != -1; i = ne[i]) {
int u = e[i];
if(--in[u] == 0) {
// 移除了节点t, 节点u入度减一, 若等于0, 加入到队列
q[++tt] = u;
}
}
}
if(tt == n - 1) {
for(int i = 0; i < n; i++) printf("%d ", q[i]);
} else printf("-1");
return 0;
}