🍕 暴力递归
暴力递归就是尝试
- 把问题转化为规模缩小了的同类问题的子问题
- 有明确的不需要继续进行递归的条件(base case)
- 有当得到了子问题的结果之后的决策过程
- 不要求记录每一个子问题的解
【注意⚠️】:
- 初学暴力递归时, 一定不要步子迈得太大。 初学时, 能理解好局部就可以, 即思考一个子问题怎么做就行, 至于全局的内容, 不要去纠结, 不然容易陷入自己的思维陷阱中。如果一开始就想着全局是如何的, 那么很容易走火入魔, 越想越乱。
- 暴力递归就是动态规划的基础。 暴力递归是人的智慧, 动态规划是机器的"智慧"——运行的更快
- 在尝试的过程中, 优先选择可变参数形式最简单, 可变参数更少的试法。
- 可变参数, 即该尝试的过程中参数值会变的
- 可变参数形式简单, 就是这个参数使用一个值就可以表达, 别弄出什么链表之类的来表示可变参数。
- 可变参数形式越简单, 数量越少, 才更容易改成动态规划。
案例1 汉诺塔问题
三层塔时, 最优解步骤如下:
═╬═
════╬════
═══════╬═══════
──────────┴───────── ──────────────────── ────────────────────
════╬════
═══════╬═══════ ═╬═
──────────┴───────── ──────────────────── ──────────┴─────────
═══════╬═══════ ════╬════ ═╬═
──────────┴───────── ──────────┴───────── ──────────┴─────────
═╬═
═══════╬═══════ ════╬════
──────────┴───────── ──────────┴───────── ────────────────────
═╬═
════╬════ ═══════╬═══════
──────────────────── ──────────┴───────── ──────────┴─────────
═╬═ ════╬════ ═══════╬═══════
──────────┴───────── ──────────┴───────── ──────────┴─────────
════╬════
═╬═ ═══════╬═══════
──────────┴───────── ──────────────────── ──────────┴─────────
═╬═
════╬════
═══════╬═══════
──────────────────── ──────────────────── ──────────┴─────────
抽象一下, 从上到下, 圆盘依次是 1, 2, 3, ... i, 我们的目标就是将 1 ~ i 圆盘, 从 from 杆移动到 to 杆上去, 另外一个杆叫做 other。
- 主函数接收四个输入 (i, from, to, other)
- 第一步: 把上面的圆盘 1 ~ (i-1) 从 from 移到 other 上
- 第二步: 把最底下圆盘 i从 from 移到 to 上
- 第三步: 把上面的圆盘 1 ~ (i-1) 从 other 移到 to 上。
算法如下:
void hannoi(int n) {
if (n > 0) {
func(n, '左', '右', '中');
}
}
void func(int i, String from, String to, String other) {
if (i == 1) { // base case
print(f'将 {i} 从 {from} 移动到 {to} 上');
} else {
// 大象装冰箱分三步
func(i-1, from, other, to); // 第一步, 将上面的圆盘全部从 from 移到 other 上
print(f'将 {i} 从 {from} 移动到 {to} 上'); // 第二步
func(i-1, other, end, start); // 第三步, 将上面的圆盘从 other 移到 to 上。
}
}
案例2 打印字符串的全部子序列
对于字符串上的每个位置上的字符, 它们都只有两种情况, 输出 or 不输出。
比如字符串 abc
〇
a 〇 要 a 和不要 a
ab a〇 〇b 〇〇 要 b 和不要 b
abc ab〇 a〇c a〇〇 〇bc 〇b〇 〇〇c 〇〇〇 要 c 和不要 c
abc ab ac a bc b c ''
void process1(char[] str, int i, List<Character> res) {
if (i == str.length) {
printStr(res); // 每次选择的内容, 都会保存在 res 列表中, 最后输出就行。
return;
}
List<Character> resKeep = copyList(res);
resKeep.add(str[i]); // 当前是在对 i 位置的字符, 判断是要还是不要
process1(str, i+1, resKeep);
List<Character> resNoInclude = copyList(res);
process1(str, i+1, resNoInclude);
}
// 省点空间的做法, 实现对 str 的复用
void process2(char[] str, int i) {
if (i == str.length) {
print(str);
return;
}
process2(str, i+1); // 要当前字符串的路
char tmp = str[i];
str[i] = 0;
process2(str, i+1); // 不要当前字符的路
str[i] = tmp; // 改完后又改回去
}
案例3 打印字符串的全部排列
打印字符串的全部排列, 要求不重复, 可以有两种方法,
- 一种是找出来后再洗数据, 但不推荐。
- 另一种方法是 "分支限界", 即在遍历的过程中, 不可能的分支直接杀死, 不去走。 这种方法只优化了常数时间, 不会改变时间复杂度。
全排列, 比如 abc, 它的全排列为 abc, acd, bac, bca, cab, cba
ArrayList<String> Permutation(String str) {
ArrayList<String> res = new ArrayList<>();
if (str == null || str.length() == 0) {
return res;
}
char[] chs = str.toCharArray();
process(chs, 0, res);
res.sort(null);
return res; // 全部排列结果都在 res 里面
}
// str[i..] 范围上, 所有的字符, 都可以在 i 位置上, 后续都去尝试
// str[0..i-1]范围上, 是之前做的选择
void process(char[] str, int i, ArrayList<String> res) {
if (i == str.length) {
res.add(String.valueOf(str)); // 所有的字符串形成的全排列, 加入到res里去
}
boolean[] visit = new boolean[26]; // 但字符串全部都是小写字符是, 可以利用这张表实现不重复
for (int j = i; j < str.length; j++) { // 因为当前位置的值, 已经被前面的每个位置的值都交换过了, 所以不需要再和前面的交换
// 这种在遍历过程中, 直接杀死不可能分支, 叫做 "分支限界"
if (!visit[str[j] - 'a']) { // 在尝试前, 要确定该字符还没有尝试过。
visit[str[j] - 'a'] = true;
swap(str, i, j); // i 位置上的字符, 可以出现在 i ~ len 的所有位置上 比如 abc, 当前在 0 位置上
process(str, i+1, res); // 获取 i 在其他位置上的所有情况 这里将会依次尝试 axx, xax, xxa, 即 a 分别在 0,1,2 位置上
swap(str, i, j); // 使用完后要将 i 换回去 尝试完后, 要把 xxa 换回到 axx
}
}
}
案例4 先手后手
给定一个整型数组arr, 代表数值不同的纸牌排成一条线。 玩家 A 和玩家 B 依次拿走每张纸牌, 规定玩家 A 先拿, 玩家 B 后拿, 但是每个玩家每次只能拿走最左或最右的纸牌, 玩家A和玩家B都绝顶聪明。请返回最后获胜者的分数。
举例:
- 假设 arr=[1,2,100,4]。 开始时, 玩家 A 作为绝顶聪明的人不会先拿 4, 因为拿 4 之后, 玩家 B 将拿走 100。所以玩家 A 会先拿 1 让排列变为 [2,100,4],
- 假设 arr=[1,100.2]。开始时, 玩家 A 不管拿 1 还是 2, 玩家 B 作为绝顶聪明的人, 都会把 100 拿走。 玩家 B 会获胜分数为 100。所以返回 100。
int win1(int[] arr) {
if (arr == null || arr.length == ) {
return 0;
}
// 查看先手和后手谁会赢, f 是先手, s 是后手
return Math.max(f(arr, 0, arr.length-1), s(arr, 0, arr.length-1))
}
// 先手时, 在 i到j 范围内能拿到的最大值
int f(int[] arr, int i, int j) {
if (i == j) {
return arr[j];
}
// 当前回合是先手, 下一回合就肯定是后手, 先手时有两种情况: 取出最左的, 或者取出最右的
return Math.max( arr[i]+s(arr,i+1, j), arr[j]+s(arr,i,j-1) )
}
// 后手时, 能拿到的情况
int s(int[] arr, int i, int j) {
if (i == j) {
return 0;
}
/* 如果先手选择的是左侧, 那么后手就只能拿到 [i+1, j] 范围上的最大值
如果先手选择的是右侧, 那么后手就只能拿到 [i, j-1] 范围上的最大值
也就是说, 后手能拿到什么值是由先手决定的。
先手作为绝对聪明的人, 肯定会让我在两个最大值之间, 选择一个最小的, 这样先手才能赢。
所以这里返回的是 Math.min(), 拿到最小值不是后手决定的, 而是先手决定的, 所以是返回最小的
后手拿到的 "最大值" 体现在后手下一轮变成 "先手" 的情况。
*/
return Math.min( f(arr, i+1, j), f(arr, i, j-1) )
}
案例5 逆转栈
给你一个栈,请你逆序这个栈,不能申请额外的数据结构/空间,只能使用递归函数如何实现?
【注意】: 只是说明自己不能申请额外的数据结构/空间, 但并不代表整个程序的使用过程中不会新创建一个数据结构/空间。 所以可以利用递归来实现。
void reverse(Stack<Integer> stack) {
if (stack.isEmpty()) {
return;
}
// 逆转栈分三步
int i = f(stack); // 第一步, 先弹出栈底元素
reverse(stack); // 第二步, 将栈的剩余元素反转
stack.push(i); // 第三步, 将栈底元素压入栈顶
}
int f(Stack<Integer> stack) {
int result = stack.pop(); // 弹出栈顶元素
if (stack.isEmpty()) {
return result; // 如果为空, 说明该元素是栈顶元素, 是我们想要的, 所以直接返回
} else {
// 不为空, 说明栈中还有一个元素。 利用递归来保存当前弹出的元素, 即创建递归, 让递归函数去帮你找栈底元素, 同时自己也能保留 result 变量
int last = f(stack); // 调用递归, 让他找到栈底元素返回返回
stack.push(result); // 找到栈低元素了, 然后将原来的弹出的元素再弹回去, 因为 f 函数需要不能改变栈的情况下返回栈底元素
return last; // 返回栈底元素; 人外有人, 天外有天, 你调用 f 递归函数, 让别人帮你找栈底元素, 但你自己又何尝不是别人的递归函数呢?
}
}
案例6 数字转26进制所有结果
规定1和A对应、2和B对应、3和C对应. 那么一个数字字符串比如”111",就可以转化为”AAA”、“KA"和”AK”给定一个只有数字字符组成的字符串str,返回有多少种转化结果。
// 当前面的 [..., i] 位置是固定的时候, 更改 [i, ..] 位置上的转换, 有多少种转换结果
int process(char[] str, int i) {
if (i == str.length) {
return 1; // i 超出数组范围了, 则说明前面固定的转换结果是有效的, 这算作一种转换结果
}
if (str[i] == '0') {
return 0; // 如果前面转换结果. 会导致有 0 开头的数字, 则说明这种转换不是合法的转换, 所以直接是 0 中转换结果
// 注意, 因为我们这个函数, 只能查看 [i, ..] 后面有多种中结果, 所以 0 开头的数字, 肯定是错误的转换结果
}
if (str[i] == '1') {
// 以 1 开头的数字, 可以有两种转换方式
int res = process(str, i+1); // 第一种, 直接把 1 当成 a, 查看这种转换, 能有多少种转换结果
if (i+1 < str.length) {
res += process(str, i+2); // 第二种, 直接把 1x 当成某个字母, 查看这种转换能有多少种转换结果
}
return res;
}
if (str[i] == '2') {
// 和 1 同理, 只不过 2 允许的后一个数字只能是 [0, 6]
int res = process(str, i+1);
if (i+1 < str.length && ('0' <= str[i+1] && str[i+1] <= '6' )) {
res += process2(str, i+1);
}
return res;
}
// 如果当前位置是 [3-9], 则只有一种转换结果, 然后再查看固定了当前位置的转换结果后, 右侧剩下的位置有多少种结果, 故调用 process。
return process(str, i+1);
}
案例7 暴力背包
给定两个长度都为 N 的数组 weights 和 values, weights[i] 和 values[i] 分别代表 i 号物品的重量和价值。 给定一个正数 bag, 表示一只袋子允许的最大重量, 你装的物品不能超过这个重量。 返回你能装下最多的价值是多少?
// 这个函数就是返回的是, 给我第 i 个物品时, 这个物品能产生的最大价值
int process1(int[] weights, int[] values,
int i, int alreadyWeight, int bag
// 之前做的决定所造成的重量就是 alreadyWeight
// 这个决定可能是 装入物品 i, 那么 alreadyWeight 就 包含 i 物品重量
// 也可能是 不装入物品 i, 那么 alreadyWeight 就不包含 i 物品重量
) {
if (alreadyWeight > bag) { // 如果之前的决定导致重量超重了, 那么不用判断是否装入 i 物品了, 直接返回价值 0
return 0;
}
if (i == weights.length) { // 如果要装的物品是 空, 那么也不会获得价值
return 0;
}
return Math.max(
// 不装入 i+1 物品时能获得的最大价值: 交给 看看是否装入 i+1 号获去决定
process1(weights, values, i+1, alreadyWeight, bag),
// 装入 i 物品时能获得的最大价值: 则在 i 物品价值的基础上, 再加上添加了 i+1 号物品的最大价值
values[i] + process1(weights, values, i+1,
alreadyWeight+weights[i], bag) // 加上了 i 号货, 所以要加重量
)
}
// 获取换种写法, process2 是给我 i 号物品, 能获得的最大价值。 上一种写法更好, 因为上一种写法可变参数少(没有 alreadyValue)
int process2(int[] weights, int[] values,
int i, int alreadyWeight, int alreadyValue, int bag
) {
if (alreadyWeight > bag) { // 一旦超重, 前面的安排就是没有意义的, 所以总价值是 0
return 0;
}
if (i == weights.length) {
return alreadyValue; // 返回的是最大价值, 而不是当前 i 物品能生成的最大价值, 所以直接返回当前最大价值
}
Math.max(
// 不添加 i 物品时的能获取的最大价值
process2(weights, values, i+1, alreadyWeight, alreadyValue, bag)
// 添加 i 物品时的能获取的最大价值
process2(weights, values, i+1, alreadyWeight + weights[i], alreadyValue + values[i], bag)
)
}
int maxValue2(int[] c, int[] p, int bag) {
int[][] dp = new int[c.length + 1][bag + 1];
for (int i = c.length - 1; i >= 0; i--) {
for (int j = bag; j >= 0; i--) {
dp[i][j] = dp[i+1][j];
if (j + c[i] <= bag) {
dp[i][j] = Math.max(dp[i][j], p[i]+dp[i+1][j+c[i]]);
}
}
}
}
案例8 N 皇后问题
N 皇后问题是指在 N*N 的棋盘上要摆 N 个皇后, 要求任何两个皇后不同行、不同列也不在同一条斜线上。 给定一个整数n, 返回n皇后的摆法有多少种。
- n=1, 返回1。 只有一个格子一个皇后。
- n=2或3, 返回0。 因为 2x2 格子中两个皇后不管怎么放都会同行或同列或同斜线。 3x3 格子也一样。
- n=8, 返回 92。
N 皇后的时间复杂度是 O(N^2), 时间复杂度是没法优化的, 但是常数时间复杂度可以优化。 思路就是利用位运算的特性, 来检查放置的皇后是否合法, 这比起使用数组一列一列的查, 快得多。
int num1(int n) {
if (n < 1) {
return 0;
}
// 下标表示行号, 同时也是第一个皇后, 也就是说, 第 i 个皇后一定放在第 i 行
// 元素值表示列号。 比如 record[0] = 0 表示第 0 个皇后放在第0行第0列。
int[] record = new int[n];
return process1(0, record, n); // 从第 0 行开始
}
// i 表示当前来到第几行
// record[..., i-1] 表示之前的行, 皇后的位置列号, 并且都是合法的, 即这些皇后都是不同行同列同斜线
// n, 即 n 皇后, 不过因为我们是从 0 计数的, 所以准确的将只有 n-1 个皇后。 n 的含义是终止行, 即到达这一行就不能继续了。 因为皇后已经摆放完了
int process1(int i, int[] record, int n) {
if (i == n) { // 如果能来到终止行, 说明前 n-1 行的皇后都有了合法的位置, 说明决策过程结束, 这是一种合法摆放的方式
return 1; // 返回 1, 表示找到一种合法的方式
}
int res = 0;
for (int j = 0; j < n; j++) {
// 判断当前 i 行的皇后, 放在 j 列, 会不会和之前 (..i-1) 的皇后, 共行共列或者共斜线,
if (isValid(record, i, j)) {
record[i] = j; // 可以放, 则放
res += process1(i + 1, record, n); // 同时继续查看这种方式下还有多少种摆放方式
// 注意这里不要 break, 因为还有其他的列没尝试
}
}
return res;
}
// 判断 如果第i行j列放了皇后, 是否合法
boolean isValid(int[] record, int i, int j) {
for (int k = 0; k < i; k++) { // 只需要看 [0..i-1] 行的皇后是否满足
// 只需判断是否同列, 或者同斜线。 因为不同皇后一定放在不同行, 所以不需要判断是否同行。
if (j == record[k] || Math.abs(record[k] - j) == Math.abs(i - k)) {// 行号的差值和列号的差值相同, 说明同斜线
return false;
}
}
return true;
}
int num2(int n) {
if (n < 1 || n > 32) { // 因为 int 是 4 字节 32 位的, 所以超出 32 的不处理
return 0;
}
// 这个 limit 是用来限制那些位上可以用来尝试放皇后的, 比如 8 皇后, 则从右到左连续 8 位都是 1
int limit = n == 32? -1 : (1 << n) - 1;
return process2(limit, 0, 0, 0); // 初始值没有任何限制, 所以后三个参数都是 0
}
int process2(int limit,
// 这三个参数, 只使用它们的位信息, 具体的十进制值是没有意义的。 位上为 1 表示被限制, 即这个位置上不能放皇后, 不然会和其他行的皇后同行同列同斜线
int colLim, // 表示列限制
int leftDiaLim, // 表示左斜线的限制
int rightDiaLim // 表示右斜线的限制
) {
if (colLim == limit) { // 达到 limit 时, 说明所有列上都放了皇后, 表示找到一种合法的放置方式
return 1;
}
int mostRightOne = 0;
// colLim | leftDiaLim | rightDiaLim 得到的是总的限制, 即这些位上放皇后会导致同列或同斜线
// colLim | leftDiaLim | rightDiaLim 位上的信息是, 1 表示被限制, 0 表示不被限制
// limit & (~ (总线制)), 这个过程是在将有效位上的信息, 0 变成 1, 1 变成 0。
// 那不应该是直接 ~ 就可以了嘛, 还要再 & 上 limit 是因为我们不能改变左侧的无效位的信息
int pos = limit & (~ (colLim | leftDiaLim | rightDiaLim) );
// 所以 pos 上, 1 表示可以放皇后, 0 表示不可以放, 含义颠倒过来了
int res = 0;
while (pos != 0) {
// 提取出最右侧第一个为 1 的值, 即从右侧查看第一个能放置皇后的位置是哪个位置
mostRightOne = pos & (~pos + 1);
// 每个位置尝试后, 就要把这个位置上的 1 变成 0, 所以直接减去。 当全部尝试完的时候 pos 就会是 0, 表示所以可尝试的位置都尝试了
pos = pos - mostRightOne;
// 递归
res += process2(limit, // limit 是不变的
colLim | mostRightOne, // 每次尝试的是 mostRightOne 位置上放皇后, 所以下一个皇后的列限制, 就会多上 mostRightOne 这个位置
(leftDiaLim | mostRightOne) << 1, // 更新左斜线的限制
(rightDiaLim | mostRightOne) >>> 1 // 更新右斜线的限制, 注意是无符号, 因为是 32 位都有在用。
)
}
}