【leetcode】(十四)从暴力递归到动态规划

1.思路流程

暴力递归到动态规划的思路流程:

(1)从尝试方法进行递归优化:try(递归)

(2)记忆搜索动态规划:记搜(dp)

(3)严格表结构的动态规划:严格表结构(dp)

在某些问题中,记忆搜索动态规划和严格表结构的动态规划的时间复杂度近似。

(4)精确严格表结构的动态规划:严格表结构精版(dp)

严格表建立流程:

(1)分析可变参数变化范围,两个可变参数就是二维表,三个可变参数就是三维表;

(2)标出你要计算的终止位置

(3)根据basecase标出不用计算直接出答案的位置

(4)推普遍位置是如何依赖其它关系的

(5)定出严格表从哪些格子推出到哪些格子的顺序,将try(递归)得到的东西改动态规划表实现拿值的过程,进行替代

现在用一些题目来讲明以上的流程。

2.机器人位置问题

给定四个整数 N、S、E、K:

  • N:一共有N个位置,分别编号为1、2、3、......、N。

  • S:机器人的起始位置,范围为1~N。

  • E:机器人的最终目标位置,范围为1~N。

  • K:机器人必须行走的步数。

机器人每次只能向左或向右移动一个位置,不能停留在原地,并且必须遵守边界条件:

  • 机器人处于位置1时,只能向右移动到位置2。

  • 机器人处于位置N时,只能向左移动到位置N-1。

  • 机器人处于中间位置时,可以选择向左或向右移动。

问题:机器人必须恰好走K步,从S到达E,共有多少种不同的走法?(参考leetcode62和70,754,2400)

例如,N=5,S=2,E=4,K=4。

符合条件的路径共有4条:

复制代码
(1) 2 → 3 → 4 → 5 → 4
(2) 2 → 3 → 4 → 3 → 4
(3) 2 → 3 → 2 → 3 → 4
(4) 2 → 1 → 2 → 3 → 4

最终结果:4种方法

下面按照四个阶段进行优化:

复制代码
暴力递归
    ↓
记忆化搜索动态规划
    ↓
严格表结构动态规划
    ↓
严格表结构精简版(空间压缩)

2.1 方法一:暴力递归(try)

首先,采用从左到右的尝试方法,让机器人每次选择向左或向右移动,直到剩余步数为0。

定义递归函数:

复制代码
f1(int N, int E, int rest, int cur)

其中:

复制代码
N:总位置数量,固定参数
E:目标位置,固定参数
rest:机器人还需要走多少步
cur:机器人当前所在位置

返回值:
从cur出发,恰好走rest步到达E的方法数。

由于 N 和 E 在整个递归过程中保持不变,因此我们只需要关注 rest 和 cur 两个变化的参数。

(1)递归终止条件

如果机器人已经走完要求的步数,即 rest=0,则判断当前是否到达目标位置。

复制代码
if (rest == 0) {
    return cur == E ? 1 : 0;
}

如果 cur==E,说明找到了一条合法路径,返回1;否则返回0。

(2)递归尝试过程

根据当前位置分为三种情况:

java 复制代码
情况一:cur=1

机器人只能向右移动。
f(rest,1) = f(rest-1,2)

情况二:cur=N

机器人只能向左移动。
f(rest,N) = f(rest-1,N-1)

情况三:1<cur<N

机器人可以向左或者向右移动。
f(rest,cur)= f(rest-1,cur-1)+ f(rest-1,cur+1)

(3)递归展开过程

例如,N=5,S=2,E=4,K=4。

初始状态是:

复制代码
f(4,2)

表示:
当前位置为2,剩余4步,
最终需要到达位置4。

展开递归树:

复制代码
                   f(4,2)
                  /      \
                 /        \
             f(3,1)      f(3,3)
                |         /   \
                |        /     \
             f(2,2)   f(2,2)  f(2,4)
               / \       / \     / \
              ... ...   ... ... ... ...

观察发现:f(2,2) 被重复计算了两次。继续递归下去,还会出现大量相同的子问题。这些子问题具有相同的 rest 和 cur,因此它们的计算结果必然相同。但是暴力递归每次遇到这些状态时,都会重新展开递归树,导致大量重复计算。

(4)时间复杂度

每次递归最多分裂成两个子问题,而递归深度最大为K,因此递归树可以看成一棵高度为K的二叉树。最坏情况下,时间复杂度为:O(2^K)},递归调用栈的空间复杂度为:O(K)

因此,暴力递归能够解决问题,但当K较大时,执行效率较低。

2.2 方法二:记忆化搜索动态规划(记搜DP)

通过前面的递归树可以发现,递归过程中存在大量重复状态。例如:

复制代码
f(4,2)
   |
   ├── f(3,1)
   |      |
   |      └── f(2,2)
   |
   └── f(3,3)
          |
          ├── f(2,2)
          |
          └── f(2,4)

其中,f(2,2) 无论通过哪一条路径进入,返回值都相同。因此,可以建立一张二维表,将已经计算过的状态保存起来。当某个状态再次出现时,直接从表中读取结果,不再重复递归。

(1)建立记忆化搜索表

因为递归过程中变化的参数只有:

复制代码
rest:0~K
cur:1~N

所以建立:

复制代码
int[][] dp = new int[K + 1][N + 1];

其中:dprestcur表示:从cur位置出发,还需要走rest步到达E的方法数。

将所有位置初始化为 -1,表示该状态还没有计算过。

复制代码
for (int i = 0; i <= K; i++) {
    for (int j = 0; j <= N; j++) {
        dp[i][j] = -1;
    }
}

(2)记忆化搜索过程

当程序进入 f2() 时,首先检查缓存:

复制代码
if (dp[rest][cur] != -1) {
    return dp[rest][cur];
}

如果之前已经计算过该状态,就直接返回。否则继续进行递归,并将最终结果记录在表中。

例如:

复制代码
dp[rest][cur] =
        f2(N, E, rest - 1, cur - 1, dp)
      + f2(N, E, rest - 1, cur + 1, dp);

这样每个状态最多只会真正计算一次。

(3)记忆化搜索的优化效果

在暴力递归中:

复制代码
f(2,2)

第一次出现:
展开递归,计算结果。

第二次出现:
再次展开递归,重新计算。

而在记忆化搜索中:

复制代码
f(2,2)

第一次出现:
展开递归,计算结果并存入dp。

第二次出现:
直接读取dp[2][2]。

第三次出现:
仍然直接读取dp[2][2]。

此时,重复计算被消除。整个问题最多存在:(K+1) N 个不同的有效状态,因此时间复杂度降低为:O(KN)。空间复杂度为:O(KN)} 。此外,还需要最多 O(K) 的递归调用栈空间。

需要注意的是,记忆化搜索仍然保留递归过程,只是为递归增加了缓存。

2.3 方法三:严格表结构动态规划(严格表结构DP)

通过记忆化搜索,我们已经发现,机器人位置问题的计算结果只依赖两个变量:

复制代码
rest:剩余步数
cur:当前位置

因此,可以直接建立二维DP表,而不再依赖递归函数。

(1)建立DP表

例如:

复制代码
N=5,S=2,E=4,K=4

建立:

复制代码
int[][] dp = new int[K + 1][N + 1];

计算完成的DP表如下:

目标位置 E=4

|-------------|---|-----|---|---|---|
| rest \ cur | 1 | 2 | 3 | 4 | 5 |
| 0 | 0 | 0 | 0 | 1 | 0 |
| 1 | 0 | 0 | 1 | 0 | 1 |
| 2 | 0 | 1 | 0 | 2 | 0 |
| 3 | 1 | 0 | 3 | 0 | 2 |
| 4 | 0 | 4 ✓ | 0 | 5 | 0 |

列表示当前位置cur,行表示剩余步数rest。最终取dp42=4。

(2)确定初始状态

当 rest=0 时,机器人已经不能继续移动。

因此:

复制代码
如果cur=E:
dp[0][cur] = 1

如果cur≠E:
dp[0][cur] = 0

本题目标位置为4,因此:

复制代码
dp[0][4] = 1;

对应第0行:

复制代码
cur:   1  2  3  4  5
rest=0:0  0  0  1  0

(3)分析表结构的依赖关系

由于每走一步,剩余步数都会减少1,因此当前状态依赖上一行。

对于左边界位置1:

复制代码
dp[rest][1] = dp[rest-1][2]

对于右边界位置N:

复制代码
dp[rest][N] = dp[rest-1][N-1]

对于中间位置:

复制代码
dp[rest][cur]
    = dp[rest-1][cur-1]
    + dp[rest-1][cur+1]

