【基础算法】关于如何学习动态规划

872 阅读8分钟

线性 DP

动态规划一直都是面试高频,而动态规划中的线性 DP 更是重中之重。

今天将用 44 道题,带大家体会下线性 DP 该如何突破。

688. 骑士在棋盘上的概率

在一个 n×nn \times n 的国际象棋棋盘上,一个骑士从单元格 (row,column)(row, column) 开始,并尝试进行 kk 次移动。行和列是 从 00 开始 的,所以左上单元格是 (0,0)(0,0) ,右下单元格是 (n1,n1)(n - 1, n - 1)

象棋骑士有 88 种可能的走法,如下图所示。每次移动在基本方向上是两个单元格,然后在正交方向上是一个单元格。

每次骑士要移动时,它都会随机从 88 种可能的移动中选择一种(即使棋子会离开棋盘),然后移动到那里。

骑士继续移动,直到它走了 kk 步或离开了棋盘。

返回 骑士在棋盘停止移动后仍留在棋盘上的概率 。

示例 1:

输入: n = 3, k = 2, row = 0, column = 0

输出: 0.0625

解释: 有两步(到(1,2),(2,1))可以让骑士留在棋盘上。
在每一个位置上,也有两种移动可以让骑士留在棋盘上。
骑士留在棋盘上的总概率是0.0625。

提示:

  • 1<=n<=251 <= n <= 25
  • 0<=k<=1000 <= k <= 100
  • 0<=row,column<=n0 <= row, column <= n
线性 DP

定义 f[i][j][p]f[i][j][p] 为从位置 (i,j)(i, j) 出发,使用步数不超过 pp 步,最后仍在棋盘内的概率。

不失一般性考虑 f[i][j][p]f[i][j][p] 该如何转移,根据题意,移动规则为「八连通」,对下一步的落点 (nx,ny)(nx, ny) 进行分情况讨论即可:

  • 由于计算的是仍在棋盘内的概率,因此对于 (nx,ny)(nx, ny) 在棋盘外的情况,无须考虑;
  • 若下一步的落点 (nx,ny)(nx, ny) 在棋盘内,其剩余可用步数为 p1p - 1,则最后仍在棋盘的概率为 f[nx][ny][p1]f[nx][ny][p - 1],则落点 (nx,ny)(nx, ny)f[i][j][p]f[i][j][p] 的贡献为 f[nx][ny][p1]×18f[nx][ny][p - 1] \times \frac{1}{8},其中 18\frac{1}{8} 为事件「(i,j)(i, j) 走到 (nx,ny)(nx, ny)」的概率(八连通移动等概率发生),该事件与「到达 (nx,ny)(nx, ny) 后进行后续移动并留在棋盘」为相互独立事件。

最终的 f[i][j][p]f[i][j][p] 为「八连通」落点的概率之和,即有:

f[i][j][p]=f[nx][ny][p1]×18f[i][j][p] = \sum {f[nx][ny][p - 1] \times \frac{1}{8}}

代码:

class Solution {
    int[][] dirs = new int[][]{{-1,-2},{-1,2},{1,-2},{1,2},{-2,1},{-2,-1},{2,1},{2,-1}};
    public double knightProbability(int n, int k, int row, int column) {
        double[][][] f = new double[n][n][k + 1];
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                f[i][j][0] = 1;
            }
        }
        for (int p = 1; p <= k; p++) {
            for (int i = 0; i < n; i++) {
                for (int j = 0; j < n; j++) {
                    for (int[] d : dirs) {
                        int nx = i + d[0], ny = j + d[1];
                        if (nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
                        f[i][j][p] += f[nx][ny][p - 1] / 8;
                    }
                }
            }
        }
        return f[row][column][k];
    }
}
  • 时间复杂度:令某个位置可联通的格子数量 C=8C = 8,复杂度为 O(n2×k×C)O(n^2 \times k \times C)
  • 空间复杂度:O(n2×k)O(n^2 \times k)

650. 只有两个键的键盘

最初记事本上只有一个字符 'A' 。你每次可以对这个记事本进行两种操作:

  • Copy All(复制全部):复制这个记事本中的所有字符(不允许仅复制部分字符)。
  • Paste(粘贴):粘贴 上一次 复制的字符。

