每天一道LeetCode-----使用最少的操作將一個字串轉換成另一個字串,只有插入,刪除,替換三種操作
Edit Distance
題目要求,輸入兩個字串word1和word2,計算可以將word1轉換成word2的最小的操作次數,可以執行的操作如下,每個操作算作1次
- 將word1的某個字元刪除
- 在word1當前位置插入一個字元
- 將word1當前位置的字元替換成另一個字元
上面的三個操作每操作一次總操作次數都需要加一,計算最小的操作次數。
這是典型的動態規劃問題。
假設word1的長度為m, word2的長度為n,則可以將word1和word2分別表示成
- word1[0, 1, …, m - 1]
- word2[0, 1, …, n - 1]
那麼word1[0, 1, ..., i - 1]
word2[0, 1, ..., j - 1]
就表示word2的前j個字元
定義dp[i][j]
表示將word1[0, 1, ..., i - 1]
轉換到word2[0, 1, ..., j - 1]
所需要的最小操作次數。
首先明確一點,動態規劃是先求子問題的解,再求實際問題的解,對於本題而言。子問題是對於任意的i和j,計算將word1[0, 1, …, i - 1]轉換成word2[0, 1, …, j - 1]的最小次數,也就是計算所有dp[i][j]的值
當所有子問題的解都計算完畢後,就可以求解最終的實際問題,這裡隱含了一個資訊,那就是當要求解最終問題時,所有子問題的解是已知的。
假設現在要將word1[0, 1, ..., i - 1]
轉換成word2[0, 1, ..., j - 1]
,同時假設已經直到了將word1[0, 1, ..., i - 2]
轉換成word2[0, 1, ..., j - 2]
的最小操作此時,即dp[i - 1][j - 1]
。
根據上面的敘述,此時的實際問題是計算dp[i][j],那麼所有子問題的解是已知的,即dp[i - 1][j - 1],dp[i][j - 1]以及dp[i - 1][j]的值是已經知道的(這裡只需要使用這三個子問題的解)
那麼
- 如果
word1[i - 1] == word2[j - 1]
,那麼對於將字元word1[i - 1]
word2[j - 1]
是不需要任何操作的
- 將
word1[0, 1, ..., i - 2]
轉換到word2[0, 1, ..., j - 2]
,即dp[i][j] = dp[i - 1][j - 1]
- 將
- 如果
word1[i - 1] != word2[j - 1]
,此時有三種方式可以將word1[0, 1, ..., i - 1]
轉換到word2[0, 1, ..., j - 1]
- 將word1[i - 1]替換成word2[j - 1],此時dp[i][j] = 1 + dp[i - 1][j - 1]
- 將word1[i - 1]刪掉,此時dp[i][j] = 1 + dp[i - 1][j]
- 在word1的i - 1位置插入字元word2[ j - 1],此時dp[i][j] = 1 + dp[i][j - 1]
對於替換操作,因為已經直到將word1[0, 1, ..., i - 2]
轉換到word2[0, 1, ..., j - 2]
的最小操作次數即dp[i - 1][j - 1]
,那麼就可以將word1[i - 1]
替換成word2[j - 1]
。也就是說,可以先將word1[0 , 1, ..., i - 2]
轉換到word2[0, 1, ..., j - 2]
,然後將word1[i - 1]
替換成word2[j - 1]
,以達到將word1[0, 1, ..., i - 1]
轉換到word2[0, 1, ..., j - 1]
的目的
對於刪除操作,因為已經知道將word1[0, 1, ..., i - 2]
轉換到word2[0, 1, ..., j - 1]
的最小運算元即dp[i - 1][j]
,那麼就可以將word1[i - 1]
刪掉,也就是說,可以先將word1[0, 1, ..., i - 2]
轉換到word2[0, 1, ..., j - 1]
,然後將word1[i - 1]
刪掉,以達到將word1[0, 1, ..., i - 1]
轉換到word2[0, 1, ..., j - 1]
的目的
對於插入操作,因為已經直到將word1[0, 1, ..., i - 1]
轉換到word2[0, 1, ..., j - 2]
的最小運算元即dp[i][j - 1]
,那麼就可以將word1[0, 1, ..., i - 1]
的後面新增字元word2[j - 1]
,即word1[0, 1, ..., i - 1] + word2[j - 1] == word2[0, 1, ..., j - 1]
。也就是說,可以先將word1[0, 1, ..., i - 1]
轉換到word2[0, 1, ..., j - 2]
,然後在word1[0, 1, ..., i - 1]
的後面新增字元word2[j - 1]
,以達到將word1[0, 1, ..., i - 1]
轉換到word2[0, 1, ..., j - 1]
的目的
對於動態規劃而言,有遞迴和迭代兩種方法,遞迴的程式易於理解,但是遞迴的過程造成的棧開銷會影響效能,而迭代恰好相反,不易理解,但是效能高。
遞迴法是從最終問題遞迴到一個最小的子問題上,可以理解成從上向下進行,如果使用遞迴法,那麼對於動態規劃陣列的定義是需要有所改變的。原因是實際問題要求計算將word1
轉換到word2
的最小操作,那麼隨著遞迴深度的增加最後到達word1[m - 1]
和word2[n - 1]
,子問題就變成將word1
的某個字元轉換成word2
的某個字元的最小操作次數。
所以,這裡將dp[i][j]
的定義改為
- dp[i][j]表示將word1[i, i+1, …, m)轉換到word2[j, j+1, …, n)的最小操作次數。
在實際的操作過程中,仍然使用上述的三種轉換方式
程式碼如下
class Solution {
public:
int minDistance(string word1, string word2) {
int n1 = word1.size();
int n2 = word2.size();
vector<vector<int>> dp(n1, vector<int>(n2, INT_MAX));
return minDistance(word1, word2, 0, 0, dp);
}
private:
int minDistance(string& word1, string& word2, int i, int j, vector<vector<int>>& dp)
{
/* 如果二者都達到末尾,說明轉換完畢,返回0即可 */
if(i >= word1.size() && j >= word2.size())
return 0;
/* 如果其中一個到達末尾,那麼只能通過刪除/插入方式使二者相等 */
else if(i >= word1.size() && j < word2.size())
return word2.size() - j;
else if(i < word1.size() && j >= word2.size())
return word1.size() - i;
if(dp[i][j] != INT_MAX)
return dp[i][j];
if(word1[i] == word2[j])
dp[i][j] = std::min(dp[i][j], minDistance(word1, word2, i + 1, j + 1, dp));
/* 將word1[i]替換成word2[j] */
dp[i][j] = std::min(dp[i][j], 1 + minDistance(word1, word2, i + 1, j + 1, dp));
/* 在word1當前位置插入word2[j] */
dp[i][j] = std::min(dp[i][j], 1 + minDistance(word1, word2, i, j + 1, dp));
/* 刪除word1[i] */
dp[i][j] = std::min(dp[i][j], 1 + minDistance(word1, word2, i + 1, j, dp));
return dp[i][j];
}
};
這種方法效率奇低,原因是每次遞迴都需要向三個不同的方向遞迴,即使使用了動態規劃陣列減少遞迴次數,但是開銷仍然很大。不過這種方法倒是最容易想到的
迭代法就是模擬遞迴的返回過程,即從最深層的位置向上返回,這就避免後向下遞迴的過程,也沒有所謂的遞迴棧開銷
迭代法的dp定義就和上面相同了,即
- dp[i][j]表示將word1[0, 1, …, i - 1]轉換到word2[0, 1, …, j - 1]的最小操作次數
程式碼如下
class Solution {
public:
int minDistance(string word1, string word2) {
int n1 = word1.size();
int n2 = word2.size();
vector<vector<int>> dp(n1 + 1, vector<int>(n2 + 1, 0));
/* 如果word2為空,那麼轉換方式就是將word1每個字元刪掉,次數就是word1的個數 */
for(int i = 0; i <= n1; ++i)
dp[i][0] = i;
/* 如果word1為空,那麼轉換方式就是將依次插入word2對應字元,次數就是word2的個數 */
for(int j = 0; j <= n2; ++j)
dp[0][j] = j;
for(int i = 1; i <= n1; ++i)
{
for(int j = 1; j <= n2; ++j)
{
/* 如果對應字元相等,那麼只需要將word1[0, 1, ... i - 2]轉換成word2[0, 1, ..., j - 2] */
if(word1[i - 1] == word2[j - 1])
dp[i][j] = dp[i - 1][j - 1];
/* 否則,取三種操作的最小值 */
else
dp[i][j] = 1 + std::min(dp[i - 1][j - 1], std::min(dp[i][j - 1], dp[i - 1][j]));
}
}
return dp[n1][n2];
}
};
迭代法的效率比較高(至少從程式碼長度上看也是),不過不太容易理解。
將dp設定為(n1 + 1) * (n2 + 1)
的原因是根據dp[i][j]
的定義,原因dp[i][j]
中的i和j分別表示word1
和word2
的長度,當其中一個是0時,對相當於對應字串是空字元
- dp[i][0]表示將word1[0, 1, …, i - 1]轉換成空字元,此時操作次數就應該是word1的長度
- dp[0][j]表示將空字元轉換成word2[0, 1, …, j - 1],此時操作次數就應該是word2的長度
- dp[0][0]表示將空字元轉換成空字元,此時操作次數就應該是0
當然,凡是動態規劃的迭代法都有方法將dp陣列降一個維度,這裡dp陣列是一個二維陣列,那麼就有辦法用一個一維陣列解決問題
對比上面的迭代法,每次迴圈需要的數值有
- dp[i - 1][j - 1],表示將word1[0, 1, …, i - 2]轉換成word2[0, 1, …, j - 2]的最小操作次數
- dp[i][j - 1],表示將word1[0, 1, …, i - 1]轉換成word2[0, 1, …, j - 2]的最小操作次數
- dp[i - 1][j],表示將word1[0, 1, …, i - 2]轉換成word2[0, 1, …, j - 1]的最小操作次數
那麼,可不可以只用一維陣列就表示上面三個數值呢。
因為是將word1
轉換成word2
,那麼假設dp陣列的定義為vector<int> dp(word1.size() + 1, 0);
定義dp[i]
表示將word1[0, 1, ..., i - 1]
轉換到word2
的最小操作次數,word2
的長度是任意的,換句話說,對於任意的j,dp[i]
都表示將word1[0, 1, ..., i - 1]
轉換到word2[0, 1, ..., j - 1]
的最小操作次數。
那麼,肯定需要從最小的j開始計算,那麼可以先將j放在最外層的for迴圈中,大體的框架如下
class Solution {
public:
int minDistance(string word1, string word2) {
int n1 = word1.size();
int n2 = word2.size();
vector<int> dp(n1 + 1, 0);
for(int i = 0; i <= n1; ++i)
dp[i] = i;
for(int j = 1; j <= n2; ++j) //外層
{
for(int i = 1; i <= n1; ++i) //內層
{
}
}
return dp[n1];
}
};
想一下,應該如何表示dp[i - 1][j - 1]
,dp[i][j - 1]
以及dp[i - 1][j]
假設當j = 1
時執行一次內層迴圈,此時dp1, dp2, …., dp[n1]
當j= 2
時再次執行內層迴圈時,正要計算還沒有計算dp[i]
時,dp[i]
的值是當j = 1
時的值
也就是說此時的dp[i]
等價於dp[i][j - 1]
假設當j = 1
時執行一次內層迴圈,在內層迴圈中計算了dp[1], dp[2], ..., dp[i - 1]
正要計算dp[i]
時,dp[i - 1]
的值等價於dp[i - 1][j]
假設當j = 1
時執行了一次內層迴圈,此時dp1, dp2, …, dp[n1]
當j = 2
時再次執行內層迴圈,在內層迴圈中計算了dp[1], dp[2], ..., dp[i]
,計算dp[i]
時記錄與dp[i][j - 1]
等價的dp[i]
,也就是還沒有更新dp[i]
時的值,記錄在遍歷prev
中。
此時prev
表示將word1[0, 1, ..., i - 1]
轉換成word2[0, 1, ..., j - 2]
的值,即dp[i][j - 1]
當計算dp[i + 1]
時,prev中的i就變成了i-1,所以表示成dp[i - 1][j - 1]
,說明prev
正是與dp[i - 1][j - 1]
等價的值
(注,如果dp[i + 1 - 1][j - 1]
不容易理解,可以將i + 1
看成n,此時要計算將word1[0, 1, ..., n - 1]
轉換成word2[0, 1, ..., j - 1]
的最小操作次數,即dp[n - 1][j - 1]
)
以上只是推導了一下二維陣列中的dp[i - 1][j - 1]
,dp[i][j - 1]
和dp[i - 1][j]
等價的一維陣列對應的值
程式碼如下
class Solution {
public:
int minDistance(string word1, string word2) {
int n1 = word1.size();
int n2 = word2.size();
vector<int> dp(n1 + 1, 0);
for(int i = 0; i <= n1; ++i)
dp[i] = i;
for(int j = 1; j <= n2; ++j)
{
/* dp[0]等價與dp[0][j - 1] */
int prev = dp[0];
dp[0] = j;
for(int i = 1; i <= n1; ++i)
{
/* 此時的dp[i]等價於dp[i][j - 1],將其賦值給prev
* 在下次迴圈時,prev就代表dp[i - 1][j - 1]
* 因為i增加了,此時的i就變為i-1了 */
int temp = dp[i];
if(word1[i - 1] == word2[j - 1])
dp[i] = prev;
else
/* 此時的dp[i]等價於dp[i][j - 1],而dp[i - 1]等價於dp[i - 1][j] */
/* prev等價於dp[i - 1][j - 1],因為prev中的i是此時的i - 1 */
dp[i] = 1 + std::min(prev, std::min(dp[i], dp[i - 1]));
prev = temp;
}
}
return dp[n1];
}
};
上述分別用遞迴,迭代法實現動態規劃,可以發現,遞迴的動態規劃效能不如迭代法。
另外,迭代法可以將動態規劃陣列降維,從而進一步減少了空間複雜度