可以发现:

  • 第一列依赖上一行的右上角。

  • 最后一列依赖上一行的左上角。

  • 中间位置依赖上一行的左上角与右上角之和。

因此,只需要从上往下依次填写DP表。

(4)逐行填写DP表

第一行:

复制代码
rest=0:

0 0 0 1 0

第二行:

复制代码
rest=1:

dp[1][3] = dp[0][2] + dp[0][4]
         = 0 + 1
         = 1

dp[1][5] = dp[0][4]
         = 1

结果:
0 0 1 0 1

第三行:

复制代码
rest=2:

dp[2][2] = dp[1][1] + dp[1][3]
         = 0 + 1
         = 1

dp[2][4] = dp[1][3] + dp[1][5]
         = 1 + 1
         = 2

结果:
0 1 0 2 0

第四行:

复制代码
rest=3:

dp[3][1] = dp[2][2] = 1

dp[3][3] = dp[2][2] + dp[2][4]
         = 1 + 2
         = 3

dp[3][5] = dp[2][4] = 2

结果:
1 0 3 0 2

第五行:

复制代码
rest=4:

dp[4][2] = dp[3][1] + dp[3][3]
         = 1 + 3
         = 4

dp[4][4] = dp[3][3] + dp[3][5]
         = 3 + 2
         = 5

结果:
0 4 0 5 0

最终机器人从位置2出发,恰好走4步到达位置4的方法数为:

复制代码
dp[4][2] = 4;

(5)严格表结构DP的特点

严格表结构动态规划不再使用递归,而是根据递归关系确定DP表中每个位置的依赖关系,然后按照正确顺序填表。

由于有K+1行、N个有效位置,每个状态的计算只需要常数次操作,因此:

时间复杂度:O(KN)

空间复杂度:O(KN)

与记忆化搜索相比,两者时间复杂度相同,但严格表结构DP不需要递归调用栈。

2.4 方法四:严格表结构精简版动态规划(空间压缩DP)

观察第三种方法的状态转移方程:

复制代码
dp[rest][cur]
    = dp[rest-1][cur-1]
    + dp[rest-1][cur+1]

我们发现,当前行的计算只依赖上一行,与更早的行没有关系。

例如:计算rest=4时:只需要rest=3的数据。计算rest=3时:只需要rest=2的数据。

因此,没有必要保留整个二维DP表,只需要保存当前行和上一行。

(1)使用两个一维数组

原来:

复制代码
int[][] dp = new int[K + 1][N + 1];

现在:

复制代码
int[] dp = new int[N + 1];
int[] next = new int[N + 1];

其中:

复制代码
dp:上一行的状态
next:当前正在计算的状态

初始状态仍然为:

复制代码
dp[E] = 1;

(2)空间压缩的计算过程

以 N=5,E=4,K=4 为例。

复制代码
初始化:rest=0

dp:
位置  1  2  3  4  5
数值  0  0  0  1  0


第一轮:rest=1

根据dp计算next:

next:
位置  1  2  3  4  5
数值  0  0  1  0  1

计算结束后:
dp = next


第二轮:rest=2

根据上一轮dp计算next:

next:
位置  1  2  3  4  5
数值  0  1  0  2  0

计算结束后:
dp = next


第三轮:rest=3

next:
位置  1  2  3  4  5
数值  1  0  3  0  2

计算结束后:
dp = next


第四轮:rest=4

next:
位置  1  2  3  4  5
数值  0  4  0  5  0

计算结束后:
dp = next


最终:

dp[S] = dp[2] = 4

(3)精简版状态转移

虽然不再使用二维数组,但状态转移方程完全不变。

复制代码
for (int rest = 1; rest <= K; rest++) {

    int[] next = new int[N + 1];

    for (int cur = 1; cur <= N; cur++) {

        if (cur == 1) {
            next[cur] = dp[2];

        } else if (cur == N) {
            next[cur] = dp[N - 1];

        } else {
            next[cur] = dp[cur - 1] + dp[cur + 1];
        }
    }

    dp = next;
}

需要注意,不能在没有额外处理的情况下直接覆盖原来的 dp[cur]。因为当前行的状态计算需要使用上一行的数据。如果一边计算一边覆盖原数组,就可能破坏后面状态所依赖的旧数据。所以这里使用 next 保存当前行,计算完成后再令 dp=next。

(4)复杂度分析

每一轮仍然需要计算N个位置,一共计算K轮。因此时间复杂度仍然为:O(KN)}

但是只需要保存两个长度为N+1的一维数组。所以空间复杂度降低为:O(N)}

第四种方法没有改变第三种方法的递推关系,只是利用相邻行之间的依赖关系,减少了不必要的历史状态存储。

2.5 代码和总结对比

代码如下:

java 复制代码
package Class014;

public class Code_RobotWalk {
    public static int walkWays1(int N,int E,int S,int K){
        return f1(N,E,K,S);
    }
    //一共是1~N这么多位置,固定参数
    //最终的目标是E,固定参数
    //当前在cur位置
    //还剩rest步需要走
    //返回从cur出发,恰好走rest步到达E的方法数
    public static int f1(int N,int E,int rest,int cur){
        if (rest==0){
            //来到终止位置,记录1个路径;没有来到终止位置,0个路径
            return cur==E?1:0;
        }
        //res>0有路可走
        //当前位置是1,只能从1->2,rest-1
        if (cur==1){
            return f1(N,E,rest-1,2);
        }
        //当前位置是N,要走,rest-1
        if (cur==N){
            return f1(N,E,rest-1,N-1);
        }
        //机器人来达的是中间位置,可以往左一步,或者往右一步。
        return f1(N,E,rest-1,cur-1)+f1(N,E,rest-1,cur+1);
    }

    // 方法二:记忆化搜索
    public static int walkWays2(int N,int E,int S,int K){
        int[][] dp=new int[K+1][N+1];
        for (int i=0;i<=K;i++){
            for (int j=0;j<=N;j++){
                dp[i][j]=-1;
            }
        }
        return f2(N,E,K,S,dp);
    }

    //一共是1~N这么多位置,固定参数
    //最终的目标是E,固定参数
    //当前在cur位置
    //还剩rest步需要走
    //返回方法数
    public static int f2(int N,int E,int rest,int cur,int[][]dp){
        //如果之前算过,那么就从缓存里面取
        if (dp[rest][cur]!=-1){
            return dp[rest][cur];
        }
        //缓存没有命中的情况
        if (rest==0){
            //来到终止位置,记录1个路径;没有来到终止位置,0个路径
            dp[rest][cur]=cur==E?1:0;
            return dp[rest][cur];
        }
        //res>0有路可走
        //当前位置是1,只能从1->2,rest-1
        if (cur==1){
            dp[rest][cur]=f2(N,E,rest-1,2,dp);
        }
        //当前位置是N,要走,rest-1
        else if (cur==N){
            dp[rest][cur]=f2(N,E,rest-1,N-1,dp);
        }
        //机器人来达的是中间位置,可以往左一步,或者往右一步。
        else {
            dp[rest][cur]=f2(N,E,rest-1,cur-1,dp)+f2(N,E,rest-1,cur+1,dp);
        }
        return dp[rest][cur];
    }

    // 方法三:严格表结构动态规划
    // P:目标位置E
    // M:起始位置S
    // K:必须走的步数
    public static int dpWay(int N,int P,int M,int K){
        // dp[rest][cur]:
        // 从cur出发,剩余rest步到达P的方法数
        int[][]dp=new int[K+1][N+1];//dp[...][0]废了不用
        // 剩余0步时,只有已经到达P才有1种方法
        dp[0][P]=1;
        // 从第1行开始,依次填写到第K行
        for (int rest=1;rest<=K;rest++){
            for (int cur=1;cur<=N;cur++){
                if (cur==1){
                    // 左边界,只能向右
                    dp[rest][cur]=dp[rest-1][2];
                }else if (cur==N){
                    // 右边界,只能向左
                    dp[rest][cur]=dp[rest-1][N-1];
                }else {
                    // 中间位置,左右方法数相加
                    dp[rest][cur]=dp[rest-1][cur-1]+dp[rest-1][cur+1];
                }
            }
        }
        // 从M出发,走K步,到达P
        return dp[K][M];
    }
    // 方法四:严格表结构精简版(空间压缩)
    // 保持原来的状态转移方程不变
    // 只保留上一行和当前行
    public static int dpWayFine(int N,int P,int M,int K){
        //dp[cur]保存上一行的结果
        int[] dp=new int[N+1];
        //对应原二维表的dp[0][P]=1
        dp[P]=1;
        for (int rest=1;rest<=K;rest++){
            //当前行
            int[] next=new int[N+1];
            for (int cur=1;cur<=N;cur++){
                //左边界
                if (cur==1){
                    next[cur]=dp[2];
                }
                //右边界
                else if (cur==N){
                    next[cur]=dp[N-1];
                }
                //中间位置
                else {
                    next[cur]=dp[cur-1]+dp[cur+1];
                }
            }
            //当前行成为下一轮的上一行
            dp=next;
        }
        return dp[M];
    }