给你一个数字 n ,你需要使用最少的操作次数,在记事本上输出 恰好 n 个 'A' 。返回能够打印出 n 个 'A' 的最少操作次数。

示例 1:

输入:3

输出:3

解释:
最初, 只有一个字符 'A'。
第 1 步, 使用 Copy All 操作。
第 2 步, 使用 Paste 操作来获得 'AA'。
第 3 步, 使用 Paste 操作来获得 'AAA'

提示:

  • 1<=n<=10001 <= n <= 1000
动态规划

定义 f[i][j]f[i][j] 为经过最后一次操作后,当前记事本上有 ii 个字符,粘贴板上有 jj 个字符的最小操作次数。

由于我们粘贴板的字符必然是经过 Copy All 操作而来,因此对于一个合法的 f[i][j]f[i][j] 而言,必然有 j<=ij <= i

不失一般性地考虑 f[i][j]f[i][j] 该如何转移:

  • 最后一次操作是 Paste 操作:此时粘贴板的字符数量不会发生变化,即有 f[i][j]=f[ij][j]+1f[i][j] = f[i - j][j] + 1

  • 最后一次操作是 Copy All 操作:那么此时的粘贴板的字符数与记事本上的字符数相等(满足 i=ji = j),此时的 f[i][j]=min(f[i][x]+1),0x<if[i][j] = \min(f[i][x] + 1), 0 \leq x < i

我们发现最后一个合法的 f[i][j]f[i][j](满足 i=ji = j)依赖与前面 f[i][j]f[i][j](满足 j<ij < i)。

因此实现上,我们可以使用一个变量 minmin 保存前面转移的最小值,用来更新最后的 f[i][j]f[i][j]

再进一步,我们发现如果 f[i][j]f[i][j] 的最后一次操作是由 Paste 而来,原来粘贴板的字符数不会超过 i/2i / 2,因此在转移 f[i][j]f[i][j](满足 j<ij < i)时,其实只需要枚举 [0,i/2][0, i/2] 即可。

代码:

class Solution {
    int INF = 0x3f3f3f3f;
    public int minSteps(int n) {
        int[][] f = new int[n + 1][n + 1];
        for (int i = 0; i <= n; i++) {
            for (int j = 0; j <= n; j++) {
                f[i][j] = INF;
            }
        }
        f[1][0] = 0; f[1][1] = 1;
        for (int i = 2; i <= n; i++) {
            int min = INF;
            for (int j = 0; j <= i / 2; j++) {
                f[i][j] = f[i - j][j] + 1;
                min = Math.min(min, f[i][j]);
            }
            f[i][i] = min + 1;
        }
        int ans = INF;
        for (int i = 0; i <= n; i++) ans = Math.min(ans, f[n][i]);
        return ans;
    }
}
  • 时间复杂度:O(n2)O(n^2)
  • 空间复杂度:O(n2)O(n^2)
数学

如果我们将「11Copy All + xxPaste」看做一次“动作”的话。

那么 一次“动作”所产生的效果就是将原来的字符串变为原来的 x+1x + 1

最终的最小操作次数方案可以等价以下操作流程:

  1. 起始对长度为 11 的记事本字符进行 11Copy All + k11k_1 - 1Paste 操作(消耗次数为 k1k_1,得到长度为 k1k_1 的记事本长度);
  2. 对长度为为 k1k_1 的记事本字符进行 11Copy All + k21k_2 - 1Paste 操作(消耗次数为 k1+k2k_1 + k_2,得到长度为 k1k2k_1 * k_2 的记事本长度) ...

最终经过 kk 次“动作”之后,得到长度为 nn 的记事本长度,即有:

n=k1k2...kxn = k_1 * k_2 * ... * k_x

问题转化为:如何对 nn 进行拆分,可以使得 k1+k2+...+kxk_1 + k_2 + ... + k_x 最小。

对于任意一个 kik_i(合数)而言,根据定理 ab>=a+ba * b >= a + b 可知进一步的拆分必然不会导致结果变差。

因此,我们只需要使用「试除法」对 nn 执行分解质因数操作,累加所有的操作次数,即可得到答案。

代码:

