LeetCode - 9. 動態規劃
目錄刷題順序來自:程式碼隨想錄
基礎題目
70. 爬樓梯
到達第i
層樓梯的方法數量等於到達第i-1
層與第i-2
層的方法數量之和。遞推公式為:dp[i]=dp[i-1]+dp[i-2]
,答案實際上就是斐波那契數。
public int climbStairs(int n) { if(n <= 2) { return n; } int f1 = 1, f2 = 2, res = 3; for(int i = 0; i < n - 2; i++) { res = f1 + f2; f1 = f2; f2 = res; } return res; }
746. 使用最小花費爬樓梯
遞推公式為:dp[i] = Math.min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
public int minCostClimbingStairs(int[] cost) { int[] dp = new int[cost.length+1]; for(int i = 2; i < dp.length; i++) { dp[i] = Math.min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]); } return dp[dp.length-1]; }
62. 不同路徑
遞推公式為:dp[i][j] = dp[i][j-1] + dp[i-1][j]
public int uniquePaths(int m, int n) {
int[][] dp = new int[m][n];
// dp陣列初始化
for(int i = 0; i < m; i++) {
dp[i][0] = 1;
}
for(int j = 0; j < n; j++) {
dp[0][j] = 1;
}
// 計算dp陣列
for(int i = 1; i < m; i++) {
for(int j = 1; j < n; j++) {
dp[i][j] = dp[i][j-1] + dp[i-1][j];
}
}
return dp[m-1][n-1];
}
63. 不同路徑 II
遞推公式與上一題相同,但是需要注意不能直接初始化第一行和第一列,由於障礙物的存在,只能初始化第一個位置,然後根據第一個位置更新其他位置。
public int uniquePathsWithObstacles(int[][] obstacleGrid) {
int m = obstacleGrid.length;
int n = obstacleGrid[0].length;
// dp陣列初始化
int[][] dp = new int[m][n];
if(obstacleGrid[0][0] != 1) {
dp[0][0] = 1;
}
// 計算dp陣列
for(int i = 0; i < m; i++) {
for(int j = 0; j < n; j++) {
if(obstacleGrid[i][j] != 1) {
// 在計算時要考慮索引是否合法
if(i - 1 >= 0) {
dp[i][j] += dp[i-1][j];
}
if(j - 1 >= 0) {
dp[i][j] += dp[i][j-1];
}
}
}
}
return dp[m-1][n-1];
}
343. 整數拆分
dp[i]
表示整數i
被拆分成若干整數後的最大乘積。在計算dp[i]
時,如果要將i
拆分出整數j
,那麼最大乘積為dp[j]
和j
的較大值乘以i-j
,那麼只需遍歷2到i-1
,找出使dp[i]
最大的j
即可。
public int integerBreak(int n) {
int[] dp = new int[n+1];
// dp陣列初始化
dp[2] = 1;
// 計算dp陣列
for(int i = 3; i <= n ; i++) {
// 計算dp[i]的值
for(int j = 2; j <= i - 1; j++) {
dp[i] = Math.max(dp[i], Math.max(j, dp[j]) * (i - j));
}
}
return dp[n];
}
96. 不同的二叉搜尋樹
計算dp[3]
:以1為根節點的數量 + 以2為根節點的數量 + 以3為根節點的數量
- 以1為根節點:
dp[0]+dp[2]
- 以2為根節點:
dp[1]+dp[1]
- 以3為根節點:
dp[2]+dp[0]
public int numTrees(int n) {
int[] dp = new int[n+1];
// dp陣列初始化
dp[0] = 1;
dp[1] = 1;
// 計算dp陣列
for(int i = 2; i <= n; i++) {
for(int j = 1; j <= i; j ++) {
dp[i] += dp[j-1] * dp[i-j];
}
}
return dp[n];
}
01揹包問題
01揹包理論基礎,分別包括二維DP陣列和一維DP陣列的講解
416. 分割等和子集
轉化為01揹包問題,即找出容量為sum/2
的揹包最多能裝多少物品,如果恰好也為sum/2
,說明能找到平均分割的子集。
- 在本題中,物品的體積和價值相同
public boolean canPartition(int[] nums) {
int sum = 0;
for(int i = 0; i < nums.length; i++) {
sum += nums[i];
}
// 必須是偶數才能平分
if(sum % 2 != 0) {
return false;
}
// dp陣列
int[] dp = new int[sum/2 + 1];
for(int i = 0; i < nums.length; i++) {
for(int j = dp.length-1; j >= nums[i]; j--) {
dp[j] = Math.max(dp[j], dp[j-nums[i]] + nums[i]);
}
}
return dp[dp.length-1] == sum/2;
}
1049. 最後一塊石頭的重量 II
本題與上一題本質上相同,即尋找累加和最接近sum/2
的子集,找到該子集之後,求出剩下子集的累加和與該子集的累加和之差即為答案。
public int lastStoneWeightII(int[] stones) {
int sum = 0;
for(int i = 0; i < stones.length; i++) {
sum += stones[i];
}
// 注意dp陣列的容量+1, 否則會忽略揹包容量為sum/2時的情況
int[] dp = new int[sum/2 + 1];
for(int i = 0; i < stones.length; i++) {
for(int j = dp.length - 1; j >= stones[i]; j--) {
// 同樣的, 物品體積和價值相同
dp[j] = Math.max(dp[j], dp[j-stones[i]] + stones[i]);
}
}
// 剩餘子集累加和 - 當前子集累加和
return sum - dp[dp.length-1] * 2;
}
494. 目標和
詳細思路參考16.目標和。
public int findTargetSumWays(int[] nums, int target) {
int sum = 0;
for(int i = 0; i < nums.length; i++) {
sum += nums[i];
}
// 無法完成的情況
if((target + sum) % 2 != 0 || (target + sum) < 0) {
return 0;
}
// 初始化時注意dp[0]取1, 否則dp陣列將全為0
int[] dp = new int[(target + sum) / 2 + 1];
dp[0] = 1;
// 計算dp陣列, 累加之間的結果
for(int i = 0; i < nums.length; i++) {
for(int j = dp.length - 1; j >= nums[i]; j--) {
dp[j] += dp[j-nums[i]];
}
}
return dp[dp.length - 1];
}
474. 一和零
和之前的01揹包問題類似,不同的是,揹包容量變成了兩個維度:能裝多少個'0'
以及能裝多少個'1'
。
public int findMaxForm(String[] strs, int m, int n) {
int[] zeros = new int[strs.length];
int[] ones = new int[strs.length];
// 首先計算出strs陣列中, 每個元素有多少0和1
for(int i = 0; i < strs.length; i++) {
for(int j = 0; j < strs[i].length() ; j++) {
if(strs[i].charAt(j) == '0') {
zeros[i]++;
}
else {
ones[i]++;
}
}
}
// 初始化dp陣列
int[][] dp = new int[m+1][n+1];
// 在計算時, 需要考慮兩個維度, 能裝多少0以及能裝多少1
for(int i = 0; i < strs.length; i++) {
for(int j = m; j >= zeros[i]; j--) {
for(int k = n; k >= ones[i]; k--) {
dp[j][k] = Math.max(dp[j][k], dp[j-zeros[i]][k-ones[i]] + 1);
}
}
}
return dp[m][n];
}
完全揹包問題
完全揹包問題需要兩層迴圈的順序,例如當我們求組合數量時:
- 先遍歷物品,再遍歷揹包容量:將
[1, 1, 3]
,[1, 3, 1]
,[3, 1, 1]
視為同一個組合,計數為1 - 先遍歷揹包容量,再遍歷物品:將
[1, 1, 3]
,[1, 3, 1]
,[3, 1, 1]
視為不同的組合,計數為3
518. 零錢兌換 II
由於是求組合數,需要注意初始化和dp陣列遞推公式。組合內元素沒有順序,所以先遍歷物品,再遍歷揹包容量。
public int change(int amount, int[] coins) {
int[] dp = new int[amount+1];
dp[0] = 1; // 初始化
for(int i = 0; i < coins.length; i++) {
for(int j = coins[i]; j < dp.length; j++) {
dp[j] += dp[j-coins[i]]; // 求組合數
}
}
return dp[dp.length-1];
}
377. 組合總和 Ⅳ
組合內元素有順序,所以先遍歷揹包容量,再遍歷物品。
public int combinationSum4(int[] nums, int target) {
int[] dp = new int[target+1];
dp[0] = 1;
for(int i = 0; i < dp.length; i++) {
for(int j = 0; j < nums.length; j++) {
if(i >= nums[j]) {
dp[i] += dp[i-nums[j]];
}
}
}
return dp[target];
}
70. 爬樓梯
爬樓梯問題實際上也是完全揹包問題,物品有1和2兩種,並且組合元素區分順序。如果一次性可以爬m
個臺階,則修改為j <= m
即可。
public int climbStairs(int n) {
int[] dp = new int[n+1];
dp[0] = 1;
for(int i = 0; i < dp.length; i++) {
for(int j = 1; j <= 2; j++) {
if(i >= j) {
dp[i] += dp[i-j];
}
}
}
return dp[n];
}
322. 零錢兌換
需要將dp陣列除了dp[0]
以外的其他位置初始化為一個足夠大的數,這裡初始化為amount+1
。
public int coinChange(int[] coins, int amount) {
// dp陣列初始化, 除了dp[0] = 0之外, 其他位置初始化為amount + 1
int[] dp = new int[amount+1];
for(int i = 1; i <dp.length; i++) {
dp[i] = amount + 1;
}
// 計算dp陣列時取較小值
for(int i = 0; i < coins.length; i++) {
for(int j = coins[i]; j < dp.length; j++) {
dp[j] = Math.min(dp[j], dp[j-coins[i]]+1);
}
}
if(dp[amount] == amount+1) {
return -1;
}
return dp[amount];
}
279. 完全平方數
public int numSquares(int n) {
// dp陣列初始化
int[] dp = new int[n+1];
for(int i = 0; i < dp.length; i++) {
dp[i] = i;
}
int m = (int) Math.sqrt(n); // 物品種類為1~m
for(int i = 1; i <= m; i++) {
for(int j = i*i; j < dp.length; j++) {
dp[j] = Math.min(dp[j], dp[j-i*i] + 1);
}
}
return dp[n];
}
139. 單詞拆分
單詞可以重複使用,屬於完全揹包問題;單詞前後順序有區分,所以先遍歷揹包容量再遍歷物品。
public boolean wordBreak(String s, List<String> wordDict) {
// dp[i]表示長度為i的s的子串是否能被拆分
int[] dp = new int[s.length() + 1];
dp[0] = 1;
for(int i = 1; i <= s.length(); i++) {
for(int j = 0; j < wordDict.size(); j++) {
String word = wordDict.get(j);
if(i >= word.length() && dp[i-word.length()] == 1
&& word.equals(s.substring(i-word.length(), i))) {
dp[i] = 1;
}
}
}
return dp[dp.length - 1] == 1;
}
打家劫舍
198. 打家劫舍
dp[i]
表示如果打劫第i
個房間,能盜竊的最高金額是多少。使用這個方法,dp[i]
就一定會打劫第i
個房間,dp[dp.length-1]
也不是最大值。此時,dp陣列的遞推公式為dp[i] = nums[i] + maxValue(dp, i - 1)
。
public int rob(int[] nums) {
int[] dp = new int[nums.length];
dp[0] = nums[0];
for(int i = 1; i < nums.length; i++) {
dp[i] = nums[i] + maxValue(dp, i - 1);
}
return maxValue(dp, dp.length);
}
// 返回陣列array從0到end中的最大值, 不包括end
int maxValue(int[] array, int end) {
// 如果end為0, 返回0
if(end == 0) {
return 0;
}
int max = array[0];
for(int i = 1; i < end; i++) {
if(max < array[i]) {
max = array[i];
}
}
return max;
}
以下這種方法,dp陣列表示到第i
個房間為止,能盜竊的最多的錢,此時第i
個房間可以不被盜竊。
public int rob(int[] nums) {
// 考慮只有1個數字的情況
if(nums.length == 1) {
return nums[0];
}
// dp陣列初始化
int[] dp = new int[nums.length];
dp[0] = nums[0];
dp[1] = Math.max(nums[0], nums[1]);
// dp陣列計算
for(int i = 2; i < nums.length; i++) {
dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i]);
}
return dp[dp.length - 1];
}
213. 打家劫舍 II
修改上一題的邏輯,分別比較從第1家到倒數第2家能打劫到的錢與從第2家到倒數第1家能打劫到的錢。
public int rob(int[] nums) {
if(nums.length == 1) {
return nums[0];
}
return Math.max(robRange(nums, 0, nums.length - 1), robRange(nums, 1, nums.length));
}
// 與上一題邏輯相同
int robRange(int[] nums, int start, int end) {
int length = end - start;
if(length <= 1) {
return nums[start];
}
int[] dp = new int[length];
dp[0] = nums[start];
dp[1] = Math.max(nums[start], nums[start+1]);
for(int i = 2; i < dp.length; i++) {
dp[i] = Math.max(dp[i-1], dp[i-2] + nums[i+start]);
}
return dp[dp.length - 1];
}
337. 打家劫舍 III
後序遍歷,為了防止多次遞迴,需要儲存當前的值。map.get(root)
表示root
節點能打劫到的最大金額,可以不打劫root
。
HashMap<TreeNode, Integer> map = new HashMap<>();
public int rob(TreeNode root) {
if(root == null) {
return 0;
}
int left = rob(root.left);
int right = rob(root.right);
int leftChildren = 0;
int rightChildren = 0;
if(root.left != null) {
leftChildren = check(root.left.left) + check(root.left.right);
}
if(root.right != null) {
rightChildren = check(root.right.left) + check(root.right.right);
}
// 考慮是否要打劫root
map.put(root, Math.max(left + right, root.val + leftChildren + rightChildren));
return map.get(root);
}
int check(TreeNode root) {
return map.getOrDefault(root, 0);
}
股票問題
121. 買賣股票的最佳時機
- 貪心演算法
public int maxProfit(int[] prices) {
int profit = 0;
int min = prices[0]; // 記錄當前日期之前的價格的最小值
for(int i = 1; i < prices.length; i++) {
profit = Math.max(profit, prices[i] - min);
min = Math.min(min, prices[i]); // 更新最小价格值
}
return profit;
}
- 動態規劃:
dp[i][0]
表示第i
天持有股票的最大收益,dp[i][0]
表示第i
天不持有股票的最大收益
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length][2];
dp[0][0] = -prices[0]; // 持有股票
for(int i = 1; i < prices.length; i++) {
// 不持有股票: 前一天持有股票, 保持原狀; 前一天不持有股票, 今天買入股票
dp[i][0] = Math.max(dp[i-1][0], -prices[i]);
// 持有股票: 前一天持有股票, 今天賣出; 前一天不持有股票, 保持原狀
dp[i][1] = Math.max(dp[i-1][0] + prices[i], dp[i-1][1]);
}
return dp[dp.length - 1][1];
}
122. 買賣股票的最佳時機 II
- 貪心演算法
public int maxProfit(int[] prices) {
int profit = 0;
for(int i = 1; i < prices.length; i++) {
if(prices[i] - prices[i-1] > 0) {
profit += prices[i] - prices[i-1];
}
}
return profit;
}
- 動態規劃:和上一題不同的地方是,
dp[i][0]
在更新時,需要考慮前一天不持有股票的收益 (因為在上一題中,一定是0,但本題支援多次買賣)
public int maxProfit(int[] prices) {
int[][] dp = new int[prices.length][2];
dp[0][0] = -prices[0];
for(int i = 1; i < prices.length; i++) {
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] - prices[i]);
dp[i][1] = Math.max(dp[i-1][0] + prices[i], dp[i-1][1]);
}
return dp[dp.length-1][1];
}
714. 買賣股票的最佳時機含手續費
與前面類似,注意賣出時有手續費。
public int maxProfit(int[] prices, int fee) {
int[][] dp = new int[prices.length][2];
dp[0][0] = -prices[0];
for(int i = 1; i < dp.length; i++) {
dp[i][0] = Math.max(dp[i-1][0], dp[i-1][1] - prices[i]);
// 賣出時考慮手續費
dp[i][1] = Math.max(dp[i-1][0] + prices[i] - fee, dp[i-1][1]);
}
return dp[dp.length - 1][1];
}
309. 最佳買賣股票時機含冷凍期
與前面類似,需要注意當第i
天買入時, 需要保證第i-1
天是沒有賣出的, 所以考慮第i-2
天不持有股票,即dp[i-2][1] - prices[i]
。
public int maxProfit(int[] prices) {
if(prices.length == 1) {
return 0;
}
// 初始化時需要初始化前兩天
int[][] dp = new int[prices.length][2];
dp[0][0] = -prices[0];
dp[1][0] = Math.max(-prices[0], -prices[1]);
dp[1][1] = Math.max(0, -prices[0] + prices[1]);
for(int i = 2; i < dp.length; i++) {
// 唯一不同的地方是, 當第i天買入時, 需要保證第i-1天是沒有賣出的, 所以考慮第i-2天不持有股票
dp[i][0] = Math.max(dp[i-1][0], dp[i-2][1] - prices[i]);
dp[i][1] = Math.max(dp[i-1][0] + prices[i], dp[i-1][1]);
}
return dp[dp.length - 1][1];
}
123. 買賣股票的最佳時機 III
子序列問題
300. 最長遞增子序列
dp陣列初始化為1,遞推公式為:dp[i] = max(dp[i], dp[j] + 1)
。注意dp[i]
的意義,到i
個元素為止的最長遞增子序列的長度,且子序列必須包括元素i
,所以dp[dp.length-1]
不一定是最終結果。
public int lengthOfLIS(int[] nums) {
// dp陣列初始化為1
int[] dp = new int[nums.length];
Arrays.fill(dp, 1);
// 記錄最長的遞增子序列長度
int result = 1;
for(int i = 1; i < nums.length; i++) {
for(int j = 0; j < i; j++) {
if(nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
result = Math.max(result, dp[i]);
}
return result;
}
674. 最長連續遞增序列
public int findLengthOfLCIS(int[] nums) {
int result = 1; // 記錄最長連續遞增子序列長度
int count = 1; // 記錄當前連續遞增子序列長度
for(int i = 1; i < nums.length; i++) {
if(nums[i] > nums[i-1]) {
count++;
result = Math.max(result, count);
}
else {
count = 1;
}
}
return result;
}
718. 最長重複子陣列
public int findLength(int[] nums1, int[] nums2) {
int result = 0;
int[][] dp = new int[nums1.length + 1][nums2.length + 1];
for(int i = 1; i <= nums1.length; i++) {
for(int j = 1; j <= nums2.length; j++) {
if(nums1[i-1] == nums2[j-1]) {
dp[i][j] = dp[i-1][j-1] + 1;
}
result = Math.max(result, dp[i][j]);
}
}
return result;
}
1143. 最長公共子序列
與上一題類似,不同點在於,由於子序列不需要連續,所以dp陣列需要繼承之前的最長子序列長度。
public int longestCommonSubsequence(String text1, String text2) {
int[][] dp = new int[text1.length() + 1][text2.length() + 1];
for(int i = 1; i <= text1.length(); i++) {
for(int j = 1; j <= text2.length(); j++) {
if(text1.charAt(i - 1) == text2.charAt(j - 1)) {
dp[i][j] = dp[i-1][j-1] + 1;
}
else {
dp[i][j] = Math.max(dp[i][j-1], dp[i-1][j]);
}
}
}
return dp[text1.length()][text2.length()];
}
583. 兩個字串的刪除操作
本質上與上一題類似,求最大公共子序列。
public int minDistance(String word1, String word2) {
int[][] dp = new int[word1.length() + 1][word2.length() + 1];
for(int i = 1; i <= word1.length(); i++) {
for(int j = 1; j <= word2.length(); j++) {
if(word1.charAt(i - 1) == word2.charAt(j - 1)) {
dp[i][j] = dp[i-1][j-1] + 1;
}
else {
dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
}
}
}
int common = dp[word1.length()][word2.length()];
return word1.length() + word2.length() - common * 2;
}
1035. 不相交的線
53. 最大子陣列和
public int maxSubArray(int[] nums) {
int result = nums[0];
int sum = nums[0]; // 記錄當前累加和
for(int i = 1; i < nums.length; i++) {
sum = Math.max(sum + nums[i], nums[i]);
result = Math.max(result, sum);
}
return result;
}
392. 判斷子序列
採用雙指標法更簡單。
public boolean isSubsequence(String s, String t) {
if(s.length() == 0) {
return true;
}
int index = 0; // s串的下標
for(int i = 0; i < t.length(); i++) {
if(s.charAt(index) == t.charAt(i)) {
index++;
}
if(index == s.length()) {
return true;
}
}
return false;
}