例题 一 : Triangle
LeetCode 120. Triangle
Given a triangle, find the minimum path sum from top to bottom. Each step you may move to adjacent numbers on the row below.
For example, given the following triangle
[
[2],
[3,4],
[6,5,7],
[4,1,8,3]
]
The minimum path sum from top to bottom is 11 (i.e., 2 + 3 + 5 + 1 = 11).
分析:
设三角形共有 N N N 行, r r r 为三角形的行, c c c 为三角形的列。从点 P ( r , c ) P(r,c) P ( r , c ) 出发,每向下走一步有两个点P 1 ( r + 1 , c ) , P 2 ( r + 1 , c + 1 ) P_1(r+1,c), P_2(r+1, c+1) P 1 ( r + 1 , c ) , P 2 ( r + 1 , c + 1 ) 可以选择 ,如果每次都选值小的点m i n ( P 1 , P 2 ) min(P1, P2) min ( P 1 , P 2 ) ,则最后得到的点的值之和即是最优解。令 M ( r , c ) M(r,c) M ( r , c ) 为从 P ( r , c ) P(r,c) P ( r , c ) 开始到下面的列的各条路径中,最佳路径的数字之和。
解法一:
这是一个典型的递归问题。
M ( r , c ) = { P ( r , c ) , if r = N m i n ( M ( r + 1 , c ) , M ( r + 1 , c + 1 ) ) + P ( r , c ) , others M(r,c) = \begin{cases} P(r,c), & \text{if r = N }\\ min(M(r+1,c), M(r+1,c+1)) + P(r,c),& \text{others}\\ \end{cases}
M ( r , c ) = { P ( r , c ) , min ( M ( r + 1 , c ) , M ( r + 1 , c + 1 )) + P ( r , c ) , if r = N others
由此写出代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 class Solution {public : int getMinPathSum (vector<vector<int >>& triangle, int r, int c) { if (r == triangle.size () - 1 ) return triangle[r][c]; int x = getMinPathSum (triangle, r+1 , c); int y = getMinPathSum (triangle, r+1 , c+1 ); x = x<y ? x: y; return triangle[r][c] + x; } int minimumTotal (vector<vector<int >>& triangle) { return getMinPathSum (triangle, 0 ,0 ); } };
代码没有问题,在 “Run Code” 时可以得出正确的结果。但是 “Submit” 却会给出 “Time Limit Exceeded”,超时!
如果我们推算这个解法的时间复杂度的话,可以得到 O ( 2 n ) O(2^n) O ( 2 n ) .这不超时就有鬼了。我们需要改进这个算法。
解法二:
可以看出,M M M (即getMinPathSum) 被重复计算了,越是靠近三角形底部、越是靠近每行的中心,重复计算的次数越多。设想:假如当我们第一次算出 M ( r , c ) M(r,c) M ( r , c ) 时, 就将其保存到某个地方 T ( r , c ) T(r,c) T ( r , c ) ,下次需要计算 M ( r , c ) M(r,c) M ( r , c ) 的时候直接取 T ( r , c ) T(r,c) T ( r , c ) ,那不就可以避免重复计算了吗,这样时间复杂度可以降到 O ( n 2 ) O(n^2) O ( n 2 ) , 因为该三角形中的数字的总个数为 n ( n + 1 ) 2 \frac {n(n+1)}{2} 2 n ( n + 1 ) 。如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 class Solution {public : struct MinVal { bool flag; int val; MinVal ():flag (false ),val (0 ){}; }; int getMinPathSum (vector<vector<int >>& triangle, int r, int c, vector<vector<MinVal>>& tmp) { if (r == triangle.size () - 1 ) return triangle[r][c]; if (tmp[r][c].flag) return tmp[r][c].val; int x = getMinPathSum (triangle, r+1 , c, tmp); int y = getMinPathSum (triangle, r+1 , c+1 , tmp); x = x<y ? x: y; x = triangle[r][c] + x; tmp[r][c].val = x; tmp[r][c].flag = true ; return x; } int minimumTotal (vector<vector<int >>& triangle) { vector<vector<MinVal>> tmp (triangle.size ()); for (int i = 0 ; i<tmp.size (); ++i){ tmp[i] = vector <MinVal>(triangle[i].size ()); } return getMinPathSum (triangle, 0 ,0 , tmp); } };
一个小结:
在解题的过程中,我们思路:
将一个求最优解的问题分解成求多个最优解的子问题 (定义 M ( r , c ) M(r,c) M ( r , c ) 的过程 )
意识到子问题会重复计算
将会被重复计算的子问题的解存储起来。且不会改变。
这种求解具有 重叠子问题 的 最优解 的问题的过程中,避免子问题被重复计算而将子问题的解 存储 起来的算法,可以称之为 动态规划 算法。我们可把子问题叫做 状态 ,在方法二中,从求子问题的解到将子问题的解存储起来的过程,看做是从一种状态到另一状态的 转移 。
再将上面的思路总结一下,即为动态规划算法的适用性:
最优子结构性质:问题的最优解所包含了子问题的解也是最优的;
子问题重叠性质:每次产生的子问题并不总是新问题。动态规划利用该性质将每个子问题的解存储,该子问题不再进行重复计算;
状态转移无后效性质: 子问题的解一旦确定,将不会再改变。
解法三:
上面的解法是自顶而下的递归解法。根据以上三个性质,我们也可以尝试自下而上的来解问题。即从最后一行开始,算出每个点的最优解,一直算到顶点,该顶点的解即是最优解:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 class Solution {public : int minimumTotal (vector<vector<int >>& triangle) { if (triangle.size () == 1 ) return triangle[0 ][0 ]; vector<vector<int >> tmp (triangle.size ()); for (int r = 0 ; r<tmp.size (); ++r){ tmp[r] = vector <int >(triangle[r].size ()); } for (int r = triangle.size ()-1 ; r >= 0 ; --r){ for (int c = 0 ; c < triangle[r].size (); ++c){ if (r == triangle.size () - 1 ){ tmp[r][c] = triangle[r][c]; } else { tmp[r][c] = triangle[r][c] + (tmp[r+1 ][c] < tmp[r+1 ][c+1 ] ?tmp[r+1 ][c] : tmp[r+1 ][c+1 ] ); } } } return tmp[0 ][0 ]; } };
这种思路将递归的解法改造成递推的解法,更容易理解。
解法四:
解法二、三都构造了一个与三角形等大的数组。空间复杂度大大增加了(O ( r 2 ) O(r^2) O ( r 2 ) )。可不可以改进呢?可以的,实际上,在递推的过程中,当 T ( r + 1 ) T(r+1) T ( r + 1 ) 被 M ( r ) M(r) M ( r ) 使用以后不会再参与计算,可以使用 T ( r ) T(r) T ( r ) 覆盖 T ( r + 1 ) T(r+1) T ( r + 1 ) ,这样临时空间只需要大小的 r r r 即能完全够用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 class Solution {public : int minimumTotal (vector<vector<int >>& triangle) { if (triangle.size () == 1 ) return triangle[0 ][0 ]; vector<int > tmp (triangle[triangle.size()-1 ].size()) ; for (int r = triangle.size ()-1 ; r >= 0 ; --r){ for (int c = 0 ; c < triangle[r].size (); ++c){ if (r == triangle.size () - 1 ){ tmp[c] = triangle[r][c]; } else { tmp[c] = triangle[r][c] + (tmp[c] < tmp[c+1 ] ? tmp[c] : tmp[c+1 ] ); } } } return tmp[0 ]; } };
同理,可以再优化:当完成 M ( r , c ) M(r,c) M ( r , c ) 后,P ( r , c ) P(r, c) P ( r , c ) 将不再参与计算,这个空间是可以拿来利用的,可以用其来存储 M ( r , c ) M(r,c) M ( r , c ) 。这样最终结果存储在 P ( 0 , 0 ) P(0,0) P ( 0 , 0 ) 中。即在上面代码中,最后返回的结果是 triangle[0][0] . 代码略。
例题 二:Maximum Subarray
LeetCode 53. Maximum Subarray
Given an integer array nums, find the contiguous subarray (containing at least one number) which has the largest sum and return its sum.
Example:
Input: [-2,1,-3,4,-1,2,1,-5,4],
Output: 6
Explanation: [4,-1,2,1] has the largest sum = 6.
Follow up:
If you have figured out the O(n) solution, try coding another solution using the divide and conquer approach, which is more subtle.
解法一:
朴素的递归方法。
分析:求和最大的子数组。设 S i S_i S i 为从 i i i 开始的, 有最大和的子数组之和, 那么该问题的解即为求 S 0 , S 1 , … , S n S_0, S_1, \dots , S_n S 0 , S 1 , … , S n 中的最大值。
S i = { n u m s [ i ] , if i = = n ; m a x ( n u m s [ i ] , n u m s [ i ] + S i + 1 ) , Others S_i = \begin{cases} nums[i], & \text{if } i ==n ;\\max(nums[i], nums[i] + S_{i+1}), & \text{Others} \\ \end{cases}
S i = { n u m s [ i ] , ma x ( n u m s [ i ] , n u m s [ i ] + S i + 1 ) , if i == n ; Others
相应的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 class Solution {public : int getMaxSum (vector<int >& nums, int i) { if (i == nums.size () - 1 ) return nums[nums.size ()-1 ]; return std::max (nums[i] + getMaxSum (nums, i+1 ),nums[i]); } int maxSubArray (vector<int >& nums) { if (nums.size () == 1 ) return nums[0 ]; vector<int > maxs (nums.size()) ; for (int i = 0 ; i<nums.size (); ++i){ maxs[i] = getMaxSum (nums,i); } return *std::max_element (maxs.begin (), maxs.end ()); } };
解法二:
动态规划法:
状态转移: 将以第 i i i 个元素开头的子数组的最大和存储在临时变量中,使其不用在下次使用时再计算一遍。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 class Solution {public : struct Val { bool flag; int val; Val ():flag (false ), val (0 ){}; }; int getMaxSum (vector<int >& nums, int i, vector<Val>& tmp) { if (i == nums.size () - 1 ) return nums[nums.size ()-1 ]; if (tmp[i].flag) return tmp[i].val; tmp[i].val = std::max (nums[i] + getMaxSum (nums, i+1 , tmp),nums[i]); tmp[i].flag = true ; return tmp[i].val; } int maxSubArray (vector<int >& nums) { if (nums.size () == 1 ) return nums[0 ]; vector<Val> maxs (nums.size()) ; for (int i = 0 ; i<nums.size (); ++i){ nums[i] = getMaxSum (nums,i, maxs); } return *std::max_element (nums.begin (), nums.end ()); } };
解法三:
动态规划递推:
思路转变:从后往前,依次求出以元素 nums[i] 的最优解(即以nums[i] 起始的子数组的最大和 tmp , 此值将参与求元素 nums[i-1] 最优解的运算一次。解法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 class Solution {public : int maxSubArray (vector<int >& nums) { if (nums.size () == 1 ) return nums[0 ]; int tmp, maxVal; tmp = maxVal = nums[nums.size ()-1 ]; for (int i = nums.size ()-2 ; i>=0 ; --i){ tmp = std::max (nums[i], nums[i] + tmp); maxVal = std::max (maxVal, tmp); } return maxVal; } };
这样该题的时间复杂度为 O ( n ) O(n) O ( n ) . 当然该题的题目里也提示了,可以尝试使用分治法解决该问题,下次再讨论。
LeetCode 上的其他类似题目:
再一个小结:
对于:可以分解成多个重复的子问题、求最优解 的题目,可以尝试使用动态规划的方法来解题。解决了局部的最优解, 即能得到全局的最优解。如果题目比较简单,可以尝试使用直接递推法,否则可以先进行子问题的分解,使用递归的方法解题,再对递归内的状态进行转移,优化成动态规划的解法。