class Solution {
    public int minSteps(int n) {
        int ans = 0;
        for (int i = 2; i * i <= n; i++) {
            while (n % i == 0) {
                ans += i;
                n /= i;
            }
        }
        if (n != 1) ans += n;
        return ans;
    }
}
  • 时间复杂度:O(n)O(\sqrt{n})
  • 空间复杂度:O(1)O(1)
打表

我们发现,对于某个 minSteps(i)minSteps(i) 而言为定值,且数据范围只有 10001000,因此考虑使用打表来做。

代码:

class Solution {
    static int N = 1010;
    static int[] g = new int[N];
    static {
        for (int k = 2; k < N; k++) {
            int cnt = 0, n = k;
            for (int i = 2; i * i <= n; i++) {
                while (n % i == 0) {
                    cnt += i;
                    n /= i;
                }
            }
            if (n != 1) cnt += n;
            g[k] = cnt;
        }
        // System.out.println(Arrays.toString(g)); // 输出打表结果
    }
    public int minSteps(int n) {
        return g[n];
    }
}
  • 时间复杂度:将打表逻辑配合 static 交给 OJ 执行,复杂度为 O(CC)O(C * \sqrt{C})CC 为常数,固定为 10101010;将打表逻辑放到本地执行,复杂度为 O(1)O(1)
  • 空间复杂度:O(C)O(C)

978. 最长湍流子数组

A 的子数组 A[i],A[i+1],...,A[j]A[i], A[i+1], ..., A[j] 满足下列条件时,我们称其为湍流子数组:

  • 若 i<=k<ji <= k < j,当 kk 为奇数时,A[k]>A[k+1]A[k] > A[k+1],且当 kk 为偶数时,A[k]<A[k+1]A[k] < A[k+1]
  • 若 i<=k<ji <= k < j,当 kk 为偶数时,A[k]>A[k+1]A[k] > A[k+1] ,且当 kk 为奇数时,A[k]<A[k+1]A[k] < A[k+1]

也就是说,如果比较符号在子数组中的每个相邻元素对之间翻转,则该子数组是湍流子数组。

返回 A 的最大湍流子数组的长度。

示例 1:

输入:[9,4,2,10,7,8,8,1,9]
输出:5
解释:(A[1] > A[2] < A[3] > A[4] < A[5])

提示:

  • 1<=A.length<=400001 <= A.length <= 40000
  • 0<=A[i]<=1090 <= A[i] <= 10^9
基本思路

本题其实是要我们求最长一段呈 ↗ ↘ ↗ ↘ 或者 ↘ ↗ ↘ ↗ 形状的数组长度。

看一眼数据范围,有 4000040000,那么枚举起点和终点,然后对划分出来的子数组检查是否为「湍流子数组」的朴素解法就不能过了。

朴素解法的复杂度为 O(n3)O(n^3) ,直接放弃朴素解法。

复杂度往下优化,其实就 O(n)O(n) 的 DP 解法了。

动态规划

至于 DP 如何分析,通过我们会先考虑一维 DP 能否求解,不行再考虑二维 DP。

对于本题,由于每个位置而言,能否「接着」上一个位置形成「湍流」,取决于上一位置是由什么形状而来。

举个例子,对于样例 [3,4,2],从 4 -> 2 已经确定是 状态,那么对于 2 这个位置能否「接着」4 形成「湍流」,要求 4 必须是由 而来。

因此我们还需要记录某一位是如何来的( 还是 ),需要使二维 DP 来求解 ~

我们定义 f[i][j]f[i][j] 代表以位置 ii 为结尾,而结尾状态为 jj 的最长湍流子数组长度(0:上升状态 / 1:下降状态)

PS. 这里的状态定义我是猜的,这其实是个技巧。通常我们做 DP 题,都是先猜一个定义,然后看看这个定义是否能分析出状态转移方程帮助我们「不重不漏」的枚举所有的方案。一般我是直接根据答案来猜定义,这里是求最长子数组长度,所以我猜一个 f(i,j) 代表最长湍流子数组长度

