leetcode-動態規劃總結
leetcode-70-爬樓梯(climbing trees)-java
爬樓梯的三種解法
1 簡單的遞迴演算法
return climbStairs(n-1)+climbStairs(n-2);
速度是o(2^n),速度很慢
2 動態規劃,也是備忘錄演算法
有一個hashmap存放之前得出的值
if(map.containsKey(n)){
return map.get(n);
}
else{
int val=climbStair(n-1,map)+climbStair(n-2,map);
map.put(n, val);
return val;
}
速度o(n),空間o(n)
3 動態規劃,簡單的自底向上,只保留運算下一個所需要的數字,其餘的不要,所以空間固定
int first=1;
int second=2;
int third=0;
for(int i=3;i<=n;i++){
third=first+second;
first=second;
second=third;
}
return third;
速度o(n),空間o(c)
leetcode-121-買賣股票的最佳時機(best time to buy and sell stock)-java
最大利潤為max(當前的值-之前的min,之前的max利潤)
動態規劃的要訣就是得到當前的要的值,如何根據之前的值推斷的公式,不斷遞推
leetcode-53- 最大子序和(maximum subarray)-java
包含i處的最大利潤 sum=max(sum+nums[i],nums[i]);
i處的最大利潤 max_sum=max(sum,max_sum);
當前的和 為如果之前的sum小於0,則為當前元素
否則為sum+now
然後每次更新max
leetcode-198-打家劫舍(house robber)-java
思路:文中給出不能連續搶兩家,因此假設從最後一個房屋開始搶,最後一個房屋為index。將原問題分割成子問題,子問題的最優決策可以匯出原問題的最優決策。現有兩種可能情況,當前房屋搶和當前房屋不搶。若當前房屋搶,則下一房屋不能搶;若當前房屋不搶,則下一房屋可搶;選出這兩種情況的最大值,遞迴執行,直到index<0。
現在i的max=max( (i-2)的max+num[i] ,(i-1)的max)
max[i]=max[i-2]+nums[i] / max[i-1]
用動態規劃,不斷max上去
leetcode-55-跳躍遊戲(jump game)-java
只要用上界就行了。
那麼這道題的思路就是使用一個貪心法,使用一個步進指標,用一個上界指標。
每次遍歷的時候,不停的更新上界指標的位置(也就是當前位置+當前可以跳到的位置),知道看你能遇到結尾嗎?如果成功了,就範圍true,沒有就返回false
leetcode-62-不同路徑 (unique paths)-java
方法1:
建立一個n行m列的陣列,每個對應的數字是從左上角到到這一點能走的路線數,很明顯,因為只能向右或向下,所以最上的一條邊和最左的一條邊只能從左上角一路走,都為1。
然後之後的所有的點的值為左邊+上邊。然後從第2行的第2個到最後一個開始計算,然後每行依次類推,右下角的最後一個計算,它的值就是結果
方法2:
滾動陣列的優化就是其實你在算dp [i] [j] 的時候,你左邊的dp[i][j-1]還是dp[j-1],而你上面的dp[i-1][j]還是dpj,所以可以只需要一個陣列,所以滾動優化決定的是你更新的順序;
leetcode-322-零錢兌換 (coin change)-java
建立一個陣列,needCoins,value為對應index需要的硬幣數,結果就是index為amount的value。
首先初始化陣列,0,為0,其餘都是-1(其實應該只需要初始化0就可以,-1應該不需要)
自底向上的動態規劃,然後從1開始迴圈到amount,每次計算出i所需要的硬幣數,硬幣數為它之前的i-coins[j]所在位的value的min+1,如果那些位都沒有或者都為-1,則值為-1
leetcode-300-最長上升子序列(longest increasing subsequence)-java
解法1:使用動態規劃。
狀態的定義:以 num[i] 結尾的最長上升子序列的長度。
狀態轉移方程:之前的數中比 num[i] 小的最長上升子序列的長度 + 1。
對於原陣列每個元素,二重迴圈從頭遍歷原陣列,每當找到一個比當前元素小的值,證明至少可以形成一個dp[j]+1的上升子序列,所以dp[i] = max(dp[i], dp[j] + 1),而dp[j]之前已經求得。
速度o(n^2)
解法二:動態規劃+二分查詢
10,9,2,5,3,7,101,18
首先看到10,加入備選集,備選集合為{10};
之後看到了9,沒有形成上升序列,那麼9不應該加入備選集合。但是因為9小於10,所以如果把10替換成9會增加接下來產生上升序列的機會,且並不影響備選集合元素的個數(因為是替換),所以替換掉,備選集現在有{9};
遇到2道理同上,替換掉9,備選集變成{2};
遇到5,這時候形成了上升序列,此時應該是新增到備選集合,變為{2,5};
遇到3,沒有形成上升序列,但還是道理同加入9的情況,如果此時把5替換成3,會增加接下來形成上升序列的機會,且備選集保持上升,並且個數也沒變,所以替換掉5,備選集變成{2,3};
遇到7,同遇到5,新增元素,備選集{2,3,7};
遇到101,同上,備選集{2,3,7,101};
遇到18,還是一樣,雖然沒有形成上升序列,但是如果把101替換掉,那麼接下來形成上升序列的機會會增加,並且備選集的上升屬性和元素個數都不變,所以替換,備選集變為{2,3,7,18}。
至此所有元素新增完畢,備選集的元素個數就是最長上升子序列長度。但這裡注意,備選集裡面的元素並不是最後最長子序列的元素。因為在尋找最長子序列的過程中,目標是儘可能的讓以後形成上升序列的機會增加,所以進行了替換。
“人工”做出來之後,只要用程式實現思考過程就好。總結起來就是:
如果遇到的元素比備選集合裡面的元素都大,那麼就新增進去,使得上升序列長度增加;
如果遇到的元素比備選集合裡最後一個元素小,那麼代表它無法被新增到備選集。但是為了使後面得到上升序列的機會增加,需要在不破壞集合上升屬性和元素總數的情況下,替換掉備選集中的元素,那麼就是替換掉大於他的元素中最小的那個,這樣才能滿足條件。
相當於原來是10,20,30, 現在將15插入,換成10,15,30,更容易插入。
如果下一個是25,變成10,15,25,顯然更容易讓後一個加入。
如果下一個是40,會變成10,15,30,40,總共4個,但實際排序是10,20,30,40,但是長度一樣,所以之前替換為15無妨,因為長度沒變。
新加入的元素要比最後一個大,而最後一個元素顯然是在新加入元素之前,所以無妨。
例如順序為10,20,30,15,25,27 會從10,20,30,,替換為10,15,25,最後加入27
這時候,發現備選集一直是保持有序,尋找替換元素的時候就可以用到二分查詢,得到O(n log n)的時間複雜度。其中還要注意的是如果元素已經在備選集合中,是不需要任何操作的,因為它並不能增加上升序列的長度,也不會增加之後遇到上升序列的機會,所以直接跳過。
這個做法的精髓是即使用小的元素替換掉中間的元素,備選集的大小不變,還是原來的大小,
leetcode-152-乘積最大子序列-java
設定max陣列和min陣列,分別代表以i為結尾的乘積最大/小子序列之積
每次計算新的max[i],min[i],根據nums[i],max[i-1],min[i-1],三者小於0,大於0,等於0,來設定。
我們注意到上邊max[i] 的取值無非就是三種,max[i-1] * nums[i] 、min[i-1] * nums[i] 以及 nums[i]。
所以我們更新的時候,無需去區分當前是哪種情況,只需要從三個取值中選一個最大的即可。
max[i] = max(max[i-1] * nums[i], min[i-1] * nums[i], nums[i]);
求 dpMin[i] 同理。
min[i] = min(max[i-1] * nums[i], min[i-1] * nums[i], nums[i]);
更新過程中,我們可以用一個變數 max 去儲存當前得到的最大值。
動態規劃的老問題,我們注意到更新 dp[i] 的時候,我們只用到 dp[i-1] 的資訊,再之前的資訊就用不到了。所以我們完全不需要一個陣列,只需要一個變數去重複覆蓋更新即可。
leetcode-309-最佳買賣股票時機含冷凍期-java
第 1 步:狀態定義
dp[i][j] 表示 [0, i] 區間內,到第 i 天(從 0 開始)狀態為 j 時的最大收益。
這裡 j 取三個值:
0 表示不持股;
1 表示持股;
2 表示處在冷凍期。
第 2 步:狀態轉移方程
不持股可以由這兩個狀態轉換而來:(1)昨天不持股,今天什麼都不操作,仍然不持股。(2)昨天持股,今天賣了一股。
持股可以由這兩個狀態轉換而來:(1)昨天持股,今天什麼都不操作,仍然持股;(2)昨天處在冷凍期,今天買了一股;
處在冷凍期只可以由不持股轉換而來,因為題目中說,剛剛把股票賣了,需要冷凍 1 天。
第 3 步:思考初始化
在第 0 天,不持股的初始化值為 0,持股的初始化值為 -prices[0](表示購買了一股),雖然不處於冷凍期,但是初始化的值可以為 0。
第 4 步:思考輸出
每一天都由前面幾天的狀態轉換而來,最優值在最後一天。取不持股和冷凍期的最大者。
由於當前天只參考了昨天的狀態值,因此可以考慮使用滾動陣列。
leetcode-279-完全平方數-java
首先初始化長度為n+1的陣列dp,每個位置都為0
如果n為0,則結果為0
對陣列進行遍歷,下標為i,每次都將當前數字先更新為最大的結果,即dp[i]=i,比如i=4,最壞結果為4=1+1+1+1即為4個數字
動態轉移方程為:dp[i] = MIN(dp[i], dp[i - j * j] + 1),i表示當前數字,jj表示平方數
時間複雜度:O(nsqrt(n)),sqrt為平方根
leetcode-139-單詞拆分-java
這個方法的想法是對於給定的字串(s)可以被拆分成子問題 s1和 s2。如果這些子問題都可以獨立地被拆分成符合要求的子問題,那麼整個問題 s也可以滿足。也就是,如果 “catsanddog” 可以拆分成兩個子字串 “catsand” 和 “dog” 。子問題 “catsand” 可以進一步拆分成 “cats” 和 “and” ,這兩個獨立的部分都是字典的一部分,所以 “catsand” 滿足題意條件,再往前, “catsand” 和 “dog” 也分別滿足條件,所以整個字串 “catsanddog” 也滿足條件。
現在,我們考慮 dp陣列求解的過程。我們使用 n+1大小陣列的 dp,其中 n是給定字串的長度。我們也使用 2 個下標指標 i和 j ,其中 i 是當前字串從頭開始的子字串(s′)的長度, j 是當前子字串(s′)的拆分位置,拆分成 s′(0,j) 和 s′(j+1,i)。
為了求出 dp 陣列,我們初始化 dp[0]為 true ,這是因為空字串總是字典的一部分。 dp陣列剩餘的元素都初始化為 false 。
我們用下標 i來考慮所有從當前字串開始的可能的子字串。對於每一個子字串,我們通過下標 j 將它拆分成 s1和 s2′ (注意 i 現在指向 s2′的結尾)。為了將 dp[i]陣列求出來,我們依次檢查每個 dp[j] 是否為 true ,也就是子字串 s1′ 是否滿足題目要求。如果滿足,我們接下來檢查 s2′是否在字典中。如果包含,我們接下來檢查 s2′ 是否在字典中,如果兩個字串都滿足要求,我們讓 dp[i] 為 true,否則令其為 false。
leetcode-140-單詞拆分 II-java
這個方法背後的想法是對於給定的問題(s),它可以被拆分成子問題 s1 和 s2 。如果這些子問題分別都能滿足條件,那麼整個文字 s 也可以滿足。比方說, “catsanddog” 可以被拆分成子字串 “catsand” 和 “dog” 。子問題 “catsand” 進一步可以被拆分成 “cats” 和 “and” ,它們分別都是字典的一部分,所以 “catsand” 也是滿足條件的。遞迴回來,因為 “catsand” 和 “dog” 分別都滿足要求,所以原字串 “catsanddog” 也符合要求。
現在,我們來考慮 dp 陣列如何求出。我們使用長度為 n+1n+1n+1 的陣列 dp ,其中 n 是給定字串的長度。 dp[k] 被用來儲存用 s[0:k−1]可被拆分成合法單詞的句子。我們同事用兩個指標 i 和 j ,其中 i 表示子字串 s′ 的長度(s′ 是 s 的一個字首), j 表示 s′ 的拆分位置,即拆分成 s′(0,j) 和 s′(j+1,i) 。
為了求出 dp 陣列,我們將 dp[0] 初始化為空串。我們以 i 為結尾表示的子字串的所有字首,通過指標 j 將 s 拆分成 s1′ 和 s2′ 。為了求出dp[i] ,我們檢查所有 dp[j] 包含的所有非空字串,也就是所有能形成 s1′ 的句子。如果存在,我們進一步檢查 s2′ 是否在字典裡,如果兩個條件都滿足,我們將子字串 s2′ 新增到所有 s1′ 的句子的後面(這些句子已經儲存在了 dp[j] 裡面),並將這些新形成的句子儲存進(dp[i])。最終, dp[n] (n 是給定字串 s 的長度)裡面儲存了所有可以得到完整字串 s 的所有句子。
leetcode-312-戳氣球-java
解法1
回溯法
剛看到這個題目,腦中可以很輕易的想象出解空間的結構:一個n層的陣列,每層的元素相同,我們從第一層走到第n層,每層走動時不能使用之前走過的元素。然後按照規則計算獲取的金幣,我們嘗試所有可以走的路徑並記錄下每條路徑所能獲得的金幣和,最大值即題目的解。在層數不確定的情況下,使用遞迴比for迴圈的巢狀更加方便:
因為被戳破的氣球等於不存在,我們在計算獲得的金幣時需要做一點小小的處理。因為氣球上的數字是大於等於0的,我們將走過的氣球標誌為-1。在計算可以獲得的金幣數時,如果相鄰的氣球是-1,則略過取相鄰的下一個氣球即可。另外,出於兩邊的氣球只有一個相鄰氣球,需要做一下特殊處理。我們將上述程式碼的“嘗試所有可走路徑”中的“to do something”完善起來:
按上面的思路,這就是一個很簡單的搜尋問題,但每走一層都會對下面的路徑造成影響,所以我們需要通過回溯的手法,每嘗試完一種可能性後,在嘗試下一種路徑前我們都要把之前路徑戳破的氣球恢復。回溯很簡單,只需要加一行程式碼,即遞迴呼叫結束後將當前for迴圈中戳破的氣球恢復。
上述解法超時導致提交不通過。細想下當前解法的時間複雜度就可以知道,不通過是有原因的。
每層有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次時相比,效能得到了極大的提升。
解法2
分治法
在使用分治法時,我們應該考慮的核心問題是如何用子問題的解來表示原問題的解,也就是子問題該如何劃分才能通過子問題來求解原問題。我們把描述子問題的解與原問題的解之間的關係的表示式稱為狀態轉移方程。
首先我們嘗試每戳破一個氣球,以該氣球為邊界將氣球陣列分為兩部分,使用這兩部分的解來求解原問題。
我們設戳破區間 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 是正確的。
效率提升到這裡好像挺完美了,但我們還忽略了一點:即使使用了分治使時間複雜度大幅下降,但我們的實現中還存在著遞迴呼叫。遞迴呼叫的效率是很低的,因為牽扯到大量的函式呼叫,即棧幀的建立與釋放。而且由於臨時變數的存在以及需要儲存之前棧幀的esp、程式計數器等暫存器值,在遞迴層數加深時會佔用大量的棧空間,非常容易引起爆棧。這樣的程式碼是絕對不可以放到生產環境上的,我們應該去思考遞迴呼叫的迴歸過程,通過模擬迴歸過程來用遞推實現上述程式碼。
用遞推模擬迴歸過程的方法,就是在上述實現的快取 cache[i][j] 中逐漸推演,通過一步步的解決小問題來得到最終問題的解,這便是動態規劃解法。
解法3(別人的)
動態規劃
動態規劃演算法通常用於求解具有某種最優性質的問題。在這類問題中,可能會有許多可行解。每一個解都對應於一個值,我們希望找到具有最優值的解。動態規劃演算法與分治法類似,其基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然後從這些子問題的解得到原問題的解。與分治法不同的是,適合於用動態規劃求解的問題,經分解得到子問題往往不是互相獨立的。對於分治法求解的問題,子問題的相互獨立僅僅是同層級的子問題間沒有互相依賴。但對於動態規劃而言,同層級的子問題可能會依賴相同的低層級問題,這就導致低層級問題可能會被計算多次。
若用分治法來解這類問題,則分解得到的子問題數目太多,有些子問題被重複計算了很多次。如果我們能夠儲存已解決的子問題的答案,而在需要時再找出已求得的答案,這樣就可以避免大量的重複計算,節省時間。我們可以用一個表來記錄所有已解的子問題的答案。不管該子問題以後是否被用到,只要它被計算過,就將其結果填入表中。這就是動態規劃法的基本思路。具體的動態規劃演算法多種多樣,但它們具有相同的填表格式。
其實在上面的分治解法,我加入了一個二維陣列用於快取已經計算過的子問題的結果,將快取去掉才是概念上的分治解法。而加入了快取避免了子問題的重複計算,已經是一個動態規劃解法的雛形,我們只需要將遞迴改為遞推便是動態規劃解法。正如上面所說,通常情況下,遞迴的解法是不可以放在生產環境的,因為我們很難控制問題規模的大小,無法預料何時會有爆棧的風險。
具有最優子結構性質以及重疊子問題性質的問題可以通過動態規劃求解。
最優子結構
• 如果一個問題的最優解包含其子問題的最優解,我們就稱此問題具有最優子結構
• 一個問題具有最優子結構,可能使用動態規劃方法,也可能使用貪心方法。所以最優子結構只是一個線索,不是看到有最優子結構就一定是用動態規劃求解
重疊子問題
• 子問題空間必須足夠“小”,即在不斷的遞迴過程中,是在反覆求解大量相同的子問題,而不是每次遞迴時都產生新的子問題。
• 一般的,不同子問題的總數是輸入規模的多項式函式為好
• 如果遞迴演算法反覆求解相同的子問題,我們就稱最優化問題具有重疊子問題性質
對於前面的分治解法,我們的計算過程分為兩個階段:
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 推演即可。
如果覺著順序抽象,可以在上述分治解法的基礎上,列印出快取陣列的演變過程,來理解迴歸的計算順序。
leetcode-32-最長有效括號-java
解法1 動態規劃
我們定義 dp[i] 表示以下標 i 字元結尾的最長有效括號的長度。我們將 dp 陣列全部初始化為 0 。顯然有效的子串一定以 ‘)’結尾,因此我們可以知道以 ‘(’ 結尾的子串對應的 dp值必定為 0 ,我們只需要求解 ‘)’ 在 dp陣列中對應位置的值。
我們從前往後遍歷字串求解 dp 值,我們每兩個字元檢查一次:
s[i]=‘)’且 s[i−1]=‘(’,也就是字串形如 “……()”,我們可以推出:dp[i]=dp[i−2]+2
我們可以進行這樣的轉移,是因為結束部分的 “()” 是一個有效子字串,並且將之前有效子字串的長度增加了 2 。
s[i]=‘)’且 s[i−1]=‘)’,也就是字串形如 “……))”,我們可以推出:
如果 s[i−dp[i−1]−1]=‘(’,(即 i-1 位置的右括號對應的左括號)那麼
dp[i]=dp[i−1]+dp[i−dp[i−1]−2]+2
我們考慮如果倒數第二個 ‘)’ 是一個有效子字串的一部分(記作 subs),對於最後一個 ‘)’,如果它是一個更長子字串的一部分,那麼它一定有一個對應的 ‘(’,且它的位置在倒數第二個 ‘)’ 所在的有效子字串的前面(也就是 subs 的前面)。因此,如果子字串 subs 的前面恰好是 ‘(’,那麼我們就用 2 加上 subs 的長度(dp[i−1])去更新 dp[i]。同時,我們也會把有效子串 “(subs)”之前的有效子串的長度也加上,也就是再加上 dp[i−dp[i−1]−2]。
即字串為 有效字串prev + ( + subs + )
最後的答案即為 dp陣列中的最大值。
解法2 棧
撇開方法一提及的動態規劃方法,相信大多數人對於這題的第一直覺是找到每個可能的子串後判斷它的有效性,但這樣的時間複雜度會達到 O(n^3),無法通過所有測試用例。但是通過棧,我們可以在遍歷給定字串的過程中去判斷到目前為止掃描的子串的有效性,同時能得到最長有效括號的長度。
具體做法是我們始終保持棧底元素為當前已經遍歷過的元素中「最後一個沒有被匹配的右括號的下標」,這樣的做法主要是考慮了邊界條件的處理,棧裡其他元素維護左括號的下標:
對於遇到的每個 ‘(’,我們將它的下標放入棧中
對於遇到的每個 ‘)’,我們先彈出棧頂元素表示匹配了當前右括號:
如果棧為空,說明當前的右括號為沒有被匹配的右括號,我們將其下標放入棧中來更新我們之前提到的「最後一個沒有被匹配的右括號的下標」
如果棧不為空,當前右括號的下標減去棧頂元素即為「以該右括號為結尾的最長有效括號的長度」
我們從前往後遍歷字串並更新答案即可。
需要注意的是,如果一開始棧為空,第一個字元為左括號的時候我們會將其放入棧中,這樣就不滿足提及的「最後一個沒有被匹配的右括號的下標」,為了保持統一,我們在一開始的時候往棧中放入一個值為 −1的元素。
解法3 貪心演算法
在此方法中,我們利用兩個計數器 left 和 right 。首先,我們從左到右遍歷字串,對於遇到的每個 ‘(’,我們增加 left 計數器,對於遇到的每個 ‘)’ ,我們增加 right 計數器。每當 left 計數器與 right 計數器相等時,我們計算當前有效字串的長度,並且記錄目前為止找到的最長子字串。當 right 計數器比 計數器大時,我們將 left 和 right 計數器同時變回 0。
這樣的做法貪心地考慮了以當前字元下標結尾的有效括號長度,每次當右括號數量多於左括號數量的時候之前的字元我們都扔掉不再考慮,重新從下一個字元開始計算,但這樣會漏掉一種情況,就是遍歷的時候左括號的數量始終大於右括號的數量,即 (() ,這種時候最長有效括號是求不出來的。
解決的方法也很簡單,我們只需要從右往左遍歷用類似的方法計算即可,只是這個時候判斷條件反了過來:
當 left 計數器比 right 計數器大時,我們將 left 和 right 計數器同時變回 0
當 left 計數器與 right 計數器相等時,我們計算當前有效字串的長度,並且記錄目前為止找到的最長子字串
這樣我們就能涵蓋所有情況從而求解出答案。
leetcode-64- 最小路徑和-java
我們完全不需要建立 dp 矩陣浪費額外空間,直接遍歷 grid[i][j]修改即可。這是因為:grid[i][j] = min(grid[i - 1][j], grid[i][j - 1]) + grid[i][j] ;原 grid 矩陣元素中被覆蓋為 dp 元素後(都處於當前遍歷點的左上方),不會再被使用到。
首先改造第一行和第一列,逐個遞加即可。然後剩餘的ij,比較(i-1) (i,j-1)的大小,那個小就那個+自己,即可。最後返回(row-1,col-1)
leetcode-72-編輯距離-java
啥叫編輯距離?我們說word1和word2的編輯距離為X,意味著word1經過X步,變成了word2,咋變的你不用管,反正知道就需要X步,並且這是個最少的步數。
我們有word1和word2,我們定義dp[i][j]的含義為:word1的前i個字元和word2的前j個字元的編輯距離。意思就是word1的前i個字元,變成word2的前j個字元,最少需要這麼多步。
例如word1 = “horse”, word2 = “ros”,那麼dp[3][2]=X就表示"hor"和“ro”的編輯距離,即把"hor"變成“ro”最少需要X步。
如果下標為零則表示空串,比如:dp[0][2]就表示空串""和“ro”的編輯距離
定理一:如果其中一個字串是空串,那麼編輯距離是另一個字串的長度。比如空串“”和“ro”的編輯距離是2(做兩次“插入”操作)。再比如"hor"和空串“”的編輯距離是3(做三次“刪除”操作)。
定理二:當i>0,j>0時(即兩個串都不空時)dp[i][j]=min(dp[i-1][j]+1,dp[i][j-1]+1,dp[i-1][j-1]+int(word1[i]!=word2[j]))。
啥意思呢?舉個例子,word1 = “abcde”, word2 = “fgh”,我們現在算這倆字串的編輯距離,就是找從word1,最少多少步,能變成word2?那就有三種方式:
知道"abcd"變成"fgh"多少步(假設X步),那麼從"abcde"到"fgh"就是"abcde"->“abcd”->“fgh”。(一次刪除,加X步,總共X+1步)
知道"abcde"變成“fg”多少步(假設Y步),那麼從"abcde"到"fgh"就是"abcde"->“fg”->“fgh”。(先Y步,再一次新增,加X步,總共Y+1步)
知道"abcd"變成“fg”多少步(假設Z步),那麼從"abcde"到"fgh"就是"abcde"->“fge”->“fgh”。(先不管最後一個字元,把前面的先變好,用了Z步,然後把最後一個字元給替換了。這裡如果最後一個字元碰巧就一樣,那就不用替換,省了一步)
leetcode-85-最大矩形-java
https://blog.csdn.net/xushiyu1996818/article/details/107711909
leetcode-96-不同的二叉搜尋樹-java
給定一個有序序列 1⋯n,為了構建出一棵二叉搜尋樹,我們可以遍歷每個數字 i,將該數字作為樹根,將 1⋯(i−1)序列作為左子樹,將 (i+1)⋯n 序列作為右子樹。接著我們可以按照同樣的方式遞迴構建左子樹和右子樹。
在上述構建的過程中,由於根的值不同,因此我們能保證每棵二叉搜尋樹是唯一的。
由此可見,原問題可以分解成規模較小的兩個子問題,且子問題的解可以複用。因此,我們可以想到使用動態規劃來求解本題。
題目要求是計算不同二叉搜尋樹的個數。為此,我們可以定義兩個函式:
G(n): 長度為 n 的序列能構成的不同二叉搜尋樹的個數。
F(i,n): 以 i 為根、序列長度為 n 的不同二叉搜尋樹個數 (1≤i≤n)。
可見,G(n) 是我們求解需要的函式。
稍後我們將看到,G(n) 可以從 F(i,n) 得到,而 F(i,n) 又會遞迴地依賴於 G(n)。
首先,根據上一節中的思路,不同的二叉搜尋樹的總數 G(n),是對遍歷所有 i (1≤i≤n) 的 F(i,n) 之和。換言之:
G(n)=∑i=1-n F(i,n)
對於邊界情況,當序列長度為 1(只有根)或為 0(空樹)時,只有一種情況,即:
G(0)=1,G(1)=1
給定序列 1⋯n,我們選擇數字 i 作為根,則根為 i 的所有二叉搜尋樹的集合是左子樹集合和右子樹集合的笛卡爾積,對於笛卡爾積中的每個元素,加上根節點之後形成完整的二叉搜尋樹
舉例而言,建立以 3 為根、長度為 7 的不同二叉搜尋樹,整個序列是 [1,2,3,4,5,6,7],我們需要從左子序列 [1,2] 構建左子樹,從右子序列 [4,5,6,7]構建右子樹,然後將它們組合(即笛卡爾積)。
對於這個例子,不同二叉搜尋樹的個數為 F(3,7)。我們將 [1,2] 構建不同左子樹的數量表示為 G(2), 從 [4,5,6,7]構建不同右子樹的數量表示為 G(4),注意到 G(n) 和序列的內容無關,只和序列的長度有關。於是,F(3,7)=G(2)⋅G(4)。 因此,我們可以得到以下公式:
F(i,n)=G(i−1)⋅G(n−i)
將公式 (1),(2) 結合,可以得到 G(n)的遞迴表示式:
G(n)=∑i=1 - n G(i−1)⋅G(n−i)
至此,我們從小到大計算 G 函式即可,因為 G(n) 的值依賴於 G(0)⋯G(n−1)。
leetcode-221-最大正方形-java
解法1
row1這個二維陣列為,計算以(i,j)開頭,向右,連續的1的數量。然後maxSquares這個二維陣列為,(i,j)作為正方形的左上角,根據(i,j),(i+1,j)等,連續的1的數量,確定一個最大的1的正方形。
解法2(別人的)
可以使用動態規劃降低時間複雜度。我們用 dp(i,j)表示以 (i,j)為右下角,且只包含 1 的正方形的邊長最大值。如果我們能計算出所有 dp(i,j) 的值,那麼其中的最大值即為矩陣中只包含 1 的正方形的邊長最大值,其平方即為最大正方形的面積。
那麼如何計算 dp中的每個元素值呢?對於每個位置 (i,j),檢查在矩陣中該位置的值:
如果該位置的值是 0,則 dp(i,j)=0,因為當前位置不可能在由 1 組成的正方形中;
如果該位置的值是 1,則 dp(i,j) 的值由其上方、左方和左上方的三個相鄰位置的 dp 值決定。具體而言,當前位置的元素值等於三個相鄰位置的元素中的最小值加 1,狀態轉移方程如下:
dp(i,j)=min(dp(i−1,j),dp(i−1,j−1),dp(i,j−1))+1
如果讀者對這個狀態轉移方程感到不解,可以參考 1277. 統計全為 1 的正方形子矩陣的官方題解,其中給出了詳細的證明。
此外,還需要考慮邊界條件。如果 i 和 j 中至少有一個為 0,則以位置 (i,j)為右下角的最大正方形的邊長只能是 1,因此 dp(i,j)=1。
leetcode-337-打家劫舍 III-java
簡化一下這個問題:一棵二叉樹,樹上的每個點都有對應的權值,每個點有兩種狀態(選中和不選中),問在不能同時選中有父子關係的點的情況下,能選中的點的最大權值和是多少。
我們可以用 f(o)表示選擇 o 節點的情況下,o 節點的子樹上被選擇的節點的最大權值和;g(o) 表示不選擇 o 節點的情況下,o 節點的子樹上被選擇的節點的最大權值和;l 和 r 代表 o 的左右孩子。
當 o 被選中時,o 的左右孩子都不能被選中,故 o 被選中情況下子樹上被選中點的最大權值和為 l 和 r 不被選中的最大權值和相加,即 f(o)=g(l)+g®。
當 o 不被選中時,o 的左右孩子可以被選中,也可以不被選中。對於 o 的某個具體的孩子 x,它對 o 的貢獻是 x 被選中和不被選中情況下權值和的較大值。故 g(o)=max{f(l),g(l)}+max{f®,g®}。
至此,我們可以用雜湊對映來存 f 和 g 的函式值,用深度優先搜尋的辦法後序遍歷這棵二叉樹,我們就可以得到每一個節點的 f 和 g。根節點的 f 和 g 的最大值就是我們要找的答案。
我們可以做一個小小的優化,我們發現無論是 f(o) 還是 g(o),他們最終的值只和 f(l)、g(l)、f®、g® 有關,所以對於每個節點,我們只關心它的孩子節點們的 f 和 g 是多少。我們可以設計一個結構,表示某個節點的 f 和 g 值,在每次遞迴返回的時候,都把這個點對應的 f 和 g 返回給上一級呼叫,這樣可以省去雜湊對映的空間。
leetcode-338-位元位計數-java
解法1
可以看0-1與2-3
00 01
10 11
可以看到2-3就是在0-1的前面加了個1,所以num[2]=num[0]+1,num[3]=num[1]+1
000 001 010 011 100 101 110 111
可以看到4-7同樣也是比0-3每個都多了一個1
可以認為num[i]=num[i-size]+1 size為剛剛小於等於i的2^n
這種方法實際是動態規劃 + 最高有效位(與其他結果的最高有效位不同)
解法2
遵循上一方法的相同原則,我們還可以通過最低有效位來獲得狀態轉移函式。
觀察x 和 x′=x/2 的關係:
x=(1001011101)2=(605)10
x′=(100101110)2=(302)10
可以發現 x′ 與 x 只有一位不同,這是因為x′ 可以看做 x 移除最低有效位的結果。
這樣,我們就有了下面的狀態轉移函式:
P(x)=P(x/2)+(xmod 2)
解法3
動態規劃 + 最後設定位
與上述方法思路相同,我們可以利用最後設定位。
最後設定位是從右到左第一個為1的位。使用 x &= x - 1 將該位設定為0,就可以得到以下狀態轉移函式:
P(x)=P(x&(x−1))+1;
leetcode-416-分割等和子集-java
https://blog.csdn.net/xushiyu1996818/article/details/108098915
相關文章
- 動態規劃 總結動態規劃
- leetcode總結——動態規劃LeetCode動態規劃
- 一維動態規劃總結動態規劃
- [LeetCode] 動態規劃題型總結LeetCode動態規劃
- 動態規劃分類題目總結動態規劃
- 總結 | 動態規劃十問十答動態規劃
- 動態規劃小結動態規劃
- [leetcode初級演算法]動態規劃總結LeetCode演算法動態規劃
- 動態規劃動態規劃
- 動態規劃入門——動態規劃與資料結構的結合,在樹上做DP動態規劃資料結構
- 動態規劃此一篇就夠了 萬字總結!動態規劃
- 德魯週記10--15天從0開始刷動態規劃(leetcode動態規劃題目型別總結)動態規劃LeetCode型別
- [leetcode] 動態規劃(Ⅰ)LeetCode動態規劃
- 動態規劃法動態規劃
- 模板 - 動態規劃動態規劃
- 動態規劃初步動態規劃
- 動態規劃分析動態規劃
- 動態規劃(DP)動態規劃
- 演算法系列-動態規劃(1):初識動態規劃演算法動態規劃
- 最小總和問題(動態規劃演算法)動態規劃演算法
- [leetcode 1235] [動態規劃]LeetCode動態規劃
- 動態規劃專題動態規劃
- 動態規劃-----線性動態規劃
- 好題——動態規劃動態規劃
- 動態規劃初級動態規劃
- 淺談動態規劃動態規劃
- 3.動態規劃動態規劃
- 動態規劃題單動態規劃
- 雙序列動態規劃動態規劃
- 動態規劃方法論動態規劃
- [atcoder 358] 【動態規劃】動態規劃
- 區間動態規劃動態規劃
- 動態規劃(Dynamic programming)動態規劃
- 有關動態規劃動態規劃
- 動態規劃之數的劃分動態規劃
- 禮物的最大價值(一維動態規劃&二維動態規劃)動態規劃
- leetcode題解(動態規劃)LeetCode動態規劃
- [動態規劃] 區間 dp動態規劃