字串演算法與操作
範圍 — 日常字串目錄 — 字元層級的雙指標掃描、頻率與 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、格式合法性
- 解析:切割、切詞、擷取
- 比較:字典序、編輯距離
辨識
從題目敘述找訊號;限制條件的提示來自 complexity_cheatsheet.md 的那把尺。導向其他表的那幾列是正確答案,不是缺口。
| 題目敘述中的訊號 | 限制條件提示 | 該用什麼 | 不是這個,如果 |
|---|---|---|---|
| 「只看英數字元,是不是回文」、「原地反轉」 | n ≤ 2·10⁵,要求 O(1) 額外空間 | Template 1 雙指標掃描 | 允許刪一個字元,或要找最長回文子字串 → palindrome.md |
| 「anagram」、「是…的排列」、「把字母相同的單字分在一組」 | 小寫英文字母 → int[26] |
Template 2 頻率簽章 | 「最多 k 種不同字元的最長子字串」→ 可變視窗,sliding_window.md |
| 「連續的 0 和 1」、「相同字元的組」、「壓縮」 | n ≤ 10⁵ → 不能 O(n²) 列舉子字串 | Template 3 run-length 分組 | 最多翻 k 個零 → 直接在原字串上開視窗(LC 1004) |
| 「把字串轉成整數」、羅馬數字、一條條由上往下讀的規則 | 題目點名 32 位元溢位 | Template 4 分階段解析 | 帶指數的完整數字文法(LC 65)→ DFA,advanced_string_algorithms.md |
| 「每一行剛好 maxWidth 個字元」、「左右對齊」 | 總字元數 ≤ 10⁴ | Template 5 貪婪打包 + 分配 | 要最小化各行參差的代價 → DP,不是貪婪 |
「絕對路徑」、輸入裡有 \n 和 \t、「簡化路徑」 |
Template 6 切割 + 深度表/token 堆疊 | 有優先順序的算式 → stack_expression_parsing.md | |
| 「刪掉最少的字元讓結果合法」 | n ≤ 10⁵ → 不能反覆 substring |
Template 7 先標記再重建 | 只問「合不合法?」→ LC 20,普通堆疊就夠 |
| 「切成盡可能多段」、「每個字母最多只出現在一段」 | Template 8 最後出現位置掃描 | 每段直接以區間給出 → 合併區間(LC 56) | |
| 「用清單裡其他單字一次加一個字元蓋出每個單字」 | 排序 + 集合,只檢查直接前綴 → string_examples.md(LC 720) | 多個單字之間的前綴查詢 → trie.md | |
| 「在文字裡找模式」、「重複的子字串」 | n·m 太大 → O(n + m) | string_matching_kmp_rolling_hash.md | 只搜一次且 n·m 放得下 → 內建的 find / indexOf |
| 「滿足…的最長/最短子字串」 | n ≤ 10⁵ | sliding_window.md | |
| 比較或對齊兩個字串、「最少操作次數」、「最長公共」 | m·n ≤ 10⁶ | dp_string.md |
模板與演算法
模板比較表
| 模板 | 適用情境 | 複雜度 | 程式碼在哪 |
|---|---|---|---|
| 雙指標掃描/反轉 | 從兩端比較或交換 | 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 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
一般性的雙指標模式(快慢指標、陣列上的左右指標)見 2_pointers.md;可刪一個字元的變形(LC 680)和中心擴張見 palindrome.md。留在這裡的是字元層級的邊掃邊換。
辨識 — 「只考慮英數字元,是不是回文」、「原地反轉字串/母音/只反轉字母」。 n ≤ 2·10⁵ 且要求 O(1) 額外空間 → 不要先做一份清理過的副本,直接從兩端走原字串。
關鍵 — s[left..right] 以外的每個字元都已經和它的鏡像配對過,所以視窗內第一個不相等
就證明「不是回文」,而 left >= right 就證明它是。
# python
# LC 125 - Valid Palindrome
# IDEA: left/right walk inward, each skipping non-alphanumerics; compare case-folded
# time = O(n), space = O(1)
def isPalindrome(s):
left, right = 0, len(s) - 1
while left < right:
while left < right and not s[left].isalnum(): # skip punctuation on the left
left += 1
while left < right and not s[right].isalnum(): # ... and on the right
right -= 1
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
# LC 344 - Reverse String: the same two pointers, swapping instead of comparing
def reverseString(s):
left, right = 0, len(s) - 1
while left < right:
s[left], s[right] = s[right], s[left]
left += 1
right -= 1
// java
// LC 125 - Valid Palindrome
// IDEA: left/right walk inward, each skipping non-alphanumerics; compare case-folded
// time = O(n), space = O(1)
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;
}
複雜度
- 時間 O(n):兩個跳過迴圈看起來是巢狀的,但
left只往右、right只往左, 三個迴圈加起來最多走 n 步。 - 空間 O(1):直接讀
s,沒有cleaned副本。
陷阱
- 用
isalpha取代isalnum來跳過:"0P"會把0跳掉,兩指標相遇,回傳True。 數字也要算。 - 跳過迴圈拿掉
left < right:".,"會讓left跑出字串尾端,丟出IndexError。 cleaned == cleaned[::-1]是對的,但要 O(n) 空間;面試官的追問就是「不用副本再做一次」。
不適用時 — 「最多刪一個字元」(LC 680)或「最長回文子字串」(LC 5): 它們會分岔或從中心擴張 → palindrome.md。
練習 — LC 125 ⭐ · LC 344 · LC 345 (只交換母音) · LC 917 (只交換字母) · LC 151 (先反轉整個字串,再反轉每個單字)
Template 2:字元頻率與 anagram 簽章 — LC 242 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
細節在別處:hashing.md 擁有 LC 242 和 LC 49 的題解,而 sliding_window.md 擁有 LC 438 / LC 567 的固定視窗機制。
辨識 — 「anagram」、「是…的排列」、「字元相同」、「把用相同字母的單字分成一組」。
限制條件寫「小寫英文字母」→ 用 int[26] 取代 map。
關鍵 — 兩個字串互為 anagram 的充要條件是它們的字元多重集合相等。這個多重集合就是簽章: 直接比較它(LC 242)、在固定長度的視窗上滾動它(LC 438),或用它的可雜湊寫法 — 排序後的字元 — 當成 key(LC 49)。
# python
# LC 242 - Valid Anagram
# IDEA: count s up and t down in one int[26]; anagrams iff every count returns to 0
# time = O(n), space = O(1) (26 letters)
from collections import Counter, defaultdict
def isAnagram(s, t):
if len(s) != len(t):
return False
count = [0] * 26
for a, b in zip(s, t):
count[ord(a) - ord('a')] += 1
count[ord(b) - ord('a')] -= 1
for c in count:
if c != 0:
return False
return True
# LC 49 - Group Anagrams: the same signature, as a hash key
# time = O(n * k log k), k = longest word
def groupAnagrams(strs):
groups = defaultdict(list)
for word in strs:
key = "".join(sorted(word)) # a list is unhashable; the joined string is not
groups[key].append(word)
return list(groups.values())
# LC 438 - Find All Anagrams in a String: the same signature, rolled across a window of len(p)
# time = O(n), space = O(1) (26 letters)
def findAnagrams(s, p):
need = Counter(p)
window = Counter()
result = []
for i, c in enumerate(s):
window[c] += 1
if i >= len(p): # the window is one too long: drop its first char
out = s[i - len(p)]
window[out] -= 1
if window[out] == 0:
del window[out]
if window == need:
result.append(i - len(p) + 1)
return result
// java
// LC 242 - Valid Anagram
// IDEA: count s up and t down in one int[26]; anagrams iff every count returns to 0
// time = O(n), space = O(1)
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) {
return false;
}
int[] count = new int[26];
for (int i = 0; i < s.length(); i++) {
count[s.charAt(i) - 'a']++;
count[t.charAt(i) - 'a']--;
}
for (int c : count) {
if (c != 0) {
return false;
}
}
return true;
}
// LC 49 - Group Anagrams: the sorted characters are the key
public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> groups = new HashMap<>();
for (String word : strs) {
char[] chars = word.toCharArray();
Arrays.sort(chars);
String key = new String(chars);
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(word);
}
return new ArrayList<>(groups.values());
}
複雜度
- 時間 O(n)(LC 242):兩個字串一起走一趟,再檢查固定的 26 格。LC 49 是
O(n · k log k):由每個單字的
sorted(word)決定;改用 26 格計數的 tuple 當 key 就是 O(n · k)。 - 空間 O(1)(LC 242):不管 n 多大,
count都只有 26 格。LC 49 每個單字存一次,O(n · k)。
陷阱
- 省略長度檢查:
isAnagram("a", "ab")—zip在較短的字串就停了,回傳True。 groups[sorted(word)]會丟出TypeError: unhashable type: 'list'。要用"".join(sorted(word))或tuple(...)。- LC 438 的滾動視窗:計數歸零就刪掉。Python 3.10 之前,帶著
0項的Counter和沒有那一項的 不相等,所以"cbaebabacd"、"abc"會漏掉位置 6 的答案(被移出的e留成e: 0); 刪掉在每個版本都正確。 int[26]假設是小寫字母。追問「Unicode 呢?」要的是HashMap。
不適用時 — 「最多 k 種不同字元的最長子字串」:視窗長度不固定, 所以是可變視窗 → sliding_window.md。
練習 — LC 242 ⭐ · LC 383 (單向:雜誌要涵蓋信件) · LC 49 ⭐ · LC 438 (滾動簽章) · LC 567
Template 3:Run-Length 分組(連續相同字元的組) — LC 696 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「連續的 0 和 1」、「相同字元的組」、「壓縮字串」、「最長的連續段」。 n ≤ 10⁵ 排除了 O(n²) 列舉子字串;先壓縮,再在組長度陣列上做。
關鍵 — LC 696 的每個合法子字串都長成 0…01…1 或 1…10…0,所以它恰好跨過一道
相鄰兩組之間的邊界。長度分別為 a 和 b 的兩組之間的邊界剛好產生 min(a, b) 個:
對 k = 1 … min(a, b) 各取邊界兩側 k 個字元;再大的 k 就會溢進第三組。
# python
# LC 696 - Count Binary Substrings
# IDEA: compress s into consecutive-group lengths; each adjacent pair adds min(prev, cur)
# time = O(n), space = O(n)
def group_lengths(s):
groups = [1]
for i in range(1, len(s)):
if s[i - 1] != s[i]: # boundary: start a new group
groups.append(1)
else: # same char: extend the current group
groups[-1] += 1
return groups
def countBinarySubstrings(s):
groups = group_lengths(s)
ans = 0
for i in range(1, len(groups)):
ans += min(groups[i - 1], groups[i])
return ans
串流寫法只保留最後兩組,這就是追問 O(1) 空間時的答案:
# python
# LC 696 - Count Binary Substrings, streaming
# IDEA: never build the group array; keep prev (last finished group) and cur (open group)
# 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 # the open group becomes prev; restart cur
else:
cur += 1
return ans + min(prev, cur) # the loop never closes the last pair
// java
// LC 696 - Count Binary Substrings
// IDEA: streaming run-length groups; each boundary adds min(prev, cur)
// 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++;
}
}
return ans + Math.min(prev, cur); // flush the final pair
}
複雜度
- 時間 O(n):一趟
for i in range(1, len(s));每個字元不是延長一組就是開一組新的。 - 空間 O(n) 給
groups(每段一格,最壞情況"0101…"是 n);串流寫法是 O(1), 只留兩個整數。
試跑
s = "001110011"
groups: 00 | 111 | 00 | 11 -> [2, 3, 2, 2]
min(2, 3) = 2 "01", "0011"
min(3, 2) = 2 "10", "1100"
min(2, 2) = 2 "01", "0011"
total = 6
陷阱
- 忘了迴圈後的收尾:
"01"會回傳 0 而不是 1,因為迴圈要看到下一道邊界才結算一對。 - 用
prev = 1起頭:"01"會回傳 2。用prev = 0,第一道邊界才會如預期加上min(0, cur) = 0。 - 把長度為 1 的組濾掉:
"010"有兩個合法子字串,都是由長度 1 的組構成的。
不適用時 — 「最多翻 k 個零,求最長的連續 1」(LC 1004):答案橫跨好幾組, 所以要在原字串上滑視窗 → sliding_window.md。
練習 — LC 696 ⭐ · LC 1446 (最大組長度) · LC 830 (回報長度 ≥ 3 的組) · LC 443
(原地寫入 char + count) · LC 38 (每組輸出 count + char,做 n 次)
| 題目 | 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 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「把字串轉成 32 位元整數」、「整數轉羅馬數字/羅馬數字轉整數」、「zigzag」、 「把一串字串編碼成一個字串」:題目敘述是一條條由上往下讀的規則清單,並且點名了溢位的行為。
關鍵 — 一個索引依序走過固定的階段(空白 → 最多一個正負號 → 數字);每個階段只吃自己的字元, 所以第一個沒有階段接受的字元就結束解析。數值是在建構的同時就被夾住,所以永遠不會超出 32 位元範圍。
# python
# LC 8 - String to Integer (atoi)
# IDEA: one index, three phases (leading spaces, one sign, digits); clamp as each digit lands
# time = O(n), space = O(1)
def myAtoi(s):
INT_MAX, INT_MIN = 2**31 - 1, -2**31
i, n = 0, len(s)
while i < n and s[i] == ' ': # phase 1: leading spaces only
i += 1
sign = 1
if i < n and s[i] in '+-': # phase 2: at most one sign
if s[i] == '-':
sign = -1
i += 1
num = 0
while i < n and '0' <= s[i] <= '9': # phase 3: digits until the first non-digit
num = num * 10 + (ord(s[i]) - ord('0'))
if sign * num > INT_MAX:
return INT_MAX
if sign * num < INT_MIN:
return INT_MIN
i += 1
return sign * num
# LC 12 - Integer to Roman: the rebuild direction — greedy over the value/symbol table,
# where the subtractive pairs (900 "CM", 4 "IV") are just more rows
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 value, symbol in zip(values, symbols):
while num >= value:
result.append(symbol)
num -= value
return "".join(result)
// java
// LC 8 - String to Integer (atoi)
// IDEA: one index, three phases; test for overflow BEFORE num * 10 + digit can wrap
// time = O(n), space = O(1)
public int myAtoi(String s) {
int i = 0, n = s.length();
while (i < n && s.charAt(i) == ' ') {
i++;
}
int sign = 1;
if (i < n && (s.charAt(i) == '+' || s.charAt(i) == '-')) {
if (s.charAt(i) == '-') {
sign = -1;
}
i++;
}
int num = 0;
while (i < n && s.charAt(i) >= '0' && s.charAt(i) <= '9') { // ASCII digits only
int digit = s.charAt(i) - '0';
if (num > (Integer.MAX_VALUE - digit) / 10) { // num * 10 + digit would overflow
return sign == 1 ? Integer.MAX_VALUE : Integer.MIN_VALUE;
}
num = num * 10 + digit;
i++;
}
return sign * num;
}
複雜度
- 時間 O(n):索引
i在三個階段迴圈裡只會往前走。 - 空間 O(1):數字是原地累加的,沒有切任何片段。(LC 12 的輸出也是 O(1): num ≤ 3999 時最多 15 個符號。)
陷阱
"+-12"回傳 0:吃掉一個正負號之後,-是非數字,結束第三階段。接受第二個正負號是經典 bug。- Java 的
num * 10 + digit會默默溢位:"2147483648"會變成負數,除非溢位檢查跑在乘法之前。"-91283472332"必須夾到-2147483648。 s.strip()也會吃掉尾端空白和"\t";題目說的只有開頭的' '。在 LeetCode 的測資上無害, 但讀規則時是錯的。str.isdigit()對"²"也是True;要和'0'…'9'比較。
不適用時 — 帶正負號、小數點和指數的完整數字文法(LC 65 Valid Number): 階段多到不適合手寫 → advanced_string_algorithms.md 裡的 DFA。
練習 — LC 8 ⭐ · LC 13 (羅馬轉整數:較小的符號在較大的前面就相減) · LC 12 · LC 6 (zigzag:一個來回彈跳的列索引) · LC 271 (長度前綴編碼:先解析長度, 再讀那麼多個字元)
Template 5:貪婪打包整行 + 分配空白(文字換行) — LC 68 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「每一行剛好 maxWidth 個字元」、「左右對齊」、「多出來的空白盡量平均分配,
左邊的間隙拿比較多」。所有換行斷句或欄位排版的題目都是兩個階段:打包一行,再分配它剩下的空白。
關鍵 — 打包時,words[i..j] 需要的寬度是 sum(len) + (j - i):間隙數量剛好是 j - i,
所以塞不塞得下的檢查是 lineLen + len(words[j]) + (j - i) <= maxWidth。分配時,每個間隙拿
base = spaces // slots,而前 spaces % slots 個間隙各多拿一個(往左堆)。
# python
# LC 68 - Text Justification
# IDEA: 2 phases per line -> (1) greedy pack words, (2) distribute leftover spaces
# time = O(total chars), space = O(total chars) for the output
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])
gap = base + (1 if k - i < extra else 0)
parts.append(" " * gap)
parts.append(words[j - 1])
line = "".join(parts)
res.append(line)
i = j # the next line starts where this one stopped
return res
// java
// LC 68 - Text Justification
// IDEA: 2 phases per line -> (1) greedy pack words, (2) distribute leftover spaces
// time = O(total chars), space = O(total chars) for the output
public List<String> fullJustify(String[] words, int maxWidth) {
List<String> res = new ArrayList<>();
int i = 0, n = words.length;
while (i < n) {
// (1) GREEDY PACK: (j - i) is the number of gaps if words[j] is also taken
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 {
// (2b) FULL JUSTIFY: base = spaces / slots, 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; // the next line starts where this one stopped
}
return res;
}
複雜度
- 時間 O(total chars):每個單字只被打包一次(內層
while j不會回頭,因為i = j跳過了它), 每個輸出字元也只寫一次。 - 空間 O(total chars):就是輸出本身;工作狀態只有幾個整數。
陷阱
- 把最後一行也左右對齊:
["What","must","be","acknowledgment","shall","be"]、寬度 16, 最後一行必須是靠左對齊再補空白的"shall be ",不是"shall be"。 - 只有一個單字的那一行:
"acknowledgment"單獨一行時slots == 0,除非那個分支放在前面, 否則spaces / slots會除以零。 - 往右堆:
"example of text"(寬度 16)是 3 個空白分給 2 個間隙;多的那一個給第一個間隙。 - 靠左對齊的那一行沒有補到
maxWidth,過不了長度剛好的檢查。
不適用時 — 「最小化各行的總參差度」:對平方空白的代價來說貪婪打包不是最佳 → 在斷行點上做 DP。
練習 — LC 68 ⭐ · LC 1592 (只有一行:平均分配空白,多的放到最後) · LC 273 (把片段收進 list、最後 join 一次 — 改成三位數一組;見 string_examples.md)
Template 6:解析結構化文字(分隔符切割 + 深度/堆疊) — LC 388 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 輸入是一個序列化後的結構:「絕對路徑」、輸入裡有 \n 和 \t、
「簡化 Unix 路徑」、帶欄位的 log 行。用分隔符切開並維護目前的上下文,永遠不要回頭重掃字串。
關鍵 — 搬的是長度或 token,永遠不是子字串。depth_len[d] 是目前這條分支上深度 d
的路徑前綴長度,所以深度 d 的檔案在 O(1) 內就是 depth_len[d] + len(name),
而覆寫 depth_len[d + 1] 就丟掉了已經結束的分支。
# python
# LC 388 - Longest Absolute File Path
# IDEA: split on '\n'; leading '\t' count = depth; depth_len[d] = prefix length at depth d
# time = O(n), space = O(max depth)
def lengthLongestPath(inp):
best = 0
depth_len = {0: 0} # root has an empty prefix
for line in inp.split("\n"):
name = line.lstrip("\t")
depth = len(line) - len(name) # depth = number of leading tabs
if "." in name:
best = max(best, depth_len[depth] + len(name)) # a file is a leaf
else:
depth_len[depth + 1] = depth_len[depth] + len(name) + 1 # +1 for '/'
return best
// java
// LC 388 - Longest Absolute File Path
// IDEA: split on '\n'; leading '\t' count = depth; depthLen[d] = prefix length at depth d
// time = O(n), space = O(max depth)
public int lengthLongestPath(String input) {
int best = 0;
Map<Integer, Integer> depthLen = new HashMap<>();
depthLen.put(0, 0); // root has an empty prefix
for (String line : input.split("\n")) {
int depth = 0; // number of leading '\t', not the indent width
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;
}
複雜度
- 時間 O(n):
split和數 tab 都只碰每個字元一次;每次查表都是 O(1)。 - 空間 O(max depth) 給
depth_len,外加split回傳的 O(n) 串列。
陷阱
- 算縮排寬度:
'\t'是一個字元,所以"dir\n\tfile.txt"的深度是 1,不是 4。 - 完全沒有檔案:
"a"回傳 0,不是最長的目錄路徑。 - 檔案後面多接一個
/:+ 1只屬於目錄;"dir\n\tfile.ext"是 12(dir/file.ext),不是 13。
練習 — LC 388 ⭐ · LC 1598 (只需要深度計數) · LC 71 (token 堆疊,見下方) · LC 937 (每筆 log 切成 id + 內容,再排序)
變形 6.1:Token 堆疊 — LC 71 Simplify Path
關鍵 — 一樣是切割後維護上下文的形狀,但上下文是一個 token 堆疊:處理完每個 token 後, 堆疊剛好就是到目前為止的正規路徑,所以
..彈出,其他名稱一律推入。
歸約到:Template 6 — 深度表換成 token 堆疊。
# python
# LC 71 - Simplify Path
# IDEA: split on '/', ignore "" and ".", ".." pops, everything else pushes
# time = O(n), space = O(n)
def simplifyPath(path):
stack = []
for tok in path.split("/"):
if tok == "" or tok == ".": # "//" and "/./" are no-ops
continue
if tok == "..":
if stack: # ".." at the root stays at the root
stack.pop()
else:
stack.append(tok)
return "/" + "/".join(stack)
// java
// LC 71 - Simplify Path
// IDEA: split on '/', ignore "" and ".", ".." pops, everything else pushes
// time = O(n), space = O(n)
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(); // ".." at the root stays at the root
}
} else {
stack.offerLast(tok);
}
}
StringBuilder sb = new StringBuilder();
for (String dir : stack) {
sb.append('/').append(dir);
}
return sb.length() == 0 ? "/" : sb.toString();
}
- ⚠️
"..."/"....."是合法的目錄名稱 — 只有剛好等於".."才彈出。 - ⚠️ 結果一定以
/開頭,而且絕不以/結尾(純根目錄"/"除外)。
Template 7:原地 char 陣列 — 先標記再重建 — LC 1249 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「刪掉最少數量的字元,讓結果合法」、「刪掉這些位置上的字元」。違規者是用索引找到的,
而 n ≤ 10⁵ 排除了在迴圈裡用 substring 刪除。
關鍵 — 堆疊裡放的是 '( 的索引,不是字元,所以掃完之後它剛好就是沒配對到的 '(' 位置;
每個沒配對到的 ')' 在出現當下就被標記了。兩趟:先標記,再一次重建。
# python
# LC 1249 - Minimum Remove to Make Valid Parentheses
# IDEA: stack of '(' INDICES; unmatched ')' blanked on sight, unmatched '(' left on the stack
# time = O(n), space = O(n)
def minRemoveToMakeValid(s):
arr = list(s)
stack = []
for i, c in enumerate(arr):
if c == "(":
stack.append(i) # push the index, not the char
elif c == ")":
if stack:
stack.pop() # matched pair
else:
arr[i] = "" # ')' with no partner -> blank it out
for i in stack: # leftovers = unmatched '(' positions
arr[i] = ""
return "".join(arr)
// java
// LC 1249 - Minimum Remove to Make Valid Parentheses
// IDEA: stack of '(' INDICES; unmatched ')' marked on sight, unmatched '(' left on the stack
// time = O(n), space = O(n)
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); // push the index, not the char
} else if (arr[i] == ')') {
if (stack.isEmpty()) {
arr[i] = '*'; // ')' with no partner -> mark for deletion
} else {
stack.pop(); // matched pair
}
}
}
while (!stack.isEmpty()) { // leftovers = unmatched '(' positions
arr[stack.pop()] = '*';
}
StringBuilder sb = new StringBuilder();
for (char c : arr) {
if (c != '*') {
sb.append(c);
}
}
return sb.toString();
}
複雜度
- 時間 O(n):一趟標記、一次清空堆疊(最多 n 個索引)、一趟重建。
- 空間 O(n):
char[]副本和索引堆疊;"(((("時堆疊會存下每一個索引。
陷阱
- 省略第二次清空:
"))(("會回傳"(("而不是""— 沒配對到的'('要等掃完才知道。 - 哨兵值可能出現在輸入裡:
'*'對 LC 1249 是安全的(只有字母和括號);Python 用空字串最安全, 因為"".join會跳過它。 - 在迴圈裡用
s = s[:i] + s[i+1:]刪除是 O(n²),而且會把後面每個索引往前推,存下來的位置就過期了。
不適用時 — 只問「合不合法?」(LC 20)只需要布林值,不需要索引;
「最長合法子字串」(LC 32)沿用索引堆疊來量 i - stack[-1]。
練習 — LC 1249 ⭐ · LC 20 (只驗證合法性) · LC 921 (數沒配對到的,不用重建) · LC 32 (用索引堆疊量長度)
Template 8:依最後出現位置貪婪切分 — LC 763 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「把字串切成盡可能多段,讓每個字母最多只出現在一段」:在某個性質保持在段內的前提下, 求最多段數。
關鍵 — 當前這段不可能在 max(last[c])(對這段裡看過的每個 c)之前結束。當 i == end,
段裡沒有任何東西碰得到更右邊,所以在這裡切是安全的,而在第一個安全點就切,段數最多。
# python
# LC 763 - Partition Labels
# IDEA: last[c] = final index of c; extend `end` while scanning, cut when i reaches it
# time = O(n), space = O(1) (26 letters)
def partitionLabels(s):
last = {}
for i, c in enumerate(s): # pass 1: the last index of every character
last[c] = i
res = []
start = end = 0
for i, c in enumerate(s): # pass 2: stretch the chunk, cut when it closes
end = max(end, last[c])
if i == end:
res.append(end - start + 1)
start = i + 1
return res
// java
// LC 763 - Partition Labels
// IDEA: last[c] = final index of c; extend `end` while scanning, cut when i reaches it
// time = O(n), space = O(1) (26 letters)
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++) {
end = Math.max(end, last[s.charAt(i) - 'a']); // stretch to cover this char
if (i == end) { // nothing inside reaches past i -> safe to cut
res.add(end - start + 1);
start = i + 1;
}
}
return res;
}
複雜度
- 時間 O(n):對
s兩趟互相獨立的掃描。 - 空間 O(1):
last最多 26 項。
陷阱
- 用
i == last[s[i]]取代i == end來切:"abab"會在第二個a後面切,回傳[3, 1]; 正確答案是[4],因為b把end推到了 3。 - 在切分的同一趟裡建
last:切的時候看不到後面的出現位置,所以獨立的第一趟是必要的。
不適用時 — 每段直接以區間給出,而不是從字串推出來:同一套掃描就是合併區間(LC 56),
把 [first[c], last[c]] 合併起來。
練習 — LC 763 ⭐ · LC 2405 (遇到重複字元就切) · LC 56 (區間的形式)
重量級演算法各自住在哪 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
有四個字串家族大到足以擁有自己的一張表。本表不會把它們重新推導一遍; 挑到對的那一列,就直接去那張表。
子字串搜尋 — 該用哪個演算法?
| 情境 | 用什麼 | 表 |
|---|---|---|
只搜一次,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才會留著。
練習路徑
| 階段 | 題目(易 → 難) | 加了什麼 |
|---|---|---|
| 核心 ⭐ | LC 125 未評定 · LC 242 未評定 · LC 49 ok · LC 271 ok | 雙指標掃描、頻率簽章作為檢查與作為 key、長度前綴解析 |
| 練習 | LC 344 未評定 · LC 696 again · LC 8 未評定 · LC 763 ok · LC 388 again · LC 1249 未評定 | 剩下每個模板各一題:反轉、run-length 分組、分階段解析、最後出現位置切分、深度表、先標記再重建 |
| 延伸 | LC 443 未評定 · LC 71 again · LC 68 未評定 | 原地壓縮、token 堆疊、往左堆空白的貪婪打包 |
總結與速查
題目 → 模板決策表 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
訊號 → 模板的分派見 辨識 表,練習順序見 練習路徑。下面是那張表沒有列到的路線:
| 題目裡的訊號 | 做法 | 在哪 | 題目 |
|---|---|---|---|
| 超出單純檢查的回文題 | 中心擴張、區間 DP、Manacher、KMP 前綴 | palindrome.md | 5, 131, 214, 516, 647, 680 |
| 很多單字共用前綴 | 字典樹(Trie) | trie.md | 208, 211, 212, 648, 745 |
| 「一個是不是另一個的旋轉」 | 先比長度,再看 goal in (s + s) |
string_matching_kmp_rolling_hash.md | 796 |
| 自訂字母表/比較器的排序 | 用 int[26] 建排名表,比較相鄰的一對 |
hash_map.md § Template 6 | 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(堆疊)
一直移除「同字母但大小寫不同」的相鄰配對,直到沒有這種配對為止。
# python
# LC 1544 - Make The String Great
# IDEA: stack; a char that is the top's other-case twin (|diff| == 32) cancels it, else push
# time = O(n), space = O(n)
def makeGood(s):
stack = []
for ch in s:
if stack and abs(ord(stack[-1]) - ord(ch)) == 32: # same letter, other case
stack.pop() # the pair cancels
else:
stack.append(ch)
return "".join(stack)
// java
// LC 1544 - Make The String Great
// IDEA: stack; a char that is the top's other-case twin (|diff| == 32) cancels it, else push
// time = O(n), space = O(n)
public String makeGood(String s) {
Deque<Character> stack = new ArrayDeque<>();
for (char ch : s.toCharArray()) {
if (!stack.isEmpty() && Math.abs(stack.peek() - ch) == 32) {
stack.pop(); // same letter, other case: the pair cancels
} else {
stack.push(ch);
}
}
StringBuilder sb = new StringBuilder();
while (!stack.isEmpty()) {
sb.append(stack.pollLast()); // bottom of the stack is the front of the answer
}
return sb.toString();
}
關鍵洞見: 這個技巧可以推廣到任何「相鄰配對互相抵消、而配對由 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、字元分類,以及蓋字串的效能規則。 |