    public static void main(String [] args){
        //每组:N,S,E,K,预期结果
        int[][]tests={
                {5, 2, 4, 4, 4},
                {5, 2, 4, 2, 1},
                {5, 2, 4, 3, 0},
                {5, 2, 2, 0, 1},
                {5, 2, 4, 0, 0},
                {5, 1, 5, 4, 1},
                {2, 1, 2, 3, 1}
        };
        for (int[] t:tests){
            int N=t[0];
            int S=t[1];
            int E=t[2];
            int K=t[3];
            int expected=t[4];

            int ans1=walkWays1(N,E,S,K);
            int ans2=walkWays2(N,E,S,K);
            int ans3=dpWay(N,E,S,K);
            int ans4=dpWayFine(N,E,S,K);

            System.out.println("N="+N+",S="+S+",E="+E+",K="+K);
            System.out.println("暴力递归:" + ans1);
            System.out.println("记忆化搜索:" + ans2);
            System.out.println("严格表结构DP:" + ans3);
            System.out.println("精简版DP:" + ans4);
            if (ans1==expected && ans1==ans2 && ans2==ans3 && ans3==ans4){
                System.out.println("测试提过");
            }else {
                throw new AssertionError("四种方法结果不一致");
            }
            System.out.println("-------------------------");
        }

    }

}

运行结果:

java 复制代码
N=5,S=2,E=4,K=4
暴力递归:4
记忆化搜索:4
严格表结构DP:4
精简版DP:4
测试提过
-------------------------
N=5,S=2,E=4,K=2
暴力递归:1
记忆化搜索:1
严格表结构DP:1
精简版DP:1
测试提过
-------------------------
N=5,S=2,E=4,K=3
暴力递归:0
记忆化搜索:0
严格表结构DP:0
精简版DP:0
测试提过
-------------------------
N=5,S=2,E=2,K=0
暴力递归:1
记忆化搜索:1
严格表结构DP:1
精简版DP:1
测试提过
-------------------------
N=5,S=2,E=4,K=0
暴力递归:0
记忆化搜索:0
严格表结构DP:0
精简版DP:0
测试提过
-------------------------
N=5,S=1,E=5,K=4
暴力递归:1
记忆化搜索:1
严格表结构DP:1
精简版DP:1
测试提过
-------------------------
N=2,S=1,E=2,K=3
暴力递归:1
记忆化搜索:1
严格表结构DP:1
精简版DP:1
测试提过
-------------------------

四种方法对比:

方法 核心思想 时间复杂度 空间复杂度
暴力递归 walkWays1() 每一步尝试左右移动,递归枚举所有路径 最坏O(2^K) O(K)
记忆化搜索 walkWays2() 将已计算的递归状态记录到二维表,避免重复计算 O(KN) O(KN)
严格表结构DP dpWay() 根据递归依赖关系,自底向上填写完整二维表 O(KN) O(KN)
严格表结构精简版 dpWayFine() 当前行只依赖上一行,用滚动数组压缩空间 O(KN) O(N)

3.01背包问题

leetcode322零钱兑换。

2,7,3,5,3,......正数序列,每个代表一枚硬币,aim=10,问最少要多少枚硬币?

思路:

java 复制代码
3. 01背包问题:最少硬币数量

题目:给定正整数数组arr,每个元素代表一枚硬币,每枚硬币最多只能使用一次。给定目标金额aim,求凑出aim所需的最少硬币数量,无法凑出则返回-1。

例如:arr=[2,7,3,5,3],aim=10。
可以选择7+3=10(2枚)、2+3+5=10(3枚)、2+5+3=10(3枚),因此最优结果为2枚硬币。


一、方法一:暴力递归(try)

1. 定义递归状态

process1(arr,index,rest):使用arr[index...]中的硬币,凑出剩余金额rest所需要的最少硬币数量。

其中index表示当前考虑的硬币位置,rest表示还需要凑出的金额。返回-1表示无法凑出,返回0表示已经凑够,返回正数表示需要的最少硬币数量。

2. 递归终止条件

(1)rest<0:选择硬币后金额超过目标,返回-1。
(2)rest==0:已经凑够目标金额,不需要更多硬币,返回0。
(3)index==arr.length且rest>0:全部硬币考虑完毕,但仍未凑够金额,返回-1。

3. 每个位置的两种选择

第一种:不选择当前硬币,剩余金额不变,直接考虑下一枚硬币。

p1=process1(arr,index+1,rest)

第二种:选择当前硬币,剩余金额减少arr[index],再考虑下一枚硬币。

p2Next=process1(arr,index+1,rest-arr[index])

因为已经选择了当前硬币,所以在p2Next有效时,实际需要的硬币数量为p2Next+1。

4. 递归结果的合并

(1)p1==-1且p2Next==-1:两种选择都无法凑出,返回-1。
(2)p1==-1且p2Next有效:只能选择当前硬币,返回p2Next+1。
(3)p1有效且p2Next==-1:只能不选择当前硬币,返回p1。
(4)两种选择都有效:返回Math.min(p1,p2Next+1)。

5. 具体计算过程

arr=[2,7,3,5,3],aim=10,初始调用process1(arr,0,10),当前考虑的硬币为2。

                  f(0,10)
                 /       \
             不选择2     选择2
               /           \
           f(1,10)       f(1,8)

(1)计算左侧f(1,10),当前硬币为7。

不选择7:进入f(2,10),剩余硬币为[3,5,3]。这些硬币无法凑出10,所以f(2,10)=-1。

选择7:进入f(2,3),剩余硬币为[3,5,3]。只需要再选择一枚3,例如选择index=2的硬币3,进入f(3,0)=0,因此f(2,3)=0+1=1。

综合两种选择:不选择7的分支无效,选择7的分支需要1+1=2枚,所以f(1,10)=2,对应7+3=10。

(2)计算右侧f(1,8),当前硬币为7。

不选择7:进入f(2,8),剩余硬币为[3,5,3],可以选择3+5=8,所以f(2,8)=2。

选择7:进入f(2,1),剩余硬币为[3,5,3],无法凑出1,所以f(2,1)=-1。

因此f(1,8)=2,因为只有不选择7的分支有效。

(3)回到初始状态f(0,10)。

不选择2:p1=f(1,10)=2。
选择2:p2Next=f(1,8)=2,算上当前硬币2,实际需要2+1=3枚。

最终:f(0,10)=min(2,3)=2。

6. 时间复杂度

每枚硬币都有"选择"和"不选择"两种情况,递归树深度最大为N,因此最坏时间复杂度为O(2^N),递归调用栈空间复杂度为O(N)。


二、方法二:记忆化搜索动态规划(记搜DP)

1. 分析重复子问题

暴力递归中真正变化的参数只有index和rest。如果两个递归状态的index、rest相同,那么它们对应的剩余问题完全相同,可以直接复用计算结果。

例如arr=[2,7,3,5,3],aim=10,以下两条路径会到达同一个状态:

第一条路径:不选择2 → 选择7 → 不选择index=2的3 → 不选择5,此时index=4、rest=3。

第二条路径:选择2 → 不选择7 → 不选择index=2的3 → 选择5,此时index=4、rest=3。

两条路径最终都需要计算f(4,3)。此时只剩最后一枚3,选择它后进入f(5,0)=0,因此f(4,3)=1。

暴力递归会重复计算f(4,3),而记忆化搜索只计算第一次,随后直接读取缓存结果。

2. 建立二维缓存表

int[][] dp=new int[N+1][aim+1];

dp[index][rest]表示使用arr[index...]中的硬币,凑出rest所需要的最少硬币数量。

状态约定:-2表示尚未计算,-1表示已经计算但无法凑出,0表示已经凑够,正数表示最少硬币数量。初始化时将整个dp数组填充为-2。

3. 记忆化搜索的执行过程

以process2(arr,4,3,dp)为例:

第一次访问时,dp[4][3]=-2,说明尚未计算,需要继续递归。

不选择最后一枚3:f(5,3)=-1。
选择最后一枚3:f(5,0)=0,算上当前硬币得到0+1=1。

因此将dp[4][3]=1,并返回1。

第二次访问process2(arr,4,3,dp)时,发现dp[4][3]=1,直接返回1,不再展开递归。

4. 整体递归计算

