心得体会
这周仍然是接着上次没有做完的分治法题目继续做,然后遇到了不少挺有趣的题目:Burst Balloons这道题结合了分治法和动态规划,并且需要一定的逆向思维,可以说是非常巧妙,这也是这周做到的最难的题,主要是思路清奇,很难想得到,所以本片博客将大力讲解解题思路,分析解题过程,详情见博客正文;Count of Smaller Numbers After Self 和 Count of Range Sum 这两道题则是巧妙利用mergesort算法,对其进行变式求解问题,也是需要相当精巧的设计,这两道题将会在后续博客中简单说一下;其它的一些题目虽然有些难度,但是思路上远远不如这两个题目巧妙,所以就不说了。
题解正文
题目描述
问题分析
此题从一个数组中不断除去数字直到数组为空,每次去掉一个数字的时候都将num[left]*num[thisNum]*num[right]
(left=thisNum-1,right=thisNum+1)加入到结果result
当中,即就是将这个数字和它左右两个数字相乘加入到result
,如果它的左右没有数字就用1
填充,我们的目的是求出这个最大的result
解题思路
这个题目难就难在思路上,至少对我来说我难以想到正确、合理的解法,导致迟迟无法下手。
-
难点1:如何正确对这个题目使用分治。
这个题目最简单的想法是对于每一种去除数字的顺序都计算出对应的result,然后保留最大的result,而总共的去除数字的顺序有 A n n = n ! A^n_n=n! Ann=n!种,也就是说这是一个 O ( n ! ) O(n!) O(n!)复杂度的算法,这根本不用动手写代码,光是想一想就不可能用这个方法,因为这个复杂度太高了,一定有更加好的办法。
考虑到这个题目的分治法标签,我们可以尝试使用分治法,然后第一个问题就是怎么把这个问题分解成两个子问题:我们随便从数组中选择一个数字然后算出
result += num[thisNum-1]*num[thisNum]*num[thisNum+1]
,然后将这个组合分成左右两份,但是问题在于生成的两个子问题类型上和原问题有所不同
,原问题中,如果某个数字左右没有数字了用1
填充,但是新生成的两个子问题中,在计算边界数字的时候如果左右没有数字,则需要用对方集合中的数字
填充而不是用1
填充,并且这个对方集合中的数字
还是任意的,不一定也是对方集合边界上的数字。所以这种分割产生子问题的方法不可行。正向分割行不通,不妨反向思考,我们可以考虑通过最后一个被删除的元素对集合进行分割:我们随便从数组中选择一个数字然后算出
result += num[left]*num[thisNum]*num[right]
,需要注意:此式left下标是整个集合左侧的元素下标,right是整个集合右侧元素的下标,比如对于{1,2,3,4}的子集{2,3},它的left是1,right是4
,然后集合被分为左右两部分,在新生成的两个子问题中,计算边界数字的时候如果左右没有数字,使用最后删除的那个元素来填充,事实上在计算式中还是result += num[thisNum-1]*num[thisNum]*num[thisNum+1]
,不需要做特别操作,照常计算即可,所以这种分割产生子问题的方法可行。 -
难点2:由大量重复算式计算联想到
适用于有重叠子问题的动态规划方法
。我们在经过上述分治操作之后,将一个大的问题分解成两个更小的子问题,而要求出最大result,需要进行n此分割(因为对于一个长为n的数组,选出最后一个被删除的元素,有n种方式),所以我们得到父问题和子问题的关系表达式: T ( n ) = ( T ( 0 ) + T ( n − 1 ) + 1 ) + ( T ( 1 ) + T ( n − 2 ) + 1 ) + ⋅ ⋅ ⋅ + ( T ( n − 2 ) + T ( 1 ) + 1 ) + ( T ( n − 1 ) + T ( 0 ) + 1 ) = 2 × ∑ k = 0 n − 1 T ( k ) + n T(n)=(T(0)+T(n-1)+1)+(T(1)+T(n-2)+1)+···+(T(n-2)+T(1)+1)+(T(n-1)+T(0)+1)=2\times \sum_{k=0}^{n-1}T(k) + n T(n)=(T(0)+T(n−1)+1)+(T(1)+T(n−2)+1)+⋅⋅⋅+(T(n−2)+T(1)+1)+(T(n−1)+T(0)+1)=2×∑k=0n−1T(k)+n。
T ( n ) = 2 × ∑ k = 0 n − 1 T ( k ) + n > 2 × T ( n − 1 ) T(n)=2\times \sum_{k=0}^{n-1}T(k) + n > 2\times T(n-1) T(n)=2×∑k=0n−1T(k)+n>2×T(n−1),也就是说它的增长速度比$2^n$要快
;
T ( n ) T ( n − 1 ) = 2 × ∑ k = 0 n − 1 T ( k ) ) T ( n − 1 ) + n T ( n − 1 ) < n \frac{T(n)}{T(n-1)}=\frac {2\times \sum_{k=0}^{n-1}T(k))}{T(n-1)} + \frac{n}{T(n-1)} < n T(n−1)T(n)=T(n−1)2×∑k=0n−1T(k))+T(n−1)n<n,也就是说它的增长比$n!$更慢
;
要证明上述式子可以假设 T ( n ) T ( n − 1 ) > n \frac{T(n)}{T(n-1)}>n T(n−1)T(n)>n,即就是 T ( n − 1 ) T ( n ) < 1 n \frac{T(n-1)}{T(n)}<\frac{1}{n} T(n)T(n−1)<n1,由此可得 T ( n ) T ( n − 1 ) = 2 × ∑ k = 0 n − 1 T ( k ) ) T ( n − 1 ) + n T ( n − 1 ) < 2 × ∑ k = 0 n − 1 ( n − k − 1 ) ! ( n − 1 ) ! + n ( n − 1 ) ! < 2 × ( 1 + 1 ( n − 1 ) + 1 ( n − 1 ) ( n − 2 ) + 1 ( n − 2 ) ( n − 3 ) + 1 ( n − 3 ) ( n − 4 ) + ⋅ ⋅ ⋅ + 1 2 × 3 + 1 1 × 2 ) + n ( n − 1 ) ! = 2 × ( 1 − 1 n − 1 + 1 n − 1 + 1 ) + n ( n − 1 ) ! < 3 < n \frac{T(n)}{T(n-1)}=\frac {2\times \sum_{k=0}^{n-1}T(k))}{T(n-1)} + \frac{n}{T(n-1)}<2 \times {\sum_{k=0}^{n-1}\frac{(n-k-1)!}{(n-1)!}}+\frac{n}{(n-1)!}<2\times(1+\frac{1}{(n-1)}+\frac{1}{(n-1)(n-2)}+\frac{1}{(n-2)(n-3)}+\frac{1}{(n-3)(n-4)}+···+\frac{1}{2\times3}+\frac{1}{1\times2})+\frac{n}{(n-1)!} =2\times(1-\frac{1}{n-1}+\frac{1}{n-1}+1)+\frac{n}{(n-1)!} <3<n T(n−1)T(n)=T(n−1)2×∑k=0n−1T(k))+T(n−1)n<2×∑k=0n−1(n−1)!(n−k−1)!+(n−1)!n<2×(1+(n−1)1+(n−1)(n−2)1+(n−2)(n−3)1+(n−3)(n−4)1+⋅⋅⋅+2×31+1×21)+(n−1)!n=2×(1−n−11+n−11+1)+(n−1)!n<3<n,这对于n比较大的时候总能够成立,所以假设 T ( n ) T ( n − 1 ) > n \frac{T(n)}{T(n-1)}>n T(n−1)T(n)>n不对,所以T(n)增长比 n ! n! n!更慢,证毕。
至此我们说明了通过分治算法我们能够使问题比 O ( n ! ) O(n!) O(n!)简单,但是比 O ( 2 n ) O(2^n) O(2n)复杂,应该还有更好的方法。如果我们手动做一下简单的几个版本,比如计算3,1,5,8,9的最大result值,我们会发现,我们无论按照{3,1,5}->8->9的顺序还是按照{3,1,5}->9->8的顺序来删除元素都要计算出{3,1,5}这个集合的最大result,所以这个计算是重复计算,还有很多类似这样的重复计算。
基于这种情况我们可以考虑将已经计算出的子问题结果保存,这些子问题一共有 n 2 + n 2 \frac{n^2+n}{2} 2n2+n种,因为每一个这样的子问题对应一个(left,right)数对,left<=right,表示这个问题是要求出从
下标从left到right的这些数字组成的集合
进行操作后能得到的最大result,这样我们可以用一个上三角矩阵来存储每个子问题,第left行,第right列存储(left,right)数对对应子问题的最大result,然后最终的计算结果将存储在最右上角Matrix[0][n]处。 -
解决了上面两个难点,我们可以得出最后的解决方案:
要求长度为n的问题的最大result,需要知道长度为1到n-1的子问题的result结果,然后遍历n种分割方法,求出 r e s u l t l e f t + 1 ∗ n u m s [ i ] ∗ 1 + r e s u l t r i g h t result_{left}+1*nums[i]*1+result_{right} resultleft+1∗nums[i]∗1+resultright的最大值,也就是最终的答案;
所以我们可以采用:- 自底向上的方法,从长度为1的问题开始求解,然后利用长度为1的问题的求解结果计算长度为2的问题答案,然后利用长度为1、2的问题的求解结果计算长度为3的问题答案,···,依次类推,利用长度为1到n-1问题的答案,使用上面说到的遍历方法,就可以求出长度为n的问题的result。具体操作可以看算法步骤部分;
- 自顶向下的方法,需要使用递归,求解长度为n的问题的最大result,遍历n种分割方法,然后对于每一种分割方法,通过递归调用求解子问题,但是注意要保存每次子问题的求解结果,这样下次需要用到某个子问题求解结果时可以通过 O ( 1 ) O(1) O(1)的复杂度直接得到结果,否则将和难点2中说到的第一个解法那样复杂度超过 O ( 2 n ) O(2^n) O(2n)。具体操作可以看算法步骤部分;
算法步骤
- 自底向上的方法:
- 第一层循环,每层循环求解求解长度为
l
e
n
g
t
h
∈
(
1
,
n
)
length∈(1,n)
length∈(1,n)的子问题的解
- 第二层循环,每层循环求解长度为length的、对应于数对(start,start+length-1)的子问题的解,其中
s
t
a
r
t
∈
(
0
,
n
u
m
s
.
s
i
z
e
(
)
−
l
e
n
g
t
h
)
start∈(0,nums.size()-length)
start∈(0,nums.size()−length)
- 第三层循环,遍历数对(start,start+length-1)的对应子问题的所有分割方法,一共有length种分割方法,对应于最后删除元素 lastBurst 从 start 到 start+length-1
- 每层循环将Matrix[start][start+length-1]更新为
M a x Max Max{ M a t r i x [ s t a r t ] [ s t a r t + l e n g t h − 1 ] Matrix[start][start+length-1] Matrix[start][start+length−1], r e s u l t l e f t + l e f t N u m ∗ n u m s [ l a s t B u r s t ] ∗ r i g h t N u m + r e s u l t r i g h t result_{left}+leftNum*nums[lastBurst]*rightNum+result_{right} resultleft+leftNum∗nums[lastBurst]∗rightNum+resultright},
其中 M a t r i x [ s t a r t ] [ s t a r t + l e n g t h − 1 ] Matrix[start][start+length-1] Matrix[start][start+length−1]存储了之前的第三层循环计算出的、目前最大的result,
r e s u l t l e f t result_{left} resultleft是之前的第一层循环求解出来的、长度小于目前length的、对应于数对(start,lastBurst-1)的子问题result,
r e s u l t r i g h t result_{right} resultright是之前的第一层循环求解出来的、长度小于目前length的、对应于数对(lastBurst+1,start+length-1)的子问题result,
leftNum是下标start左边的数字,如果没有用1填充,rightNum是下标start+length-1右边的数字,如果没有用1填充;
- 每层循环将Matrix[start][start+length-1]更新为
- 第三层循环,遍历数对(start,start+length-1)的对应子问题的所有分割方法,一共有length种分割方法,对应于最后删除元素 lastBurst 从 start 到 start+length-1
- 第二层循环,每层循环求解长度为length的、对应于数对(start,start+length-1)的子问题的解,其中
s
t
a
r
t
∈
(
0
,
n
u
m
s
.
s
i
z
e
(
)
−
l
e
n
g
t
h
)
start∈(0,nums.size()-length)
start∈(0,nums.size()−length)
- 第一层循环,每层循环求解求解长度为
l
e
n
g
t
h
∈
(
1
,
n
)
length∈(1,n)
length∈(1,n)的子问题的解
- 自顶向下的方法:
maxCoin函数调用maxLeftToRight(0,size-1)求解(0,size-1)对应问题也即是整个问题的答案;
对于maxLeftToRight(left,right)函数,可以求解(left,right)对应问题的答案,求解细节如下:- 如果用于保存求解结果的上三角矩阵Matrix[left][right]>0,那么说明之前已经求解过该问题,直接返回结果Matrix[left][right](矩阵Matrix一开始初始化为全0矩阵);
- 如果用于保存求解结果的上三角矩阵Matrix[left][right]==0,那么遍历数对(left,right)的对应子问题的所有分割方法,一共有right-left+1种分割方法,对应于最后删除元素 lastBurst 从 left到 right
- 每层循环首先递归调用maxLeftToRight(left,lastBurst-1)求解对应于数对(left,lastBurst-1)的子问题result,结果存储到 r e s u l t l e f t result_{left} resultleft;
- 然后递归调用调用maxLeftToRight(lastBurst+1,right)求解对应于数对(lastBurst+1,right)的子问题result,结果存储到 r e s u l t r i g h t result_{right} resultright;
- 最后将Matrix[left][right]更新为
M a x Max Max{ M a t r i x [ l e f t ] [ r i g h t ] Matrix[left][right] Matrix[left][right], r e s u l t l e f t + l e f t N u m ∗ n u m s [ l a s t B u r s t ] ∗ r i g h t N u m + r e s u l t r i g h t result_{left}+leftNum*nums[lastBurst]*rightNum+result_{right} resultleft+leftNum∗nums[lastBurst]∗rightNum+resultright},
其中 M a t r i x [ l e f t ] [ r i g h t ] Matrix[left][right] Matrix[left][right]存储了之前的循环计算出的、目前最大的result,
r e s u l t l e f t result_{left} resultleft、 r e s u l t r i g h t result_{right} resultright是之前递归调用求得结果,
leftNum是下标left左边的数字,如果没有用1填充,rightNum是下标right右边的数字,如果没有用1填充;
算法复杂度分析
-
对于自顶向下方法:答案显而易见,外面三层循环之下,最里面的循环操作是 O ( 1 ) O(1) O(1)级别复杂度的,所以整个问题求解的复杂度为 O ( n 3 ) O(n^3) O(n3)
-
对于自底向上方法:这里虽然不像上一个解法那样容易看出复杂度,不过我们可以从另外的角度说明,使用这个方法求解整个问题的复杂度也是 O ( n 3 ) O(n^3) O(n3):
整个问题的求解过程需要对大小 n × n n\times n n×n二维矩阵Matrix进行填充,这一共需要 n 2 + n 2 \frac{n^2+n}{2} 2n2+n次填充操作;
每次填充操作都是遍历数对(left,right)对应问题的k种分割方法,不断更新Matrix[left][right]的值,k的值从1均匀变化到n,所以这个操作是 O ( n ) O(n) O(n)复杂度的;
综上所述,整个问题的求解复杂度是 O ( n 3 ) O(n^3) O(n3)
代码实现&结果分析
- 自底向上方法:
对应的结果:class Solution { public: int maxCoins(vector<int>& nums) { if (nums.size() == 0) return 0; int coinMatrix[nums.size()][nums.size()] = {}; for (int length = 1; length <= nums.size(); length++) { for (int start = 0; start <= nums.size()-length; start++) { for (int lastBurst = start; lastBurst <= start+length-1; lastBurst++) { int leftMaxCoin = lastBurst == start ? 0 : coinMatrix[start][lastBurst-1]; int rightMaxCoin = lastBurst == start+length-1 ? 0 : coinMatrix[lastBurst+1][start+length-1]; int leftNum = start == 0 ? 1 : nums[start-1]; int rightNum = start+length == nums.size() ? 1 : nums[start+length]; int middleCoin = leftNum*nums[lastBurst]*rightNum; if (leftMaxCoin+rightMaxCoin+middleCoin > coinMatrix[start][start+length-1]) { coinMatrix[start][start+length-1] = leftMaxCoin + rightMaxCoin + middleCoin; } } } } return coinMatrix[0][nums.size()-1]; } };
- 自顶向下方法:
对应的结果:class Solution { public: int maxCoins(vector<int>& nums) { int** coinMatrix = new int* [nums.size()]; for (int i = 0; i < nums.size(); ++i) { coinMatrix[i] = new int[nums.size()]; for (int j = 0; j < nums.size(); j++) coinMatrix[i][j] = 0; } return maxLeftRight(coinMatrix, nums, 0, nums.size()-1); } int maxLeftRight(int** coinMatrix, vector<int>& nums, int left, int right) { if (left > right) return 0; if (coinMatrix[left][right] > 0) return coinMatrix[left][right]; for (int lastBurst = left; lastBurst <= right; lastBurst++) { int leftMaxCoin = maxLeftRight(coinMatrix, nums, left, lastBurst-1); int rightMaxCoin = maxLeftRight(coinMatrix, nums, lastBurst+1, right); int leftNum = left == 0 ? 1 : nums[left-1]; int rightNum = right == nums.size()-1 ? 1 : nums[right+1]; int middleCoin = leftNum*nums[lastBurst]*rightNum; if (leftMaxCoin + rightMaxCoin + middleCoin > coinMatrix[left][right]) { coinMatrix[left][right] = leftMaxCoin + rightMaxCoin + middleCoin; } } return coinMatrix[left][right]; } };