前言
之前整整花了4个文章篇幅讨论了从递推到到动态规划,从递推套路到递推问题的求解方向,从递推公式到动态转移方程。我们也已经初步了解了一个动态规划程序要从何下手了。但是,我们之前实现的动态规划还不是最优的程序,很多程序为了能够更简单直白的阐述所讲知识点,因此没有对这些题目进行优化。今天咱们就通过一些实实在在的算法题来看看我们要如何优化一个动态规划程序。
714. 买卖股票的最佳时机含手续费
解题思路
- 状态定义:我们获得最大收益无非取决于第
i
不持有股票的最大收益和第i
天持有股票的最大收益。因此,我们有两个递推状态: dp[i][0]:
第i
天不持有股票的最大收益dp[i][1]:
第i
天持有股票的最大收益- 动态转移方程:既然状态定义分成两种情况讨论,那么,我们的状态转义方程也应该分成两种情况来讨论。
- 第i天不持有股票的收益取决于以下两种情况的最大值:,即:
dp[i][0] = max(dp[i-1][0],dp[i-1][1] + price[i] - free)
1. 第i-1天没有持有股票,第i天也没有持有股票:这种情况的收益最大值应该取决于第i-1天不持有股票的最大值,即:dp[i][0] = dp[i-1][0]
2. 第i-1天有股票,但是我在第i天把股票买了:这种情况应该取决于第i-1天持有股票的最大值再加上卖掉股票之后挣的收益,再减去手续费,即:dp[i][0] = dp[i-1][1] + price[i] - fee
- 第i天持有股票的收益:,即:
dp[i][1] = max(dp[i-1][1], dp[i-1][0] - price[i])
1. 第i-1天持有股票,第i天继续持有:dp[i][1] = dp[i-1][1]
2. 第i-1天没有股票,第i天新买入股票:dp[i][1] = dp[i-1][0] - price[i]
- 边界条件:第1天如果不持有股票,那么收益为0,如果第1天持有股票,由于是第一天,肯定是新买入的,那么收益就应该是
-prize[i]
。 - 程序实现:直接使用循环方式解决。
优化思路
由于我们每一天要么就是持有股票,要么就不持有股票,并且,当前的最大收益仅跟上一天的最大收益有关,因此,我们无需额外开辟存储空间用来出处状态,可以直接定义buy
和sell
两个变量用来记录最后一天买入和卖出的最大收益,最后在这两者中取最大值即可完成推算任务。
代码实现
未优化版本
function maxProfit(prices: number[], fee: number): number {
const n = prices.length;
const dp: number[][] = new Array(n);
dp.fill(new Array(2));
// 第一天没有持有股票,那么最大收益为0
dp[0][0] = 0;
// 第一天持有股票,那么最大收益就是-prices[0]
dp[0][1] = -prices[0];
for(let i=1;i<n;i++) {
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] + prices[i] - fee);
dp[i][1] = Math.max(dp[i-1][1], dp[i-1][0] - prices[i]);
}
// 最后再最后一天是否持有股票所带来的收益中取最大值
return Math.max(dp[n-1][0], dp[n-1][1]);
};
优化版本
function maxProfit(prices: number[], fee: number): number {
const n = prices.length;
// 我们始终记录最后一天卖出和买入的收益即可
// 初始化卖出与买入的最大收益
let [sell, buy] = [0, -prices[0]];
for(let i=1;i<n;i++) {
[sell, buy] = [Math.max(sell, buy + prices[i] - fee), Math.max(buy, sell - prices[i]) ];
}
// 最后再最后一天买入或卖出所带来的收益中取最大值
return Math.max(sell, buy);
};
213. 打家劫舍 II
解题思路
这道题与之前的打家劫舍1唯一的不同点就是这些房子是连成环的。也就是说,如果我们偷了第一家,那么就不能偷第n家,如果偷了第n家,就不能偷第1家。在这里,其实我们可以分两次分别求解最大收益,再从两次的最大收益中取最大值即可。第一次固定不偷最后一家,那么这种情况下,我们第一家就可偷可不偷了,我们最大的收益就去取决于不偷n-1加的收益。第二次偷的时候,我们固定不偷第一家,那么这种情况最大收益则取决于是否偷n-1家的最大值。
- 状态定义:这道题跟我们之前做的打家劫舍1的状态定义其实都是一样的,获得的最大收益其实就是取决于第i家到底是偷还是不偷,因此,我们的递推状态为:
dp[i][0]
和dp[i][1]
; - 动态转移方程:由于递推状态分为两种情况,因此动态转移方程也有两种情况。
- 当我们不偷第i家时:不偷第i家时的最大收益取决于是否偷i-1家的最大值,即:
dp[i][0] = max(dp[i-1][0],dp[i-1][1])
- 当我们偷第i家时:当我们偷第i家时,由于不能连续偷两家,因此获得的最大收益应该为不偷i-1加的最大收益加上偷第i家得到的最大收益,即:
dp[i][1] = dp[i-1][0] + val(i)
- 边界条件:当不偷第一家时,收益为0,偷第一家时,收益为第一家的钱。(当我们在单独讨论一定不偷第一家时,第一家偷到的收益固定为0)。
- 程序实现:使用进行两次动态规划推算出固定不偷第一家的最大收益和固定不偷第一家的最大收益,最终取他们之间的最大值即可。
代码演示
function rob(nums: number[]): number {
const n = nums.length;
if(n === 1) return nums[0];
const dp: number[][] = [];
for(let i=0;i<n;i++) dp.push(new Array(2))
// 第一次,我们固定不偷最后一家,那么第一家可偷可不偷
dp[0][0] = 0;
dp[0][1] = nums[0];
for(let i=1;i<n;i++) {
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1]);
dp[i][1] = dp[i-1][0] + nums[i];
}
// 不偷最后一家获得的最大收益
const val1 = dp[n-1][0];
// 第二次,我们固定不偷第一家,那么第一家的收益固定为0
dp[0][0] = 0;
dp[0][1] = 0;
for(let i=1;i<n;i++) {
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1]);
dp[i][1] = dp[i-1][0] + nums[i];
// console.log(dp[i][0], dp[i][1]);
}
const val2 = Math.max(dp[n-1][0], dp[n-1][1]);
// 在固定不偷第一家与固定不偷第二家中选取一个最大值
// console.log(val1, val2);
return Math.max(val1, val2);
};
474. 一和零
解题思路
我们先来分析一下这道题,他让我们找到尽可能多的字符串,让0和1的数量分别是m和n。一看到这种类型的描述,基本断定是0-1背包
问题了,其中,m
和n
就可以看成是背包容量的限制,我们挑选的字符串就相当于要放入背包的物品。由于这道题让我们选择尽可能多的字符串,因此,我们把每个字符串看成一个单位,每个单位的价值就是1。
我们已经分析清楚了这道问题的本质了,接下来还是按照动态规划四步走:
-
递推状态:由于我们这道题能选取多少个字符串与
m
和n
都有关系,因此,我们定义递推状态为:dp[i][m][n]
代表前i
个字符串有m个0,n个1的最多的字符串数量。 -
状态转义方程:确定了递推状态之后,我们就分为两种情况,要么就选第
i
个串,要么就不选第i
个串,然后再这两种情况中找个最大值。 -
选择第i个串:
dp[i][m][n] = dp[i-1][m - count(i, 0)][n - count(i, 1)] + 1
-
不选第i个串:
dp[i][m][n] = dp[i-1][m][n]
综上,我们最终的状态转义方程应该为:dp[i][m][n] = max(dp[i-1][m - count(i, 0)][n - count(i, 1)] + 1, dp[i-1][m][n])
。
由于dp[i][][]
只跟dp[i-1][][]
有关系,因此,我们可以使用滚动数组技巧将原本的三维数组转换为二维,即:dp[m][n] = max(dp[m - count(i, 0)][n - count(i, 1)] + 1, dp[m][n])
-
边界条件:初始时将二维数组全部初始化为0
-
程序实现:使用
滚动数组
和逆向刷表法
优化程序的时间与空间复杂度,减少边界判断。
代码演示
// 统计每个字符串0和1出现的次数
const getZerosOnes = (str) => {
const zerosOnes = new Array(2).fill(0);
const length = str.length;
for (let i = 0; i < length; i++) {
zerosOnes[str[i].charCodeAt(0) - '0'.charCodeAt(0)]++;
}
return zerosOnes;
}
function findMaxForm(strs: string[], m: number, n: number): number {
let dp: number[][] = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0))
for(let s of strs) {
const [count0, count1] = getZerosOnes(s);
// 使用逆向刷表法倒着扫描生成dp
for(let i=m;i>=count0;--i) {
for(let j=n;j>=count1;--j) {
dp[i][j] = Math.max(dp[i - count0][j - count1] + 1, dp[i][j]);
}
}
}
return dp[m][n];
};
518. 零钱兑换 II
解题思路
这道题因为只是让我们求方法总数,没有决策过程,因此,算是递推问题。那么,我们就按照递推问题的思路:
- 定义递推状态:
dp[i][j]
代表使用第i中硬币拼凑j元钱的方法总数 - 递推公式:拼凑的方法总数需要分为两种情况来讨论:
- 没有使用第i种硬币:
dp[i][j] = dp[i-1][j]
- 使用了第i种硬币:
dp[i][j] = dp[i][j-x]
,其中,x代表第i种硬币的面额 - 边界条件:要拼凑0元的方法总数有1中
- 程序实现:使用正向刷表法
代码演示
function change(amount: number, coins: number[]): number {
// 定义递推状态:dp[i][j]代表使用第i中硬币拼凑j元钱的方法总数
// 递归公式:dp[i][j] = dp[i-1][j] + dp[i][j-x],代表如果没使用第i中硬币的方法总数为dp[i-1][j],使用了第i种硬币的方法总数为dp[i][j-x],
// 两者相加就是总的方法总数
// 由于dp[i][]只跟dp[i-1]有关,因此,我们可以将二位数组压缩成一维,即:
// dp[j] = dp[j] + dp[j-x]
// 或 dp[j]+=dp[j-x]
let dp: number[] = new Array(amount+1);
dp.fill(0);
dp[0] = 1;
for(let x of coins) {
for(let i=x;i<=amount;i++) dp[i] += dp[i - x];
}
return dp[amount];
};