46. 全排列
我是从第39题——组合总和,然后看解析找到了这一题,重头学习了 “回溯搜索” 算法。最开始推导出了树形结构,也知道要递归,但不知道该怎么递归,后面才了解到要用这个回溯算法,感觉第一次接触???或者是忘了,所以从头学了下。
总的思路: 基于递归的回溯搜索
1. 回溯搜索
做到第39题,了解到了这个词,感觉很陌生,属实不应该。故,从头学习了一下这个算法。总的来说,这个算法就是枚举所有可能的组合,然后找到满足要求的结果的集合。一开始我想到了动态规划。两者的区别是,动态规划要求得到一个最优的解,比如背包问题和找零问题;而回溯搜索呢,找到的一个集合,里面的每个元素都是一个满足条件的解。
之所以叫回溯搜索,我觉得名字很形象,可以拆成两个部分来解释。首先是“搜索”,注定了这是一个搜索算法,用来查找某些东西;跟搜索相关的算法,一般都离不开递归,而递归关键的就是终止条件,对于回溯搜索来说也不例外。其次是“回溯”,这个回用得很形象,可以解释为回头,指的是用过一个元素后,要取消,给别的元素一个机会。结合一些树形图例(leecode解析里面很多),以及一个B站大佬的视频讲解,可以得到回溯搜索算法的一个模板。
void backtrack(参数) { // 具体情况具体分析,需要的参数可能不同
if (终止条件) { // 这个也很关键,决定递归什么时候停止!一般根据题目要求来
存放结果; // 将满足条件的一个解,放入最后的集合中
return;
}
for (选择:本层集合中元素) { // 循环选取给定集合中的元素
处理节点;
backtrack(路径,选择列表); // 递归处理,往更深的地方搜索
回溯 // 这一步即回头
}
}
这个问题可以看作有 n 个排列成一行的空格,我们需要从左往右依此填入题目给定的 n 个数,每个数只能使用一次。那么很直接的可以想到一种穷举的算法,即从左往右每一个位置都依此尝试填入一个数,直到填完这 n个空格。
(1) 如果 first==n,说明我们已经填完了 n 个位置(注意下标从 0 开始,这就是终止条件),找到了一个可行的解,我们将output 放入答案数组中,递归结束。
(2) 如果 first<n,我们要考虑这第first 个位置我们要填哪个数。根据题目要求我们不能填已经填过的数,因此很容易想到的一个处理手段是我们定义一个标记数组来标记已经填过的数,那么在填第first 个数的时候我们遍历题目给定的 n 个数,如果这个数没有被标记过,我们就尝试填入,并将其标记,继续尝试填下一个位置,即调用函数 backtrack(first + 1, output)。搜索回溯的时候要撤销这一个位置填的数以及标记,并继续尝试其他没被标记过的数。
2. 基于标记数组的回溯搜索
这种做法是,用一个数组来标记哪些数字已经被使用了。如果遇到被标记的数字,那么直接跳过(continue),进入下次循环。具体的算法如下:
vector<vector<int>> res;
vector<int> path;
void backtrack(vector<int>& nums, vector<bool>& used) {
//当path的长度和输入数组的长度一样的时候,说明做完了一次搜索,加入结果中
if (path.size() == nums.size()) { //终止条件
res.push_back(path);
return;
}
// 循环处理,递归,回溯
for (int i = 0; i < nums.size(); i++) {
if (used[i] == true) { //如果某个数字已经被用过了
continue; //选下一个数字
}
//如果没被用过,那么就用
path.push_back(nums[i]); // 选取当前没用过的
// ** 注意这里是=,不是==,之前就是这里错了一直有问题。
used[i] = true; //这一步很关键,因为当前选择了,那么它必须标为已经用过。必须在递归步骤之前,否则无效
// 重头戏,递归
backtrack(nums, used);
// 下面的步骤回溯
path.pop_back();
used[i] = false;
}
}
3. 基于交换的回溯搜索
这种做法是将题目给定的 n 个数的数组 nums[]划分成左右两个部分,左边的表示已经填过的数,右边表示待填的数,在递归搜索的时候通过将已经用过的(左边的)和没有用过的(右边的)进行交换,即可。
具体来说,假设已经填到第 first个位置,那么 nums[]数组中[0, first−1] 是已填过的数,[first, n−1] 是待填的数。我们要用代填的 [first, n−1] 里的数去填第first 个数,假设待填的数的下标为 i ,那么填完以后我们将第 i 个数和第first 个数交换,即能使得在填第first+1个数的时候nums[] 数组的[0, first] 部分为已填过的数,[first+1, n−1] 为待填的数,回溯的时候交换回来即能完成撤销操作。
例子: 假设我们有 [2, 5, 8, 9, 10] 这 5 个数要填入,已经填到第 3 个位置,已经填了 [8,9] 两个数,那么这个数组目前为 [8, 9 | 2, 5, 10] 这样的状态,分隔符区分了左右两个部分。假设这个位置我们要填 10 这个数,为了维护数组,我们将 2 和 10 交换,即能使得数组继续保持分隔符左边的数已经填过,右边的待填 [8, 9, 10 | 2, 5] 。
但是,这样生成的全排列并不是按字典序存储在答案数组中的,如果题目要求按字典序输出,那么请还是用标记数组或者其他方法。
8 | 9 | 2 | 5 | 10 |
8 | 9 | 10 | 2 | 5 |
具体的代码如下:
void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len) {
// 所有数都填完了
if (first == len) {
res.emplace_back(output);
return;
}
for (int i = first; i < len; i++) {
// 动态维护数组
swap(output[i], output[first]);
// 继续递归填下一个数
backtrack(res, output, first + 1, len);
// 撤销操作
swap(output[i], output[first]);
}
}
(在此,我还学了下emplace_back()和push_back()函数的区别:emplace_back()函数是c++11开始加入的新函数。两者都用来往容器后面插入一个元素;但不同的是:push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程。
也有知乎的不同的观点:https://www.zhihu.com/question/387522517/answer/1152172397)
总的来说,就是回溯搜索算法的使用,在这里要避免重复的使用;使用回溯搜索算法时要主要,终止条件,递归参数,回溯步骤。