[LeetCode] 動態規劃題型總結

virgilshi發表於2020-07-13

寫在前面

動態規劃在網際網路公司的筆試題中經常會使大題的壓軸題,解動態規劃題的關鍵是定義動態規劃變數和寫出狀態轉移方程,本篇部落格主要探討動態規劃題型解法,最後也會介紹動態規劃與其他演算法知識結合的題。

152. 乘積最大子陣列

給你一個整數陣列 nums ,請你找出陣列中乘積最大的連續子陣列(該子陣列中至少包含一個數字),並返回該子陣列所對應的乘積。

解題思路: 此題很容易受連續子陣列最大和值 題的影響,以當前元素結尾序列的最大和值只取決於當前值、當前值+前一序列最大和,但是對於乘法,因為一個數a乘上數b,若數a是最大值,乘上數b後,若數b為正數,那麼依然能保證是最大值,但是若數b為負數,反而會成為最小值,因此,我們會發現求當前位置的最大乘積值,要同時關心前一個位置的最大乘積值和最小乘積值,此題屬於雙動態規劃題,定義動態規劃變數mx[i]表示以nums[i]為結尾的最大乘積值,mn[i]表示以nums[i]為結尾的最小乘積值,狀態轉移方程如下:

mn[i] = min(nums[i], min(mn[i - 1] * nums[i], mx[i - 1] * nums[i]));
mx[i] = max(nums[i], max(mn[i - 1] * nums[i], mx[i - 1] * nums[i]));
// DP,以nums[i]為結尾的序列最大乘積由nums[i],MIN[i-1]*nums[i],MAX[i-1]*nums[i]
// 決定且取最大值,而最小乘積也有三者決定且取最小值
// T: O(n), space: O(n)
class Solution {
public:
    int maxProduct(vector<int>& nums) {
        int n = nums.size();
        int res = nums[0];
        vector<int> mn(n), mx(n);
        mn[0] = nums[0];
        mx[0] = nums[0];
        for (int i = 1; i < n; ++i) {
            mn[i] = min(nums[i], min(mn[i - 1] * nums[i], mx[i - 1] * nums[i]));
            mx[i] = max(nums[i], max(mn[i - 1] * nums[i], mx[i - 1] * nums[i]));
            res = max(res, mx[i]);
        }
        return res;
    }
};

44. 萬用字元匹配

給定一個字串 (s) 和一個字元模式 § ,實現一個支援 ‘?’ 和 ‘*’ 的萬用字元匹配。

'?' 可以匹配任何單個字元。
'*' 可以匹配任意字串(包括空字串)。

兩個字串完全匹配才算匹配成功。

說明:

  • s 可能為空,且只包含從 a-z 的小寫字母。
  • p 可能為空,且只包含從 a-z 的小寫字母,以及字元 ? 和 *。

原題連結

解題思路: 此題比較適合用動態規劃解題,因為兩個字串匹配可以轉化為兩個字串字首子串匹配的問題(將問題化成子問題,典型的動態規劃特徵),由於要考慮空串,因此定義dp[i][j]為s前i個字元和p前j個字元是否匹配,而與狀態(i,j)關聯的有三個狀態,即(i-1,j),(i-1,j-1),(i,j-1),因此,動態轉移方程:

dp[i][j] = dp[i][j-1] || dp[i][j-1] || dp[i][j-1]

注:動態規劃解題的關鍵是,找出與當前狀態關聯的其他狀態(完備且互斥),然後思考與他們的關聯(即狀態轉變)。

// DP, dp[i][j]表示s前i個字元與p前j個字元匹配
// dp[i][j]下面兩項決定:
// 1. p[j-1]='*'時,dp[i-1][j] || dp[i][j-1]
// 2. s[i-1]==p[j-1] || p[j]=='?' || p[j] == '*'時,dp[i-1][j-1]
// time&space: O(n*m)
class Solution {
public:
    bool isMatch(string s, string p) {
        int n = s.size(), m = p.size();
        vector<vector<bool>> dp(n + 1, vector<bool>(m + 1, false));
        dp[0][0] = true;
        for (int i = 0; i < m; ++i) {
            if (p[i] == '*') dp[0][i + 1] = true;
            else break; 
        }
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= m; ++j) {
                if (p[j - 1] == '*') dp[i][j] = dp[i][j] || dp[i - 1][j] || dp[i][j - 1];
                if (s[i - 1] == p[j - 1] || p[j - 1] == '?' || p[j - 1] == '*') {
                    dp[i][j] = dp[i][j] || dp[i - 1][j - 1];
                }
            }
        }
        return dp[n][m];
    }
};

