分享复杂的线性动态规划问题(一)
摘要:以一篇比较复杂的线性动态规划作为恢复营业的回归篇。除了以往的分析步骤,作为对自我近期更新惰性的小惩大诫,新增必写的时空复杂度分析步骤。
正文:
1.先复习一下动态规划的基本步骤:
(1)题意分析;
(2)基于分析数学建模;
(3)判定是否可以符合使用动规的两大前置条件(最优子结构和无后效性),是则下一步,否则终止(非动规可以解决的问题,另寻他法);
(4)动规基本三步曲:
1)结合题意根据模型选择计算出比较合适的状态转移方程,归约初始的状态值,推导出终止(最终收敛)条件;
2)迭代验证;
3)选择合适的迭代次序实现状态转移方程的迭代和收敛;
(5)编程实现。
2.进入正题:
给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。
设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。
注意: 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
示例 1:
输入: [2,4,1], k = 2
输出: 2
解释: 在第 1 天 (股票价格 = 2) 的时候买入,在第 2 天 (股票价格 = 4) 的时候卖出,这笔交易所能获得利润 = 4-2 = 2 。
示例 2:
输入: [3,2,6,5,0,3], k = 2
输出: 7
解释: 在第 2 天 (股票价格 = 2) 的时候买入,在第 3 天 (股票价格 = 6) 的时候卖出, 这笔交易所能获得利润 = 6-2 = 4 。
随后,在第 5 天 (股票价格 = 0) 的时候买入,在第 6 天 (股票价格 = 3) 的时候卖出, 这笔交易所能获得利润 = 3-0 = 3 。
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock-iv
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
咋看,其实题意很容易理解,给定一组股票每天价格的序列,算出在该序列里的最大交易利润。只是要注意,如上输入输出和解释所示,一笔交易是包含买入和卖出的。
3.题解:
既然是求最大的交易总利润,那就理应以单笔交易作为单位元素去考虑这个问题,给定的元素数组显然并非代表单笔交易,故我们还要预处理一下输入数组。
1)预处理:把输入的元素序列a[i](i=0,..,n - 1)转化为代表单笔交易的元素数组t[i](0, .., n - 1);
根据交易的定义,一笔交易的利润 = 买入 - 卖出,加上股价序列是有序序列,故我们可以考虑转化为原子的单位交易利润 = 下一个相邻股价 - 当前股价,即
gap[i] = price[i + 1] - price[i]
这样转化还有一个好处,通过累加可以得到第(k + i + 1)天与第(k + 1)天的交易利润 =
price[k + i] - price[k] = price[k + i] - price[k + i - 1] + price[k + i - 1] - price[k + i - 2] + price[k + i - 2] - .. - price[k + 1] + price[k + 1] - price[k] = gap[k + i - 1] + gap[k + i - 2] + .. + gap[k] = sum(gap, k, k + i - 1)
这样一来,可以通过该数组累加获取任一第(i)天与第(j)天的交易利润,比之前初始输入的直接计算方便很多。现在以该数组gap作为输入数组考虑以下问题。
2)分析建模;
数学模型很容易就看出来:映射成函数曲线图,就是计算其中若干个曲线段的极差的最大总和,分段体现在gap数组就是断隔取元素,例如, gap[i], gap[i + 1], gap[i + 2],取gap[i] , gap[i + 2]就是断隔取值,因为如果像取gap[i], gap[i + 1]的情况,那累加后也只是算1个曲线段,代表第(i + 2)与第(i + 1)天的极差。算这样离散型的最优值,可以考虑是否可以使用动态规划。
3)判别是否符合动态规划的两个前置条件:最优子结构和无后效性;
首先,确定一点是最终的最优解,必然是最多k个取值范围位置在[0, n]的以gap[n]作为必取元素的最优解中的最大值,问题转化为求最多k个取值范围位置在[0, n]的以gap[n]作为必取元素的所有最优解,然后取最大值即可。显然,当前转化后的问题,是属于最优子结构,因为很明显存在当前范围内的最优子解可以推导出下一个范围内的最优子解,以k为参数。其次,无后效性也是可以通过这个转化后的问题,这次需要拓宽一下无后效性在动态规划中的要求,事实上所有网上说法都离不开,三个概念:历史,当前,未来、一种定义:状态即结果论。只要当前以后的未来状态仅跟当前能确定的某个状态相关,与当前不能确定的状态无关,则可以认为与无后效性等效。狭义来说,未来状态只跟当前状态相关,与历史状态无关。广义来说,未来状态只跟当前可以唯一确定的某个状态有关,不同时依赖于若干个历史状态才能得出。详见4)的方程求解。
4)建立状态转换方程求解:
对于该题,很重要一点在于,对状态转换方程的推导有信心,因为最后卡住你,让你觉得不对的往往并非迭代方程,原因详见5)的总结。有信心,结合模型就可以得出方程,分类讨论一下k个取值范围位置在[0, n]的以gap[n]作为必取元素的最优解max[k][n]如何通过当前状态max[k][n - 1]推导出来,根据断隔取值的特征分类两种:不断隔的情况可以取到当前状态max[k][n - 1] 加上gap[n]即可,因为gap[n - 1]所在的曲线段必然和gap[n]合并成为一个曲线段(gap[n - 1]与gap[n]连续的);断隔就稍微麻烦一点,可以取与gap[n]不相邻的历史k - 1个曲线段的所有最优解中的最大值加上gap[n]得出候选值。两种情况的候选值比较取最大值即max[k][n]。方程如下:
max[k][n] = max{max{max[k - 1][n - 1 - t] t € [1, n -1)}, max[k - 1][n - 1] + a[n]}
取最大值即为最终结果:
res = max{max[k][n]}
该题就是一个例子,历史状态虽有若干个,但是未来状态max[k][n]只与历史状态的的特征状态(最大值,即max{max[k - 1][n - 1 - t] t € [1, n -1) )有关,即确定的唯一状态,所以也可以用动态规划的思想解决。
5)编程实现。
每种算法的公共部分:
/**
* 主方法
* @param k
* @param prices
* @return
*/
public static int maxProfit(int k, int[] prices) {
if (null == prices || prices.length == 0 || k <= 0) {
return 0;
}
int[] maxPricesGaps = maxPricesGaps(prices);
if (null == maxPricesGaps || maxPricesGaps.length == 0) {
return 0;
}
// return maxPriceGap(maxPricesGaps, 0, maxPricesGaps.length, k); //穷举法
return maxPriceGap(maxPricesGaps, k); //动态规划
}
/**
* 预处理
* @param prices
* @return
*/
private static int[] maxPricesGaps(int[] prices) {
int[] gaps = new int[prices.length - 1];
for (int i = 0; i < gaps.length; i++) {
gaps[i] = prices[i + 1] - prices[i];
}
return gaps;
}
a.穷举法:
/**
* 穷举法
* @param maxPriceGaps
* @param start
* @param end
* @param k
* @return
*/
private static int maxPriceGap(int[] maxPriceGaps, int start, int end, int k) {
if (start >= end) {
return 0;
}
if (k <= 0) {
return 0;
}
if (k == 1) {
int res = 0;
int[] maxs = new int[end - start];
System.arraycopy(maxPriceGaps, start, maxs, 0, maxs.length);
for (int i = 0; i < maxs.length; i++) {
if (i > 0 && maxs[i - 1] > 0) {
maxs[i] += maxs[i - 1];
}
if (res < maxs[i]) {
res = maxs[i];
}
}
return res;
}
int res = 0;
for (int i = start; i < end; i++) {
if (maxPriceGaps[i] <= 0) {
continue;
}
int max = maxPriceGap(maxPriceGaps, start, i, k - 1) + maxPriceGap(maxPriceGaps, i, end, 1);
res = Math.max(max, res);
}
return res;
}
b.动态规划:
/**
* 动态规划
* @param priceGaps
* @param k
* @return
*/
private static int maxPrice(int[] priceGaps, int k) {
int m = Math.min(priceGaps.length / 2 + 1, k);
int[][] max = new int[m][priceGaps.length];
//初始化
max[0][0] = priceGaps[0];
int res = Math.max(0, max[0][0]);
//迭代,max[k][n]标识k+1个最大限制[0..n](n + 1个)以a[n]为末尾的波形序列的最大和
for (int i = 0; i < max.length; i++) {
int lastMax = 0;
for (int j = i + 1; j < max[i].length; j++) {
if (i > 0 && j > 1 && lastMax < max[i - 1][j - 2]) {
lastMax = max[i - 1][j - 2];
}
max[i][j] = Math.max(max[i][j - 1], lastMax) + priceGaps[j];
if (res < max[i][j]) {
res = max[i][j];
}
}
}
return res;
}
c.优化后的动态规划:
/**
* 动态规划(优化后)
* 状态迭代方程:max[k][n] = max{max{max[k - 1][n - 1 - t] t IN [1, n -1)}, max[k - 1][n - 1] + a[n]}
* @param priceGaps
* @param k
* @return
*/
private static int maxPriceGap(int[] priceGaps, int k) {
int[][] max = new int[2][priceGaps.length];
//初始化
max[0][0] = priceGaps[0];
int res = Math.max(0, max[0][0]);
int m = Math.min(priceGaps.length / 2 + 1, k);
//迭代,max[k][n]标识k+1个最大限制[0..n](n + 1个)以a[n]为末尾的波形序列的最大和
for (int i = 0; i < m; i++) {
int lastMax = 0;
for (int j = i + 1; j < max[i % 2].length; j++) {
if (i > 0 && j > 1 && lastMax < max[(i - 1) % 2][j - 2]) {
lastMax = max[(i - 1) % 2][j - 2];
}
max[i % 2][j] = Math.max(max[i % 2][j - 1], lastMax) + priceGaps[j];
if (res < max[i % 2][j]) {
res = max[i % 2][j];
}
}
}
return res;
}
4.分析时空复杂度:
算法 | 平均时间复杂度 | 最优时间复杂度 | 最坏时间复杂度 | 空间复杂度 |
穷举法 | o(N!) | o(N!) | o(N!) | o(1) |
动态规划 | o(N^2) | o(KN) | o(N^2) | min(o(N^2), o(KN)) |
优化后的动态规划 | o(N^2) | o(KN) | o(N^2) | o(N) |
如上表所示,时空复杂度可以根据代码分析得出,不再赘述。
5.总结:
其实,笔者一开始是用了错误的方法:企图利用函数单调性算出范围内极大值然后用容量为k的堆存储,最后累加堆中容量内的元素即得到期望结果。事实上是,这种方法是贪心策略,得到的最终结果未必是最优解,因为这样每次选择了当前最大值后,意味后续的选择范围也就框定了,你并不知道跟不选择当前最大值后的选择范围相比,哪个更优,哪个取值方向可以达到最大总和。这只是过程最优,未必可以达到结果最优。当然,这次挥发着无招胜有招(其实是实在笔者没招了)的盲目自信,一开始就用了穷举法直接暴力破解,受到了以下的教训:
0.方向错误,一开始往贪心的方向靠,后面意识到存在一个反例,使得这样得出的结果不对,就是这样的举一反三,及时纠正错误的方向。
1.第二轮超时,发现最简单朴素的穷举法没办法AC(通过的意思,下同),尔后仔细一算,复杂度竟然有o(N!)之大,就开始认真研究起来了,往有点深层次的算法方面去考虑。最后修正了往动态规划的方向靠了。
2.第三轮超内存,是还没优化的动态规划中炸出来的,是因为存在测试用例上升数组大小高达10000的时候才意识到的。申请的内存在这样的测试用例背景下最坏情况会申请100002个整型值的数组空间,可知这样真的会炸。当时一瞬间曾怀疑过是不是有更加简洁高效的状态迭代方程。但根据输入参数确实无法归一化简成一维,还优化了一波内存申请,对k和数组长度的一半比较取最小值,来减少内存申请,因为并不需要那么多次冗余计算量和冗余空间,但还是超内存。
3.坚定往减少冗余方向走是对的,我后面清晰意识到我只是需要最终答案,过程中申请多大空间都无所谓,当然从最优角度想是在满足了计算存储计算结果的需要的情况下越少越好,最终想到了只取两行一维数组作为计算空间,用取余法在有限空间做允许无限次的迭代运算,优化计算申请的内存空间,从o(N ^ 2)的最坏情况到o(N)的最坏情况。消耗了多余的时间开销做取余运算,但由于是基本类型的基本运算,并不会有多大额外开销,算是一次挺好的通过算法优化内存使用的经历。所以说,只要你有所思考,终究有解决方案。
综述,这道题,算是开门红了,浪了七次才AC,不过也让我深刻地经历了典型的几种错误,推导出几种解决超时超内存的思路。算法的问题,先尝试从算法优化的层面上考虑,再行他法。