問題
給出兩個單詞(start和end)與一個字典,找出從start到end的最短轉換序列。規則如下:
- 一次只能改變一個字母
- 中間單詞必須在字典里存在
例如:
給出
start = "hit"
end = "cog"
dict = ["hot","dot","dog","lot","log"]
返回
[
["hit","hot","dot","dog","cog"],
["hit","hot","lot","log","cog"]
]
注意
- 所有單詞的長度一樣
- 所有單詞中只有小寫字母
初始思路
最直接的想法是從start開始,對每個字母位置從'a'到'z'嘗試替換。如果替換字母后的單詞在字典中,將其加入路徑,然后以新單詞為起點進行遞歸調用,否則繼續循環。每層遞歸函數終止的條件是end出現或者單詞長度*26次循環完畢。end出現時表示找到一個序列,對比當前最短序列做相應更新即可。
處理過程中需要注意的主要有幾點:
- 不能相同字母替換,如hot第一個位置遍歷到h時不應該處理。否則就會不斷的在hot上死循環。因此在替換后前要做一次檢查。
- 我們要找的是最短的轉換方案,所以轉換序列不應該出現重復的單詞。否則組合將會有無數多種,如例子中的["hit","hot","dot","dog","dot","dog","dog",....."cog"]。這里我們可以使用一個unordered_set容器來保存某次一次替換序列中已出現過的單詞,也可以每次使用std:find去搜索當前替換序列。如果使用unordered_set,在遞歸處理時,和單詞序列一樣,要在遞歸后做相應的出棧操作。
- 處理過程中如果發現當前處理序列長度已經超過當前最短序列長度,可以中止對該序列的處理,因為我們要找的是最短的序列。

