动态规划从简单到复杂

MrHe··3 min read

今天咱们来聊聊动态规划,这个东西在算法题里太常见了,我平时刷题的时候,总觉得它像个老朋友,从简单入手就能逐步推到高级优化。咱们不废话,直接拿个经典题练手:斐波那契数列。问题是,给定一个数n,求斐波那契序列的第n项(从0开始,f(0)=0, f(1)=1, f(2)=1...)。

我第一次遇这个题的时候,就想用递归直奔主题。思路简单:f(n) = f(n-1) + f(n-2),边界是n=0或1直接返回。但递归有问题,重复计算太多,比如f(5)会算好几次f(3),时间复杂度指数级,n大点就爆了。代码这么写:

public int fib(int n) {
    if (n == 0) return 0;
    if (n == 1 || n == 2) return 1;
    return fib(n - 1) + fib(n - 2);
}

这玩意儿简单,但效率低。我想,得优化。加个记忆化搜索,用个数组存已经算过的值,避免重复。数组memo[n]存f(n),初始化-1表示没算过。递归时先查memo,有就直接用,没就算完存进去。这样时间复杂度降到O(n),空间也O(n)。思路是从顶向下,边算边记。

public int fibMemo(int n) {
    int[] memo = new int[n + 1];
    Arrays.fill(memo, -1);
    return process(n, memo);
}

private int process(int n, int[] memo) {
    if (n == 0) return 0;
    if (n == 1 || n == 2) return 1;
    if (memo[n] != -1) return memo[n];
    memo[n] = process(n - 1, memo) + process(n - 2, memo);
    return memo[n];
}

记忆化好使,但我觉得还能从底向上推。动态规划的本质就是填表,定义dp[i]为第i项的值。dp[0]=0, dp[1]=1,然后for循环从2到n,dp[i] = dp[i-1] + dp[i-2]。这样空间O(n),时间O(n),而且没有递归栈的风险。思路是先把小问题解了,再推大问题。

public int fibDP(int n) {
    if (n == 0) return 0;
    if (n == 1 || n == 2) return 1;
    int[] dp = new int[n + 1];
    dp[1] = 1;
    dp[2] = 1;
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    return dp[n];
}

这已经很好了,但空间还能压。我发现,每次只用前两个值,不需要整个数组。用两个变量pre和cur,迭代更新。pre初始1(f1),cur初始1(f2),然后从3到n,temp = pre + cur, pre=cur, cur=temp。空间O(1),时间还是O(n)。这优化让我觉得动态规划的思维就是不断压缩状态。

public int fibOptimize(int n) {
    if (n == 0) return 0;
    if (n == 1 || n == 2) return 1;
    int pre = 1;
    int cur = 1;
    for (int i = 3; i <= n; i++) {
        int temp = pre + cur;
        pre = cur;
        cur = temp;
    }
    return cur;
}

斐波那契是入门,练练手。现在升级,聊聊0-1背包问题。给定容量W的背包,N个物品,每个有重量w[i]和价值v[i],每个物品只能选一次,求最大价值。

我先想暴力递归:对每个物品,选或不选。process(index, rest)表示从index开始,剩余容量rest的最大价值。base case:index==N,返回0。递归:不选=process(index+1, rest),选(如果w[index]<=rest)=v[index] + process(index+1, rest - w[index]),取max。但重复子问题多,时间指数级。

public int knapsackRecursive(int[] w, int[] v, int N, int W) {
    return process(w, v, 0, W, N);
}

private int process(int[] w, int[] v, int index, int rest, int N) {
    if (index == N) return 0;
    int p1 = process(w, v, index + 1, rest, N);
    int p2 = 0;
    if (w[index] <= rest) {
        p2 = v[index] + process(w, v, index + 1, rest - w[index], N);
    }
    return Math.max(p1, p2);
}

加记忆化:用二维数组dp[index][rest]存结果。-1表示没算过。思路一样,但查表避免重复。

public int knapsackMemo(int[] w, int[] v, int N, int W) {
    int[][] dp = new int[N + 1][W + 1];
    for (int[] row : dp) Arrays.fill(row, -1);
    return processMemo(w, v, 0, W, dp);
}

private int processMemo(int[] w, int[] v, int index, int rest, int[][] dp) {
    if (dp[index][rest] != -1) return dp[index][rest];
    if (index == w.length) {
        dp[index][rest] = 0;
        return 0;
    }
    int p1 = processMemo(w, v, index + 1, rest, dp);
    int p2 = 0;
    if (w[index] <= rest) {
        p2 = v[index] + processMemo(w, v, index + 1, rest - w[index], dp);
    }
    dp[index][rest] = Math.max(p1, p2);
    return dp[index][rest];
}

转严格DP表:dp[i][j]表示前i个物品,容量j的最大价值。i从N到0,j从0到W。dp[N][j]=0所有j。for i=N-1 downto 0, for j=0 to W: dp[i][j] = max(dp[i+1][j], j>=w[i] ? v[i]+dp[i+1][j-w[i]] : 0)。时间O(NW),空间O(NW)。

public int knapsackDP(int[] w, int[] v, int N, int W) {
    int[][] dp = new int[N + 1][W + 1];
    for (int i = N - 1; i >= 0; i--) {
        for (int j = 0; j <= W; j++) {
            int p1 = dp[i + 1][j];
            int p2 = 0;
            if (j >= w[i]) {
                p2 = v[i] + dp[i + 1][j - w[i]];
            }
            dp[i][j] = Math.max(p1, p2);
        }
    }
    return dp[0][W];
}

优化空间:因为每行只依赖下一行,用一维数组dp[j],从后往前填(j=W downto 0),dp[j] = max(dp[j], j>=w[i] ? v[i]+dp[j-w[i]] : 0)。空间O(W)。

public int knapsackOptimize(int[] w, int[] v, int N, int W) {
    int[] dp = new int[W + 1];
    for (int i = 0; i < N; i++) {  // 注意这里从0到N-1
        for (int j = W; j >= w[i]; j--) {  // 从后往前
            dp[j] = Math.max(dp[j], v[i] + dp[j - w[i]]);
        }
    }
    return dp[W];
}

动态规划的思维就是这样,从暴力到记忆化,到表,到优化。每个题多练几种解法,思路就活了。下次咱们聊LIS啥的,继续提升。