不失一般性考虑 f[i][j]f[i][j] 该如何求解,我们知道位置 ii 是如何来是唯一确定的(取决于 arr[i]arr[i]arr[i1]arr[i - 1] 的大小关系),而只有三种可能性:

  • arr[i1]<arr[i]arr[i - 1] < arr[i]:该点是由上升而来,能够「接着」的条件是 i1i - 1 是由下降而来。则有:f[i][0]=f[i1][1]+1f[i][0] = f[i - 1][1] + 1
  • arr[i1]>arr[i]arr[i - 1] > arr[i]:改点是由下降而来,能够「接着」的条件是 i1i - 1 是由上升而来。则有:f[i][1]=f[i1][0]+1f[i][1] = f[i - 1][0] + 1
  • arr[i1]=arr[i]arr[i - 1] = arr[i]:不考虑,不符合「湍流」的定义

代码:

class Solution {
    public int maxTurbulenceSize(int[] arr) {
        int n = arr.length, ans = 1;
        int[][] f = new int[n][2];
        f[0][0] = f[0][1] = 1;
        for (int i = 1; i < n; i++) {
            f[i][0] = f[i][1] = 1;
            if (arr[i] > arr[i - 1]) f[i][0] = f[i - 1][1] + 1;
            else if (arr[i] < arr[i - 1]) f[i][1] = f[i - 1][0] + 1;
            ans = Math.max(ans, Math.max(f[i][0], f[i][1]));
        }
        return ans;
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:O(n)O(n)
空间优化:奇偶滚动

我们发现对于 f[i][j]f[i][j] 状态的更新只依赖于 f[i1][j]f[i - 1][j] 的状态。

因此我们可以使用「奇偶滚动」方式来将第一维从 nn 优化到 22

修改的方式也十分机械,只需要改为「奇偶滚动」的维度直接修改成 22 ,然后该维度的所有访问方式增加 %2 或者 &1 即可:

代码:

class Solution {
    public int maxTurbulenceSize(int[] arr) {
        int n = arr.length, ans = 1;
        int[][] f = new int[2][2];
        f[0][0] = f[0][1] = 1;
        for (int i = 1; i < n; i++) {
            f[i % 2][0] = f[i % 2][1] = 1;
            if (arr[i] > arr[i - 1]) f[i % 2][0] = f[(i - 1) % 2][1] + 1;
            else if (arr[i] < arr[i - 1]) f[i % 2][1] = f[(i - 1) % 2][0] + 1;
            ans = Math.max(ans, Math.max(f[i % 2][0], f[i % 2][1]));
        }
        return ans;
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:使用固定 2 * 2 的数组空间。复杂度为 O(1)O(1)
空间优化:维度消除

既然只需要记录上一行状态,能否直接将行的维度消除呢?

答案是可以的,当我们要转移第 ii 行的时候,f[i]f[i] 装的就已经是 i1i - 1 行的结果。

这也是著名「背包问题」的一维通用优手段。

但相比于「奇偶滚动」的空间优化,这种优化手段只是常数级别的优化(空间复杂度与「奇偶滚动」相同),而且优化通常涉及代码改动。

代码:

class Solution {
    public int maxTurbulenceSize(int[] arr) {
        int n = arr.length, ans = 1;
        int[] f = new int[2];
        f[0] = f[1] = 1;
        for (int i = 1; i < n; i++) {
            int a = f[0], b = f[1];
            f[0] = arr[i - 1] < arr[i] ? b + 1 : 1;
            f[1] = arr[i - 1] > arr[i] ? a + 1 : 1;
            ans = Math.max(ans, Math.max(f[0], f[1]));
        }
        return ans;
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:O(1)O(1)

1220. 统计元音字母序列的数目

给你一个整数 n,请你帮忙统计一下我们可以按下述规则形成多少个长度为 n 的字符串:

字符串中的每个字符都应当是小写元音字母('a', 'e', 'i', 'o', 'u')

  • 每个元音 'a' 后面都只能跟着 'e'
  • 每个元音 'e' 后面只能跟着 'a' 或者是 'i'
  • 每个元音 'i' 后面 不能 再跟着另一个 'i'
  • 每个元音 'o' 后面只能跟着 'i' 或者是 'u'
  • 每个元音 'u' 后面只能跟着 'a'

由于答案可能会很大,所以请你返回 模 109+710^9 + 7 之后的结果。

示例 1:

输入:n = 1

输出:5

解释:所有可能的字符串分别是:"a", "e", "i" , "o""u"

示例 2:

输入:n = 2

输出:10

解释:所有可能的字符串分别是:"ae", "ea", "ei", "ia", "ie", "io", "iu", "oi", "ou""ua"

示例 3:

输入:n = 5

输出:68

提示:

  • 1<=n<=21041 <= n <= 2 * 10^4
线性 DP

定义 f[i][j]f[i][j] 为考虑长度为 i+1i + 1 的字符串,且结尾元素为 jj 的方案数(其中 jj 代表数组 ['a', 'e', 'i', 'o', 'u'] 下标)。

不失一般性考虑 f[i][j]f[i][j] 该如何计算。

我们可以从题意给定的规则进行出发,从 f[i]f[i] 出发往前更新 f[i+1]f[i + 1],也可以直接利用对称性进行反向分析。

为了方便大家理解,还是将常规的「从 f[i]f[i] 出发往前更新 f[i+1]f[i + 1]」作为主要分析方法吧。

根据条件可以容易写出转移方程:

  • 每个元音 'a' 后面都只能跟着 'e'f[i+1][1]+=f[i][0]f[i + 1][1] += f[i][0]
  • 每个元音 'e' 后面只能跟着 'a' 或者是 'i'f[i+1][0]+=f[i][1]f[i + 1][0] += f[i][1]f[i+1][2]+=f[i][1]f[i + 1][2] += f[i][1]
  • 每个元音 'i' 后面 不能 再跟着另一个 'i'f[i+1][j]+=f[i][2],(j不能为2)f[i + 1][j] += f[i][2], (j 不能为 2)
  • 每个元音 'o' 后面只能跟着 'i' 或者是 'u'f[i+1][2]+=f[i][3]f[i + 1][2] += f[i][3]f[i+1][4]+=f[i][3]f[i + 1][4] += f[i][3]
  • 每个元音 'u' 后面只能跟着 'a'f[i+1][0]+=f[i][4]f[i + 1][0] += f[i][4]
class Solution {
    int MOD = (int)1e9+7;
    public int countVowelPermutation(int n) {
        long[][] f = new long[n][5];
        Arrays.fill(f[0], 1);
        for (int i = 0; i < n - 1; i++) {
            // 每个元音 'a' 后面都只能跟着 'e'
            f[i + 1][1] += f[i][0]; 
            // 每个元音 'e' 后面只能跟着 'a' 或者是 'i'
            f[i + 1][0] += f[i][1];
            f[i + 1][2] += f[i][1];
            // 每个元音 'i' 后面 不能 再跟着另一个 'i'
            f[i + 1][0] += f[i][2];
            f[i + 1][1] += f[i][2];
            f[i + 1][3] += f[i][2];
            f[i + 1][4] += f[i][2];
            // 每个元音 'o' 后面只能跟着 'i' 或者是 'u'
            f[i + 1][2] += f[i][3];
            f[i + 1][4] += f[i][3];
            // 每个元音 'u' 后面只能跟着 'a'
            f[i + 1][0] += f[i][4];
            for (int j = 0; j < 5; j++) f[i + 1][j] %= MOD;
        }
        long ans = 0;
        for (int i = 0; i < 5; i++) ans += f[n - 1][i];
        return (int)(ans % MOD);
    }
}
  • 时间复杂度:令 CC 为字符集大小,本题 CC 固定为 55。整体复杂度为 O(nC)O(n * C)
  • 空间复杂度:O(nC)O(n * C)

总结

这里简单说下「线性 DP」和「序列 DP」的区别。

线性 DP 通常强调「状态转移所依赖的前驱状态」是由给定数组所提供的,即拓扑序是由原数组直接给出。更大白话来说就是通常有 f[i][...]f[i][...] 依赖于 f[i1][...]f[i - 1][...]

这就限定了线性 DP 的复杂度是简单由「状态数量(或者说是维度数)」所决定。

序列 DP 通常需要结合题意来寻找前驱状态,即需要自身寻找拓扑序关系(例如本题,需要自己结合题意来找到可转移的前驱状态 f[im]f[i - m])。

这就限定了序列 DP 的复杂度是由「状态数 + 找前驱」的复杂度所共同决定。也直接导致了序列 DP 有很多玩法,往往能够结合其他知识点出题,来优化找前驱这一操作,通常是利用某些性质,或是利用数据结构进行优化。