动态规划
递归与动态规划导论
509-斐波那契数
斐波那契数列 [0, 1, 1, 2, 3, 5, 8, ...] 可以表示为递归形式:
int fib(n) {
if (n == 0) return 0;
if (n == 1) return 1;
return fib(n-1) + fib(n-2);
}
涉及递归的问题都可以画出递归树,对于分析算法复杂度、寻找算法低效的原因、找到高效的解法有很大帮助!假设求 fib(20),画出递归树后会发现,这个递归存在着大量重复计算,假设 N = 20,那么树的高度是 20,树的节点数是 2 ^ N,算法复杂度是 O(2^n),这太大了!
因此我们可以用一个数组或者哈希表存储已经计算过的结果,当遍历递归树遇到同样的节点时,就可以快速访问到结果而不必重复计算,这就是“剪枝”。通过剪枝,把一颗存在巨量冗余的递归树,变成了不存在冗余的递归图,fib(20) ... fib(1) 每个结果都只计算了一次。这就是“记忆化递归”!
递归的思路是把一个大问题,自顶向下分解成子问题,最后到达递归树的叶子结点,然后逐层返回计算结果。而动态规划的思想,是从最小子问题开始,即 f(1) 和 f(2),自底向上,逐步构建出大问题的解。
class Solution {
public:
int fib(int n) {
if (n <= 1) return n;
vector<int> dp(n+1, 0);
dp[1] = 1;
for (int i = 2; i <= n; ++i) {
dp[i] = dp[i-1] + dp[i-2];
}
return dp[n];
}
};
322-零钱兑换
给定不同面额的硬币和一个目标金额,计算可以凑成目标金额的最少硬币个数;如果不能凑成目标金额,返回 -1。每一种硬币的数量不限。
递归方案,自顶向下思考。由于每一种硬币的数量不限,每一步都可以选择任意一枚硬币,这是一个 N 叉递归树的前序遍历。
举例:目标 11 元,硬币 [1, 2, 5]。最少硬币数 dfs(11) = 1 + min(dfs(11-1), dfs(11-2), dfs(11-5))
这个递归调用存在许多子问题被重复计算,因此我们需要一个缓存,来实现递归树的剪枝。
class Solution {
public:
int dfs(vector<int> &coins, int target, unordered_map<int, int> &um) {
if (target < 0) return -1;
if (target == 0) return 0;
if (um.find(target) != um.end()) {
return um[target];
}
int res = INT_MAX;
for (int c : coins) {
int cur = 1 + dfs(coins, target - c, um);
if (cur > 0)
res = min(res, cur);
}
um[target] = res == INT_MAX ? -1 : res;
return um[target];
}
int coinChange(vector<int>& coins, int amount) {
unordered_map<int, int> um;
return dfs(coins, amount, um);
}
};
动态规划问题的两个要素:最优子结构、状态转移方程。
- 最优子结构:问题的最优解,可以从其子问题的最优解构造出来。
- 状态转移方程:假设已知子问题的最优解 dp[0] ... dp[n-1],如何得到问题的最优解 dp[n]。
如何找到最优子结构?——“问什么就设什么”。这道题目问的是凑成总金额 S 所需的最少的硬币个数。我们就设 F(S) 为组成金额 S 最少的硬币数。
画 N * M 表格。N = len(coins) + 1,M = amount + 1。
第一行,什么硬币也不选。代表 dp 数组的初始状态。其中 dp[0] = 0,0 元不需要硬币。
第二行,选择 2 元硬币。对于 amount >= 2,dp[i] = (1 + dp[i-2]) if dp[i-2] != -1 else -1
第三行,选择 5 元硬币,对于 amount >= 5,要组合成 amount,可以用 1 + dp[i-5]
枚硬币, 或者 dp[i] 枚硬币(继承上一行),结果应该是两者中较小的一个。得到状态转移方程 dp[i] = min(dp[i], 1 + dp[i-c])
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1, -1);
dp[0] = 0;
for (int c : coins) {
for (int i = c; i < amount+1; ++i) {
if (dp[i-c] != -1) {
int res = dp[i-c] + 1;
dp[i] = dp[i] == -1 ? res : min(dp[i], res);
}
}
}
return dp[amount];
}
};
518-零钱兑换 II
给定不同面额的硬币和一个目标金额,计算可以凑成目标金额的硬币组合数;如果不能凑成目标金额,返回 0。每一种硬币的数量不限。
这是一个典型的组合问题,对于每种硬币都有选与不选两种情况,且最后组成的金额可以拆分为更小的金额。这种大问题里嵌套子问题的,首先想到用动态规划解决。动态规划两步走——最优子结构(求什么就设什么)、状态转移方程(画表格)。
动态规划的过程就是一个填表格的过程,假设可用硬币为 [2, 5, 10],计算 amount = 11 元的组合总数,M = len([2, 5, 10]),N = 11,画一个 M+1 行,N+1 列的表格。
填第一行,即什么硬币都不选。
填第二行,此时选择了硬币 [2],对于 amount < 2,用 2 元硬币无法凑成,因此结果是直接继承上一行。对于 amount >= 2,则组合数 = 选择 2 元硬币的组合数 + 不选择 2 元硬币的组合数。
其中,不选择 2 元硬币的组合数 = dp[i],继承上一行。选择 2 元硬币的组合数 = dp[i-2]。这是动态规划中重要的思想——前面计算的结果可以为后面所用。由于我们是从左往右遍历,我们已经计算出了 dp[i-2] 的组合数,dp[i] 的每种组合即 dp[i-2] 的每种组合加上一枚 2 元硬币。
因此我们得到了一个递推关系:dp[i] = dp[i] + dp[i-coin]
而且,在动态计算的过程中,dp[i] 的计算只与上一行有关,因此我们只需要一个长度为 amount+1 的数组作为存储即可。
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount+1, -1);
dp[0] = 0;
for (int c : coins) {
for (int i = c; i <= amount; ++i) {
if (dp[i-c] != -1) {
int res = 1 + dp[i-c];
dp[i] = dp[i] == -1 ? res : min(dp[i], res);
}
}
}
return dp[amount];
}
};
背包问题
背包问题
给定不同大小的物品和一个背包,每个物品的大小为 A[i],背包容量为 m,挑选物品装入背包,问最大能装多满?
动态规划,物品一个一个选,背包容量一点点增大。填表格时,因为每件物品只能选择一次,所以每一行要从右往左填,不能让左边填好的结果影响右边;填每一个格子时,可能上一行的结果更好,所以取的是 max(dp[i], a + dp[i-a])
。
class Solution {
public:
int backPack(int m, vector<int> &A) {
vector<int> dp(m+1, 0);
for (int a : A) {
for (int i = m; i >= a; --i) {
dp[i] = max(dp[i], dp[i-a] + a);
}
}
return dp[m];
}
};
背包问题 II
给定不同大小的 物品和一个背包,每个物品的大小为 A[i]、价值为 V[i],背包容量为 m,挑选物品装入背包,问能装入背包的最大价值是多少?
class Solution {
public:
int backPackII(int m, vector<int> &A, vector<int> &V) {
vector<int> dp(m + 1, 0);
for (int i = 0; i < A.size(); ++i) {
for (int j = m; j >= A[i]; --j) {
dp[j] = max(dp[j], dp[j-A[i]] + V[i]);
}
}
return dp[m];
}
};
背包问题 III
给定不同大小的物品和一个背包,每个物品的大小为 A[i]、价值为 V[i],背包容量为 m,挑选物品装入背包,每个物品可以选择不限次数,问能装入背包的最大价值是多少?
class Solution {
public:
int backPackIII(vector<int> &A, vector<int> &V, int m) {
vector<int> dp(m + 1, 0);
for (int i = 0; i < A.size(); ++i) {
for (int j = A[i]; j <= m; ++j) {
dp[j] = max(dp[j], dp[j-A[i]] + V[i]);
}
}
return dp[m];
}
};
背包问题 IV
给定不同大小的物品和一个背包,每个物品的大小为 nums[i],背包容量为 target,求能填满背包的方案数。每一个物品可以用无限次。
class Solution {
public:
int backPackIV(vector<int> &nums, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int num : nums) {
for (int i = num; i <= target; ++i) {
dp[i] += dp[i-num];
}
}
return dp[target];
}
};
背包问题 V
给定不同大小的物品和一个背包,每个物品的大小为 nums[i],背包容量为 target,求能填满背包的方案数。每一个物品只能用一次。
class Solution {
public:
int backPackV(vector<int> &nums, int target) {
vector<int> dp(target + 1, 0);
dp[0] = 1;
for (int num : nums) {
for (int i = target; i >= num; --i) {
dp[i] += dp[i-num];
}
}
return dp[target];
}
};
背包问题 IX
你总共有 n 万元,希望申请国外的大学,给出每个大学的申请费用、以及得到 offer 的概率。可以同时申请多所大学。找到至少获得一份 offer 的最大概率。
获得至少一份 offer 的概率 = 1 - 没有 offer 的概率。设 dp[i] 为花 i 元申请大学,没有 offer 的概率。
class Solution {
public:
double backpackIX(int n, vector<int> &prices, vector<double> &probability) {
vector<double> dp(n+1, 1.0);
for (int i = 0; i < prices.size(); ++i) {
for (int j = n; j >= prices[i]; --j) {
double noOffer1 = dp[j-prices[i]];
double noOffer2 = 1 - probability[i];
double noOffer = noOffer1 * noOffer2;
dp[j] = min(dp[j], noOffer);
}
}
return 1 - dp[n];
}
};
动态规划
10-正则表达式
算法的设计是一个螺旋上升、逐步完善的过程,正则表达式匹配就是一个典型的例子。
先不考虑正则表达式,我们先看一个递归形式的字符串匹配,这个算法充分体现了递归的思想——递归函数返回的结果,被当下执行的函数所利用。或者说,管好当下,后面的交给递归去解决。
def isMatch(s, p):
if len(p) == 0: return len(s) == 0
firstMatched = len(s) != 0 and p[0] == s[0]
return firstMatched and isMatch(s[1:], p[1:]) ## 第一个字符相同,剩下的字符串同理。
然后我们处理 .
通配符(可以代表任何单个字符):
def isMatch(s, p):
if len(p) == 0: return len(s) == 0
## 提示:一个短的正则表达式可以匹配很长的字符串,所以这里我们是检查 p[0] 是否在某些字符范围里
firstMatched = len(s) != 0 and p[0] in {s[0], '.'}
return firstMatched and isMatch(s[1:], p[1:])
然后我们处理 *
通配符:
class Solution:
def isMatch(self, s: str, p: str) -> bool:
if len(p) == 0: return len(s) == 0
firstMatched = len(s) != 0 and p[0] in {s[0], '.'}
if len(p) >= 2 and p[1] == '*':
## 当前发现 * 通配符
## p[0] 匹配零次:self.isMatch(s, p[2:])
## p[0] 匹配第一个字符:firstMatched and self.isMatch(s[1:], p)
return self.isMatch(s, p[2:]) or (firstMatched and self.isMatch(s[1:], p))
else:
return firstMatched and self.isMatch(s[1:], p[1:])
显然这个递归存在大量重复计算 ,用记忆化递归改造:
class Solution:
def isMatch(self, s: str, p: str) -> bool:
cache = {}
return self.match(s, p, 0, 0, cache)
def match(self, s, p, i, j, cache):
if j >= len(p): return i >= len(s)
if (i, j) in cache:
return cache[(i, j)]
firstMatched = i < len(s) != 0 and p[j] in {s[i], '.'}
if j < len(p) - 1 and p[j+1] == '*':
cache[(i, j)] = self.match(s, p, i, j+2, cache) or (firstMatched and self.match(s, p, i+1, j, cache))
return cache[(i, j)]
else:
cache[(i, j)] = firstMatched and self.match(s, p, i+1, j+1, cache)
return cache[(i, j)]
本题也可以用动态规划解决。
一、最优子结构:dp[i][j] 表示 s 的前 i 个是否能被 p 的前 j 个匹配
二、状态转移方程:已知 `dp[i-1][j-1]`、s[i]、p[j],如何求 `dp[i][j]`?
当 p[j] in {s[i], '.'},则 dp[i][j] = dp[i-1][j-1]
考虑 \* 通配符,\* 的含义是匹配前面的字符零次或多次
p[j] == '\*' and p[j-1] == s[i] or p[j-1] == ".",\* 前面的字符可以匹配 N 次,……
p[j] == '\*' and p[j-1] != s[i],\* 前面的字符匹配不上,只能匹配零次,则 `dp[i][j] = dp[i][j-2]
class Solution:
def isMatch(self, s: str, p: str) -> bool:
M = len(s)
N = len(p)
dp = [[False for _ in range(N+1)] for _ in range(M+1)]
## 初始化
dp[0][0] = True
for j in range(1, N+1):
if p[j-1] == '*':
dp[0][j] = dp[0][j-2]
## 计算
for i in range(1, M+1):
for j in range(1, N+1):
if p[j-1] in {s[i-1], '.'}:
dp[i][j] = dp[i-1][j-1]
elif p[j-1] == '*':
if p[j-2] in {s[i-1], '.'}:
dp[i][j] = dp[i][j-2] or dp[i-1][j-2] or dp[i-1][j]
else:
dp[i][j] = dp[i][j-2]
return dp[M][N]
53-最大和子数组
一、找最优子结构——问什么设什么,题目求的是最大和连续子数组的和,我们就设 dp[i] 为以 nums[i] 结尾的最大子数组和。
二、找状态转移方程——如果全是正数,那么最大和子数组就是 nums 全体。因为有负数的存在,而 dp[i] 是以 nums[i] 为结尾的,因此容易推出 dp[i] = nums[i] + (dp[i-1] if dp[i-1] > 0 else 0)
class Solution {
public:
int maxSubArray(const vector<int> &nums) {
if (nums.empty())
return 0;
int res = nums[0];
int N = static_cast<int>(nums.size());
vector<int> dp(N);
dp[0] = nums[0];
for (int i = 1; i < N; ++i) {
dp[i] = nums[i] + (dp[i-1] > 0 ? dp[i-1] : 0);
res = std::max(dp[i], res);
}
return res;
}
};
62-不同路径
一个机器人位于一个 m x n 网格的左上角,每次只能向下或者向右移动一步。请问到达到网格的右下角总共有多少条不同的路径?
动态规划就是画表格,画表格就是动态规划。
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[1 for i in range(n)] for i in range(m)]
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i][j-1] + dp[i-1][j]
return dp[m-1][n-1]
63-不同路径 II
一个机器人位于一个 m x n 网格的左上角,每次只能向下或者向右移动一步。请问到达到网格的右下角总共有多少条不同的路径?网格中有障碍物。
dp[i][j]
表示到达网格 [i][j]
的路径总数,dp[i][j] = dp[i-1][j] + dp[i][j-1]
,填表格时,由左往右、由上往下,dp[i][j]
的结果取决于左一格和上一格,dp 二维数组可以优化为一维,数组长度为列数。
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m = len(obstacleGrid)
n = len(obstacleGrid[0])
dp = [0 for i in range(n)]
dp[0] = 1 if obstacleGrid[0][0] == 0 else 0
for i in range(m):
for j in range(n):
if obstacleGrid[i][j] == 1:
dp[j] = 0
continue
if j >= 1 and obstacleGrid[i][j-1] == 0:
dp[j] += dp[j-1]
return dp[n-1]