一、卡特兰数
卡特兰数又称卡塔兰数,英文名Catalan number,是组合数学中一个常出现在各种计数问题中出现的数列。其前几项为:
1, 1, 2, 5, 14, 42, 132, 429, 1430, 4862, 16796, 58786, 208012, 742900, 2674440, 9694845, 35357670, 129644790, 477638700, 1767263190, 6564120420, 24466267020, 91482563640, 343059613650, 1289904147324, 4861946401452, ...
k(0) = 1, k(1) = 1时,如果接下来的项满足:
k(n) = k(0) * k(n - 1) + k(1) * k(n - 2) + ... + k(n - 2) * k(1) + k(n - 1) * k(0)
或者
k(n) = c(2n, n) - c(2n, n-1)
或者
k(n) = c(2n, n) / (n + 1)
就说这个表达式,满足卡特兰数,常用的是范式1和2,3几乎不会使用到
二、题目一
假设给你N个0,和N个1,你必须用全部数字拼序列
返回有多少个序列满足:任何前缀串,1的数量都不少于0的数量
package class39;
import java.util.LinkedList;
/**
* 假设给你N个0,和N个1,你必须用全部数字拼序列
*
* 返回有多少个序列满足:任何前缀串,1的数量都不少于0的数量
*
* 题型与成对括号是一样的。 有n个左括号,n个右括号 拼接全部字符()排序列 返回多少个满足的序列
* 潜台词有效序列就是要 任何前缀串 左括号数量要不少于右括号的数量 比如 ())... 这里来到第三个字符 九出现右括号2个>左括号 就是无效的
* 那么有效的排列是多少 根据排列组合 C(2N,N) 就是全部组合数 总共2n个字符,左括号n个 要在2n个的位置找n个 所以就是C 2N ,N
* 那么无效的怎么算: 这里我们有个结论 就是 两个集合的数 分别都各自有一一对应的数 那么两集合就是相等数的
* 我们将一个 无效的排序 在出现前缀串无效的后一个开始 后面的括号都取相反, 比如())(() 一开始是3个左右括号
* 来到第三个 出现无效情况 ()),此时前缀肯you定是右括号个数=左括号个数+1
* 接着后面的括号 (() 都取反 变成 ))( 这里也变成 右括号=左括号+1
* 最终变成 右括号+1 = 左括号-1 大2个 而这种集合 就是一一对应无效排序的 所以算出 2n个中选N+1位置排列组合
* 就是C(2N,N-1) 等价于无效排序组合是这么多
* 那么有效的就是 C(2N,N) - C(2N,N+1) 来自公式一演算
*
*
*
* 这个题型是运用 卡特兰数的公式求解
* k(0) = 1, k(1) = 1时,如果接下来的项满足:
* k(n) = k(0) * k(n - 1) + k(1) * k(n - 2) + ... + k(n - 2) * k(1) + k(n - 1) * k(0)
* 或者
* k(n) = c(2n, n) - c(2n, n-1)
* 或者
* k(n) = c(2n, n) / (n + 1)
* 就说这个表达式,满足卡特兰数,常用的是范式1和2,3几乎不会使用到
*/
public class Ways10 {
//根据公式三直接算出结果
public static long ways2(int n){
//base case 0个 1个的时候 都能满足 有1种
if(n < 0) return 0;
if(n < 2) return 1;
//定义两个辅助变量
long a = 1;
long b = 1;
long n2 = n << 1;
//C(2N,N) 就是 2n! / (2n - n)! * n! = 2n! / n ! * n !
//这里2n!可以约掉分母一个 n! 然后就剩下
// (n+1)*(n+2)*(n+3)..*(2n) / n !
//所以根据这个约分后的结果 进行遍历
for(int i = 1; i <= n2; i++){
if(i <= n){
//a就是 n! 累乘1...n 作为分母
a *= i;
} else {
//b是到了 n+1开始累乘到2n 就是约分后的分子
b *= i;
}
}
return (b / a) / (n + 1);
}
public static long ways1(int N) {
int zero = N;
int one = N;
LinkedList<Integer> path = new LinkedList<>();
LinkedList<LinkedList<Integer>> ans = new LinkedList<>();
process(zero, one, path, ans);
long count = 0;
for (LinkedList<Integer> cur : ans) {
int status = 0;
for (Integer num : cur) {
if (num == 0) {
status++;
} else {
status--;
}
if (status < 0) {
break;
}
}
if (status == 0) {
count++;
}
}
return count;
}
public static void process(int zero, int one, LinkedList<Integer> path, LinkedList<LinkedList<Integer>> ans) {
if (zero == 0 && one == 0) {
LinkedList<Integer> cur = new LinkedList<>();
for (Integer num : path) {
cur.add(num);
}
ans.add(cur);
} else {
if (zero == 0) {
path.addLast(1);
process(zero, one - 1, path, ans);
path.removeLast();
} else if (one == 0) {
path.addLast(0);
process(zero - 1, one, path, ans);
path.removeLast();
} else {
path.addLast(1);
process(zero, one - 1, path, ans);
path.removeLast();
path.addLast(0);
process(zero - 1, one, path, ans);
path.removeLast();
}
}
}
public static void main(String[] args) {
System.out.println("test begin");
for (int i = 0; i < 10; i++) {
long ans1 = ways1(i);
long ans2 = ways2(i);
if (ans1 != ans2) {
System.out.println("Oops!");
}
}
System.out.println("test finish");
}
}
三、题目二
有N个二叉树节点,每个节点彼此之间无任何差别
返回由N个二叉树节点,组成的不同结构数量是多少?
package class39;
/**
* 有N个二叉树节点,每个节点彼此之间无任何差别
*
* 返回由N个二叉树节点,组成的不同结构数量是多少?
*
* 这道题也是 卡特兰数的变种,由公式一推算出来
* 1. 左子树为0 右子树就为n-1 根节点占了1个节点 能匹配出 k(0) * k(n - 1) 种树型
* 2. 左子树为1 右子树就为n-2 匹配出 k(1) * k(n - 2) 种...
* 以此类推 将这么些类型累加 k(n) = k(0) * k(n - 1) + k(1) * k(n - 2) + ... + k(n - 2) * k(1) + k(n - 1) * k(0)
* 空树 k(0) , 只有根节点树k(1) 值都为1 1种 符合公式一
*
* 实际上我们用三个公式任意一个求解都可
*
*/
public class DifferentBTNum {
//公式一 得出多少种树
public static long num1(int n){
if(n < 0) return 0;
if(n < 2) return 1;
//定义一个数组 包含0..n个数 索引 那么长度就是n+1
long[] dp = new long[n+1];
//初始化 0 和 1个数的时候 只有一种树结构
dp[0] = 1;
dp[1] = 1;
//开始遍历填充 i=2,3,4....
for(int i =2; i <= n; i++){
for(int leftSize = 0; leftSize < i; leftSize++){
//内层循环就是指 左边树的结果 乘以 总数n - 左边的再-1就是右树的结果 总共的n-1个节点 因为根节点要保留一个 这个公式就是这么算的
dp[i] += dp[leftSize] * dp[i-1-leftSize];
}
}
//最后返回下标N位置 表示n个节点的树的种树
return dp[n];
}
//公式三 得出多少种树
public static long num2(int n){
if(n < 0) return 0;
if(n < 2) return 1;
//定义辅助变量
long a = 1;
long b = 1;
//C(2N,N) 就是 2n! / (2n - n)! * n! = 2n! / n ! * n !
//这里2n!可以约掉分母一个 n! 然后就剩下
// (n+1)*(n+2)*(n+3)..*(2n) / n !
// 这里我们加入一个方法目的就是防止数字过大溢出 所以边进行除以两数的最大公约数
// 就是将每一个数 进行求最大公约数 每次都除以这个最大公约数
for(int i = 1 , j = n+1; i <= n;i++, j++){
//i 从1 累乘到n 赋值给a 作为分母
a *= i;
//j 从n+1 累乘到 2n 赋值给b 作为 分子
b *= j;
long gcd = gcd(a,b); //求每次的最大公约数
a /= gcd;
b /= gcd;
}
//最后按公式返回结果
return (b / a) / (n+1);
}
//求两数的最大公约数
public static long gcd(long a, long b){
return b == 0 ? a : gcd(b, a %b);
}
public static void main(String[] args) {
System.out.println("test begin");
for (int i = 0; i < 15; i++) {
long ans1 = num1(i);
long ans2 = num2(i);
if (ans1 != ans2) {
System.out.println("Oops!");
}
}
System.out.println("test finish");
}
}
四、题目三
// arr中的值可能为正,可能为负,可能为0 // 自由选择arr中的数字,返回能不能累加得到sum
package class39;
import class33.Hash;
import java.util.HashMap;
import java.util.HashSet;
/**
*
// arr中的值可能为正,可能为负,可能为0
// 自由选择arr中的数字,返回能不能累加得到sum
*/
public class IsSumTest {
/**
* 暴力递归: arr[0..index] 自由选择是否可以累加到sum
* @return
*/
public static boolean process1(int[] arr, int index, int sum){
//base case: 累加减到0时, 自由选择 可以不选 得到0 所以返回true
if(sum == 0) return true;
//base case: 如果0..index 越界了 也就是没有数的时候 sum也不为0的情况下 是不能累加到Sum 返回false
if(index == -1) return false;
//分析常规情况 当前index 有数 并且sum还不为0
//当前Index数 不选 那么就等价于 0..index-1的是否能累加到sum
boolean p1 = process1(arr,index-1,sum);
//选 那么就等价于 0...index-1的数 是否能累加到sum - arr[index]
boolean p2 = process1(arr,index-1, sum - arr[index]);
return p1 || p2;
}
//方法一: 递归
public static boolean isSum1(int[] arr, int sum){
//base case: 不选数字 满足累加到0 返回true
if(sum == 0) return true;
if(arr == null || arr.length == 0) return false; //数组没有数 返回false
//调用递归函数 整个数组 0...n-1开始递归判断是否存在累加和为sum
return process1(arr, arr.length - 1, sum);
}
/**
* 递归+记忆化搜索 增加一个缓存机制 HashMap 存储每个 0..index 自由选择 是否可以累加到sum
* @param arr
* @param index
* @param sum
* @param map
* @return
*/
public static boolean process2(int[] arr, int index, int sum, HashMap<Integer,HashMap<Integer,Boolean>> map){
if(map.containsKey(index) && map.get(index).containsKey(sum)){
//利用记忆化搜索哈希表 先在缓存表找 index 累加到sum的情况 如果存在记录 直接就返回 0...index 是否能累加到sum的结果 加快速度
return map.get(index).get(sum);
}
//base case: 累加减到0时, 自由选择 可以不选 得到0 所以返回true
if(sum == 0) return true;
//base case: 如果0..index 越界了 也就是没有数的时候 sum也不为0的情况下 是不能累加到Sum 返回false
if(index == -1) return false;
//分析常规情况 当前index 有数 并且sum还不为0 不选 与 选 两种情况 其一有累加和符合即可
//当前Index数 不选 那么就等价于 0..index-1的是否能累加到sum
//选 那么就等价于 0...index-1的数 是否能累加到sum - arr[index]
boolean ans = process2(arr,index-1,sum,map) || process2(arr, index-1, sum-arr[index], map);
//刷新当前Index 哈希表 如果不存在index的记录 那么就先增加这个记录
if(!map.containsKey(index)){
map.put(index,new HashMap<>());
}
//最后再更新index 累加到sum 是否存在的记录
map.get(index).put(sum,ans);
return ans;
}
//方法二: 递归+记忆化搜索 其实就是动态规划了
public static boolean isSum2(int[] arr, int sum){
//base case: 不选数字 满足累加到0 返回true
if(sum == 0) return true;
if(arr == null || arr.length == 0) return false; //数组没有数 返回false
//调用递归函数 整个数组 0...n-1开始递归判断是否存在累加和为sum
return process2(arr, arr.length - 1, sum, new HashMap<>());
}
//方法三: 经典动态规划
public static boolean isSum3(int[] arr, int sum){
//base case: 不选数字 满足累加到0 返回true
if(sum == 0) return true;
if(arr == null || arr.length == 0) return false; //数组没有数 返回false
//递归的两个变量参数 index sum 范围 index 就是0..arr.length-1
//sum 这里注意了 因为题目存在负数 所以 sum的负数需要考虑进来 形成长度是 最小是最小负数和 最大是最大正数和的长度
int min =0; //定义两个数 分别表示 数组中 累加和最小和最大
int max =0;
for(int num : arr){
if(num < 0){
min += num;
}else { //这里num=0 加在min max都无所谓 值不影响
max += num;
}
}
//定义dp数组 行表示 数字 列表示sum和的范围 前面求出最小和最大 那么区间都多少个就是 大-小+1个
//表示 dp[i][j] 0..i 数字自由选择 凑到和为j的是否存在 存在为true
int n = arr.length;
boolean[][] dp = new boolean[n][max - min + 1];
//这里可以得到sum的越界判断 sum 最小不能小于min 最大不能大于max 否则就是无效的
if(sum < min || sum > max) return false;
//base case: 在0..index 中 不选数字 就都可以凑到 0数 值为true
//也就是每一行的 -min列 就是true 因为前面存在负数 我们的数组的列 长度是包括了负数的 相当于是 min...0...max 这样的一个sum值
//那么sum=0的时候 就是列在 dp[i][-min]了 因为min是负数 所以是- 才能来到 0的位置
dp[0][-min] = true;
// for(int i = 0; i < n; i++){
// dp[i][-min] = true;
// }
//base case: 在0..0 中 也就是首个位置数字 刚好凑齐的sum 就是arr[i]这个值 由于前面负数的原因 列要来到这个值的时候 要-min 才能横移到对应位置
dp[0][arr[0] - min] = true;
//分析情况 递归的依赖情况: index 行 依赖于 index-1行 也就是第一行已经完善好 前面的base case 接着往下填充
//从第二行 第一列开始
for(int i = 1; i < n; i++){
for(int j = min; j <= max; j++){
//情况1 当前数字不取 那么依赖就是下个位置0...i-1数字
dp[i][j - min] = dp[i-1][j -min];
//情况2 当前数字取 取的话 注意这个边界溢出要做判断
//一开始是和 j-min 取了数 就要减去数arr[i] 减完之后 要在dp数量列范围内 索引范围: 0,max-min
int rest = j-min - arr[i];
dp[i][j-min] |=(rest >=0 && rest <= max - min && dp[i-1][rest]) ;
}
}
//调用就是 0...n-1位置 和得到和sum的结果 行就是最后一行n-1 列由于存在负数 我们需要把负数占的列不上 -min min是负数所以是-才能横移到对应sum的位置
return dp[n-1][sum-min];
}
// arr中的值可能为正,可能为负,可能为0
// 自由选择arr中的数字,能不能累加得到sum
// 分治的方法
// 如果arr中的数值特别大,动态规划方法依然会很慢
// 此时如果arr的数字个数不算多(40以内),哪怕其中的数值很大,分治的方法也将是最优解
public static boolean isSum4(int[] arr, int sum) {
//base case: 不选数字 满足累加到0 返回true
if(sum == 0) return true;
if(arr == null || arr.length == 0) return false; //数组没有数 返回false
if (arr.length == 1) { //数组长度1 只有一个数 这个数就等于sum和就表示符合存在
return arr[0] == sum;
}
//进行二分思想 分治
int N = arr.length;
int mid = N >> 1;
//定义左右两边收集到的sum值 调用递归左边0...mid 右边mid+1...n-1 递归完成后就填充好两边能累加的和 保存在左右集合中
HashSet<Integer> leftSum = new HashSet<>();
HashSet<Integer> rightSum = new HashSet<>();
process4(arr, 0, mid, 0, leftSum);
process4(arr, mid+1, N-1, 0, rightSum);
//接着判断是否存在 就是 左边集合 存在sum 右边集合存在sum 或者 左边加右边的和是sum 都是表示存在的 就返回true
for (int l : leftSum) {
//这里有个小细节 就是 左边和右边单独的判断 实际也是包含得了 虽然这里是遍历左集合 取右集合中存在sum-l
//但是我们入参的时候 参数pre求和是从0开始的 所以,左右集合 都会存在一个0的数
//假设左 右 各自存在 有sum 的数 那么 sum-l 必然也是能取到
//比如 l = sum sum-l=0 右集合存在0这个值
//如果是 右集合存在sum 那么就是 sum-l=sum l=0 左集合存在0这个数
if (rightSum.contains(sum - l)) {
return true;
}
}
//否则没有满足的就返回false
return false;
}
/**
* 二分思想 递归 从i..end 自由选择 能累加得到pre 的数加到有序表记录
* @param arr
* @param i
* @param end
* @param pre
* @param ans
*/
public static void process4(int[] arr, int i, int end, int pre, HashSet<Integer> ans) {
if (i > end) {
//当前遍历的区间越界没有数了 就直接把能累加的和pre加入集合
ans.add(pre);
} else {
//如果区间大于1个数 那么分析 不取 取 两种情况
process4(arr, i + 1, end, pre, ans);
process4(arr, i + 1, end, pre + arr[i], ans);
}
}
// 为了测试
// 生成长度为len的随机数组
// 值在[-max, max]上随机
public static int[] randomArray(int len, int max) {
int[] arr = new int[len];
for (int i = 0; i < len; i++) {
arr[i] = (int) (Math.random() * ((max << 1) + 1)) - max;
}
return arr;
}
// 对数器验证所有方法
public static void main(String[] args) {
int N = 20;
int M = 100;
int testTime = 100000;
System.out.println("测试开始");
for (int i = 0; i < testTime; i++) {
int size = (int) (Math.random() * (N + 1));
int[] arr = randomArray(size, M);
int sum = (int) (Math.random() * ((M << 1) + 1)) - M;
boolean ans1 = isSum1(arr, sum);
boolean ans2 = isSum2(arr, sum);
boolean ans3 = isSum3(arr, sum);
boolean ans4 = isSum4(arr, sum);
if (ans1 ^ ans2 || ans3 ^ ans4 || ans1 ^ ans3) {
System.out.println("出错了!");
System.out.print("arr : ");
for (int num : arr) {
System.out.print(num + " ");
}
System.out.println();
System.out.println("sum : " + sum);
System.out.println("方法一答案 : " + ans1);
System.out.println("方法二答案 : " + ans2);
System.out.println("方法三答案 : " + ans3);
System.out.println("方法四答案 : " + ans4);
break;
}
}
System.out.println("测试结束");
}
}
五、总结
1)如果像题目一一样,这个太明显了,你一定能发现
成对括号问题
2)看看题目是不是类题目二问题,比如:
人员站队问题
出栈入栈问题
需要敏感度!