什么是dp的路径压缩技巧?

115 阅读4分钟

最小路径和问题

题目链接

image.png

image.png

暴力递归

class Solution {
    final int magic = Integer.MAX_VALUE;
    public int minPathSum(int[][] grid) {
        return process(grid, 0, 0);
    }

    //当来到i,j位置时,走到右下角需要的最小路径之和
    int process(int[][] grid, int i, int j) {
        int row = grid.length;
        int col = grid[0].length;
        if (i >= row || i < 0 || j >= col || j < 0) return -1;
        if (i == row - 1 && j == col - 1) return grid[i][j];
        int ans = magic;
        int p1 = process(grid, i + 1, j);
        int p2 = process(grid, i, j + 1);
        if (p1 != -1) ans = Math.min(p1, ans);
        if (p2 != -1) ans = Math.min(p2, ans);
        return ans + grid[i][j];
    }
}

分析

这个题比较简单,如果用dp[i][j]表示从[i][j]点到右下角的距离,那么可以得出递推公式为:dp[i][j]=min(dp[i][j+1],dp[i+1][j])+grid[i][j]dp[i][j] = min(dp[i][j+1],dp[i+1][j]) + grid[i][j](没有考虑边界条件)。

因此最初始的动态规划可以用一个与Grid数组的行数和列数分别相等的dp数组,但是考虑到dp数组中的每一个元素都仅依赖于其下方的元素和右方的元素,因此可以用一个一维数组来进行dp。并且dp数组的长度等于grid数组的行数和列数中较小的那一个。

最终的动态规划代码如下:

class Solution {
    public int minPathSum(int[][] grid) {
        int row = grid.length;
        int column = grid[0].length;
        int[] dp;
        if (row <= column) {
            //如果row <= column, 也就是行数小于列数
            dp = new int[row];
            dp[row - 1] = grid[row - 1][column - 1];
            for (int i = row - 2; i >= 0; i--) {
                dp[i] = dp[i + 1] + grid[i][column - 1];
            }
            for (int j = column - 2; j >= 0; j--) {
                dp[row - 1] = dp[row - 1] + grid[row - 1][j];
                for (int i = row - 2; i >= 0; i--) {
                    dp[i] = Math.min(dp[i], dp[i + 1]) + grid[i][j];
                }
            }
        } else {
            dp = new int[column];
            dp[column - 1] = grid[row - 1][column - 1];
            for (int j = column - 2; j >= 0; j--) {
                dp[j] = dp[j + 1] + grid[row - 1][j];
            }
            for (int i = row - 2; i >= 0; i--) {
                dp[column - 1] = dp[column - 1] + grid[i][column - 1];
                for (int j = column - 2; j >= 0; j--) {
                    dp[j] = Math.min(dp[j], dp[j + 1]) + grid[i][j];
                }
            }
        }
        return dp[0];
    }
}

image.png


下面是三个典型的空间压缩的题目

1、0-1背包问题

arr是货币数组,其中的值都是正数。再给定一个正数aim每个值都认为是一张货币,即使是值相同的货币也认为每一张都是不同的。 返回组成aim的方法数(返回需要的最小货币数是另一道题)。

例如:

arr = {1,1,1}, aim = 2

第0个和第1个能组成2,第1个和第2个能组成2,第0个和第2个有组成2.一共就3种方法,所以返回3.

2、完全背包问题

arr是货币数组,其中的值都是正数且没有重复。再给定一个正数aim每个值都认为是一种面值,且认为张数是无限的。 返回组成aim的方法数(或最小货币数)。

3、多重背包问题

arr是货币数组,其中的值都是正数。再给定一个正数aim。每个值都认为是一张货币、 认为值相同的货币没有任何不同。返回组成aim的方法数。(或需要的最小货币数) 例如:

arr = {1,2,1,1,2,1,2}, aim = 4 方法:1+1+1+1 1+1+2 2+2 一共就三种方法,所以返回3


(1)0-1背包问题解答

1、题目为返回方法数

暴力递归代码
public static int coinWays(int[] arr, int aim) {
    return process(arr, 0, aim);
}

// arr[index....] 组成正好rest这么多的钱,有几种方法
public static int process(int[] arr, int index, int rest) {
    if (rest < 0) {
       return 0;
    }
    if (index == arr.length) { // 没钱了!
       return rest == 0 ? 1 : 0;
    } else {
       return process(arr, index + 1, rest) + process(arr, index + 1, rest - arr[index]);
    }
}
dp代码
public static int dp(int[] arr, int aim) {
    if (aim == 0) {
       return 1;
    }
    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] + (rest - arr[index] >= 0 ? dp[index + 1][rest - arr[index]] : 0);
       }
    }
    return dp[0][aim];
}

