算法基础部分4-深度优先搜索

算法部分 基础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 层蛋糕的最小侧面积
剪枝都会把中间过程中最优的数据存储起来,然后和后面的最比较,如果劣于前面的
直接放弃,进行新的尝试。
其余地方都比较好理解。

三、总结

  深度优先搜索是一个很重要的算法,注意前面第一列出的方法,再结合具体实际再学习,主要难点是剪枝,需要思考一些特殊情况,剪枝一般来说,会把中间最优的过程存储,后面出现比之前差的情况,直接放弃。总体来说,有思路看代码理解还是比较好理解,但是自己编写还需要多看多练习。
  总的来说,剪枝需要考虑主要参考蛋糕问题,我感觉这个是最综合的一个题了。其余的都要结合场景来进行深度优先搜索。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值