前置知识和介绍
前置知识:
- 基本的递归
- 二叉树
介绍:
递归是一种解决问题的方法,基本思想是将问题分解为更小的子问题,直到问题的规模足够小,能够直接解决。
由于递归的特性,它降低了问题的规模,而且原问题类似。因此,只有处理好递归状态(如何削减规模同时根据实际问题处理),然后写好基线条件(base case)。递归是解决复杂问题的强力工具,它隐藏了许多烧脑的细节,省下我们的脑细胞。
所有递归都是深度优先遍历(DFS),DFS是树、图的遍历方式。初学递归时,不用刻意在意DFS这个术语,只要知道递归是深度优先遍历即可。学过图遍历算法后,回顾递归会有更深的印象。
或许你曾经写过的递归代码是不带路径的递归,也就是不带状态的递归。比如斐波那契数列
。这是典型的不带路径的递归。
递归实际分为带路径的递归和不带路径的递归,带路径的递归被称为回溯算法。
回溯
回溯这个术语并不重要,本质上是深度优先遍历,是递归。
回溯,即带路径递归,通常指的是在递归过程中,记录和追踪当前的状态或路径。每一步递归都会将当前的路径(或状态)传递给下一层递归,直到找到所有可能的解或者满足条件的解。
回溯算法能抽象成决策树模型。
有些问题, 只能对可行解进行枚举, 系统地搜索每一个解的一种方式是采用决策树模型。树中每一个节点都是一个决策状态,实际上每一个内点都是一个决策状态,每一个叶子节点都是一个可行解。
举个例子:
当你做出决策1, 决策2, 决策3, …决策k, 出结果了。最终结果就是一个可行解。这只是一个可行解,而且这个解是由决策1影响出决策2,决策2影响决策3,…决策k。决策树中节点向下影响了,父节点向孩子传递了信息,进而孩子的决策方案就发生变化了。
-
此时孩子的决策可能方案有3种(假设这个孩子不是可行解):
- 孩子不能向下决策了, 没有可行解了。
- 孩子有一个方案, 同样将信息传递给它的决策节点孩子。
- 孩子有多个方案, 枚举所有方案导出可能性。
-
第三种情况,孩子有多个方案,每种决策是独立的。它不能选择第一种决策方案,而选择该方案的结果反过来影响了它自己(即孩子反过来影响父亲,这种一般不允许,因为节点的各个分支一般是相互独立的)。
示例 1: 子集问题
这里举个leetcode的题目78. 子集, 避免太空洞抽象了。
题目给定条件是不重复的整数数组 nums(符合数学中的集合), 找出其所有的子集。
如果一个集合中有n个元素, 那么子集的个数为
2
n
2^n
2n个。
自然智慧, 我们从数组0下标处出发, 只是询问当前的元素在不在子集中。 如果是, 则选择当前元素作为子集的元素。否则跳过这个元素。
下面的代码诠释:
- 回溯是递归, 处理好base case和递归过程是关键。
- 本题需要收集决策的可行解,因此我们叶子节点处将结果放进ans中, 在下面主函数中创建
List<List<Integer>> ans = new ArrayList<>();
, 并将其作为参数传入process函数中, 遇到决策树的叶子节点, 就将结果写入(叶子节点回馈信息)。 - 决策方案, 本题的决策方案是2种, 一种是选择当前元素作为子集的元素, 另一种跳过这个元素。因此对多种方案的讨论就不是很明显了。----
不涉及递归剪枝
. - 独立性处理, 当选择方案一可能会对路径数组或者列表进行处理,需要复原;保持各个方案相互独立。
- 父节点向下决策传递信息一般传递列表数组切片…
class Solution {
public List<List<Integer>> subsets(int[] nums) {
//收集决策的可行解
List<List<Integer>> ans = new ArrayList<>();
process(nums,0,new ArrayList<Integer>(),ans);
return ans;
}
public static void process(int[] nums,int i,List<Integer> path, List<List<Integer>> ans){
//base case
//决策结束条件很明显, 恰好越界时终止
if(i == nums.length){
//决策树的叶子节点, 要收集这条决策路径的最终结果
//这里记得深拷贝列表, 而不是直接ans.add(path)
List<Integer> res = new ArrayList<Integer>();
for(int elem: path){
res.add(elem);
}
ans.add(res);
return ;
}
//递归状态
//决策方案可能1
//选择当前i位置的数作为子集的元素
path.add(nums[i]);
process(nums,i+1,path,ans);
//决策方案可能2: 决策树分支独立性处理
//由于path前面增加nums[i], 保持两种方案独立
path.removeLast();//移除path最后一个元素nums[i];
process(nums,i+1,path,ans);
}
}
示例 2: N皇后问题
问题描述:
N 皇后问题中,我们需要放置 N 个皇后到 N × N 的棋盘上,要求任何两个皇后不在同一行、列或对角线上。
题目要求皇后不能在同一行, 同一列, 对角线。 因为这样皇后之间不会攻击对方
一种直观的想法, 我们先按每一行考虑, 对每行的所有列可能进行枚举决策。
下图
中, 如果只有一个皇后, 那么就刚好放在1*1的棋盘中的位置。
如果有两个皇后, 无论放在第一行第一列或者第二列, 第二行永远被堵死了放不了第二个皇后。可能结果为0.
如果有3个皇后,如果放在第一行哪一列,后续第二行只能放一个位置, 后面的第三行没位置了。
ok, 考虑 4 × 4 4\times4 4×4的棋盘,4个皇后, 如上方示例图。
我们总是先考虑第一行穷举所有列,向下次决策传递信息,下次决策时皇后不能放到前面行中放过皇后的列和及其对角线的位置。
剪枝:当所在行没有位置时不在向下递归而是向上返回给父节点, 让父节点选择其它的方案可能。 剪枝就是确定决策到不了叶子节点的可行解, 直接返回; 这一步骤减少不必要的递归减少了计算量优化了常数时间。
代码部分:
以下是N皇后的额外细节处理。
- 这里以数组形式传递信息。数组下标代表当前行, 数组值代表放置皇后的列。
- 递归base case是
cur==n
, 说明找到了一条可行解。这是比较明显的, 我们只需要将决策数组的信息按题目要求的字符串形式, 放进ans中。 - 方案数, 当前行有n种枚举可能, 但通过递归剪枝。违背规则的决策方案(
check函数检查是否违背规则
), 直接返回给父节点。 check
函数; 遍历前面的path数组, 当前选择的列不能和前面的行皇后列冲突。 对角线冲突如何判断呢?算斜率, 如果斜率为+=1, 那么可能是正对角线或者反对角线冲突。 总之这种决策被剪掉了,应该考虑选择其它方案或者向上返回给父节点。- Java中
StringBuilder
类, 可变字符串类型,用于拼接字符串。builder.setLength(0);
,一种高效清空StringBuilder对象中的内容。
直观解法:
我们先按每一行考虑,对于每行的所有列进行枚举决策。
回溯代码:
class Solution {
public List<List<String>> solveNQueens(int n) {
List<List<String>> ans = new ArrayList<>();
process(0, new int[n], n, ans);
return ans;
}
public static void process(int cur, int[] path, int n, List<List<String>> ans) {
if (cur == n) {
List<String> result = pathToString(path, n);
ans.add(result);
} else {
for (int i = 0; i < n; i++) {
if (check(i, cur, path)) {
path[cur] = i;
process(cur + 1, path, n, ans);
}
}
}
}
public static boolean check(int target, int cur, int[] path) {
for (int i = 0; i < cur; i++) {
if (path[i] == target) {
return false;
}
else if (Math.abs(cur - i) == Math.abs(path[i] - target)) {
return false;
}
}
return true;
}
public static List<String> pathToString(int[] path, int n) {
StringBuilder builder = new StringBuilder();
List<String> ans = new ArrayList<>();
for (int j = 0; j < n; j++) {
for (int i = 0; i < n; i++) {
if (i == path[j]) {
builder.append("Q");
} else {
builder.append(".");
}
}
ans.add(builder.toString());
builder.setLength(0); // 清空StringBuilder
}
return ans;
}
}
示例 3: N皇后II
N皇后II是N皇后I的简化版,少了收集结果的过程,而是直接返回结果。根据上面的N皇后问题的代码,你可以试着编写N皇后II的代码。
这里提供一个打表方法(手动doge):
class Solution {
public int totalNQueens(int n) {
switch (n) {
case 1: return 1;
case 2: return 0;
case 3: return 0;
case 4: return 2;
case 5: return 10;
case 6: return 4;
case 7: return 40;
case 8: return 92;
case 9: return 352;
}
return -1;
}
}
总结和参考
总结, 嗯, 好好看前面的…
决策树(决策方案, 分支独立性,复原操作,收集可行解,枚举…), 剪枝, 递归(回溯法)。
向下传递的影响, 向上传递的结果…
好好休息, 明天见!
最后附上
参考书籍:
- 《程序员代码面试指南》—
左程云
- 《离散数学及其应用》