从process2(arr,0,10,dp)开始,每次进入递归状态时,先检查dp[index][rest]。

若结果不等于-2,说明该状态已经计算过,直接返回;否则按照"选择"和"不选择"两种情况递归,并将最优结果写入dp[index][rest]。

以后再次遇到相同状态时,直接读取缓存。最终dp[0][10]=2,返回2。

5. 时间复杂度

index的取值范围为0~N,rest的有效取值范围为0~aim,因此最多存在(N+1)*(aim+1)个状态。

每个状态最多真正计算一次,每次计算只需要常数次运算,所以时间复杂度为O(N*aim),二维缓存空间复杂度为O(N*aim),另外需要O(N)的递归调用栈。


三、方法三:严格表结构动态规划(dp)

1. 从递归结构推导表结构

根据前面两种方法,process(arr,index,rest)存在两种选择:

不选择当前硬币:p1=dp[index+1][rest]。
选择当前硬币:p2Next=dp[index+1][rest-arr[index]],实际硬币数量为p2Next+1。

因此,dp[index][rest]依赖下一行的两个位置。如果两条路径都有效,则:

dp[index][rest]
=min(dp[index+1][rest],
     dp[index+1][rest-arr[index]]+1)

如果只有一条路径有效,则采用有效路径的结果;如果两条路径均不可达,则dp[index][rest]=-1。

2. 确定DP表的初始状态

arr=[2,7,3,5,3],aim=10,N=5。

建立int[][] dp=new int[6][11],其中行index为0~5,列rest为0~10。

首先填写第0列:dp[index][0]=0,因为剩余金额为0时不需要任何硬币。

然后填写最后一行:dp[N][rest]=-1(rest>0),因为已经没有剩余硬币,无法凑出正数金额。

3. 分析表格填写方向

dp[index][rest]依赖dp[index+1][rest]和dp[index+1][rest-arr[index]],因此当前行只依赖下一行。

填写方向必须从最后一行向上,即index=N-1,N-2,...,0。同一行中的状态互不依赖,因此rest可以从1递增到aim。

4. 逐行填写DP表

(1)第5行:index=5,没有剩余硬币。

rest:  0  1  2  3  4  5  6  7  8  9 10
dp[5]: 0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1


(2)第4行:index=4,当前硬币为3。

计算dp[4][3]:不选择3,dp[5][3]=-1;选择3,dp[5][0]+1=1,所以dp[4][3]=1。

dp[4]: 0 -1 -1  1 -1 -1 -1 -1 -1 -1 -1


(3)第3行:index=3,当前硬币为5,剩余硬币为[5,3]。

计算dp[3][5]:不选择5,dp[4][5]=-1;选择5,dp[4][0]+1=1,所以dp[3][5]=1。

计算dp[3][8]:不选择5,dp[4][8]=-1;选择5,dp[4][3]+1=1+1=2,所以dp[3][8]=2。

dp[3]: 0 -1 -1  1 -1  1 -1 -1  2 -1 -1


(4)第2行:index=2,当前硬币为3,剩余硬币为[3,5,3]。

计算dp[2][3]:不选择3,dp[3][3]=1;选择3,dp[3][0]+1=1,所以dp[2][3]=min(1,1)=1。

计算dp[2][6]:不选择3,dp[3][6]=-1;选择3,dp[3][3]+1=1+1=2,所以dp[2][6]=2。

计算dp[2][8]:不选择3,dp[3][8]=2;选择3,dp[3][5]+1=1+1=2,所以dp[2][8]=min(2,2)=2。

dp[2]: 0 -1 -1  1 -1  1  2 -1  2 -1 -1


(5)第1行:index=1,当前硬币为7,剩余硬币为[7,3,5,3]。

计算dp[1][7]:不选择7,dp[2][7]=-1;选择7,dp[2][0]+1=1,所以dp[1][7]=1。

计算dp[1][10]:不选择7,dp[2][10]=-1;选择7,dp[2][3]+1=1+1=2,所以dp[1][10]=2。

dp[1]: 0 -1 -1  1 -1  1  2  1  2 -1  2


(6)第0行:index=0,当前硬币为2,剩余硬币为[2,7,3,5,3]。

计算dp[0][10]:不选择2,dp[1][10]=2;选择2,dp[1][8]+1=2+1=3。

两条路径都有效,因此dp[0][10]=min(2,3)=2。

dp[0]: 0 -1  1  1 -1  1  2  1  2  2  2


5. 最终DP表

                 rest
index    0  1  2  3  4  5  6  7  8  9 10
------------------------------------------------
  5      0 -1 -1 -1 -1 -1 -1 -1 -1 -1 -1
  4      0 -1 -1  1 -1 -1 -1 -1 -1 -1 -1
  3      0 -1 -1  1 -1  1 -1 -1  2 -1 -1
  2      0 -1 -1  1 -1  1  2 -1  2 -1 -1
  1      0 -1 -1  1 -1  1  2  1  2 -1  2
  0      0 -1  1  1 -1  1  2  1  2  2  2
------------------------------------------------

最终取dp[0][aim],即dp[0][10]=2。

6. 时间复杂度

DP表共有(N+1)*(aim+1)个状态,每个状态只依赖下一行的两个位置,使用常数时间即可完成计算。

因此,时间复杂度为O(N*aim),空间复杂度为O(N*aim)。

代码:

java 复制代码
package Class014;

import java.util.Arrays;

public class Code_CoinsMin {
    public static int minCoins1(int[]arr,int aim){
        return process1(arr,0,aim);
    }
    //arr[index.....]组成出rest这么多钱,最少的硬币数量返回
    public static int process1(int[] arr,int index,int rest){
        if (rest<0){
            return -1;
        }
        if (rest==0){
            return 0;
        }
        //rest>0
        if (index==arr.length){
            return -1;
        }
        //rest>0 且也有硬币
        //第一种情况是不要当前硬币,用剩下的硬币搞定
        //第二种是用当前硬币,接着用剩下的硬币搞定
        int p1=process1(arr,index+1,rest);
        int p2Next=process1(arr,index+1,rest-arr[index]);
        if(p1==-1&&p2Next==-1){
            return -1;
        }else {
            if (p1==-1){
                return p2Next+1;
            }
            if (p2Next==-1){
                return p1;
            }
            return Math.min(p1,p2Next+1);
        }
    }

    public static int minCoins2(int[]arr,int aim){
        int[][] dp=new int[arr.length+1][aim+1];
        for (int i=0;i<=arr.length;i++){
            for (int j=0;j<=aim;j++){
                dp[i][j]=-2;
            }
        }
        return process2(arr,0,aim,dp);
    }
    //arr[index.....]组成出rest这么多钱,最少的硬币数量返回
    public static int process2(int[] arr,int index,int rest,int[][] dp){
        //此步可以视为缓存区域
        //由于无法表示-1,这些都是无效区域,故将表中低于0的位置全部标为-1
        if (rest<0){
            return -1;
        }
        if (dp[index][rest]!=-2){
            return dp[index][rest];
        }
        if (rest==0){
            dp[index][rest]=0;
        } else if (index==arr.length){
            //rest>0
            dp[index][rest]=-1;
        }else {
            int p1=process2(arr,index+1,rest,dp);
            int p2Next=process2(arr,index+1,rest-arr[index],dp);
            if(p1==-1&&p2Next==-1){
                dp[index][rest]=-1;
            }else {
                if (p1==-1){
                    dp[index][rest]=p2Next+1;
                } else if (p2Next==-1){
                    dp[index][rest]= p1;
                }
                else {
                    dp[index][rest]=Math.min(p1,p2Next+1);
                }
            }
        }
        return dp[index][rest];
    }
    public static int minCoins3(int[]arr,int aim){
        int N=arr.length;
        int[][] dp=new int[N+1][aim+1];
        for (int row=0;row<=N;row++){
            dp[row][0]=0;
        }
        for (int col=1;col<=aim;col++){
            dp[N][col]=-1;
        }
        for (int index=N-1;index>=0;index--){
            for (int rest=1;rest<=aim;rest++){
                int p1=dp[index+1][rest];
                int p2Next=-1;
                if (rest-arr[index]>=0){
                    p2Next=dp[index+1][rest-arr[index]];
                }
                if(p1==-1&&p2Next==-1){
                    dp[index][rest]= -1;
                }else {
                    if (p1==-1){
                        dp[index][rest]= p2Next+1;
                    }else if (p2Next==-1){
                        dp[index][rest]= p1;
                    }else {
                        dp[index][rest]= Math.min(p1,p2Next+1);
                    }
                }
            }
        }
        return dp[0][aim];
    }

