進階字串演算法
範圍 — 比較重的字串工具:後綴結構、Z-algorithm、Manacher,以及字串 DP — 這些對主字串文件來說太專門的部分。 另見:string.md — 日常字串的題目目錄與模板;string_matching_kmp_rolling_hash.md — 專講子字串搜尋;palindrome.md — 回文題家族;trie.md — 前綴結構。
LeetCode 題目清單
總覽
進階字串演算法指的是基本操作之外、比較講究的字串處理技巧。這些演算法在模式比對、回文偵測和複雜字串操作上,能給出有理論保證的最佳解。
關鍵性質
- 時間複雜度:最佳演算法通常是 O(n) 或 O(n + m)
- 空間複雜度:預處理結構需要 O(n)
- 核心想法:先把字串預處理過,之後查詢和比對就能很快
- 什麼時候用:字串模式複雜、要查很多次、需要最佳化
- 主要演算法:KMP、Manacher、Z-Algorithm、Rolling Hash、後綴陣列
共同特徵
- 預處理:先建輔助結構,換取後續操作的速度
- 辨識模式:找出重複出現的結構與週期
- 線性時間:靠一些巧妙的技巧壓到最佳複雜度
- 多次查詢:同一個字串反覆操作時特別划算
- 理論基礎:建立在字串理論與自動機之上
題型分類
分類 1:模式比對
- 描述:在文字中高效率地找出模式出現的位置
- 例題:LC 28(Find Index of First Occurrence)、LC 459(Repeated Substring Pattern)
- 模式:用 KMP、Z-Algorithm、Rolling Hash 做到 O(n + m)
分類 2:回文問題
- 描述:找出所有回文,或最長的回文子字串
- 例題:LC 5(Longest Palindromic Substring)、LC 647(Palindromic Substrings)
- 模式:用 Manacher 演算法做到 O(n) 的回文偵測
分類 3:字串週期性
- 描述:偵測重複模式與字串週期
- 例題:LC 459(Repeated Substring Pattern)、LC 1316(Distinct Echo Substrings)
- 模式:用 failure function 或 Z-array 做週期偵測
分類 4:後綴類問題
- 描述:牽涉到字串後綴與字典序的問題
- 例題:LC 1044(Longest Duplicate Substring)、LC 1316(Distinct Echo Substrings)
- 模式:後綴陣列、最長共同前綴、rolling hash
模板與演算法
模板比較表
| 演算法 | 用途 | 時間複雜度 | 空間複雜度 | 什麼時候用 |
|---|---|---|---|---|
| KMP | 模式比對 | O(n + m) | O(m) | 單一模式搜尋 → string_matching_kmp_rolling_hash.md |
| Manacher | 所有回文 | O(n) | O(n) | 回文類問題 |
| Z-Algorithm | 字串比對 | O(n) | O(n) | 模式比對的各種變形 |
| Rolling Hash | 子字串比較 | O(n) | O(1) | 多模式搜尋 → string_matching_kmp_rolling_hash.md |
| DFA/狀態機 | 格式驗證/斷詞 | O(n) | O(1) | 規則亂成一團的 if/else 解析(LC 65) |
KMP 與 Rolling Hash 的主場在字串比對那份
子字串搜尋只有一個主場:string_matching_kmp_rolling_hash.md。原本這裡的模板 1 與模板 4(KMP、rolling hash)跟它重複,所以已移除,其餘模板保留原本的編號。
| 你需要的 | 去哪裡 |
|---|---|
| KMP 失敗函數、Rabin-Karp 搜尋 | string_matching_kmp_rolling_hash.md § 1-2) Templates |
| 用雙重雜湊降低碰撞 | § 1-3) Double Hashing |
| 對長度二分搜尋 + rolling hash(LC 1044、LC 718) | § 2-3) LC 1044、§ 1-6) LC 718 |
留在這裡的是那份文件沒有涵蓋的工具:Manacher、Z-algorithm、後綴陣列與字串自動機。
模板 2:Manacher 演算法 — LC 5
def manacher_algorithm(s):
"""Manacher's algorithm to find all palindromes in O(n)"""
def preprocess(s):
"""Preprocess string to handle even-length palindromes"""
# Transform "abba" -> "^#a#b#b#a#$"
result = "^"
for c in s:
result += "#" + c
result += "#$"
return result
processed = preprocess(s)
n = len(processed)
p = [0] * n # p[i] = radius of palindrome centered at i
center = right = 0
for i in range(1, n - 1):
# Mirror of i with respect to center
mirror = 2 * center - i
# If i is within the right boundary, use previously computed values
if i < right:
p[i] = min(right - i, p[mirror])
# Try to expand palindrome centered at i
while processed[i + p[i] + 1] == processed[i - p[i] - 1]:
p[i] += 1
# If palindrome centered at i extends past right, adjust center and right
if i + p[i] > right:
center, right = i, i + p[i]
return p, processed
def manacher_applications():
"""Various applications of Manacher's algorithm"""
def longest_palindromic_substring(s):
"""Find longest palindromic substring (LC 5)"""
if not s:
return ""
p, processed = manacher_algorithm(s)
max_len = 0
center_index = 0
for i in range(1, len(p) - 1):
if p[i] > max_len:
max_len = p[i]
center_index = i
# Convert back to original string coordinates
start = (center_index - max_len) // 2
return s[start:start + max_len]
def count_palindromic_substrings(s):
"""Count all palindromic substrings (LC 647)"""
if not s:
return 0
p, processed = manacher_algorithm(s)
count = 0
for i in range(1, len(p) - 1):
# Each palindrome of radius r contributes (r+1)/2 palindromes
count += (p[i] + 1) // 2
return count
def is_palindrome_range(s, left, right):
"""Check if substring s[left:right+1] is palindrome"""
p, processed = manacher_algorithm(s)
# Convert to processed string coordinates
center = left + right + 2
radius = right - left
return p[center] >= radius
def all_palindromic_substrings(s):
"""Get all palindromic substrings with their positions"""
p, processed = manacher_algorithm(s)
palindromes = []
for i in range(1, len(p) - 1):
for r in range(p[i] + 1):
# Convert back to original coordinates
start = (i - r - 1) // 2
end = (i + r - 1) // 2
if start <= end:
palindromes.append((start, end, s[start:end + 1]))
return palindromes
return {
'longest': longest_palindromic_substring,
'count': count_palindromic_substrings,
'is_palindrome': is_palindrome_range,
'all_palindromes': all_palindromic_substrings
}
模板 3:Z-Algorithm — LC 459
def z_algorithm(s):
"""Z-algorithm: compute Z array where Z[i] = length of longest substring
starting from s[i] which is also a prefix of s"""
n = len(s)
z = [0] * n
left = right = 0
for i in range(1, n):
if i <= right:
# Use previously computed values
z[i] = min(right - i + 1, z[i - left])
# Try to extend match
while i + z[i] < n and s[z[i]] == s[i + z[i]]:
z[i] += 1
# Update window if we extended past right boundary
if i + z[i] - 1 > right:
left, right = i, i + z[i] - 1
return z
def z_algorithm_applications():
"""Applications of Z-algorithm"""
def pattern_search_z(text, pattern):
"""Pattern searching using Z-algorithm"""
combined = pattern + "$" + text
z = z_algorithm(combined)
pattern_len = len(pattern)
matches = []
for i in range(pattern_len + 1, len(combined)):
if z[i] == pattern_len:
matches.append(i - pattern_len - 1)
return matches
def find_all_occurrences(s, pattern):
"""Find all occurrences of pattern in string"""
return pattern_search_z(s, pattern)
def longest_prefix_suffix(s):
"""Find longest prefix which is also suffix"""
z = z_algorithm(s)
n = len(s)
for i in range(n - 1, 0, -1):
if z[i] == n - i:
return s[:n - i]
return ""
def period_detection(s):
"""Find the period of string using Z-algorithm"""
z = z_algorithm(s)
n = len(s)
for period in range(1, n):
if period + z[period] == n:
return period
return n # String has no period shorter than itself
return {
'search': pattern_search_z,
'find_all': find_all_occurrences,
'prefix_suffix': longest_prefix_suffix,
'period': period_detection
}
模板 5:後綴陣列建構 — LC 1044
def suffix_array_construction(s):
"""Construct suffix array using counting sort (O(n log n))"""
def counting_sort(arr, key_func, max_val):
"""Stable counting sort"""
count = [0] * (max_val + 1)
for item in arr:
count[key_func(item)] += 1
for i in range(1, len(count)):
count[i] += count[i - 1]
result = [0] * len(arr)
for i in range(len(arr) - 1, -1, -1):
key = key_func(arr[i])
count[key] -= 1
result[count[key]] = arr[i]
return result
n = len(s)
if n == 0:
return []
# Initial ranking based on first character
suffixes = list(range(n))
rank = [ord(c) for c in s]
k = 1
while k < n:
# Sort by (rank[i], rank[i + k])
def sort_key(i):
return (rank[i], rank[i + k] if i + k < n else -1)
# Find maximum rank for counting sort
max_rank = max(rank) + 1
# Sort by second key first, then by first key
suffixes = counting_sort(suffixes, lambda i: rank[i + k] if i + k < n else -1, max_rank)
suffixes = counting_sort(suffixes, lambda i: rank[i], max_rank)
# Update ranks
new_rank = [0] * n
for i in range(1, n):
if (rank[suffixes[i]], rank[suffixes[i] + k] if suffixes[i] + k < n else -1) == \
(rank[suffixes[i - 1]], rank[suffixes[i - 1] + k] if suffixes[i - 1] + k < n else -1):
new_rank[suffixes[i]] = new_rank[suffixes[i - 1]]
else:
new_rank[suffixes[i]] = i
rank = new_rank
k *= 2
return suffixes
def lcp_array(s, suffix_array):
"""Compute LCP (Longest Common Prefix) array"""
n = len(s)
if n == 0:
return []
# Inverse of suffix array
rank = [0] * n
for i in range(n):
rank[suffix_array[i]] = i
lcp = [0] * (n - 1)
h = 0
for i in range(n):
if rank[i] > 0:
j = suffix_array[rank[i] - 1]
while i + h < n and j + h < n and s[i + h] == s[j + h]:
h += 1
lcp[rank[i] - 1] = h
if h > 0:
h -= 1
return lcp
模板 6:DFA/狀態機(字串驗證) — LC 65 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
關鍵想法:當一個字串格式的規則全是「這個只能接在那個後面」這種糾纏在一起的條件時, 別再寫巢狀
if了,改寫轉移表。先把每個字元歸到少數幾個字元類別, 再讓一個state整數在手刻的 DFA 上跑。 一趟掃完、O(1) 記憶體,而且所有規則都集中在一張看得懂的表裡。什麼時候該拿出來用:格式驗證/斷詞(LC 65 Valid Number)這種題, 臨時拼湊的
if/else解法正是多數人在邊界情況上翻船的地方("."、"4e+"、"3."、".9")。 這跟 KMP 底下的自動機是同一個概念(LPS 陣列本身就是一台比對自動機)— 差別只在這裡的自動機是手寫的,不是從模式推導出來的。
做法
- 列出字元類別(這裡是
digit、sign、dot、exp)— 其他字元一律直接拒絕。 - 列出狀態,每個狀態代表一種「我目前合法看過的東西」。
- 填表;
-1代表死狀態。 - 標出接受狀態;字串合法的充要條件是它停在其中之一。
// java
// LC 65 - Valid Number
// IDEA: hand-built DFA — classify char, follow transition table, accept only in a terminal state
// time = O(N), space = O(1) (table is a fixed 8x4 constant)
public class ValidNumber {
// rows = states, cols = char class {0:digit, 1:sign, 2:dot, 3:exp}, -1 = dead
private static final int[][] DFA = {
// d s . e
{ 2, 1, 3, -1 }, // 0 start
{ 2, -1, 3, -1 }, // 1 after leading sign
{ 2, -1, 4, 5 }, // 2 integer digits (ACCEPT)
{ 4, -1, -1, -1 }, // 3 dot with no digit yet
{ 4, -1, -1, 5 }, // 4 fraction digits (ACCEPT)
{ 7, 6, -1, -1 }, // 5 just saw 'e' / 'E'
{ 7, -1, -1, -1 }, // 6 sign after 'e'
{ 7, -1, -1, -1 }, // 7 exponent digits (ACCEPT)
};
public boolean isNumber(String s) {
int state = 0;
for (char c : s.toCharArray()) {
int cls;
if (c >= '0' && c <= '9') cls = 0;
else if (c == '+' || c == '-') cls = 1;
else if (c == '.') cls = 2;
else if (c == 'e' || c == 'E') cls = 3;
else return false; // illegal character
state = DFA[state][cls];
if (state == -1) return false; // illegal transition
}
return state == 2 || state == 4 || state == 7; // accepting states
}
}
# python
# LC 65 - Valid Number
# IDEA: hand-built DFA — classify char, follow transition table, accept only in a terminal state
# time = O(N), space = O(1)
DFA = [
# d s . e
[ 2, 1, 3, -1], # 0 start
[ 2, -1, 3, -1], # 1 after leading sign
[ 2, -1, 4, 5], # 2 integer digits (ACCEPT)
[ 4, -1, -1, -1], # 3 dot with no digit yet
[ 4, -1, -1, 5], # 4 fraction digits (ACCEPT)
[ 7, 6, -1, -1], # 5 just saw 'e' / 'E'
[ 7, -1, -1, -1], # 6 sign after 'e'
[ 7, -1, -1, -1], # 7 exponent digits (ACCEPT)
]
def isNumber(s):
state = 0
for c in s:
if c.isdigit():
cls = 0
elif c in "+-":
cls = 1
elif c == ".":
cls = 2
elif c in "eE":
cls = 3
else:
return False # illegal character
state = DFA[state][cls]
if state == -1: # illegal transition
return False
return state in (2, 4, 7) # accepting states
為什麼是狀態 2/4/7、而不是別的 — 一個合法數字只有三種結尾方式:
"53" 0 -d-> 2 accept (integer)
"53.5" 0 -d-> 2 -.-> 4 -d-> 4 accept (fraction)
"3." 0 -d-> 2 -.-> 4 accept ("digits then dot" is legal)
".9" 0 -.-> 3 -d-> 4 accept (dot first needs a digit after)
"." 0 -.-> 3 REJECT (state 3 is not accepting)
"53e-9" 0 -d-> 2 -e-> 5 -s-> 6 -d-> 7 accept
"4e+" 0 -d-> 2 -e-> 5 -s-> 6 REJECT (exponent needs a digit)
"99e2.5" ... state 7 -.-> -1 REJECT (no dot in the exponent)
面試小技巧:先在白板上把狀態圖畫出來,再抄成表。這張表讓解法可以自己被檢查 — 面試官讀一格就能驗證一條規則。
依模式分類的題目
模式比對類
| 題目 | LC # | 最佳演算法 | 時間複雜度 | 難度 |
|---|---|---|---|---|
| Find Index of First Occurrence | 28 | KMP | O(n + m) | Medium |
| Repeated Substring Pattern | 459 | KMP/Z-Algorithm | O(n) | Easy |
| Shortest Palindrome | 214 | KMP + 反轉 | O(n) | Hard |
回文類
| 題目 | LC # | 最佳演算法 | 時間複雜度 | 難度 |
|---|---|---|---|---|
| Longest Palindromic Substring | 5 | Manacher | O(n) | Medium |
| Palindromic Substrings | 647 | Manacher | O(n) | Medium |
| Shortest Palindrome | 214 | Manacher/KMP | O(n) | Hard |
進階字串類
| 題目 | LC # | 最佳演算法 | 時間複雜度 | 難度 |
|---|---|---|---|---|
| Longest Duplicate Substring | 1044 | Rolling Hash + 二分搜尋 | O(n log n) | Hard |
| Distinct Echo Substrings | 1316 | Rolling Hash | O(n²) | Hard |
| Find All Anagrams | 438 | Rolling Hash | O(n) | Medium |
LC 範例
這份文件以前解過的題目,現在各有主場;上面的技巧章節只保留真正需要它們的題目(LC 459 用 Z-algorithm、LC 1044 用後綴陣列、LC 65 用 DFA)。
| 題目 | 主場 |
|---|---|
| LC 28 Find the Index of the First Occurrence in a String(KMP) | string_matching_kmp_rolling_hash.md § 2-1),Python 與 Java |
| LC 686 Repeated String Match(先界定 haystack 長度,再搜尋) | string_matching_kmp_rolling_hash.md § 1-4) |
| LC 5 Longest Palindromic Substring(從中心擴展) | 2_pointers.md § Template 3;Manacher 是上面的模板 2 |
| LC 1044 Longest Duplicate Substring(二分搜尋 + Rabin-Karp) | string_matching_kmp_rolling_hash.md § 2-3),Python 與 Java |
| LC 718 Maximum Length of Repeated Subarray(同一招,用在兩個整數陣列之間) | string_matching_kmp_rolling_hash.md § 1-6) |
進階技巧
多模式比對
class AhoCorasick:
"""Aho-Corasick algorithm for multiple pattern matching"""
def __init__(self):
self.trie = {}
self.failure = {}
self.output = {}
def add_pattern(self, pattern, pattern_id):
"""Add pattern to trie"""
node = self.trie
for char in pattern:
if char not in node:
node[char] = {}
node = node[char]
node['$'] = pattern_id
def build_failure_function(self):
"""Build failure function for Aho-Corasick"""
from collections import deque
queue = deque()
self.failure[id(self.trie)] = self.trie
# Initialize first level
for char, child in self.trie.items():
if char != '$':
self.failure[id(child)] = self.trie
queue.append((child, char))
# Build failure links using BFS
while queue:
current, char = queue.popleft()
current_id = id(current)
for next_char, next_node in current.items():
if next_char == '$':
continue
queue.append((next_node, next_char))
# Find failure link
failure_node = self.failure[current_id]
while failure_node != self.trie and next_char not in failure_node:
failure_node = self.failure[id(failure_node)]
if next_char in failure_node:
self.failure[id(next_node)] = failure_node[next_char]
else:
self.failure[id(next_node)] = self.trie
def search_all(self, text):
"""Find all pattern occurrences in text"""
current = self.trie
results = []
for i, char in enumerate(text):
# Follow failure links
while current != self.trie and char not in current:
current = self.failure[id(current)]
if char in current:
current = current[char]
# Check for matches
temp = current
while temp != self.trie:
if '$' in temp:
pattern_id = temp['$']
results.append((i, pattern_id))
temp = self.failure[id(temp)]
return results
字串雜湊的最佳化
雙重雜湊(兩個底數、兩個模數,比較這一對值)只寫在一個地方:string_matching_kmp_rolling_hash.md § 1-3)。
效能最佳化建議
演算法選擇指南
def choose_string_algorithm(problem_characteristics):
"""Guide for choosing optimal string algorithm"""
if problem_characteristics['type'] == 'pattern_matching':
if problem_characteristics['single_pattern']:
return "KMP or Z-Algorithm"
else:
return "Aho-Corasick"
elif problem_characteristics['type'] == 'palindromes':
if problem_characteristics['all_palindromes']:
return "Manacher's Algorithm"
else:
return "Expand around centers"
elif problem_characteristics['type'] == 'substring_queries':
if problem_characteristics['many_queries']:
return "Rolling Hash or Suffix Array"
else:
return "Simple comparison"
elif problem_characteristics['type'] == 'string_matching':
if problem_characteristics['approximate']:
return "Edit distance DP"
else:
return "KMP or Rolling Hash"
這個進階演算法真的划算嗎? 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
面試官其實很少想看到 Manacher 或後綴陣列。他們要的是直觀的解法、寫對寫乾淨, 再加一句「如果限制真的逼我,我會這樣把它壓下來」。先給預期的答案,再把進階版當 follow-up 提出來。
| 題目 | 面試官預期的解法 | 進階選項 | 值得換嗎? |
|---|---|---|---|
| LC 28 Find First Occurrence | 滑動比對,O(N·M) | KMP,O(N+M) | 值得 — 這題就是在考 KMP;用暴力解會被讀成「不會」 |
| LC 5 Longest Palindromic Substring | 中心擴展,O(N²) | Manacher,O(N) | 不值得 — 只有被要求 O(N) 才寫 Manacher。中心擴展就是被接受的答案(見 palindrome.md) |
| LC 647 Palindromic Substrings | 中心擴展,O(N²) | Manacher,O(N) | 不值得 — 理由同上 |
| LC 686 Repeated String Match | b in a*k 加上界限論證 |
KMP 搜尋 | 很少需要 — 重點是界限的證明,不是比對器 |
| LC 718 Max Length of Repeated Subarray | LCS 型 DP,O(N·M) | 二分搜尋 + rolling hash,O((N+M)·log N) | 只在 follow-up 時 — 先 DP,再提這個 |
| LC 1044 Longest Duplicate Substring | (沒有簡單解 — O(N²) 會 TLE) | 二分搜尋 + Rabin-Karp | 值得 — 進階解是唯一過得了的解 |
| LC 1316 Distinct Echo Substrings | — | Rolling hash | 值得 — 雜湊本來就是這題預設的工具 |
| LC 214 Shortest Palindrome | 反轉 + 前綴檢查,O(N²) | 在 s + '#' + rev(s) 上跑 KMP 的 prefix function |
被要求 O(N) 時值得 — 見 palindrome.md |
| LC 10 / LC 44 Regex & Wildcard Matching | 二維 DP — 不需要字串自動機 | 自己建 NFA/DFA | 不值得 — DP 就是答案;見 dp_pattern.md、recursion_to_dp.md |
| LC 65 Valid Number | 東拼西湊的 flag 大亂鬥 | DFA 表(模板 6) | 值得 — 表格版更短,而且可以證明它涵蓋了所有邊界情況 |
經驗法則:在下列情況才動用進階結構 — (a) 暴力解的複雜度在題目給的限制下真的會 TLE、 (b) 同一個字串要被查很多次,預處理攤提得下來,或 © 進階版其實更容易寫對(LC 65 的 DFA、LC 28 的 KMP)。 其他時候它就是個包袱 — 更多程式碼、更多 bug、更少時間講話。
鄰近的模式各自住在哪裡
下面這些屬於其他 cheatsheet,在這裡只做交叉引用,讓這頁專心處理後綴結構、自動機、雜湊和線性時間比對:
- LC 336 Palindrome Pairs — 反轉單字的 trie/雜湊表拆分 →
trie.md、palindrome.md - LC 208 / 211 / 212 / 472 / 648(Trie、Add & Search Words、Word Search II、Concatenated Words、Replace Words)→
trie.md - LC 10 / 44(Regular Expression & Wildcard Matching) — 兩個字串的布林 DP 表格 →
dp_pattern.md、recursion_to_dp.md - LC 3 / 438(Longest Substring Without Repeating Characters、Find All Anagrams) — 視窗 + 計數,不需要雜湊 →
sliding_window.md、string.md - LC 8 / 12 / 13 / 43 / 68 / 273 / 443(解析、格式化、大數字串) — 一般的字串操作 →
string.md - LC 20 / 224 / 227 / 394 / 1249(括號與運算式解析) — 堆疊機,不是字串演算法 →
stack.md、string.md
總結與速查
演算法複雜度比較
| 演算法 | 時間複雜度 | 空間複雜度 | 最適合的場景 |
|---|---|---|---|
| KMP | O(n + m) | O(m) | 單一模式搜尋 |
| Manacher | O(n) | O(n) | 所有回文 |
| Z-Algorithm | O(n) | O(n) | 模式比對的各種變形 |
| Rolling Hash | O(n) | O(1) 攤提 | 多次查詢 |
| 後綴陣列 | O(n log n) | O(n) | 複雜的字串操作 |
常見錯誤與建議
🚫 常見錯誤:
- 沒處理空字串或空模式
- 索引計算的差一錯誤
- 只用單一雜湊,碰撞問題沒解決
- failure function 算錯
✅ 最佳實務:
- 一定要先驗證輸入字串
- 用雙雜湊來抵抗碰撞
- 預期會多次查詢時就先預處理
- 依題目限制挑演算法
- 用邊界情況和長字串測過
面試建議
- 先辨認核心需求:是模式比對、回文,還是多次查詢
- 挑對演算法:依時間/空間限制決定
- 處理邊界情況:空字串、單一字元
- 考慮預處理:查詢次數多的時候
- 實作要小心:索引管理是關鍵
- 測試要徹底:各種長度與模式的字串都試過
這份進階字串演算法 cheatsheet 收錄了字串處理與模式比對上最講究的那些技巧。