28、算法与数据结构 - 实战:卡特兰数、数组累加和问题、矩阵技巧
卡特兰数
假设k(0) = 1, k(1) = 1,满足下面三个公式的其中一个,都是卡特兰数,它们是等价的:
1、 k(n)=k(0)*k(n-1)+k(1)*k(n-2)+…+k(n-2)*k(1)+k(n-1)*k(0);
2、 k(n)=c(2n,n)/(n+1);
3、 k(n)=c(2n,n)-c(2n,n-1);
用处:看一道题
假设给你n个左括号,又给你n个右括号
随意组合,有多少种组合方式满足括号组号的范式
比如(())即使合法,())(就是不合法
这一题可以用卡特兰数的公式解
但是怎么分析出它的解符合卡特兰数呢?
这里直接去推有多少种合法的组合方式,是很难推的
可以反过来看,有多少种组合方式是不合法的,然后总的组合方式数减去不合法数,就是答案
总的组合数 c(2n, n)
那么不合法的方式有多少种呢?
首先看一个规律:
假设有A集合,B集合,它们是两个不相干的集合,
但是如果A集合中的每个元素都存在某种关系使得他在B集合中,都有一个元素与之一对一对应
而反过来B集合中的每个元素都存在某种关系使得他在A集合中,都有一个元素与之一对一对应
那么可以肯定,这两个结合的元素个数一定相等
根据这个规律,再回过头来看
在n个左括号和n个右括号自由组合的不合法方式中,总有一个前缀是右括号比左括号多一个的,那么后半部分就是左括号比右括号多一个,把后半部分括号反转,整体就是右括号比左括号多两个。所以可以得知,n个左括号和n个右括号自由组合的不合法方式中,每种不合法的组合方式,都对应一种n-1个左括号和n+1个右括号的一种组合方式,而且对应关系是唯一的(因为不合法前缀是各不相同的)。
而反过来,n-1个左括号和n+1个右括号的每种组合方式,都在n个左括号和n个右括号自由组合的不合法方式中,有一种不合法的组合方式与之对应:找到右括号比左括号多一个的左半部分,然后后半部分括号反转,就变成了一种n个左括号和n个右括号自由组合的不合法方式。这种对应关系也是唯一的(因为对应回n个左括号和n个右括号中,不合法前缀是各不相同的)
那么求n个左括号和n个右括号自由组合的不合法方式有多少种,就可以转化为n-2个左括号和n+2个右括号有多少种不同的组合方式。C(2n, n+1)或者C(2n, n-1)。
那么求n个左括号和n个右括号自由组合的合法方式有多少种,就是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) / (n + 1)
* 或者
* k(n) = c(2n, n) - c(2n, n-1)
*
* Created by huangjunyi on 2022/12/9.
*/
public class CatalanNumber {
public static long num1(int N) {
if (N < 0) {
return 0;
}
if (N < 2) {
return 1;
}
long[] dp = new long[N + 1];
dp[0] = 1;
dp[1] = 1;
for (int i = 2; i <= N; i++) {
for (int leftSize = 0; leftSize < i; leftSize++) {
dp[i] += dp[leftSize] * dp[i - 1 - leftSize];
}
}
return dp[N];
}
}
类似括号问题的题目还有:
1、 有n个数字,要进栈,有多少种合理的进出栈方式?例如4个数字,进进出出(合法),出进进出(不合法)其实进就是左括号,出就是右括号;
2、 假设某公司股票的波动要么往上45°,要么往下45°,一共做n次交易,一共有多少种交易方式,不会过X轴一下其实往上45°就是左括号,往下45°就是右括号;
3、 n个节点,组成二叉树,有多少中组合方式头节点占1一个节点,剩下n-1个节点,分左树和右树考虑左0右n-1,左1右n-2,左2右n-3,…,左n-1右0还是卡特兰数;
数组累加和问题
题目一
给定一个正整数组成的无序数组arr,给定一个正整数值k
找到arr的所有子数组里,哪个子数组的累加和等于k,并且长度最大的
返回其长度
这一题可以使用窗口求解
因为数组都是正整数,存在一种单调性,就是窗口往右扩,窗口内的数的累加和变大,窗口往右缩,窗口内的数的累加和变小
1、 所以可以定义一个窗口,L指针和R指针,表示左边界和右边界,一开始都是0,表示只有arr[0]一个数在窗口内;
2、 如果窗口内的数的累加和sum,小于k,则R指针往右移动,窗口扩大;
3、 如果窗口内的数的累加和sum,大于k,则L指针往右移动,窗口缩小;
4、 如果窗口内的数的累加和sum,等于k,则抓到一个答案,和最大长度PK一下,PK过了则更新最大长度;
/**
* 给定一个数组,只有正整数,返回累加和等于k的所有子数组中的最大长度子数组的长度
* Created by huangjunyi on 2022/9/11.
*/
public class ArrSum01 {
public static int getMaxLen(int[] arr, int k) {
int l = 0; // 窗口左边界
int r = 0; // 窗口右边界
int sum = arr[0]; // 当前窗口内累加和
int max = 0; // 子数组为k的最大长度
while (r < arr.length) {
if (sum == k) {
// 窗口累计和等于k,收集答案,PK一下
max = Math.max(max, r - l + 1);
sum -= arr[l++];
continue;
}
if (sum > k) {
// 窗口累计和大于k,左指针右移,窗口缩小
sum -= arr[l++];
continue;
}
if (sum < k) {
// 窗口累计和大于k,右指针右移,窗口扩大
r++;
if (r < arr.length) {
sum += arr[r];
}
}
}
return max;
}
}
题目二
给定一个整数组成的无序数组arr,值可能正、可能负、可能0
给定一个正数值k
找到arr的所有子数组里,哪个子数组的累加和等于k,并且是长度最大的
返回其长度
在遇到这种子数组相关的问题时,一定要马上想到以每个下标为结尾的子数组求一个答案,或者以每个下标为开头的子数组求一个答案
比如这一题,
以0位置结尾的子数组,累加和为k的,如果有,收一个答案
以1位置结尾的子数组,累加和为k的,如果有,收一个答案
以2位置结尾的子数组,累加和为k的,如果有,收一个答案
以3位置结尾的子数组,累加和为k的,如果有,收一个答案
…
而子数组累加和,又可以转化为数组前缀和来求解
那么假设以位置5结尾前缀和为10,那么怎么求以位置5结尾并且累加和为k的子数组呢
只要看前面有没有10-k的前缀和,假设以位置2结尾的数组前缀和就是10-k,那么以位置5结尾并且累加和为k的子数组就是3~5
但是题目要求返回累加和等于k,并且是长度最大的子数组的长度
那么当遍历到某个位置,累加到某个前缀和preSum,要求出以当前下标为结尾并且累加和为k并且长度最长的子数组,就要看是否存在前缀和为k-preSum的位置,并且是最早的
因此要建立一个map,记录前缀和为key,value为最早出现该前缀和的位置
遍历数组收集答案的同时,要同时记录map,如果当前前缀和在map中没有记录,则记录到map中,如果已经记录过了,则不更新
/**
* 给定一个数组,有正有负有0,返回累加和等于k的所有子数组中的最大长度子数组的长度
* Created by huangjunyi on 2022/9/11.
*/
public class ArrSum02 {
public static int getMaxLen(int[] arr, int k) {
if (arr == null || arr.length == 0) return 0;
// key 前缀和,value 最早出现该前缀和的位置
Map<Integer, Integer> sumIndexMap = new HashMap<>();
// 预先塞一条前缀和为0的记录,前缀和0最早出现在-1位置,
// 如果遇到前缀和就是k的情况,就可以收集答案
// 如果不垫这一条记录,遇到前缀和就是k的情况时,就会错过
sumIndexMap.put(0, -1);
int sum = 0; // 前缀和
int len = 0; // 最长长度
for (int i = 0; i < arr.length; i++) {
sum += arr[i]; // 累加前缀和
int diff = sum - k; // 计算得到k的差值前缀和
if (sumIndexMap.containsKey(diff)) {
// 如果map中存在该差值前缀和,则可以收集一个答案PK一下
len = Math.max(len, i - sumIndexMap.get(diff));
}
// 如果map中存在该差值前缀和,不更新
if (sumIndexMap.containsKey(sum)) continue;
// 如果map中不存在该差值前缀和,记录
sumIndexMap.put(sum, i);
}
return len;
}
}
题目三
给定一个整数组成的无序数组arr,值可能正,可能负,可能0
给定一个整数值k
找到arr的所有子数组里,哪个子数组的累加和<=k,并且是长度最大的
返回其长度
解法: 1、 准备两个长度与arr一样的的数组,minSum,minSumEnd;minSum[i]记录从i位置为开头,往右扩出的子数组累加和最小的,最小累加和是多少;minSumEnd[i]表示从i位置为开头,往右扩出的子数组累加和最小的,右边界到哪里;
2、 从右往左遍历arr,初始化minSum和minSumEnd,遍历到arr[i],计算minSum[i]和minSumEnd[i],只需要看i+1位置即可,如果minSum[i+1]<0,则i位置可以往右扩,minSum[i]就是arr[i]+minSum[i],minSumEnd[i]就是minSumEnd[i+1];
3、 初始化完minSum和minSumEnd后,再从左往右遍历arr,以窗口的方式,根据minSum和minSumEnd求每个位置能推多远依然累加和<=k的,推到无法再推,则窗口往右缩,累加和减去出窗口的数;
/**
* 给定一个数组,返回累加和小于等于k的所有子数组中的最大长度子数组的长度
* Created by huangjunyi on 2022/9/11.
*/
public class ArrSum03 {
public static int getMaxLen(int[] arr, int k) {
if (arr == null || arr.length == 0) return 0;
// minSum[i] 以i开头,往右扩出的累加和最小的子数组 的累加和
int[] minSum = new int[arr.length];
// minSumEnd[i] 以i开头,往右扩出的累加和最小的子数组 的右边界
int[] minSumEnd = new int[arr.length];
minSum[arr.length - 1] = arr[arr.length - 1];
minSumEnd[arr.length - 1] = arr.length - 1;
// 从右往左遍历arr,初始化minSum和minSumEmd
for (int i = arr.length - 2; i >= 0; i--) {
// i + 1位置的累加和小于0,可以往右扩
if (minSum[i + 1] <= 0) {
minSum[i] = arr[i] + minSum[i + 1];
minSumEnd[i] = minSumEnd[i + 1];
}
// 否则不往右扩,自己单独算作minSum[i]
else {
minSum[i] = arr[i];
minSumEnd[i] = i;
}
}
int end = 0; // 扩不到的那个位置
int sum = 0; // 当前窗口累加和
int len = 0; // 抓到的最大长度
for (int start = 0; start < arr.length; start++) {
// 根据minSum和minSumEnd的信息把窗口右边界往右推
while (end < arr.length && sum + minSum[end] <= k) {
sum += minSum[end];
end = minSumEnd[end] + 1;
}
// 推到不能再推,抓一个答案PK
len = Math.max(len, end - start);
// 如果窗口还有长度,则还可以缩,减去将要出窗口的数
if (start < end) sum -= arr[start];
// 窗口没长度了,在左边界++之前,右边界先++
else end = start + 1;
}
return len;
}
}
题目四
给定一个数组arr,给定一个值v
求子数组平均值小于等于v的最长子数组长度
解法: 把数组所有的数减一个v,就变成求数组累加和小于等于0的子数组,最长的长度
这样就转化成了和上面一样的问题
因为所有的数都减去v后,一个子数组累加和大于0,那么每个数加v后,平均值肯定大于v
相反,如果因为所有的数都减去v后,一个子数组累加和小于等于0,那么每个数加v后,平均值肯定也小于等于v
总结
题目一主要技巧:利用单调性优化
题目二主要技巧:利用预处理结构优化
题目三主要技巧:假设答案法+淘汰可能性
矩阵技巧
题目一
用Zigzag扫描打印矩阵matrix
/**
* 用Zigzag扫描打印矩阵matrix
* Created by huangjunyi on 2022/9/11.
*/
public class Matrix01 {
public static void printMatrix(int[][] matrix) {
/*
A点(a,b)
B点(c,d)
A点每次往右走一步,到最右侧了,则每次往下走一步
B点每次往下走一步,到最下方了,则每次往右走一步
那么每次AB两点间,都能全都一条斜线
用一个boolean变量标识本次应该从上午往下打印还是从下往上打印
a ➡ b⬇
c -----------
⬇| |
| |
| |
-----------
d ➡
*/
// A点坐标
int a = 0;
int b = 0;
// B点坐标
int c = 0;
int d = 0;
int endRow = matrix.length - 1;
int endColumn = matrix[0].length - 1;
boolean fromUp = false;
while (b <= endRow) {
// 打印斜线
printMatrix(matrix, a, b, c, d, fromUp);
// A点每次往左走一步,到最右侧了,则每次往下走一步
b = a == endColumn ? b + 1 : b;
a = a == endColumn ? a : a + 1;
// B点每次往下走一步,到最下方了,则每次往右走一步
d = c == endRow ? d + 1 : d;
c = c == endRow ? c : c + 1;
}
}
private static void printMatrix(int[][] matrix, int a, int b, int c, int d, boolean fromUp) {
/*
(a, b)
/
/
/
/
/
/
/
(d, c)
*/
if (fromUp) {
//从右上往左下打印
while (b < matrix.length && a >= 0) System.out.println(matrix[b++][a--]);
} else {
//从左下往右上打印
while (d < matrix[0].length && c >= 0) System.out.println(matrix[c--][d++]);
}
}
}
题目二
转圈打印矩阵
/**
* 转圈打印矩阵
* Created by huangjunyi on 2022/9/11.
*/
public class Matrix02 {
public static void printMatrix(int[][] matrix) {
/*
x ➡
y
⬇
(a, b)
-----------
| |
| |
| |
-----------
(c, d)
*/
// 左上角点坐标
int a = 0;
int b = 0;
// 右下角的左边
int c = matrix[0].length - 1;
int d = matrix.length - 1;
// 分圈,从外往里,一圈一圈的打印
while (a <= c && b <= d) {
printMatrix(matrix, a++, b++, c--, d--);
}
}
private static void printMatrix(int[][] matrix, int a, int b, int c, int d) {
if (a == c) {
while (b <= d) System.out.println(matrix[b++][a]);
return;
}
if (b == d) {
while (a <= c) System.out.println(matrix[b][a++]);
return;
}
int curR = b;
int curC = a;
while (curC < c) System.out.println(matrix[curR][curC++]);
while (curR < d) System.out.println(matrix[curR++][curC]);
while (curC > a) System.out.println(matrix[curR][curC--]);
while (curR > b) System.out.println(matrix[curR--][curC]);
}
}
题目三
旋转矩阵
/**
* 旋转矩阵
* Created by huangjunyi on 2022/9/11.
*/
public class Matrix03 {
public static void rotate(int[][] matrix) {
/*
矩阵分圈,一圈一圈旋转,圈与圈之间是互不影响的
圈内分组,每组就是4个点,循环交换位置
x ➡
y
⬇
(x=a, y=b)
-----------
| |
| |
| |
-----------
(x=c, y=d)
*/
int a = 0;
int b = 0;
int c = matrix[0].length - 1;
int d = matrix.length - 1;
while (a < c) rotate(matrix, a++, b++, c--, d--);
}
private static void rotate(int[][] matrix, int a, int b, int c, int d) {
for (int i = 0; i < c - a; i++) {
int temp = matrix[b][a + i];
matrix[b][a + i] = matrix[d - i][a];
matrix[d - i][a] = matrix[d][c - i];
matrix[d][c - i] = matrix[b + i][c];
matrix[b + i][c] = temp;
}
}
}