最小路径和问题
暴力递归
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]点到右下角的距离,那么可以得出递推公式为:(没有考虑边界条件)。
因此最初始的动态规划可以用一个与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];
}
}
下面是三个典型的空间压缩的题目
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方法的时间复杂度为,由于算每一行格子的时候,只会用到下一行中位于正下方的格式和左面的格子,因此可以压缩为一个一维数组,每次算的时候都要从右往左算。
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];
}
当前算法的时间复杂度优化到了:, 由于滑动窗口的时间复杂度为,所以我们在这里用滑动窗口的优化掉了枚举行为产生的