LeetCode 312 Burst Balloons 思路分析總結

P.yh發表於2019-05-10

LC 312. Burst Balloons

題目描述

Given n balloons, indexed from 0 to n-1. Each balloon is painted with a number on it represented by array nums. You are asked to burst all the balloons. If the you burst balloon i you will get nums[left] * nums[i] * nums[right] coins. Here left and right are adjacent indices of i. After the burst, the left and right then becomes adjacent.

Find the maximum coins you can collect by bursting the balloons wisely.

Note:

You may imagine nums[-1] = nums[n] = 1. They are not real therefore you can not burst them. 0 ≤ n ≤ 500, 0 ≤ nums[i] ≤ 100


思路分析與程式碼

拿到這道題的時候,可以從題目描述看出最後要求解的問題是存在子問題的,而且最後的求解是 “最大值”,可以想到用動態規劃來求解,但是難點在於狀態怎麼定義?遞推方程是什麼?陣列裡面的數的數目是在動態變化的,如果直接從動態規劃的思路想,很難看出當前問題和前面的子問題的關係是什麼。因此,首先可以考慮暴力的深度優先搜尋,但是這裡有兩個思路:

  • 當前考慮的氣球是最扎爆的氣球
  • 當前考慮的氣球是最扎爆的氣球

其實第一種思路是最好理解的,但是寫程式碼的時候你會發現很多問題,例如如何知道當前數的左右相鄰的數是什麼?你可以通過移除陣列中的數來獲得,但是這會導致時間的增多,並不是一個高效的做法。在看看第二種思路,如果最後打爆的氣球編號是 i,那麼說明 [0,i-1] 和 [i+1,n-1] 兩個區間上面的氣球已經被打爆,這裡的答案就是 1*i*1 + [0,i-1]的解 + [i+1,n-1]的解,這樣一個問題被拆分成兩個子問題,子問題還可以繼續往下拆分

               i
        [0,i-1] [i+1,n-1]
          ...     ...

ans(0,n-1) = max(
    nums[-1]*nums[0]*nums[1]+ans(1,n-1),            // 最後打 0 號氣球
    ans(0,0)+nums[0]*nums[1]*nums[2]+ans(2,n-1),    // 最後打 1 號氣球
    ans(0,1)+nums[1]*nums[2]*nums[3]+ans(3,n-1),    // 最後打 2 號氣球
                      ...
    ans(0,n-3)+nums[n-3]*nums[n-2]*nums[n-1]+ans(n-1,n-1),  // 最後打 n-2 號氣球
    ans(0,n-2)+nums[n-2]*nums[n-1]*nums[n],         //最後打 n-1 號氣球
); where nums[-1] == nums[n] == 1
複製程式碼

這裡可以看出,這樣的思路和分治很像,的確如此,這道題目特殊的地方也在於此,它其實是動態規劃和分治的結合,我們稍後再詳細說明,根據上面的思路,我們可以轉化為程式碼:

public int maxCoins(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }
    
    return helper(nums, 0, nums.length - 1);
}

private int helper(int[] nums, int l, int r) {
    if (l > r) {
        return 0;
    }
    
    int max = nums[l];
    for (int i = l; i <= r; ++i) {
        int cur = helper(nums, l, i - 1)
                    + get(nums, l - 1) * nums[i] * get(nums, r + 1)
                    + helper(nums, i + 1, r);
        
        max = Math.max(max, cur);
    }
    
    return max;
}

private int get(int[] nums, int i) {
    if (i < 0 || i >= nums.length) {
        return 1;
    }
    
    return nums[i];
}
複製程式碼

上面的程式碼非常的簡潔,核心程式碼就是 for 迴圈裡面的遞迴,但是注意這只是暴力的解法,之所以是暴力是因為它做了很多之前做過、沒有必要的重複操作,你可以從之前講過的遞推公式可以看到,或者可以畫一個遞迴樹狀圖來看。這裡只需要加上一個陣列幫助記錄之前做過的事情,就可以大大提高效率

public int maxCoins(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }
    
    int[][] dp = new int[nums.length][nums.length];
    
    return helper(nums, dp, 0, nums.length - 1);
}

