425. 單詞方塊


ID: 221
TITLE: 最大正方形
TAG: Java,Python,回溯,Trie


概述

再深入研究解決方案之前,最好先退一步弄清楚問題的要求。

給定一個非重複的單詞列表,要求我們構造所有可能的單詞方塊組合。這是單詞方塊的定義:

當且僅當從第 k 行形成的每個字符串(HkH_k)等於從第 k 列形成的字符串(VkV_k)時,單詞序列形成有效的單詞方塊,即:
Hk==Vkk0k<max(numRows,numColumns).H_k == V_k \qquad \forall{k} \quad 0 ≤ k < \max(\text{numRows}, \text{numColumns}).

在這裏插入圖片描述
從定義中我們可以看到,由於行和列的形成的字符串相等形成單詞方塊,所以單詞方塊應在對角線上對稱。

換句話說,如果我們知道單詞方塊的右上部分,我們可以推斷出它的左下部分,反之亦然。單詞方塊中的對稱性也可以解釋爲問題的約束,這可以幫助我們縮小有效組合的範圍。

算法:回溯

算法:

給定一個單詞列表,要求我們找到可以構成單詞方塊的單詞的組合。解決上述問題的算法的主要部分可能非常簡單。

其思想是我們從上到下一行一行的構造單詞方塊。在每一行中,我們只需嘗試和出錯,即嘗試使用一個單詞,如果不滿足要求,則嘗試另一個單詞。

我們可能注意到,上述算法的思想實際上被稱爲回溯,它通常與遞歸和 DFS(深度優先搜索)相聯繫。

讓我們用一個例子來說明這個想法。給定一個單詞列表 [ball, able, area, lead, lady],我們嘗試將四個單詞放在一起構建單詞方塊。

在這裏插入圖片描述

  • 讓我們從單詞 ball 開始,作爲單詞方塊的第一個單詞,也就是我們將放到第一行的單詞。
  • 然後我們轉到第二行。考慮到單詞方塊的對稱性,我們現在知道應該填充在第二行第一列的字母。也就是說,我們知道第二行的單詞應該以 a 前綴開頭。
  • 在單詞列表中,有兩個前綴爲 a 的單詞(即 ablearea)。這兩個單詞都可能是填充第二行單詞的候選。下一步我們對這兩個單詞進行嘗試。
  • 下一步,我們用單詞 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(),它將查找具有給定前綴的所有單詞。在每次調用該函數時,我們都在遍歷整個單詞列表,具有線性時間複雜性 O(N)\mathcal{O}(N)

優化 getWordsWithPrefix() 函數的一個想法時預先處理這些單詞,構建一個數據結構,以便加快查找過程。

你們可能還記得,提供快速查找操作的數據結構之一稱爲哈希表或字典。我們可以簡單構建一個哈希表,將所有可能的前綴作爲鍵,將與前綴相關聯的的那次作爲表中的值。稍後,給定前綴,我們能夠在常量時間 O(1)\mathcal{O}(1) 中列出所有具有給定前綴的所有單詞。

算法:

  • 我們在上面列出的回溯算法的基礎上,對兩部分進行了調整。
  • 第一部分,我們添加了一個新的函數 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>());
  }
}

複雜度分析

  • 時間複雜度:O(N26L)\mathcal{O}(N\cdot26^{L})。其中 NN 是輸入單詞列表的單詞數量和 LL 指的是單詞的長度。
    • 在回溯算法中,要精確的計算運算次數是很困難的。我們知道回溯的路徑會形成一顆 n 元樹。因此,運算次數的上界是一顆完整 n 元樹的結點總數。
    • 在我們的例子中,任何結點上,最多可以有 26 個分支(即 26 個字母)。因此 26 元樹種的最大結點數約爲 26L26^L
    • 在回溯函數的循環種,我們列舉了每個單詞作爲單詞方塊起始單詞的可能性。因此,算法的總時間複雜度應爲 O(N26L)\mathcal{O}(N\cdot26^{L})
    • 實際上,回溯實際跟蹤比上限小的多,這是由於約束檢查大大減少了回溯的跟蹤。
  • 空間複雜度:O(NL+NL2)=O(NL)\mathcal{O}(N\cdot{L} + N\cdot\frac{L}{2}) = \mathcal{O}(N\cdot{L}),其中 NN 是輸入單詞列表的單詞數量和 LL 指的是單詞的長度。
    • 空間複雜度的前半部分(即 NLN\cdot{L})是哈希表種存儲的值,長度爲 LL 乘以單詞列表中的單詞數。
    • 空間複雜度的後半部分(即 NL2N\cdot\frac{L}{2})是哈希表鍵佔用的空間,它包含所有單詞的前綴。
    • 總的來說,可以說算法的總空間與單詞的總數乘以單個單詞的長度成正比。

方法二:使用單詞查找樹的回溯

說到前綴,還有另一種稱爲 Trie(也成爲前綴樹)的數據結構,可以在該問題中使用它。

在上述方法中,我們已將檢索給定前綴的單詞列表的時間複雜度從線性時間 O(N)\mathcal{O}(N) 降到常量時間 O(1)\mathcal{O}(1)。作爲交換,我們要花費一些額外的空間來存儲每個單詞的前綴。

Trie 數據結構提供了緊湊且快速的方法來檢索具有給定前綴的單詞。

下圖中,我們展示了一個示例,說明如何從單詞列表構建 Trie。

在這裏插入圖片描述
如我們所見,Trie 是一個 n 元樹,其中每個結點表示前綴的一個字符。Trie 數據結構非常緊湊,可以存儲前綴並且可以消除冗餘前綴,例如 lela 的前綴將共享一個結點。然而,從 Trie 檢索單詞仍然很快。檢索一個單詞需要 O(L)\mathcal{O}(L),其中 LL 指的是

如我們所見,Trie基本上是一個n-aray樹,其中每個節點表示前綴中的一個字符。Trie數據結構非常緊湊,可以存儲前綴,因爲它可以消除冗餘前綴,例如le和la的前綴將共享一個節點。然而,從Trie檢索單詞仍然很快。檢索一個單詞需要OL\mathcal{O}(L),其中LL是單詞的長度,比暴力枚舉快得多。

算法:

  • 我們在上面列出的回溯算法的基礎上,對兩部分進行了調整。
  • 第一部分,我們添加了一個函數 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;
  }
}

複雜度分析

  • 時間複雜度:O(N26LL)\mathcal{O}(N\cdot26^{L}\cdot{L}),其中 NN 指的是輸入單詞數量和 LL 指的是單個單詞的長度。
    • 基本上,時間複雜度與方法一(O(N26L)\mathcal{O}(N\cdot26^{L}))相同,只是我們現在需要的不是 getWordsWithPrefix(prefix) 函數的常數時間訪問,而是 O(L)\mathcal{O}(L).
  • 空間複雜度:O(NL+NL2)=O(NL)\mathcal{O}(N\cdot{L} + N\cdot\frac{L}{2}) = \mathcal{O}(N\cdot{L}),其中 NN 指的是單詞數量和 LL 指的是單個單詞的長度。
    • 空間複雜度前半部分(即 NLN\cdot{L}) 是存儲在 Trie 中的單詞。
    • 空間複雜度後半部分(即 NL2N\cdot\frac{L}{2})是所有單詞前綴的佔用空間。最壞的清空下,前綴之間沒有重疊。
    • 總的來說,這種方法與以前的方法具有相同的時間複雜度。然而,在運行時,由於我們所作的優化,它將消耗更少的內存。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章