雙指標
範圍 — 陣列與字串上的雙指標家族:兩端向內收斂、快慢指標、從中心擴張、讀寫分離的原地重寫,每一種各給一個標準模板;依條件伸縮的視窗題不在這裡。 另見 — 從這份文件拆出去的深入內容:2_pointers_examples.md — LC 題目實作集,每題一個標準解;quickselect.md — QuickSelect/以 partition 為主的第 K 個元素選取,它算是選取演算法,而不是雙指標掃描。 相鄰主題:sliding_window.md — 由條件驅動的變動大小視窗;2_pointers_linkedlist.md — 同樣的想法,但作用在節點而非索引上;n_sum.md — 有序陣列上的 k-sum 特化;palindrome.md — 從中心擴張的完整討論。
LeetCode 題目清單
總覽
雙指標在同一個序列上放兩個索引(或兩個序列各放一個),依照某條規則推進它們,用一趟線性掃描取代巢狀迴圈。
關鍵性質
- 複雜度:單趟掃描是 O(N);需要先排序的話是 O(N log N);對每個索引都做中心擴張則是 O(N^2)。額外空間 O(1) — 指標就是全部的狀態
- 核心想法:每次迭代至少推進一個指標,而且從不回頭走同一個索引,所以總工作量被指標移動次數限制住
- 什麼時候用:輸入有序、原地重寫、回文、合併兩個有序序列、子序列檢查、環偵測
- 關鍵技巧:快慢指標(同向)、左右指標(收斂)、從中心擴張、一個指標永遠前進
參考資料
辨識
| 題目裡的訊號 | 限制條件提示 | 該用 | 不是這個,如果 |
|---|---|---|---|
| 「原地移除…」、「回傳 k,前 k 個元素」、「O(1) 額外空間」 | n ≤ 3·10⁴,已排序或固定的 val |
模板 1 快慢指標壓縮 — LC 26, LC 27, LC 80, LC 283 | 保留元素的順序無所謂、寫入代價高 → 模板 2 的雙向 LC 27 |
| 已排序陣列、「和為…的一組配對」、「兩條線之間最多的水」、「回文」 | n ≤ 10⁵ → O(n),先排序則 O(n log n) | 模板 2 收斂指標 — LC 11, LC 125, LC 167, LC 344 | 未排序且答案是索引 → 雜湊表找補數(hash_map.md) |
| 「最長/計算回文子字串」 | n ≤ 1000 → O(n²) 沒問題 | 模板 3 從中心擴張 — LC 5, LC 647 | 回文子序列 → 區間 DP(LC 516) |
| 「s 是不是 t 的子序列」、「刪掉一些字元後形成」 | len(t) ≤ 10⁴ | 模板 4 一個指標永遠前進 — LC 392, LC 524, LC 1023, LC 809 | 連續的子字串比對 → KMP/視窗;最長共同子序列 → DP(LC 1143) |
| 「把 nums2 合併進 nums1」,nums1 尾端有空位 | m + n ≤ 200,要求 O(1) 空間 | 模板 5 從後面往前填 — LC 88, LC 977, LC 986 | 兩條鏈結串列 → 用虛擬頭節點往前合併(LC 21) |
| 「一趟把只有 0、1、2 的陣列排好」、「依值分成三組」 | O(1) 空間、一趟 | 模板 6 荷蘭國旗 — LC 75, LC 905 | 必須保持相對順序 → 串列的穩定分割(LC 86)見 linked_list.md 的兩個虛擬節點 |
| 「鏈結串列有沒有環」、「環從哪裡開始」、「不修改陣列找出重複數字」 | O(1) 記憶體 | 模板 7 Floyd 快慢指標 — LC 141, LC 142, LC 287 | 允許 O(n) 記憶體 → 用 visited 集合更簡單 |
| 視窗依一個條件變大、依另一個條件縮小 | n ≤ 10⁵ | 模板 8 右邊長、左邊縮 — LC 567, LC 209 | 真正的視窗題 → sliding_window.md,那裡才是這一族的主場 |
| 兩份輸入逐個 token 比較 — 「比較版本號」、「只差一次編輯」、「套用退格後是否相等」 | O(n + m) 時間、O(1) 空間 | 模板 9 兩條序列同步前進 — LC 165, LC 161, LC 844, LC 986 | token 可以被跳過或重排 → 子序列(模板 4)或 DP |
| 第 k 大/最接近的 k 個(用選取,不要排序) | n ≤ 10⁵,平均 O(n) | quickselect.md — LC 215, LC 973 | k 很小或輸入是串流 → 堆積 |
限制條件提示取自 complexity_cheatsheet.md 的尺規。其餘的長尾題目實作,都放在 2_pointers_examples.md。
題型分類
指標的種類
-
指標種類
-
快慢指標- 快、慢指標從
同一個起點出發
- 快、慢指標從
-
左右指標- 左、右指標分別從
idx = 0, idx = len(n) - 1出發 - 通常設成
- 左指標 = 0
- 右指標 = len(nums)
- binary search
- 陣列反轉
- 2 sum
- sliding window
- 左、右指標分別從
-
-
從中心
擴張(並處理奇數、偶數兩種情況)- LC 680
- LC 647
- LC 005
-
合併有序陣列
- LC 88
-
把所有 1 聚在一起的最少交換次數
- LC 1151(見 sliding_window.md)
-
救生艇
- LC 881
-
排序 + 固定一個 + 雙指標(最接近/較小的和)- 固定
i,把l/r往內夾;用|sum - target|追蹤最接近的答案 - LC 16(3Sum Closest)
- LC 259(3Sum Smaller)
- 固定
-
先移動右指標,再依條件移動左指標- LC 567
- LC 209(見
sliding window cheatsheet)
-
帶字元型態限制的
子序列比對- 一個指標永遠前進,另一個看條件才動
- 對不匹配的字元要額外驗證
- LC 392(Is Subsequence)
- LC 1023(Camelcase Matching — 帶大小寫限制)
-
逐段比對兩個字串- 兩個指標都是一次前進一整段(同一字元的連續區塊),不是一次一個字元
- 每組對齊的段落都要驗證:數量不同時,長度必須足以延伸(>= 3)
- LC 809(Expressive Words)
-
找樞紐 + 找後繼 + 反轉後綴(下一個排列)- 從右往左掃,找到第一個遞增的相鄰對(樞紐),再找剛好比它大的最小後繼
- 交換樞紐與後繼,接著把遞減的後綴反轉 → 變成遞增
- LC 31(Next Permutation)、LC 556(Next Greater Element III)
-
用收斂的 low/high 指標建出排列(貪婪)- 看到一種訊號就取用最小的可用值,另一種就取最大的
low/high在[0, n]範圍上往內走;最後活下來的那個填最後一格- LC 942(DI String Match)
-
最後出現位置 + 右邊界擴張(貪婪分段)- 先算出每個字元的最後索引;
i == end時就切一刀 - LC 763(Partition Labels)
- 先算出每個字元的最後索引;
-
有序二維矩陣上的階梯指標- 從
右上角的鞍點出發:太大 →col--,太小 →row++ - LC 240(Search a 2D Matrix II)
- 從
-
跨兩個字串的雙指標(同步/逐段)- 每一輪,兩個指標各從自己的字串取用一段
- 先耗盡的那一邊 → 隱含補
0(LC 165)或套前綴規則(LC 953) - LC 165(Compare Version Numbers)、LC 953(Verifying an Alien Dictionary)
-
在有序區間上的三階段指標掃描- 在前面的 → 合併重疊的 → 在後面的
- LC 57(Insert Interval)
-
演算法
- 二分搜尋
- 滑動視窗
- for 迴圈 + 「從中心往
左、右擴張」
-
資料結構
- 陣列
- 鏈結串列
模板與演算法
模板 1:快慢指標的讀寫壓縮 — LC 26 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「原地移除重複值/所有 val,回傳保留的個數 k」、「不要配置額外空間」、
「前 k 個元素必須是結果」。n ≤ 3·10⁴ → 一趟 O(n)。
關鍵 — nums[0..slow] 永遠依序存著要保留的元素。fast 每個索引讀一次;
slow 只在保留一個值時前進,所以寫入位置永遠追不過讀取位置,沒讀過的值不會被蓋掉。
# python
# LC 26 - Remove Duplicates from Sorted Array
# IDEA: fast reads every index; slow is the last kept slot, advanced only for a new value
# time = O(n), space = O(1)
def removeDuplicates(nums):
if not nums:
return 0
slow = 0 # nums[0..slow] = the unique values seen so far
for fast in range(1, len(nums)):
if nums[fast] != nums[slow]: # a value not kept yet
slow += 1 # open the next write slot
nums[slow] = nums[fast] # copy it forward
# equal: a duplicate, fast moves on and slow stays
return slow + 1 # slow is an index; the count is index + 1
// java
// LC 26 - Remove Duplicates from Sorted Array
// IDEA: fast reads every index; slow is the last kept slot, advanced only for a new value
// time = O(n), space = O(1)
public int removeDuplicates(int[] nums) {
if (nums.length == 0) {
return 0;
}
int slow = 0; // nums[0..slow] = the unique values seen so far
for (int fast = 1; fast < nums.length; fast++) {
if (nums[fast] != nums[slow]) {
slow++; // open the next write slot
nums[slow] = nums[fast]; // overwrite with the new unique value
}
}
return slow + 1;
}
複雜度
- 時間 O(n):只有一個
for fast迴圈;每個索引讀一次、最多寫一次。 - 空間 O(1):兩個索引就是全部的狀態 — 結果直接寫回
nums。
試跑
nums = [0,0,1,1,2]
fast=1 0 == nums[0] skip slow=0
fast=2 1 != 0 slow=1, nums[1]=1 [0,1,1,1,2]
fast=3 1 == nums[1] skip slow=1
fast=4 2 != 1 slow=2, nums[2]=2 [0,1,2,1,2]
return 3 -> nums[:3] = [0,1,2]
陷阱
[]:在 Python 裡只寫return會回傳None而不是0。要明確處理。- 允許 k 份(LC 80):要跟已寫入的前綴比,
nums[fast] != nums[write - k], 不能跟原本的鄰居nums[fast - k]比。在[1,1,1,2,2,3]、k = 2 上,fast - k的版本會讀到已被覆寫的格子,回傳[1,1,2,3],少了一個2。 - 用交換取代覆寫(
nums[slow], nums[fast] = nums[fast], nums[slow])也正確, 而且每個值都還留在陣列某處;覆寫比較單純,因為尾端永遠不會再讀。
不適用時 — 保留元素的順序無所謂、寫入代價高:
模板 2 底下的雙向 LC 27
從右端搬值,val 很罕見時碰到的格子更少。
練習 — LC 26 ⭐ · LC 27 (!= val 就保留;slow 是下一個空位) · LC 283
(跟寫入位置交換,0 自然漂到右邊) · LC 80 (最多兩份:跟
nums[write - 2] 比) · LC 83 / LC 82 (同一條規則用在鏈結串列,見
2_pointers_linkedlist.md)
允許最多 K 份重複
把比較條件推廣成 nums[fast] != nums[write - k],write 是下一個空位,
前 k 個元素一定保留:k = 1 就是 LC 26,k = 2 就是 LC 80。實作在
2_pointers_examples.md。
Remove Element — LC 27
關鍵 — 同一種壓縮,只是保留的判斷不同:只要
nums[fast]不是val就保留。 這裡的slow是下一個空位而不是最後保留的位置,所以答案就是slow本身。
歸約到:模板 1 — 只換了保留的判斷。
# python
# LC 27 - Remove Element
# IDEA: copy every element that is not val down to the write slot
# time = O(n), space = O(1)
def removeElement(nums, val):
slow = 0 # next free write slot
for fast in range(len(nums)):
if nums[fast] != val:
nums[slow] = nums[fast]
slow += 1
return slow
// java
// LC 27 - Remove Element
// IDEA: copy every element that is not val down to the write slot
// time = O(n), space = O(1)
public int removeElement(int[] nums, int val) {
int slow = 0; // next free write slot
for (int fast = 0; fast < nums.length; fast++) {
if (nums[fast] != val) {
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
模板 2:兩端向內收斂的雙指標 — LC 11 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 已排序陣列加上「和為…的一組配對」、「哪兩條線能裝最多 水」、「是不是回文」、「原地反轉」。n ≤ 10⁵ → O(n),必要時先花 O(n log n) 排序。
關鍵 — 每一步都永久丟掉一端,而且被丟掉的只會是贏不了目前這組的配對。以 LC 11 來說, 較矮的牆限制了它參與的每一組配對,剩下的夥伴又都更近,所以用到那面矮牆的配對都贏不了剛量過的這組。
# python
# LC 11 - Container With Most Water
# IDEA: measure the widest pair, then drop the shorter wall: it cannot do better with a narrower partner
# time = O(n), space = O(1)
def maxArea(height):
left = 0
right = len(height) - 1
best = 0
while left < right:
width = right - left
best = max(best, min(height[left], height[right]) * width)
if height[left] < height[right]: # left is the bottleneck: discard it
left += 1
else: # right is (or ties): discard it
right -= 1
return best
// java
// LC 11 - Container With Most Water
// IDEA: measure the widest pair, then drop the shorter wall: it cannot do better with a narrower partner
// time = O(n), space = O(1)
public int maxArea(int[] height) {
int left = 0;
int right = height.length - 1;
int best = 0;
while (left < right) {
int width = right - left;
best = Math.max(best, Math.min(height[left], height[right]) * width);
if (height[left] < height[right]) {
left++; // left is the bottleneck: discard it
} else {
right--;
}
}
return best;
}
複雜度
- 時間 O(n):
while left < right每一輪恰好把一個指標往內移,所以迴圈 跑 n − 1 次。 - 空間 O(1):兩個索引加上目前最佳值。
陷阱
- 移動較高的牆:
[1,8,6,2,5,4,8,3,7]會回傳 8 而不是 49 — 矮牆一直留著, 持續限制每一組配對。 while left <= right會讓索引跟自己配對。對 LC 11 無害(寬度 0),對 LC 167 是錯的:numbers = [2,3,4]、target 4 會回傳[1,1],把2用了兩次。- 拿未排序的輸入做配對和:「太小 → 左邊前進」這條規則需要有序。先排序, 或在答案是原始索引時改用雜湊表。
不適用時 — 未排序、答案是索引 → 用 hash_map.md 找補數(LC 1);帶條件的連續區間 → sliding_window.md。
練習 — LC 11 ⭐ · LC 344 (反轉:交換後兩邊都移動) · LC 125 ⭐ (比較前先跳過非英數
字元) · LC 167 (和太小 → left++) · LC 15 (固定一個,其餘收斂 — 見
n_sum.md) · LC 42 (移動最大值較小的那一側)
Valid Palindrome — LC 125 就是這張卡加上跳過:兩側都先跨過不算數的字元,再比較這一對。 完整解法在 string.md § 模板 1; 允許刪一個字元的版本(LC 680)是同一趟掃描,在第一次不匹配時分兩支檢查,見 palindrome.md。
Remove Element 的雙向版本 — LC 27
關鍵 —
l上的val被nums[r]覆寫、r縮小,但l不動: 剛搬進來的值本身也可能是val。當l > r時,l就是保留元素的個數。
歸約到:模板 2 — 兩端收斂,拿來做分割而不是找配對。
跟快慢指標(模板 1)的關鍵差別:
- 快慢指標依序覆寫 → 保持相對順序
- 雙向版是把
nums[l]換成nums[r]→ 不保持順序,但寫入次數可能較少(val很罕見時划算)
// java
// LC 27 Remove Element - Bidirectional variant
// IDEA: a val at l is overwritten by the rightmost element; l only advances past a kept element
// time = O(n), space = O(1)
/**
* Key Idea:
* - l starts at 0, r starts at nums.length - 1
* - If nums[l] == val, OVERWRITE it with nums[r] and shrink r
* (do NOT advance l yet — the new nums[l] might also be val)
* - If nums[l] != val, it is a "good" element → advance l
* - When l > r, l equals the count of valid elements
*
* //--------------------
* Example 1
* //--------------------
* nums = [3,2,2,3], val = 3
*
* [3,2,2,3] nums[l]=3==val, nums[l]=nums[r]=3, r--
* l r
*
* [3,2,2,3] nums[l]=3==val, nums[l]=nums[r]=2, r--
* l r
*
* [2,2,2,3] nums[l]=2!=val, l++
* l r
*
* [2,2,2,3] nums[l]=2!=val, l++
* lr
*
* l(2) > r(1), return l = 2
*
* //--------------------
* Example 2
* //--------------------
* nums = [0,1,2,2,3,0,4,2], val = 2
*
* [0,1,2,2,3,0,4,2] nums[l]=0!=val, l++
* l r
*
* [0,1,2,2,3,0,4,2] nums[l]=1!=val, l++
* l r
*
* [0,1,2,2,3,0,4,2] nums[l]=2==val, nums[l]=nums[r]=2, r--
* l r
*
* [0,1,2,2,3,0,4,2] nums[l]=2==val, nums[l]=nums[r]=4, r--
* l r
*
* [0,1,4,2,3,0,4,2] nums[l]=4!=val, l++
* l r
*
* [0,1,4,2,3,0,4,2] nums[l]=2==val, nums[l]=nums[r]=0, r--
* l r
*
* [0,1,4,0,3,0,4,2] nums[l]=0!=val, l++
* l r
*
* [0,1,4,0,3,0,4,2] nums[l]=3!=val, l++
* l r
*
* l(5) > r(4), return l = 5
*/
public int removeElement(int[] nums, int val) {
int l = 0;
int r = nums.length - 1;
while (l <= r) {
if (nums[l] == val) {
// Overwrite with rightmost element, shrink right boundary
// do NOT advance l — the new nums[l] might also be val
nums[l] = nums[r];
r--;
} else {
// Good element confirmed, advance left
l++;
}
}
// l is exactly the count of non-val elements
return l;
}
比較:快慢 vs 雙向
| 面向 | 快慢(模板 1) | 雙向(本節) |
|---|---|---|
| 順序 | 保持相對順序 | 不保持順序 |
| 寫入次數 | 每個有效元素寫一次 | val 罕見時寫得更少 |
| 迴圈寫法 | for 迴圈(fast 一定前進) |
while (l <= r) |
| 什麼時候用 | 順序有意義時 | 順序無所謂、想少寫幾次時 |
相似題目:
- LC 27 Remove Element(本模式)
- LC 905 Sort Array By Parity — 偶數往左、奇數往右(同樣是雙向往內縮的想法)
- LC 75 Sort Colors(Dutch National Flag)— 三向雙指標分割
- LC 283 Move Zeroes — 順序有意義,改用快慢指標
- LC 26 Remove Duplicates from Sorted Array — 順序有意義,改用快慢指標
- LC 80 Remove Duplicates from Sorted Array II — 順序有意義,改用快慢指標
反轉陣列
這個模板最陽春的骨架:沒有配對要評估,只是交換、兩邊一起往內走。
// java
// IDEA: swap the two ends, move both inward until they meet
// time = O(n), space = O(1)
void reverse(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
int tmp = nums[left];
nums[left] = nums[right];
nums[right] = tmp;
left++;
right--;
}
}
模板 3:從中心擴張 — LC 5 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「最長回文子字串」、「計算回文子字串的個數」。 n ≤ 1000 → 預期就是 O(n²)。
關鍵 — 每個回文都是從它的中心擴張出來的,而中心只有 2n − 1 個: n 個字元(奇數長度)和 n − 1 個縫隙(偶數長度)。對每個中心在兩端相同時持續擴張, 每個回文子字串恰好被走訪一次。
# python
# LC 5 - Longest Palindromic Substring
# IDEA: try all 2n - 1 centres (each char, each gap); expand while the ends match; keep the widest
# time = O(n^2), space = O(1)
def longestPalindrome(s):
def expand(left, right):
# grow while s[left..right] is a palindrome
while left >= 0 and right < len(s) and s[left] == s[right]:
left -= 1
right += 1
# the loop stopped one step too far on each side
return left + 1, right # half-open [start, end)
best_start, best_end = 0, 0
for center in range(len(s)):
for left, right in ((center, center), (center, center + 1)): # odd, then even
start, end = expand(left, right)
if end - start > best_end - best_start:
best_start, best_end = start, end
return s[best_start:best_end]
// java
// LC 5 - Longest Palindromic Substring
// IDEA: try all 2n - 1 centres (each char, each gap); expand while the ends match; keep the widest
// time = O(n^2), space = O(1)
public String longestPalindrome(String s) {
int bestStart = 0;
int bestEnd = 0; // half-open [bestStart, bestEnd)
for (int center = 0; center < s.length(); center++) {
int[] odd = expand(s, center, center);
int[] even = expand(s, center, center + 1);
if (odd[1] - odd[0] > bestEnd - bestStart) {
bestStart = odd[0];
bestEnd = odd[1];
}
if (even[1] - even[0] > bestEnd - bestStart) {
bestStart = even[0];
bestEnd = even[1];
}
}
return s.substring(bestStart, bestEnd);
}
private int[] expand(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
left--;
right++;
}
return new int[]{left + 1, right}; // undo the last failed step
}
複雜度
- 時間 O(n²):
for center迴圈做 2n − 1 次擴張,expand裡的每個while最多跑 n/2 步(全部相同的輸入"aaaa")。 - 空間 O(1):只存索引;子字串在最後切一次。
試跑
s = "cbbd"
center 0 odd "c" even c|b stop best "c"
center 1 odd "b" even b|b -> "bb", then c|d stop best "bb"
center 2 odd "b" even b|d stop
center 3 odd "d"
return "bb"
陷阱
- 只試奇數中心:
"cbbd"會回傳"c"— 兩個b之間的偶數中心從沒試過。 while結束時兩側都多走了一步;之後讀s[left:right + 1]會多包進兩個不相等的字元。用left + 1和right(左閉右開)。- 每成功擴張一步就切一個新字串,會把 O(n²) 變成 O(n³);比較長度,最後才切一次。
不適用時 — 回文子序列(字元可以跳過)→ 區間 DP(LC 516); n ≤ 10⁵ 需要 Manacher 的 O(n)。回文題型的完整討論在 palindrome.md。
練習 — LC 5 ⭐ · LC 647 ⭐ (計數:每成功擴張一步就加 1) · LC 680 (收斂 指標加一次跳過 — 模板 2) · LC 516 (子序列 → DP;長得像但不是)
LC 5 與 LC 647 的完整解法在 2_pointers_examples.md。
模板 4:子序列比對,一個指標永遠前進 — LC 392 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「s 是不是 t 的子序列」、「在不改變順序的前提下刪掉一些字元而形成」、
「查詢字串符不符合樣式」。len(t) ≤ 10⁴ → 一趟 O(|t|) 掃描。
關鍵 — 把 s[i] 配給最早出現的相同 t[j],絕不會比配給較晚的更差:
它替 s[i+1:] 留下 t 最長的後綴。所以 j 每一步都前進、i 只在
匹配時前進,最後 i == len(s) 就代表每個字元都依序放好了。
# python
# LC 392 - Is Subsequence
# IDEA: j scans t every step; i advances only when s[i] matches (greedy earliest match)
# time = O(|t|), space = O(1)
def isSubsequence(s, t):
i = 0 # next character of s to place
for j in range(len(t)): # t's pointer always moves
if i < len(s) and s[i] == t[j]:
i += 1 # matched: move s's pointer too
return i == len(s)
// java
// LC 392 - Is Subsequence
// IDEA: j scans t every step; i advances only when s[i] matches (greedy earliest match)
// time = O(|t|), space = O(1)
public boolean isSubsequence(String s, String t) {
int i = 0; // next character of s to place
for (int j = 0; j < t.length(); j++) { // t's pointer always moves
if (i < s.length() && s.charAt(i) == t.charAt(j)) {
i++;
}
}
return i == s.length();
}
複雜度
- 時間 O(|t|):
for j迴圈把t的每個字元走一次;i只是跟著。 - 空間 O(1):兩個索引。
試跑
s = "abc", t = "ahbgdc"
j=0 'a' == s[0] i=1
j=1 'h' i=1
j=2 'b' == s[1] i=2
j=3 'g' j=4 'd' i=2
j=5 'c' == s[2] i=3 == len(s) -> true
陷阱
- 沒檢查
i < len(s)就讀s[i]:s = "a"、t = "aa"在第二個a時會讀到s結尾之外。 - 回傳
j == len(t)而不是i == len(s):j一定會走到底,所以在"ahbgdc"裡找"axc"也會回傳 true。 s = ""是任何字串(包括"")的子序列;迴圈不能要求有匹配才回傳 true。- 對同一個
t有大量查詢(LC 392 進階題、LC 792):每次查詢都重掃t是 O(k·|t|)。 先替每個字元建好排序的索引清單,再用二分搜尋找下一個位置。
不適用時 — 連續的比對(子字串)→ KMP 或視窗;兩個字串的最長共同子序列 → DP(LC 1143),因為那裡貪婪選擇不再安全。
練習 — LC 392 ⭐ · LC 524 (用這個掃描測字典裡的每個字) · LC 1023 (沒配到的
t 字元必須是小寫) · LC 809 (以相同字元的群組為單位前進) · LC 792 (大量
查詢:索引清單 + 二分搜尋)
模板 5:合併兩個有序陣列,從後面往前填 — LC 88 優先度 5/5 — 必備 — 幾乎每一輪面試都會出現
辨識 — 「把 nums2 原地合併進 nums1」、「nums1 長度為 m + n,最後 n 個是
0,請忽略」。m + n ≤ 200,但重點是 O(1) 額外空間。
關鍵 — 把剩下最大的值寫進最後一個空位。寫入索引
write = p1 + p2 + 1 永遠 ≥ p1,所以一格只會在被讀過(或本來就是空的)之後才被寫:
往後合併不需要緩衝區,往前合併則會蓋掉還沒讀的 nums1 值。
# python
# LC 88 - Merge Sorted Array
# IDEA: two read pointers at the ends of the valid parts, one write pointer at the very end;
# always write the larger tail. Loop on p2 only: leftover nums1 is already in place.
# time = O(m + n), space = O(1)
def merge(nums1, m, nums2, n):
p1 = m - 1 # read nums1 from the end of its valid part
p2 = n - 1 # read nums2 from its end
write = m + n - 1 # write from the very end of nums1
while p2 >= 0:
if p1 >= 0 and nums1[p1] > nums2[p2]:
nums1[write] = nums1[p1]
p1 -= 1
else: # nums2's tail is larger or equal, or nums1 is used up
nums1[write] = nums2[p2]
p2 -= 1
write -= 1
// java
// LC 88 - Merge Sorted Array
// IDEA: write the larger tail into the last free slot; loop until nums2 is used up
// time = O(m + n), space = O(1)
public void merge(int[] nums1, int m, int[] nums2, int n) {
int p1 = m - 1; // read nums1
int p2 = n - 1; // read nums2
int write = m + n - 1; // write slot
while (p2 >= 0) {
if (p1 >= 0 && nums1[p1] > nums2[p2]) {
nums1[write] = nums1[p1];
p1--;
} else {
nums1[write] = nums2[p2];
p2--;
}
write--;
}
}
複雜度
- 時間 O(m + n):
while p2 >= 0每一輪寫一格並把write往左移;格子最多 m + n 個。 - 空間 O(1):合併就在
nums1自己的空尾端進行。
試跑
nums1 = [1, 2, 3, 0, 0, 0], m = 3
nums2 = [2, 5, 6], n = 3
| Step | p1 | p2 | write | Compare | Action | nums1 |
|------|----|----|-------|-------------------------|---------|----------------|
| init | 2 | 2 | 5 | — | setup | [1,2,3,0,0,0] |
| 1 | 2 | 2 | 5 | nums1[2]=3 < nums2[2]=6 | write 6 | [1,2,3,0,0,6] |
| 2 | 2 | 1 | 4 | nums1[2]=3 < nums2[1]=5 | write 5 | [1,2,3,0,5,6] |
| 3 | 2 | 0 | 3 | nums1[2]=3 > nums2[0]=2 | write 3 | [1,2,3,3,5,6] |
| 4 | 1 | 0 | 2 | nums1[1]=2 == nums2[0]=2| write 2 | [1,2,2,3,5,6] |
| 5 | 1 | -1 | 1 | p2 < 0 | STOP | [1,2,2,3,5,6] |
nums1[0..1] = [1,2] is already in place -> no flush step
陷阱
- 由左往右合併會蓋掉還沒讀的值:
nums1 = [4,5,6,0,0,0]、nums2 = [1,2,3]會在讀到4之前就把1寫在它上面。 while p1 >= 0 and p2 >= 0卻沒有收尾:nums1 = [0]、m = 0、nums2 = [1]永遠不會複製那個1。 只看p2當迴圈條件,或事後補上nums1[:p2 + 1] = nums2[:p2 + 1]。- 沒檢查
p1 >= 0就讀nums1[p1]:在 Python 裡nums1[-1]會默默讀到最後一格,而不是報錯。 nums1 = sorted(nums1 + nums2)只是重新綁定區域變數;呼叫者的陣列沒變。write從m - 1開始:nums1有 m + n 格;第一次寫必須在m + n - 1。
不適用時 — 兩條鏈結串列:沒有空尾端可以填,所以用虛擬頭節點 往前合併(LC 21);k 個有序輸入 → 堆積(LC 23)。
練習 — LC 88 ⭐ · LC 977 (最大的平方在兩端之一:結果從後往前填) · LC 350 (兩個有序指標,保留共同的值) · LC 986 (推進先結束的那個區間) · LC 4 (只在概念上合併 — 改成二分搜尋切分點)
模板 6:三向分割(荷蘭國旗)— LC 75 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「一趟、常數空間排好只有 0、1、2 的陣列」、「依樞紐分成小於/等於/ 大於三組」。O(1) 空間、單一趟。
關鍵 — 每一步都維持四個區域成立:nums[0..low-1] = 0、
nums[low..mid-1] = 1、nums[mid..high] = 未看過、nums[high+1..] = 2。每一步讓
未看過的區域縮小一格,所以迴圈在 mid > high 時結束。
# python
# LC 75 - Sort Colors
# IDEA: 0 -> swap into the low region, 2 -> swap into the high region, 1 -> leave in place
# time = O(n), space = O(1)
def sortColors(nums):
low, mid, high = 0, 0, len(nums) - 1
while mid <= high: # nums[mid..high] is still unseen
if nums[mid] == 0:
nums[low], nums[mid] = nums[mid], nums[low]
low += 1
mid += 1 # what came from low is a 1 (or mid itself): already seen
elif nums[mid] == 1:
mid += 1
else:
nums[mid], nums[high] = nums[high], nums[mid]
high -= 1 # do NOT move mid: the value from high is unseen
// java
// LC 75 - Sort Colors
// IDEA: 0 -> swap into the low region, 2 -> swap into the high region, 1 -> leave in place
// time = O(n), space = O(1)
public void sortColors(int[] nums) {
int low = 0; // next slot for a 0
int mid = 0; // current element
int high = nums.length - 1; // next slot for a 2
while (mid <= high) {
if (nums[mid] == 0) {
swap(nums, low, mid);
low++;
mid++;
} else if (nums[mid] == 1) {
mid++;
} else {
swap(nums, mid, high);
high--; // the swapped-in value is unseen: check it next round
}
}
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
複雜度
- 時間 O(n):
while mid <= high每一輪不是把mid往右就是把high往左,所以 未看過的區域每次縮小一格。 - 空間 O(1):三個索引;交換都是原地進行。
試跑
nums = [2,0,2,1,1,0] low mid high
mid=0 is 2 -> swap with high [0,0,2,1,1,2] 0 0 4
mid=0 is 0 -> swap with low [0,0,2,1,1,2] 1 1 4
mid=1 is 0 -> swap with low [0,0,2,1,1,2] 2 2 4
mid=2 is 2 -> swap with high [0,0,1,1,2,2] 2 2 3
mid=2 is 1 2 3 3
mid=3 is 1 2 4 3 mid > high: done
陷阱
- 跟
high交換後也推進mid:[1,2,0]會變成[1,0,2]— 從右邊換過來的0從沒被檢查。 while mid < high:[1,0]在mid == high還沒檢查時就停下,維持[1,0]。
不適用時 — 必須保持相對順序(穩定分割):串列的穩定分割(LC 86) 見 linked_list.md 的兩個虛擬節點, 陣列則用模板 1 的快慢指標保持順序(LC 283)。
練習 — LC 75 ⭐ · LC 905 (兩組:一個交換指標就夠) · LC 2161 (穩定 — 需要 第二趟或額外陣列) · LC 215 (quickselect 裡就是同一種三向分割,見 quickselect.md)
模板 7:快慢指標偵測環 — LC 141 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「鏈結串列有沒有環」、「回傳環開始的節點」、 「不修改陣列、O(1) 額外空間找出重複數字」。O(1) 記憶體就排除了 visited 集合。
關鍵 — fast 一次走兩步,slow 一步。兩者都進入長度 c 的環之後,
它們的距離每步縮小一,所以在 c 步內相遇;沒有環的話 fast 會走到 None。
找入口(LC 142):頭到入口的距離 ≡ 相遇點到入口的距離(mod c),所以一個從
head、一個從相遇點出發,各走一步,恰好在入口相遇。
# python
# LC 141 - Linked List Cycle; LC 142 - Linked List Cycle II (the entry node)
# IDEA: slow 1 step, fast 2 steps -> they meet iff there is a cycle; then head and meet point
# walk 1 step each and meet at the entry
# time = O(n), space = O(1)
def hasCycle(head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow is fast: # identity, not value
return True
return False
def detectCycle(head):
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow is fast: # inside the cycle: now find where it starts
entry = head
while entry is not slow:
entry = entry.next
slow = slow.next
return entry
return None # fast fell off the end: no cycle
// java
// LC 141 - Linked List Cycle; LC 142 - Linked List Cycle II (the entry node)
// IDEA: slow 1 step, fast 2 steps -> they meet iff there is a cycle; then head and meet point
// walk 1 step each and meet at the entry
// time = O(n), space = O(1)
public boolean hasCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
return true;
}
}
return false;
}
public ListNode detectCycle(ListNode head) {
ListNode slow = head;
ListNode fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
ListNode entry = head;
while (entry != slow) {
entry = entry.next;
slow = slow.next;
}
return entry;
}
}
return null; // no cycle: the guard above is what prevents an NPE
}
複雜度
- 時間 O(n):相遇之前,
slow最多走 n 步(尾巴長度 + 一圈);找入口 的那段最多再 n 步。 - 空間 O(1):兩三個節點參考。
陷阱
- 只檢查
fast.next:無環的[1,2]跳一次後fast就是None,fast.next會丟例外。先檢查fast,再檢查fast.next。 - 無環串列上的 LC 142 必須回傳
None;無條件執行找入口那段(根本沒相遇) 會對null取值。 - 從
fast = head.next開始會改變距離:在[3,2,0,-4](尾巴接回索引 1)上,頭/相遇點 那段永遠走不到入口,無限迴圈。 - 比較
slow.val == fast.val會在無環的[1,1,1]上回報有環。要比較節點本身。
不適用時 — 允許 O(n) 記憶體且重視清楚 → visited 集合;以節點為單位的這一族 (中間節點、倒數第 n 個、重排、回文串列)在 2_pointers_linkedlist.md。
練習 — LC 141 ⭐ · LC 876 (fast 走到底時停:slow 就是中間) · LC 202 (隱含的
串列:n → 各位數平方和) · LC 142 (找入口那段) · LC 287 (把陣列當成串列
i → nums[i];重複的數字就是入口)
模板 8:右邊長、左邊縮 — LC 567
辨識 — 「s2 是否包含 s1 的某個排列」、「視窗依一個條件變大、
依另一個條件縮小」。n ≤ 10⁴。這是滑動視窗的骨架,這裡留著它的雙指標
視角;sliding_window.md 才是這一族的主場。
關鍵 — right 只增不減、left 也只增不減,所以每個索引進出視窗各
一次。while 之後視窗永遠不會比 s1 長,所以在那裡比較計數,就把每個
長度為 len(s1) 的視窗恰好測過一次。
# python
# LC 567 - Permutation in String
# IDEA: grow right; shrink left until the window is len(s1) long; equal counts = a permutation
# time = O(26 * n), space = O(26)
def checkInclusion(s1, s2):
need = [0] * 26
for ch in s1:
need[ord(ch) - ord('a')] += 1
window = [0] * 26
left = 0
for right in range(len(s2)):
window[ord(s2[right]) - ord('a')] += 1 # grow
while right - left + 1 > len(s1): # too long -> shrink
window[ord(s2[left]) - ord('a')] -= 1
left += 1
if window == need:
return True
return False
// java
// LC 567 - Permutation in String
// IDEA: grow right; shrink left until the window is len(s1) long; equal counts = a permutation
// time = O(26 * n), space = O(26)
public boolean checkInclusion(String s1, String s2) {
int[] need = new int[26];
for (int i = 0; i < s1.length(); i++) {
need[s1.charAt(i) - 'a']++;
}
int[] window = new int[26];
int left = 0;
for (int right = 0; right < s2.length(); right++) {
window[s2.charAt(right) - 'a']++; // grow
while (right - left + 1 > s1.length()) { // too long -> shrink
window[s2.charAt(left) - 'a']--;
left++;
}
if (Arrays.equals(window, need)) {
return true;
}
}
return false;
}
複雜度
- 時間 O(26·n):
right和left各走過s2一次(內層while攤銷 O(1)); 26 格的比較每個right跑一次。 - 空間 O(26):兩個計數陣列。
陷阱
len(s1) > len(s2)("abc"、"ab"):迴圈永遠湊不出完整視窗,正確地回傳 false — 不要一開始就切s2[:len(s1)]並假設它存在。- 在縮小之前就測
window == need,比較的是多一個字元的視窗。
不適用時 — 視窗大小不固定、條件是和或計數 → sliding_window.md 裡的可變視窗卡片;和裡有負值 → 前綴和。
練習 — LC 567 ⭐ · LC 438 (收集每個起點而不是直接回傳) · LC 209 (和仍 ≥ target 時就縮小) · LC 76 (have/need 計數器 — sliding_window 的卡片)
模板 9:兩條序列同步前進 — LC 165 優先度 4/5 — 高價值 — 這裡有缺口就會掉關
辨識 — 「比較 version1 和 version2」、「這兩個字串是否只差一次編輯」、「套用退格後再比較」、 「求兩串已排序區間的交集」。兩份輸入,各自由自己的指標讀過一次,每一輪讀一個 token: 一個修訂號、一個有效字元、一個區間。要求 O(n + m) 時間、O(1) 空間。
關鍵 — 每一輪從兩邊各取一個 token 來比較;第一組不相等的 token 就決定答案,所以
它之前的內容永遠不會再看第二次。只要任一邊還有輸入就繼續跑,並讓讀完的一邊產生中性 token
(0、空字串),長度不同就不再是特例。
# python
# LC 165 - Compare Version Numbers
# IDEA: i walks version1, j walks version2; each round parses one revision from each side
# (an exhausted side yields 0) and the first unequal pair decides
# time = O(n1 + n2), space = O(1)
def compareVersion(version1, version2):
i, j = 0, 0
n1, n2 = len(version1), len(version2)
while i < n1 or j < n2: # OR: the shorter side keeps yielding 0
a = 0
while i < n1 and version1[i] != ".":
a = a * 10 + int(version1[i]) # leading zeros vanish here
i += 1
b = 0
while j < n2 and version2[j] != ".":
b = b * 10 + int(version2[j])
j += 1
if a != b:
return -1 if a < b else 1
i += 1 # step over the '.' (harmless past the end)
j += 1
return 0
// java
// LC 165 - Compare Version Numbers
// IDEA: i walks version1, j walks version2; each round parses one revision from each side
// (an exhausted side yields 0) and the first unequal pair decides
// time = O(n1 + n2), space = O(1)
public int compareVersion(String version1, String version2) {
int i = 0, j = 0;
int n1 = version1.length(), n2 = version2.length();
while (i < n1 || j < n2) {
int a = 0;
while (i < n1 && version1.charAt(i) != '.') {
a = a * 10 + (version1.charAt(i) - '0');
i++;
}
int b = 0;
while (j < n2 && version2.charAt(j) != '.') {
b = b * 10 + (version2.charAt(j) - '0');
j++;
}
if (a != b) {
return Integer.compare(a, b);
}
i++;
j++;
}
return 0;
}
複雜度
- 時間 O(n1 + n2):兩個內層
while只會讓i、j往前走,外層迴圈在兩者都 走過自己的字串時結束。 - 空間 O(1):兩個指標和兩個解析出來的整數;不用
split。
試跑 — version1 = "1.01"、version2 = "1.001.1"
round a (from version1) b (from version2) result
1 1 1 equal, continue
2 "01" -> 1 "001" -> 1 equal, continue
3 exhausted -> 0 1 0 < 1 -> return -1
陷阱
while i < n1 and j < n2(AND)會停在較短的字串:"1.0.1"對"1"回傳 0 而不是 1。- 把修訂號當字串比:
"1.10"對"1.2"— 以文字來看"10" < "2"。要解析成整數, 這樣"01"也會等於"001"。 - Python 裡用
split(".")再補零比較短,可以提一下,但它要 O(n) 額外空間;指標寫法才是 O(1) 的答案。
不適用時 — 兩條序列可能不按順序配對、或可任意跳過 → 子序列檢查 (模板 4) 或 LCS DP;兩份輸入都已排序、目標是合併成一份輸出 → 模板 5。
練習 — LC 165 ⭐ · LC 844 (token 是退格後留下的下一個字元 — 兩個字串都從後面往前走) · LC 161 (停在第一個不匹配處,比較後綴) · LC 953 (相鄰單字、排名表 — hash_map.md) · LC 986 (區間:先結束的那個前進 — intervals.md)
練習路徑
| 階段 | 題目(易 → 難) | 增加了什麼 |
|---|---|---|
| 核心 ⭐ | LC 125 未評定 · LC 141 ok · LC 11 ok · LC 5 again · LC 647 again | 帶跳過的收斂、Floyd、矮牆論證、兩種中心、以擴張計數 |
| 練習 | LC 26 未評定 · LC 88 again · LC 392 ok · LC 167 ok · LC 75 ok · LC 142 ok · LC 15 ok | 壓縮、往後合併、貪婪子序列、有序配對、三個區域、環的入口、固定一個 + 收斂(n_sum.md) |
| 延伸 | LC 80 again · LC 881 未評定 · LC 287 ok · LC 42 未評定 | 最多 k 份、排序後貪婪配對、陣列上的 Floyd、兩個累計最大值 |
總結與速查
模式選擇表
模板 1–9 由 辨識 表分派,練習順序見 練習路徑。下面這些是放在其他文件裡的雙指標題:
| 模式 | 什麼時候用 | 範例題目 |
|---|---|---|
| 滑動視窗 | 子陣列/子字串題 | LC 3, LC 76, LC 209 — sliding_window.md |
| 固定一個 + 雙指標(精確/最接近) | 和 == target,或最接近它 | LC 15, LC 18, LC 16, LC 259 — n_sum.md |
| 可刪字元的回文 | 允許 k 次修改 | LC 680, LC 1216 — palindrome.md |
| 最長回文前綴 | 找最長回文前綴,把反轉後的後綴接到前面 | LC 214, LC 336 — palindrome.md |
| 最後出現位置 + 擴張右界 | 貪婪分段;i == end 時切一刀 |
LC 763 — string.md |
| 長度前綴(編碼/解碼) | 解析 len#word 區塊;i 依宣告的長度跳 |
LC 271, LC 297 — 2_pointers_examples.md |
| 收斂的 low/high(建排列) | 貪婪:看訊號取用最小/最大的可用值 | LC 942 — 2_pointers_examples.md |
| 階梯法(有序二維矩陣) | 在列與行都有序的格子上搜尋 | LC 240 — 2_pointers_examples.md |
| 三階段區間掃描 | 把一個區間插入/合併進有序清單 | LC 57, LC 56 — intervals.md |
依難度分類的經典題
Easy
- LC 26 Remove Duplicates from Sorted Array
- LC 27 Remove Element
- LC 125 Valid Palindrome
- LC 283 Move Zeroes
- LC 344 Reverse String
- LC 345 Reverse Vowels of a String
- LC 349 Intersection of Two Arrays
- LC 350 Intersection of Two Arrays II
- LC 392 Is Subsequence
- LC 680 Valid Palindrome II
- LC 844 Backspace String Compare
- LC 942 DI String Match
- LC 953 Verifying an Alien Dictionary
- LC 977 Squares of a Sorted Array
- LC 14 Longest Common Prefix(同步掃過所有字串的同一個字元位置)
Medium
- LC 3 Longest Substring Without Repeating Characters(滑動視窗)
- LC 5 Longest Palindromic Substring
- LC 11 Container With Most Water
- LC 15 3Sum
- LC 16 3Sum Closest
- LC 18 4Sum
- LC 75 Sort Colors(Dutch National Flag)
- LC 80 Remove Duplicates from Sorted Array II
- LC 86 Partition List
- LC 88 Merge Sorted Array
- LC 142 Linked List Cycle II
- LC 165 Compare Version Numbers
- LC 167 Two Sum II - Input Array Is Sorted
- LC 240 Search a 2D Matrix II(階梯式雙指標)
- LC 57 Insert Interval(三階段指標掃描)
- LC 763 Partition Labels
- LC 209 Minimum Size Subarray Sum(滑動視窗)
- LC 287 Find the Duplicate Number
- LC 567 Permutation in String(滑動視窗)
- LC 647 Palindromic Substrings
- LC 713 Subarray Product Less Than K
- LC 881 Boats to Save People
- LC 986 Interval List Intersections
- LC 1023 Camelcase Matching
Hard
- LC 42 Trapping Rain Water
- LC 76 Minimum Window Substring(滑動視窗)
- LC 214 Shortest Palindrome
- LC 828 Count Unique Characters of All Substrings
面試提示
| 訊號 | 模式 |
|---|---|
| 「排序 + 找一組配對」 | 排完序後用左右指標 |
| 「原地移除/去重」 | 慢快寫指標 |
| 「鏈結串列有沒有環」 | 龜兔賽跑 |
| 「分成 3 組」 | Dutch national flag |
| 「原地合併有序序列」 | 從後往前填 |
| 「回文檢查」 | 從兩端往內走的指標 |
| 「最大面積/容器」 | 縮掉比較矮的那一側 |
相關文件
- binary_search.md — 左右指標,但每次把範圍砍半而不是掃過去
- sliding_window.md — 由條件驅動的視窗
- n_sum.md — 固定一個元素 + 收斂指標(LC 15、LC 16、LC 18)
- 2_pointers_examples.md — LC 題目實作集
- quickselect.md — 用 partition 找第 K 個元素