這道題目做了兩個晚上,發現解題思路的優化過程非常有代表性。文章詳細說明了如何從回溯解法改造為分治解法,以及如何由分治解法過渡到動態規划解法。解法的用時從 超時 到 超過 95.6% 提交者,到超過 99.8% 提交者。現整理下來分享給大家,如有錯誤評論區歡迎指正!
題目如下:
回溯法
剛看到這個題目,腦中可以很輕易的想象出解空間的結構:一個n層的數組,每層的元素相同,我們從第一層走到第n層,每層走動時不能使用之前走過的元素。然后按照規則計算獲取的金幣,我們嘗試所有可以走的路徑並記錄下每條路徑所能獲得的金幣和,最大值即題目的解。在層數不確定的情況下,使用遞歸比for循環的嵌套更加方便,算法的框架如下:
//維護一個最大值 int maxCoin=0; public void getCoins(int[] nums,int currentLevel,int currentCoin){ //每層的氣球數,也是層級數 int length=nums.length; //回歸條件,我們走完最后一層了 if(currentLevel==length){ //如果該路徑所得金幣數大於當前最大值,更新最大值 if(currentCoin>maxCoin){ maxCoin=currentCoin; } } //嘗試該層所有可走的路徑 for(int i=0;i<length;i++){ //to do something } }
因為被戳破的氣球等於不存在,我們在計算獲得的金幣時需要做一點小小的處理。因為氣球上的數字是大於等於0的,我們將走過的氣球標志為-1。在計算可以獲得的金幣數時,如果相鄰的氣球是-1,則略過取相鄰的下一個氣球即可。另外,出於兩邊的氣球只有一個相鄰氣球,需要做一下特殊處理。我們將上述代碼的“嘗試所有可走路徑”中的“to do something”完善起來:
//嘗試該層所有可走的路徑 for(int i=0;i<length;i++){ //to do something //如果氣球已經被戳破了,略過 if(nums[i]==-1){continue;} //標記已經戳破的氣球,並保存氣球上的標號 int temp = nums[i]; nums[i] = -1; //獲取上一個氣球的數字 int before = i - 1; int beforeNum = 0; //略過被戳破的氣球 while (before > -1 && nums[before] == -1) { before--; } //到達邊界時的處理 if (before < 0) { beforeNum = 1; } else { beforeNum = nums[before]; } //獲取下一個氣球的數字 int next = i + 1; int nextNum = 0; //略過被戳破的氣球 while (next < length && nums[next] == -1) { next++; } //到達邊界時的處理 if (next > length - 1) { nextNum = 1; } else { nextNum = nums[next]; } //計算戳破當前氣球的coin int tempCoin = temp * nextNum * beforeNum; //遞歸搜索下一層,進行變量傳遞 maxCoins(nums,currentCoin+tempCoin,currentLevel+1); }
按上面的思路,這就是一個很簡單的搜索問題,但每走一層都會對下面的路徑造成影響,所以我們需要通過回溯的手法,每嘗試完一種可能性后,在嘗試下一種路徑前我們都要把之前路徑戳破的氣球恢復。回溯很簡單,只需要加一行代碼,即遞歸調用結束后將當前for循環中戳破的氣球恢復,即下方代碼標紅部分:
//嘗試該層所有可走的路徑 for(int i=0;i<length;i++){ //to do something //如果氣球已經被戳破了,略過 if(nums[i]==-1){continue;} //標記已經戳破的氣球,並保存氣球上的標號 int temp = nums[i]; nums[i] = -1; //獲取上一個氣球的數字 int before = i - 1; int beforeNum = 0; //略過被戳破的氣球 while (before > -1 && nums[before] == -1) { before--; } //到達邊界時的處理 if (before < 0) { beforeNum = 1; } else { beforeNum = nums[before]; } //獲取下一個氣球的數字 int next = i + 1; int nextNum = 0; //略過被戳破的氣球 while (next < length && nums[next] == -1) { next++; } //到達邊界時的處理 if (next > length - 1) { nextNum = 1; } else { nextNum = nums[next]; } //計算戳破當前氣球的coin int tempCoin = temp * nextNum * beforeNum; //遞歸搜索下一層,進行變量傳遞 maxCoins(nums,currentCoin+tempCoin,currentLevel+1);
//回溯
nums[i]=temp; }
回溯的完整代碼如下:
/** * @Author Nyr * @Date 2019/11/30 22:24 * @Param nums:氣球數組, y:遞歸層級,即currentLevel, length:數組長度,防止每層都計算一次, beforeCoins:之前所有層獲得的金幣和,即currentCoin * @Return * @Exception * @Description 回溯解法 */ public static void maxCoins2(int[] nums, int y, int length, int beforeCoins) { //回歸條件 if (y == length) { if (beforeCoins > maxCoin) { maxCoin = beforeCoins; } return; } for (int i = 0; i < length; i++) { //略過已經戳破的氣球 if (nums[i] == -1) { continue; } //標記已經戳破的氣球 int temp = nums[i]; nums[i] = -1; //獲取上一個氣球的數字 int before = i - 1; int beforeNum = 0; while (before > -1 && nums[before] == -1) { before--; } if (before < 0) { beforeNum = 1; } else { beforeNum = nums[before]; } //獲取下一個氣球的數字 int next = i + 1; int nextNum = 0; while (next < length && nums[next] == -1) { next++; } if (next > length - 1) { nextNum = 1; } else { nextNum = nums[next]; } //計算戳破當前氣球的coin int tempCoin = temp * nextNum * beforeNum; //遞歸進行下一戳 maxCoins2(nums, y + 1, length, beforeCoins + tempCoin); //回溯嘗試其它戳法 nums[i] = temp; } }
上述解法通過與樣例代碼跑的結果比較進行測試,結果是正確的。
為什么要與樣例代碼的結果比較而不直接提交呢,當然是超時導致提交不通過。細想下當前解法的時間復雜度就可以知道,不通過是有原因的。
每層有n中選擇,第i層有n-i中選擇,時間復雜度為n*(n-1)*(n-2)...*1即 !n。n的階乘,指數級的時間復雜度,太可怕,我們應該想辦法優化它。
我們都都知道,算法的時間復雜度分為多項式級時間復雜度與非多項式級時間復雜度,我們來重溫一下時間復雜度的排名:
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n)< O(!n)
其中 O(!n)與O(2^n)被稱為非多項式級時間復雜度,增長速度大於且遠遠大於前面的多項式級時間復雜度。
當遇到時間復雜度為 !n 的算法時,首先考慮的是使用分治的方式將問題規模縮小。因為 !n 的增長率是恐怖的,縮小問題規模,時間復雜度的優化效果也將是立竿見影的。下面看一個很簡單的例子,8的階乘是遠大於兩個4的階乘的和的:
8的階乘是40320。我們如果將問題分解,比如對半分則我們將得到兩個問題規模為4的子問題,時間復雜度為4的階乘加4的階乘等於48。
在將規模為8的原問題分解為兩個子問題時,我們將會有6種分法,為了覆蓋解空間我們需要將所有子問題的分解方式都嘗試一次,則嘗試所有分法的計算次數為∑( !k +!(n-k)),其中0<k<n。以問題規模為8時為例,將問題分為兩個子問題的計算次數將是1804,與原問題計算40320次時相比,性能得到了極大的提升。
下面我們就嘗試使用分治法來求解該問題。
分治法
在使用分治法時,我們應該考慮的核心問題是如何用子問題的解來表示原問題的解,也就是子問題該如何划分才能通過子問題來求解原問題。我們把描述子問題的解與原問題的解之間的關系的表達式稱為狀態轉移方程。
首先我們嘗試每戳破一個氣球,以該氣球為邊界將氣球數組分為兩部分,使用這兩部分的解來求解原問題。
我們設戳破區間 i 到 j 間的氣球我們得到的最大金幣數為coin。及coin = def( i , j )。
則當我們戳破氣球 k 時,兩邊區間的最大值分別是 def( i , k-1 ) 與 def( k+1 , j )。
此時我們發現了問題,因為戳破了氣球 k ,氣球數組的相鄰關系發生了改變,k-1 與 k+1 原本都與 k 相鄰,而 k 戳破后他們兩個直接相鄰了。而且先戳破 k+1 與先戳破 k-1 得到的結果將完全不同,也就是說兩個子問題間發生了依賴。如果先戳破 k-1 ,則 k+1 左邊的相鄰氣球變成了 k-2;反之 k-1 右邊相鄰的氣球變成了 k+2 。
子問題的處理順序將影響到每個子問題的解,這將使我們的狀態轉移方程極為復雜和低效,我們應當換一種划分子問題的方式,使每個子問題都是獨立的。
那么我們換一種划分方式,既然兩個子問題都依賴 k 和兩個邊界,那么我們划分子問題時,k 與兩個邊界的氣球我們都不戳破,求出 i+1 到 k-1 與 k+1 到 j-1 之間的解。這樣兩個子問題間的依賴便被消除了,兩個邊界及氣球 k 不被戳破,兩個子問題的依賴都不會越過 k 到另一個子問題上,子問題間是相互獨立的。
並且在兩個子問題解決后,氣球序列還剩下 k 與兩個邊界的氣球沒有戳破,那么我們用兩個子問題的解與戳破 k 與兩個邊界的最大值即可求出原問題的解。
那么 def( i , j ) 函數的定義則為,不戳破 i 與 j ,僅戳破 i 與 j 之間的氣球我們能得到的最大金幣數。
如此划分,狀態轉移方程為: def( i, j ) = def( i , k ) + def( k , j )+nums[ i ][ j ][ k ]
其中 nums[ i ][ j ][ k ] 為戳破氣球 k 時我們能得到的金幣數,因為def( i , j )表示戳破 i 到 j 之間的氣球,自然包括 k 。
上述方程其實還有問題,前面說過,為了保證我們可以完整的搜索解空間,我們需要嘗試所有的子問題划分方式,對於上述狀態轉移方程,也就是 k 的取值。k 的取值應當介於 i+1 與 j-1 之間,我們嘗試所有 k 的取值並從中挑選最大值,這才是原問題真正的解。
真正的狀態轉移方程應該為:def( i, j ) = max { def( i , k ) + def( k , j )+nums[ i ][ j ][ k ] } | i<k<j
這樣我們便找到了用子問題的解來表示原問題的解的方法,或者說子問題的划分方式。因為我們要划分子問題,必然不是只划分一次這么簡單。而是要把問題一直划分到不能繼續划分,也就是划分到問題規模最小的最小子問題,使效率最大化。
因為 k 是介於 i 與 j 之間的,那么當 i 與 j 相鄰時我們的問題將不能再繼續划分。此時按照我們對問題的定義,“不戳破 i 與 j ,僅戳破 i 與 j 之間的氣球”,因為 i 與 j 之間沒有氣球,我們得到的金幣數是 0 。
為了保證問題定義的正確性,我們向上推演一次。def( i , i+2 ) = def( i , i+1 ) + def( i+1 , i+2 ) + nums[i]*nums[ i+1]*nums[i+2]
def( i , i+1 ) , def( i+1 , i+2 ) 都是最小子問題,返回0。即 def( i , i+2 ) = nums[i]*nums[ i+1]*nums[i+2] 。因為問題的定義我們不戳破 i 與 i+2,所以我們只能戳破 i+1,戳破 i+1得到的金幣確實是 nums[i]*nums[ i+1]*nums[i+2] 即 def( i , i+2 ) 。
所以說對於我們的狀態轉移方程 def( i, j ) = max { def( i , k ) + def( k , j )+nums[ i ][ j ][ k ] } | i<k<j ,回歸條件 def( i , i+1 ) = 0 是正確的。
狀態轉移方程與回歸條件都找到了,實現起來就很簡單了:
/** * @Author Nyr * @Date 2019/11/30 0:23 * @Param nums:氣球數組;length:數組長度,避免每層都計算一次;begin:開始下標;end:結束下標;cache:緩存,避免重復計算 * @Return * @Exception * @Description 狀態轉移方程 def( i, j ) = max { def( i , k ) + def( k , j )+nums[ i ][ j ][ k ] } | i<k<j 的實現 */ public static int maxCoins4(int[] nums, int length, int begin, int end,int[][] cache) { //回歸條件,問題分解到最小子問題 if (begin == end - 1) { return 0; } //緩存,避免重復計算 if(cache[begin][end]!=0){ return cache[begin][end]; } //維護一個最大值 int max = 0; //狀態轉移方程 def( i, j ) = max { def( i , k ) + def( k , j )+nums[ i ][ j ][ k ] } | i<k<j for (int i = begin + 1; i < end; i++) { int temp = maxCoins4(nums, length, begin, i,cache) + maxCoins4(nums, length, i, end,cache) + nums[begin] * nums[i] * nums[end]; if (temp > max) { max = temp; } } //緩存,避免重復計算 cache[begin][end]=max; return max; }
我們再封裝一層方法,對空數組進行處理。因為 def( i , j ) 並不戳破兩個邊界的氣球,我們為氣球數組加上虛擬的邊界:
public static final int maxCoins4MS(int[] nums) { //空數組處理 if (nums == null) { return maxCoin; } //加虛擬邊界 int length = nums.length; int[] nums2=new int[length+2]; System.arraycopy(nums,0,nums2,1,length); nums2[0]=1; nums2[length+1]=1; length=nums2.length; //創建緩存數組 int[][] cache=new int[length][length]; //調用分治函數 return maxCoins4M(nums2, length,cache); } public static int maxCoins4M(int[] nums, int length,int[][] cache) { int max = maxCoins4(nums, length, 0, length - 1,cache); return max; }
實現很簡單,一個帶緩存的遞歸調用,我們來看一下效果,測試代碼如下:
public static void main(String[] args) { int[] nums = {3,4,5,6,7,5,7,8,5,3,2,5}; long start = System.currentTimeMillis(); start = System.currentTimeMillis(); System.out.println(maxCoins(nums)); System.out.println("原始回溯用時 : " + String.valueOf(System.currentTimeMillis() - start)+" 運算次 數:"+sum3); start = System.currentTimeMillis(); System.out.println(maxCoins4MS(nums)); System.out.println("分治用時 : " + String.valueOf(System.currentTimeMillis() - start)+" 運算次 數:"+sum1+" 實際運算次數:"+sum2); }
我在運算時加入和調用次數的計數以及計時,因為使用的是System.currentTimeMillis()進行計時,時間的精度是毫秒級,可以看到結果完全相同,分治法甚至在1毫秒之內解決了回溯使用26秒才能解決的問題。
運算次數上回溯運算了 8億次 而分治法只運算了 573次,並且其中真正的運算只有 78次,另外的 495次是通過緩存避免的重復計算。
指數級(非多項式級時間復雜度的一種)時間復雜度的可怕可見一斑!
用分治法提交:
效率提升到這里好像挺完美了,但我們還忽略了一點:即使使用了分治使時間復雜度大幅下降,但我們的實現中還存在着遞歸調用。遞歸調用的效率是很低的,因為牽扯到大量的函數調用,即棧幀的創建與釋放。而且由於臨時變量的存在以及需要保存之前棧幀的esp、程序計數器等寄存器值,在遞歸層數加深時會占用大量的棧空間,非常容易引起爆棧。這樣的代碼是絕對不可以放到生產環境上的,我們應該去思考遞歸調用的回歸過程,通過模擬回歸過程來用遞推實現上述代碼。
用遞推模擬回歸過程的方法,就是在上述實現的緩存 cache[i][j] 中逐漸推演,通過一步步的解決小問題來得到最終問題的解,這便是動態規划解法。
動態規划
動態規划算法通常用於求解具有某種最優性質的問題。在這類問題中,可能會有許多可行解。每一個解都對應於一個值,我們希望找到具有最優值的解。動態規划算法與分治法類似,其基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然后從這些子問題的解得到原問題的解。與分治法不同的是,適合於用動態規划求解的問題,經分解得到子問題往往不是互相獨立的。對於分治法求解的問題,子問題的相互獨立僅僅是同層級的子問題間沒有互相依賴。但對於動態規划而言,同層級的子問題可能會依賴相同的低層級問題,這就導致低層級問題可能會被計算多次。
若用分治法來解這類問題,則分解得到的子問題數目太多,有些子問題被重復計算了很多次。如果我們能夠保存已解決的子問題的答案,而在需要時再找出已求得的答案,這樣就可以避免大量的重復計算,節省時間。我們可以用一個表來記錄所有已解的子問題的答案。不管該子問題以后是否被用到,只要它被計算過,就將其結果填入表中。這就是動態規划法的基本思路。具體的動態規划算法多種多樣,但它們具有相同的填表格式。
其實在上面的分治解法,我加入了一個二維數組用於緩存已經計算過的子問題的結果,將緩存去掉才是概念上的分治解法。而加入了緩存避免了子問題的重復計算,已經是一個動態規划解法的雛形,我們只需要將遞歸改為遞推便是動態規划解法。正如上面所說,通常情況下,遞歸的解法是不可以放在生產環境的,因為我們很難控制問題規模的大小,無法預料何時會有爆棧的風險。
具有最優子結構性質以及重疊子問題性質的問題可以通過動態規划求解。
最優子結構
- 如果一個問題的最優解包含其子問題的最優解,我們就稱此問題具有最優子結構
- 一個問題具有最優子結構,可能使用動態規划方法,也可能使用貪心方法。所以最優子結構只是一個線索,不是看到有最優子結構就一定是用動態規划求解
重疊子問題
- 子問題空間必須足夠“小”,即在不斷的遞歸過程中,是在反復求解大量相同的子問題,而不是每次遞歸時都產生新的子問題。
- 一般的,不同子問題的總數是輸入規模的多項式函數為好
- 如果遞歸算法反復求解相同的子問題,我們就稱最優化問題具有重疊子問題性質
對於前面的分治解法,我們的計算過程分為兩個階段:
1、遞歸的不斷的分解問題,直到問題不可繼續分解。
2、當問題不可繼續分解,也就是分解到最小子問題后,由最小子問題的解逐步向上回歸,逐層求出上層問題的解。
階段1我們稱為遞歸過程,而階段2我們稱為遞歸調用的回歸過程。我們要做的,就是省略遞歸分解子問題的過程,將階段2用遞推實現出來。
舉個例子,對於區間 0 到 4 之間的結果,遞歸過程是:
dp[0][4] =max { dp[0][1]+dp[1][4]+nums[0]*nums[1]*nums[4] , dp[0][2]+dp[2][4]+nums[0]*nums[2]*nums[4] , dp[0][3]+dp[3][4]+nums[0]*nums[3]*nums[4] }
標紅部分沒有達到回歸條件,會繼續向下分解,以 dp[1][4] 為例:
dp[1][4]= max { dp[1][2]+dp[2][4]+nums[1]*nums[2]*nums[4] , dp[1][3]+dp[3][4]+nums[1]*nums[3]*nums[4] }
標紅部分繼續分解:
dp[2][4]= dp[2][3] + dp[3][4] + nums[2]*nums[3]*nums[4]
dp[1][3] = dp[1][2] + dp[1][3] + nums[1]*nums[2]*nums[3]
到這里因為已經分解到了最小子問題,最小子問題會帶着它們的解向上回歸,也就是說我們的回歸過程是:dp[3][4] , dp[2][3] , dp[2][4] , dp[1][2] , dp[1][3] , dp[1][4] , dp[0][1] , dp[0][2] , dp[0][3] , dp[0][4] 。因為 dp[i][j] 依賴的是 dp[i][k] 與 dp[k][j] 其中 i < k < j ,也就是說如果要求解 dp[ i ][ j ] 依賴了 [ i ][ 0 ] 到 [ i ][ j-1 ] 以及 [ i+1 ][ j ] 到 [ j-1 ][ j ] 的值。那么我們在dp表中 i 從 length 遞減到 0, j 從 i+1 遞增到 j 推演即可。
如果覺着順序抽象,可以在上述分治解法的基礎上,打印出緩存數組的演變過程,來理解回歸的計算順序。
/** * @Author Nyr * @Date 2019/11/30 01:43 * @Param * @Return * @Exception * @Description 動態規划解法 */ public static int maxCoins4DP(int[] nums) { //避免空指針異常 if (nums == null) { return 0; } //創建虛擬邊界 int length = nums.length; int[] nums2 = new int[length + 2]; System.arraycopy(nums, 0, nums2, 1, length); nums2[0] = 1; nums2[length + 1] = 1; length = nums2.length; //創建dp表 length = nums2.length; int[][] dp = new int[length][length]; //開始dp:i為begin,j為end,k為在i、j區間划分子問題時的邊界 for (int i = length - 2; i > -1; i--) { for (int j = i + 2; j < length; j++) { //維護一個最大值;如果i、j相鄰,值為0 int max = 0; for (int k = i + 1; k < j; k++) { int temp = dp[i][k] + dp[k][j] + nums2[i] * nums2[k] * nums2[j]; if (temp > max) { max = temp; } } dp[i][j] = max; } } return dp[0][length-1]; }
用動態規划法提交: