127. 單詞接龍
127. 單詞接龍
給定兩個單詞(beginWord和 endWord)和一個字典,找到從beginWord 到endWord 的最短轉換序列的長度。轉換需遵循如下規則:
每次轉換隻能改變一個字母。
轉換過程中的中間單詞必須是字典中的單詞。
說明:
如果不存在這樣的轉換序列,返回 0。
所有單詞具有相同的長度。
所有單詞只由小寫字母組成。
字典中不存在重複的單詞。
你可以假設 beginWord 和 endWord 是非空的,且二者不相同。
示例1:
輸入:
beginWord = "hit",
endWord = "cog",
wordList = ["hot","dot","dog","lot","log","cog"]
輸出: 5
解釋: 一個最短轉換序列是 "hit" -> "hot" -> "dot" -> "dog" -> "cog",
返回它的長度 5。
示例 2:
輸入:
beginWord = "hit"
endWord = "cog"
wordList = ["hot","dot","dog","lot","log"]
輸出:0
解釋:endWord "cog" 不在字典中,所以無法進行轉換。
擁有一個 beginWord 和一個 endWord,分別表示圖上的 start node 和 end node。我們希望利用一些中間節點(單詞)從 start node 到 end node,中間節點是 wordList 給定的單詞。我們對這個單詞接龍每個步驟的唯一條件是相鄰單詞只可以改變一個字母。
我們將問題抽象在一個無向無權圖中,每個單詞作為節點,差距只有一個字母的兩個單詞之間連一條邊。問題變成找到從起點到終點的最短路徑,如果存在的話。因此可以使用廣度優先搜尋方法。
演算法中最重要的步驟是找出相鄰的節點,也就是隻差一個字母的兩個單詞。為了快速的找到這些相鄰節點,我們對給定的 wordList 做一個預處理,將單詞中的某個字母用 * 代替。
這個預處理幫我們構造了一個單詞變換的通用狀態。例如:Dog ----> D*g <---- Dig,Dog 和 Dig 都指向了一個通用狀態 D*g。
這步預處理找出了單詞表中所有單詞改變某個字母后的通用狀態,並幫助我們更方便也更快的找到相鄰節點。否則,對於每個單詞我們需要遍歷整個字母表檢視是否存在一個單詞與它相差一個字母,這將花費很多時間。預處理操作在廣度優先搜尋之前高效的建立了鄰接表。
例如,在廣搜時我們需要訪問 Dug 的所有鄰接點,我們可以先生成 Dug 的所有通用狀態:
Dug => *ug
Dug => D*g
Dug => Du*
第二個變換 D*g 可以同時對映到 Dog 或者 Dig,因為他們都有相同的通用狀態。擁有相同的通用狀態意味著兩個單詞只相差一個字母,他們的節點是相連的。
方法 1:廣度優先搜尋想法
利用廣度優先搜尋搜尋從 beginWord 到 endWord 的路徑。
演算法
對給定的 wordList 做預處理,找出所有的通用狀態。將通用狀態記錄在字典中,鍵是通用狀態,值是所有具有通用狀態的單詞。
將包含 beginWord 和 1 的元組放入佇列中,1 代表節點的層次。我們需要返回 endWord 的層次也就是從 beginWord 出發的最短距離。
為了防止出現環,使用訪問陣列記錄。
當佇列中有元素的時候,取出第一個元素,記為 current_word。
找到 current_word 的所有通用狀態,並檢查這些通用狀態是否存在其它單詞的對映,這一步通過檢查 all_combo_dict 來實現。
從 all_combo_dict 獲得的所有單詞,都和 current_word 共有一個通用狀態,所以都和 current_word 相連,因此將他們加入到佇列中。
對於新獲得的所有單詞,向佇列中加入元素 (word, level + 1) 其中 level 是 current_word 的層次。
最終當你到達期望的單詞,對應的層次就是最短變換序列的長度。
標準廣度優先搜尋的終止條件就是找到結束單詞。
程式碼:
class Solution{ public: int ladderLength(string beginWord, string endWord, vector<string>& wordList){ //加入所有節點,訪問過一次,刪除一個。 set<string> s; for (int i = 0; i < wordList.size(); i++) s.insert(wordList[i]); queue<pair<string, int> > q; //加入beginword q.push( pair<string, int>( beginWord, 1) ); string tmp; //每個節點的字元 int step; //抵達該節點的step while ( !q.empty() ){ if ( q.front().first == endWord){ return (q.front().second); } tmp = q.front().first; step = q.front().second; q.pop(); //尋找下一個單詞了 char ch; for (int i = 0; i < tmp.length(); i++){ ch = tmp[i]; for (char c = 'a'; c <= 'z'; c++){ //從'a'-'z'嘗試一次 if ( ch == c) continue; tmp[i] = c ; //如果找到的到 if ( s.find(tmp) != s.end() ){ q.push(pair<string, int>(tmp, step+1)); s.erase(tmp) ; //刪除該節點 } tmp[i] = ch; //復原 } } } return 0; } };
方法 2:雙向廣度優先搜尋想法
根據給定字典構造的圖可能會很大,而廣度優先搜尋的搜尋空間大小依賴於每層節點的分支數量。假如每個節點的分支數量相同,搜尋空間會隨著層數的增長指數級的增加。考慮一個簡單的二叉樹,每一層都是滿二叉樹的擴充套件,節點的數量會以 2 為底數呈指數增長。
如果使用兩個同時進行的廣搜可以有效地減少搜尋空間。一邊從 beginWord 開始,另一邊從 endWord 開始。我們每次從兩邊各擴充套件一個節點,當發現某一時刻兩邊都訪問了某一頂點時就停止搜尋。這就是雙向廣度優先搜尋,它可以可觀地減少搜尋空間大小,從而降低時間和空間複雜度。
演算法
演算法與之前描述的標準廣搜方法相類似。
唯一的不同是我們從兩個節點同時開始搜尋,同時搜尋的結束條件也有所變化。
我們現在有兩個訪問陣列,分別記錄從對應的起點是否已經訪問了該節點。
如果我們發現一個節點被兩個搜尋同時訪問,就結束搜尋過程。因為我們找到了雙向搜尋的交點。過程如同從中間相遇而不是沿著搜尋路徑一直走。
雙向搜尋的結束條件是找到一個單詞被兩邊搜尋都訪問過了。
最短變換序列的長度就是中間節點在兩邊的層次之和。因此,我們可以在訪問陣列中記錄節點的層次。
程式碼:
class Solution { public: int ladderLength(string beginWord, string endWord, vector<string>& wordList) { unordered_set<string> dict(wordList.begin(), wordList.end()); if (dict.find(endWord) == dict.end() ) return 0; // 初始化起始和終點 unordered_set<string> beginSet, endSet, tmp, visited; beginSet.insert(beginWord); endSet.insert(endWord); int len = 1; while (!beginSet.empty() && !endSet.empty()){ if (beginSet.size() > endSet.size()){ tmp = beginSet; beginSet = endSet; endSet = tmp; } tmp.clear(); for ( string word : beginSet){ for (int i = 0; i < word.size(); i++){ char old = word[i]; for ( char c = 'a'; c <= 'z'; c++){ if ( old == c) continue; word[i] = c; if (endSet.find(word) != endSet.end()){ return len+1; } if (visited.find(word) == visited.end() && dict.find(word) != dict.end()){ tmp.insert(word); visited.insert(word); } } word[i] = old; } } beginSet = tmp; len++; } return 0; } };