深度优先搜索详解 C++实现

DFS

全文大概四千字左右,如果您初学DFS相信会对您会有很大的帮助,能力有限,很多术语不够专业,理解万岁

二叉树的深度优先搜索

二叉树的概念这里就不细谈了

使用数组来存储二叉树,根结点从1开始(方便计算),设父节点的下标为n,那么左儿子的下标为2n,右儿子的下标为2n+1

       2
     /   \
    3     5
   / \    / \
  7   8  6    1
 /     \
4       9

上面这棵树用数组就可以表示成

数组下标:0 1 2 3 4 5 6 7 8 9 10 11 
     值:0 2 3 5 7 8 6 1 4 0  0  9

因为深度优先遍历是深度优先,形象一点就是不撞南墙不回头。

深度优先遍历是基于栈完成的,因为我们是记录路线所以下面的步骤应该是这样(您不妨拿上纸和笔来模拟一下这个过程):

  1. 首先准备一个栈;
  2. 然后把根结点入栈,这个时候程序应该把入栈结点的值输出(每次入栈都输出入栈节点的值),然后根节点左边有儿子,那么我们就向左出发,直到4为止,因为4左右都没有节点了,这个时候栈里面的元素从栈底到栈顶为2 3 7 4;
  3. 这时候的栈顶为4,因为4左右都没有节点了,所以我们把4出栈;这时候的栈顶为7,同理7左右都没有节点了,所以7出栈;这时候的栈顶为3,然后3的右边有节点,所以把3右边的8入栈,8右边有节点所以把9入栈,这个时候栈里面的元素从栈底到栈顶为2 3 8 9;
  4. 因为 3 8 9我们都走过了,并且已经没有新的节点让我们走了,所以3 8 9出栈,这时候栈顶为2,然后可以看到根节点的右边还有没访问的节点,所以我们就把5入栈,相信到这里您应该明白了深度优先遍历在二叉树的路线,后面您可以尝试自己把剩下的走完;

所以上面这棵树的深度优先遍历的结果应为 2 3 7 4 8 9 5 6 1

一句话概括就是一直往左边走,左边走完了或者已经走过了就走右边

但是我们在编写代码的时候我们不需要自己手动开一个栈,计算机在内部使用被称为调用栈的栈,也就是说我们可以通过递归来实现上面这个过程,具体的过程和上面一样不用过分纠结,下面是使用递归实现的代码

注意点:

  1. 判断是否为空节点是通过0来判断(这样做是不严谨的,因为有时我们需要存0,我们可以把0替换成一个使用率很低的数字,比如0x3f3f3f3f);
  2. 为了避免越界,tree数组稍微开大一点点;
  3. vis数组判断当前点是否被访问过。访问过为true,没访问过为false;
int tree[50];
bool vis[50];

void dfs(int u) {	// u代表当前在哪个点
  
	vis[u] = true;  // 将当前节点标记为访问过的节点
	printf("%d ", tree[u]); // 输出

	if(tree[2 * u] != 0 && !vis[2 * u]) {
		dfs(2 * u);
	} 

	if(tree[2 * u + 1] != 0 && !vis[2 * u + 1]) {
		dfs(2 * u + 1);
	} 
}

int main() {

	memset(vis, false, sizeof vis);

	tree[1] = 2;
	tree[2] = 3;
	tree[3] = 5;
	tree[4] = 7; 
	tree[5] = 8;
	tree[6] = 6;
	tree[7] = 1;
	tree[8] = 4;
	tree[11] = 9;

	dfs(1);
 	
	return 0;
}

搜索

搜索算法是利用计算机的高性能来有目的的穷举一个问题解空间的部分或所有的可能情况,从而求出问题的解的一种方法。

搜索算法实际上是根据初始条件和扩展规则构造一棵“解答树”并寻找符合目标状态的节点的过程。所有的搜索算法从最终的算法实现上来看,都可以划分成两个部分——控制结构(扩展节点的方式)和产生系统(扩展节点),而所有的算法优化和改进主要都是通过修改其控制结构来完成的。其实,在这样的思考过程中,我们已经不知不觉地将一个具体的问题抽象成了一个图论的模型——树,即搜索算法的使用第一步在于搜索树的建立。

全排列问题https://www.luogu.com.cn/problem/P1706

我们首先思考一下for循环的做法,假设我们只求三位

for(int i = 1; i <= 3; ++i) {
		for(int j = 1; j <= 3; ++j) {
			for(int k = 1; k <= 3; ++k) {
				if(i != j && i != k && j != k) {
					printf("%d %d %d\n", i, j, k);
				}
			}
		}
	}

问题引刃而解,但是很明显数据范围0~9,我们不可能这么写。

这个时候我们仔细思考一下,我们求全排列的位数与有几层for是相同的,我们都知道遇到这种代码相似点比较多的时候(不知道也没关系,起码现在知道了),我们可以考虑使用递归去解决。

不过在我们开始写递归之前,我们先看看这句话搜索算法的使用第一步在于搜索树的建立

搜索树是什么?怎么建立?让我们看看上面三层for循环的代码

