[LeetCode] 943. Find the Shortest Superstring 找到最短的超級字符串



Given an array A of strings, find any smallest string that contains each string in A as a substring.

We may assume that no string in A is substring of another string in A.

Example 1:

Input: ["alex","loves","leetcode"]
Output: "alexlovesleetcode"
Explanation: All permutations of "alex","loves","leetcode" would also be accepted.

Example 2:

Input: ["catg","ctaagt","gcta","ttca","atgcatc"]
Output: "gctaagttcatgcatc"

Note:

  1. 1 <= A.length <= 12
  2. 1 <= A[i].length <= 20

這道題給了一個字符串數組A,讓我們找一個最短的字符串,使得所有A中的字符串是都該字符串的子串,並給了兩個例子。通過第一個例子可以發現,假如任意兩個字符串首尾沒有重復字母的話,其就是所有字符串直接連接起來即可。但是如果有重復字母的話,比如 abc 和 bca,二者連接起來就是 abca,而不是 abcbca 了,因為 bc 兩個字母是可以復用的,這是本題的難點。如果啥也不考慮,直接上最暴力的破解方法,當然就是嘗試數組A中n個字符串的各種全排列方式,然后在兩兩之間去掉復用的字符,選長度最短的那一種,但這種無腦暴力的方法的時間復雜度是n的階乘,就是傳說中的 NP Hard,對計算機十分的不友好,計算機最多算到 n=12 左右,而這道題剛好n的范圍不大於12,這難道是在引誘我們暴力平推么,這么想的話就是不尊重 OJ 了,No Way!本題最大的難點還是要求出最優解時各個字符串的排列順序,知道了這個順序,去除復用字符神馬的都是浮雲。這里如果將A中的n個字符串各自都看作一個結點,實際上要求的就是按照某個順序依次經過所有的結點,這其實就是著名的旅行推銷員問題 Travelling Salesman Problem了,注意跟哈密頓回路 Hamiltonian Cycle區分開來,哈密頓回路是問是否有能經過所有結點並回到起始結點的路徑,而 TSP 是一定存在哈密頓回路的,並實際上存在多個回路,這里需要求出權重最大的回路,因為兩個字符串之間的復用字符的個數就可以看作是權重,可以復用的字符越多,表示最終得到的字符串就會越短。對於 TSP 問題有一些高逼格的算法,比如遺傳算法、蟻群算法、模擬退火算法、粒子群算法,當然這里不會講,不是對力扣不尊重,而是沒有必要,這里只講兩種解法,一種是經過剪枝優化的窮舉法,另一種是動態規划 Dynamic Programming。

先來看經過剪枝優化的窮舉法,這里參考了花花醬的帖子。首先來解決復用字符個數計算的問題,為了避免重復計算,可以直接計算任意兩個字符串之間的復用字符的個數,用一個二維數組 overlap 表示,其中 overlap[i][j] 表示字符串 A[i] 和 A[j] 之間的復用字符的個數,比如 abc 和 bca 的復用個數是2,注意 overlap[j][i] 不一定等於 overlap[i][j],比如 bca 和 abc 的復用字符個數就是1個。更新 overlap 數組的方法就是遍歷任意兩個字符串的組合,跳過i和j相等的地方,然后就是從短的字符串的末尾開始查找,一個一個字符的比較,例如 abc 和 bca,比較是順序是 abc 和 bca,bc 和 bc,c 和 b,其實最后的 c 和 b 是不用比的,當比到中間的 bc 時就可以斷開了,因為長度是按從大到小的順序比的,通過這種方法就可以算出任意兩個字符串之間的復用字符個數了。接下來就是要找出最優的排序了,使用遞歸遍歷,使用 cur 來記錄當前已經遍歷到的字符串的個數,used 利用二進制來記錄使用過的字符串,curLen 記錄當前超級字符串的長度,mn 是全局最優解的長度,order 是當前的排列順序,best_order 是全局最優的排列順序,變量還真不少呢。進入遞歸函數,其實就比較常規了,首先是一個剪枝優化,若當前長度 curLen 大於全局最優長度 mn 時,直接返回,不需要再遍歷了,因為后面只會增加長度。然后就是若 cur 等於n了,說明所有的字符串都使用了,當前一定是最優的,因為不是最優的情況已經被剪掉了,沒法遍歷到最后面的,所以此時用 curLen 更新 mn,用 order 更新 best_order,並返回即可。否則的話就從開頭遍歷數組A,若當前字符串已經使用過了,則跳過。這里用的 trick 是利用 used 的二進制數的對應位跟數組A中的位置相對應,為1的話表示使用過了,為0則表示沒用過。若沒用過,則將當前位置加入 order,進行下一個位置的遞歸,下一個位置的當前長度更新比較復雜,這里用一個新變量 nextLen 來表示,若 cur 為0,說明是第一個字符串,不用考慮復用字符,則直接帶入當前遍歷到的字符串的長度,否則的話是要加上當前遍歷到的字符串的長度減去復用字符的個數,而這個復用字符個數需要到 overlap 中取,注意坐標順序,前一個位置是 order[cur-1],這是上一個字符串在A中的位置,后一個位置就是i,這樣取出的才是正確的。還有就是別忘了更新 used 的對應位為1。得到最優排序之后,最后只要生成這個超級串就行了,這算是最簡單的一步了,因為知道了任意兩個相鄰的字符串,也知道其之間的復用字符個數,生成最終的超級串就很容易了,參見代碼如下(不過貌似現在已經超時過不了 OJ 了):


