动态规划是一个非常非常重要的算法。众所周知LeetCode周赛中十hard九dp,接触算法那么多年,动态规划始终没有做到完全的掌握,趁着这月LeetCode周赛的主题是dp,也争取完全掌握这门 算法。
内容会不定期更新,初步目标二十道,由易到难。
LeetCode-121:买卖股票的最佳时机
lc上的dp老题了,其实它只是借用了dp思想而已,和传统的dp有一点点差别。
首先考虑暴力做法。题意不难理解,找出数组中差值最大的两个数,输出他们的差值,要求较小数的下标要小于较大数。那么可以很简洁的写出一个直译:
class Solution { public: int maxProfit(vector<int>& prices) { int n = prices.size(); int ans = 0; for(int i = 0; i < n; i++) for(int j = i; j < n; j++) ans = max(prices[j]-prices[i], ans); return ans; } };
但是会在最后一个点超时。超时的原因是在枚举数字上浪费了太多时间,如果能在一个循环内完成对两个数的枚举自然就不会超时了。
用ans变量来记录答案,设置mini变量来维护数组最小值。状态转移方程即是:
ans = max(ans, prices[i] - mini); mini = min(mini, prices[i]);
这样就可以计算出最大价值了。
class Solution { public: int maxProfit(vector<int>& prices) { int n = prices.size(); int ans = 0; int mini = INT_MAX; for(int i = 0; i < n; i++) { ans = max(ans, prices[i] - mini); mini = min(mini, prices[i]); } return ans; } };
LeetCodeOffer-42:连续子数组的最大和
给出一个包含正负整数的数组,要求找到一组下标连续的子数组的最大和。
设dp[i]表示以nums[i]结尾的子数组的最大和。
考虑两种情况:
- dp[i-1]<0:即dp[i-1]对下一个状态做负贡献,此时dp[i-1]+nums[i]显然不如直接累加nums[i];
- dp[i-1]>0:dp[i-1]可以做正贡献,此时正常累加。
根据上述思路可以得出状态转移方程:
dp[i] = max(dp[i-1] + nums[i], nums[i]);
对于所有的dp[i],我们返回它们的最大值作为答案。
class Solution { public: int maxSubArray(vector<int>& nums) { int n = nums.size(); int dp[100010]; int ans = dp[0] = nums[0]; for(int i = 1; i < n; i++) { dp[i] = max(dp[i-1] + nums[i], nums[i]); ans = max(dp[i], ans); } return ans; } };
LeetCode-618:最长重复子数组
设dp[i][j]表示A数组后i个元素与B数组后j个元素中最长重复子数组的长度,初始状态为dp[alen][blen]=A[alen]==B[blen]?1:0,即从尾部往前计算。
状态转移方程为:
A[i]==B[j]:dp[i][j] = dp[i+1][j+1]+1; A[i]!=B[j]:dp[i][j] = 0;
方程很好理解,如果当前元素相等,那么就在前一个状态的基础上加上一个单位的长度;如果不相等,当前情况自然就清零了,从下一个相等的字符开始重新计数。
class Solution { public: int findLength(vector<int>& A, vector<int>& B) { int alen = A.size(); int blen = B.size(); int dp[1010][1010]; int ans = 0; for (int i = alen - 1; i >= 0; i--) { for (int j = blen - 1; j >= 0; j--) { if(A[i] == B[j]) dp[i][j] = dp[i + 1][j + 1] + 1; else dp[i][j] = 0; ans = max(ans, dp[i][j]); } } return ans; } };
LeetCode-64:最小路径和
经典的二维宫格最短路,从左上走到右下。设dp[i][j]表示从起点走到i,j的最短路,因为每一步只能走右或下,即当前状态只可能由左边或上边的状态得到。所以状态转移方程不难得出:
dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]
初始条件下,预处理第一行与第一列的dp值用以计算。
class Solution { public: int minPathSum(vector<vector<int>>& grid) { int n = grid.size(); int m = grid[0].size(); auto dp = vector< vector<int> >(n, vector<int>(m)); dp[0][0] = grid[0][0]; for(int i = 1; i < n; i++) dp[i][0] = dp[i-1][0] + grid[i][0]; for(int i = 1; i < m; i++) dp[0][i] = dp[0][i-1] + grid[0][i]; for(int i = 1; i < n; i++) for(int j = 1; j < m; j++) dp[i][j] = min(dp[i-1][j], dp[i][j-1]) + grid[i][j]; return dp[n-1][m-1]; } };
LeetCode-62:不同路径
跟上一题有异曲同工的地方,依然是经典的走宫格。
设dp[i][j]表示从起点走到(i,j)的路径数。因为只能往下和往右,所以通往当前位置的路径数只与dp[i][j-1]和dp[i-1][j]有关,那么不难得到状态转移方程:
dp[i][j] = dp[i-1][j] + dp[i][j-1];
初始状态下,第一列与第一行的dp值都为1,因为只有一种路径。
class Solution { public: int uniquePaths(int m, int n) { int dp[110][110]; for(int i = 0; i < m; i++) dp[i][0] = 1; for(int i = 0; i < n; i++) dp[0][i] = 1; for(int i = 1; i < m; i++) for(int j = 1; j < n; j++) dp[i][j] = dp[i-1][j] + dp[i][j-1]; return dp[m-1][n-1]; } };
LeetCode-63:不同路径II
上一题的升级版,多了个障碍的设定。
显然,如果当前位置是障碍,那么肯定是走不通了,所以障碍位置的dp值一定是0。其他地方都和上一题一致。
class Solution { public: int uniquePathsWithObstacles(vector<vector<int>>& grid) { const int m = grid.size(); const int n = grid[0].size(); auto dp = vector< vector<int> >(m, vector<int>(n)); //int dp[m][n];memset(dp, 0, sizeof(dp)); if(m == 0) return 0; for(int i = 0; i < m && grid[i][0] == 0; i++) dp[i][0] = 1; for(int i = 0; i < n && grid[0][i] == 0; i++) dp[0][i] = 1; for(int i = 1; i < m; i++) for(int j = 1; j < n; j++) if(grid[i][j] == 0) dp[i][j] = dp[i-1][j] + dp[i][j-1]; return dp[m-1][n-1]; } };
LeetCode-343:整数拆分
划分型dp。显然,我们不难发现,每个整数拆分出的因子积取决于比他小的整数的因子积,所以可以用dp。假设dp[n]表示n的最大因子积。
对于每个整数i,拆分出因子j,此时获得的因子积为j*(i-j),如果继续拆分,则因子积为j*dp[i-j]。所以,对于单个j,dp[i] = max(j*(i-j), dp[i-j])。因为j∈(0,i),所以实际上dp[i]的值是对于取值范围中所有j求出的最大值。状态转移方程为:
dp[i] = max{max(j * dp[i-j], j * (i - j))};
那么不难得出答案:
class Solution { public: int integerBreak(int n) { vector <int> dp(n+1); dp[0] = dp[1] = 0; for(int i = 2; i <= n; i++) { int cur = -INT_MAX; for(int j = 1; j < i; j++) cur = max(max(j * (i - j), j * dp[i-j]), cur); dp[i] = cur; } return dp[n]; } };
LeetCode-120:三角形最小路径
它曾经还是1994年IOI的竞赛题目,如今沦落为本蒟蒻的练手题…
又是最短路dp。这次的地图是三角形的,每次只能往下或往右下走,那么思路也是一样:
设dp[i][j]表示从起点到(i,j)的最短路径,与之前一样,可以根据题设得到状态转移方程:
dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + grid[i][j];
初始条件下,三角形数组的高与斜边需要初始化。dp[i][0]的值就是第一列的前缀和,dp[i][i]的值也是dp[0][0]~dp[i-1][i-1]的前缀和,因为在高与斜边上的方格都有且仅有一条路径可以走。
class Solution { public: int minimumTotal(vector<vector<int>>& tri) { int ans = INT_MAX; int n = tri.size(); vector< vector<int> >dp(n, vector<int>(n)); dp[0][0] = tri[0][0]; for(int i = 1; i < n; i++) { dp[i][0] += dp[i-1][0] + tri[i][0]; dp[i][i] += dp[i-1][i-1] + tri[i][i]; } for(int i = 1; i < n; i++) for(int j = 1; j < i; j++) dp[i][j] = min(dp[i-1][j], dp[i-1][j-1]) + tri[i][j]; return *min_element(dp[n-1].begin(), dp[n-1].end()); } };
LeetCode-647:回文子串
统计回文子串的题,其实回文串的dp都是一个套路,核心就是回文子串具有子结构性质。
与下一题相似,设dp[l][r]=1表示从第l个字符到第r个字符的子串是回文子串,那么有状态转移方程:
dp[l][r] = dp[l+1][r-1] && (s[l] == s[r]);
其中r=l+d,两层循环外层枚举子串长度d,内层枚举子串起始位置l。
最后用一个累加器统计答案,如果dp[l][r]=1就++。
class Solution { public: int countSubstrings(string s) { int ans = 0; bool dp[1010][1010]; const int n = s.size(); for(int d = 0; d < n; d++) { for(int l = 0; l < n; l++) { int r = l + d; if(r >= n) break; if(d == 0) dp[l][r] = 1; else if(d == 1) dp[l][r] = (s[l] == s[r]); else dp[l][r] = dp[l+1][r-1] && (s[l] == s[r]); if(dp[l][r]) ans++; } } return ans; } };
LeetCode-5:最长回文子串
回文串具有很好的状态转移性质,因为一个回文串掐头去尾后它还是个回文串,这个结论是显然成立的。
根据上述思路,我们设dp[i][j]=1表示以第i个字符开头第j个字符结尾的子串是回文串。如果s[i]==s[j]并且dp[i+1][j-1]也等于1,那么显然当前子串也是回文串,至此我们可以得到状态转移方程:
dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1];
具体实现上,外层循环枚举当前子串长度,内层循环枚举子串起始位置即可。
class Solution { public: string longestPalindrome(string s) { string ans; const int n = s.size(); vector< vector<bool> > dp(n, vector<bool>(n)); for(int len = 0; len < n; len++) { for(int i = 0; i + len < n; i++) { int j = i + len; if(len == 0) dp[i][j] = 1; else if(len == 1) dp[i][j] = s[i] == s[j]; else dp[i][j] = (s[i] == s[j]) && dp[i+1][j-1]; if(dp[i][j] && len + 1 > ans.size()) ans = s.substr(i, len + 1); } } return ans; } };
评论
动态规划实际上不能够说是「一个算法」,只能说是一种编程方法,人们面对某种问题时,可能会参考动态规划的思想来设计出相应的算法来进行求解.参考Wikipedia:”Dynamic programming is both a mathematical optimization method and a computer programming method.”
Wayne 是这样的,在严谨的定义上dp确实没有被归到算法大类下,但一般情况下话说得也不会那么严谨啦