暴力递归
暴力递归就是尝试:
- 把问题转化为规模缩小了的同类问题的子问题(递归思路)
- 有明确的不需要继续进行递归的条件(base case——基线条件)
- 有当得到了子问题的结果之后的决策过程
- 不记录每一个子问题的解
尝试的方法和起点是关键。
经典例题
汉诺塔问题
题目说明
有三根竖直的滑杆(左、中、右),有若干个大小不一的中空圆盘,初始时所有圆盘按上小下大的顺序放在左滑杆上。现在要把所有圆盘移动到右滑杆上,且最终保持上小下大的顺序,请输出操作顺序。
递归思路
假设某个中间状态有n个圆盘需要移动,初始滑杆为 from ,目标滑杆为 to ,剩下的那个滑杆为 other ,那么我们需要做的就是:把 1 ~ n-1 的圆盘从 from 移到 other (递归),然后把第n个圆盘从 from 移到 to ,最后把 1 ~ n-1 的圆盘从other 移到 to (递归)最终即可把所有圆盘按要求摆放好。
代码实现
public static void hanota(int n, String from, String other, String to) {
if (n == 1) {
System.out.println("Move plane " + n + " from " + from + " to " + to);
return;
}
hanota(n-1, from, to, other);
System.out.println("Move plane " + n + " from " + from + " to " + to);
hanota(n-1, other, from, to);
}
打印字符串的子序列
题目说明
打印一个字符串的全部子序列,包括空字符串。(子序列无需在父串中连续)
递归思路
对字符串中的每一个字符,都有保留和不保留两种选择,实际上变成了一颗递归二叉树。
代码实现
public static void printAllSubsquence(String str) {
char[] chs = str.toCharArray();
process(chs, 0);
}
public static void process(char[] chs, int i) {
if (i == chs.length) {
// 选完一个子串,打印
System.out.println(String.valueOf(chs));
return;
}
// 保留当前字符,到下一个字符
process(chs, i + 1);
char tmp = chs[i];
// ascii码0代表空字符
chs[i] = 0;
// 不保留当前字符,到下一个字符
process(chs, i + 1);
// 还原
chs[i] = tmp;
}
打印字符串的全部排列
题目说明
打印一个字符串中字符的全部排列,且不出现重复的排列。(字符串中均为小写字母)
递归思路
对于字符串中的每一个位置,都尝试下还未尝试过的字符(分支限界,不合要求的分支直接剪掉),最终得到的结果就是去重的所有排列。
代码实现
public static List<String> printAllStr(String str) {
ArrayList<String> res = new ArrayList<>();
if (str == null || str.length() == 0) {
return res;
}
char[] chs = str.toCharArray();
process(chs, 0, res);
return res
}
public static void process(char[] chs, int i, ArrayList<String> res) {
if (i == chs.length) {
// 得到一种排列
res.add(String.valueOf(chs));
return;
}
// 去重,字符串仅小写字母时可以用该方法
boolean[] vis = new boolean[26];
for (int j = i; j < chs.length; ++j) {
if (!vis[str[j] - 'a']) {
// 把当前字符转为下标,只有当前字符未试过时才进行下一步
// 例如 ...c... 已经试过了,那就不再尝试
vis[str[j] - 'a'] = true;
swap(chs, i, j);
// i 位置尝试之后的所有字符
process(chs, i + 1, res);
swap(chs, i, j);
}
}
}
public static void swap(char[] chs, int i, int j) {
char tmp = chs[i];
chs[i] = chs[j];
chs[j] = tmp;
}
卡牌游戏
题目说明
给定一个整型数组arr,代表数值不同的纸牌排成一条线。玩家A和玩家B依次拿走每张指派,规定玩家A先拿,玩家B后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家A和玩家B都绝顶聪明。请返回最后获胜者的分数。
【举例】
arr=[1,2,100,4]
开始时,玩家A只能拿走1或4。如果开始时玩家A拿走1,则排列变为[2,100,4]
,接下来玩家B可以拿走2或4,然后继续轮到玩家A…
显然,玩家A会拿1,接下来不管B拿2还是4,A都可以把100拿到,所以A获胜,总分是101。
递归思路
把拿第一张和最后一张的情况分别递归获得分数,取最大值。
代码实现
public static int win(int[] arr) {
if (arr == null || arr.length == 0) {
return 0;
}
// 先手和后手中较大值(胜者)
return Math.max(f(arr, 0, arr.length - 1), s(arr, 0, arr.length - 1));
}
// 先手函数
public static int f(int[] arr, int i, int j) {
if (i == j) {
// 只剩一张牌可抽,直接返回
return arr[i];
}
// 自己抽第一张牌的结果和抽最后一张牌的结果取最大值
return Math.max(arr[i] + s(arr, i + 1, j), arr[j] + s(arr, i, j - 1));
}
// 后手函数
public static int s(int[] arr, int i, int j) {
if (i == j) {
// 只剩一张牌可抽(被对方抽走),直接返回0
return 0;
}
// 对方抽第一张牌的结果和抽最后一张牌的结果取最小值(对方一定选对我不利的结果)
return Math.min(f(arr, i + 1, j), f(arr, i, j - 1));
}
逆序栈
题目说明
给你一个栈,请你逆序这个栈,不能申请额外的数据结构,只能使用递归函数。
递归思路
每次取栈底元素,用递归内的空间存储,取完后依次下压,即可逆序。关键是取栈底函数的实现。
代码实现
// 用递归逆序栈
public static void reverse(Stack<Integer> stack) {
if (stack.isEmpty()) {
return;
}
// 取栈底
int i = f(stack);
// 翻转剩余的栈
reverse(stack);
// 栈底变栈顶
stack.push(i);
}
// 取栈底元素的函数
public static int f(Stack<Integer> stack) {
int result = stack.pop();
if (stack.isEmpty()) {
// 栈为空说明当前即是栈底元素,返回
return result;
} else {
// 不是则继续递归获取栈底元素
int last = f(stack);
// 将之前弹出的元素压回
stack.push(result);
// 返回栈底元素
return last;
}
}
数字字符串转化
题目说明
规定 1 和 A 对应、2 和 B 对应、 3 和 C 对应…
那么一个数字字符串比如 “111”,就可以转化为 “AAA”、“KA”、“AK”。
给定一个数字字符串str,返回有多少种转化结果。
递归思路
还是老思路,从左往右尝试可能的转化。假设当前来到位置i,注意几种情况:i是0,则当前尝试作废(没有可供转化的字母),返回0;i是1,有单独使用和与后一位数字一起使用两种尝试;i是2,如果后一位数字不大于6,也有两种,大于6则只有一种尝试;i是3-9,只有单独使用一种尝试。
代码实现
// 转化数字字符串
public static int revertNumStr(String str) {
if (str == null || str.length() == 0) {
return 0;
}
char[] chs = str.toCharArray();
return process(chs, 0);
}
public static int process(char[] chs, int i) {
if (i == str.length) {
// 尝试出一种转化,返回
return 1;
}
if (chs[i] == '0') {
// 无效尝试,返回0
return 0;
}
if (chs[i] == '1') {
// 单独使用
int res = process(chs, i + 1);
if (i + 1 < chs.length) {
// 联合后一位使用
res += process(chs, i + 2);
}
return res;
}
if (chs[i] == '2') {
// 单独使用
int res = process(chs, i + 1);
if (i + 1 < chs.length && chs[i + 1] >= '0' && chs[i + 1] <= '6') {
// 联合后一位使用
res += process(chs, i + 2);
}
return res;
}
// 3-9 直接单独使用
return process(chs, i + 1);
}
拿取最大总价值的物品
题目说明
给定两个长度都为N的数组weights和values,weights[i]和values[i]分别代表i号物品的重量和价值。给定一个正整数bag,表示一个载重bag的袋子,你装入袋中的物品不能超过这个重量。返回你能装下的物品的最大价值是多少?
递归思路
依然是从左往右尝试,每个货物都尝试要和不要,即遍历所有方式,得到最大结果。
代码实现
// 拿取最大总价值的物品
public static int maxValue(int[] weights, int[] values, int bag) {
if (weights.length == 0 || bag <= 0) {
return 0;
}
return process(weights, values, bag, 0, 0);
}
public static int process(int[] weights, int[] values, int bag, int i, int alreadyweight) {
if (alreadyweight > bag || i == weights.length) {
// 已经超重或已经选完
return 0;
}
// 当前拿和不拿两种情况取最大值
return Math.max(process(weights, values, bag, i + 1, alreadyweight), values[i] + process(weights, values, bag, i + 1, alreadyweight + weights[i]));
}
N皇后问题
见另一篇文章:N皇后问题
总结
暴力递归的关键点在于尝试的思路,思路对了,尝试自然就能成功。而且优秀的尝试,或者说递归思路是之后用动态规划优化递归的前置条件,递归思路越优秀——可变参数形式越简单、数量越少,越容易用动态规划来优化。