    //for test
    public static int[] generateRandomArray(int len,int max){
        int[] arr=new int[(int) (Math.random()*len)+1];
        for (int i=0;i<arr.length;i++){
            arr[i]=(int) (Math.random()*max)+1;
        }
        return arr;
    }
    // 固定用例校验
    public static void check(int[] arr, int aim, int expected) {
        int ans1 = minCoins1(arr, aim);
        int ans2 = minCoins2(arr, aim);
        int ans3 = minCoins3(arr, aim);
        System.out.println("arr=" + Arrays.toString(arr)
                        + ", aim=" + aim
                        + " | 递归=" + ans1
                        + " 记搜=" + ans2
                        + " DP=" + ans3
        );
        if (ans1 != expected
                || ans2 != expected
                || ans3 != expected) {
            throw new AssertionError("测试失败");
        }
    }
    public static void main(String[] args) {
        System.out.println("===== 固定测试 =====");
        check(new int[]{2, 7, 3, 5, 3}, 10, 2);
        check(new int[]{5, 2, 3}, 5, 1);
        check(new int[]{2, 5}, 6, -1);
        check(new int[]{3, 3, 3}, 6, 2);
        check(new int[]{2, 2, 2}, 4, 2);
        check(new int[]{1, 2, 4}, 7, 3);
        check(new int[]{1, 2, 4}, 8, -1);
        check(new int[]{}, 0, 0);
        check(new int[]{}, 5, -1);
        System.out.println("\n===== 随机交叉测试 =====");
        int len = 10;
        int max = 10;
        int testTime = 10000;
        for (int i = 0; i < testTime; i++) {
            int[] arr = generateRandomArray(len, max);
            int aim = (int) (Math.random() * 3 * max) + max;
            int ans1 = minCoins1(arr, aim);
            int ans2 = minCoins2(arr, aim);
            int ans3 = minCoins3(arr, aim);
            // 任意两个答案不同,都报告错误
            if (ans1 != ans2 || ans2 != ans3) {
                System.out.println("ooops!");
                System.out.println(Arrays.toString(arr));
                System.out.println("aim=" + aim);
                System.out.println(ans1 + ", " + ans2 + ", " + ans3);
                return;
            }
        }
        System.out.println("通过 " + testTime + " 组随机测试");
    }
}

运行结果:

java 复制代码
===== 固定测试 =====
arr=[2, 7, 3, 5, 3], aim=10 | 递归=2 记搜=2 DP=2
arr=[5, 2, 3], aim=5 | 递归=1 记搜=1 DP=1
arr=[2, 5], aim=6 | 递归=-1 记搜=-1 DP=-1
arr=[3, 3, 3], aim=6 | 递归=2 记搜=2 DP=2
arr=[2, 2, 2], aim=4 | 递归=2 记搜=2 DP=2
arr=[1, 2, 4], aim=7 | 递归=3 记搜=3 DP=3
arr=[1, 2, 4], aim=8 | 递归=-1 记搜=-1 DP=-1
arr=[], aim=0 | 递归=0 记搜=0 DP=0
arr=[], aim=5 | 递归=-1 记搜=-1 DP=-1

===== 随机交叉测试 =====
通过 10000 组随机测试

三种方法的复杂度总结

设 N=arr.length,A=aim。

方法 时间复杂度 空间复杂度
暴力递归 minCoins1() 最坏 O(2^N) O(N)
记忆化搜索 minCoins2() O(NA) O(NA)
严格表结构DP minCoins3() O(NA) O(NA)

4.两端抽牌问题

给定一个整型数组 arr,代表数值不同的纸牌排成一条线。玩家 A 和玩家 B 依次拿走每张纸牌,规定玩家 A 先拿,玩家 B 后拿,但是每个玩家每次只能拿走最左或最右的纸牌,玩家 A 和玩家 B 都绝顶聪明。请返回最后获胜者的分数。(leetcode486,1423)

【举例】

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。

arr = 1, 100, 2。

开始时,玩家 A 不管拿 1 还是 2,玩家 B 作为绝顶聪明的人,都会把 100 拿走。玩家 B 会获胜,分数为 100。所以返回 100。

详细的分析见【leetcode】(八)暴力递归。在那一章里,我们设置了一个arrL.....R数组,接着设置了一个int f(arr,L,R)函数进行分析。具体代码如下:

java 复制代码
package Class014;

public class Code_CardsInLine {
    public static int win1(int[] arr){
        if(arr==null||arr.length==0){
            return 0;
        }
        //返回最后的获胜者
        return Math.max(f(arr,0,arr.length-1),s(arr,0,arr.length-1));
    }
    //先手函数
    //当前该你拿,arr[i,,j]
    //返回你的最好分数
    public static int f(int[] arr,int i,int j){
        if(i==j){
            return arr[j];
        }
        //i..j
        return Math.max(arr[i]+s(arr,i+1,j),arr[j]+s(arr,i,j-1));
    }
    //后手函数
    //当前不该你拿,是对方在arr[i..j]范围上拿
    //返回你的最好分数
    public static int s(int[]arr,int i,int j){
        if(i==j){
            return 0;
        }
        //i..j
        return Math.min(f(arr,i+1,j),f(arr,i,j-1));
    }
    public static int dp(int[] arr){
        if (arr==null|| arr.length==0){
            return 0;
        }
        int[][]f=new int[arr.length][arr.length];
        int[][]s=new int[arr.length][arr.length];
        for(int j=0;j<arr.length;j++){
            f[j][j]=arr[j];
            for(int i=j-1;i>=0;i--){
                f[i][j]=Math.max(arr[i]+s[i+1][j],arr[j]+s[i][j-1]);
                s[i][j]=Math.min(f[i+1][j],f[i][j-1]);
            }
        }
        return Math.max(f[0][arr.length-1],s[0][arr.length-1]);
    }

    public static void main(String []args){
        int[]arr1={1,9,10};
        System.out.println("列表:1,9,10");
        System.out.println(win1(arr1));
        System.out.println(dp(arr1));
        // A是先手,B是后手
        int A=f(arr1,0,arr1.length-1);
        int B=s(arr1,0,arr1.length-1);
        System.out.println("玩家A得分:"+A);
        System.out.println("玩家B得分:"+B);
        if(A>B){
            System.out.println("玩家A获胜");
        }else if(A<B){
            System.out.println("玩家B获胜");
        }else{
            System.out.println("双方平局");
        }

        int[]arr2={1,9,2,4,9,13,12};
        System.out.println("列表:1,9,2,4,9,13,12");
        System.out.println(win1(arr2));
        System.out.println(dp(arr2));
        // A是先手,B是后手
        int A1=f(arr2,0,arr2.length-1);
        int B1=s(arr2,0,arr2.length-1);
        System.out.println("玩家A得分:"+A1);
        System.out.println("玩家B得分:"+B1);
        if(A1>B1){
            System.out.println("玩家A获胜");
        }else if(A1<B1){
            System.out.println("玩家B获胜");
        }else{
            System.out.println("双方平局");
        }

        int[]arr3={1,9,9,1};
        System.out.println("列表:1,9,9,1");
        System.out.println(win1(arr3));
        System.out.println(dp(arr3));
        // A是先手,B是后手
        int A2=f(arr3,0,arr3.length-1);
        int B2=s(arr3,0,arr3.length-1);
        System.out.println("玩家A得分:"+A2);
        System.out.println("玩家B得分:"+B2);
        if(A2>B2){
            System.out.println("玩家A获胜");
        }else if(A2<B2){
            System.out.println("玩家B获胜");
        }else{
            System.out.println("双方平局");
        }
    }
}

运行结果:

java 复制代码
列表:1,9,10
11
11
玩家A得分:11
玩家B得分:9
玩家A获胜
列表:1,9,2,4,9,13,12
26
26
玩家A得分:24
玩家B得分:26
玩家B获胜
列表:1,9,9,1
10
10
玩家A得分:10
玩家B得分:10
双方平局

那么如何从暴力递归到动态规划?现在来展示一下代码的思路。

首先构造两张表,一张为f表,另一张为s表,对于arr3,100,4,50,f表和s表构造如下

java 复制代码
arr[3,100,4,50]
    0  1  2  3 
       f
    0 1 2 3 ->i
0         *
1
2
3
|
i

        s
    0 1 2 3 ->j
0         *
1
2
3
|
j

由于递归尝试模型,左下半区域一概无效:

java 复制代码
arr[3,100,4,50]
    0  1  2  3 
       f
    0 1 2 3 ->i
0         *
1   x 
2   x x
3   x x x
|
i

        s
    0 1 2 3 ->j
0         *
1   x
2   x x
3   x x x
|
j

