# 演算法課程題解 - 動態規劃\: 最長共同子序列 LCS # UVa 10405 ## 題目 http://domen111.github.io/UVa-Easy-Viewer/?10405 給兩個字串,求兩字串的最長共同子序列 ## 想法 By Koios 從兩個字串的最後面一個字看回來,如果兩個字是相同的,那麼把它包含在子序列當中必定是最好的,因為再取前面的字串,子字串的長度必定是 $\leq$ 取當前這個的總長度。 那麼如果兩個字是不同的,那麼有兩種選擇 1. 留下第一個字串的 2. 留下第二個字串的 所以,當我們決定好要**取**或是**不取**某些元素之後,剩下的就是同類型的子問題了 根據上面的觀察可以得出 DP 式 定義 $dp[i][j]$ 表示第一個字串的前 $i$ 個字元以及第二個字串的前 $j$ 個字元組成的 LCS 最大長度 令第一個字串為 $s$ ,第二個字串為 $t$ 則有轉移式 $$ \begin{cases} dp[i][j] = dp[i-1][j-1] + 1 \quad \quad s[i] == t[j] \\ dp[i][j] = max(dp[i-1][j], dp[i][j-1]) \end{cases} $$ :::info ***Tips*** 如果刻意地讓 dp 式的 $i, j$ 都由 $1$ 開始的話,我們就不需要額外判斷邊界問題了~ ::: :::warning 題目的字串是可以包含空格的 ::: ### 程式碼 ```cpp= //By Koios1143 #include<iostream> using namespace std; int dp[1005][1005]; string s, t; int main(){ while(getline(cin, s) && getline(cin, t)){ for(int i=1 ; i<=s.size() ; i++){ for(int j=1 ; j<=t.size() ; j++){ if(s[i-1] == t[j-1]) dp[i][j] = dp[i-1][j-1] + 1; else dp[i][j] = max(dp[i-1][j], dp[i][j-1]); } } cout<<dp[s.size()][t.size()]<<"\n"; } return 0; } ``` ### 時間複雜度分析 DP 每個狀態轉移時間複雜度為 $O(1)$ 總共有 $|t||s|$ 個狀態,DP 總時間複雜度為 $O(|t||s|)$ 每筆測資總時間複雜度為 $O(|t||s|)$ # UVa 111 ## 題目 http://domen111.github.io/UVa-Easy-Viewer/?111 在歷史考試當中學生被要求要對歷史事件作排序,給分的方式為 - 每個在最長(但不一定要連續)的序列事件中,其相對的順序亦可以在標準答案發現者,每個事件得1分 也就是說,如果我們能在學生回答的順序當中找到一串最長的子序列,使得子序列當中的所有事件在正確答案當中的相對順序相同,那麼這些答案都得 1 分 現在告訴你正解以及學生回答的**每個事件發生的順序**,請依序回答每個學生的得分。 ## 想法 By Koios 題目其實等同要我們求正解以及學生答案的**最長共同子序列** 那麼,一樣從兩個序列的最後一個元素看回來 如果最後的元素相同,那麼加進子序列當中必定會是最好的。 如果最後的元素不同,那麼有兩種選擇 1. 留下第一個序列的元素 2. 留下第二個序列的元素 所以,當我們決定好要**取**或是**不取**某些元素之後,剩下的就是同類型的子問題了 根據上面的觀察可以得出 DP 式 定義 $dp[i][j]$ 表示第一個序列前 $i$ 個元素以及第二個序列前 $j$ 個元素的 LCS 長度 假設第一個序列為 $A$,第二個序列為 $B$ 則有轉移式 $$ \begin{cases} dp[i][j] = dp[i-1][j-1] + 1 \quad \quad A[i] == B[j] \\ dp[i][j] = max(dp[i-1][j], dp[i][j-1]) \end{cases} $$ :::info ***Tips*** 如果刻意地讓 dp 式的 $i, j$ 都由 $1$ 開始的話,我們就不需要額外判斷邊界問題了~ ::: :::warning 題目給的是**每個事件發生的順序**,而不是直接依照事件的發生順序輸入 所以記得預先將序列對應成**每個順序對應到哪個事件** ::: ### 程式碼 ```cpp= //By Koios1143 #include<iostream> using namespace std; int n, tmp, ans_ary[25], stu_ary[25], dp[25][25]; int main(){ while(cin>>n){ // 記得要對應好 for(int i=1 ; i<=n ; i++){ cin>>tmp; ans_ary[tmp] = i; } while(cin>>tmp){ stu_ary[tmp] = 1; for(int i=2 ; i<=n ; i++){ cin>>tmp; stu_ary[tmp] = i; } for(int i=1 ; i<=n ; i++){ for(int j=1 ; j<=n ; j++){ if(ans_ary[i] == stu_ary[j]) dp[i][j] = dp[i-1][j-1] + 1; else dp[i][j] = max(dp[i-1][j], dp[i][j-1]); } } cout<<dp[n][n]<<"\n"; } } return 0; } ``` ### 時間複雜度分析 每筆測資輸入時間複雜度為 $O(N)$ DP 每個狀態轉移時間複雜度為 $O(1)$ 總共有 $N^2$ 個狀態,DP 總時間複雜度為 $O(n^2)$ 每筆詢問總時間複雜度為 $O(n + n^2)$ # TOJ 26 ## 題目 https://toj.tfcis.org/oj/pro/26/ 給一個字串,求最長回文子字串的長度為何 也就是說移除最少的字元,使得字串變成回文字串,求該回文字串的長度 ## 想法 By Koios 回文的定義是前後對應的字元都相同 所以現在我們可以假想有兩個箭頭分別指向字串的頭跟尾 如果說兩個箭頭指到的字元相同,那麼包含進回文一定可以使回文總長度最長 如果兩個箭頭指到的字元不同,那麼我們可以選擇移除後面指向的字元,抑或是選擇刪除前面指項的字元 這樣觀察過後,發現到跟過去寫 LCS 的題目觀察點是相同的 換個角度再看一次這個題目 現在我們有字串 $s$ 以及把 $s$ 翻轉過來的字串 $s'$ 題目其實就等同於求 $LCS(s, s')$ 剛剛分別指向前端以及後端的指標分別指向 $s$ 以及 $s'$ 的前端 發現到其實是一樣的概念 那麼就回到 LCS 的 DP 部分 定義 $dp[i][j]$ 表示第一個字串前 $i$ 個字元以及第二個字串前 $j$ 個字元的 LCS 長度 則有轉移式 $$ \begin{cases} dp[i][j] = dp[i-1][j-1] + 1 \quad \quad s[i] == s'[j] \\ dp[i][j] = max(dp[i-1][j], dp[i][j-1]) \end{cases} $$ :::info ***Tips*** 如果刻意地讓 dp 式的 $i, j$ 都由 $1$ 開始的話,我們就不需要額外判斷邊界問題了~ ::: ### 程式碼 ```cpp= //By Koios1143 #include<iostream> using namespace std; int T, dp[3005][3005]; string s, t; int main(){ cin>>T; while(T--){ cin>>s; t = s; for(int i=0, j=s.size()-1 ; i<s.size() ; i++, j--){ t[j] = s[i]; } for(int i=1 ; i<=s.size() ; i++){ for(int j=1 ; j<=t.size() ; j++){ if(s[i-1] == t[j-1]) dp[i][j] = dp[i-1][j-1] + 1; else dp[i][j] = max(dp[i-1][j], dp[i][j-1]); } } cout<<dp[s.size()][t.size()]<<"\n"; } return 0; } ``` ### 時間複雜度分析 DP 每個狀態轉移時間複雜度為 $O(1)$ 總共有 $|s|^2$ 個狀態,DP 總時間複雜度為 $O(|s|^2)$ 每筆測資總時間複雜度為 $O(|s|^2)$ ###### tags: `SCIST 演算法 題解`