1. 暴力递归
通俗来说,使用一种彻彻底底穷举的方法把一个答案试出来,就叫暴力递归(Force Recursive)。
暴力递归就是尝试,把问题转化为规模缩小了的同类问题的子问题,子问题通过决策过程概括大问题,同时子问题还要拆成更小的子问题,当子问题拆分到不需要再尝试能就出来答案的时候为止(base case)。
暴力尝试不要记录每一个子问题的解,只有尝试最重要,如何优化,自然有方法。
2. 汉诺塔问题
题目:打印n层汉诺塔从最左边杆移动到最右边杆的全部过程。汉诺塔移动过程中,小圆盘可以压大圆盘,大圆盘不能压小圆盘。
分析:
讲一种比较好的尝试。
首先,我们不考虑杆的绝对位置,抽象问题,只设置from杆,to杆,和other杆。from,to或者other杆可复用,具体指的是最左杆,最右杆,中间杆都有可能。
确定当前整体任务:将from杆上第 1~ i 的圆盘全部移动到to杆上去。
自上而下划分子任务:
第一步:将from杆上第 1 ~ (i-1) 的圆盘全部移动到other杆上去。
第二步:将from杆上第 i 个圆盘移动到to杆上去。
第三步:将other杆上第 1 ~ (i-1) 的圆盘从other杆移动到to杆上去。
注意:
1 ~ (i-1) 的盘子移动的过程如何保证大盘子在小盘子之下?
我只要保证在第 i 个盘子移动的过程中大盘在小盘之下,那么第 i-1个盘子移动的子过程也会保持相同规则。同样 i-2 个盘子移动也会满足相同规则。
在暴力递归中,很多人会想弄清楚全局的操作流程,然后怎么想都想不清楚。
在尝试的时候,完全不必要这么想。尝试就是你给所有的过程定义一个统一的标准,只要你父问题能不违反标准,子问题就一定也不违反标准。你在尝试的过程中,你只要思考,在这个局部下,你如何拆问题。只要保证在这个局部下拆的决策是对的,那么整体一定是对的。
代码:
public static void hanoi(int n) {
process(n, "左", "右", "中");
}
/**
* 尝试方法
* @param i 1~i 的圆盘
* @param from 移动起始杆
* @param to 移动目的杆
* @param other 另外一个杆
*/
public static void process(int i, String from, String to, String other) {
// base case 当只有一个圆盘时,该圆盘放哪个杆都可以
if (i == 1) {
System.out.println("Move 1 from " + from + " to " + to);
return ;
}
process(i - 1, from, other, to);
System.out.println("Move " + i + " from " + from + " to " + to);
process(i - 1, other, to, from);
}
3. 字符串子序列
题目:打印一个字符串的全部子序列,包括空字符串。
分析:
经典尝试方法,从左往右,每个位置元素要或不要做决策。
代码:
public static void printAllSubStr(String str) {
char[] charSequence = str.toCharArray();
process(charSequence, 0, new ArrayList<Character>());
}
/**
* 尝试方法
* @param charSequence 字符串拆解的字符序列
* @param i 当前字符在字符序列中的下标
* @param chars 当前通路已经选择的字符
*/
public static void process(char[] charSequence, int i, List<Character> chars) {
// 当尝试到通路最后时,直接输出通路所有字符序列组成的字符串
if (i == charSequence.length) {
printList(chars);
return ;
}
// 添加当前字符往下走
List<Character> addCurChars = copyList(chars);
addCurChars.add(charSequence[i]);
process(charSequence, i + 1, addCurChars);
// 不添加当前字符往下走
List<Character> notAddCurChars = copyList(chars);
process(charSequence, i + 1, notAddCurChars);
}
// 打印List
public static void printList(List<Character> chars) {
for (Character character : chars) {
System.out.print(character);
}
System.out.println();
}
// 拷贝List
public static List<Character> copyList(List<Character> chars) {
return new ArrayList<>(chars);
}
省空间的写法,思路是复用原先字符序列,通过修改当前位置的字符,从而实现要还是不要当前字符。
两种写法的时间复杂度指标是一样的。
public static void printAllSubStr(String str) {
char[] charSequence = str.toCharArray();
process(charSequence, 0);
}
public static void process(char[] charSequence, int i) {
if (i == charSequence.length) {
// 当尝试到通路最后时,直接输出通路所有字符序列组成的字符串
System.out.println(String.valueOf(charSequence));
return ;
}
// 添加当前字符往下走
process(charSequence, i + 1);
// 在原字符序列上将当前字符抹去
char tmp = charSequence[i];
charSequence[i] = 0;
// 不添加当前字符往下走
process(charSequence, i + 1);
// 还原原字符序列
charSequence[i] = tmp;
}
4. 字符串排列
题目:打印一个字符串的全部排列,要求不要出现重复的排列。
分析:
尝试方法:从左到右,后续每一个字符和当前字符做交换,做决策。
同样也是在原字符串的基础上做修改,利用递归保留代码片段的特性,复用了原字符序列,从而节省空间。
代码:
public static void printAllArrange(String str) {
char[] charSequence = str.toCharArray();
ArrayList<String> list = new ArrayList<>();
process(charSequence, 0, list);
// 打印
for (String string : list) {
System.out.println(string);
}
}
public static void process(char[] charSequence, int i, ArrayList<String> list) {
// 如果当前遍历到字符序列末尾,存储当前字符序列,并打印
if (i == charSequence.length) {
list.add(new String(charSequence));
return ;
}
// 遍历当前字符后的每一个字符
for (int j = i; j < charSequence.length; ++ j) {
// 字符和当前字符交换
swap(charSequence, i, j);
// 递归到下一个字符
process(charSequence, i + 1, list);
// 还原字符串
swap(charSequence, j, i);
}
}
public static void swap(char[] charSequence, int i, int j) {
char temp = charSequence[i];
charSequence[i] = charSequence[j];
charSequence[j] = temp;
}
上述代码中,如果str中没有重复字符,那么结果不会出问题,如果有重复字符,那么结果就会出现重复的排列。因为如果str没有重复字符,其他字符替换某个字符时只会到该字符位置一次;如果有重复字符,其他字符替换某个字符时重复的字符就会到该字符位置不止一次。
如果要求在字符串的全排列中,不能有重复排列,那么有两种思路。
- 使用上述代码,在得到所有结果后洗数据,将重复数据洗掉。
- 使用下面代码,在尝试换字符时,判断目标替换字符有没有在当前位置出现过,如果出现过,就不能交进行交换。
第一种方案很慢,因为需要走所有路径,得到所有结果,最后还要洗数据。
第二种方案更好,因为在走分支时,就杀死了不可能的分支,会得到更快的方法(分支限界)。
两种方案时间复杂度的指标一样,因为在最差情况下,第二种情况的剪枝策略可能都不成立,这个时候和第一种方案就没有区别了。但是第二种方案在不同数据状况下常数项上有优化。
public static void process(char[] charSequence, int i, ArrayList<String> list) {
if (i == charSequence.length) {
list.add(new String(charSequence));
return ;
}
// 记录每一个字符(a-z)是否尝试过,默认全是false
boolean[] flag = new boolean[26];
// 遍历当前字符后的每一个字符
for (int j = i; j < charSequence.length; ++ j) {
int index = charSequence[j] - 'a';
// 判断是否在该位置尝试过该字符串
if (!flag[index]) {
swap(charSequence, i, j);
process(charSequence, i + 1, list);
swap(charSequence, j, i);
// 记录已经尝试
flag[index] = true;
}
}
}
5. 最长递增子序列
题目:从一个数组中,找出最长递增子序列的长度。例如:[ 1, 5, 2, 4, 3 ],最长递增子序列有两个,分别是 [ 1, 2, 4 ],[ 1, 2, 3 ],返回3。
分析:
尝试方法:从每一个元素开始,向后遍历所有元素,决策是否加入递增序列。
代码:
public static int longestIncreasingSubSequence(int[] arr, int begin) {
if (begin == arr.length - 1) {
return 1;
}
int longestLength = 1;
for (int i = begin; i < arr.length; i ++) {
if (arr[i] > arr[begin]) {
longestLength = Math.max(longestIncreasingSubSequence(arr, i) + 1, longestLength);
}
}
return longestLength;
}
6. 拿牌问题
题目:给定一个整型数组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拿走4,则排列变为[1, 2, 100],接下来玩家B可以拿走1或100,然后继续轮到玩家A...
玩家A作为绝顶聪明的人不会先拿4,因为拿4之后,玩家B将拿走100。所以玩家A会先拿1,让排列变为[2, 100, 4],接下来玩家B不管怎么选,100都会被玩家A拿走。玩家A会获胜,分数为101。所以返回101。
分析:
这道题如何做决策已经给出,每个玩家每次只能拿走最左或最右的纸牌。
尝试方法:在每一轮摸牌中,要不摸最左边的牌,要不摸最右边的牌。
代码:
public static int win(int[] arr) {
// base case
if (arr == null || arr.length == 1) {
return 0;
}
// 先手的是玩家A,后手的玩家B,谁大谁赢
return Math.max(offensive(arr, 0, arr.length - 1), defensive(arr, 0, arr.length - 1));
}
// 先手函数
public static int offensive(int[] arr, int left, int right) {
// 在先手的情况下只有一张牌,直接拿
if (left == right) {
return arr[left];
}
// 尝试拿左边牌和拿右边牌,然后后手
return Math.max(arr[left] + defensive(arr, left + 1, right), arr[right] + defensive(arr, left, right - 1));
}
// 后手函数
public static int defensive(int[] arr, int left, int right) {
// 在后手的情况下只有一张牌,无牌可拿
if (left == right) {
return 0;
}
// 分别在两种情况中先手,选择小的那个是因为对手肯定把差的牌给我
return Math.min(offensive(arr, left + 1, right), offensive(arr, left, right - 1));
}
7. 逆序栈
题目:给你一个栈,请你逆序这个栈,不能申请额外的数据结构,只能使用递归函数。如何实现?
分析:
本题是一个递归技法题,在暴力递归中,对于递归技法的要求比较高,因此可以尝试做一下该题。
首先需要实现一个函数,该函数的功能是:将栈底元素移除栈且返回,但是栈中其他元素位置保持不变。
代码:
public static void reverseStack(Stack<Integer> stack) {
// base case
if (stack.isEmpty()) {
return ;
}
// 保存当前处理的栈底元素
int item = process(stack);
// 进行下一次处理
reverseStack(stack);
// 将保存的栈底元素压回栈中
stack.push(item);
}
// 该函数完成了取出栈底元素,并保持栈中其他元素位置不变的作用
public static int process(Stack<Integer> stack) {
// 弹栈,并保存栈顶元素
int item = stack.pop();
// 当栈空的时候,最后一个栈顶元素不压栈,直接返回
if (stack.isEmpty()) {
return item;
}
// 获取最后一个栈顶元素
int lastItem = process(stack);
// 将其他栈顶元素再压回栈中
stack.push(item);
// 返回最后一个栈顶元素
return lastItem;
}
8. 数字字符串转化
题目:
规定1和A对应,2和B对应,3和C对应 ... 26和Z对应。那么一个数字字符串如 "111" 就可以转化为 "AAA","AK","KA" 三种结果。
给定一个只有数字字符组成的字符串str,求有多少种转化结果。
分析:
本题的试法也是经典的从左到右开始尝试。
比方说来到了 i 位置,假设 0 ~ (i-1) 的决策已经确定了,我们只关注从第 i 位和第 i 位往后的所有变种,和前面的决策相结合,有多少种有效的整体决策,具体细节由Coding来处理。
代码:
public static int transformStrCount(String str) {
return process(str.toCharArray(), 0);
}
// i表示从第i位开始转化
public static int process(char[] chars, int i) {
// str的最后一位也被转化完成,是一种可行的转化方案
if (i == chars.length) {
return 1;
}
// 转化到第i位的字符是0,没有对应字母与之对应,str无法被转换,该转化方案不可行
if (chars[i] == '0') {
return 0;
}
// 转化到第i位的字符是1
if (chars[i] == '1') {
// 单独转化第i位,构成一种方案
int count = process(chars, i + 1);
// 判断第i位后面一个字符是否存在
if (i + 1 < chars.length) {
// 如果存在,可以将第i位和后面一个字符合并转化,构成一种方案
count += process(chars, i + 2);
}
return count;
}
// 转化到第i位的字符是2
if (chars[i] == '2') {
// 单独转化第i位,构成一种方案
int count = process(chars, i + 1);
// 判断第i位后面的字符是否存在,,如果存在是否小与等于6
if (i + 1 < chars.length && chars[i + 1] >= '0' && chars[i + 1] <= '6') {
// 如果存在且小于6,可以将第i位后后面一个在字符合并转化,构成一种方案
count += process(chars, i + 2);
}
return count;
}
// 转化到第i位的字符是3-9,只能单独转化第i位
return process(chars, i + 1);
}
9. 背包问题
题目:
给定两个长度都为 N 的数组 weights 和 values,weights[ i ] 和 values[ i ]分别代表 i 号物品的重量和价值。给定一个正数bag,表示一个载重为 bag 的袋子,你装的物品不能超过这个重量。
你能装下物品的总价值最大是多少?
分析:
总左往右尝试,0号货要或不要,1号货要或不要 ...
代码:
本题目有两种常规写法,第一种写法是第二种的简化版本。
第一种写法好,因为第一种写法只有两个可变参数,i 和 alreadyWeight;而第二种写法有三个可变参数 i 、alreadyWeight 和 alreadyValue。
我们在构建尝试方法时,有一个原则 "尽量构建可变参数形式最简单,可变参数数量最少的的方法"。可变参数形式最简单表示可以使用一个值就可以表达,如果使用链表,哈希表等作为可变参数的形式,就会非常复杂。这个是后续改DP的基础,可变参数形式越简单,数量越少,DP越好改。
第一种:
public static int knapsackProblem(int[] weights, int[] values, int bag) {
return process(weights, values, bag, 0, 0);
}
/**
* 对当前第i号物品做决策
* @param weights 所有物品重量
* @param values 所有物品价值
* @param bag 袋子最大重量
* @param alreadyWeight 之前所做的决策袋子的重量
* @param i 当前第i号物品
* @return 最大价值
*/
public static int process(int[] weights, int[] values, int bag, int alreadyWeight, int i) {
// 如果所有物品尝试完
if (i == weights.length) {
return 0;
}
// 如果当前袋子超重
if (alreadyWeight > bag) {
return 0;
}
// 放入第i号物品和不放入产生的价值大的返回
return Math.max(
// 将第i号物品放入袋子中
values[i] + process(weights, values, bag, alreadyWeight + weights[i], i + 1),
// 不将第i号物品放入袋子中
process(weights, values, bag, alreadyWeight, i + 1)
);
}
第二种:
public static int knapsackProblem(int[] weights, int[] values, int bag) {
return process(weights, values, bag, 0, 0, 0);
}
public static int process(int[] weights, int[] values, int bag, int alreadyWeight, int alreadyValue, int i) {
// 所有物品尝试完
if (i == weights.length) {
return alreadyValue;
}
// 如果当前袋子超重
if (alreadyWeight > bag) {
return 0;
}
return Math.max(
// 将第i号物品放入袋子中
process(weights, values, bag, alreadyWeight - weights[i], alreadyValue + values[i], i + 1),
// 不将第i号物品放入袋子中
process(weights, values, bag, alreadyWeight, alreadyValue, i + 1)
);
}
10. N皇后问题
题目:
N皇后问题是指在N * N的棋盘上要摆N个皇后,要求任何两个皇后不同行、不同列,也不在同一条斜线上。
给定一个整数n,返回n皇后的摆法有多少种。
分析:每一行只能放一个Queen,在每一行中,从左往右一列一列的尝试。
代码:
public static int nQueen(int n) {
if (n < 1) {
return 0;
}
int[] record = new int[n];
return process(n, record, 0);
}
/**
* @param n n行n列
* @param record record[i] = j 表示一个Queen在第i行第j列
* @param i 当前做决策的行
* @return 摆放方法数
*/
public static int process(int n, int[] record, int i) {
// 所有行都已经做完决策,是一种合法方案
if (i == n) {
return 1;
}
int result = 0;
// 遍历第i行所有列,判断能否放Queen
for (int j = 0; j < n; j ++) {
if (isValid(record, i, j)) {
// 放皇后
record[i] = j;
// 去下一行做决策
result += process(n, record, i + 1);
}
}
return result;
}
// 判断第i行第j列放一个Queen是否合法
public static boolean isValid(int[] record, int i, int j) {
// 0 ~ i-1 行放的所有Queen不共列,不共斜线
for (int k = 0; k < record.length; k ++) {
if (record[k] == j || Math.abs(k - i) == Math.abs(record[k] - j)) {
return false;
}
}
return true;
}