由于f函数中if(i==j){ return arrj; },s函数中if(i==j){ return 0; }所以对角线上的点可以直接填写值:

java 复制代码
arr[3,100,4,50]
    0  1  2  3 
       f
    0    1    2    3    ->i
0   3              *
1   x   100
2   x    x    4
3   x    x    x    50
|
i

        s
    0    1    2    3    ->j
0   0              *
1   x    0
2   x    x    0
3   x    x    x    0
|
j

由于f函数中的上三角的区域依赖s函数的上三角区域:Math.max(arri+s(arr,i+1,j),arrj+s(arr,i,j-1));由于s函数中的上三角的区域依赖f函数的上三角区域:Math.min(f(arr,i+1,j),f(arr,i,j-1))。

而f函数矩阵中的上三角区域的某一个点的值,是由s函数矩阵中上三角区域的左边与下方的点的值决定的;而s函数矩阵中的上三角区域的某一个点的值,是由f函数矩阵中上三角区域的左边与下方的点的值决定的。比方说s函数矩阵中的(0,1)点的值是由f函数矩阵中的(0,0)和(1,1)决定的,对应的,f函数矩阵中的(0,1)点的值是由s函数矩阵中的(0,0)和(1,1)决定的。也就是说,s矩阵的上三角对角线能够推f矩阵的倒数第二条上三角对角线,f矩阵的上三角对角线能够推s矩阵的倒数第二条上三角对角线;s矩阵的倒数第二条上三角对角线能够推f矩阵的倒数第三条上三角对角线,f矩阵的倒数第二条上三角对角线能够推s矩阵的倒数第三条上三角对角线;以此类推,直到推出矩阵的顶端(0,3)。

最后,能够推出动态规划的矩阵:

java 复制代码
arr[3,100,4,50]

            f
    0    1    2    3    ->j
0   3   100   7   150
1   x   100  100  104
2   x    x    4    50
3   x    x    x    50
|
i


            s
    0    1    2    3    ->j
0   0    3   100    7
1   x    0    4    50
2   x    x    0     4
3   x    x    x     0
|
i


最终结果:

f[0][3] = 150
s[0][3] = 7

获胜者分数 = max(150,7) = 150

玩家A获胜。

5.象棋问题(马走日)

马初始在(0,0)位置,对应(x,y),给定(a,b)是马最终要去的位置,但是马一定要跳k步,问有多少种跳法。

java 复制代码
y
|
9

8

7

6

5

4

3

2

1

0
    0    1    2    3    4    5    6    7    8    ->x

马初始在(0,0)位置,对应(x,y),给定(a,b)是马最终要去的位置,但是马一定要跳k步,问有多少种跳法。

分析,采用倒推的方法,也就是从(x,y)到(0,0),每一步都有8种走法。

java 复制代码
y
|
9

8

7

6

5                        a        a
 
4                  a                   a

3                            p

2                  a                   a
                
1                       a         a

0
    0    1    2    3    4    5    6    7    8    ->x


比如,从(0,0)出发,,必须走8步,现在走了7步,到达a这个点
那么对于p(x,y)这个目标,再走一步就能到达,那么a的位置有多少种可能?

由于马走日的特性,我们发现有8种可能性。

举例:

java 复制代码
y
|
9

8                           a          a           
    
7                       a                   a   

6                                 p
                                  |
5                       a         |         a
                                  |
4                            a    |    a
                                  |    
3                                 |
                                  |
2                                 |
                                  |  
1                                 |
                                  |
0                                 |
    0    1    2    3    4    5    6    7    8    ->x


从(0,0)出发,必须走10步,到达(6,6)这个位置,那么有多少种走法?
分别对应(x-1,y+2)(x+1,y+2)(x+2,y+1)(x+2,y-1)
        (x+1,y-2)(x-1,y-2)(x-2,y-1)(x-2,y+1)

也就是说:

第0层:h=0,初始化起点。

第1层:h=1,根据第0层计算。

第2层:h=2,根据第1层计算。

第3层:h=3,根据第2层计算。

......

第K层:h=K,根据第K-1层计算。

最后建立一张小三维表来说明:

java 复制代码
y                               
|                                  
5             a        a
                                  
4       a                    a
                                     
3                  p              
                                  
2       a          |         a   

1             a    |    a         
                    
0                  |               
    0    1    2    3    4    5 ->x    

目标是(3,3),当k=3时,f(3,3,3),倒数一步为f(2,5,2),f(1,4,2)
那么如何从(0,0)实现两步就达到f(2,5,2)或f(1,4,2)?

最后建立了一个三维立方体,如图所示:

注意:这里只用两步就能实现。

示例代码:

java 复制代码
package Class014;

public class Code_HorseJump {
    public static int getWay(int x,int y,int k){
        return process(x,y,k);
    }
    //跳过了多少步
    //x,y在的位置
    //从(0,0)出发跳必须跳step步之后,来到(x,y)位置的方法数有多少种
    public static int process(int x,int y,int step){
        if (x<0||x>8||y<0||y>9){
            return 0;
        }
        if (step==0){//已经没有步数,跳完了,不能再动了
            return (x==0&&y==0)?1:0;
        }
        //要到达的位置不越界,也有步数可以跳
        return process(x-1,y+2,step-1)
                +process(x+1,y+2,step-1)
                +process(x+2,y+1,step-1)
                +process(x+2,y-1,step-1)
                +process(x+1,y-2,step-1)
                +process(x-1,y-2,step-1)
                +process(x-2,y-1,step-1)
                +process(x-2,y+1,step-1);
    }
    public static int dpWays(int x,int y,int step){
        if (x<0||x>8||y<0||y>9||step<0){
            return 0;
        }
        int[][][] dp=new int[9][10][step+1];
        dp[0][0][0]=1;
        for (int h=1;h<=step;h++){//层
            //枚举每一层
            for (int r=0;r<9;r++){
                for (int c=0;c<10;c++){
                    dp[r][c][h]+=getValue(dp,r-1,c+2,h-1);
                    dp[r][c][h]+=getValue(dp,r+1,c+2,h-1);
                    dp[r][c][h]+=getValue(dp,r+2,c+1,h-1);
                    dp[r][c][h]+=getValue(dp,r+2,c-1,h-1);
                    dp[r][c][h]+=getValue(dp,r+1,c-2,h-1);
                    dp[r][c][h]+=getValue(dp,r-1,c-2,h-1);
                    dp[r][c][h]+=getValue(dp,r-2,c-1,h-1);
                    dp[r][c][h]+=getValue(dp,r-2,c+1,h-1);
                }
            }
        }
        return dp[x][y][step];
    }

    //为了检验枚举的顺序性对答案毫无影响,只是层之间存在依赖关系,设置dpWays01函数
    public static int dpWays01(int x,int y,int step){
        if (x<0||x>8||y<0||y>9||step<0){
            return 0;
        }
        int[][][] dp=new int[9][10][step+1];
        dp[0][0][0]=1;
        for (int h=1;h<=step;h++){//层
            //枚举每一层
            for (int r=8;r>=0;r--){
                for (int c=0;c<10;c++){
                    dp[r][c][h]+=getValue(dp,r-1,c+2,h-1);
                    dp[r][c][h]+=getValue(dp,r+1,c+2,h-1);
                    dp[r][c][h]+=getValue(dp,r+2,c+1,h-1);
                    dp[r][c][h]+=getValue(dp,r+2,c-1,h-1);
                    dp[r][c][h]+=getValue(dp,r+1,c-2,h-1);
                    dp[r][c][h]+=getValue(dp,r-1,c-2,h-1);
                    dp[r][c][h]+=getValue(dp,r-2,c-1,h-1);
                    dp[r][c][h]+=getValue(dp,r-2,c+1,h-1);
                }
            }
        }
        return dp[x][y][step];
    }
    //相当于一个全是零的立方体,被分成许多层(step是层数)许多step平面堆叠成立方体(x,y,step)
    public static int getValue(int[][][] dp,int row,int col,int step){
        //越界就累加0
        if (row<0||row>8||col<0||col>9){
            return 0;
        }
        //不越界就累加它原来的值
        return dp[row][col][step];
    }
    public static void main(String [] args){
        int x=7;
        int y=7;
        int step=10;
        System.out.println(getWay(x,y,step));
        System.out.println(dpWays(x,y,step));
        System.out.println(dpWays01(x,y,step));
    }
}

运行结果:

java 复制代码
515813
515813
515813

方法对比:

方法 时间复杂度 空间复杂度
暴力递归 process() 最坏 O(8^K) O(K)
严格表结构DP dpWays() O(9*10*K*8) O(9*10*(K+1))
改变遍历顺序 dpWays01() 与 dpWays() 相同 与 dpWays() 相同