1 class Solution { 2 public: 3 std::vector<std::vector<std::string>> findLadders(std::string start, std::string end, std::unordered_set<std::string> &dict) 4 { 5 std::vector<std::vector<std::string>> result; 6 std::vector<std::string> entry; 7 8 entry.push_back(start); 9 Find(start, end, dict, 0, result, entry); 10 11 return result; 12 } 13 14 private: 15 void Find(std::string& start, const std::string& end, const std::unordered_set<std::string> &dict 16 , size_t positionToChange, std::vector<std::vector<std::string>>& result, std::vector<std::string>& entry) 17 { 18 //如果長度已經等於當前結果中的長度,再找出來肯定就 19 //超過了,終止處理 20 if(!result.empty() && entry.size() == result[0].size()) 21 { 22 return; 23 } 24 25 for(size_t pos = positionToChange; pos < start.size(); ++pos) 26 { 27 char beforeChange = ' '; 28 for(int i = 'a'; i <= 'z'; ++i) 29 { 30 //防止同字母替換 31 if(start[pos] == i) 32 { 33 continue; 34 } 35 beforeChange = start[pos]; 36 start[pos] = i; 37 38 //用std::find的話 39 /* 40 if(std::find(entry.begin(), entry.end(), start) != entry.end()) 41 { 42 start[pos] = beforeChange; 43 continue; 44 } 45 */ 46 //如果單詞已經用過的情況 47 if(!used_.empty() && used_.count(start)!=0 ) 48 { 49 start[pos] = beforeChange; 50 continue; 51 } 52 53 54 if(start == end) 55 { 56 entry.push_back(start); 57 58 //只需要保存最短的序列 59 if(!result.empty()) 60 { 61 if(entry.size() < result[0].size()) 62 { 63 result.clear(); 64 result.push_back(entry); 65 } 66 else if(entry.size() == result[0].size()) 67 { 68 result.push_back(entry); 69 } 70 } 71 else 72 { 73 result.push_back(entry); 74 } 75 //完成一個序列,把前面加入的end彈出 76 entry.pop_back(); 77 return; 78 } 79 80 if(dict.find(start) != dict.end()) 81 { 82 entry.push_back(start); 83 used_.insert(start); 84 Find(start, end, dict, 0, result, entry); 85 used_.erase(*entry.rbegin()); 86 entry.pop_back(); 87 88 89 if(!entry.empty()) 90 { 91 start = *entry.rbegin(); 92 } 93 else 94 { 95 start[pos] = beforeChange; 96 } 97 } 98 else 99 { 100 start[pos] = beforeChange; 101 } 102 } 103 } 104 105 return; 106 } 107 108 std::unordered_set<std::string> used_; 109 };
提交測試,Judge Small沒有問題。Judge Large不幸超時。
優化
觀察我們的處理方法,找到可變換后的單詞后,我們會馬上基於它繼續查找。這是一種深度優先的查找方法,即英文的DFS(Depth-first search)。這對找出答案很可能是不利的,如果一開始進入了一條很長的序列,就會浪費了時間。而廣度優先BFS(Breadth-first search)的方法似乎更合適,當找到一個序列時,這個序列肯定是最短的之一。
要進行廣度優先遍歷,我們可以在發現替換字母后的單詞在字典中時,不馬上繼續處理它,而是將其放入一個隊列中。通過隊列先進先出的性質,就可以實現廣度優先的處理了。由於最后要輸出的是整個轉換序列,為了簡單起見,我們可以將當前已轉換出的序列放入隊列中,即隊列形式為std::vector<std::vector<std::string>>,序列的最后一個元素就是下次處理時要繼續轉換的單詞。
使用廣度優先遍歷后,還有一個特性使得我們可以更方便的處理深度優先中重復單詞的問題。當一個單詞在某一層(一層即從第一個單詞到當前單詞的長度一樣的序列)出現后,后面再出現的情況肯定不會是最短序列(相當於走了回頭路),因此我們可以在處理完一層后直接將已用過的單詞從字典中去除。需要注意的是,同一層是不能去除的,如例子中的hot在兩個序列的第二層中都出現了。這樣我們就需要一個容器把當前層用過的單詞保存起來,等處理的層數發生變化時再將它們從字典里移除。
最后要注意的是查找結束的條件。由於找到了一個序列后該序列只是最短的之一,我們還要繼續進行處理,直到隊列中當前層都處理完畢。所以我們要在找到一個序列后將它的長度記錄下來,當要處理的序列長度已經大於該值時,就可以結束查找了。

1 class Solution { 2 public: 3 std::vector<std::vector<std::string> > findLadders(std::string start, std::string end, std::unordered_set<std::string> &dict) 4 { 5 std::queue<std::vector<std::string>> candidate; 6 7 std::vector<std::vector<std::string>> result; 8 std::unordered_set<std::string> usedWord; 9 10 std::string currentString; 11 bool foundShortest = false; 12 size_t shortest = 0; 13 size_t previousPathLen = 0; 14 15 candidate.push({start}); 16 17 while(!candidate.empty()) 18 { 19 currentString = *candidate.front().rbegin(); 20 21 if(candidate.front().size() != previousPathLen) 22 { 23 for(auto iter = usedWord.begin(); iter != usedWord.end(); ++iter) 24 { 25 dict.erase(*iter); 26 } 27 } 28 29 if(foundShortest && candidate.front().size() >= shortest) 30 { 31 break; 32 } 33 34 for(size_t pos = 0; pos < start.size(); ++pos) 35 { 36 char beforeChange = ' '; 37 for(int i = 'a'; i <= 'z'; ++i) 38 { 39 beforeChange = currentString[pos]; 40 41 if(beforeChange == i) 42 { 43 continue; 44 } 45 46 currentString[pos] = i; 47 48 if(dict.count(currentString) > 0) 49 { 50 usedWord.insert(currentString); 51 52 if(currentString == end) 53 { 54 result.push_back(candidate.front()); 55 result.rbegin()->push_back(end); 56 foundShortest = true; 57 shortest = result.rbegin()->size(); 58 } 59 else 60 { 61 std::vector<std::string> newPath(candidate.front()); 62 newPath.push_back(currentString); 63 64 candidate.push(newPath); 65 } 66 } 67 currentString[pos] = beforeChange; 68 } 69 } 70 71 if(!candidate.empty()) 72 { 73 previousPathLen = candidate.front().size(); 74 75 candidate.pop(); 76 } 77 } 78 79 return result; 80 } 81 };
提交后Judge Large多處理了幾條用例,但是最后還是超時了。
再次優化
一個比較明顯的優化點是我們把存儲序列的vector放到了隊列中,每次都要拷貝舊隊列然后產生新隊列。回想一下我們使用vector的原因,主要是為了能保存序列,同時還能獲得當前序列的長度。為了實現這兩個目的,我們可以定義如下結構體:
1 struct PathTag 2 { 3 PathTag* parent_; 4 std::string value_; 5 int length_; 6 7 PathTag(PathTag* parent, const std::string& value, int length) : parent_(parent), value_(value), length_(length) 8 { 9 } 10 };
結構體記錄了當前單詞的前一個單詞以及當前的序列長度。有了這個結構體,我們在最后找到end后就可以通過不斷往前回溯得出整個路徑。而這個路徑是反向的,最后還需要做一次倒置操作。改進前面的BFS代碼如下(需要注意的是,由於LeetCode里不能使用智能指針,我們通過輔助函數來申請和釋放裸指針而不是直接new。如果不關心內存問題的話直接new了不管也可。):

1 class Solution{ 2 public: 3 ~Solution() 4 { 5 for(auto iter = pool_.begin(); iter != pool_.end(); ++iter) 6 { 7 delete *iter; 8 } 9 } 10 11 std::vector<std::vector<std::string> > findLadders(std::string start, std::string end, std::unordered_set<std::string> &dict) 12 { 13 std::queue<PathTag*> candidate; 14 15 std::vector<std::vector<std::string>> result; 16 std::unordered_set<std::string> usedWord; 17 18 std::string currentString; 19 bool foundShortest = false; 20 size_t shortest = 0; 21 size_t previousPathLen = 0; 22 23 candidate.push(AllocatePathTag(nullptr, start, 1)); 24 25 while(!candidate.empty()) 26 { 27 PathTag* current = candidate.front(); 28 currentString = current->value_; 29 30 if(current->length_ != previousPathLen) 31 { 32 for(auto iter = usedWord.begin(); iter != usedWord.end(); ++iter) 33 { 34 dict.erase(*iter); 35 } 36 } 37 38 if(foundShortest && current->length_ >= shortest) 39 { 40 break; 41 } 42 43 44 for(size_t pos = 0; pos < start.size(); ++pos) 45 { 46 char beforeChange = ' '; 47 for(int i = 'a'; i <= 'z'; ++i) 48 { 49 beforeChange = currentString[pos]; 50 51 if(beforeChange == i) 52 { 53 continue; 54 } 55 56 currentString[pos] = i; 57 58 if(dict.count(currentString) > 0) 59 { 60 usedWord.insert(currentString); 61 62 if(currentString == end) 63 { 64 65 GeneratePath(result, current, currentString); 66 foundShortest = true; 67 shortest = result.rbegin()->size(); 68 continue; 69 } 70 else 71 { 72 candidate.push(AllocatePathTag(current, currentString, current->length_ + 1)); 73 } 74 } 75 currentString[pos] = beforeChange; 76 } 77 } 78 79 if(!candidate.empty()) 80 { 81 previousPathLen = current->length_; 82 83 candidate.pop(); 84 } 85 } 86 87 return result; 88 } 89 90 private: 91 struct PathTag 92 { 93 PathTag* parent_; 94 std::string value_; 95 int length_; 96 97 PathTag(PathTag* parent, const std::string& value, int length) : parent_(parent), value_(value), length_(length) 98 { 99 100 } 101 102 }; 103 104 PathTag* AllocatePathTag(PathTag* parent, const std::string& value, int length) 105 { 106 if(nextPoolPos_ >= pool_.size()) 107 { 108 for(int i = 0; i < 100; ++i) 109 { 110 PathTag* newTag = new PathTag(nullptr, " ", 0); 111 pool_.push_back(newTag); 112 } 113 } 114 115 PathTag* toReturn = pool_[nextPoolPos_]; 116 toReturn->parent_ = parent; 117 toReturn->value_ = value; 118 toReturn->length_ = length; 119 120 ++nextPoolPos_; 121 122 return toReturn; 123 } 124 125 int nextPoolPos_; 126 127 std::vector<PathTag*> pool_; 128 129 void GeneratePath(std::vector<std::vector<std::string>>& result, PathTag* pathTag, const std::string& end) 130 { 131 std::vector<std::string> path; 132 133 path.push_back(end); 134 135 while(pathTag != nullptr) 136 { 137 path.push_back(pathTag->value_); 138 139 pathTag = pathTag->parent_; 140 } 141 142 size_t left = 0; 143 size_t right = path.size() - 1; 144 145 while(left < right) 146 { 147 std::swap(path[left], path[right]); 148 149 ++left; 150 --right; 151 } 152 153 result.push_back(path); 154 } 155 156 };
提交后Judge Large又多處理了幾條用例,但是還是沒能通過。
使用鄰接列表
在繼續優化之前,我們先來學習一個圖論中的概念 - 鄰接列表(Adjacency List)。具體細節可以參見這篇wiki:http://en.wikipedia.org/wiki/Adjacency_list 。簡單來說,這是一個存儲圖中每個頂點的所有鄰接頂點的數據結構。如無向圖:
a
/ \
b --- c
它的鄰接列表為:
a => b, c
b => a, c
c => a, b
具體到本問題,我們可以發現,start到end的所有序列,就是一個這些序列中所有單詞為點組成的圖。如果我們生成了該圖的鄰接列表,就可以不斷的在每個單詞的鄰接列表里找到轉換的下一個單詞,從而最終找到end。那么,我們首先要對字典里的單詞生成鄰接列表:遍歷字典里的單詞,針對每個單詞用前面逐字母替換的方法找出鄰接單詞,並保存起來。這里使用一個std::unordered_map<std::string, std::unordered_set<std::string>>來保存鄰接列表。
有了鄰接列表,尋找序列的方法就發生變化了。我們不再逐個替換字母,而是從start出發,遍歷start的鄰接頂點,將鄰接頂點放入隊列中。並重復操作直到隊列為空。還有一個發生變化的地方是去重操作。由於不再遍歷字典,現在我們發現非同層出現重復的單詞就跳過它而不是從字典里刪去。
剩下的生成路徑的方法仍然和BFS2類似,全部代碼如下:

1 class Solution { 2 public: 3 ~Solution() 4 { 5 for(auto iter = pool_.begin(); iter != pool_.end(); ++iter) 6 { 7 delete *iter; 8 } 9 } 10 11 std::vector<std::vector<std::string> > findLadders(std::string start, std::string end, std::unordered_set<std::string> &dict) 12 { 13 nextPoolPos_ = 0; 14 std::unordered_map<std::string, std::unordered_set<std::string>> adjacencyList; 15 std::string word; 16 17 for (auto iter = dict.begin(); iter != dict.end(); ++iter) 18 { 19 word = *iter; 20 BuildAdjacencyList(word, adjacencyList, dict); 21 } 22 23 std::vector<std::vector<std::string>> result; 24 std::queue<PathTag*> candidate; 25 std::unordered_map<std::string, int> usedWord; 26 27 std::string currentString; 28 bool foundShortest = false; 29 size_t shortest = 0; 30 31 candidate.push(AllocatePathTag(nullptr, start, 1)); 32 33 while(!candidate.empty()) 34 { 35 PathTag* current = candidate.front(); 36 37 if(foundShortest && current->length_ >= shortest) 38 { 39 break; 40 } 41 42 candidate.pop(); 43 44 auto adjacentIter = adjacencyList.find(current->value_); 45 46 if(adjacentIter != adjacencyList.end()) 47 { 48 for(auto iter = adjacentIter->second.begin(); iter != adjacentIter->second.end(); ++iter) 49 { 50 if(*iter == end) 51 { 52 GeneratePath(result, current, *iter); 53 foundShortest = true; 54 shortest = result.rbegin()->size(); 55 continue; 56 } 57 58 auto usedIter = usedWord.find(*iter); 59 60 61 if(usedIter != usedWord.end() && usedIter->second != current->length_ + 1) 62 { 63 continue; 64 } 65 66 usedWord[*iter] = current->length_ + 1; 67 68 candidate.push(AllocatePathTag(current, *iter, current->length_ + 1)); 69 } 70 } 71 else 72 { 73 continue; 74 } 75 76 } 77 78 return result; 79 } 80 81 private: 82 struct PathTag 83 { 84 PathTag* parent_; 85 std::string value_; 86 int length_; 87 88 PathTag(PathTag* parent, const std::string& value, int length) : parent_(parent), value_(value), length_(length) 89 { 90 } 91 92 }; 93 94 PathTag* AllocatePathTag(PathTag* parent, const std::string& value, int length) 95 { 96 if(nextPoolPos_ >= pool_.size()) 97 { 98 for(int i = 0; i < 100; ++i) 99 { 100 PathTag* newTag = new PathTag(nullptr, " ", 0); 101 pool_.push_back(newTag); 102 } 103 } 104 105 PathTag* toReturn = pool_[nextPoolPos_]; 106 toReturn->parent_ = parent; 107 toReturn->value_ = value; 108 toReturn->length_ = length; 109 110 ++nextPoolPos_; 111 112 return toReturn; 113 } 114 115 int nextPoolPos_; 116 117 std::vector<PathTag*> pool_; 118 119 void GeneratePath(std::vector<std::vector<std::string>>& result, PathTag* pathTag, const std::string& end) 120 { 121 std::vector<std::string> path; 122 123 path.push_back(end); 124 125 while(pathTag != nullptr) 126 { 127 path.push_back(pathTag->value_); 128 129 pathTag = pathTag->parent_; 130 } 131 132 size_t left = 0; 133 size_t right = path.size() - 1; 134 135 while(left < right) 136 { 137 std::swap(path[left], path[right]); 138 139 ++left; 140 --right; 141 } 142 143 result.push_back(path); 144 } 145 146 void BuildAdjacencyList(std::string& word, std::unordered_map<std::string, std::unordered_set<std::string>>& adjacencyList, const std::unordered_set<std::string>& dict) 147 { 148 std::string original = word; 149 150 for(size_t pos = 0; pos < word.size(); ++pos) 151 { 152 char beforeChange = ' '; 153 for(int i = 'a'; i <= 'z'; ++i) 154 { 155 beforeChange = word[pos]; 156 157 if(beforeChange == i) 158 { 159 continue; 160 } 161 162 word[pos] = i; 163 164 if(dict.count(word) > 0) 165 { 166 auto iter = adjacencyList.find(original); 167 if(iter != adjacencyList.end()) 168 { 169 iter->second.insert(word); 170 } 171 else 172 { 173 adjacencyList.insert(std::pair<std::string, std::unordered_set<std::string>>(original, std::unordered_set<std::string>())); 174 adjacencyList[original].insert(word); 175 } 176 } 177 178 word[pos] = beforeChange; 179 } 180 } 181 182 } 183 };
這回終於通過Judge Large了。
一種更快的解決方案
下面再介紹一種更快的解決方案,思路及代碼來自niaokedaoren的博客。
前一個解決方案雖然能通過大數據集測試,但是為了保存路徑信息我們額外引入了一個結構體而且因為需要用到指針使用了大量的new操作。還有什么不用保存所有路徑信息的辦法?
niaokedaoren的方案中使用了一個前驅單詞表,即記錄每一個單詞的前驅單詞是哪些。這樣在遍歷完畢后,我們從end出發遞歸就能把所有路徑生成出來。但是由於前驅單詞表不能記錄當前的層次信息,似乎我們沒法完成去重的工作。這個方案的巧妙之處就在於它沒有使用我們通常的隊列保存待處理的單詞,一個單詞一個單詞先進先出處理的方法,而是使用兩個vector來模擬隊列的操作。我們從vector 1中遍歷單詞進行轉換嘗試,發現能轉換的單詞后將其放入vector 2中。當vector 1中的單詞處理完畢后即為一層處理完畢,它里面的單詞就可以從字典里刪去了。接着我們對vector 2進行同樣處理,如此反復直到當前處理的vector中不再有單詞。我們發現vector 1和vector 2在不斷地交換正處理容器和待處理容器的身份,因此可以通過將其放入一個數組中,每次循環對數組下標值取反實現身份的交替:
int current = 0;
int previous = 1;
循環
current = !current; previous = !previous;
......
循環結束
完全代碼如下:

class Solution { public: std::vector<std::vector<std::string> > findLadders(std::string start, std::string end, std::unordered_set<std::string> &dict) { result_.clear(); std::unordered_map<std::string, std::vector<std::string>> prevMap; for(auto iter = dict.begin(); iter != dict.end(); ++iter) { prevMap[*iter] = std::vector<std::string>(); } std::vector<std::unordered_set<std::string>> candidates(2); int current = 0; int previous = 1; candidates[current].insert(start); while(true) { current = !current; previous = !previous; for (auto iter = candidates[previous].begin(); iter != candidates[previous].end(); ++iter) { dict.erase(*iter); } candidates[current].clear(); for(auto iter = candidates[previous].begin(); iter != candidates[previous].end(); ++iter) { for(size_t pos = 0; pos < iter->size(); ++pos) { std::string word = *iter; for(int i = 'a'; i <= 'z'; ++i) { if(word[pos] == i) { continue; } word[pos] = i; if(dict.count(word) > 0) { prevMap[word].push_back(*iter); candidates[current].insert(word); } } } } if (candidates[current].size() == 0) { return result_; } if (candidates[current].count(end)) { break; } } std::vector<std::string> path; GeneratePath(prevMap, path, end); return result_; } private: void GeneratePath(std::unordered_map<std::string, std::vector<std::string>> &prevMap, std::vector<std::string>& path, const std::string& word) { if (prevMap[word].size() == 0) { path.push_back(word); std::vector<std::string> curPath = path; reverse(curPath.begin(), curPath.end()); result_.push_back(curPath); path.pop_back(); return; } path.push_back(word); for (auto iter = prevMap[word].begin(); iter != prevMap[word].end(); ++iter) { GeneratePath(prevMap, path, *iter); } path.pop_back(); } std::vector<std::vector<std::string>> result_; };
可以看到處理速度快了不少: