1. 程式人生 > 其它 >LeetCode - 9. 動態規劃

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;
}