6.死亡越界

给定一个 N×M 的二维网格,Bob初始位于 (i,j)。每一步可以等概率地向上、下、左、右移动一格,共移动K步。

如果Bob在移动过程中越过网格边界,则立即死亡。求Bob完成K步后仍然存活的概率。

例如:N=10,M=10,i=3,j=2,K=5,最终存活概率为 945/1024。

代码:

java 复制代码
package Class014;

public class Code_BobDie {
    //现在bob在row行,col列
    //区域是N*M的
    //如果bob在1走rest步的过程中2,都没有出圈,那么在走完的时候,bob就获得一个生存点
    //返回bob的生存点数
    public static int live(int N,int M,int row,int col,int rest){
        if (row<0||row>=N||col<0||col>=M){
            return 0;
        }
        if (rest==0){
            return 1;
        }
        int point=0;
        point+=live(N,M,row+1,col,rest-1);
        point+=live(N,M,row-1,col,rest-1);
        point+=live(N,M,row,col-1,rest-1);
        point+=live(N,M,row,col+1,rest-1);
        return point;
    }
    public static String bob1(int N,int M,int i,int j,int K){
        //所有可能的场景
        long all=(long) Math.pow(4,K);
        //存活下来的场景
        long live=process(N,M,i,j,K);
        //计算最大公约数,便于huaj
        long gcd=gcd(all,live);
        return String.valueOf((live/gcd)+"/"+(all/gcd));
    }
    //N*M的区域,Bob在(row,col)位置,走rest步之后,获得的生存方法数
    public static long process (int N,int M,int row,int col,int rest){
        if (row<0||row==N||col<0||col==M){
            return 0;
        }
        //row,col没越界
        if(rest==0){
            return 1;
        }
        //还没走完,row,col也没越界,上下左右移动存活的方法数累加
        //得到总体生存的方法数
        long live=process(N,M,row-1,col,rest-1);
        live+=process(N,M,row+1,col,rest-1);
        live+=process(N,M,row,col-1,rest-1);
        live+=process(N,M,row,col+1,rest-1);
        return live;
    }
    public static long gcd(long m,long n){
        return n==0?m:gcd(n,m%n);
    }
    public static String bob2(int N,int M,int i,int j,int K){
        int[][][]dp=new int[N+2][M+2][K+1];
        for (int row=1;row<=N;row++){
            for (int col=1;col<=M;col++){
                dp[row][col][0]=1;
            }
        }
        for (int rest=1;rest<=K;rest++){
            for (int row=1;row<=N;row++){
                for (int col=1;col<=M;col++) {
                    dp[row][col][rest]=dp[row-1][col][rest-1];
                    dp[row][col][rest]+=dp[row+1][col][rest-1];
                    dp[row][col][rest]+=dp[row][col-1][rest-1];
                    dp[row][col][rest]+=dp[row][col+1][rest-1];
                }
            }
        }
        long all=(long) Math.pow(4,K);
        long live=dp[i+1][j+1][K];
        long gcd=gcd(all,live);
        return String.valueOf((live/gcd)+"/"+(all/gcd));
    }
    public static void main(String[] args){
        int N=10;
        int M=10;
        int i=3;
        int j=2;
        int K=5;
        System.out.println(bob1(N,M,i,j,K));
        System.out.println(bob2(N,M,i,j,K));
    }
}

运行结果:

java 复制代码
945/1024
945/1024

分析:

方法一:暴力递归

  1. 定义递归状态

    process(N,M,row,col,rest)

表示Bob当前处于 (row,col),还需要走 rest 步,在整个过程中不越界的合法走法数量。

  1. 递归终止条件

    (1)如果row<0、row>=N、col<0或col>=M,说明已经越界,返回0。
    (2)如果rest==0且仍在网格内,说明成功完成所有步数,返回1。

  2. 递归尝试过程

Bob每次可以向上下左右四个方向移动,因此:

复制代码
process(row,col,rest)
    = process(row-1,col,rest-1)
    + process(row+1,col,rest-1)
    + process(row,col-1,rest-1)
    + process(row,col+1,rest-1)

每条路径的计算结果为0或正整数,四个方向的合法方法数相加,就是当前位置剩余 rest 步的总存活方法数。

  1. 存活概率的计算

每一步有4种等概率选择,K步共有:

种可能的移动序列。

设 live 为不会越界的合法移动序列数,则:

例如K=5:

复制代码
全部可能走法:4^5=1024
存活方法数量:945

存活概率=945/1024≈92.29%

使用 gcd(all,live) 求最大公约数,可以将最终概率化为最简分数。

  1. 复杂度

每一步递归最多展开4个分支,递归深度为K,因此最坏时间复杂度为 O(4^K),递归栈空间复杂度为 O(K)。由于相同的 (row,col,rest) 可能重复出现,因此存在大量重复计算。

方法二:严格表结构动态规划

  1. 建立三维DP表

递归过程中变化的参数只有行、列和剩余步数,因此可以建立:

复制代码
int[][][] dp = new int[N+2][M+2][K+1];

其中 dp[row][col][rest] 表示从对应位置出发,继续走 rest 步仍然存活的方法数。

为了简化边界处理,原代码把实际网格坐标 (i,j) 映射为DP数组中的 (i+1,j+1),在网格外增加一圈值为0的边界。

  1. 初始化第0层

    for (int row=1; row<=N; row++) {
    for (int col=1; col<=M; col++) {
    dp[row][col][0]=1;
    }
    }

当剩余步数为0时,只要Bob还在网格内,就有1种存活方式。因此第0层的所有有效位置均为1,网格外的边界保持0。

  1. 状态转移

当前状态依赖上一层的四个相邻位置:

复制代码
dp[row][col][rest]
    = dp[row-1][col][rest-1]
    + dp[row+1][col][rest-1]
    + dp[row][col-1][rest-1]
    + dp[row][col+1][rest-1]

如果某个相邻位置越界,对应的DP值为0,不会增加存活方法数。

  1. 逐层填写DP表

从 rest=1 开始,依次计算到第K层。每一层都由上一层推导得到,同一层内部的状态互不依赖,因此行列的遍历顺序不会影响结果。

以 N=10,M=10,i=3,j=2,K=5 为例,实际起点对应DP数组中的 (4,3)。

复制代码
第0层:dp[4][3][0] = 1
第1层:dp[4][3][1] = 4
第2层:dp[4][3][2] = 16
第3层:dp[4][3][3] = 63
第4层:dp[4][3][4] = 245
第5层:dp[4][3][5] = 945

其中第5层的计算为:

复制代码
dp[4][3][5]
    = dp[3][3][4]
    + dp[5][3][4]
    + dp[4][2][4]
    + dp[4][4][4]

    = 236+246+209+254
    = 945

因此,最终返回:

复制代码
long live = dp[i+1][j+1][K];

存活方法数为945,全部移动序列数为1024,得到:

  1. 复杂度

三维DP表共有约 N×M×K 个有效状态,每个状态只需要累加4个相邻位置,因此时间复杂度为 O(NMK),空间复杂度为 O(NMK)。

需要注意:原代码中的 bob2() 使用 int[][][] 存储路径数,当K较大时可能发生整数溢出,应改用 long[][][];对于更大的K,还需要考虑

两种算法对比

方法 时间复杂度 空间复杂度
暴力递归 bob1() O(4^K) O(K)
三维动态规划 bob2() O(NMK) O(NMK)

本题与前面的象棋马跳跃问题类似,都是通过累加不同方向的合法路径数来完成状态转移。区别在于:象棋问题每次有8种移动方向,并要求到达指定目标;Bob死亡越界问题每次有4种移动方向,只要求整个移动过程中不越界,最后再用存活方法数除以全部可能移动序列数,计算存活概率。

7.零钱兑换

给一个数组arr\[\],都是正数,无重复值,一个位置的值代表一个面值的货币,比如arr3,5,10,2,要求拿任意个货币,凑够aim=1000的零钱数进行兑换,求一共有多少种方法。(leetcode322,518)

原始暴力递归代码:

java 复制代码
package Class014;
public class Code_CoinsWays {
    //arr里都是正数,没有重复值,每一个值代表一种货币,每一种都可以用无限张
    //最终要找零钱数是aim,
    //找零钱方法数返回
    public static int way1(int[] arr,int aim){
        return process(arr,0,aim);
    }
    //可以自由使用arr[index...]所有的货币
    //需要搞定的钱数是rest
    //返回找零的方法数
    public static int process(int[]arr,int index,int rest){
        //basecase
        //没有方法可以选了,要求搞定的是0,则返回1;要求搞定的不是0,则返回0
        if (index==arr.length){
            return rest==0?1:0;
        }
        //arr[index]0张1张。。。不要超过rest的钱数
        int ways=0;
        for (int zhang=0;arr[index]*zhang<=rest;zhang++){
            ways+=process(arr,index+1,rest-arr[index]*zhang);
        }
        return ways;
    }
}

