(十)算法与数据结构 | 回溯


1. 简介

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

results = []
void backtrack(路径, 选择列表):
	if 满足结束条件:
		results.add(路径)
		return
		
	for 选择 in 选择列表:
		做选择
		backtrack(路径, 选择列表)
		撤销选择

上面伪代码中的路径表示我们已经选择的元素,选择列表表示我们接下来可供选择元素的范围。回溯法的核心就是循环里面的递归,在递归调用之前做选择在递归调用之后撤销选择。下面将介绍几道 L e e t C o d e {\rm LeetCode} LeetCode中使用回溯法解决的经典例题。


2. 回溯法经典例题

2.1 全排列

题目来源 46.全排列

题目描述 给定一个没有重复数字的序列,返回其所有可能的全排列。如给定的输入是[1,2,3],则返回的内容为[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

先来看怎么手动计算得到上述全排列的结果,通常做法是首先固定第一位,然后第二位的选择变为两种,固定第二位后随之第三位也固定,如果数组元素大于三个类似。对照上述回溯算法的伪代码,什么时候满足结束条件?由于给定输入数组的元素个数为三,所以当路径上的元素个数为三时满足结束条件。再来看循环体,这里选择列表即为我们可选择的范围,首先第一次我们有三种可选的元素,第二次有两个可选的元素,以此类推。这里的做选择即为不断填充路径,使其满足结束条件,即得到一个可行的排列。当得到一组可行解后,我们需要寻找下一组解,这时候我们要破坏前一组结果,即撤回先前做的选择,这在递归后执行。最后,由于排列中不包含重复元素,我们额外使用一个数组来标识当前元素是否已经使用过。最后整体程序如下:

vector<int> path;
vector<vector<int>> res;

void backtrack(vector<int>& nums, vector<int>& used) {
	// 结束条件,元素个数等于数组长度
	if (path.size() == nums.size()) {
		// 将数组加入结果
		res.push_back(path);
		return;
	}
	// 否则对原数组循环遍历
	for (int i = 0; i < nums.size(); ++i) {
		// 排除重复的选择
		if (used[i]) {
			continue;
		}
		// 置当前元素已经访问
		used[i] = 1;
		// 做选择
		path.push_back(nums[i]);
		// 开始尝试继续做选择
		backtrack(nums, used);
		// 撤销选择
		path.pop_back();
		// 置当前元素没有访问
		used[i] = 0;
	}
}
// 主体函数求数组的全排列结果
vector<vector<int>> permute(vector<int>& nums) {
	vector<int> u(nums.size(), 0);
	backtrack(nums, u);
	return res;
}

其他题解 官方题解

2.2 括号生成

题目来源 22.括号生成

题目描述 给定输入数字 n n n表示生成的括号对数,设计函数生成所有可能并且有效的括号组合(所谓有效的指生成的括号组合中括号都是成对出现的,且位置相互对应)。如 n = 3 n=3 n=3,返回的结果是["((()))", "(()())","(())()","()(())","()()()"]

这里,如果我们将左右括号看作是上一题中数组的元素,则可以将本题的解题思路与上一题对应。这里,上一题中排除重复选择的操作就变成当前生成的括号组合是否合法。而判断括号是否合法可以使用栈,或者定义一个表示平衡的变量,当遇到左括号则变量值加一;否则减一,当该变量小于零时表示右括号数量多于左括号,非法。最后,判断该变量的值是否等于零。

基于全排列程序的思路,可以轻易得到本题的实现程序:

void generateAllParenthesis(string& current, int n, vector<string>& result) {
	// 结束条件,当生成括号组合的数量等于n(主函数传入参数为2*n)
	if (current.length() == n) {
		// 判断当前组合是否合法
		if (isValid(current))
			result.push_back(current);
		return;
	}
	// 没有达到结束条件,继续添加括号
	else {
		// 做选择
		current += '(';
		// 尝试做下一次选择
		generateAllParenthesis(current, n, result);
		// 撤销选择
		current.pop_back();
		// 做选择
		current += ')';
		// 尝试做下一次选择
		generateAllParenthesis(current, n, result);
		// 撤销选择
		current.pop_back();
	}
}

vector<string> generateParenthesis(int n) {
	vector<string> result;
	string current;
	generateAllParenthesis(current, 2 * n; result);
	return result;
}

用于判断当前括号组合是否合法的判断函数:

bool isValid(string str) {
	// 平衡变量
	int balance = 0;
	for (char s: str) {
		if (s == '(')
			++balance;
		else {
			--balance;
			if (balance < 0) 
				return false;
		}
	}
	return balance == 0;
}

其他题解 官方题解

2.3 N皇后问题

题目来源 51.N皇后

题目描述 N {\rm N} N皇后问题研究的是如何将 N {\rm N} N皇后放置在 N × N {\rm N×N} N×N的棋盘上,并且是皇后彼此之间不能攻击(即皇后间不能同行、同列或位于同一斜线上)。如下图是一个八皇后问题的一种解:
在这里插入图片描述

图1:8皇后的一种解

假设输入是 4 4 4,则返回的结果为[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]],其中点表示空位置, Q {\rm Q} Q表示皇后的位置,四皇后共有两种可能的解。

