待续,暂时有些乱
1、总结一类子问题空间结构
+维度1:是否有形式不同的子问题
+维度2:是否仅仅依赖于下一层的子问题
2、分析DP与分治处理这类子问题空间结构的过程
3、几个微妙的地方:
+求解子问题时求解过程的混合,以及求解后解的分开保存
+有些子问题图,不需要保存完整的DP数组,仅仅保存一些DP的值就可以了。(利用这一点可以优化空间复杂度的)
摘要
在处理最大子数组问题(leetcode53),最大矩形问题(leetcode85),最大子数组乘积问题(leetcode152)的过程中,总感觉有一层迷雾在挡着我,特别是用DP处理了这些问题后,得到的代码效率还是很低。这让我意识到,在DP处理的过程中肯定存在某些可以得到优化的细节我没有注意到。
另一个角度说来,最初接触最大子数组问题的时候,是在算法导论中,当时使用的是分治算法来解决的。这让我有一种想去分析分治与DP处理过程中究竟有些什么不同,又有哪些是相同的欲望。
这两点让我回过头来想一想,问题的根源应该都来自于对子问题空间结构分析的不够清楚上。所以重新去探究分析子问题空间结构中被忽视的点。
在这个分析的过程中,我发现这类问题的子问题空间结构有一些共通性,而分治算法与DP算法在处理这类子问题空间结构上,有一些大家容易忽视的地方,或者说没有特别仔细的去分析过的微妙的地方[1] [2]
最后我会借助子问题图说明,有一类子问题图其是即可以借助DP又可以借助分治来处理的
最大子数组问题描述
给定一个整数数组nums[0,…,n-1],找出nums的一个子数组,其求和是所有子数组中最大的。输出这个求和的值。
使用分治解决最大子数组问题
对于一个数组A[low,…,high],其最大子数组A[i,…,j]所处位置必然是下述三种情况之一:
- 完全位于子数组A[low,…,mid]中
- 完全位于子数组A[mid+1,…,high]中
- 跨越了中点,即low <= i <= mid < j <= high
我们可以看到,对于一个原问题,我们分解成了三个子问题。但是这三个子问题是有区别的,其中问题1,2是与原问题形式相同的子问题(我们可以直接递归求解);而问题3是一个与原问题形式不同的问题,我们需要具体分析这个问题的求解。
在算法导论描述的分治算法步骤(分解->求解->合并)中,将这类与原问题形式不同的子问题的求解过程,划分到合并步骤中的。
使用DP解决最大子数组问题
除了使用分治可以解决最大子数组问题,我们也可以使用DP来解决最大子数组问题,先描述其中的子问题空间结构:
DP[i] 记录子问题nums[0,…,i]的解,其中0<=i<=n-1
DPAux[i] 做为一个辅助的DP数组,其记录的是子问题nums[0,…,i]中包含nums[i]的最大连续子数组的解
DPAux
[
i
]
=
{
n
u
m
s
[
i
]
if
i
=
=
0
m
a
x
(
DPAux
[
i
−
1
]
+
n
u
m
s
[
i
]
,
n
u
m
s
[
i
]
)
if
i
>
0
\begin{aligned} \text{DPAux}[i] = \begin{cases} nums[i] & \text{if } i==0 \\ max(\text{DPAux}[i-1]+nums[i], nums[i]) & \text{if } i > 0 \end{cases} \end{aligned}
DPAux[i]={nums[i]max(DPAux[i−1]+nums[i],nums[i])if i==0if i>0
DP
[
i
]
=
{
n
u
m
s
[
i
]
if
i
=
=
0
m
a
x
(
n
u
m
s
[
i
]
,
DP
[
i
−
1
]
,
DPAux
[
i
]
)
if
i
>
0
\begin{aligned} \text{DP}[i] = \begin{cases} nums[i] & \text{if } i==0 \\ max( nums[i], \text{DP}[i-1], \text{DPAux}[i]) & \text{if } i > 0 \end{cases} \end{aligned}
DP[i]={nums[i]max(nums[i],DP[i−1],DPAux[i])if i==0if i>0
关于上述两个表达式有非常多值得说道的地方:
先来说一说DPAux的含义,DPAux记录的是子问题nums[0,…,i]的一个包含nums[i]的最大连续子数组。我们假设:
DPAux[i-1] = nums[k] + … + nums[i]
那么的DPAux[i] 就会是下述两种情况之一:
1、DPAux[i] = nums[k] + … + num[i] + nums[i+1]
2、DPAux[i] = nums[i+1]
那么DPAux[i] = nums[k’] + … + nums[i+1] (k’ != k) 为什么不可能出现呢?
答:因为最优子结构。假设我们的DPAux[i] = nums[k’] + … + nums[i+1],显然我们可以使用DPAux[i] 来替换nums[k’] + … + nums[i]可以得到一个更优的解。
再说一说DP[i]的含义:DP[i]则记录的是我们需要的问题的解了。但是有趣的是DP[i]的求解却依赖于DPAux[i]。这一切都是由于我们的问题的两大性质:
1、原问题会分解为形式相同的子问题和形式不同的子问题
2、原问题的解依赖于形式不同的子问题的解
至此我们做一个总结,本问题中其实蕴含两个最优子结构,而DP正是基于这两个最优子结构,同时进行了两个DP数组的计算,最后的原问题的解也依赖于这两个DP数组的。
其实上述描述有一点不严谨,在分治中我们可以说原问题的求解依赖于形式不同的子问题,在此处其实DP[i]依赖的是同规模的DPAux[i],不能直接说其是依赖于子问题的。但是DPAux[i]的求解是依赖于DPAux[i-1]的,从这个角度我们又可以说其是依赖于形式不同的子问题的
子问题空间结构
一句话:
原问题分解为多个子问题,其中有与原问题形式一致的子问题,也有与原问题形式不一致的子问题。而原问题的求解却依赖于形式不一致的子问题
我们分析的关键有两点:
1、DP与分治是如何处理这两类子问题的
2、DP与分治是如何根据这两类子问题构建出原问题的解的。
对于问题1:
在分治中,递归处理与原问题形式一致的子问题,将与原问题形式不一致的子问题,通通丢到合并阶段去处理。
在DP中,其实这两类问题地位是等价了,我们需要得到原问题的解,我们会使用DP的方法去求解所有形式的子问题的。(就相当于处理不同规模的数据的时候,我们都同时处理了多个不同形式的子问题)。这样看来DP中,这不同形式的问题的求解是完全独立的。其实不是,我们知道求解的过程是解决相同规模下的不同形式的子问题的。这些子问题虽然形式不同,但其解却往往有某些联系(这种联系正是来源于问题的子结构)。所以一般来说,求解某个规模的子问题的时候,我们把不同形式的子问题放到一起求解,最后将结果分开保存到不同的DP数组中。
对于问题2:
两种方法其实都是一样的。只是在分治中将形式不同的子问题的求解也划分到了合并阶段,而在DP中的合并就真的仅仅指的是将不同形式的子问题的解合并为原问题的解。
更重要的是分析:
在DP与分治中,这两类子问题之间的关系有什么不同么?(这种子问题之间的关系,正是体现了DP与分治的区别的地方)
一句话:
在分治中,这两类子问题地位是不同的
在DP中,这两类子问题地位是相同的,虽然最后我仅仅需要的是原问题形式相同的解。但是求解的过程中,这两类子问题的地位是相等的。更重要的是,求解的过程中,还可能将这两类问题的求解放在一起,只是最后将结果分开保存而已。
子问题图说明
图1是那种仅仅分解为原问题形式相同的子问题空间结构,我们可以简单的按照拓扑排序的顺序自底向上来解决。
图2则展示了那类可以分解为原问题形式不同的子问题的空间结构,我们在使用DP解决的时候,本质相当于同时进行了多个DP数组的计算,因为每类问题内部是具有最优子结构的,也按照拓扑排序自底向上来解决。
一些微妙的可以优化的地方
1、上述子问题图中,当前问题仅仅依赖于前一个子问题,所以我们并不需要保存完整的DP数组来维持所有的子问题的解,仅仅需要保存前一个子问题的解就可以了。
这揭露了一个非常重要的点,那些既适用于分治又适用于DP的问题一般都具有这种特点
为什么呢?我们仔细想一想分治的过程,在求解原问题的时候,我们递归去求解下一层的子问题,即分治的过程中原问题仅仅依赖于下一层的子问题,这种情况下,是不会出现大量的重叠子问题的求解的,所以使用分治是可以的。
如果出现图3这种子问题图(譬如钢条切割问题的子问题图),就很容易出现子问题重叠的情况,就需要使用DP来进行优化了。
2、对于第二个子问题图,虽然相同规模的时候,子问题的形式不同,但其求解过程往往有某些联系,我们可以借助这些联系来优化求解过程。只是求解过程虽然可以混合着来,最后将解分开保存就可以了。
案例1:最大矩形问题
案例2:最大乘积子数组问题
子问题图与最大连续子数组问题基本一致,略微不同的就是,在使用子问题的解来合并原问题的解的计算过程中要复杂一点。
在对子问题空间结构有了上述非常难透彻的理解后,我可以写出一个非常简洁的代码
vector<int> solution(vector<int> &nums, int i)
{
vector<int> ans(4, nums[i]); //DPmin, DPmax, DPAuxmin, DPAuxmax
if(i == 0)
return ans;
auto lastans = solution(nums, i-1);
ans[2] = min({lastans[2]*nums[i], lastans[3]*nums[i], nums[i]});
ans[3] = max({lastans[2]*nums[i], lastans[3]*nums[i], nums[i]});
ans[0] = min( ans[2], lastans[0]);
ans[1] = max( ans[3], lastans[1]);
return ans;
}
int maxProduct(vector<int> &nums)
{
auto ans = solution(nums, nums.size()-1);
return ans[1];
}
思考:
是否说,有着上述子问题结构的问题,我们都可以既使用分治又可以使用DP来解决呢?如果可以的话,是否存在某种算法永远比另一种算法效率高呢?
注
[1] 这个微妙指的是,我根据算法导论分析分治算法的步骤去分析DP的时候,能够总结出一些很有趣的点。所以读者应该对算法导论的分治章节有过阅读,才能体会到我说的微妙的点。否则可能觉得我说的有点牵强。
[2] 在参考了一些其他人描述其DP代码的思想的文档后。感觉那些描述都不够形式化,不够理论,很多停留在对于代码运行过程的模拟的描述中。所以我会尝试使用算法导论中的分治步骤与DP步骤来描述。