91. 解碼方法

原題連結

解題思路: 分析題特徵,若想知道整個字串的解碼個數,是不是可以考慮若知道某個字首解碼個數,然後每次解碼選取的數只能在[1,26]範圍內,因此要麼選一個字元,要麼選兩個字元組合,選一個字元時要求不能為’0’,選兩個字元組合時,要求形成的數在[10,26]範圍內,這種若求一個狀態可求另一個(子)狀態的題最適合用Dynamic Programming解題,狀態轉移方程好寫,dp[i]=dp[i-1]+dp[i-2]dp[i]表示前i個字元組成的字串有多少種解碼,對於長度為0的dp[0]我們選擇初始化它為1,這裡主要是考慮當s[0]和s[1]組合的值恰好在[10,26]範圍內,那這個組合值算1種解法。此題的corner case應該是與0相關的測試點,0在前或者後。

// DP, dp[i]=dp[i-1]+dp[i-2],考慮與'0'相關的corner case
// T&S: O(n)
class Solution {
public:
    int numDecodings(string s) {
        if (s[0] == '0') return 0;
        int n = s.size();
        vector<int> dp(n + 1);
        dp[0] = 1;
        dp[1] = 1;
        for (int i = 2; i <= n; ++i) {
            if (s[i - 1] != '0') dp[i] = dp[i - 1];
            int num = 10 * (s[i - 2] - '0') + s[i - 1] - '0';
            if (num >= 10 && num <= 26) dp[i] += dp[i - 2];
        }
        return dp[n];
    }
};

639. 解碼方法 2

原題連結

解題思路: 與題91不同的是,本題引入了'*',可以當做1-9,但是解題的本質與題91是相同的,增添的工作量僅僅是需要更多種情況的分類討論,在DP基礎上寫好此題的關鍵是,假設當前位置是i,分類討論s[i-1]s[i]是否為'*'的2*2種情況。OK,看下面程式碼。

class Solution {
public:
    int numDecodings(string s) {
        if (s[0] == '0') return 0;
        if (s.size() == 1) {
            if (s[0] == '*') return 9;
            else return 0;
        }
        int n = s.size();
        long M = 1e9+7;
        vector<long> dp(n + 1, 0);
        dp[0] = 1;
        dp[1] = s[0] == '*' ? 9 : 1;
        for (int i = 2; i <= n; ++i) {
            if (s[i - 1] == '*') {
                dp[i] = (dp[i] + dp[i - 1] * 9) % M;
                if (s[i - 2] != '*') {
                    for (int j = 1; j <= 9; ++j) {
                        int num = (s[i - 2] - '0') * 10 + j;
                        if (num >= 10 && num <= 26) dp[i] = (dp[i] + dp[i - 2]) % M;
                    }
                } else {
                    for (int k = 1; k <= 9; ++k) {
                        for (int j = 1; j <= 9; ++j) {
                            int num = k * 10 + j;
                            if (num >= 10 && num <= 26) dp[i] = (dp[i] + dp[i - 2]) % M;
                        }
                    }
                }
            } else {
                if (s[i - 2] == '*') {
                    if (s[i - 1] != '0') dp[i] = (dp[i] + dp[i - 1]) % M;
                    for (int j = 1; j <= 9; ++j) {
                        int num = j * 10 + s[i - 1] - '0';
                        if (num >= 10 && num <= 26) dp[i] = (dp[i] + dp[i - 2]) % M;
                    }
                } else {
                    if (s[i - 1] != '0') dp[i] = (dp[i] + dp[i - 1]) % M;
                    int num = (s[i - 2] - '0') * 10 + s[i - 1] - '0';
                    if (num >= 10 && num <= 26) dp[i] = (dp[i] + dp[i - 2]) % M;
                }
            }
            
         }
         return dp[n];
    }
};

97. 交錯字串

原題連結