这种初始方法过于暴力,现在对它进行分析:

java 复制代码
[3,5,10,1]
 0,1, 2,3

aim=1000

f(0,1000)
0代表3及其之后的货币可选,1000代表需要搞定的钱数

3:0张->f(1,1000)
3:1张->f(1,997)
3:2张->f(1,994)
......
3:333张->f(1,1)

以此类推

那么如何改造呢?基本情况,index==arr.length,确定一个中间位置(2,5),分析它需要依赖哪些点

java 复制代码
[3,5,1,2]
 0,1,2,3

aim=10

    0    1    2    3    4    5    6    7    8    9    10
0                                                     *

1

2                            ?

3   n    n    n    n    n    n

4   1    0    0    0    0    0    0    0    0    0    0    

由于代码中的for (int zhang=0;arrindex*zhang<=rest;zhang++){ways+=process(arr,index+1,rest-arrindex*zhang);}

我们发现,一块钱货币做选择的时候,?(2,5)位置依赖index+1行,也就是第3行,rest-arrindex*zhang列,张数是1,index=1,也就是(3,0)(3,1)(3,2)(3,3)(3,4)(3,5)这些点。

java 复制代码
[3,5,1,2]
 0,1,2,3

aim=10

    0    1    2    3    4    5    6    7    8    9    10
0                                                     *

1                                           ?

2                  1                        0

3   

4   1    0    0    0    0    0    0    0    0    0    0    

五块钱货币做选择的时候,0张的时候对应(2,8),1张的时候对应(2,3)。其他位置以此类推。

不优化枚举:

java 复制代码
 public static int way2(int[] arr,int aim){
        if (arr==null||arr.length==0){
            return 0;
        }
        int N=arr.length;
        int[][]dp=new int[N+1][aim+1];
        dp[N][0]=1;
        for (int index=N-1;index>=0;index--){
            for (int rest=0;rest<=aim;rest++){
                int ways=0;
                for (int zhang=0;arr[index]*zhang<=rest;zhang++){
                    ways+=dp[index+1][rest-arr[index]*zhang];
                }
                dp[index][rest]=ways;
            }
        }
        return dp[0][aim];
    }

不优化时,枚举行为的时间复杂度=O(N*aim)*O(aim)=O(N*aim^2),那么枚举行为有必要吗?

如图所示,我们发现对于?位置j和x位置它们的方法数为:?=a+b+c+d+...,x=b+c+d+...,,?位置j和x位置只差了一个偏移量,既然算x时已经加过了,那么算?的方法数时只需要把x的方法数叠加,即?=x+a就行了。也就是一种斜率优化,用一种枚举行为用临近方法替代(四边形不等式)。

java 复制代码
    0    1    2    3    4    5    6    7    8    9    10
0                                                     *

1                                      x    ?

2                            d    c    b    a

3   

4   1    0    0    0    0    0    0    0    0    0    0    

于是得到了第三种方法:

java 复制代码
public static int way3(int[] arr,int aim){
        if (arr==null||arr.length==0){
            return 0;
        }
        int N=arr.length;
        int[][]dp=new int[N+1][aim+1];
        dp[N][0]=1;
        for (int index=N-1;index>=0;index--){
            for (int rest=0;rest<=aim;rest++){
                //自己本行下一行的格子
                dp[index][rest]=dp[index+1][rest];
                //不越界的情况下
                if (rest-arr[index]>=0){
                    //自己本行减去一个面值的格子
                    dp[index][rest]+=dp[index][rest-arr[index]];
                }
            }
        }
        return dp[0][aim];
    }

优化后,枚举行为的时间复杂度=O(N*aim),时间复杂度明显下降。

代码:

java 复制代码
package Class014;
public class Code_CoinsWays {
    //arr里都是正数,没有重复值,每一个值代表一种货币,每一种都可以用无限张
    //最终要找零钱数是aim,
    //找零钱方法数返回
    public static int way1(int[] arr,int aim){
        return process(arr,0,aim);
    }
    //可以自由使用arr[index...]所有的货币
    //需要搞定的钱数是rest
    //返回找零的方法数
    public static int process(int[]arr,int index,int rest){
        //basecase
        //没有方法可以选了,要求搞定的是0,则返回1;要求搞定的不是0,则返回0
        if (index==arr.length){
            return rest==0?1:0;
        }
        //arr[index]0张1张。。。不要超过rest的钱数
        int ways=0;
        for (int zhang=0;arr[index]*zhang<=rest;zhang++){
            ways+=process(arr,index+1,rest-arr[index]*zhang);
        }
        return ways;
    }
    public static int way2(int[] arr,int aim){
        if (arr==null||arr.length==0){
            return 0;
        }
        int N=arr.length;
        int[][]dp=new int[N+1][aim+1];
        dp[N][0]=1;
        for (int index=N-1;index>=0;index--){
            for (int rest=0;rest<=aim;rest++){
                int ways=0;
                for (int zhang=0;arr[index]*zhang<=rest;zhang++){
                    ways+=dp[index+1][rest-arr[index]*zhang];
                }
                dp[index][rest]=ways;
            }
        }
        return dp[0][aim];
    }

    public static int way3(int[] arr,int aim){
        if (arr==null||arr.length==0){
            return 0;
        }
        int N=arr.length;
        int[][]dp=new int[N+1][aim+1];
        dp[N][0]=1;
        for (int index=N-1;index>=0;index--){
            for (int rest=0;rest<=aim;rest++){
                //自己本行下一行的格子
                dp[index][rest]=dp[index+1][rest];
                //不越界的情况下
                if (rest-arr[index]>=0){
                    //自己本行减去一个面值的格子
                    dp[index][rest]+=dp[index][rest-arr[index]];
                }
            }
        }
        return dp[0][aim];
    }

    //for test
    public static int[] generateRandomArray(int len,int max){
        int[] arr=new int[(int) (Math.random()*len)+1];
        for (int i=0;i<arr.length;i++){
            arr[i]=(int) (Math.random()*max)+1;
        }
        return arr;
    }
    public static void main(String [] args){
        System.out.println("hi");
        int len=10;
        int max=10;
        int testTime=10000;
        for (int i=0;i<testTime;i++){
            int[] arr=generateRandomArray(len,max);
            int aim=(int) (Math.random()*3*max)+max;
            if (way1(arr,aim)!=way2(arr,aim)||way2(arr,aim)!=way3(arr,aim)){
                System.out.println("ooops");
                break;
            }
        }
    }
}

运行结果:

java 复制代码
hi

8.动态规划思路总结

尝试方法(左..右,范围),应对七成题目->记忆化搜索(列表分析表格的格子之间的关系)->严格表结构的动态规划->精细表结构的动态规划。

那么如何知晓哪一种尝试是好的?

(1)单可变参数维度(对于int整型来说,f(int)可以看作0维可变参数,即一个点,维度很低。如果函数变成f(int\[\]a,int x),这样维度就特别高,变成了1维,可能性猛增。前者的面试题目占绝对多数。)

(2)可变参数的个数(随个数上升,依次为线性数组,表格,立方体,可变参数越少,动态规划修改起来越简单)

相关推荐
旖旎夜光2 小时前
LeetCode 215: 数组中的第K个最大元素 (快速排序) —— 题解
c++·学习·算法·leetcode·力控
技术胖2 小时前
奎爷巡演 AIGC 视频教程 文档
算法
德彪稳坐倒骑驴2 小时前
Leetcode力扣刷题记录与题解
算法·leetcode·职场和发展
lzhdim8 小时前
AI的分类
人工智能·算法·机器学习·分类·数据挖掘
凤凰院凶涛QAQ11 小时前
《Java版数据结构 & 集合类剖析》Map & Set & 反射:“从哈希碰撞到红黑树转换,反射帮你扒开 HashMap 的桶数组看个究竟”
java·数据结构·算法
H.莓飛11 小时前
【数据结构】排序_OJ题(一):计数、桶与排序后的处理
java·开发语言·数据结构
w_zero_one13 小时前
算法--模拟
java·数据结构·算法
秋天的怀念_David14 小时前
算法一千零一夜 · 第十三夜 幻方,武侠世界里的数学迷宫
java·程序人生·算法
秋天的怀念_David14 小时前
算法一千零一夜 · 第十四夜 幻方与奇妙的二进制
java·程序人生·算法