算法部分 基础4
一、深度优先搜索的简述
1. 深度优先搜索的简述
比如在图上寻找路径,就是一种深度优先搜索的典型例子。简单解释就是: 从起点出发,走过的点要做标记,发现有没走过的点,就随意挑一个往前走,走不了就回退,这种路径搜索策略就称为 “深度优先搜索”, 简称深搜。这种策略总是试图走得更远,深度就是距离起点的步数来衡量。
1.1 要找到终点,伪代码如下
// 判断 V 出发是否能到终点
bool Dfs(V){
if(V 为 终点)return true;
if(V 为 旧点)return false;
将 V 标记为旧点;
对和 V 相邻的每个节点 U{
if(Dfs(U) == true)return true;
}
return false;
}
int main(){
将所有点都标记为新点;
起点 = 开始;
终点 = 结束;
cout << Dfs(起点);
}
1.2 在图上寻找路径,伪代码如下
Node path[MAX_LEN]; // MAX_LEN 取节点总数即可
int depth;
bool Dfs(V){
if( V 为终点){
path[depth] = V;
return true;
}
if( V 为旧点)return false;
将 V 标记为旧点;
path[depth] = v;
++depth;
对和 V 相邻的每个节点 U{
if(Dfs(U) == true)return true;
}
depth--;
return false;
}
int main(){
将所有点都标记作新点;
depth = 0;
if(Dfs(起点)){
for(int i = 0; i <= depth; i++){
cout << path[i] << endl;
}
}
}
1.3 遍历图上所有的点,伪代码如下
Dfs(V){
if(V 是旧点)return ;
将 V 标记为旧点;
对和 V 相邻的每个点 U {
Dfs(U);
}
}
int main(){
将所有点都标记为新点;
while(在图中能找到新点 k )Dfs(k);
}
2. 图的表示方法
2.1 邻接图
用一个二维数组 G 存放图,G [ i ] [ j ] 表示节点 i 和节点 j 之间边的情况 (如有无边,边方向,权重大小等)。遍历复杂度为 O(n^2) , n 为节点数目。
2.2 邻接表
每个节点 V 对应一个一维数组 (vector) , 里面存放从 V 连出去的边,边的信息包括另一顶点,还可能包括边权值等。
遍历复杂度为 O(n + e) ,n 为节点数目,e 为边数。如果边比较稀疏情况下,比邻接图数组存放方式效率要高很多。
二、深度优先搜索例子
1. 城堡问题
问题描述:下图是一个城堡的地形图。请编写一个程序,计算城堡一共有多少个房间,最大房间为多大。城堡被分割成 m*n (m <= 50, n <= 50) 个方块,每个方块可以有 0~4 面墙。
输入输出
输入:
. 程序从标准输入设备读入数据。
. 第一行是两个整数,分别代表南北向、东西向的方块数。
. 在接下来的输入行里,每个方块用一个数字 (0 <= p <= 50) 描述。用一个数
字表示方块周围的墙, 1 表示西墙,2 表示北墙,4 表示东墙,8 表示南墙。每个方块
用代表其周围墙的数字之和表示。城堡的内壁被计算两次,方块 (1, 1) 的南墙同时也
是方块 (2, 1) 的北墙。
. 输入的数据保证城堡至少有两个房间。
输出:
. 城堡的房间数、城堡中最大房间所包括的方块数。
. 结果显示在标准输出设备上。
北(2)
西(1) 东(4)
南(8)
墙用二进制方式表示
西(1) 0001
北(2) 0010
东(4) 0100
南(8) 1000
比如 11 表示为 1011 那么就有 西、北、南 三个墙
样例如下,
输入:
4
7
11 6 11 6 3 10 6
7 9 6 13 5 15 5
1 10 12 7 13 7 5
13 11 10 8 10 12 13 // 描述上面图片地形的输入方法
输出:
5 // 一共有 5 个房间
9 // 最大房间的面积是 9
如何把城堡问题和深度优先搜索方式联系起来,需要有建模的思路,所以分析如下,
. 把方块看作是节点,相邻两个方块之间如果没有墙,则在方块之间连一条边,这样城
堡就能转换成一个图。
. 求房间个数,实际上就是在求图中有多少个极大连通子图。
. 一个连通子图,往里头加任何一个图里的其他点,就会变得不连通,那么这个连通子
图就是极大连通子图。(如 (8, 5, 6))
这个图起到分析作用,和上面分析相关
解题思路
对每个房间,深度优先搜索,从而给这个房间能够到达的所有位置染色。最后统计一共
用了几种颜色,以及每种颜色的数量。(只是一种思路)
比如,把上面的房间染成 5 种颜色,
1 1 2 2 3 3 3
1 1 1 2 3 4 3
1 1 1 5 3 5 3
1 5 5 5 5 5 3
通过观察颜色就可以看出,最大房间颜色为 1 占据了 9 个格子,一共有 5 个颜色,那
么有 5 个房间。
程序如下
#include <iostream>
#include <stack>
#include <cstring>
using namespace std;
int R, C;
int rooms[60][60];
int colors[60][60];
// 最大房间面积,房间数目
int maxRoomArea = 0, roomNum = 0;
int roomArea; // 当前房间面积
void Dfs(int i, int j){
if( i > R || i < 0 || j > C || j < 0 ) return ;
if( colors[i][j] ) return ;
roomArea++;
colors[i][j] = roomNum;
/*
北(2)
西(1) 东(4)
南(8)
墙用二进制方式表示
西(1) 0001
北(2) 0010
东(4) 0100
南(8) 1000
*/
if(( rooms[i][j] & 1) == 0 ) Dfs(i, j - 1);
if(( rooms[i][j] & 2) == 0 ) Dfs(i - 1, j);
if(( rooms[i][j] & 4) == 0 ) Dfs(i, j + 1);
if(( rooms[i][j] & 8) == 0 ) Dfs(i + 1, j);
}
int main(){
cin >> R >> C;
for(int i = 1; i <= R; i++){
for(int j = 1; j <= C; j++){
cin >> rooms[i][j];
}
}
memset(colors, 0, sizeof(colors)); // 全部置 0
for(int i = 1; i <= R; i++){
for(int j = 1; j <= C; j++){
if( !colors[i][j] ){
roomArea = 0;
roomNum++;
Dfs(i, j);
maxRoomArea = max(roomArea, maxRoomArea);
}
}
}
cout << roomNum << endl;
cout << maxRoomArea << endl;
}
// 复杂度为 O(R * C)
运行结果如下,
程序思路还是比较好理解的,主要是对题目的理解,还有建模抽象得到图的情况就可以了。递归利用树形遍历思考就行。
2. 踩方格
有一个方格矩阵,矩阵边界在无穷远处。我们做如下假设:
a. 每走一步时,只能从当前方格移动一格,走到某个相邻的方格上;
b. 走过的格子立即塌陷无法再走第二次;
c. 只能向北、东、西三个方向走。
请问:如果允许在方格矩阵上走 n 步 (n <= 20), 共有多少种不同的方案。 2 种走法只要有一步不一样,即被认为是不同的方案。
思路如下
思路:
递归
从 (i, j) 出发,走 n 步的方案数,等于以下三项之和:
从(i+1, j)出发,走 n-1 步的方案数。前提:(i+1, j)还没走过
从(i, j+1)出发,走 n-1 步的方案数。前提:(i, j+1)还没走过
从(i, j-1)出发,走 n-1 步的方案数。前提:(i, j-1)还没走过
程序如下,
#include <iostream>
#include <cstring>
using namespace std;
int visited[30][50];
int ways( int i, int j, int n ){
if( n == 0 ) return 1;
visited[i][j] = 1;
int num = 0;
if( ! visited[i][j - 1] )
num += ways(i, j - 1, n - 1);
if( ! visited[i][j + 1] )
num += ways(i, j + 1, n - 1);
if( ! visited[i + 1][j] )
num += ways(i + 1, j, n - 1);
// 相当于对于 (i, j - 1), (i, j + 1), (i + 1, j)
// 中下面层次的嵌套递归走过的路做还原,不然会冲突
visited[i][j] = 0;
return num;
}
int main(){
int n;
cin >> n;
memset( visited, 0, sizeof(visited) );
// (0, 25) 作为起点
cout << ways(0, 25, n) << endl;
return 0;
}
运行结果如下,
重点分析
其他的都比较好理解,重点是这两个语句
visited[i][j] = 1;
...
visited[i][j] = 0;
置 1 是为了深度遍历做标记,走过的就不能走了
置 0 是为了能走的路中下面层次的嵌套递归走过的路做还原,不然会冲突
这里可以仔细思考,很有意思的一步。
3. 寻路问题 1 – 深度优先遍历
问题描述:N 个城市,编号 1 到 N . 城市间有 R 条单向道路。每条道路连接两个城市,有长度和过路费两个属性。 Bob 只有 K 块钱,他想从城市 1 走到城市 N . 问最短共需要走多长的路。如果到不了 N , 输出为 -1 .
2 <= N <= 100
0 <= K <= 10000
1 <= R <= 10000
每条路的长度 L , 1 <= L <= 100
每条路的过路费 T, 0 <= T <= 100
输入描述
输入:
K
N
R
s1 e1 L1 T1
s2 e2 L2 T2
......
sR eR LR TR
s e 是路起点和终点
解题思路
从城市 1 开始深度优先遍历整个图,找到所有能过到达 N 的走法,选一个最优的。
程序如下,
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
int K, N, R;
struct Road{
// 终点,长度,路费
int d, L, t;
};
// 用临接表的形式存放
vector < vector < Road > > G(110); // G[i] i 表示起点
int minLen;
int totalLen;
int totalCost;
int visited[110];
void dfs( int s ){
if( s == N ){
minLen = min( minLen, totalLen );
return ;
}
// 使用邻接表定位到当前路,对当前这条路可以走的位置进行遍历
for( int i = 0; i < G[s].size(); i++ ){
// 定位到第 r 条路的位置
Road r = G[s][i];
if( totalCost + r.t > K ) continue; // 走不了了
if( ! visited[r.d] ){
// 计算过程中数值累加
totalLen += r.L;
totalCost += r.t;
visited[r.d] = 1;
dfs(r.d);
// 计算完成和避免冲突,和上面踩方格问题一样
visited[r.d] = 0;
totalLen -= r.L;
totalCost -= r.t;
}
}
}
int main(){
// 钱、城市、路数
cin >> K >> N >> R;
for( int i = 0; i < R; i++ ){
int s;
Road r;
// 起点、终点、路长、路费
cin >> s >> r.d >> r.L >> r.t;
// 用邻接表方式存放,元素位置就是起点
if( s != r.d ){
G[s].push_back(r);
}
}
memset( visited, 0, sizeof(visited) );
totalLen = 0;
minLen = 1 << 30; // 表示一个很大的数
totalCost = 0;
visited[1] = 1;
dfs(1);
// minLen 表示一个很大的数,为了求 min
if( minLen < (1 << 30) ){
cout << minLen << endl;
}
else{
cout << "-1" << endl;
}
return 0;
}
结果如下,
这里主要理解的几个点
1. 在输入部分
// 起点、终点、路长、路费
cin >> s >> r.d >> r.L >> r.t;
// 用邻接表方式存放,元素位置就是起点
if( s != r.d ){
G[s].push_back(r);
}
输入部分,使用 vector 容器作为邻接表,那样行就是出发位置,列代表当前出发位置
可以走的所有路
2. minLen = 1 << 30; // 表示一个很大的数
这里老师只是为了让我们理解下左移生成很大的数的方式
3. 在 dfs 中
if( ! visited[r.d] ){
// 计算过程中数值累加
totalLen += r.L;
totalCost += r.t;
visited[r.d] = 1;
dfs(r.d);
// 计算完成和避免冲突,和上面踩方格问题一样
visited[r.d] = 0;
totalLen -= r.L;
totalCost -= r.t;
}
这里理解思路和上面踩方格理解思路一样。
但是这个程序如果数据过多就会超时,因此要继续改进。
4. 寻路问题 2 – 剪枝方法
因为上面程序运行时数据过多会导致超时,因此需要对程序进行改进,让时间复杂度满足要求。
用剪枝思路来实现要求,在搜索过程中,如果走一条路就算到了终点,也肯定不优于之前的路,那就直接抛弃,重新寻找新的路。
思路:
从城市 1 开始深度优先遍历整个图,找到所有能过到达 N 的走法,选择一个最优的。
最优性剪枝:
1. 如果当前已经找到的最优路径长度为 L , 那么在继续搜索的过程中,总长度已经
大于等于 L 的走法,就可以直接放弃,不用走到底了。
保存中间计算结果用于最优性剪枝:
2. 用 mid[k][m] 表示: 走过城市 k 时总过路费为 m 的条件下,最优路径的长度。
若在后续的搜索中,再此走到 k 时,如果总路费恰好为 m , 其此时的路径长度已经超
过 mid[k][m] , 则不必再走下去。
因此改进算法如下,
#include <iostream>
#include <vector>
#include <cstring>
using namespace std;
int K, N, R;
struct Road{
// 终点,长度,路费
int d, L, t;
};
// 用临接表的形式存放
vector < vector < Road > > G(110); // G[i] i 表示起点
int minLen;
int totalLen;
int totalCost;
int minL[110][10010]; // 坐标是城市和钱的组合,存储的是走到的位置
int visited[110];
void dfs( int s ){
if( s == N ){
minLen = min( minLen, totalLen );
return ;
}
// 使用邻接表定位到当前路,对当前这条路可以走的位置进行遍历
for( int i = 0; i < G[s].size(); i++ ){
// 定位到第 r 条路的位置
Road r = G[s][i];
// 可行性剪枝
if( totalCost + r.t > K ) continue; // 走不了了
if( ! visited[r.d] ){
// 可行性剪枝,如果走一条路就算到了终点,也肯定不优于之前的路,
// 那就直接抛弃,重新寻找新的路。
if( totalLen + r.L >= minLen ) continue ;
// 走到了 d 这个位置,一共走的路程与之前走过的
// 相同位置下,花费的钱对应的路程做比较,如果大
// 于就没必要寻找了,肯定不是最优的方案
if( totalLen + r.L >= minL[r.d][totalCost + r.t] ) continue ;
// 更新 minL
minL[r.d][totalCost + r.t] = totalLen + r.t;
// 计算过程中数值累加
totalLen += r.L;
totalCost += r.t;
visited[r.d] = 1;
dfs(r.d);
// 计算完成和避免冲突,和上面踩方格问题一样
visited[r.d] = 0;
totalLen -= r.L;
totalCost -= r.t;
}
}
}
int main(){
// 钱、城市、路数
cin >> K >> N >> R;
for( int i = 0; i < R; i++ ){
int s;
Road r;
// 起点、终点、路长、路费
cin >> s >> r.d >> r.L >> r.t;
// 用邻接表方式存放,元素位置就是起点
if( s != r.d ){
G[s].push_back(r);
}
}
memset( visited, 0, sizeof(visited) );
totalLen = 0;
minLen = 1 << 30; // 表示一个很大的数
totalCost = 0;
visited[1] = 1;
// 开始全部置为 大的数
for(int i = 0; i < 110; i++){
for(int j = 0; j < 10010; j++){
minL[i][j] = 1 << 30;
}
}
dfs(1);
// minLen 表示一个很大的数,为了求 min
if( minLen < (1 << 30) ){
cout << minLen << endl;
}
else{
cout << "-1" << endl;
}
return 0;
}
上面运行时间要远远小于之前的程序,主要添加两个最优剪枝的点,分析如下
1. if( totalLen + r.L >= minLen ) continue ;
可行性剪枝,如果走一条路就算到了终点,也肯定不优于之前的路,那就直接抛弃,重新
寻找新的路。
2. if( totalLen + r.L >= minL[r.d][totalCost + r.t] ) continue ;
走到了 d 这个位置,一共走的路程与之前走过的相同位置下,花费的钱对应的路程做比
较,如果大于就没必要寻找了,肯定不是最优的方案。
5. 生日蛋糕问题
问题描述:要制作一个体积
N
π
N\pi
Nπ 的 M 层生日蛋糕,每层都是一个圆柱体。
设从下往上数第 i (1 <= i <= M) 层蛋糕是半径为 Ri , 高度为 Hi 的圆柱。当 i < M 时,要求 Ri > R{i + 1} 且 Hi + 1.
由于要在蛋糕上抹奶油,为尽可能节约经费,希望蛋糕外表面 (最下一层地面除外) 的面积 Q 最小。
令 Q =
S
π
S\pi
Sπ
请编程对给出的 N 和 M ,找出蛋糕的制作方案,每层的 R 和 H 的值 (适当的 Ri 和 Hi 的值),使 S 最小。(除 Q 外,以上所有数据皆是正整数),思路如下
--深度优先搜索,枚举每一层可能的高度和半径。
--底层蛋糕的最大可能半径和最大可能高度。
--从底层往上搭蛋糕,而不是从顶层往下搭。
--重点:如何剪枝?
剪枝方法
剪枝 1 :搭建过程中发现已建好的面积已经超过目前求得的最优表面积,或者预见搭完
后面积一定会超过目前最优表面积,则停止搭建。(最优性剪枝)
剪枝 2 :搭建过程中预见到再网上搭,高度已经无法安排,或者半径已经无法安排,则
停止搭建。(可行性剪枝)
剪枝 3 :搭建过程中发现还没搭的那些层的体积,一定会超过还缺的体积,则停止搭建
(可行性剪枝)
剪枝 4 :搭建过程中发现还没搭的那些层的体积,最大也到不了还缺的体积,则停止搭
建(可行性剪枝)
程序如下,
#include <iostream>
#include <vector>
#include <cstring>
#include <cmath>
using namespace std;
int N, M; // 体积, 层数
int minArea = 1 << 30; // 最优表面积
int area = 0; // 正在搭建中蛋糕的表面积
int minV[30]; // minV[n] 表示 n 层蛋糕最小的体积
int minA[30]; // minA[n] 表示 n 层蛋糕的最小侧面积
int maxVforNRH(int n, int r, int h){
// 求在蛋糕有 n 层,底层最大半径为 r , 最高高度为 h 的
// 情况下能凑出来的最大体积
int v = 0;
for(int i = 0; i < n; i++){
v += (r - i) * (r - i) * (h - i);
}
return v;
}
void Dfs(int v, int n, int r, int h){
// 要用 n 层去凑体积 v, 最底层半径不能超过 r , 高度不能超过 h
// 求出最小表面积放入 minArea
if( n == 0 ){
if( v ) return ;
else{
minArea = min( minArea, area );
return ;
}
}
if( v <= 0 ) return ;
// 剪枝 3
if(minV[n] > v) return ;
// 剪枝 1
if(area + minA[n] >= minArea) return ;
// 剪枝 2
if(h < n || r < n) return ;
// 剪枝 4
if(maxVforNRH(n ,r, h) < v) return ;
// 当前底层蛋糕半径和高度,递归调用时候进行寻找
// 把 n 当作最底层,递归最后是 1 ,所以作为最终点
for( int rr = r; rr >= n; rr-- ){
if( n == M ) // 底面积
area = rr * rr;
// 枚举到底层高度
for( int hh = h; hh >= n; hh-- ){
area += 2 * rr * hh; // 仅算侧面积
Dfs(v - rr * rr * hh, n - 1, rr - 1, hh - 1); // 进行状态转移
area -= 2 * rr * hh;
}
}
}
int main(){
cin >> N >> M; // 体积,层数
minV[0] = 0;
minA[0] = 0;
for(int i = 1; i <= M; i++ ){
minV[i] = minV[i - 1] + i * i * i; // 第 i 层半径至少为 i , 高度至少为 i
minA[i] = minA[i - 1] + 2 * i * i;
}
if( minV[M] > N )
cout << 0 << endl; // 不满足要求
else{
// 进行反解
int maxH = ( N - minV[M - 1] ) / (M + M) + 1; // 底层最大高度
// 最底层面积不超过 (N - minV[M - 1]), 且半径至少为 M
int maxR = sqrt( double(N - minV[M - 1]) / M ) + 1; // 底层高度至少为 M
area = 0;
minArea = 1 << 30;
Dfs(N, M, maxR, maxH);
if(minArea == 1 << 30)
cout << 0 << endl;
else
cout << minArea << endl;
}
return 0;
}
运行结果如下
分析
这里主要难点是剪枝,注意这里都是整型运算,所以半径高度都是整数,蛋糕深度优先搜
索的思路遍历所有半径和高度的组合,并满足总体积 V 的要求。
剪枝如下:
这里主要两个数组,
int minV[30]; // minV[n] 表示 n 层蛋糕最小的体积
int minA[30]; // minA[n] 表示 n 层蛋糕的最小侧面积
剪枝都会把中间过程中最优的数据存储起来,然后和后面的最比较,如果劣于前面的
直接放弃,进行新的尝试。
其余地方都比较好理解。
三、总结
深度优先搜索是一个很重要的算法,注意前面第一列出的方法,再结合具体实际再学习,主要难点是剪枝,需要思考一些特殊情况,剪枝一般来说,会把中间最优的过程存储,后面出现比之前差的情况,直接放弃。总体来说,有思路看代码理解还是比较好理解,但是自己编写还需要多看多练习。
总的来说,剪枝需要考虑主要参考蛋糕问题,我感觉这个是最综合的一个题了。其余的都要结合场景来进行深度优先搜索。