解題思路: 此題起初的想法是,若s1和s2中不存在相同的字串題目就好解了,直接雙指標移動即可,而使此題成為hard的題的關鍵是,若將s3與s1和s2對應位比對時,此時s1和s2對應位字元相同,那該由哪一個匹配s3對應位就不好抉擇了,因為這會影響到後續的比對,想到著,我想出的第一個演算法是,對於當前位選與不選的問題,至少有兩個方法可以提供使用,即回溯演算法和動態規劃的01揹包,於是先選擇用回溯法解,但是回溯演算法若不剪枝的複雜度太高,OJ果然TLE了,然後想到用動態規劃解,分析一下,一個狀態有三個變數決定,即s1s2s3三個對應位置i,j,k決定,而k=i+j,因此可以進一步簡化一下,由i和j決定,因此定義狀態dp[i][j]為s1前i個字元和s2前j個字元能交錯成長度為(i+j)的s3,而s3的(i+j-1)位置上的字元要麼來自s1要麼來自s2,OK狀態轉移方程有了:

dp[i][j] = (dp[i - 1][j] && s1[i - 1] == s3[i + j - 1]) ||  (dp[i][j - 1] && s2[j - 1] == s3[i + j - 1]);

完美解決動態規劃問題由三步組成,即定義狀態+狀態轉移+狀態初始化和更新。因此接下來就要做狀態初始化和更新的分析,這一步,我們要保證在推進到狀態(i,j)之前,狀態(i-1,j)和(i-1,j)都被初始化和更新,在這裡,因為i和j都是沿著遞增方向推進的,因此不會出現前置狀態未更新。詳細的分析推薦看這篇部落格,博主總結的一條規律,感覺不錯,引用如下。

只要是遇到字串的子序列或是匹配問題直接就上動態規劃 Dynamic Programming,其他的都不要考慮,什麼遞迴呀的都是浮雲(當然帶記憶陣列的遞迴寫法除外,因為這也可以算是 DP 的一種),千辛萬苦的寫了遞迴結果拿到 OJ 上妥妥 Time Limit Exceeded

class Solution {
public:
    bool isInterleave(string s1, string s2, string s3) {
        int n = s1.size(), m = s2.size(), r = s3.size();
        if (n + m != r) return false;
        vector<vector<bool>> dp(n + 1, vector<bool>(m + 1, false));
        dp[0][0] = true;
        for (int i = 1; i <= n; ++i) {
            dp[i][0] = dp[i - 1][0] && s1[i - 1] == s3[i - 1];
        }
        for (int j = 1; j <= m; ++j) {
            dp[0][j] = dp[0][j - 1] && s2[j - 1] == s3[j - 1];
        }
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= m; ++j) {
                dp[i][j] = (dp[i - 1][j] && s1[i - 1] == s3[i + j - 1]) ||
                            (dp[i][j - 1] && s2[j - 1] == s3[i + j - 1]);
            }
        }
        return dp[n][m];
    }
};

32. 最長有效括號

原題連結

解題思路: 此題解起來還是有一定難度的,我沒解出來,參考了這篇部落格思路,但是僅根據部落格思路,雖然程式碼能AC,但是還是不能證明為什麼這個解法是對的(可能是我太笨啦╮(╯▽╰)╭),這裡記錄一下我對這題的分析以證明前面解法的合理性。考察配對的問題,比如括號、運算子等等,最常規的是用來解題,這個在《資料結構》書上一般都會舉出這個例子,而真正覺得此題為hard的是字串中間若插入了不能配對的(或者)這個字串就不能取,但是用棧解題的基本框架我們可以先搭起來,然後在遇到)時,處理字串長度,我們用start記錄可能合理的字串起始位置,我們可以分析下面一組序列,因為1-3字串出現3因此不能與後序字串作為一個合理字串計算長度,因此start從下一個位置開始計算合理字串,OK,我們從4開始往後分析,若在遍歷到位置10時,若我們發現棧不為空,即6,我們展示還無法確定11是否能與6匹配(i.e.,字串會不會因為6無法匹配導致不能從start開始合理),因此當前位置我們只能上一個元素,即6,作為計算當前合理字串的起點(注意這裡不能以9作為起點因為會漏掉7和8,這個在LT水池題中出現過),當然,若我們遍歷到11位置,發現出棧後棧為空,那說明從start位置開始,所有元素均完成配對,所以可以肯定從start至當前位置字串是合理的,於是可以用start位置計算,那麼,我們為什麼在出棧後要判斷棧是否空呢?這是因為我們在是否用start做計算,i.e.,是否認定從start開始到當前位置字串合理拿捏不準,因此,以出棧後棧是否為空來做最長合理串的盡力計算(貪婪思想)。
在這裡插入圖片描述