解法一:

// Time Limit Exceeded (TLE)
class Solution {
public:
    string shortestSuperstring(vector<string>& A) {
        int n = A.size(), mn = INT_MAX;
        vector<int> order(n), best_order;
        vector<vector<int>> overlap(n, vector<int>(n));
        for (int i = 0; i < n; ++i) {
        	for (int j = 0; j < n; ++j) {
        		if (i == j) continue;
        		for (int k = min(A[i].size(), A[j].size()); k > 0; --k) {
        			if (A[i].substr(A[i].size() - k) == A[j].substr(0, k)) {
        				overlap[i][j] = k;
        				break;
        			}
        		}
        	}
        }
        helper(A, overlap, 0, 0, 0, mn, order, best_order);
        string res = A[best_order[0]];
        for (int k = 1; k < n; ++k) {
            int i = best_order[k - 1], j = best_order[k];
            res += A[j].substr(overlap[i][j]);
        }
        return res;
    }
    void helper(vector<string>& A, vector<vector<int>>& overlap, int cur, int used, int curLen, int& mn, vector<int>& order, vector<int>& best_order) {
    	if (curLen >= mn) return;
    	if (cur == A.size()) {
    		mn = curLen;
            best_order = order;
    		return;
    	}
    	for (int i = 0; i < A.size(); ++i) {
    		if (used & (1 << i)) continue;
    		order[cur] = i;
            int nextLen = (cur == 0) ? A[i].size() : curLen + A[i].size() - overlap[order[cur - 1]][i];
    		helper(A, overlap, cur + 1, used | (1 << i), nextLen, mn, order, best_order);
    	}
    }
};