private int helper(int[] nums, int[][] dp, int l, int r) {
    if (l > r) {
        return 0;
    }
    
    if (dp[l][r] != 0) {
        return dp[l][r];
    }
    
    int max = nums[l];
    for (int i = l; i <= r; ++i) {
        int cur = helper(nums, dp, l, i - 1)
                    + get(nums, l - 1) * nums[i] * get(nums, r + 1)
                    + helper(nums, dp, i + 1, r);
        
        max = Math.max(max, cur);
    }
    
    dp[l][r] = max;
    
    return max;
}

private int get(int[] nums, int i) {
    if (i < 0 || i >= nums.length) {
        return 1;
    }
    
    return nums[i];
}
複製程式碼

其實上面的程式碼實現已經用到了動態規劃了,但是是使用了遞迴的實現方式,這時候我們再回過頭去看看動態規劃裡面的 “狀態” 和 “遞推公式” 就一目瞭然:

dp[i][j] -> 輸入陣列 [i,j] 區間上的最大值

dp[0,n-1] = max(
    nums[-1]*nums[0]*nums[1]+dp[1,n-1],         
    dp[0,0]+nums[0]*nums[1]*nums[2]+dp[2,n-1],
    dp[0,1]+nums[1]*nums[2]*nums[3]+dp[3,n-1],
                      ...
    dp[0,n-3]+nums[n-3]*nums[n-2]*nums[n-1]+dp[n-1,n-1],
    dp[0,n-2]+nums[n-2]*nums[n-1]*nums[n],
);
複製程式碼

有了狀態的定義和遞推公式,我們就可以直接上手動態規劃了,但是注意邊界條件:

public int maxCoins(int[] nums) {
    if (nums == null || nums.length == 0) {
        return 0;
    }
    
    int[] newNums = new int[nums.length + 2];
    
    newNums[0] = newNums[nums.length + 1] = 1;
    for (int i = 0; i < nums.length; ++i) {
        newNums[i + 1] = nums[i];
    }
    
    int n = newNums.length;
    
    int[][] dp = new int[n][n];

    for (int i = 2; i < n; ++i) {
        for (int l = 0; l < n - i; ++l) {
            int r = l + i;
            for (int j = l + 1; j < r; ++j) {
                dp[l][r] = Math.max(dp[l][r], 
                             newNums[l] * newNums[j] * newNums[r] 
                                    + dp[l][j] + dp[j][r]);
            }
        }
    }
    
    return dp[0][n - 1];
}
複製程式碼

這是一個二維的動態規劃,如果在紙上畫表格來看 DP 陣列的記錄軌跡的話,你會發現這裡記錄只用到二維矩陣的上三角,也就是以對角線為軸的一半;記錄順序也並不是一行一行下來的,而是以對角線進行的。


總結

這裡可以看到我們解這道題的一個過程是

  1. 思考並實現暴力求解
  2. 畫樹狀圖或者思考是否有重複的子問題
  3. 在暴力求解的基礎上,看能不能增加記憶化,記錄之前解過的子問題的解
  4. 通過狀態和遞推公式,試著用動態規劃求解

往往遇到不能一下子得到最優演算法,或者沒有什麼思路的題,都可以按這個步驟試試。往往動態規劃能夠解決的問題,暴力搜尋都可以解,但是反過來就不一定了。只是說暴力搜尋它並不是我們的終點,但它卻可以為我們提供一個不錯的突破口,我們在此基礎之上再來思考如何進一步地優化,得到我們最終想要看到的演算法。不斷地去熟練這麼一個過程,相信思維能力和直覺能力會不由自主地提高。

另外就是這道題的一個非常巧妙的地方就是把分治的思想加了進來,分治演算法與動態規劃演算法不同的地方,或者說是截然相反的地方是,分治是不存在重複子問題的,不理解的話可以想想快速排序,一個區間被一分為二,被分開的兩個區間不存在任何交集,它們各自在各自的空間內做事情;正是因為這一點,在思考暴力求解的時候,按照分治的思想,選擇的方向則是從結果導向,倒著去想,先分再合,分到不能分為止,再去合併,對於這道題來說,合併是非常簡單的,就是相加;但是如果我們按照一般動態規劃的思路順著去想,那麼打爆一個氣球后,這個氣球的左右區間將會合在一起,這無法將一個問題化成更小的問題去解決。雖然這樣的思路打破了我們通常的思維方式,但是還是那句話,多積累,現在不會以後會,見得多了就不怕了。

相關文章