class Solution {
public:
    int longestValidParentheses(string s) {
        stack<int> st;
        int start = 0, res = 0;
        for (int i = 0; i < s.size(); ++i) {
            if (s[i] == '(') st.push(i);
            else {
                if (st.empty()) start = i + 1;
                else {
                    st.pop();
                    if (st.empty()) res = max(res, i - start + 1);
                    else res = max(res, i - st.top());
                }
            }
        }
        return res;
    }
};

標籤裡提到動態規劃解題,此題屬於字串子串問題,也應該要想到是否能用動態規劃題。此題我動態規劃除錯了半個多小時,AC後感慨了一會,果然是編寫思維不嚴謹,除錯耗時數萬倍,所以我們在寫程式碼的時候,對所有程式碼的位置編排、條件的並排或巢狀,都要能明確的思路說服自己為什麼這麼寫,只有這樣寫出來的程式碼才能bug-free。OK,看此題動態規劃解題思路,所有的括號關係我們可以歸納為要麼巢狀(i.e.,包含)要麼並排,在判斷括號字串是否有效之前,首先要考察自身是否能合理,然後在儘量利用括號關係往內部(i.e.,巢狀)或者向左邊(i.e.,並排)貪婪計算儘可能長的長度。設dp[i]表示包含第i-1個字元的字串前i個字元能組成的最長合理括號串的長度,只有結尾是)的字串dp值才非零,那麼dp[i]前的dp[i-1]若非零,則表示dp[i]前面有長度為dp[i-1]是合理字串,OK,那我們往前推進dp[i-1]+1長度即可找到s[i - dp[i-1] - 2],看它是否為(,i.e.,判斷dp[i]是否配對,若不配對,實際上就不用再考慮巢狀和並排的關係了,直接為0,在配對的情況下,考慮巢狀關係,則可在dp[i-1]長度基礎上再增加兩個配對元素,i.e., dp[i-1]+2,再考慮並排關係,再向前推一個元素,若s[i - dp[i-1] - 3])或者說dp[i - dp[i-1] - 2]非零,則可將前面並排的合理字串也併入其中。

class Solution {
public:
    int longestValidParentheses(string s) {
        int n = s.size(), res = 0;
        vector<int> dp(n + 1, 0);
        for (int i = 2; i <= n; ++i) {
            if (s[i - 1] == ')') {
                int j = i - dp[i - 1] - 2;
                if (j >= 0 && s[j] == '(') {
                    dp[i] = dp[i - 1] + 2;
                    dp[i] += dp[j];    
                }       
            }
            res = max(res, dp[i]);
        }
        return res;
    }
};

87. 擾亂字串

原題連結

解題思路: 看到字串子串問題,我們優先想到的是動態規劃,但在用動態規劃解題之前先介紹遞迴解法。我們先分析一下題意,題目定義將字串寫成二叉樹,將二叉樹的節點的左右子節點交換,得到的新字串為擾亂字串,題眼即左右子節點交換,而反應到字串上,相鄰兩端子串交換,在轉化一下,給定字串s,將s從中間某點斷開,形成字串s1s2,交換兩半段,得新字串s2s1,為擾亂字串。OK,剖析至此,基本明朗了,假設字串s1和s2,假設斷開段的長度一個是L和LEN-L,當對s1左->右斷開L處,那麼s2既可以左->右斷開L處,也可以右->左斷開L處,然後交叉判斷對應的子串是否能是scrambe,若是,則s1和s2是scramble,當然判斷之間可以先判斷s1和s2中對應的字元及其個數是否相同,最簡單的方法是排序然後比較字串是否相等。
在這裡插入圖片描述