我们考虑 N {\rm N} N皇后的解题思路其实和括号生成类似,结束条件设定为设置某个变量是否等于棋盘的边长,这个变量表示已经放置的皇后数(即如果已经放置的皇后数等于棋盘长度,则表示得到了一组可行解);另外为了得到可行解我们要保证皇后之间不发生冲突,所以我们定义一个检查函数用于检查当前元素放入后是否会产生冲突,如果会则跳过;否则才进行下一次的放置。

根据上一题的程序,可以轻易得到该题的解答程序:

vector<vector<int>> res;

// backtrack用于放置第r行的皇后,小于r的行已经放置完毕
void backtrack(vector<string>& board, int r) {
	// 结束条件,如果r等于棋盘的行数
	if (r == board.size()) {
		res.push_back(board);
		return;
	}
	// 循环列查看应该在哪一列放置皇后
	int n = board[r].size();
	for (int c = 0; c < n; ++c) {
		// 如果当前选择不合法
		if (!isValid(board, r, c))
			continue;
		// 做选择
		board[r][c] = 'Q';
		// 开始尝试继续做选择
		backtrack(board, r + 1);
		// 撤销选择,即将放置皇后的位置置为空位
		board[r][c] = '.';
	}
}

vector<vector<string>> solveNQueens(int n) {
	// '.'表示空位,'Q'表示皇后,注意棋盘的初始化方式
	vector<string> board(n, string(n, '.'));
	backtrack(board, 0);
	return res;
}

然后来看检查函数,其实现的功能是检查当前情况是否存在冲突:

// 检查是否可以在board[r][c]处放置皇后
bool isValid(vector<string>& board, int r, int c) {
	int n = board.size();
	// 检查列是否存在冲突
	for (int i = 0; i < n; ++i) {
		// 如果当前列某个元素为Q,则产生冲突
		if (board[i][c] == 'Q')
			return false;
	}
	// 检查右上方是否存在冲突
	for (int i = r - 1, j = c + 1; i >= 0 && j < n; --i, ++j) {
		if (board[i][j] == 'Q')
			return false;
	}
	// 检查左上方是否存在冲突
	for (int i = r - 1, j = c - 1; i >= 0 && j >= 0; --i, --j) {
		if (board[i][j] == 'Q') 
			return false;
	}
	// 没有产生冲突
	return true;
}

其他题解 官方题解


3. 总结

由上面三道关于回溯的经典例题可以得到,使用回溯算法的关键有:确定结束条件判断结果的有效性做 / 撤销选择。注意在回溯算法中选择列表的定义方式,上面第二道题的定义方式与其他两道题不一样,当然我们也可以将左括号和右括号定义成一个含两个元素的数组,然后使用循环替换选择语句。


参考

  1. https://leetcode-cn.com/tag/backtracking/.
  2. https://zhuanlan.zhihu.com/p/93530380.


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值