單詞搜尋 (Word Search)#
Word Search II ↗#
題目#
給定二維字元矩陣和單詞列表,找出所有存在於矩陣中的單詞。
- 單詞由相鄰字元組成(上下左右四連通)
- 同一位置的字母不能重複使用
Board:
o a a n
e t a e
i h k r
a i f l
Words: ["oath", "pea", "eat", "rain"]
Output: ["oath", "eat"]解法演進#
暴力 DFS(會超時)#
對每個單詞,在矩陣中執行 DFS 搜尋。
問題:單詞多時,重複掃描矩陣。
Trie + DFS(最佳)#
- 將所有單詞建成 Trie
- 走訪矩陣每個位置作為起點
- DFS 同時在矩陣和 Trie 中行走
- Trie 中無路可走時剪枝
核心最佳化:一次 DFS 同時檢查所有以當前路徑為前綴的單詞。
Trie + DFS 實作#
完整程式碼
class TrieNode {
val children = HashMap<Char, TrieNode>()
var word: String? = null // 儲存完整單詞
}
class Solution {
fun findWords(board: Array<CharArray>, words: Array<String>): List<String> {
// 建立 Trie
val root = TrieNode()
for (word in words) {
var node = root
for (c in word) {
node = node.children.getOrPut(c) { TrieNode() }
}
node.word = word // 標記單詞結尾
}
val rows = board.size
val cols = board[0].size
val result = mutableListOf<String>()
val directions = arrayOf(-1 to 0, 1 to 0, 0 to -1, 0 to 1)
fun dfs(r: Int, c: Int, node: TrieNode) {
val ch = board[r][c]
// 剪枝:Trie 中無此字元
val nextNode = node.children[ch] ?: return
// 找到完整單詞
nextNode.word?.let {
result.add(it)
nextNode.word = null // 避免重複
}
// 標記已訪問
board[r][c] = '#'
// 四個方向擴展
for ((dr, dc) in directions) {
val nr = r + dr
val nc = c + dc
if (nr in 0 until rows && nc in 0 until cols && board[nr][nc] != '#') {
dfs(nr, nc, nextNode)
}
}
// 回溯
board[r][c] = ch
}
// 從每個位置開始搜尋
for (r in 0 until rows) {
for (c in 0 until cols) {
dfs(r, c, root)
}
}
return result
}
}關鍵技巧#
傳遞 Trie 節點(而非字串)#
// 低效:每次從根節點重新搜尋前綴
fun dfs(r: Int, c: Int, currentStr: String) {
if (!trie.startsWith(currentStr)) return // O(len)
}
// 高效:直接傳遞當前 Trie 節點
fun dfs(r: Int, c: Int, node: TrieNode) {
if (ch !in node.children) return // O(1)
}傳遞節點使「是否有此前綴」的判斷從
O(len)降為O(1)。
原地標記 vs visited 陣列#
| 方法 | 優點 | 缺點 |
|---|---|---|
| 修改 board | 省空間 | 破壞原資料 |
| visited 陣列 | 不破壞資料 | 多用 O(mn) 空間 |
// 原地標記
board[r][c] = '#' // 標記
// ... DFS ...
board[r][c] = ch // 回溯時恢復方向陣列#
val directions = arrayOf(-1 to 0, 1 to 0, 0 to -1, 0 to 1)
for ((dr, dc) in directions) {
val nr = r + dr
val nc = c + dc
if (nr in 0 until rows && nc in 0 until cols) {
dfs(nr, nc, node)
}
}複雜度分析#
| 項目 | 複雜度 |
|---|---|
| 建立 Trie | O(W × L),W 為單詞數,L 為平均長度 |
| DFS 搜尋 | O(M × N × 4^L),最壞情況 |
| 空間 | O(W × L) Trie + O(L) 遞迴堆疊 |
實際執行時,Trie 的剪枝會大幅減少搜尋次數。
常見錯誤#
回溯遺漏:DFS 返回前必須恢復狀態(board 或 visited)。
// 錯誤:忘記回溯
fun dfs(r: Int, c: Int, node: TrieNode) {
board[r][c] = '#'
for (/* ... */) {
dfs(/* ... */)
}
// 缺少:board[r][c] = ch
}
// 正確
fun dfs(r: Int, c: Int, node: TrieNode) {
val temp = board[r][c]
board[r][c] = '#'
for (/* ... */) {
dfs(/* ... */)
}
board[r][c] = temp // 回溯
}搜尋結果摘要:綜合題(滑動視窗 + 索引歸併)#
把「單詞搜尋」放大一個層級,就會碰到搜尋引擎每天都要解的問題:使用者搜了幾個關鍵詞之後,結果頁下面那一小段灰字摘要(snippet)要怎麼挑?答案是——在整篇文章裡選出一段長度為 k 的視窗,使關鍵詞在這段視窗中出現的次數最多,這樣讀者一眼就能看到最相關的片段。
單一關鍵詞:滑動視窗#
只搜一個關鍵詞時,先記下它在文中出現的所有位置(一串遞增的索引),再用一個寬度為 k 的視窗在這串位置上滑動,統計每個視窗能涵蓋幾個出現點,取最多的那一段即可。視窗左右兩端隨著掃描單向移動,每個位置只進出視窗一次,整體 O(L)(L 為文章長度)。
這正是滑動視窗的標準應用:用兩個單向移動的指標維護一段連續區間,避免每次都重新統計。
多關鍵詞:索引歸併#
搜多個關鍵詞時,逐字掃整篇文章去湊「哪段同時塞得下最多關鍵詞」太慢。更聰明的做法是:
- 先各自取得每個關鍵詞在文中的位置索引(每串都已排序)。
- 把這幾串位置想成幾條已排序的串列,用類似歸併排序的多指標來回移動、合併,沿途維護「目前各關鍵詞最近一次出現的位置」。
- 在合併過程中持續追蹤一個能讓所有關鍵詞距離最近的視窗,這個最小涵蓋區間就是最佳摘要的起點。
因為只在「關鍵詞出現的位置」上跳動,而不是把文章每個字都看一遍,當關鍵詞稀疏時能省下大量時間。實作上可借助雙指標來收放涵蓋區間;若要快速回答「某區間內出現幾次」這類統計,也可搭配前綴和預處理。
這題把**位置索引(建表)+ 滑動視窗(選區間)+ 歸併(合併多條有序串)**三個技巧縫在一起,是檢驗你能否「把陌生問題拆回熟悉模型」的好練習。
進階:讓摘要更聰明#
權重、覆蓋與片語匹配
實際的搜尋引擎不會只數次數,還會做這些調整:
| 手法 | 作法 | 目的 |
|---|---|---|
| IDF 加權 | 罕見詞權重高、常見詞權重低 | 讓「真正關鍵」的詞主導摘要 |
| 鼓勵全覆蓋 | 對「所有關鍵詞都出現」的視窗額外加分 | 避免摘要只圍著單一高頻詞打轉 |
| N 元組匹配 | 連續關鍵詞構成的片語給更高優先級 | 片語命中比零散命中更貼近意圖 |
把這些評分塞進前面的視窗掃描裡:滑動過程中不再只比「次數」,而是比「加權後的分數」,其餘流程不變。