可以看出这个dp方法的时间复杂度为O(arr长度aim)O(arr长度 * aim),由于算每一行格子的时候,只会用到下一行中位于正下方的格式和左面的格子,因此可以压缩为一个一维数组,每次算的时候都要从右往左算。

2、题目为返回所需要的最少货币数

暴力递归
//0-1背包问题,求需要的最少货币数
static int func(int[] arr, int aim) {
    if (arr == null || arr.length < 1 || aim == 0) return 0;
    return process(arr, 0, aim);
}
static int process(int[] arr, int index, int rest) {
    if (rest < 0) {
        return -1;
    }
    if (rest == 0) {
        return 0;
    }
    int len = arr.length;
    if (index == len) {
        return -1;
    }
    int p1 = process(arr, index + 1, rest);
    int temp =  process(arr, index + 1, rest - arr[index]);
    if (temp != -1)
        temp += 1;
    int ans = Integer.MAX_VALUE;
    if (p1 != -1) {
        ans = p1;
    }
    if (temp != -1) {
        ans = Math.min(ans, temp);
    }
    return ans == Integer.MAX_VALUE ? -1 : ans;
}

(2)完全背包问题解答

求方法数和最小货币数量的题目都在力扣上有。

518. 零钱兑换 II - 力扣(LeetCode)(求方法数)

322. 零钱兑换 - 力扣(LeetCode)(求最小硬币个数)

(3)多重背包问题解答

1、题目为返回方法数

代码
public static class Info {
    public int[] coins;
    public int[] zhangs;

    public Info(int[] c, int[] z) {
        coins = c;
        zhangs = z;
    }
}

public static Info getInfo(int[] arr) {
    HashMap<Integer, Integer> counts = new HashMap<>();
    for (int value : arr) {
        counts.put(value, counts.getOrDefault(value, 0) + 1);
    }
    int N = counts.size();
    int[] coins = new int[N];
    int[] zhangs = new int[N];
    int index = 0;
    for (Entry<Integer, Integer> entry : counts.entrySet()) {
        coins[index] = entry.getKey();
        zhangs[index++] = entry.getValue();
    }
    return new Info(coins, zhangs);
}

public static int coinsWay(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return 0;
    }
    Info info = getInfo(arr);
    return process(info.coins, info.zhangs, 0, aim);
}

// coins 面值数组,正数且去重
// zhangs 每种面值对应的张数
public static int process(int[] coins, int[] zhangs, int index, int rest) {
    if (index == coins.length) {
        return rest == 0 ? 1 : 0;
    }
    int ways = 0;
    for (int zhang = 0; zhang * coins[index] <= rest && zhang <= zhangs[index]; zhang++) {
        ways += process(coins, zhangs, index + 1, rest - (zhang * coins[index]));
    }
    return ways;
}

public static int dp1(int[] arr, int aim) {
    if (arr == null || arr.length == 0 || aim < 0) {
        return 0;
    }
    Info info = getInfo(arr);
    int[] coins = info.coins;
    int[] zhangs = info.zhangs;
    int N = coins.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; zhang * coins[index] <= rest && zhang <= zhangs[index]; zhang++) {
                ways += dp[index + 1][rest - (zhang * coins[index])];
            }
            dp[index][rest] = ways;
        }
    }
    return dp[0][aim];
}
优化

可以看到,在算每一个格子的时候,都有一个遍历行为,我们现在需要想办法省去这个遍历的行为。 如果当前的货币为coins[i],则在这一行中,rest位置依赖于rest-coins[i] * zhangs[i]。 相应的,rest-coins[i]位置也依赖于同样的位置(货币的个数有限制)。因此可以进行斜率优化。

//斜率优化
static int dp2_(int[] arr, int aim) {
    if (arr == null || arr.length < 1 || aim < 0) return 0;
    Info info = getInfo(arr);
    int[] coins = info.coins;
    int[] zhangs = info.zhangs;
    //index: 0 - arr.length aim : 0-aim
    int len = coins.length;
    int[][] dp = new int[2][aim + 1];
    int curLine = len % 2;
    dp[curLine][0] = 1;
    for (int index = len - 1; index >= 0; index--) {
        curLine = 1 ^ curLine;
        int prevLine = 1 ^ curLine;//0变为1  1变为0
        if (coins[index] >= 0) System.arraycopy(dp[prevLine], 0, dp[curLine], 0, coins[index]);
        for (int rest = coins[index]; rest <= aim; rest++) {
            int prev = rest - coins[index];
            dp[curLine][rest] = dp[prevLine][rest] + dp[curLine][prev];
            //过期下标
            int first = prev - coins[index] * zhangs[index];
            if (first >= 0)
                dp[curLine][rest] -= dp[prevLine][first];
        }
    }
    return dp[0][aim];
}