class Solution {
public:
    bool isScramble(string s1, string s2) {
        if (s1 == s2) return true;
        string ts1 = s1, ts2 = s2;
        sort(ts1.begin(), ts1.end());
        sort(ts2.begin(), ts2.end());
        if (ts1 != ts2) return false;
        int n = s1.size();
        for (int i = 1; i <= n - 1; ++i) {
            string s11 = s1.substr(0, i);
            string s12 = s1.substr(i);
            string s21 = s2.substr(0, i);
            string s22 = s2.substr(i);
            if ((isScramble(s11, s21) && isScramble(s12, s22)))
                return true;
            s22 = s2.substr(0, n - i);
            s21 = s2.substr(n - i);
            if ((isScramble(s11, s21) && isScramble(s12, s22)))
                return true;
        }
        return false;
    }
};

動態規劃解題,直覺告訴至少要兩個變數定義一個狀態,即s1位置和s2位置,若簡單的對整個字串切的話,大長度的子串在更新狀態時,無法保證小長度的子串已經更新了,這個在很多DP題中都碰到過,而常規的做法是,以長度作為遍歷,而不是以位置作為遍歷,這樣在更新大長度的狀態時,小長度的狀態肯定就已經更新過了。OK,為了方便更新,需要引入第三個變數即,子串長度,因此狀態定義為dp[i][j][l],其中i表示s1中子串起始的位置,j表示s2中子串起始的位置,l表示子串的長度,狀態轉移方程怎麼找呢,想一下題意,自然需要對長度為l做切開,切的方式在遞迴解法中已經講過,因此狀態轉移方程為:

dp[i][j][l] = dp[i][j][l] || (dp[i][j][k] && dp[i + k][j + k][l - k]) || (dp[i][j + l - k][k] && dp[i + k][j][l - k])
class Solution {
public:
    bool isScramble(string s1, string s2) {
        int n = s1.size();
        vector<vector<vector<bool>>> dp(n, vector<vector<bool>>(n, vector<bool>(n + 1, false)));
        for (int l = 1; l <= n; ++l) {
            for (int i = 0; i + l <= n; ++i) {
                for (int j = 0; j + l <= n; ++j) {
                    if (l == 1) {
                        dp[i][j][1] = (s1[i] == s2[j]);
                    } else {
                        for (int k = 1; k < l; ++k) {
                            if ((dp[i][j][k] && dp[i + k][j + k][l - k]) || (dp[i][j + l - k][k] && dp[i + k][j][l - k])) {
                                dp[i][j][l] = true;
                                break;
                            }
                        }
                    }
                }
            }
        }
        return dp[0][0][n];
    }
};

85. 最大矩形

原題連結

解題思路: 本題如果用暴力解法解的話,大致思路是,先確定矩陣的左上叫,然後確定矩陣的長和寬,再check這個矩陣是否全’1’,不難發現此演算法複雜度為 O ( n 4 ) O(n^4) O(n4),估計OJ會TLE。其實若是第一次做此題,還挺難解的,這題實際上是在題84基礎上解題,將此題矩陣逐行應用題84解法,具體的,對矩陣每一行,向上看,把它看成直方圖,求取這個直方圖後,利用題84解法解出最大矩陣,題84解法非常靈活,本題用的是單調棧解法。

class Solution {
public:
    int maximalRectangle(vector<vector<char>>& matrix) {
        if (matrix.empty() || matrix[0].empty()) return 0;
        int n = matrix.size(), m = matrix[0].size();
        vector<int> heights(m, 0);
        int res = 0;
        for (int i = 0; i < n; ++i) {
            for (int j = 0; j < m; ++j) {
                if (matrix[i][j] == '1') ++heights[j];
                else heights[j] = 0;
            }
            res = max(res, helper(heights));
        }
        return res;
    }
    int helper(vector<int> &heights) {
        stack<int> st;
        int res = 0;
        heights.push_back(0);
        for (int i = 0; i < heights.size(); ++i) {
            if (st.empty() || heights[st.top()] <= heights[i]) st.push(i);
            else {
                int idx = st.top(); st.pop();
                int square = (st.empty() ? i * heights[idx] : (i - st.top() - 1) * heights[idx]);
                res = max(res, square);
                --i;
            }
        }
        return res;
    }
};

72. 編輯距離

原題連結

