# DP by sa072686 - [基礎題單](http://skyoj.tnfsh.tn.edu.tw/sky/index.php/rank/view/37) - [綜合題單](http://skyoj.tnfsh.tn.edu.tw/sky/index.php/rank/view/38) # 關於DP 動態規劃(Dynamic Programming, DP)是一種演算法的設計方式。 不具備演算法定義上固定的指令集和流程,也因此解題競賽經常出現。 ## DP的精神 要直接理解 DP 的精神與用法並不容易, 建議只先看個大概,實際看過一些例題,再回頭思索、理解它們。 :::success ### 將大問題轉成同性質小問題 則小問題會因性質相同,能再轉成同性質、但更小的問題。 直到問題規模夠小時,便能直接知道答案。 相當於求原問題的遞迴解。 ``` 例:有個 n 階段的階梯,你一次可跨上 1 或 2 個階段, 問有多少種不同的方式,踏上第 n 階段。 解:可由第 n-1 階段跨 1 階,或從第 n-2 階段跨 2 階到達第 n 階, 因此 f(n) = f(n-1) + f(n-2) ``` ::: :::success ### 計算過的小問題不重覆計算 例如第 n 階乘,或者費氏數列第 n 項,不管計算多少次答案均不變, 故可將計算過的部份存下來,避免無謂的重覆計算 ``` 例:將費氏數列第 6 項遞迴展開 f(6) / \ f(5) f(4) / \ / \ f(4) f(3) f(3) f(2) / \ / \ / \ f(3) f(2) f(2) f(1) f(2) f(1) / \ f(2) f(1) 實際上只需要計算 f(1) ~ f(6) 共 6 種情況,卻產生了許多非必要的重覆計算。 若我們將計算結果儲存下來,則會剩下這樣: f(6) / \ f(5) f(4) / \ f(4) f(3) / \ f(3) f(2) / \ f(2) f(1) ``` ``` 例二:若將費氏數列第 n 項遞迴展開,需要呼叫多少次 function 才能算完? 解:呼叫 f(n) 1 次。 f(n) 會再去呼叫 f(n-1) 以及 f(n-2),所以再加上第 n-1 項和第 n-2 項的呼叫次數。 得出 g(n) = g(n-1) + g(n-2) + 1 我們知道費氏數列成長很接近 2^n,但 function 呼叫次數約為費氏數列本身的 2 倍左右。 不要小看那個 +1,累積起來非常可怕的。 n 1 2 3 4 5 6 7 8 9 10 11 12 13 f(n) 1 1 2 3 5 8 13 21 34 55 89 144 233 g(n) 1 1 3 5 9 15 25 41 67 109 177 287 465 ``` ::: ## 狀態與壓縮 在尋求合適的遞迴解時,以狀態的概念來幫助思考。 :::success 以一或多個數字為一組狀態,來表達一個情境。 ::: 例如上述的爬樓梯問題,便是用 `dp[n]` 表達爬到第 n 階上面,共有多少種方法。 :::success 如果某些情況在未來擁有**完全相同的發展**,就能壓縮成相同狀態看待。 ::: 能夠將越多種情形,壓縮到越少的狀態,執行效率就會更高。 例如上述的爬樓梯問題,實際上 `dp[5]` 壓縮了以下 8 種情形: ``` 1 1 1 1 1 1 1 1 2 1 1 2 1 1 2 1 1 2 1 1 1 1 2 2 2 1 2 2 2 1 ``` 這些可能性最後全造成了「站在第 5 階上」的結果,擁有相同的特性、相同的未來發展, 因此可被壓縮至同一個狀態,視為相同情形對待。 :::info 狀態的壓縮某程度可說是刻意的不完整描述所導致的結果。 由於描述的不完整,使得某些不同的走法,被忽略掉其它差異,描述成相同的情況。 像是把一隻黑貓、一隻白貓,描述成「兩隻貓」的感覺。 忽略了牠們其它任何個體差異,全歸類到「貓」來描述。 ::: ## 轉移 轉移便是原問題如何與子問題的解產生關聯,進而從子問題的解,求得原問題的解。 例如上述的爬樓梯問題, $dp(n) = dp(n-1) + dp(n-2)$ 即為原問題與子問題的關聯性。 :::info 也可以這樣想:假如你已經知道所有的子問題的答案,如何拿來求原問題的答案? ::: ## 複雜度計算 空間複雜度視狀態數量而定,時間複雜度則為狀態數量乘上轉移複雜度。 例如上述的爬樓梯問題,需要計算的狀態量為 n 個,空間複雜度 O(n)。 時間複雜度為狀態量 n 乘以轉移 2,共是 2n,時間複雜度 O(n)。 # 問題分類 DP 主要用來解最佳化問題,或者計算有多少種可能情形。 ## 計數型 計算有多少種情形的題型。上述的爬樓梯問題即為一例。 ``` 計數型的轉移必須包含所有可能情形,才不致於少算。 各種情形必須完全獨立不重覆,才不致於多算。 ``` 例如上述的爬樓梯問題,踏上第 n 階段,最後一步肯定是跨一階,或者跨兩階其中一種。 所以包含所有情形,沒有少算。 又,從 dp[n-1] 而來的情形,最後一步一定是跨一階; 從 dp[n-2] 而來的情形,最後一步一定是跨兩階。 透過最後一步的差異,這兩種情形完全獨立沒有交集,因此沒有多算。 既沒有多算、也沒有少算,那麼數量就會剛好。 ## 最佳化型 計算問題的最佳解,例如將爬樓梯問題改成: ``` 有 n 個非負整數,第 i 個代表踏上第 i 個階段要付多少錢。 每次只能跨一階或兩階,問踏上第 n 階最少要付多少錢。 ``` 就會變成最佳化問題。 第 n 階要嘛從第 n-1 階跨 1 階上來,要嘛從第 n-2 階跨 2 階上來。 最小花費會是兩者取較佳者,加上踏上第 n 階所付的錢: ``` dp[n] = min(dp[n-1], dp[n-2]) + cost[n] ``` ### 使用條件 狀態需滿足`「當子問題最佳,原問題才能得到最佳解」`的前提,才能使用 DP 解。 如同前述,踏上第 5 階的方法數其實共有 8 種。 當我們將這 8 種壓縮在同一個狀態,並且僅保留最佳解時, 這代表另外 7 種並非最佳解的可能性,全被捨棄掉了。 我們可以證明這題的解法,僅當子問題最佳,原問題才能得到最佳解。 ``` 如果踏上第 5 階時,最佳解是付 10 塊錢。 假設存在一組踏上第 n (n > 5) 階的最佳解,在第 5 階付了 12 塊錢。 那麼我們改採用踏上第 5 階時,只要付 10 塊錢的走法, 跟著最佳解的走法走,則第 5 階之後所付的錢會跟假設的最佳解完全相同。 第 5 階之後付的錢完全相同,但在踏上第 5 階時少付了 2 塊錢,比最佳解更佳,因此矛盾。 故不存在任何最佳解,其過程的子問題並非是最佳的。 ``` 以下是另一個例子,在子問題最佳時,原問題未必能得到最佳解。 ``` 同上述爬樓梯最小花費問題,改求踏上第 n 階時,付的錢個位數要最小。 ``` 則我們在第 5 階若有個位數 6 和 9 兩種可能,此時最佳花費應為 6; 但踏上第 6 階時若花 2 塊錢,則最佳花費應是 9+2 得到個位數 1,而非 6+2 的 8。 但 9 並非子問題的最佳解,而是被子問題所捨棄的可能性。 ### 狀態的增維 除了把狀態完全換掉之外,對狀態追加描述、進行增維,也可能解決這種問題。 ``` 將狀態改為 dp[i][j] 代表踏上第 i 階時,花費個位數 j 是否可能達成。 dp[i][j] = (dp[i-1][k] || dp[i-2][k]) // k 滿足 k+cost[i] 個位數為 j ``` # 實作面 建立一個一或多維陣列 dp,用以保存已計算的子問題的解。 在計算過程中,若子問題已計算過,則直接查表得到解答,不再重覆計算。 計算過後,將計算的問題的解填入 dp 中。 ## 邊界 邊界問題為 DP 最容易出 bug 的地方之一,但各種問題邊界注意事項不一,難以一一提及。 以下列幾個共通或常用的: * 從最小問題起手,例如階乘從 0! 開始 * 從 0 開始考慮,什麼都沒有、或什麼都不取,也是一種情形 ## Bottom-up 從小問題做起,慢慢往大問題推。 順序上須保證每個問題被計算時,所有需要的子問題皆已算完。 優點: * 省去許多 function 呼叫,速度較快、絕不會 stack-overflow * 很多時候無須記錄子問題是否算過,省去初始化時間、沒有忘記初始化的風險 缺點: * 無法判斷子問題是否必要,必須全部計算不能省略 以上面兩種爬樓梯問題為例: ```c dp[0] = 1; dp[1] = 1; for (int i=2; i<=n; i++) { dp[i] = dp[i-1] + dp[i-2]; } ``` ```c dp[0] = 0; dp[1] = cost[1]; for (int i=2; i<=n; i++) { dp[i] = min(dp[i-1], dp[i-2]) + cost[i]; } ``` ## Top-down 直接遞迴,若已計算過則直接回傳記錄的答案; 否則在計算後存入表中,並標記已計算過。 以上面兩種爬樓梯問題為例: ```c int calc(int n) { if (used[n]) { return dp[n]; } used[n] = 1; if (n < 2) { return dp[n] = 1; } return dp[n] = calc(n-1) + calc(n-2); } ``` ```c int calc(int n) { if (used[n]) { return dp[n]; } used[n] = 1; if (n == 0) { return dp[0] = 0; } if (n == 1) { return dp[1] = cost[1]; } return dp[n] = min(calc(n-1), calc(n-2)) + cost[n]; } ``` ### used && count 優化 在 Top-down 會需要記錄子問題是否計算過,這在每組輸入影響答案時必須初始化。 初始化十分耗時,又有忘記初始化的風險,以下這招可以避開這些問題: ``` used[i] = j 記錄狀態 i 最後一次被用過是在第 j 組輸入 ``` 使用例: ```c int cnt; // 記錄現在第幾組輸入,每組輸入後 cnt++; int used[N]; // 宣告在全域時預設值會全是 0,但區域變數無此特性 used[i] = cnt; // 標記狀態 i 已被用過 used[i] = 0; // 取消標記狀態 i if (used[i] == cnt) // 用以判斷 i 是否用過;若用過則其值應為 cnt,沒用過則 != cnt ``` ### 回溯解 最佳化問題有時會要你輸出其中一組最佳解,因此必須能回溯解。 最佳化問題通常是原問題在子問題中,從最佳的子問題轉移而來; 對每個狀態記錄是從哪一個子問題轉移而來,則透過狀態轉移的遞迴性質可回溯出一條路徑。 路徑上所有狀態即是其中一組最佳解。 #### 字典序最佳解 有的問題會要求若有多組最佳解,選擇字典序最小或其它優先條件。 在這種情況需注意轉移順序,因為字典序可以 greedy,只要前面的字更小,後面的字無法抗衡。 回溯是從尾巴回去,例如有兩組解 bed 以及 tea, 則回溯順序是 d -> e -> b 以及 a -> e -> t, 顯然就算結尾狀態記錄較 d 為佳的 a 也會因為最前面 b 比 t 好而整個逆轉。 故意把 DP 倒著做,回溯順序就會反轉,變成 b -> e -> d 和 t -> e -> a, 回溯出來的解就不可能被逆轉,一定是最優的那一個。 # 例題 儘管 DP 並無定形,要訣卻是共通的。 一邊練習 DP 思考問題的方式,一邊把握各種定義出狀態、轉成子問題的方式, 是掌握運用 DP 的感覺最佳的方式。 仔細看的話,下列例題雖然天差地遠,其實有相當多相似之處。 最常見手法是將最後一組拿掉,看它能有什麼操作、或子問題的什麼因素會影響它。 ## UVa 369 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?369) 大多數問題,總之將關心的東西放到狀態中,切掉尾巴通常就會變成子問題。 方法數的位置通常固定,所以試著將狀態定為: ``` 以 dp[n][m] = k 代表在 n 個物品中取出 m 個,共有 k 種方法 ``` 這 n 個物品,每一個我們都能夠選擇取,或者不取,兩種可能。 比如說 4 取 2 則有以下幾種可能: ``` 1234 ---- OOXX => 12 OXOX => 13 OXXO => 14 XOOX => 23 XOXO => 24 XXOO => 34 ``` 尾巴就是第 n 個,這東西必定要決定取或不取。 把第 n 物拿開,剩下 n-1 個東西就會變成同性質子問題。 若取第 n 物,則餘下 n-1 個中,要剛好取 m-1 個,加上第 n 個剛好; 若不取第 n 物,則餘下 n-1 個中,要剛好取 m 個才會剛好。 ``` 取第 n 個 不取第 n 個 dp[n][m] = dp[n-1][m-1] + dp[n-1][m] ``` 所有可能中,第 n 個東西必定只有取和不取兩種情況,故沒有少算; 兩種方法中一邊必定有 n 一邊必定沒有 n 所以沒有多算。 ## UVa 10198 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10198) ``` 小孩學寫數字,但只學了 1, 2, 3, 4 且認為 4 的值和 1 是相同的。 他還沒有十進位概念,因此若要表達 5,他可以寫 122 這樣加起來剛好是 5。 但他也可以寫 212 或 14414 加起來也會是 5,給你 n 求小孩共有多少方式表達數字 n。 ``` 不管數字怎麼寫,最後一個數字必定是 1, 2, 3, 4 其中之一。 拿掉最後一個數字後,剩下的數字就會變成同性質子問題。 若結尾是 1,那麼剩餘數字加總應是 n-1。 若結尾是 2,那麼剩餘數字加總應是 n-2。 若結尾是 3,那麼剩餘數字加總應是 n-3。 若結尾是 4,那麼剩餘數字加總應是 n-1。 ``` dp[n] = k 表示寫出數字 n 的方法數共有 k 種 dp[n] = dp[n-1] + dp[n-2] + dp[n-3] + dp[n-1] ``` ## UVa 10401 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10401) 考慮第 i 直行,由於皇后受傷,攻擊範圍往右只有一格距離, 因此只有第 i-1 直行的皇后位置能夠影響自己可擺放位置,而這是重要的、需要分別的。 但第 i-2 或更之前的直行的皇后位置則不重要,可以壓縮在同一個狀態中。 第 i-1 直行的皇后,只要不攻擊到自己就可以了。 換言之,第 i 直行放在第 j 橫排的話,只要第 i-1 直行的皇后, 不在第 j-1, j, j+1 橫排,就不會互相攻擊。 ``` dp[i][j] = k 代表有 i 個直行的棋盤上,第 i 直行的皇后放在第 j 橫排有 k 種方法 dp[i][j] = sum(dp[i-1][k]) for k != (j-1, j, j+1) ``` ## UVa 10721 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10721) 在 n 條長條、剛好有 k 組相同時,第 n 條只有兩種情形,亮或者暗; 若是和第 k 組亮暗相同,則會歸在同一組;若不同則變成新的一組。 一組至多只有 m 條,所以第 k 組有幾條也是重要的。 ``` dp[i][j][p][q] 代表 i 條長條,有 j 組,第 j 組有 p 條,q 代表第 j 組亮暗 dp[i][j][p][q] = dp[i-1][j][p-1][q] // 第 i 條亮暗相同,湊成一組 + sum(dp[i-1][j-1][k][1-q]) // 亮暗不同,窮舉 j-1 組有幾條 ``` 十分複雜,所以改良一下,不要把條看成單位,而把組看成單位。 第 k 組只有 m 種可能,1~m 條;最左邊一定是暗,所以 k 的奇偶就是亮暗。 因此把不要的第 j 組有幾條和亮暗去掉,降維之後變成: ``` dp[i][j] = k 代表 i 條湊成 j 組有 k 種方法 dp[i][j] = sum(dp[i-a][j-1]) for a = 1~m ``` ## UVa 10664 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10664) 有 n 個東西每個取或不取,因此把第 n 個拿掉就會變成子問題。 這題關心的是重量,而同樣 n 物,重量相同的情況可視為相同。 ``` dp[n][m] = 0 or 1 代表前 n 物湊出重量 m 是否可能 dp[n][m] = dp[n-1][m] || dp[n-1][m-w[n]] ``` ## UVa 10036 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10036) 每個數字用加或減串起來,把最後一個數字和運算子拔掉就會變成子問題。 整除和餘數有關,同餘可視為相同。 ``` dp[n][m] = k 代表前 n 個數字湊出餘數 m 是否可能 dp[n][m] = dp[n-1][(m+M-num[n])%M] or dp[n-1][(m+num[n])%M] ``` ## UVa 116 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?116) 在第 (i, j) 格,肯定是從第 i-1 排的第 j-1, j, j+1 這三格過來。 不管前面怎麼走,只要到了第 (i, j) 格後,往後的發展都會一樣。 也就是說,在這格不是最佳的時候,不管怎麼走,只要最佳解走一樣路線就會更好。 ``` dp[n][m] = k 代表走到這一格上的最佳解是 k dp[n][m] = min(dp[n-1][m-1], dp[n-1][m], dp[n-1][m+1]) + ary[n][m] ``` ## UVa 11258 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?11258) n 位數字,切去第 n 位雖然可形成子問題,但同樣須注意和尾巴那組數字的串接。 同之前的要訣,將完整不再分割的數字看成一組,把尾巴那組去掉,變成子問題。 不知道切哪裡好的時候,就把所有可能位置都試一遍,挑最好的。 ``` dp[n] = k 代表前 n 位數字切割加總的最大值 dp[n] = max(dp[n-k] + to_num(n-k+1, n)) for k = [1, 10] ``` # 經典題 經典題多半有一定困難度,但可能常見、可能泛用、可能解法巧妙等, 因各種原因導致幾乎人人都會,或至少背過解法。 ## 最大連續和 ### UVa 10684 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10684) 暴力的做法是窮舉連續和的起點和終點,再把中間全部加起來。 起終點共有 n^2 對,每對花 n 的時間掃過,複雜度 n^3。 固定起點 i,掃每個終點 j 時用 [i, j-1] 的連續和加上 j 的值,可求出 [i, j] 段的和。 這樣可以降到 n^2。 考慮所有連續和必定以某個 j 為結尾,而對於特定結尾 j,要嘛和 j-1 連起來,要嘛不連。 連起來的話,則從 j-1 往前總和越大越好,也就是結尾為 j-1 的最大連續和最佳。 而不連的時候表示以 j 開頭,又 j 為結尾,故只有 j 自己。 於是發現以 j 為結尾的最大連續和,和 j-1 為結尾的最大連續和有關,又屬於同性質問題。 ``` dp[n] = k 代表以 n 為結尾時,最大連續和為 k dp[n] = max(dp[n-1], 0) + ary[n] // 0 代表不接上 j-1 ``` ### UVa 108 && 10755 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?108) [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10755) 以下用 108 來講解,10755雖然多了一維,依此類推即可。 暴力做法是窮舉每個子矩形的左上角和右下角,每個角是一個點對,一個點對需要 n^2 的複雜度, 兩個點對要 n^4,計算子矩形總和 n^2,總共是 n^6。 固定上邊界和下邊界後,決定左右邊界 (i, j) 時,子矩陣和可從左右邊界為 (i, j-1) 時, 再加上 j 這一直行的值而來,上下邊界 n^2,左右邊界 n^2,從上邊界加到下邊界 n, 成功降到 n^5。 若先建表 tbl[n] 代表陣列第一個元素到第 n 個元素的總和, 則 tbl[n] = tbl[n-1] + ary[n] 透過 tbl[j] - tbl[i-1] 可以快速求出 ary[i] 到 ary[j] 的總和。 ``` tbl[i-1] = ary[1] + ary[2] + ... + ary[i-1] tbl[j] = ary[1] + ary[2] + ... + ary[i-1] + ary[i] + ... + ary[j] ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑ ``` 將一直行用一次的減法算出總和,這樣可以省去計算直行總和的複雜度 n。 到這裡降到了 n^4,已足夠解出此題。 運用同樣的技巧,我們可以在決定上下邊界後,將每一直行用一次減法算出總和,壓成一維, 就變成經典的最大連續和問題了。 ## 找零錢 ### UVa 674 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?674) 直觀想法是 n 元可以透過窮舉硬幣面額 1, 5, 10, 25, 50 作為尾巴, 得到 dp[n] = dp[n-1] + dp[n-5] + dp[n-10] + dp[n-25] + dp[n-50] 但這是不對的,例如 dp[6] 取一元硬幣從 dp[5] 轉移而來時,有 5+1 這種方法; 而取五元硬幣從 dp[1] 轉移而來時,有 1+5 這種方法,但這是重覆計算。 為了避免這種排列順序不同,組合卻相同的情形,我們希望讓小的先放、大的後放, 這樣保證了順序必定由小至大,不會反過來,不會出現僅排列不同的問題。 考慮前 n 種硬幣組成金額 m 的情形,可以分成使用第 n 種硬幣,以及不使用,共兩種可能。 不使用的話就只靠前 n-1 種硬幣;使用的話就必須前 n 種組成 m-value[n], 加上第 n 種硬幣剛好金額 m。 ``` dp[n][m] = k 代表以面額前 n 大的硬幣,組成總金額 m 的方法數為 k dp[n][m] = dp[n-1][m] + dp[n][m-value[n]] ``` dp[n-1][m] 定義上必不包含第 n 種硬幣; 而 dp[n][m-value[n]] 則包含 0 至多個第 n 種硬幣,加上一個第 n 種硬幣,則至少有 1 個。 於是兩邊獨立不交集,又互相補完 0 和多個第 n 種硬幣的可能情形。 #### 滾動數組 考慮第 n 種硬幣時,從轉移來看,實際上只需要 dp[n] 和 dp[n-1] 兩條陣列。 因此,可用兩條陣列 p, q 分別代表 dp[n-1] 和 dp[n],之後交替代表 dp[n] 和 dp[n-1], 即為滾動數組,可將空間複雜度自 nm 降至 2n。 實際實作有幾種方式: * 寫兩份轉移 p->q 和 q->p,視奇偶互相交替。 * 自 p 轉移到 q 之後,將 q 整個陣列複製至 p。 * 宣告 int dp[2][M]; 並用 dp[n&1] 和 dp[(n-1)&1] 分別代表 q 和 p 註:&1 運算與 %2 (除以二取餘數以判斷奇偶)等價 * 宣告兩條陣列和兩個指標 p, q 並透過交換 p, q 來輪替。 第一個方法程式碼多重覆、容易出錯、寫錯時不易修正,十分不建議。 第二個方法執行效率不佳。 第三和第四差不多,儘管除法取餘不快,但位運算 & 十分快速。 #### 只用一條陣列 考慮到方向性,m 轉移時必往較小的 m-value[n] 去,方向單一,故可只用一條陣列。 剛進到第 n 種硬幣時,陣列中全是 n-1 時的內容。 正巧轉移中也需要 n-1 時的內容,便可寫成: ```c dp[m] += dp[m-value[n]]; ``` 又,考慮 m-value[n] 是取 dp[n] 的,而不是 dp[n-1] 的, 因此必須保證考慮 m 時,所有 < m 的地方都已是 dp[n],而非 dp[n-1] 的內容。 故迴圈從 0 開始往後掃,才能保證在考慮 m 時,所有比 m 小的全都已被掃過, 已被掃過就表示內容已被從 dp[n-1] 更新到 dp[n] 了。 如果順序相反,則 DP 的意義上將完全不同,就是錯的。 ```c for (int i=value[n]; i<=M; i++) { dp[i] += dp[i-value[n]]; } ``` 相對二維變得簡潔好寫,但若不從原先二維的狀態與轉移開始理解, 從花費許多功夫優化過的結果,是相當難消化的。 ## 背包 ### UVa 10130 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10130) 有 n 個物品,每個取或不取;則拿掉第 n 個物品,便是子問題。 相同重量時我們希望價值越高越好,故將相同重量不同價值壓縮起來。 進一步思考,重量較大時的最高價值,一定不比重量較小時低。 因此考慮重量 m 的時候,可將重量 m 以內的一併考慮進來, 也省去窮舉可能達成的重量的功夫。 狀態設為 n 個物品時,重量 m 以內的最大價值為 k, 第 n 件物品有兩種選擇:取,或者不取。 若取,則前 n-1 件物品重量需在 m-weight[n] 以內。 若不取,則前 n-1 件物品的重量只要在 m 以內即可。 ``` dp[n][m] = k 代表前 n 件物品,總重量 <= m 的所有情況,最大價值為 k dp[n][m] = max(dp[n-1][m], dp[n-1][m-weight[n]] + price[n]) ``` 同理可使用滾動數組,不再贅述。 #### 只用一條陣列 同理,但這裡 m-weight[n] 是要 dp[n-1] 的, 因此須保證在考慮 m 的時候,所有 < m 的部份都要維持仍是 dp[n-1] 的狀態。 迴圈順序必須從大到小,才能保證考慮 m 的時候,所有 < m 的地方仍未掃過, 維持 dp[n-1] 時的狀態。 細節參考找零錢的部份。 ## 最長遞增子序列(LIS) 序列是一個有序的集合,順序不同視為不同。 子序列為原序列刪去 0 到 任意多個元素後,形成的新序列。 每個元素可選刪與不刪,擁有 n 個元素的序列,共有 2^n 種子序列。 自己也是自己的子序列。 ### UVa 497 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?497) 題目要求找出所有子序列中,元素呈嚴格遞增者,最長長度是多少。 觀察可發現嚴格遞增的序列,拔掉最後一個元素,仍是嚴格遞增的序列。 且子序列的子序列,也會是自己的子序列。 因此拔掉最後一個元素,會變成同性質的子問題。 同時,最後一個元素的值和位置,影響這個遞增子序列,能不能添加元素變得更長。 而尾巴相同的遞增子序列中,長度最長的永遠最好。 因為後續能添加的一樣,別人怎麼變長,自己跟著做就一定比別人更好。 故結尾相同時可壓縮成同一個狀態,只需保留最佳; 拔掉結尾後即為子問題。只要子問題的結尾在自己之前,且值比自己小,自己就可以接上去。 ``` dp[n] = k 代表以 n 為結尾的遞增子序列,最長長度為 k dp[n] = max(0, dp[k]) + 1 for k < n && seq[k] < seq[n] ``` ## 最長共同子序列(LCS) 對兩序列 a, b 求最長序列 s 使其滿足既是 a 的子序列,同時是 b 的子序列。 ### UVa 10405 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10405) 注意這題的翻譯,原文的 subsequence 是子序列而不應是子字串 (substring); 子字串的定義是從一字串第 i 個字元到第 j 個字元,必須是連續的。 觀察兩個序列 a, b 假設它的最長共同子序列為 s, 我們看 a 的最後一個字元 a[n] 以及 b 的最後一個字元 b[m], s 只有四種可能: * s 的最後一個字元是 a[n] * s 的最後一個字元是 b[m] * s 的最後一個字元既不是 a[n] 也不是 b[m] * s 的最後一個字元既是 a[n] 又是 b[m] 當 a[n] == b[m] 的時候,則一定是第四種情形最佳; 否則 a[n] 和 b[m] 至少有一個字元跟其它字元成對出現在 s 中, 若都不是,那麼 s 可以透過加入 (a[n], b[m]) 變得更佳,那就不是最佳解。 但是這種情況把 s 的最後一個字元換成 (a[n], b[m]) 也不會變糟,可能性相同。 這時 s 會是 a 的前 n-1 個字元,和 b 的前 m-1 個字元的 LCS 再接上 (a[n], b[m]), 成功找到同性質子問題。 如果不是,考慮剩下三種情形: 若是第一種,那麼把沒用上的 b[m] 拿掉後,s 不變; 若是第二種,那麼把沒用上的 a[n] 拿掉後,s 不變; 第三種拿掉哪邊都不變,可以不考慮。 由於我們不會知道 s 長什麼樣,不如說正是不知道 s 長什麼樣,才需要求解; 所以無法判斷該拿掉 b[m] 還是 a[n],但可以知道只有這兩種可能性, 那就都試試看。 ``` dp[n][m] = k 代表 a 的前 n 個字元和 b 的前 m 個字元,其 LCS 長度為 k dp[n][m] = dp[n-1][m-1] + 1 if a[n] == b[m] or max(dp[n-1][m], dp[n][m-1]) else ``` ## 最佳矩陣連乘順序 ### UVa 348 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?348) n 個矩陣連乘的結果,最後必定會變成一個矩陣。 而最後這一個矩陣,會是過程中剩下的最後兩個矩陣相乘而來。 每個乘號會將兩個矩陣變成一個。 假設最後一個乘號是第 k 個乘號, 則以此乘號將運算式分成兩邊,各自乘成一個矩陣後, 再透過第 k 個乘號乘起來,就是最後結果。 被乘號分開的兩邊,各自互不相干,各自是一個運算式, 或者一開始就只有一個矩陣(雖然這也算一個運算式)。 因此找到了子問題,但是不能判斷 k 是哪一個, 那就每個都試試看。 ``` dp[n][m] = r 代表從第 i 個矩陣連乘至第 j 個矩陣,最少乘法次數為 r dp[n][m] = min(dp[n][k] + dp[k+1][m] + mul(n, k, m)) for k = [n, m-1] // mul(n, k, m) 為矩陣 [n, k] 和矩陣 [k+1, m] 相乘所需次數 ``` # 進階技巧 ## 2^n 當有 n 個東西,互相順序可對調,求最佳順序時可用。 將用過和沒用過編碼成 2^n 則可將用過的集合相同時的狀態壓縮起來。 ### UVa 10944 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10944) 撿起同樣的松果、且最後撿起的松果相同(影響最後所在位置)時,可以當作一樣的狀態。 比如說撿的順序 1→3→6→2 和 3→1→6→2 可以看作一樣,因為撿起的都是 1, 2, 3, 6 且最後所在位置都是松果 2 所在位置上。 後續可撿、需要撿的松果相同,出發位置也相同。 ``` dp[n][m] = k 代表已撿松果集合 n (以二進制表示) 所在位置 m 最佳步數為 k dp[n][m] = min(dp[n-(1<<m)][q] + dis[q][m]) for ((1<<q) & n) != 0, q != m // dis[q][m] 為松果 q 走到松果 m 的最小距離 ``` ### UVa 10911 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10911) 每次決定一組人,剩下人的集合即為子問題。剩下人的集合相同時,可以當作一樣的狀態。 每個人是否存在剩下人的集合以 0 和 1 表示,可編碼成 2^n 種狀態。 決定一組人時雖有 n^2 種配對,考慮到剩下人的集合中,最後一人遲早要被組進去, 故可直接將最後一人列入組隊,只另外幫他找一個隊友。 這樣能將 n^2 種配對降至 n 種。 例如,同樣剩下 2, 3, 5, 7 四人時,先取 2, 5 再取 3, 7 和先取 3, 7 再取 2, 5 是完全相同的意思; 與其窮舉任兩人出來配對, 不如固定把 2 抓出來,在 3, 5, 7 中找一個人當他的隊友。 反正 2 最後一定要跟某個誰組隊,放後面也沒有好處。 ``` dp[n] = k 代表剩下人的集合 n (以二進制表示) 最佳距離和為 k dp[n] = max(dp[n-(1<<p)-(1<<q)] + cost(p, q)) for p 滿足 (n & (1<<p)), p != q // q 為滿足 (n & (1<<q)) 的最小整數;cost(p, q) 為 p, q 組隊花費 ``` ## 篩法 在質數篩法中,通常用 bool tbl[N] 來儲存每個數是否有「被其它質數篩掉」。 既然所有合數都是被「其他質數」篩掉,何不記下每個合數被誰篩掉? 改用 int tbl[N] 存「每個數被哪個質數篩掉」, 這樣篩法做完後,每個合數都會知道自己的其中一個質因數。 ### UVa 884 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?884) 觀察可知,拆成一堆數字相乘時,若存在合數,則合數可再拆成更多數字相乘。 質數只能拆成不符合題目條件的 1 和無法讓狀況更佳的自己。 因此將 n! 質因數分解後,將每個質因數的次數相加,即為所求。 利用 n! = (n-1)! * n 的遞迴性質,馬上就找到子問題, 只剩下將 n 給質因數分解。 利用篩法得知的任意質因數 k,可得出子問題 n/k; n 質因數分解後的次數和,為 n/k 的次數和 + 1。 為了簡化轉移式的表達,這裡假設所有質數都會篩去自己, 即對於任意質數 p 滿足 tbl[p] = p。 ``` dp2[m] = s 表示整數 m 質因數分解後的次數和為 s dp2[m] = dp2[m/tbl[m]] + 1 // tbl[m] 為篩法過程中,篩去 m 的任意質數之一 dp[n] = r 表示 n! 質因數分解後的次數和為 r dp[n] = dp[n-1] + dp2[n] ``` ### UVa 10699 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10699) 篩法能得出整數 n 的任一質因數 k,但無法知道 k 的次數, 無法知道 n/k 是不是也包含質因數 k。 那就用 log n 的時間,把所有 k 除乾淨,得出 m 為 n 的因數,且 m%k != 0。 ``` for (m=n; m%k==0; m/=k); ``` 則 n 一定比 m 多一個 m 並沒有的質因數 k, 並且除了 k 以外,m 擁有的質因數數量和 n 相等。 ``` dp[n] = k 代表整數 n 相異質因數的個數為 k dp[n] = dp[m] + 1 // m 為 n 除以 k 直到不整除為止的結果 ``` ## 更多背包問題 ### UVa 711 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?711) 當每一種物品數量不只一個時,最簡單的做法就是將它們看作重量相同、價值相同的不同東西。 不過拆下去可能會非常多個。 觀察這個過程,同樣物品 n 放第一次進去時,每個狀態不取是 0 個 n,取就有 1 個 n; 放第二次時,每個狀態本來有 0~1 個 n,取的話就變成 1~2 個 n, 加上不取的 0~1 個,範圍會變成 0~2 個 n。 依此類推,放第三次會變成 0~3 個 n,第四次變成 0~4 個n,… 假如我們持有 k 個 n,何不一次放很多個 n 讓範圍快速成長到覆蓋 0~k 呢? 運用二進制的概念,如果我們持有 13 個 n, 將它們分開包裝成 2^0 個、2^1 個、2^2 個、以及剩下不足 2^3 的 6 個,共四組。 一開始共有 0 個 n; 第一組放進去時,覆蓋了 0~1 個 n; 第二組放進去時,不取覆蓋了 0~1,取覆蓋了 2~3,全部是 0~3; 第三組放進去時,不取覆蓋了 0~3,取覆蓋了 4~7,全部是 0~7; 第四組放進去時,不取覆蓋了 0~7,取覆蓋了 6~13,全部是 0~13。 結果和一個一個丟進去,同樣是覆蓋了 0~13 個 n 每一種可能, 但透過打包成 2^i 個一組,只要丟 log k 次,不需要丟 k 次。 以 k = 13 為例,只要丟 4 次,全拆一個一個丟需要 13 次。 ### UVa 10032 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10032) 除了重量需要盡量接近,人數也有限制。因此人數也是重要的、需要關心的。 ``` dp[n][m][o] = 0/1 代表前 n 個人中,以 o 個人湊成重量 m 是否可能 dp[n][m][o] = dp[n-1][m][o] || dp[n-1][m-w[n]][o-1] ``` 觀察發現雖然最多 100 人,但最多只要求 n/2 也就是 50 人, 超過 n/2 時另一組一定 < n/2 人,所以不會掉最佳解。 dp[n][m] 就變成一個元素個數 <= 50 的 0/1 陣列, 且操作是從 dp[n-1][m] 轉移過來,或從 dp[n-1][m-w[n]] 平移 1 而來。 由於 50 < 64 故可以一 long long int 用二進制表達, 則整個會變成: ``` long long dp[N][M]; dp[n][m] = k 代表以前 n 人湊成重量 m 是否可能:若 k 二進制第 i 位為 1,則 i 個人可能 dp[n][m] = dp[n-1][m] | (dp[n-1][m-w[n]] << 1) ``` 須注意 `(1 << i)` 由於 1 型別為整數常數預設的 int 因此會溢出, 需追加後綴 LL 轉型成 long long 型別常數: ``` (1LL << i) ``` 小寫 `ll` 效果相同,但視認上易與 `11` 混淆,建議一律以大寫 `LL` 作為後綴。 ## LIS 與 LCS ### UVa 481 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?481) 每次去搜所有在自己之前的元素,其實做了許多無謂功。 基於貪心,如果找到長度為 k 的遞增子序列,自己能接在後面, 則無需再考慮任何長度 k 以下的遞增子序列,它們一定不會更好。 考慮第 n 個元素作為結尾時,子問題的遞增子序列,最長也只到 n-1。 我們可以將長度 k 的遞增子序列,其尾巴掛在長度 k 的地方, 則只要從當前最長長度往下搜,找到第一個自己能接上的尾巴,就是最佳選擇。 考慮同樣長度 k 的遞增子序列,基於貪心,結尾更小一定不會更糟, 對任何長度 k 只需保留最小可能結尾便足夠。 用一條陣列 tail[k] = s 存長度 k 的遞增子序列中,最小結尾為 s; 則對於每一個尾巴 j,找到一個最大的 k 使得 tail[k] < j, 可知尾巴 j 最長能接在長度 k 之後,組成長度 k+1 的遞增子序列。 同時,tail[k+1] 要不是不存在,就是滿足 tail[k+1] >= j, 否則 k 就不是最大的 k 能使得 tail[k] < j,k+1 才是。 因此可將 tail[k+1] 更新成 j,因為 j 一定不會比較差。 此方法在 LIS 長度為 m 時,擁有 nm 的複雜度,儘管看起來和 n^2 沒什麼區別; 如果任一結尾 j 不是在當下最長長度 k 就接得上的話,最長長度 k 保持不變; 接得上的話,比 k 小的部份全部無須考慮,面對隨機測資時非常強。 沒特別構造測資的話,很難搞爛這個方法。 #### 進一步優化 每次更新 tail 時,從上可知 tail[k] < j <= tail[k+1], 故 tail[k] < tail[k+1] 永遠成立,對於任意 k 都成立, 這意味著 tail 本身會呈嚴格遞增。 因此,可以對其二分搜尋,將複雜度降為 n log m。 ### UVa 10131 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10131) 看似與上一題相同,由於多維而無法使用優化過的 LIS,其實不然; 剛好二維且需要排序的情形例外,按照其中一維排序後,就只需要考慮另外一維了。 例如這題希望體型嚴格遞增,而智商嚴格遞減。 按體型不遞減排序,便已確保至少體型不會變小,可以無視掉; 只須關心智商的遞減即可。 注意排序時若體型相同,排序後我們只看智商會誤接, 因此,體型相同時,智商應按照相反順序排序,像這題要按智商遞增。 如此體型相同時,智商的順序會使得同體型無法接在一起。 ### UVa 103 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?103) 這是另一種 LIS 的變形,給你 n 個東西,順序任意,問最長能串多長。 順序隨意看起來很難,但只要決定好順序,就和一般 LIS 問題沒有兩樣。 對這 n 件東西排序,讓排序對於任意兩物 i, j,必定滿足以下條件: ``` 如果可接成 i -> j,則 i 排在 j 前; 如果可接成 j -> i,則 j 排在 i 前; 若都不可能則隨意。 ``` 以這題而言,先對每一物的每一維各自排序,再對每一物做字典序排序即可。 值得注意的是,這題由於尾巴有多維,可是我們無法判斷 (2, 5) 以及 (3, 4), 這兩個尾巴哪一個才是最好的。 不論哪一個,相較其它尾巴,都無法滿足一定不會變得更差的前提。 因此,不能只保留最佳可能、並壓縮在同一個狀態內。 沒有辦法判斷最佳。 這有兩種處理方式,一是用最基本的 n^2 做法處理。 一是將所有長度 k 可能尾巴全數保留。 但基本上這樣做已失去該優化方式的優勢,卻會讓程式碼變得十分複雜。 n^2 做法看似派不上用場,但若只會優化過的方法,便無法應付這類型的題目。 ### UVa 10635 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10635) 很顯然地,LCS 那 n^2 的複雜度並不夠快。 從定義可知,序列 a, b 的共同子序列 s 中, 任何一個元素,都必定同時存在於 a 和 b 中, 因此 s 每個元素可寫作 (i, j) 代表該元素取自 a[i] 與 b[j]。 s 從定義上任意兩個元素 (i, j) 與 (p, q), 若 (i, j) 在前,則滿足 i<p && j<q。 因此,LCS 問題可透過找出所有配對 (i, j) 使得 a[i] == b[j], 對所有配對做順序可換的 LIS 即可得出 LCS 長度。 尋找配對時,可先掃過序列 a 記錄每個數出現位置, 再來掃序列 b,則此順序保證了第二維的嚴格遞增,故無須排序; 故轉成對第一維,也就是在 a 中的出現位置的一維 LIS, 而在 a 中的位置可以常數時間複雜度查表得出。 這題只有 n 個配對, 轉 LIS 可做到 n log n 的複雜度。 ### UVa 10949 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10949) 和上題相似,但序列元素可重覆導致配對數暴增。 同樣掃過第一個序列,不同的是字元會重覆出現, 故須用一陣列 loc[i] 儲存所有字元 i 出現的位置。 掃過第二個序列時,同樣保證了第二維的嚴格遞增; 但同 [UVa 10131](https://hackmd.io/oScxoezYSJyu-DYLFzyuAw?both#UVa-10131), 第二維相同時,第一維須以大到小的順序加入,以防止第二維相同時誤接上去。 又,序列 b 中相同字元產生的配對,由於是以大到小的順序加入, 若上一個字元為結尾,可形成長度 k+1 的遞增子序列, 則下一個字元為結尾時,可形成的遞增子序列長度,由於第二維相同、第一維更小, 能形成的遞增子序列長度必不超過 k+1。 因此,對於 b 的同個字元,在 a 中的不同配對, 尋找結尾最佳長度時,只需要從上一個的位置往前找,不須每次從頭。 配對數最差 n^2 個,但同個字元只往前掃不後退, 若 LCS 長度為 m 則複雜度實質上是 n^2 + nm。 ### TOJ 26 [題目連結](http://toj.tfcis.org/oj/pro/26/) 和上題相似,可轉 LIS 加速。僅供練習。 ## 最佳二元樹 ### UVa 10304 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10304) 二元搜尋樹必有一個 root, 決定任一 root k 之後,則 k 的左右兩邊各被隔絕為一棵子樹, 且子樹也是二元搜尋樹。因此成功找到子問題。 不知道哪個為 root 為佳,則都試試。 ``` dp[n][m] = k 代表以第 n 到第 m 個數組成一二元搜尋樹,最佳答案為 k dp[n][m] = max(dp[n][k-1]+dp[k+1][m]+calc(n, k, m)) for k = [n, m] // calc(n, k, m) 為連續段 [n, m] 以 k 為 root 時的額外開銷 ``` #### 四邊形不等式優化 觀察二元搜尋樹 [n, m] 已知其最佳 root 為 k, 若在 m 右邊插入新元素,則左子樹 [n, k-1] 不影響, 右子樹 [k+1, m+1] 多一個元素肯定變糟。 在右子樹變糟的情況下,若 k 左移讓左子樹更小, 則右子樹只會再更糟,一定不會比較好; 若會,則 k 應不是 [n, m] 的最佳 root。 故在右插入新元素時,k 應該不變或右移,左移一定只會更糟。 同理在最左邊插入新元素,則左子樹變糟,k 不該右移。 如此若以 root[n][m] 記錄 [n, m] 的最佳 root 時, 則應滿足 `root[n][m-1] <= root[n][m] <= root[n-1][m]`, 因為 [n, m] 相當於對 [n-1, m] 最左插新元素,故 k 不該右移; 同時 [n, m] 相當於對 [n, m-1] 最右插新元素,故 k 不該左移。 對於 n 個數字,左邊界最多有 n 種, 每種左邊界其最佳 root k 隨著右邊界增長,k 只會不變或跟著增長, 相當於線性掃過,因此複雜度從原本的 n^3 加入此優化後降為 n^2。 ## 轉移矩陣與快速冪 對於滿足是計數型、沒有 if 且轉移方程單純是某幾項乘特定係數的型, 可將初始項寫作矩陣、找出轉移矩陣後,轉成每乘一次轉移矩陣,就得出下一項的形式。 如此對初始矩陣 M、轉移矩陣 T 則第 n 項可在 M * T^n 找到, 又基於矩陣的交換律,T^n 可透過快速冪在 log n 次乘法內算出, 可得出時間複雜度為 轉移矩陣大小^3 * log n 的方法。 ### UVa 10689 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10689) 費氏數列為一相當好的例子,其轉移方程 `f(n) = 1*f(n-1) + 1*f(n-2)` 需要兩項才能推得下一項,因此初始項需要兩項: * f(0) = 0 * f(1) = 1 則初始矩陣為 `[0, 1]` 顯然地乘上轉移矩陣後,必須維持原大小,以乘上下一次的轉移矩陣。 故 1\*n 的初始矩陣,轉移矩陣大小為 n\*n。 將初始項和轉移後的目標寫上去,可得以下式子: ``` [f(0) f(1)] x [a c] = [f(1) f(2)] [b d] ``` 從矩陣乘法得: * `f(1) = a*f(0) + b*f(1)` * `f(2) = c*f(0) + d*f(1)` 可知轉移矩陣每一直行,恰為轉移後該項對轉移前每一項的係數。 從第一式推得 `a=0, b=1` 從第二式推得 `c=1, d=1` 故轉移矩陣為 ``` [0, 1] [1, 1] ``` 本題會給 a, b 作為初始項 f(0) 和 f(1),但轉移矩陣共通。 #### 快速幂的實作 快速幂常見有兩種實作方法, 第一種方法是對奇次方拆成 1 次方和 n-1 次方; 對偶數先算出 n/2 次方後自乘。 ``` a^n = a^(n-1) * a if n&1 a^(n/2) * a^(n/2) else ``` 好處是好學好懂好理解,壞處是最差變成 2 log n,不過這影響不大。 最大問題是過程出現的次方數變化大,難以建表。 第二種方法是對次方拆成二進制,例如 23 次方可拆成二進制 10111, 即: ``` a^23 = 1*a^16 + 0*a^8 + 1*a^4 + 1*a^2 + 1*a^1 ``` 壞處是相對難懂,好處是轉移矩陣共通時,由於次數共通, 轉移矩陣的 2^i 次方的結果可以不需要重新計算,能夠建表。 如果 23 用第一種方法,會得出過程 23、22、11、10、5、4、2、1 次方, 而第二種方法會得出 16、8、4、2、1 次方。 這題儘管初始項 [a, b] 隨輸入變化,由於轉移方程相同, 使用方法二則可以把過程中的轉移矩陣存下,每次無須重新計算過程中的轉移矩陣 2^i 次方。 ### UVa 10518 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10518) 在最前面就有解過,轉移方程如下: ``` dp[n] = dp[n-1] + dp[n-2] + 1 ``` 由於推至下一項需要三項,故初始矩陣為 1\*3 大小, 而轉移矩陣則需 3\*3 大小。 ``` [a d g] [1 dp[n-2] dp[n-1]] * [b e h] = [1 dp[n-1] dp[n]] [c f i] ``` $\left[ \begin{array} {ccc} 1 & dp[n-2] & dp[n-1] \\ \end{array} \right] * \left[ \begin{array} {ccc} a & d & g \\ b & e & h \\ c & f & i \\ \end{array} \right] = \left[ \begin{array} {ccc} 1 & dp[n-1] & dp[n] \\ \end{array} \right]$ * `1 = a*1 + b*dp[n-2] + c*dp[n-1]` * `dp[n-1] = d*1 + e*dp[n-2] + f*dp[n-1]` * `dp[n] = g*1 + h*dp[n-2] + i*dp[n-1]` 推得 a=1, b=0, c=0; d=0, e=0, f=1; g=1, h=1, i=1; ### UVa 11486 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?11486) 這題需要的是將第 n-1 直排的 7 格,每格以 0 或 1 代表士兵有無, 轉移到第 n 直排的 7 格,每格以 0 或 1 代表士兵有無。 因此對所有可能狀態進行編碼,變成二進制 0 ~ 2^7-1, 並求出這些狀態之間彼此是否可能互相轉移。 若狀態 a 在每隻士兵各走一步後,能夠變成狀態 b 則表示狀態 a 可轉移至狀態 b。 最後得一矩陣,其 (i, j) 格代表狀態 i 是否可轉移至狀態 j, 以此為轉移矩陣,即可將 n-1 直排轉移至 n 直排。 ### TOJ 416 [題目連結](http://toj.tfcis.org/oj/pro/416/) 僅供練習 ## 排列組合 ### UVa 10338 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?10338) 考慮 ACCDD 這個例子,如果把尾巴 D 一個一個放進去會很難計算, 將所有相同字元 D 一次拔除,則轉為子問題 ACC。 對 ACC 的所有排列方式,將兩個 D 插入, 等同於在 3 個相同物 (ACC) 中,插入 2 個相同物 (DD),為 H(3, 2) = C(5, 3)。 ``` dp[n] = k 代表前 n 種字元全放進去的排列數為 k dp[n] = dp[n-1] * H(p, q) // p 為前 n-1 種字元總數,q 為第 n 種字元數量 ``` ### ACM-ICPC Live Archive 4656 [題目連結](https://icpcarchive.ecs.baylor.edu/index.php?option=com_onlinejudge&Itemid=8&category=351&page=show_problem&problem=2657) 與 10338 解法相似,僅供練習。 ## 約瑟夫問題 [題目連結](http://domen111.github.io/UVa-Easy-Viewer/?11351) 有 n 個人圍成一個圈圈,每數 k 個人就把那人殺掉。 在殺掉第 k 個人之後,會剩下 n-1 個人, 於是得到只有 n-1 個人的子問題。 假設這 n-1 個人,最後的倖存者是第 p 個人; 原本 n 個人的時候,我們會殺掉第 k 個人, 這時剩下的 n-1 個人,是從第 k+1 個人開始數的, 因此 n-1 個人的倖存者 p 其實是從原本 n 個人中的 k+1 數起, 故 n 個人時的倖存者為第 k+1+p 個人。 實作上由於從 0 數起的話,實際死掉的是 k-1 這個人, ``` 故以 dp[n] = m 代表 n 個人時倖存者編號為 m (m 的範圍 [0, n-1]) dp[n] = (k + dp[n-1]) % n ``` 假設 k=3 的情況, n=3 時倖存者為 1: ``` 編號: 012 存亡: XOX ``` 0 和 2 都被殺死(以 X 表示),剩下 1 還活著(以 O 表示)。 這時考慮 n=4 的情況: ``` 編號: 0123 存亡: OOOO ``` 我們還不知道倖存者是誰,但第一個被殺死的會是第 k 個人, 編號是 k-1。 ``` 編號: 0123 存亡: OOXO ``` 殺掉後得到子問題:n=3, k=3 已知的倖存者是 1,但那是從第一個人開始數的情況。 在 n=4 殺掉 2 之後,剩下的三個人會是 301 ``` 編號: 0123 存亡: OOXO ↓ 編號: 0123 存亡: O 存亡: OO ↓ 編號: 0123 (n=4視角) 編號: 12X0 (n=3視角) 存亡: O 存亡: OO ↓ 編號: 0123 (n=4視角) 編號: 12X0 (n=3視角,編號 1 生存) 存亡: X 存亡: OX ``` n=3 的子問題是從第一個人(編號 0)開始數下個被殺的人, 而 n=4 轉移到 n=3 子問題時,是從被殺掉的人的下一個(編號 k)開始數下個被殺的人。 故將 n=3 子問題得到的倖存編號,重新對照一下在 n=4 時的編號, 就知道 n=4 的倖存者了。