下面這種 DP 解法主要參考了 reimu 大神的帖子,這里使用一個二維數組 dp,其中 dp[i][j] 表示 mask 為i,且結尾是 A[j] 的超級串,注意這里不是長度,而是直接保存的超級串本身。這里的 mask 跟上面解法中的 used 很像,都是利用二進制中上的位來表示數組A中的某個位置上的字符是否被使用了。既然A中有n個數字,所以有n位的二進制數就是 2^n,這就是 mask 的大小范圍,因為是二維數組,每一個 mask 都對應一個長度為n的一維字符串數組,每一個對應的位置表示最后面的字符串是數組A中對應位置的字符串。這里還是要用一個跟上面一樣的 overlap 數組,來得到任意兩個字符串之間的復用字符個數,可以參考上面的講解。接下來是要給 dp 數組初始化,因為數組中的每個字符串都有可能是超級串的開頭,所以要初始化各種情況,一旦使用了某個字符串,要在 mask 中標記對應的位置,因為目前只有一個字符串,所以直接用 1<<i 來表示 mask,並把 A[i] 加上對應位置上去。初始化完成之后就要進行更新了,要遍歷 mask 的所有值,從1到 1<<n,這實際上是遍歷數組A的所有子序列,對於每一個 mask 值,需要遍歷其二進制每一個為1的對應位的字符串,變量j從0遍歷到 n-1,假如 mask 的二進制數對應的j位上為0,則說明 A[j] 字符串未被使用,直接跳過。想想此時該如何更新 dp[mask][j],其表示的含義是使用了 mask 二進制對應位為1的所有的字符串組成的超級串,且最后一個位置是 A[j],為了更新它,需要取出 A[j],並讓其他所有字符串依次當作結尾字符串,則需要依次取出所有其他的字符串,變量i來從0遍歷到 n-1,若此時i等於j,說明是同一個字符串,跳過這種情況。又或者 mask 的二進制數對應的i位上為0,則說明 A[i] 字符串未被使用,同樣需要跳過。此時取出 A[j],則 mask 的二進制數對應的j位應該標記為0,這里通過亦或來實現 mask^(1<<j),然后此時將 A[i] 當作結尾的超級串就是 dp[mask^(1<<j)][i],然后此時要加回 A[j],不能直接加,因為可能會有復用字符,幸虧有 overlap 數組,提前計算好了,復用字符的個數是 overlap[i][j],直接將復用字符去掉,需要加上 A[j].substr(overlap[i][j]),然后用這個新的超級串跟原來的 dp[mask][j] 比較,用較短的那個來更新 dp[mask][j] 即可。最終的結果是保存在 mask 為 (1<<n)-1 的數組中的,因為所有的字符串都需要被使用,則 mask 二進制數的所有位都必須是1,在其對應的字符串數組中找到長度最短的那個返回即可,參見代碼如下:


解法二:

class Solution {
public:
    string shortestSuperstring(vector<string>& A) {
        int n = A.size();
        vector<vector<string>> dp(1 << n, vector<string>(n));
        vector<vector<int>> overlap(n, vector<int>(n));
        for (int i = 0; i < n; ++i) {
        	for (int j = 0; j < n; ++j) {
        		if (i == j) continue;
        		for (int k = min(A[i].size(), A[j].size()); k > 0; --k) {
        			if (A[i].substr(A[i].size() - k) == A[j].substr(0, k)) {
        				overlap[i][j] = k;
        				break;
        			}
        		}
        	}
        }
        for (int i = 0; i < n; ++i) dp[1 << i][i] += A[i];
        for (int mask = 1; mask < (1 << n); ++mask) {
        	for (int j = 0; j < n; ++j) {
        		if ((mask & (1 << j)) == 0) continue;
        		for (int i = 0; i < n; ++i) {
        			if (i == j || (mask & (1 << i)) == 0) continue;
        			string t = dp[mask ^ (1 << j)][i] + A[j].substr(overlap[i][j]);
        			if (dp[mask][j].empty() || t.size() < dp[mask][j].size()) {
        				dp[mask][j] = t;
        			}
        		}
        	}
        }
        int last = (1 << n) - 1;
        string res = dp[last][0];
        for (int i = 1; i < n; ++i) {
        	if (dp[last][i].size() < res.size()) {
        		res = dp[last][i];
        	}
        }
        return res;
    }
};

Github 同步地址:

https://github.com/grandyang/leetcode/issues/943


參考資料:

https://leetcode.com/problems/find-the-shortest-superstring/

https://zxi.mytechroad.com/blog/searching/leetcode-943-find-the-shortest-superstring/

https://leetcode.com/problems/find-the-shortest-superstring/discuss/194932/Travelling-Salesman-Problem

https://leetcode.com/problems/find-the-shortest-superstring/discuss/195290/C%2B%2B-solution-in-less-than-30-lines

https://leetcode.com/problems/find-the-shortest-superstring/discuss/199029/Rewrite-the-official-solution-in-C%2B%2B

https://leetcode.com/problems/find-the-shortest-superstring/discuss/198920/Shortest-Hamiltonian-Path-in-weighted-digraph-(with-instructional-explanation)


LeetCode All in One 題目講解匯總(持續更新中...)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM