从暴力递归到动态规划已经接近尾声,动态规划所有的内容都在这几篇文章中了,这篇文章主要是做一些总结。在总结之前,我们先回顾一下之前改写的整个套路。
题目一:
给定一个正数数组arr, 请把arr中所有的数分成两个集合,尽量让两个集合的累加和接近 返回: 最接近的情况下,较小集合的累加和
分析:比如说一个数组[3,4,5,1],一定要分成两个数组,并且他们的累加和要尽量接近,那么客观上来讲,3和4 一个数组 5和1 一个数组,他们累加和一个是7 一个是6 我们返回较小的 6 ,那么这道题的尝试怎么写呢,其实我们一眼就看出来了,这道题不就是一个改背包的问题嘛,我们还是先从尝试开始,这里我就直接把尝试代码给大家了,相信大家已经轻车熟路了,看起来一定soeasy。
public static int right(int[] arr){
if (arr == null || arr.length<2){
return 0;
}
int sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[i];
}
return process(arr,0,sum / 2);
}
//arr[i....] 可以自由选择,请返回尽量接近rest,但不能超过rest的情况下,最接近的累加和是多少
private static int process(int[] arr, int i, int rest) {
if (i == arr.length){
return 0;
}else {
int p1 = process(arr,i+1,rest);
int p2 = 0;
if (arr[i] <= rest){
p2 = arr[i] + process(arr,i+1,rest - arr[i]);
}
return Math.max(p1,p2);
}
}
怎么样,有了前几章的铺垫,看起来是不是很easy啊,接下来我们该干啥了,对,改动态规划呀。这样我们就要关心一个问题了,有没有重复解的出现。有哇,举个栗子,[5,2,3 ........],假如我们要凑小于等于20的解,其中有一个选择,我们要0位置,不要1 不要 2,那么,下一个选择就是3位置凑15 ,还有一个选择,我们不要0,要1,要2 这样,下一个选择依旧是 3位置 凑 15,怎么样,重复解是不是出现了。有重复解,说明我们改dp有利可图。接下来我们看该暴力递归有两个可变参数,是不是这两个可变参数确定了,返回值就确定了,是的,所以这是一张二维表。那么i的变化范围是什么呢 0 - N ,rest的范围呢 0 - sum/2 不会超过这些范围。如果我们把这张表填满,所有返回值就装下了,接下来怎么填满这张表呢,最终我们想要哪个位置呢, (0,sum/2)位置。baseCase是 i == N的时候全填0 ,也就是说这张表的最后一行全是0,接下来我们分析普遍位置怎么依赖,通过观察递归函数得知,当我在第i行,我总是依赖 i+1 的位置。妥了,这样我们不就可以从底往上推,最后推到右上角返回。
接下来就是根据上述分析和暴力递归代码,改为动态规划代码。代码如下:
public static int dp(int[] arr){
if (arr == null || arr.length<2){
return 0;
}
int sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[i];
}
sum /= 2;
int N = arr.length;
int[][] dp = new int[N+1][sum + 1];
for (int i = N-1; i >= 0 ; i--) {
for (int rest = 0; rest <= sum; rest++) {
int p1 = dp[i+1][rest];
int p2 = 0;
if (arr[i] <= rest){
p2 = arr[i] + dp[i+1][rest - arr[i]];
}
dp[i][rest] = Math.max(p1,p2);
}
}
return dp[0][sum];
}
看,是不是很熟悉的套路呀,这就是我们的动态规划版本。
题目二:
给定一个正数数组arr,请把arr中所有的数分成两个集合 如果arr长度为偶数,两个集合包含数的个数要一样多 如果arr长度为奇数,两个集合包含数的个数必须只差一个 请尽量让两个集合的累加和接近 返回: 最接近的情况下,较小集合的累加和
分析:我们看这题,还是有一些难的哈,他有两个标准,第一个是集合的个数,第二个是他们的累加和。偶数个很好理解一边四个嘛,关键是奇数个的时候,我们该怎么办呢 假如说有7个数,我们需要的累加和是100 第一中情况,我们必须拿够3个数小于等于100,离100最近的,第二种情况,我必须拿够4个数,小于等于100 离100最近的。我们应该返回这两种可能性最接近的那个。我们不能规定就三个或者就4个 例如 100,1,1,1,1,1,1 这样,客观上来讲, 我们只能 [100 1 1 ] ,[1,1,1,1] 这样分。那么我们怎么写尝试呢,在这之前我们已经会写不要求个数的尝试了,那这一题我们不就加一个挑的个数限制嘛。代码如下:
public static int right(int[] arr){
if (arr == null || arr.length<2){
return 0;
}
int sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[i];
}
if ((arr.length & 1) == 0){
return process(arr,0,arr.length/2,sum/2);
}else {
return Math.max(process(arr,0,arr.length/2,sum/2),process(arr,0,arr.length/2+1,sum/2));
}
}
//arr[i....] 可以自由选择,挑选个数一定要picks个,请返回尽量接近rest,但不能超过rest的情况下,最接近的累加和是多少
private static int process(int[] arr, int i, int picks, int rest) {
if (i == arr.length){
return picks == 0 ? 0 : -1;
}else {
int p1 = process(arr,i+1,picks,rest);
int p2 = -1;
int next = -1;
if (arr[i] <= rest){
next = process(arr,i+1,picks - 1,rest -arr[i]);
}
if (next != -1){
p2 = arr[i] + next;
}
return Math.max(p1,p2);
}
}
看是不是很简单,但是有一点,这里是三个可变参数,那么改dp就是一个三维数组,当然我们也不是第一次见三维数组,例如之前的棋盘跳马问题我们就遇到了三维数组,三维就三维嘛,我们根据暴力递归改嘛,把这张三维表填完,就得到答案了,准不会错。
public static int dp(int[] arr){
if (arr == null || arr.length<2){
return 0;
}
int sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[i];
}
sum /= 2;
int N = arr.length;
int M = (N+1)/2;
int[][][] dp = new int[N+1][M][sum+1];
for (int i = 0; i <= N ; i++) {
for (int j = 0; j <= M ; j++) {
for (int k = 0; k <= sum ; k++) {
dp[i][j][k] = -1;
}
}
}
for (int rest = 0; rest <=sum ; rest++) {
dp[N][0][rest] = 0;
}
for (int i = N - 1; i >= 0 ; i--) {
for (int picks = 0; picks <= M; picks++) {
for (int rest = 0; rest <= sum ; rest++) {
int p1 = dp[i+1][picks][rest];
int p2 = -1;
int next = -1;
if (picks - 1 >=0 && arr[i] <= rest){
next = dp[i+1][picks - 1][rest -arr[i]];
}
if (next != -1){
p2 = arr[i] + next;
}
dp[i][picks][rest] = Math.max(p1,p2);
}
}
}
if ((arr.length & 1) == 0){
return dp[0][arr.length/2][sum];
}else {
return Math.max(dp[0][arr.length/2][sum],dp[0][arr.length/2+1][sum]);
}
}
我们也见过这么多到题了,接下来我们总结一下。
动态规划最重要的是什么呀,没错,最重要的就是我们的递归要怎么写,我们要怎么尝试,如果这个都写不出来,那么就没有以后了。
怎么尝试?
1)有经验但是没有方法论?
2)怎么判断一个尝试就是最优尝试?
3)难道尝试这件事真的只能拼天赋?那我咋搞定我的面试?
4)动态规划是啥?好高端的样子哦…可是我不会啊!和尝试有什么关系?
什么暴力递归可以继续优化?
有重复调用同一个子问题的解,这种递归可以优化 如果每一个子问题都是不同的解,无法优化也不用优化
暴力递归和动态规划的关系
某一个暴力递归,有解的重复调用,就可以把这个暴力递归优化成动态规划 任何动态规划问题,都一定对应着某一个有重复过程的暴力递归 但不是所有的暴力递归,都一定对应着动态规划
面试题和动态规划的关系
解决一个问题,可能有很多尝试方法 可能在很多尝试方法中,又有若干个尝试方法有动态规划的方式 一个问题 可能有 若干种动态规划的解法
如何找到某个问题的动态规划方式?
1)设计暴力递归:重要原则+4种常见尝试模型!重点!
2)分析有没有重复解:套路解决
3)用记忆化搜索 -> 用严格表结构实现动态规划:套路解决
4)看看能否继续优化:套路解决
面试中设计暴力递归过程的原则
1)每一个可变参数的类型,一定不要比int类型更加复杂
2)原则1)可以违反,让类型突破到一维线性结构,那必须是单一可变参数
3)如果发现原则1)被违反,但不违反原则2),只需要做到记忆化搜索即可
4)可变参数的个数,能少则少
知道了面试中设计暴力递归过程的原则,然后呢?
一定要逼自己找到不违反原则情况下的暴力尝试! 如果你找到的暴力尝试,不符合原则,马上舍弃!找新的! 如果某个题目突破了设计原则,一定极难极难,面试中出现概率低于5%!
常见的4种尝试模型
1)从左往右的尝试模型
2)范围上的尝试模型
3)多样本位置全对应的尝试模型
4)寻找业务限制的尝试模型
如何分析有没有重复解
列出调用过程,可以只列出前几层 有没有重复解,一看便知
暴力递归到动态规划的套路
1)你已经有了一个不违反原则的暴力递归,而且的确存在解的重复调用
2)找到哪些参数的变化会影响返回值,对每一个列出变化范围
3)参数间的所有的组合数量,意味着表大小
4)记忆化搜索的方法就是傻缓存,非常容易得到
5)规定好严格表的大小,分析位置的依赖顺序,然后从基础填写到最终解
6)对于有枚举行为的决策过程,进一步优化
动态规划的进一步优化
1)空间压缩
2)状态化简
3)四边形不等式
4)其他优化技巧
题目三 N皇后问题
N皇后问题是指在N*N的棋盘上要摆N个皇后, 要求任何两个皇后不同行、不同列, 也不在同一条斜线上 给定一个整数n,返回n皇后的摆法有多少种。 n=1,返回1 n=2或3,2皇后和3皇后问题无论怎么摆都不行,返回0 n=8,返回92
N皇后问题大家都知道,熟悉,所以就不举例了,众所周知,N皇后问题想要得到绝对的方法数,巨暴力,甚至于今天各个国家的超级计算机,测试超级计算机的性能,你的任务怎么特别好的划分,就是那N皇后问题测的。思路呢,就是我们考虑皇后的时候,我们一行一行的填皇后,我们搞一个皇后的轨迹信息,我们看第0行的皇后在哪,再看第一行的皇后在哪,再看第二行的皇后在哪。每一行只填一个皇后,这样我们就不用检查两个皇后是否共行的问题。
public static int num1(int n){
if(n < 1){
return 0;
}
int[] record = new int[n];
return process(0,record,n);
}
//当前来到 i 行 ,一共是 0 - N - 1 行
//在i 行上方皇后,所有的列都尝试
//必须保证跟之前所有的皇后都不打架
//int[] record record[x] = y 之前的x行皇后,都放在了y列
//返回 不关心i之前发生了什么, i ... 后续有多少中方法数
private static int process(int i, int[] record, int n) {
if ( i == n){
return 1;
}
int res = 0;
// i 行的皇后放那一列呢 j列 全试
for (int j = 0; j < n; j++) {
if (isValid(record,i,j)){
record[i] = j;
res += process(i+1,record,n);
}
}
return res;
}
private static boolean isValid(int[] record, int i, int j) {
for (int k = 0; k < i; k++) {
if (j == record[k] || Math.abs(record[k] - j) == Math.abs(i - k)){
return false;
}
}
return true;
}
代码就是这么的短,简单吧,N皇后问题还有一个巨骚的操作,可以用来装逼,就是位运算的写法,位运算的写法和这种写法时间复杂度一样,仅仅是优化了常数时间,在随着皇后数的增多,位运算的形式个该形式消耗时间能差10倍之多。
我们是这样想的,我们是拿状态,一个整型值它的状态来替换record的某些东西,record是一个数组,如果用数组每回都寻址的话就会很慢,我怎么用整数来代替record,就成了N皇后问题优化的关键,假设我是7皇后问题,假设我准备好7个位置,现在我想想这我第0层的皇后我填哪,如下图:
假如说我把皇后放在x的位置,那么第一行的皇后列的限制是什么状态,什么意思,就是下一行会在?位置去填皇后,但是那些是绝对不能填的呢,列限制中画x的位置,那么0行x左下的限制就是如图中左下,没有,因为x左下它出去了,而对右下的限制如图中右下,为啥,因为皇后放在0行x位置,它的右下也就是1行第二个问号位置他是放不了皇后的,所以这些问号能选那些位置,列或上左下或上右下还是0的那些位置。然后继续选皇后,添加限制,很好玩吧。
//请不要超过32个皇后
public static int num2(int n){
if (n < 1 || n > 32){
return 0;
}
//如果你是13皇后问题,limit 最右13个 1 其他都是0
// 如果是32 皇后 就是 32个 1 -1可以表示
// 如果不是32皇后 ,那就把1左移皇后位 再减 1 就会变成 最右边皇后个 1
//为啥要搞出limit 有用
int limit = n == 32 ? -1:(1<<n)-1;
return process2(limit,0,0,0);
}
//如果是 7 皇后问题 limit 永远都是
// limit : 0......0 1 1 1 1 1 1 1
// 之前皇后的列影响 colLim
// 之前皇后的左下对角线影响 LiftDiaLim
// 之前皇后的右下对角线影响 LiftDiaLim
// 对于这些参数,我只用状态,根本不用他们原始的值
private static int process2(int limit, int colLim, int leftDiaLim, int rightDiaLim) {
//当我们发现我们的列限制能够等于limit
//说明有一种解法。
if (colLim == limit){
return 1;
}
//pos 中所有是1的位置,是你可以去尝试皇后的位置
int pos = limit & (~(colLim | leftDiaLim | rightDiaLim));
// 列: 0-0 | 0 0 0 1 0 0 0
// 左下:0-0 | 0 0 1 0 0 0 0
// 右下:0-0 | 0 0 0 0 1 0 0
//或完总限制:0-0 | 0 0 1 1 1 0 0
// 在总限制里面 是 1 的你不能放皇后 是 0 的可以
// 总限制整体取反:1-1 | 1 1 0 0 0 1 1
// limit 是 :0-0 | 1 1 1 1 1 1 1
//与完之后 :0-0 | 1 1 0 0 0 1 1
// 这样 1 就是所有可以放皇后的位置
int mostRightOne = 0;
int res = 0;
while (pos != 0){
//提取最右侧的 1
// 假如说一个二进制数 00001001001000100
// 提取最右侧的1后就会变成 00000000000000100
mostRightOne = pos & (~pos + 1);
pos = pos - mostRightOne;
res += process2(
limit,
//已经把皇后点在mostRightOne位置
//增加列限制
colLim|mostRightOne,
(leftDiaLim | mostRightOne)<<1,
(rightDiaLim | mostRightOne)>>>1);
}
return res;
}
怎么样,秀不秀,不一样的N皇后。拿去装逼。