解題思路: 轉述一下此題題意,若想知道word1和word2轉換情況,可以先知道它們字首(子串)的轉換情況,OK,這一轉換,題目有歸為字串子串問題,而子串問題我們會像膝跳反射一樣想到動態規劃。OK,因為涉及到兩個子串,確切的說是描述兩個子串的位置,那麼我們需要兩個變數來維護狀態,定義dp[i][j]為word1前i個字元子串轉換到word2前j個字元子串需要的最少操作次數,若i-1和j-1位置字元不相等,則需要藉助題目提到三個操作以觸發狀態間的轉換,若當前狀態為(i,j),那麼前一個狀態為:1)刪除,(i-1,j),2)替換,(i-1,j-1),3)插入,(i,j-1)。狀態轉移方程為:

if w1[i-1] == w2[j-1]  then  dp[i][j] = dp[i - 1][j - 1]
else dp[i][j] = min(dp[i - 1][j] + 1, min(dp[i - 1][j - 1] + 1, dp[i][j - 1] + 1))
class Solution {
public:
    int minDistance(string word1, string word2) {
        int n = word1.size(), m = word2.size();
        vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
        for (int i = 0; i <= n; ++i) {
            dp[i][0] = i;
        }
        for (int j = 0; j <= m; ++j) {
            dp[0][j] = j;
        }
        for (int i = 1; i <= n; ++i) {
            for (int j = 1; j <= m; ++j) {
                if (word1[i - 1] == word2[j - 1]) dp[i][j] = dp[i - 1][j - 1];
                else {
                    dp[i][j] = min(dp[i - 1][j] + 1, min(dp[i - 1][j - 1] + 1, dp[i][j - 1] + 1));
                }
            }
        }
        return dp[n][m];
    }
};

132. 分割回文串 II

原題連結

解題思路: 這題是題131的進階題,題131要求求出所有的情況,而此題要求求出情況的個數,這實際上是LT題中最常規的搭配了,求出所有的情況對應的是回溯+剪枝解法,而求出情況的個數如果按照前面情形解,如果找不到好的剪枝方法,勢必會TLE,最終還是得迴歸到動態規劃解法,因此總結一個結論,求所有情況用回溯剪枝,而求情況的個數用動態規劃。本題應該是我解動態規劃題畫的最長時間的一題了,著重記一筆,後面得詳細review此題。起初用的是由題131改進的回溯法,但是剪枝如何優化都沒能逃過OJ的TLE,遂放棄,然後選擇動態規劃,進而要選擇如何定義狀態以及找轉移方程,定義dp[i][0,i]最小分割次數,那麼我們對[0,i]內每個元素切一次,然後取所有切法的最小分割數做為dp[i],狀態轉移方程如下:

dp[i]=MIN(dp[j]+1) && Palindrome(s[j,i]), 0<j<i

求迴文我們常規的做法是雙指標,但是本題要求多次迴文,因此雙指標會在原演算法基礎上多加一層O(n)時間複雜度,導致整個演算法時間複雜度太高,有點極端case無法AC,因此,對迴文判斷要做優化,而多次判斷迴文採取的優化策略用到了題647用動態規劃。本題本來解起來非常簡單,但是由於對時間非常苛刻,對細節的把控就比較難想到了,比如迴文用動態規劃來優化,對於我一貫用雙指標來判斷,實在想不到了,或許多做點題,可能融會貫通,激發出一點思維火花,hhh,因此本質上此題考查了兩個動態規劃,不愧為hard╮(╯▽╰)╭.

class Solution {
public:
    int minCut(string s) {
        int n = s.size();
        vector<int> dp(n);
        vector<vector<bool>> p(n, vector<bool>(n));
        for (int i = 0; i < n; ++i) {
            dp[i] = i;
            p[i][i] = true;
            if (i >= 1 && s[i] == s[i - 1]) p[i - 1][i] = true;
        }
        for (int len = 3; len <= n; ++len) {
            for (int i = 0; i + len <= n; ++i) {
                int j = i + len - 1;
                if (s[i] == s[j] && p[i + 1][j - 1]) p[i][j] = true;
            }
        }
        for (int i = 1; i < n; ++i) {
            if (p[0][i]) dp[i] = 0;
            else {
                for (int k = 1; k <= i; ++k) {
                    if (p[k][i]) {
                        dp[i] = min(dp[i], dp[k - 1] + 1);
                    }
                }
            }
        }
        return dp[n - 1];
    }
};

相關文章