深入递归 【上】双管齐下解决递归问题
深搜、回溯、剪枝
递归有更强的表达力
1.1 “逐步生成结果”类问题之数值型
上楼梯
题源 👉 CC150走楼梯
有个小孩正在上楼梯,楼梯有n阶台阶,小孩一次可以上1阶、2阶、3阶。
请实现一个方法,计算小孩有多少种上楼的方式。
为了防止溢出,请将结果Mod 1000000007给定一个正整数int n,请返回一个数,代表上楼的方式数。
保证n小于等于100000。
推理过程:
-
若只有1个阶梯,共1种走法:
直接一步到位,达到剩0个阶梯的状态,剩0个阶梯时走法有1种
f (1) = f (0) = 1,设 f (0) 时走法为1
-
若只有2个阶梯,共2种走法:
1、第一步直接一次上2阶,达到剩0个阶梯的状态,剩0个阶梯时走法有1种
2、第一步上1阶,达到剩1个阶梯的状态,剩1个阶梯时走法有1种
f (2) = f (0) + f (1) = 2
-
若只有3个阶梯,共4种走法:
1、第一步直接一次上3阶,达到剩0个阶梯的状态,剩0个阶梯时走法有1种
2、第一步上2阶,达到剩1个阶梯的状态,剩1个阶梯时走法有1种
3、第一步上1阶,达到剩2个阶梯的状态,剩2个阶梯时走法有2种
故总共有 1 + 1 + 2 种走法
f (3) = 1 + f (1) + f (2) = 4
-
若只有4个阶梯,共7种走法:
1、第一步上3阶,达到剩1个阶梯的状态,剩1个阶梯时走法有1种
2、第一步上2阶,达到剩2个阶梯的状态,剩2个阶梯时走法有2种
3、第一步上1阶,达到剩3个阶梯的状态,剩3个阶梯时走法有4种
故总共有 1 + 2 + 4 种走法
f (4) = f (1) + f (2) + f (3) = 7
-
若只有5个阶梯,同理 f (5) = f (2) + f (3) + f (4) = 13
-
…
-
可知有n个阶梯时,
f (n) = f (n - 3) + f (n - 2) + f (n - 1),n ≠ 0,1,2
f (0) = f (1) = 1,f (2) = 2
public class case01_走楼梯 {
static final int mod = 1000000007;
public static void main(String[] args) {
System.out.println(f1(7));
System.out.println(f2(7));
}
// 递归调用方法
public static long f1(int n) {
if (n < 0)
return 0;
if (n == 0 || n == 1)
return 1;
if (n == 2)
return 2;
return f1(n - 3) % mod + f1(n - 2) % mod + f1(n - 1) % mod;
}
// 不使用递归调用方法
// 1 1 2 4 7 13 24 44
public static long f2(int n) {
if (n < 0)
return 0;
if (n == 0 || n == 1)
return 1;
if (n == 2)
return 2;
int f0 = 1;
int f1 = 1;
int f2 = 2;
for (int i = 3; i <= n; i++) {
int temp = f2;
f2 = ((f0 + f1) % mod + f2) % mod;
f0 = f1 % mod;
f1 = temp % mod;
}
return f2;
}
}
机器人走方格
有一个X*Y的网格,一个机器人只能走格点且只能向右或向下走,要从左上角走到右下角。
请设计一个算法,计算机器人有多少种走法。
给定两个正整数int x,int y,请返回机器人的走法数目。保证x+y小于等于12。
推理过程:(X,Y)表示 X 行 Y 列格子
-
(1,1)时,1种走法,f (1, 1) = 1;
(1,2)时,1种走法,f (1, 2) = 1;
(2,1)时,1种走法,f (2, 1) = 1;
(3,1)时,1种走法,f (3, 1) = 1;
…
当 X 或 Y = 1 时,都只有 1 种走法!
-
(2,2)时,2种走法:
可以右走1格达到(2,1)的状态
可以下走1格达到(1,2)的状态
f (2, 2) = f (1, 2) + f (2, 1) = 2
-
(3,2)时,3种走法:
可以右走1格达到(3,1)的状态
可以下走1格达到(2,2)的状态
f (3, 2) = f (3, 1) + f (2, 2) = 1 + 2 = 3;
-
…
-
可知有 (x, y) 格子时:
f (x , y) = f (x, y - 1) + f (x - 1, y)
注意:
- 递归形式时,以 x == 1 || y == 1 为边界条件
- 迭代形式时,以一个 x * y 的二维数组进行记录
public class case02_机器人走格子 {
public static void main(String[] args) {
System.out.println(solve1(6, 6));
System.out.println(solve2(6, 6));
}
// 递归形式
private static int solve1(int x, int y) {
if (x == 1 || y == 1)
return 1;
return solve1(x - 1, y) + solve1(x, y - 1);
}
// 迭代形式
private static int solve2(int x, int y) {
int[][] state = new int[x + 1][y + 1]; // +1是因为等会循环从1开始
for (int i = 1; i <= x; i++) { // 初始话第一列
state[i][1] = 1;
}
for (int i = 1; i <= y; i++) { // 初始话第一行
state[1][i] = 1;
}
for (int i = 2; i <= x; i++) {
for (int j = 2; j <= y; j++) {
state[i][j] = state[i - 1][j] + state[i][j - 1];
}
}
return state[x][y];
}
}
硬币表示
题源 👉 编程网站ProjectEuler
假设我们有8种不同面值的硬币{1,2,5,10,20,50,100,200},用这些硬币组合构成一个给定的数值n。
例如n=200,那么一种可能的组合方式为 200 = 3 * 1 + 1*2 + 1*5 + 2*20 + 1 * 50 + 1 * 100.
问总共有多少种可能的组合方式?
题源 👉 华为面试题
1分2分5分的硬币三种,组合成1角,共有多少种组合
直接暴力即可 1x + 2y + 5*z=10
题源 👉 创新工厂笔试题
有1分,2分,5分,10分四种硬币,每种硬币数量无限,给定n分钱,有多少组合可以组成n分钱
题源 👉 CC150硬币表示
1 5 10 25 分 n,多少种组合方法
对于 CC150硬币表示题推理过程:
对于数值n,用 {1,5,10,25}进行组合,
递归方法:
-
若只能用 硬币值为1 进行组合,则对于每个 n ,都只有1种组合方式;
-
若只能用 硬币值为1、5 进行组合,对于面值较大的硬币,即对5有 n / 5 + 1 种可能
(如 n = 10 时,可以选 10 / 5 + 1 = 3 种 ,即 0、1、2张5)
使用 i 张 5 时,剩下 n - i*5 的 价值由 硬币 {1} 进行组合
-
若只能用 硬币值为1、5、10 进行组合,对于面值较大的硬币,即对10有 n / 10 + 1 种可能
(如 n = 40 时,可以选 40 / 10 + 1 = 3 种 ,即 0、1、2、3、4张10)
使用 i 张 10 时,剩下 n - i*10 的 价值由 硬币 {1、5} 进行组合
-
若只能用 硬币值为1、5、10、25 进行组合,对于面值较大的硬币,即对25有 n / 25 + 1 种可能
(如 n = 40 时,可以选 40 / 25 + 1 = 2 种 ,即 0、1张25)
使用 i 张 25 时,剩下 n - i*25 的 价值由 硬币 {1、5、10} 进行组合
// 递归
/**
*
* @param n 要组合的面值
* @param coins 硬币数组
* @param cur 最大的硬币的数组下标
* @return
*/
private static int countWays1(int n, int[] coins, int cur) {
if (cur == 0) // 当只能使用硬币1进行组合时,任何面值都有1种方法
return 1;
int res = 0;
// 对于最大的那个硬币,可以有i种选择
for (int i = 0; i * coins[cur] <= n; i++) {
int rest = n - i * coins[cur]; // 剩余面值
res += countWays1(rest, coins, cur - 1);
}
return res;
}
迭代方法:
主要看黄色栏,数组的第 i 行表示可以使用第 i 行及其之上的硬币
要凑面值n,使用数组为arr[4][n + 1]进行标记:
-
若只能用 {1},只有1种;
-
若只能用 {1,5},对于面值 k ,硬币5的取法有 k / 5 + 1 种:
取0个5时,f0 = arr[0][k - 0 * 5] = arr[0][k] = 1;
取1个5时,f1 = arr[0][k - 1 * 5] = arr[0][k - 5];
…
取k / 5个5时,f k / 5 = arr [0][k - (k/5)*5] = arr[0][0] = 1;
故 arr[1][k] = f0 + f1 + … + f k / 5 = arr[0][k] + arr[0][k-5] + arr[0][k-10] + … + arr[0][k-(k/5)*5]
注意:除号"/"均为向下取整方式 ,如 10 / 4 = 2
-
若只能用 {1,5,10},对于面值 k ,硬币10的取法有 k / 10 + 1 种:
同理可得:
arr[2][k] = f0 + f1+ … + f k / 10 = arr[1][k] + arr[1][k-10] + arr[1][k-20] + … + arr[1][k-(k/10)*10]
…
// 迭代
private static int countWays2(int n, int[] coins) {
int[][] dp = new int[coins.length][n + 1]; // 前i种面值,组合出面值j
// 面值为0,每行都初始化为1
for (int i = 0; i < coins.length; i++)
dp[i][0] = 1;
// 对于硬币1,可凑出每个面值,即将第一行全部初始化为1
for (int j = 1; j < n + 1; j++)
dp[0][j] = 1;
for (int i = 1; i < coins.length; i++) { // 可以使用i及前i种面值
for (int j = 1; j < n + 1; j++) { // 对于面值j
// 使用i的硬币有 k = n/coins[i]+1种可能
for (int k = 0; k * coins[i] <= j; k++) {
dp[i][j] += dp[i - 1][j - k * coins[i]];
}
}
}
return dp[coins.length - 1][n];
}
1.2 "逐步生成结果"类问题之非数值型
需要用 容器 去装
合法括号
题源 👉 CC150 9.6
输入括号对数,判断一个字符串是否合法(即左右括号是否正确配对),输出所有合法的括号组合
示例:
输入:3
输出:()()(),((())),(()()),()(()),(())(),
思考过程:
S(1)层:n = 1, ()
S(2)层:n = 2,()()、(())、()()
S(3)层:n = 3,对于()():()()()左、()()()右、(()()())外、(())()内、()(())内
…
S(n)层:对S(n-1)层中每一个元素左边、右边、外层、元素内部每个左括号后,生成一对括号
- 每层使用Set类进行去重
import java.util.HashSet;
import java.util.Set;
public class case04_合法括号 {
public static void main(String[] args) {
Set<String> parenthesis = parenthesis1(3);
System.out.println(parenthesis);
parenthesis = parenthesis2(3);
System.out.println(parenthesis);
}
// 递归形式
public static Set<String> parenthesis1(int n) {
Set<String> s_n = new HashSet<String>(); // n层元素集S(n)
if (n == 1) {
s_n.add("()");
return s_n;
}
Set<String> s_n_1 = parenthesis1(n - 1); // 上一层元素集S(n-1)
// 对S(n-1)的每一个元素进行添左、添右、最外层、内层每个字符添加
for (String s : s_n_1) {
s_n.add("()" + s); // 添左
s_n.add(s + "()"); // 添右
s_n.add("(" + s + ")"); // 添在外面
// 元素内部每个左括号后
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '(')
s_n.add(s.substring(0, i + 1) + "()" + s.substring(i + 1));
}
}
return s_n;
}
// 迭代
public static Set<String> parenthesis2(int n) {
Set<String> res = new HashSet<String>(); // 保存上次迭代状态
res.add("()"); // 加入第一对括号
if (n == 1)
return res;
for (int i = 2; i <= n; i++) {
Set<String> res_new = new HashSet<>();
for (String e : res) {
res_new.add(e + "()");
res_new.add("()" + e);
res_new.add("(" + e + ")");
for (int j = 0; j < e.length(); j++) {
char c = e.charAt(j);
if (c == '(')
res_new.add(e.substring(0, j + 1) + "()" + e.substring(j + 1));
}
}
res = res_new;
}
return res;
}
}
非空子集
题源 👉 CC150 9.4
请编写一个方法,返回某集合的所有非空子集。
给定一个int数组A和数组的大小int n,请返回A的所有非空子集。
保证A的元素个数小于等于20,且元素互异。各子集内部从大到小排序,子集之间字典逆序排序
思考:
-
方法一:
对每个元素依次进行选取,每个元素有取和不取两种选择
import java.util.HashSet; import java.util.Set; public class case05_非空子集 { public static void main(String[] args) { int[] A = { 1, 2, 3 }; Set<Set<Integer>> subsets = getSubSets1(A, A.length); System.out.println(subsets); subsets = getSubSets2(A, A.length); System.out.println(subsets); } /** * 递归 * * @param A 数组 * @param n 数组长度 * @return */ public static Set<Set<Integer>> getSubSets1(int[] A, int n) { return getSubsets1Core(A, n, n - 1); } private static Set<Set<Integer>> getSubsets1Core(int[] a, int n, int cur) { Set<Set<Integer>> newSet = new HashSet<>(); if (cur == 0) { // 处理第一个元素 Set<Integer> empty = new HashSet<>(); // 空集 Set<Integer> first = new HashSet<>(); // 选择第一个元素 first.add(a[0]); newSet.add(empty); newSet.add(first); return newSet; } Set<Set<Integer>> oldSet = getSubsets1Core(a, n, cur - 1); // 对于上一层选取后的每一个元素集合,可选择加入或者不加入a[cur] for (Set<Integer> set : oldSet) { newSet.add(set); // 不选择a[cur] Set<Integer> clone = (Set<Integer>) ((HashSet) set).clone(); clone.add(a[cur]); // 选择a[cur] newSet.add(clone); } return newSet; } /** * 迭代 * * @param A * @param n * @return */ public static Set<Set<Integer>> getSubSets2(int[] A, int n) { Set<Set<Integer>> res = new HashSet<>(); res.add(new HashSet<>()); // 初始化为空集 for (int i = 0; i < n; i++) { Set<Set<Integer>> res_new = new HashSet<>(); res_new.addAll(res);// 把原来集合中的每个子集都加入到新集合中 for (Set e : res) { Set clone = (Set) ((HashSet) e).clone(); clone.add(A[i]); res_new.add(clone); } res = res_new; } return res; } }
-
方法二:二进制法,迭代法
/** * 二进制法 * * @param A * @param n * @return */ public static ArrayList<ArrayList<Integer>> getSubSets3(int[] A, int n) { Arrays.sort(A); // 正序排序 ArrayList<ArrayList<Integer>> res = new ArrayList<>();// 大集合 for (int i = ex(2, n) - 1; i > 0; i--) {// 大数字-1 ArrayList<Integer> s = new ArrayList<>();// 对每个i建立一个集合 for (int j = n - 1; j >= 0; j--) {// 检查哪个位上的二进制为1,从高位开始检查,高位对应着数组靠后的元素 if (((i >> j) & 1) == 1) { s.add(A[j]); } } res.add(s); } return res; } public static int ex(int a, int n) {...} // 与05数学问题中求快速幂的代码相同
字符串(集合)全排列
题源 👉 CC150 9.5
编写一个方法,确定某字符串的所有排列组合。
给定一个string A和一个int n,代表字符串和其长度,请返回所有该字符串字符的排列,保证字符串长度小于等于11且字符串中字符均为大写英文字符。
如 {A,B,C}进行全排列,有 3 * 2 * 1 = 6种可能排列,即 2 3 - 1 种
逐步生成大法-迭代法
和 “ 合法括号” 思路相似,先选定第一个元素,接着对第二个字符分别加在第一个的左边、右边…
1、{A}
2、{AB,BA}
3、{【CAB,ACB,ABC】,【CBA,BCA,BAC】}
import java.util.ArrayList;
public class case06_全排列I {
public static void main(String[] args) {
ArrayList<String> res = getPermutation("ABC", 3);
System.out.println(res);
}
public static ArrayList<String> getPermutation(String A, int n) {
ArrayList<String> res = new ArrayList<>();
res.add(A.charAt(0) + ""); // 初始化,加入第一个字符
for (int i = 1; i < n; i++) { // 循环进行排列的元素
ArrayList<String> res_new = new ArrayList<>();
char c = A.charAt(i); // 即将插入的元素
for (String str : res) { // 循环上一层完成的排列
// 对上层完成的每种排列插入当前元素
res_new.add(c + str);
res_new.add(str + c);
// 往中间缝隙插
for (int j = 1; j < str.length(); j++) {
String newString = str.substring(0, j) + c + str.substring(j);
res_new.add(newString);
}
}
res = res_new; // 每层结束后进行更新
}
return res;
}
}
升级🆙:
排列中的字符串按字典序从大到小排序。(不合并重复字符串)
经典写法:【回溯】
import java.util.ArrayList;
import java.util.Arrays;
public class case06_全排列II {
static ArrayList<String> res = new ArrayList<>();
public static void main(String[] args) {
getPermutation("ABC");
System.out.println(res);
}
public static ArrayList<String> getPermutation(String A) {
char[] arr = A.toCharArray(); // 转为数组
Arrays.sort(arr); // 要求按顺序
getPermutationCore(arr, 0);
return res;
}
private static void getPermutationCore(char[] arr, int k) {
if (k == arr.length) // 排好了一种情况,递归的支路走到底了
res.add(new String(arr));
// 从k位开始的每个字符,都尝试放在新排列的k这个位置
for (int i = k; i < arr.length; i++) {
swap(arr, k, i); // 把后面每个字符换到k位
getPermutationCore(arr, k + 1);
swap(arr, k, i); // 回溯
}
}
private static void swap(char[] arr, int k, int i) {
char temp = arr[k];
arr[k] = arr[i];
arr[i] = temp;
}
}
升级🆙:全排列III第k个排列
LeetCode60 n个数的排列组合找出字典序的第k个排列
The set[1,2,3,…,n]contains a total of n! unique permutations.
By listing and labeling all of the permutations in order,
We get the following sequence (ie, for n = 3):
“123”
“132”
“213”
“231”
“312”
“321”Given n and k, return the k th permutation sequence.
Note: Given n will be between 1 and 9 inclusive.时间限制:1秒
上一种方法完成全排列后再进行排序也可以
下面是前缀法:
public class case06_全排列III第k个排列 {
static int cnt = 0;
static int k = 6;
public static void main(String[] args) {
String s = "123";
permutation("", s.toCharArray());
}
private static void permutation(String prefix, char[] arr) {
if (prefix.length() == arr.length) { // 前缀的长度==字符集的长度,完成一个排列
cnt++;
if (cnt == k) {
System.out.println(prefix);
System.exit(0);
}
}
// 每次都从头扫描,只要该字符可用,我们就附加到前缀后面,前缀变长了
for (int i = 0; i < arr.length; i++) {
char c = arr[i];
// 这个字符可用:在pre中出现次数<在字符集中的出现次数
if (count(prefix, c) < count(arr, c)) {
permutation(prefix + c, arr);
}
}
}
private static int count(char[] arr, char c) {
int count = 0;
for (char ch : arr) {
if (c == ch)
count++;
}
return count;
}
private static int count(String s, char c) {
int count = 0;
for (int i = 0; i < s.length(); i++) {
if (s.charAt(i) == c)
count++;
}
return count;
}
}