字串演算法與操作
範圍 — 日常字串目錄 — 字元層級的雙指標掃描、頻率與 anagram 簽章、run-length 分組、切詞、解析與原地改寫 — 至於題解倉庫、語言層級的字串 API、回文、子字串搜尋與雙序列 DP,則各自有自己的一張表。 另見 — 從本檔拆出去的:string_examples.md — LC 題解倉庫;string_operations.md — Python/Java 字串 API、
StringBuilder、字元運算,以及大小寫/Unicode 的坑。 鄰近的表:palindrome.md — 回文家族,從中心擴張一路到 Manacher;string_matching_kmp_rolling_hash.md — 子字串搜尋(KMP、Rabin-Karp);advanced_string_algorithms.md — Z-algorithm、後綴陣列、DFA 驗證;dp_string.md — 雙序列的網格家族;sliding_window.md — 由條件驅動的字元視窗;hashing.md — 頻率表與正規化 key;trie.md — 前綴結構。
LeetCode 題目清單
總覽
字串演算法涵蓋處理、搜尋與操作文字資料的各種技巧。它們是文字處理、模式比對、解析,以及大量面試題的基礎。
關鍵性質
- 不可變性:在很多語言裡字串是不可變的(Python、Java)
- 時間複雜度:走訪通常是 O(n),土法煉鋼的比對是 O(n²)
- 空間複雜度:大部分轉換是 O(n)
- 核心技巧:雙指標、滑動視窗、雜湊、模式比對
- 什麼時候用:文字處理、模式比對、解析、驗證
常見操作
- 搜尋:找子字串、模式比對
- 操作:反轉、旋轉、轉換
- 驗證:回文、anagram、格式合法性
- 解析:切割、切詞、擷取
- 比較:字典序、編輯距離
題型分類
模式 1:雙指標
- 說明:從字串兩端處理,或用快慢指標
- 例題:LC 125、344、345、680、917
- 模式:頭尾指標往中間靠攏
模式 2:滑動視窗
- 說明:找出具備特定性質的子字串
- 例題:LC 3、76、159、340、424、567
- 模式:擴張視窗,條件滿足時收縮
模式 3:字串比對
- 說明:在文字中找模式(KMP、Rabin-Karp)
- 例題:LC 28、214、459、686、796
- 模式:預處理模式,或用滾動雜湊
模式 4:回文
- 說明:檢查或尋找回文子字串
- 例題:LC 5、125、131、409、516、647
- 模式:從中心擴張,或用 DP
模式 5:字串轉換
- 說明:在不同字串格式之間轉換
- 例題:LC 6、8、12、13、38、443
- 模式:依規則解析再重組
模式 6:字串 DP
- 說明:在字串上做動態規劃
- 例題:LC 10、44、72、115、583、1143
- 模式:用二維 DP 表比較字串
模式 7:漸進式前綴驗證
- 說明:驗證一個單字能不能從它的前綴一個字元一個字元蓋出來
- 例題:LC 720
- 模式:先排序 + 用 HashSet 記錄可蓋出的單字 + 檢查直接前綴
- 關鍵技巧:只需要檢查
word.substring(0, word.length() - 1)在不在集合裡
模式 8:Run-Length 分組(連續相同字元的組) Priority 4 of 5 — High value — a gap here costs you rounds
- 說明:把字串壓成連續相同字元的組,再在組長度陣列上解題
- 例題:LC 696、38、443、1446、485、1004、1759
- 模式:
s→[len(g1), len(g2), ...]→ 從相鄰組長度算出答案 - 關鍵技巧:LC 696 中,每一對相鄰的組貢獻
min(g[i-1], g[i])個合法子字串
模板與演算法
模板比較表
| 模板 | 適用情境 | 複雜度 | 程式碼在哪 |
|---|---|---|---|
| 雙指標掃描/反轉 | 從兩端比較或交換 | O(n) | Template 1 |
| 字元頻率/anagram 簽章 | 「字元一樣嗎?」、「依字元分組」 | O(n) | Template 2 |
| Run-Length 分組 | 答案取決於相同字元的連續段 | O(n) | Template 3 |
| 解析後重建 | atoi、羅馬數字、格式規則 |
O(n) | Template 4 |
| 貪婪打包 + 分配 | 換行斷句、欄位排版 | O(total chars) | Template 5 |
| 切割 + 深度/token 堆疊 | 路徑、縮排樹、log | O(n) | Template 6 |
標記後重建 char[] |
依索引刪除字元 | O(n) | Template 7 |
| 依最後出現位置切分 | 最多能切成幾段獨立的區塊 | O(n) | Template 8 |
| 子字串搜尋(KMP、Rabin-Karp、Z) | 精確模式搜尋 | O(n+m) | string_matching_kmp_rolling_hash.md |
| 字元上的滑動視窗 | 具備某性質的最長/最短子字串 | O(n) | sliding_window.md |
| 回文(中心擴張、Manacher) | 回文子字串 | O(n²) / O(n) | palindrome.md |
| 雙序列 DP | 編輯距離、LCS | O(mn) | dp_string.md |
| Trie | 多個單字之間的前綴比對 | O(m) | trie.md |
Template 1:雙指標掃描與原地反轉 — LC 125, LC 344 Priority 5 of 5 — Must know — expect it in almost every loop
一般性的雙指標模式(快慢指標、陣列上的左右指標)見 2_pointers.md;可刪一個字元的變形(LC 680)和中心擴張見 palindrome.md。留在這裡的是字元層級的邊掃邊換。
# Python - Two pointers for palindrome
def isPalindrome(s):
left, right = 0, len(s) - 1
while left < right:
# Skip non-alphanumeric
while left < right and not s[left].isalnum():
left += 1
while left < right and not s[right].isalnum():
right -= 1
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
# Reverse string/array in-place
def reverseString(s):
left, right = 0, len(s) - 1
while left < right:
s[left], s[right] = s[right], s[left]
left += 1
right -= 1
return s
// Java - Two pointers
public boolean isPalindrome(String s) {
int left = 0, right = s.length() - 1;
while (left < right) {
while (left < right && !Character.isLetterOrDigit(s.charAt(left))) {
left++;
}
while (left < right && !Character.isLetterOrDigit(s.charAt(right))) {
right--;
}
if (Character.toLowerCase(s.charAt(left)) !=
Character.toLowerCase(s.charAt(right))) {
return false;
}
left++;
right--;
}
return true;
}
Template 2:字元頻率與 anagram 簽章 — LC 242, LC 438, LC 49 Priority 5 of 5 — Must know — expect it in almost every loop
模式:兩個字串互為 anagram 的充要條件是它們的字元多重集合相等。這一個簽章想法會以三種面貌出現 — 直接比較多重集合、在固定大小的視窗上滾動它,或把它當成 hash key 來分組。
關鍵想法:簽章就是 Counter(s)(或 int[26]);把字元排序後的 tuple 是同一個簽章的可雜湊形式。
假設已經
from collections import Counter。細節在別處:hashing.md 擁有 LC 242 和 LC 49 以及它們的 Java 實作,而 sliding_window.md 擁有 LC 438 / LC 567 的固定視窗機制。
# LC 242 Valid Anagram
def isAnagram(s, t):
return Counter(s) == Counter(t)
# LC 438 Find All Anagrams in String (Sliding Window)
def findAnagrams(s, p):
need = Counter(p)
window = Counter()
result = []
for i, c in enumerate(s):
window[c] += 1
if i >= len(p):
left = s[i - len(p)]
window[left] -= 1
if window[left] == 0: del window[left]
if window == need:
result.append(i - len(p) + 1)
return result
# LC 49 Group Anagrams (sorted key)
def groupAnagrams(strs):
from collections import defaultdict
groups = defaultdict(list)
for s in strs:
groups[tuple(sorted(s))].append(s)
return list(groups.values())
容易踩到的坑
- ⚠️
Counter(a) == Counter(b)是 O(n),但會配置記憶體;用int[26]的差值計數才是 O(1) 空間的寫法。 - ⚠️ 滾動視窗時要把值為零的項刪掉 —
Counter會留著0,然後就再也比不相等了。 - ⚠️
sorted(s)是list,不可雜湊;key 必須是tuple(sorted(s))或"".join(sorted(s))。
Template 3:Run-Length 分組(連續相同字元的組) — LC 696 Priority 4 of 5 — High value — a gap here costs you rounds
核心想法
不要去列舉所有子字串(O(n²)),而是把字串壓成連續的組,然後在(小得多的)組長度陣列上解題。
s = "001110011"
groups:
00 -> 2
111 -> 3
00 -> 2
11 -> 2
group lengths: [2, 3, 2, 2]
對 LC 696(Count Binary Substrings) 來說,每個合法子字串一定長成 0…01…1 或 1…10…0,
也就是說它必須恰好跨過兩個相鄰組之間的一道邊界。
長度分別是 a 和 b 的兩組之間的邊界,剛好產生 min(a, b) 個合法子字串:
adjacent pairs:
min(2, 3) = 2 # "01", "0011"
min(3, 2) = 2 # "10", "1100"
min(2, 2) = 2 # "01", "0011"
--------------------
total = 6
為什麼是
min(a, b)? 你可以挑一個配對數量k = 1, 2, ..., min(a, b), 然後取邊界左邊k個字元 + 右邊k個字元。任何k > min(a, b)都會溢進第三組, 破壞「連續分組」這個規則。
模板 — 建出組陣列(O(n) 時間、O(n) 空間)
# python
# IDEA: compress s into consecutive-group lengths, then work on that array
def group_lengths(s):
groups = [1]
for i in range(1, len(s)):
# NOTE !!! boundary -> start a new group
if s[i-1] != s[i]:
groups.append(1)
# same char -> extend current group
else:
groups[-1] += 1
return groups
# LC 696 - Count Binary Substrings
# time = O(n), space = O(n)
def countBinarySubstrings(s):
groups = group_lengths(s)
ans = 0
for i in range(1, len(groups)):
# NOTE !!! each adjacent pair contributes min(prev, cur)
ans += min(groups[i-1], groups[i])
return ans
# one-liner with itertools.groupby
import itertools
def countBinarySubstrings_v2(s):
groups = [len(list(v)) for _, v in itertools.groupby(s)]
return sum(min(a, b) for a, b in zip(groups, groups[1:]))
模板 — 串流/O(1) 空間(只需要 prev + cur 兩組)
# python
# IDEA: we never need the whole group array, only the 2 latest groups
# time = O(n), space = O(1)
def countBinarySubstrings(s):
ans, prev, cur = 0, 0, 1
for i in range(1, len(s)):
if s[i-1] != s[i]:
ans += min(prev, cur) # close off the boundary
prev, cur = cur, 1 # NOTE !!! cur becomes prev, restart cur
else:
cur += 1
# NOTE !!! don't forget the LAST pair (loop never closes it)
return ans + min(prev, cur)
// java
// LC 696 - Count Binary Substrings
// time = O(n), space = O(1)
public int countBinarySubstrings(String s) {
int ans = 0, prev = 0, cur = 1;
for (int i = 1; i < s.length(); i++) {
if (s.charAt(i) != s.charAt(i - 1)) {
ans += Math.min(prev, cur);
prev = cur;
cur = 1;
} else {
cur++;
}
}
/** NOTE !!! flush the final group pair after the loop */
return ans + Math.min(prev, cur);
}
容易踩到的坑
- ⚠️ 記得沖掉最後一組。 迴圈只有在看到邊界時才會結算一組,所以最後一組永遠沒被配對到 —
迴圈結束後一定要再加一次
min(prev, cur)。 - ⚠️ 長度為 1 的組也是合法的組 — 不要把
len == 1濾掉。 - ⚠️ 把
prev初始化成0(不是 1),這樣第一道邊界貢獻的是min(0, cur) = 0。 - ⚠️ 迴圈從
i = 1開始,比較s[i]和s[i-1],避免索引越界。
相似題目(Run-Length 分組)
| 題目 | LC # | 拿組長度來做什麼 | Difficulty |
|---|---|---|---|
| Count Binary Substrings | 696 | 對每一對相鄰組加總 min(g[i-1], g[i]) |
Easy |
| Count and Say | 38 | 每組輸出 count + char,迭代 n 次 |
Medium |
| String Compression | 443 | 原地寫入 char + count |
Medium |
| Consecutive Characters | 1446 | max(組長度) |
Easy |
| Max Consecutive Ones | 485 | 1 那些組的最大長度 |
Easy |
| Max Consecutive Ones III | 1004 | 在組上做滑動視窗(最多翻 k 個零) | Medium |
| Max Consecutive Ones II | 487 | 跨過單一個 0 組,把兩個 1 組合併 |
Medium |
| Longest Repeating Char Replacement | 424 | 視窗 + 最高頻率(分組想法的推廣) | Medium |
| Positions of Large Groups | 830 | 回報長度 ≥ 3 的組 | Easy |
| Find Longest Awesome Substring | 1542 | 位元遮罩奇偶性(分組的變形) | Hard |
| Merge Strings Alternately | 1768 | 在連續段上做雙指標 | Easy |
Template 4:字串轉換 — 解析後重建 — LC 8, LC 12 Priority 4 of 5 — High value — a gap here costs you rounds
# Python - String to integer (atoi)
def myAtoi(s):
s = s.strip()
if not s:
return 0
sign = 1
idx = 0
if s[0] in ['+', '-']:
sign = -1 if s[0] == '-' else 1
idx = 1
num = 0
while idx < len(s) and s[idx].isdigit():
num = num * 10 + int(s[idx])
idx += 1
num *= sign
# Handle overflow
INT_MAX = 2**31 - 1
INT_MIN = -2**31
if num > INT_MAX:
return INT_MAX
if num < INT_MIN:
return INT_MIN
return num
# Integer to Roman
def intToRoman(num):
values = [1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1]
symbols = ["M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I"]
result = []
for i, val in enumerate(values):
count = num // val
if count:
result.append(symbols[i] * count)
num -= val * count
return ''.join(result)
Template 5:貪婪打包整行 + 分配空白(文字換行) — LC 68 Priority 5 of 5 — Must know — expect it in almost every loop
模式:先貪婪地在一行塞進最多能放的單字,再把剩下的空白攤到各個間隙上。 所有換行斷句/欄位排版的題目都是這兩個階段。
關鍵想法:打包時,words[i..j] 需要的寬度是 sum(len) + (間隙數量) — 間隙數量剛好是
j - i,所以塞不塞得下的檢查是 lineLen + len(words[j]) + (j - i) <= maxWidth。分配時,
base = spaces / slots,而且前 spaces % slots 個間隙各多拿一個空白(左重規則)。
// java
// LC 68 - Text Justification
// time = O(total chars), space = O(total chars) for the output
// IDEA: 2 phases per line -> (1) greedy pack words, (2) distribute leftover spaces
public List<String> fullJustify(String[] words, int maxWidth) {
List<String> res = new ArrayList<>();
int i = 0, n = words.length;
while (i < n) {
/** NOTE !!! (1) GREEDY PACK: widest [i, j) that fits with >= 1 space per gap
* (j - i) is the number of gaps if we also take words[j] */
int j = i, lineLen = 0;
while (j < n && lineLen + words[j].length() + (j - i) <= maxWidth) {
lineLen += words[j].length();
j++;
}
int slots = j - i - 1; // gaps between the packed words
int spaces = maxWidth - lineLen; // spaces to spread over those gaps
StringBuilder sb = new StringBuilder();
if (j == n || slots == 0) {
// (2a) LAST LINE or SINGLE WORD -> left justify, pad the right
for (int k = i; k < j; k++) {
if (k > i) sb.append(' ');
sb.append(words[k]);
}
while (sb.length() < maxWidth) sb.append(' ');
} else {
/** NOTE !!! (2b) FULL JUSTIFY
* base = spaces / slots, and the FIRST (spaces % slots) gaps get +1 */
int base = spaces / slots, extra = spaces % slots;
for (int k = i; k < j; k++) {
sb.append(words[k]);
if (k < j - 1) {
int pad = base + (k - i < extra ? 1 : 0);
for (int p = 0; p < pad; p++) sb.append(' ');
}
}
}
res.add(sb.toString());
i = j; // NOTE !!! next line starts where this one stopped
}
return res;
}
# python
# LC 68 - Text Justification
# time = O(total chars), space = O(total chars) for the output
# IDEA: 2 phases per line -> (1) greedy pack words, (2) distribute leftover spaces
def fullJustify(words, maxWidth):
res, i, n = [], 0, len(words)
while i < n:
# (1) GREEDY PACK: widest [i, j) that fits with >= 1 space per gap
j, lineLen = i, 0
while j < n and lineLen + len(words[j]) + (j - i) <= maxWidth:
lineLen += len(words[j])
j += 1
slots = j - i - 1 # gaps between the packed words
spaces = maxWidth - lineLen # spaces to spread over those gaps
if j == n or slots == 0:
# (2a) LAST LINE or SINGLE WORD -> left justify, pad the right
line = " ".join(words[i:j])
line += " " * (maxWidth - len(line))
else:
# (2b) FULL JUSTIFY: first (spaces % slots) gaps get one extra space
base, extra = divmod(spaces, slots)
parts = []
for k in range(i, j - 1):
parts.append(words[k])
parts.append(" " * (base + (1 if k - i < extra else 0)))
parts.append(words[j - 1])
line = "".join(parts)
res.append(line)
i = j
return res
容易踩到的坑
- ⚠️ 最後一行是靠左對齊,不是左右對齊 — 只有一個單字的那一行也一樣。
- ⚠️ 剩下的空白要往左堆:當
k < spaces % slots時,第k個間隙拿base + 1。 - ⚠️ 每一行輸出都必須剛好
maxWidth個字元 — 靠左對齊的情況記得補空白。 - ⚠️
slots == 0時,如果忘了單一單字那個分支就會除以零。
同一套「先把片段收進 list、最後 join 一次」的紀律,套到三位數一組上就是 LC 273 Integer to English Words — 見 string_examples.md。
Template 6:解析結構化文字(分隔符切割 + 深度/堆疊) — LC 388 Priority 4 of 5 — High value — a gap here costs you rounds
模式:輸入是一個序列化後的結構(路徑、log、縮排樹)。用分隔符切開,然後維護一個 **堆疊(或是深度 → 前綴長度的對應表)**來描述目前的上下文,而不是回頭重掃字串。
關鍵想法:永遠不要搬子字串 — 搬的是長度/token。depthLen[d] = 深度 d 的路徑前綴長度,
所以深度 d 的檔案在 O(1) 內就能算出 depthLen[d] + len(name)。
// java
// LC 388 - Longest Absolute File Path
// time = O(n), space = O(max depth)
// IDEA: split on '\n'; leading '\t' count = depth; depthLen[d] = prefix length at depth d
public int lengthLongestPath(String input) {
int best = 0;
Map<Integer, Integer> depthLen = new HashMap<>();
depthLen.put(0, 0); // root has empty prefix
for (String line : input.split("\n")) {
/** NOTE !!! depth == number of leading '\t' (tabs), NOT the indent width */
int depth = 0;
while (depth < line.length() && line.charAt(depth) == '\t') depth++;
String name = line.substring(depth);
if (name.indexOf('.') >= 0) {
// a FILE: it is a leaf -> only measure, never push
best = Math.max(best, depthLen.get(depth) + name.length());
} else {
// a DIRECTORY: children live at depth+1, +1 for the '/' separator
depthLen.put(depth + 1, depthLen.get(depth) + name.length() + 1);
}
}
return best;
}
# python
# LC 388 - Longest Absolute File Path
# time = O(n), space = O(max depth)
# IDEA: split on '\n'; leading '\t' count = depth; depth_len[d] = prefix length at depth d
def lengthLongestPath(inp):
best = 0
depth_len = {0: 0} # root has empty prefix
for line in inp.split("\n"):
name = line.lstrip("\t")
depth = len(line) - len(name) # NOTE !!! depth = number of leading tabs
if "." in name:
best = max(best, depth_len[depth] + len(name)) # file = leaf
else:
depth_len[depth + 1] = depth_len[depth] + len(name) + 1 # +1 for '/'
return best
容易踩到的坑
- ⚠️
'\t'是一個字元 — 不要當成 4 個空白的縮排。 - ⚠️ 沒有檔案時要回傳
0("a"→0),而不是最長的目錄路徑。 - ⚠️ 每次都覆寫
depthLen[depth+1]是對的:只有當前這條分支有意義。 - ⚠️ 每個目錄要
+1是因為那個'/'分隔符;檔案本身後面不接斜線。
變形 6.1:Token 堆疊 — LC 71 Simplify Path
轉折:一樣是切割後用堆疊的形狀,但堆疊裡放的是 token,而且 .. 是彈出而不是推入。
// java
// LC 71 - Simplify Path
// time = O(n), space = O(n)
// IDEA: split on '/', ignore "" and ".", ".." pops, everything else pushes
public String simplifyPath(String path) {
Deque<String> stack = new ArrayDeque<>();
for (String tok : path.split("/")) {
if (tok.isEmpty() || tok.equals(".")) continue; // "//" and "/./" are no-ops
if (tok.equals("..")) {
if (!stack.isEmpty()) stack.pollLast(); // NOTE !!! popping empty root is a no-op
} else {
stack.offerLast(tok);
}
}
StringBuilder sb = new StringBuilder();
for (String d : stack) sb.append('/').append(d);
return sb.length() == 0 ? "/" : sb.toString();
}
# python
# LC 71 - Simplify Path
# time = O(n), space = O(n)
# IDEA: split on '/', ignore "" and ".", ".." pops, everything else pushes
def simplifyPath(path):
stack = []
for tok in path.split("/"):
if tok in ("", "."):
continue
if tok == "..":
if stack:
stack.pop()
else:
stack.append(tok)
return "/" + "/".join(stack)
- ⚠️
"..."/"....."是合法的目錄名稱 — 只有剛好等於".."才彈出。 - ⚠️ 結果一定以
/開頭,而且絕不以/結尾(純根目錄"/"除外)。
Template 7:原地 char 陣列 — 先標記再重建 — LC 1249 Priority 5 of 5 — Must know — expect it in almost every loop
模式:當一題「刪掉某些字元」需要的是那些違規者的索引時,就轉成 char[],
第一趟用一個哨兵值標記要刪的位置,第二趟再重建。
這樣可以避免反覆 substring/字串串接造成的 O(n²)。
關鍵想法:堆疊裡放的是索引,不是字元,所以掃完之後還留在堆疊上的東西, 剛好就是還要刪掉的位置。
// java
// LC 1249 - Minimum Remove to Make Valid Parentheses
// time = O(n), space = O(n)
// IDEA: stack of '(' INDICES; unmatched ')' marked on sight, unmatched '(' left on the stack
public String minRemoveToMakeValid(String s) {
char[] arr = s.toCharArray();
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < arr.length; i++) {
if (arr[i] == '(') {
stack.push(i); // NOTE !!! push the INDEX
} else if (arr[i] == ')') {
if (stack.isEmpty()) arr[i] = '*'; // ')' with no partner -> mark for deletion
else stack.pop(); // matched pair
}
// letters are untouched
}
/** NOTE !!! whatever is still on the stack are unmatched '(' positions */
while (!stack.isEmpty()) arr[stack.pop()] = '*';
StringBuilder sb = new StringBuilder();
for (char c : arr) if (c != '*') sb.append(c);
return sb.toString();
}
# python
# LC 1249 - Minimum Remove to Make Valid Parentheses
# time = O(n), space = O(n)
# IDEA: stack of '(' INDICES; unmatched ')' blanked on sight, unmatched '(' left on the stack
def minRemoveToMakeValid(s):
arr = list(s)
stack = []
for i, c in enumerate(arr):
if c == "(":
stack.append(i) # NOTE !!! push the INDEX
elif c == ")":
if stack:
stack.pop() # matched pair
else:
arr[i] = "" # ')' with no partner -> blank it out
for i in stack: # NOTE !!! leftovers = unmatched '(' positions
arr[i] = ""
return "".join(arr)
容易踩到的坑
- ⚠️ 哨兵值要挑一個輸入裡不可能出現的(這裡是
'*';Python 用空字串也行, 因為"".join會直接跳過它)。 - ⚠️ 別忘了第二次沖洗 — 堆疊上還坐著那些沒配對到的
'('。 - ⚠️ 在迴圈裡用
substring刪字元,會讓它變成 O(n²),而且會把後面每個索引都往前推。
相關:LC 20 Valid Parentheses 是同一套掃描,但只需要一個布林值(掃完堆疊是不是空的);
LC 32 Longest Valid Parentheses 則沿用這個索引堆疊來量 i - stack.peek()。
Template 8:依最後出現位置貪婪切分 — LC 763 Priority 4 of 5 — High value — a gap here costs you rounds
模式:把字串切成最多段,同時讓某個性質保持在局部(例如每個字母只出現在一段裡)。 先算出每個字元的最後索引,再一邊掃一邊把當前切點往右撐。
關鍵想法:當前這段不可能在 max(last[c])(對目前看過的所有 c)之前結束。
當 i == end,裡面沒有任何東西還能碰到更右邊 → 就在這裡切。
// java
// LC 763 - Partition Labels
// time = O(n), space = O(1) (26 letters)
// IDEA: last[c] = final index of c; extend `end` while scanning, cut when i reaches it
public List<Integer> partitionLabels(String s) {
int[] last = new int[26];
for (int i = 0; i < s.length(); i++) last[s.charAt(i) - 'a'] = i; // last occurrence
List<Integer> res = new ArrayList<>();
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
/** NOTE !!! the chunk must stretch to cover this char's last occurrence */
end = Math.max(end, last[s.charAt(i) - 'a']);
if (i == end) { // nothing inside reaches past i -> safe to cut
res.add(end - start + 1);
start = i + 1;
}
}
return res;
}
# python
# LC 763 - Partition Labels
# time = O(n), space = O(1) (26 letters)
# IDEA: last[c] = final index of c; extend `end` while scanning, cut when i reaches it
def partitionLabels(s):
last = {c: i for i, c in enumerate(s)} # dict comp keeps the LAST index
res, start, end = [], 0, 0
for i, c in enumerate(s):
end = max(end, last[c]) # stretch the chunk
if i == end: # safe cut point
res.append(end - start + 1)
start = i + 1
return res
容易踩到的坑
- ⚠️
last要在獨立的第一趟建好;你不可能一邊切一邊知道未來。 - ⚠️ 切的條件是
i == end,不是i == last[s[i]](後面的字元可能已經把end往右推了)。 - ⚠️ 這其實是換皮的貪婪區間合併:把
[first[c], last[c]]這些區間合併起來。
重量級演算法各自住在哪 Priority 4 of 5 — High value — a gap here costs you rounds
有四個字串家族大到足以擁有自己的一張表。本表不會把它們重新推導一遍; 挑到對的那一列,就直接去那張表。
子字串搜尋 — 該用哪個演算法?
| 情境 | 用什麼 | 表 |
|---|---|---|
只搜一次,n·m 在限制內過得去 |
內建的 — s.find(p) / s.indexOf(p) |
— |
| 單一模式、輸入是刻意刁難的、需要 O(n+m) | KMP failure function | string_matching_kmp_rolling_hash.md |
多個模式,或「有沒有任何長度為 k 的視窗重複出現?」 |
滾動雜湊(Rabin-Karp),用雙雜湊壓掉碰撞 | string_matching_kmp_rolling_hash.md |
| 最長的「同時是前綴也是後綴」;週期性 | KMP failure 陣列或 Z-array | advanced_string_algorithms.md |
| 對答案長度二分搜尋 + 對視窗做雜湊 | 二分搜尋上面疊滾動雜湊(LC 1044、718) | string_matching_kmp_rolling_hash.md |
| 重複子字串、後綴排名 | 後綴陣列/自動機 | advanced_string_algorithms.md |
| 驗證一個數值/格式化的 token | 手刻 DFA(LC 65) | advanced_string_algorithms.md |
另外三個家族
| 家族 | 訊號 | 表 |
|---|---|---|
| 回文 | 「回文子字串/子序列」、「把它變成回文」、可刪一個字元的檢查 | palindrome.md — 中心擴張、區間 DP、Manacher(LC 5)、KMP 前綴技巧(LC 214)、回文配對(LC 336) |
| 字元視窗 | 「最長/最短的子字串,滿足…」、「最多 k 種相異字元」、「包含 t 的所有字元」 | sliding_window.md — LC 3、76、159、340、424、438、567、1004 |
| 雙序列 DP | 比較/對齊兩個字串、「最少操作次數」、「最長共同…」 | dp_string.md 和 dp.md — LC 72、97、115、583、712、1143 |
字串 API 必備
完整的 API 導覽 — 切片、split/join、StringBuilder、字元運算、大小寫與 Unicode 陷阱 —
已經搬到 string_operations.md。這裡留的是不用翻開那張表也該記得的子集。
| 任務 | Python | Java |
|---|---|---|
| 字串 → 字元 | list(s) |
s.toCharArray() |
| 字元 → 字串 | "".join(chars) |
new String(chars) |
| 反轉 | s[::-1] |
new StringBuilder(s).reverse().toString() |
| 取子字串 | s[i:j] |
s.substring(i, j) |
| 依空白/分隔符切割 | s.split() / s.split(",") |
s.trim().split("\\s+") / s.split(",") |
| 用分隔符接起來 | ",".join(parts) |
String.join(",", parts) |
| 逐步蓋出字串 | 先 parts.append(x) 再 "".join(parts) |
先 StringBuilder.append(x) 再 .toString() |
| 字元 → 碼/碼 → 字元 | ord(c) / chr(n) |
(int) c / (char) n |
| 對應到 26 個字母的索引 | ord(c) - ord('a') |
c - 'a' |
| 是不是字母/數字/英數 | c.isalpha() / c.isdigit() / c.isalnum() |
Character.isLetter(c) / isDigit(c) / isLetterOrDigit(c) |
| 轉小寫 | c.lower() |
Character.toLowerCase(c) |
| 頻率表 | Counter(s) |
int[26] 或 HashMap<Character,Integer> |
- ⚠️ 絕對不要在迴圈裡串接字串 — 兩種語言的
s += x都是 O(n²)。收集起來,最後 join。 - ⚠️ Java 的
String.split吃的是正規表示式:split(".")會在每個字元處切開;要用split("\\.")。 - ⚠️ Java 的
s.split(",")會丟掉結尾的空欄位;把 limit 傳-1才會留著。
總結與速查
題目 → 模板決策表 Priority 5 of 5 — Must know — expect it in almost every loop
看訊號那一欄;那是題目敘述裡決定該用哪個做法的關鍵字。
| 題目裡的訊號 | 做法 | 在哪 | 題目 |
|---|---|---|---|
| 從兩端比較或交換;原地反轉 | 雙指標,while left < right |
Template 1 | 125, 344, 345, 541, 917, 925, 151 |
| 「anagram」、「是不是某個排列」、「把相同字母的分成一組」 | 字元頻率簽章 | Template 2 | 242, 438, 49, 567, 451 |
| 答案取決於相同字元的連續段 | 組長度陣列(或串流式的 prev/cur) |
Template 3 | 696, 38, 443, 485, 487, 830, 1004, 1446, 1768, 809 |
| 在不同格式之間轉換 — 數字、羅馬數字、Z 字形 | 依明確規則解析,再重建 | Template 4 | 6, 8, 12, 13, 273, 482, 468 |
| 固定行寬、補空白、欄位排版 | 貪婪打包,再把剩餘空白往左堆分配 | Template 5 | 68, 273 |
| 輸入是序列化後的結構 — 路徑、log、縮排樹 | 依分隔符切割 + 深度表或 token 堆疊 | Template 6 | 388, 71, 937, 1071 |
| 「移除最少的字元,使得…」 | char[] + 索引堆疊,先標記再重建 |
Template 7 | 1249, 20, 32, 921 |
| 「最多能切成幾段」,同時某性質保持在局部 | 掃過最後出現位置,i == end 就切 |
Template 8 | 763, 56 |
| 從字典裡一個字元一個字元蓋出單字 | 排序 + 集合,只檢查直接前綴 | string_examples.md | 720, 648, 745 |
| 「在那段文字裡有效率地找到這個模式」 | KMP、滾動雜湊或 Z-array | string_matching_kmp_rolling_hash.md | 28, 459, 686, 796, 1044, 1392 |
| 「最長/最短子字串,滿足…」 | 滑動視窗 | sliding_window.md | 3, 76, 159, 340, 424, 1004 |
| 任何和回文有關的 | 中心擴張、區間 DP、Manacher、KMP 前綴 | palindrome.md | 5, 9, 125, 131, 132, 214, 409, 516, 647, 680, 1216, 1312 |
| 比較/對齊兩個字串 | 雙序列網格 DP | dp_string.md | 10, 44, 72, 97, 115, 583, 712, 1143 |
| 很多單字共用前綴 | 字典樹(Trie) | trie.md | 208, 211, 212, 648, 745 |
| 「一個是不是另一個的旋轉」 | 先比長度,再看 goal in (s + s) |
string_examples.md | 796 |
| 自訂字母表/比較器的排序 | 用 int[26] 建排名表,比較相鄰的一對 |
string_examples.md | 953, 269, 937 |
| 列舉切割位置並驗證每一段 | 巢狀列舉 + 每段各自的合法性規則 | string_examples.md | 816, 93, 282, 468 |
複雜度速查
| 操作 | 時間 | 空間 | 備註 |
|---|---|---|---|
| 雙指標 | O(n) | O(1) | 掃一趟 |
| 滑動視窗 | O(n) | O(k) | k = 視窗內元素數 |
| KMP 搜尋 | O(n+m) | O(m) | m = 模式長度 |
| Rabin-Karp | O(n) 平均 | O(1) | 有雜湊碰撞 |
| 中心擴張 | O(n²) | O(1) | 所有回文 |
| 編輯距離 | O(mn) | O(mn) | 可壓到 O(n) |
| Trie 操作 | O(m) | O(ALPHABET_SIZE * m) | m = 單字長度 |
常見技巧
ASCII 大小寫差值技巧(|char1 - char2| == 32)
一個很好用的技巧,用來偵測同一個字母但大小寫不同(例如 'a' 對 'A'):
Math.abs('a' - 'A') == 32 // true
Math.abs('z' - 'Z') == 32 // true
Math.abs('a' - 'B') == 33 // false (different letters)
為什麼是 32? 在 ASCII 裡,小寫字母從 97('a')開始,大寫從 65('A')開始。同一個字母的差值永遠剛好是 32。
經典應用:LC 1544 - Make The String Great(堆疊)
一直移除「同字母但大小寫不同」的相鄰配對,直到沒有這種配對為止。
// Java - Stack approach using |char1 - char2| == 32
public String makeGood(String s) {
Stack<Character> stack = new Stack<>();
for (char curr : s.toCharArray()) {
if (!stack.isEmpty()) {
char prev = stack.peek();
/** NOTE !!
* core idea:
* The "Great" Condition: A pair is bad if |char1 - char2| == 32.
* Math.abs('a' - 'A') == 32.
* This checks if they are the same letter but different case.
*/
if (Math.abs(curr - prev) == 32) {
stack.pop(); // They cancel out — remove the pair
continue; // Move to next character
}
}
stack.push(curr);
}
StringBuilder sb = new StringBuilder();
for (char c : stack) {
sb.append(c);
}
return sb.toString();
}
這段 Java 掃描只用
StringBuilder的那個寫法已經被拿掉了 — 堆疊語意完全一樣,沒有新的東西。
# Python equivalent
def makeGood(s: str) -> str:
stack = []
for c in s:
if stack and abs(ord(stack[-1]) - ord(c)) == 32:
stack.pop() # Cancel the pair
else:
stack.append(c)
return ''.join(stack)
關鍵洞見: 這個技巧可以推廣到任何「相鄰配對互相抵消、而配對由 ASCII 距離定義」的題目。搭配堆疊就是 O(N) 時間、O(N) 空間。
| 檢查 | 意思 | 例子 |
|---|---|---|
Math.abs(a - b) == 32 |
同一個字母,大小寫不同 | 'a' 和 'A' |
Character.toLowerCase(a) == Character.toLowerCase(b) |
同一個字母(不管大小寫) | 'a' 和 'A' |
a == b |
完全相同的字元 | 'a' 和 'a' |
常見錯誤與面試建議
🚫 常見錯誤:
- 在迴圈裡串接字串(O(n²))
- 取子字串時差一位
- 沒處理空字串
- 想去修改不可變的字串
- 字元編碼問題
✅ 最佳實務:
- 用 StringBuilder/list + join
- 問清楚字元集(ASCII/Unicode)
- 想清楚有沒有分大小寫
- 用特殊字元測一下
- 轉數字時處理溢位
🎤 面試建議:
-
問清楚需求
- 字元集是什麼?
- 分大小寫嗎?
- 可以原地做嗎?
- 要處理特殊字元嗎?
-
從簡單的開始
- 先寫暴力解
- 一步一步優化
- 說清楚取捨
-
常見的追問
- 處理 Unicode
- 優化空間
- 串流處理
- 平行處理
- 要講出來的邊界情況:空字串、單一字元、所有字元都相同、非英數字元、大小寫混雜、轉數字時溢位。
其餘的內容在哪
| 表 | 放了什麼 |
|---|---|
| string_examples.md | LC 題解倉庫 — 本表模板還沒解掉的每一題,每題每種語言一份正典解。 |
| string_operations.md | 語言層級的 API:Python 切片與各種方法、Java 的 String/StringBuilder、字元分類,以及蓋字串的效能規則。 |