2、题目为返回最少货币数

分析

首先也要将这些货币数整理成Info数组,符合从左向右的尝试模型,首先我们用暴力递归解题,即对应如下的dp代码

代码
public static int dp2(int[] arr, int aim) {
    if (aim == 0) {
        return 0;
    }
    // 得到info时间复杂度O(arr长度)
    Info info = getInfo(arr);
    int[] coins = info.coins;
    int[] zhangs = info.zhangs;
    int N = coins.length;
    int[][] dp = new int[N + 1][aim + 1];
    dp[N][0] = 0;
    for (int j = 1; j <= aim; j++) {
        dp[N][j] = magic;
    }
    // 这三层for循环,时间复杂度为O(货币种数 * aim * 每种货币的平均张数)
    for (int index = N - 1; index >= 0; index--) {
        for (int rest = 0; rest <= aim; rest++) {
            dp[index][rest] = dp[index + 1][rest];
            for (int zhang = 1; zhang * coins[index] <= rest && zhang <= zhangs[index]; zhang++) {
                if (dp[index + 1][rest - zhang * coins[index]] != magic) {
                    dp[index][rest] = Math.min(dp[index][rest], zhang + dp[index + 1][rest - zhang * coins[index]]);
                }
            }
        }
    }
    return dp[0][aim];
}
分析

从这个代码中可以看出,在算每一个格子的时候,也有一个向前的遍历行为,但实际上,如果格子dp[index][rest]位置依赖于dp[index + 1][rest - count * coins[index]]的格子,则dp[index][rest - coins[index]]也会依赖于其中的一部分的格子。当前一个格子算出来的时候,由于求的是一些格子中的最小值,轮到当前格子的时候,不知道前一个格子的最小值是否已经过期了,如果过期了,不知道去除开头的最小值之后,实际的最小值又应该是多少,因此不能用简单的斜率优化。

实际上,当算的格子依次向后移动的时候,这个过程与滑动窗口比较类似,因为每次向后算一个格子的时候,都是从右面增加一个数值,从最左面删除一个数值,并更新当前窗口中的最小值,因此可以用滑动窗口来进行优化。

使用滑动窗口优化
static int dpFinal(int[] arr, int aim) {
    if (aim == 0) return 0;
    // 得到info时间复杂度O(arr长度)
    Info info = getInfo(arr);
    int[] coins = info.coins;
    int[] zhangs = info.zhangs;
    int len = coins.length;
    int[][] dp = new int[len + 1][aim + 1];
    Arrays.fill(dp[len], magic);
    dp[len][0] = 0;
    for (int index = len - 1; index >= 0; index--) {
        //这里要进行分组,当前的货币数值为:coins[index],比如为5,则 0 1 2 3 4位置分别是不同的组别,
        //因此要分为coins[index]组。 如果货币数值为100,aim为10,分为100组也没用,所以要分成min(coins[index], aim + 1)组
        //
        int total = Math.min(coins[index], aim + 1);
        for (int group = 0; group < total; group++) {
            //每一组要来一个滑动窗口,滑动窗口中存放最小值的下标
            Deque<Integer> w = new LinkedList<>();
            for (int rest = group; rest < aim + 1; rest += coins[index]) {
                //当前要算的是dp[index][rest]的值
                while (!w.isEmpty() && (
                        dp[index + 1][w.peekLast()] == magic || dp[index + 1][w.peekLast()] + (rest - w.peekLast()) / coins[index] >= dp[index + 1][rest])) {
                    w.pollLast();
                }
                w.offerLast(rest);
                //怎么选择过期的下标呢?
                //当前的来到的位置是rest处,有zhangs[index]张货币,因此过期的下标应该是rest - (zhangs[index] + 1) * coins[index]
                if (w.peekFirst() == rest - (zhangs[index] + 1) * coins[index]) {
                    w.pollFirst();
                }
                int r = dp[index + 1][w.peekFirst()];
                dp[index][rest] = r == magic ? magic: r + (rest - w.peekFirst()) / coins[index];
            }
        }
    }
    return dp[0][aim];
}

当前算法的时间复杂度优化到了:O(货币种数aim)O(货币种数 * aim), 由于滑动窗口的时间复杂度为O(1)O(1),所以我们在这里用滑动窗口的O(1)O(1)优化掉了枚举行为产生的O(每种面值货币的平均张数)O(每种面值货币的平均张数)