ID: 221
TITLE: 最大正方形
TAG: Java,Python,回溯,Trie
概述
再深入研究解決方案之前,最好先退一步弄清楚問題的要求。
給定一個非重複的單詞列表,要求我們構造所有可能的單詞方塊組合。這是單詞方塊的定義:
當且僅當從第 k 行形成的每個字符串()等於從第 k 列形成的字符串()時,單詞序列形成有效的單詞方塊,即:
從定義中我們可以看到,由於行和列的形成的字符串相等形成單詞方塊,所以單詞方塊應在對角線上對稱。
換句話說,如果我們知道單詞方塊的右上部分,我們可以推斷出它的左下部分,反之亦然。單詞方塊中的對稱性也可以解釋爲問題的約束,這可以幫助我們縮小有效組合的範圍。
算法:回溯
算法:
給定一個單詞列表,要求我們找到可以構成單詞方塊的單詞的組合。解決上述問題的算法的主要部分可能非常簡單。
其思想是我們從上到下一行一行的構造單詞方塊。在每一行中,我們只需嘗試和出錯,即嘗試使用一個單詞,如果不滿足要求,則嘗試另一個單詞。
我們可能注意到,上述算法的思想實際上被稱爲回溯,它通常與遞歸和 DFS(深度優先搜索)相聯繫。
讓我們用一個例子來說明這個想法。給定一個單詞列表 [ball, able, area, lead, lady]
,我們嘗試將四個單詞放在一起構建單詞方塊。
- 讓我們從單詞
ball
開始,作爲單詞方塊的第一個單詞,也就是我們將放到第一行的單詞。 - 然後我們轉到第二行。考慮到單詞方塊的對稱性,我們現在知道應該填充在第二行第一列的字母。也就是說,我們知道第二行的單詞應該以
a
前綴開頭。 - 在單詞列表中,有兩個前綴爲
a
的單詞(即able
,area
)。這兩個單詞都可能是填充第二行單詞的候選。下一步我們對這兩個單詞進行嘗試。 - 下一步,我們用單詞
able
填充在第二行。然後我們轉向第三行。同樣,由於對稱性,我們知道了第三行中的單詞應該以ll
開頭。不幸的是,單詞列表中沒有以ll
開頭的單詞。導致我們不能繼續填充單詞方塊。然後,放棄了此次嘗試,返回到上一個狀態(第一行已填充)。 - 下一步,我們嘗試用單詞
area
填充第二行。一旦我們填充第二行,我們知道在下一行中,要填充的單詞應該以前綴le
開頭。這次我們在單詞列表中找到了這樣的單詞,即lead
。 - 因此,下一步我們用
lead
這個單詞填充第三行。等等等等。 - 最後,以每個單詞爲起始單詞重複上述步驟,那麼將包含所有的可能性來構造一個有效的單詞方塊。
class Solution:
def wordSquares(self, words: List[str]) -> List[List[str]]:
self.words = words
self.N = len(words[0])
results = []
word_squares = []
for word in words:
# try with every word as the starting word
word_squares = [word]
self.backtracking(1, word_squares, results)
return results
def backtracking(self, step, word_squares, results):
if step == self.N:
results.append(word_squares[:])
return
prefix = ''.join([word[step] for word in word_squares])
# find out all words that start with the given prefix
for candidate in self.getWordsWithPrefix(prefix):
# iterate row by row
word_squares.append(candidate)
self.backtracking(step+1, word_squares, results)
word_squares.pop()
def getWordsWithPrefix(self, prefix):
for word in self.words:
if word.startswith(prefix):
yield word
掃視了一眼代碼,這個問題似乎並不像它的難度等級一樣令人畏懼。事實上,如果一個人能在面試中實現代碼的框架,那麼毫不客氣的說,他已經完成了面試。
上述的實現是正確的,並在網上能通過大多數的測試用例。但是,對於某些較大的測試用例,他將遇到超出時間限制的異常。
但是,沒有必要沮喪,因爲我們已經知道了算法的難點。我們只需要最後解決優化的問題,這實際上可能是面試中的後續問題。
方法一:使用哈希表的回溯
正如我們上述回溯算法中注意到的,瓶頸在於函數 getWordsWithPrefix()
,它將查找具有給定前綴的所有單詞。在每次調用該函數時,我們都在遍歷整個單詞列表,具有線性時間複雜性 。
優化 getWordsWithPrefix()
函數的一個想法時預先處理這些單詞,構建一個數據結構,以便加快查找過程。
你們可能還記得,提供快速查找操作的數據結構之一稱爲哈希表或字典。我們可以簡單構建一個哈希表,將所有可能的前綴作爲鍵,將與前綴相關聯的的那次作爲表中的值。稍後,給定前綴,我們能夠在常量時間 中列出所有具有給定前綴的所有單詞。
算法:
- 我們在上面列出的回溯算法的基礎上,對兩部分進行了調整。
- 第一部分,我們添加了一個新的函數
buildPrefixHashTable(words)
來根據輸入的單詞構建一個哈希表。 - 第二部分,在函數
getWordsWithPrefix()
中,我們查詢哈希表來檢索給定前綴的所有單詞。
class Solution:
def wordSquares(self, words: List[str]) -> List[List[str]]:
self.words = words
self.N = len(words[0])
self.buildPrefixHashTable(self.words)
results = []
word_squares = []
for word in words:
word_squares = [word]
self.backtracking(1, word_squares, results)
return results
def backtracking(self, step, word_squares, results):
if step == self.N:
results.append(word_squares[:])
return
prefix = ''.join([word[step] for word in word_squares])
for candidate in self.getWordsWithPrefix(prefix):
word_squares.append(candidate)
self.backtracking(step+1, word_squares, results)
word_squares.pop()
def buildPrefixHashTable(self, words):
self.prefixHashTable = {}
for word in words:
for prefix in (word[:i] for i in range(1, len(word))):
self.prefixHashTable.setdefault(prefix, set()).add(word)
def getWordsWithPrefix(self, prefix):
if prefix in self.prefixHashTable:
return self.prefixHashTable[prefix]
else:
return set([])
class Solution {
int N = 0;
String[] words = null;
HashMap<String, List<String>> prefixHashTable = null;
public List<List<String>> wordSquares(String[] words) {
this.words = words;
this.N = words[0].length();
List<List<String>> results = new ArrayList<List<String>>();
this.buildPrefixHashTable(words);
for (String word : words) {
LinkedList<String> wordSquares = new LinkedList<String>();
wordSquares.addLast(word);
this.backtracking(1, wordSquares, results);
}
return results;
}
protected void backtracking(int step, LinkedList<String> wordSquares,
List<List<String>> results) {
if (step == N) {
results.add((List<String>) wordSquares.clone());
return;
}
StringBuilder prefix = new StringBuilder();
for (String word : wordSquares) {
prefix.append(word.charAt(step));
}
for (String candidate : this.getWordsWithPrefix(prefix.toString())) {
wordSquares.addLast(candidate);
this.backtracking(step + 1, wordSquares, results);
wordSquares.removeLast();
}
}
protected void buildPrefixHashTable(String[] words) {
this.prefixHashTable = new HashMap<String, List<String>>();
for (String word : words) {
for (int i = 1; i < this.N; ++i) {
String prefix = word.substring(0, i);
List<String> wordList = this.prefixHashTable.get(prefix);
if (wordList == null) {
wordList = new ArrayList<String>();
wordList.add(word);
this.prefixHashTable.put(prefix, wordList);
} else {
wordList.add(word);
}
}
}
}
protected List<String> getWordsWithPrefix(String prefix) {
List<String> wordList = this.prefixHashTable.get(prefix);
return (wordList != null ? wordList : new ArrayList<String>());
}
}
複雜度分析
- 時間複雜度:。其中 是輸入單詞列表的單詞數量和 指的是單詞的長度。
- 在回溯算法中,要精確的計算運算次數是很困難的。我們知道回溯的路徑會形成一顆 n 元樹。因此,運算次數的上界是一顆完整 n 元樹的結點總數。
- 在我們的例子中,任何結點上,最多可以有 26 個分支(即 26 個字母)。因此 26 元樹種的最大結點數約爲 。
- 在回溯函數的循環種,我們列舉了每個單詞作爲單詞方塊起始單詞的可能性。因此,算法的總時間複雜度應爲 。
- 實際上,回溯實際跟蹤比上限小的多,這是由於約束檢查大大減少了回溯的跟蹤。
- 空間複雜度:,其中 是輸入單詞列表的單詞數量和 指的是單詞的長度。
- 空間複雜度的前半部分(即 )是哈希表種存儲的值,長度爲 乘以單詞列表中的單詞數。
- 空間複雜度的後半部分(即 )是哈希表鍵佔用的空間,它包含所有單詞的前綴。
- 總的來說,可以說算法的總空間與單詞的總數乘以單個單詞的長度成正比。
方法二:使用單詞查找樹的回溯
說到前綴,還有另一種稱爲 Trie(也成爲前綴樹)的數據結構,可以在該問題中使用它。
在上述方法中,我們已將檢索給定前綴的單詞列表的時間複雜度從線性時間 降到常量時間 。作爲交換,我們要花費一些額外的空間來存儲每個單詞的前綴。
Trie 數據結構提供了緊湊且快速的方法來檢索具有給定前綴的單詞。
下圖中,我們展示了一個示例,說明如何從單詞列表構建 Trie。
如我們所見,Trie 是一個 n 元樹,其中每個結點表示前綴的一個字符。Trie 數據結構非常緊湊,可以存儲前綴並且可以消除冗餘前綴,例如 le
和 la
的前綴將共享一個結點。然而,從 Trie 檢索單詞仍然很快。檢索一個單詞需要 ,其中 指的是
如我們所見,Trie基本上是一個n-aray樹,其中每個節點表示前綴中的一個字符。Trie數據結構非常緊湊,可以存儲前綴,因爲它可以消除冗餘前綴,例如le和la的前綴將共享一個節點。然而,從Trie檢索單詞仍然很快。檢索一個單詞需要,其中是單詞的長度,比暴力枚舉快得多。
算法:
- 我們在上面列出的回溯算法的基礎上,對兩部分進行了調整。
- 第一部分,我們添加了一個函數
buildTrie(words)
來輸入單詞構建 Trie - 第二部分,在
getWordsWithPrefix(prefix)
函數中,我們查詢 Trie 來檢索具有給定前綴的單詞。 - 下面是一些示例實現,注意,我們對 Trie 數據結構進行了一些調整,以便進一步優化時間和空間複雜性。
- 我們不在 Trie 的葉節點標記單詞,而是在每個結點標記單詞,這樣一旦到達前綴中的最後一個結點,就不需要執行進一步的遍歷。這個技巧可以優化時間複雜度。
- 我們沒有在 Trie 存儲實際的單詞,而是保留單詞的所有,這樣可以大大節省空間。
class Solution:
def wordSquares(self, words: List[str]) -> List[List[str]]:
self.words = words
self.N = len(words[0])
self.buildTrie(self.words)
results = []
word_squares = []
for word in words:
word_squares = [word]
self.backtracking(1, word_squares, results)
return results
def buildTrie(self, words):
self.trie = {}
for wordIndex, word in enumerate(words):
node = self.trie
for char in word:
if char in node:
node = node[char]
else:
newNode = {}
newNode['#'] = []
node[char] = newNode
node = newNode
node['#'].append(wordIndex)
def backtracking(self, step, word_squares, results):
if step == self.N:
results.append(word_squares[:])
return
prefix = ''.join([word[step] for word in word_squares])
for candidate in self.getWordsWithPrefix(prefix):
word_squares.append(candidate)
self.backtracking(step+1, word_squares, results)
word_squares.pop()
def getWordsWithPrefix(self, prefix):
node = self.trie
for char in prefix:
if char not in node:
return []
node = node[char]
return [self.words[wordIndex] for wordIndex in node['#']]
class TrieNode {
HashMap<Character, TrieNode> children = new HashMap<Character, TrieNode>();
List<Integer> wordList = new ArrayList<Integer>();
public TrieNode() {}
}
class Solution {
int N = 0;
String[] words = null;
TrieNode trie = null;
public List<List<String>> wordSquares(String[] words) {
this.words = words;
this.N = words[0].length();
List<List<String>> results = new ArrayList<List<String>>();
this.buildTrie(words);
for (String word : words) {
LinkedList<String> wordSquares = new LinkedList<String>();
wordSquares.addLast(word);
this.backtracking(1, wordSquares, results);
}
return results;
}
protected void backtracking(int step, LinkedList<String> wordSquares,
List<List<String>> results) {
if (step == N) {
results.add((List<String>) wordSquares.clone());
return;
}
StringBuilder prefix = new StringBuilder();
for (String word : wordSquares) {
prefix.append(word.charAt(step));
}
for (Integer wordIndex : this.getWordsWithPrefix(prefix.toString())) {
wordSquares.addLast(this.words[wordIndex]);
this.backtracking(step + 1, wordSquares, results);
wordSquares.removeLast();
}
}
protected void buildTrie(String[] words) {
this.trie = new TrieNode();
for (int wordIndex = 0; wordIndex < words.length; ++wordIndex) {
String word = words[wordIndex];
TrieNode node = this.trie;
for (Character letter : word.toCharArray()) {
if (node.children.containsKey(letter)) {
node = node.children.get(letter);
} else {
TrieNode newNode = new TrieNode();
node.children.put(letter, newNode);
node = newNode;
}
node.wordList.add(wordIndex);
}
}
}
protected List<Integer> getWordsWithPrefix(String prefix) {
TrieNode node = this.trie;
for (Character letter : prefix.toCharArray()) {
if (node.children.containsKey(letter)) {
node = node.children.get(letter);
} else {
// return an empty list.
return new ArrayList<Integer>();
}
}
return node.wordList;
}
}
複雜度分析
- 時間複雜度:,其中 指的是輸入單詞數量和 指的是單個單詞的長度。
- 基本上,時間複雜度與方法一()相同,只是我們現在需要的不是
getWordsWithPrefix(prefix)
函數的常數時間訪問,而是 .
- 基本上,時間複雜度與方法一()相同,只是我們現在需要的不是
- 空間複雜度:,其中 指的是單詞數量和 指的是單個單詞的長度。
- 空間複雜度前半部分(即 ) 是存儲在 Trie 中的單詞。
- 空間複雜度後半部分(即 )是所有單詞前綴的佔用空間。最壞的清空下,前綴之間沒有重疊。
- 總的來說,這種方法與以前的方法具有相同的時間複雜度。然而,在運行時,由於我們所作的優化,它將消耗更少的內存。