i = 1, j = 1, k = 1,不符合题意所以我们不输出
i = 1, j = 1, k = 2,不符合题意所以我们不输出
i = 1, j = 1, k = 3,不符合题意所以我们不输出
i = 1, j = 2, k = 1,不符合题意所以我们不输出
i = 1, j = 2, k = 2,不符合题意所以我们不输出
i = 1, j = 2, k = 3,符合题意所以我们输出答案 1 2 3
i = 1, j = 3, k = 1,不符合题意所以我们不输出
i = 1, j = 3, k = 2,符合题意所以我们输出答案 1 3 2
i = 1, j = 3, k = 3,不符合题意所以我们不输出

篇幅原因我们只写i = 1的情况,我们尝试把上面的过程画成树,大概长下面这样

i:            1
       /      |     \
j:    1       2       3
    / | \   / | \   / | \
k: 1  2  3 1  2  3 1  2  3

那么这个连接的路径就是我们的答案

我们可以发现这个答案产生的过程和上面树的深度优先搜索是不是非常非常的神似,所以说当前的问题就是把这棵树建立出来,我们肯定不会像上面那样手动一个一个去填对吧,这个时候就需要引入另外一个概念,叫作回溯。

回溯

回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。

       2
     /   \
    3     5
   / \    / \
  7   8  6    1
 /     \
4       9
还是这棵树,我们可以看到当我们走到4的时候发现没有节点让我们走了,所以我们选择回到7,从4回到7的这一步叫作“回溯”
这里的回溯我们是通过递归调用时的调用栈出栈来完成的
思考一下,我们又应该如何去处理全排列问题的回溯

我们可以这样去思考,不要把这棵树想的太过具体,我们首先需要一个数组来存储我们最后的答案

vector<int> num;

我们还是使用递归来解决,有了上面那棵树我们可以知道这个数组的变化应该是这样的

1
1 1 
1 1 1
1 1		回溯
1 1 2
1 1		回溯
1 1 3
1 1		回溯
1 		回溯
1 2	  
1 2 1
1 2		回溯
1 2 2
1 2		回溯
1 2 3
1 2   回溯
1     回溯
1 3 
1 3 1
1 3		回溯
1 3 2
1 3		回溯
1 3 3
1 3   回溯
1     回溯

众所周知,递归需要一个递归出口,那么这里很明显我们就知道了,递归的出口就是当数组的大小为3的时候,同时我们还需要把数组里的元素输出出来,所以出口的代码应该长这样

void dfs() {
  if(num.size() == len) {
		for (int i : num) {
			cout << i << " ";
		}
		cout << endl;
		return;
	}
}

接下来的步骤就是往里面加元素,我们需要把从1到3所有的数字都放进去,这一步除了循环或许没有更好的办法吧

void dfs() {
  if(num.size() == len) {
		for (int i : num) {
			cout << i << " ";
		}
		cout << endl;
		return;
	}
  
	for(int i = 1; i <= 3; ++i) {
		num.push_back(i);
		
	}
}

忘了一点我们函数还没写参数呢!仔细想想我们需要什么参数

我们需要一个长度吧,这样我们才知道什么时候退出递归,那么我们再调整一下我们的代码

void dfs(int len) {
  if(num.size() == len) {
		for (int i : num) {
			cout << i << " ";
		}
		cout << endl;
		return;
	}
  
	for(int i = 1; i <= len; ++i) {
		num.push_back(i);
		
	}
}


写到这一步,就来到最关键的步骤了,既然我们要让下一步出现1 1还有1 1 1的排列,很明显这个时候是不是应该递归了?

for(int i = 1; i <= len; ++i) {
		num.push_back(i);
		dfs(len);	// len代表程序递归结束时的长度 直接跟着传就行了	
	}

不出意外的话,这个时候点运行程序输出了1 1 1,这是我们期望的结果之一,但是没有列举出所有情况,原因就是我们没有进行回溯,回到上面的列出的数组变化情况,我们可以发现回溯的过程就是数组删除最后一位的过程,那么很明显我们需要加上num.pop_back(),所以我们代码应该长这样

void dfs(int len) {

	if(num.size() == len) {
		for (int i : num) {
			cout << i << " ";
		}
		cout << endl;
		return;
	}


	for(int i = 1; i <= len; ++i) {
		num.push_back(i);
		dfs(len);
		num.pop_back();
	}
}

这段代码会把所有结果都输出出来,但是我们只需要符合题意的,所以我们需要检查一下,再根据题意修改一下代码,完整代码如下

#include "iostream"
#include "vector"
using namespace std;

vector<int> num;
bool vis[10]; // 这个数组用于检查数组中是否含有重复的数字

void dfs(int len) {

	if(num.size() == len) {

		for(int i = 1; i <= len; ++i) vis[i] = false; // 每次检查时记得把数组初始化

		for(int i = 0; i < num.size(); ++i) {
			if(vis[num[i]] == true) // 如果重复出现了就不符合题意 跳出 反之将这个点标记
				return ;
			else
				vis[num[i]] = true; 
		}
		// 好了接下来的就是正确答案
		for (int i : num) {
			cout << "    " << i;
		}
		cout << endl;
		return;
	}

	for(int i = 1; i <= len; ++i) {
		num.push_back(i);
		dfs(len);
		num.pop_back();
	}
}

int main() {

	int n;
	cin >> n;
	dfs(n);

	return 0;
}

结果有一个点超时了,这个时候我们来想一想该如何去优化这个算法

先看看我们之前的算法有什么问题

其实一眼就可以看出来就是每次我们都需要检查是否有重复的,我们可不可以简化成只要出现重复的就不走这个分支

那么就可以将这个递归树简化成这样

     什么都不选
   /    |    \
  1     2     3
 / \   / \   / \
2   3 1   3 1   2
|   | |   | |   |
3   2 3   1 2   1

同样的搜索的路径就是答案,我们可以发现相比于上面的算法光是1这一个分支就有13个节点,我们优化后的算法总共才15个节点,时间复杂度大大降低,这一步也被称为剪枝,再根据题意修改一下代码细节,具体的实现代码如下

#include "iostream"
#include "vector"
using namespace std;

vector<int> num;
bool vis[10];

void dfs(int len) {

	if(num.size() == len) {

		for (int i : num) {
			cout << "    " << i;
		}
		cout << endl;
		return;
	}

	for(int i = 1; i <= len; ++i) {
		if(!vis[i]) { // 判断这个节点有没有被访问过
			vis[i] = true; // 没有访问过那就标记 然后加入num数组
			num.push_back(i);
			dfs(len);
			vis[i] = false; // 回溯 所以将这个点重新标记成未访问过的状态
			num.pop_back();
		}
	}
}

int main() {

	int n;
	cin >> n;
	dfs(n);

	return 0;
}

AC了(鼓掌)

总结一下我们刚刚的步骤:

  1. 画出搜索树(递归树)
  2. 寻找递归出口
  3. 寻找递归条件
  4. 剪枝优化

代码模板

void dfs(int u) {
	if(……) { // 递归出口
		……;
		return ;
	}
	
	for(int i = 1; i <= u; ++i) {
		if(check()) { // 剪枝
			……; // 选择分枝
			dfs(u);
			……; //回溯
		}
	}
}

接下来挑战一下难题

八皇后https://leetcode-cn.com/problems/eight-queens-lcci/submissions/

根据我们上面的步骤来解决这个问题

这里我们来画棋盘为2*2的情况

 什么都不选
 /       \
 Q.      .Q
/ \      / \
Q. Q.   .Q .Q
Q. .Q   Q. .Q

很明显上面的情况都不符合情况,直到4才会出现解,但是到4的时候有256种情况,所以我们选择符合题意的两个情况

.Q..
...Q
Q...
..Q.

..Q.  
Q...
...Q
.Q..

很明显递归的出口就是列数达到需要求的棋盘大小的时候

但是这个时候我们就需要来思考剪枝的情况了

因为当棋盘大小来到9的时候,粗略估计我们需要435848049次匹配才能得出所有的答案,在加上检查的时间,很明显1秒无法完成这个算法,而且内存也会炸

仔细思考也不难发现,剪枝的情况就是皇后之间相互冲突的时候,也就是我们每次摆放下一个皇后的时候,判断一下当前的摆放会不会和前面已经摆放的皇后产生冲突,如果产生冲突,那这后面的情况我们都可以选择不要了,当然我们这里只需要检查左上、上和右上的情况,因为后面的皇后还没有放

我们使用res二维数组来表示最后答案,使用board来表示棋盘

class Solution {
public:
    vector<vector<string>> res;

    vector<vector<string>> solveNQueens(int n)
    {
        //初始化
        vector<string> board(n, string(n, '.'));
        //开始选择
        dfs(board, 0);
        return res;
    }

    void dfs(vector<string>& board, int row)
    {
        //递归出口
        if (row == board.size())
        {
            res.push_back(board);
            return;
        }

        int n = board[row].size();

        for (int col = 0; col < n; col++)
        {
            //判断当前位置能否被其他皇后攻击
            if (!isVal(board, col, row)) continue;

            //选择
            board[row][col] = 'Q';
            dfs(board, row + 1);
            //撤销选择
            board[row][col] = '.';
        }
    }

    //因为新的一行皇后下面没有皇后 所以不用检查下面 同理 左右也不用检查 只用检查左上 上 右上
    bool isVal(vector<string>& board, int col, int row)
    {
        //上
        for (int i = 0; i < row; i++)
        {
            if (board[i][col] == 'Q')
                return false;
        }
        //左上
        for (int i = row, j = col; i >= 0 && j >= 0; --i, --j)
        {
            if (board[i][j] == 'Q')
                return false;
        }
        //右上
        for (int i = row, j = col; i >= 0 && j <= board[row].size(); --i, ++j)
        {
            if (board[i][j] == 'Q')
                return false;
        }
        //没有代表可以放置
        return true;
    }
};

成功A掉(鼓掌)

最后希望您能自己独立把上面的代码都实现一遍,只有真正动手了才能跟家深入的了解这个算法的奇妙

  • 7
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值