Acwing - 算法基础课 - 笔记(七)

486 阅读7分钟

搜索与图论(一)

本节讲的是,普通的DFS和BFS,树和图的存储,拓扑排序。

DFS和BFS

概述

DFS:深度优先搜索(Depth-First-Search)

BFS:宽度优先搜索(Breadth-First-Search)

DFS和BFS的对比

  • DFS使用(stack)来实现,BFS使用队列(queue)来实现

  • DFS所需要的空间是树的高度h,而BFS需要的空间是2^h^ (DFS的空间复杂度较低)

  • DFS不具有最短路的特性,BFS具有最短路的特性

通常来说,求“最短”的操作,都可以用BFS来做,而其他一些奇怪的操作,或者对空间复杂度要求比较高,则用DFS来做

DFS

DFS中的2个重要概念:

  • 回溯:回溯的时候,一定要记得恢复现场
  • 剪枝:提前判断某个分支一定不合法,直接剪掉该分支

DFS练习题

acwing - 842: 排列数字

经典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;
}

acwing - 843: n-皇后问题

解法一

首先经过思考和提炼,容易发现,每一行只能放一个皇后,则问题被精简成了,从第一行开始,到第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练习题

acwing - 844: 走迷宫

#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();
	}
}


树与图的存储

首先,树是一种特殊的图(无环连通图)。所以,这里只说图的存储即可。

首先,图分为2种,有向图无向图

有向图中2个点之间的边是有方向的,比如a -> b,则只能从a点走到b点,无法从b点走到a点。

无向图中2个点之间的边是没有方向的,比如a - b,则可以从a走到b,也可以从b走到a

通常,我们可以将无向图看成有向图。比如上面,对ab之间的边,我们可以建立两条边,分别是ab的,和ba的。

所以,我们只需要考虑,有向图如何存储,即可。通常有2种存储方式

  • 邻接矩阵

    用一个二维数组来存,比如g[a,b]存储的就是ab的边。邻接矩阵无法存储重复边,比如ab之间有2条边,则存不了。(用的较少,因为这种方式比较浪费空间,对于有n个点的图,需要n^2^的空间,这种存储方式适合存储稠密图

  • 邻接表

    使用单链表来存。对于有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;
}