前綴和 — 進階模板
範圍 — 六個需要借用其他結構或其他恆等式的前綴和模板:補集技巧、應付含負數陣列的單調雙端佇列、二維的列對壓縮、前綴 XOR、用雜湊表實作的稀疏差分陣列,以及樹上前綴和的計數表。 另見:prefix_sum.md — 母篇:模板 1–8、概念與模板選擇策略;prefix_sum_examples.md — 實作過的例題;monotonic_queue.md — 模板 10 背後的雙端佇列;difference_array.md — 模板 13 的稠密版本;bit_manipulation.md — 為什麼 XOR 跟加法一樣支援同一個相減恆等式;matrix.md — 模板 11 壓掉的那層二維幾何;tree_backtrack.md — 模板 14 所一般化的 root→leaf 路徑模板,以及它需要的「回溯時復原」習慣。
LeetCode 題目清單
總覽
prefix_sum.md 裡的模板 1–8 講的都是同一招:把陣列建出來,然後相減兩項。 下面這六個,就是那一招不夠用的時候。
關鍵性質
- 複雜度:各模板分別標示;每一個的重點都是把 O(n²) 或 O(n·m²) 的掃描壓成 O(n) 或 O(n·m)
- 核心想法:前綴和恆等式
sum(l, r) = P[r+1] - P[l]對任何可逆的合併運算都成立 — 這就是 XOR 可行、而 min/max 不行的原因 - 什麼時候用:當你一眼就想到前綴和,但題目有東西把它弄壞了 — 負數、二維、環狀繞回、座標大到開不出陣列,或者那個「陣列」其實是樹上一條 root→node 的鏈
模板 9:補集技巧 —「總和 − 中間視窗」 — LC 1423Priority 5 of 5 — Must know — expect it in almost every loop
核心想法: 當元素是從兩端拿走時,剩下沒拿的永遠是一段連續的中間子陣列。所以不要去枚舉 (leftTake, rightTake) 的組合,把問題反過來看:
take k from the two ends, MAXIMIZE taken
⇕ (complement)
leave a contiguous window of length n-k, MINIMIZE it
answer = total - min(window of fixed length n-k)
[ 1, 2, 3, 4, 5, 6, 1 ] n=7, k=3 → window length w = 4
└──── take ────┘ windows: [1,2,3,4]=10 [2,3,4,5]=14
↑ leave the cheapest length-4 window [3,4,5,6]=18 [4,5,6,1]=16
total = 22, min window = 10 → answer = 22 - 10 = 12 ✓
這樣一來,「從兩端拿」的題目就變成單純的前綴和上的固定長度視窗問題 — 這也是它歸在這裡、而不是歸在雙指標那份文件的原因。
// java
// LC 1423 - Maximum Points You Can Obtain from Cards
// IDEA: complement — maximize both ends == minimize the middle window of length n-k
// time = O(n), space = O(1)
public int maxScore(int[] cardPoints, int k) {
int n = cardPoints.length, w = n - k;
int total = 0;
for (int x : cardPoints) total += x;
if (w == 0) return total; // take everything
int win = 0;
for (int i = 0; i < w; i++) win += cardPoints[i];
int minWin = win;
// slide the fixed-length window (rolling prefix-sum difference)
for (int i = w; i < n; i++) {
win += cardPoints[i] - cardPoints[i - w];
minWin = Math.min(minWin, win);
}
return total - minWin;
}
# python
# LC 1423 - Maximum Points You Can Obtain from Cards
# IDEA: complement — maximize both ends == minimize the middle window of length n-k
# time = O(n), space = O(1)
def maxScore(cardPoints, k):
n = len(cardPoints)
w = n - k
total = sum(cardPoints)
if w == 0:
return total
win = sum(cardPoints[:w])
min_win = win
for i in range(w, n):
win += cardPoints[i] - cardPoints[i - w] # slide right, drop left
min_win = min(min_win, win)
return total - min_win
變形 — 視窗長度不固定(LC 1658)
一樣是補集,但這裡是從兩端一直拿到總和等於 x,所以中間視窗的和是固定的(total - x),而長度可變、且我們要最大化它。因為 nums[i] >= 1,前綴和嚴格遞增 → 用收縮視窗就可以。
// java
// LC 1658 - Minimum Operations to Reduce X to Zero
// IDEA: remove-from-ends summing to x == keep the LONGEST middle subarray summing to (total - x)
// time = O(n), space = O(1)
public int minOperations(int[] nums, int x) {
int n = nums.length, total = 0;
for (int v : nums) total += v;
int target = total - x;
if (target < 0) return -1;
int left = 0, sum = 0, best = -1;
for (int right = 0; right < n; right++) {
sum += nums[right];
while (sum > target) sum -= nums[left++]; // values are positive → safe to shrink
if (sum == target) best = Math.max(best, right - left + 1);
}
return best == -1 ? -1 : n - best; // ops = n - kept
}
# python
# LC 1658 - Minimum Operations to Reduce X to Zero
# time = O(n), space = O(1)
def minOperations(nums, x):
n = len(nums)
target = sum(nums) - x
if target < 0:
return -1
left = s = 0
best = -1
for right, v in enumerate(nums):
s += v
while s > target:
s -= nums[left]
left += 1
if s == target:
best = max(best, right - left + 1)
return -1 if best == -1 else n - best
如果值可能是負的,收縮迴圈就會壞掉 — 退回模板 2(
{prefix_sum: first_index})去找和為target的最長子陣列。
模板 10:前綴和 + 單調雙端佇列(最短子陣列,允許負數) — LC 862Priority 5 of 5 — Must know — expect it in almost every loop
核心想法:「和 ≥ K 的最短子陣列」只有在所有值都非負時才能直接用滑動視窗(LC 209)。一旦有負數,前綴和不再單調,視窗也就不能安全地收縮。解法:維護一個遞增的前綴和索引單調雙端佇列。
Two rules, both discard indices that can never be the best LEFT end:
1) POP FRONT while p[i] - p[dq.front] >= K
→ dq.front already satisfies the condition at this i.
Any later i' > i gives a LONGER subarray, so this front is used once and dropped.
2) POP BACK while p[dq.back] >= p[i]
→ index i has a prefix sum <= dq.back but is FURTHER RIGHT.
It is better as a left end in every way (smaller sum AND shorter span). dq.back is dead.
Deque therefore holds indices with STRICTLY INCREASING prefix sums. Each index is
pushed once and popped once → O(n) total.
在 nums = [2, -1, 2], K = 3(p = [0, 2, 1, 3])上的追蹤:
i=0 p=0 : deque empty → push 0 dq = [0]
i=1 p=2 : 2-0=2 < 3 → push 1 dq = [0,1]
i=2 p=1 : 1-0=1 < 3 ; p[1]=2 >= 1 → pop back 1, push 2 dq = [0,2]
i=3 p=3 : 3-p[0]=3 >= 3 → ans = 3-0 = 3, pop front
3-p[2]=2 < 3 → stop ; push 3 dq = [2,3]
answer = 3
// java
// LC 862 - Shortest Subarray with Sum at Least K
// IDEA: prefix sum + monotonic increasing deque (handles negative numbers)
// time = O(n), space = O(n)
public int shortestSubarray(int[] nums, int k) {
int n = nums.length;
long[] p = new long[n + 1]; // long: sums can overflow int
for (int i = 0; i < n; i++) p[i + 1] = p[i] + nums[i];
Deque<Integer> dq = new ArrayDeque<>(); // indices, p[] increasing
int ans = n + 1;
for (int i = 0; i <= n; i++) {
// rule 1: front satisfies the condition -> record and retire it
while (!dq.isEmpty() && p[i] - p[dq.peekFirst()] >= k) {
ans = Math.min(ans, i - dq.pollFirst());
}
// rule 2: keep prefix sums increasing
while (!dq.isEmpty() && p[dq.peekLast()] >= p[i]) {
dq.pollLast();
}
dq.offerLast(i);
}
return ans <= n ? ans : -1;
}
# python
# LC 862 - Shortest Subarray with Sum at Least K
# IDEA: prefix sum + monotonic increasing deque (handles negative numbers)
# time = O(n), space = O(n)
from collections import deque
from itertools import accumulate
def shortestSubarray(nums, k):
n = len(nums)
p = list(accumulate(nums, initial=0))
dq = deque() # indices with increasing p[]
ans = n + 1
for i in range(n + 1):
while dq and p[i] - p[dq[0]] >= k: # rule 1: found a valid left end
ans = min(ans, i - dq.popleft())
while dq and p[dq[-1]] >= p[i]: # rule 2: keep p[] increasing
dq.pop()
dq.append(i)
return ans if ans <= n else -1
| 題目 | LC # | 值域 | 該用的工具 |
|---|---|---|---|
| Minimum Size Subarray Sum | 209 | 全正 | 單純滑動視窗(見 sliding_window.md) |
| Shortest Subarray with Sum at Least K | 862 | 可能有負 | 前綴和 + 單調雙端佇列 |
| Subarray Sum Equals K | 560 | 任意 | 模板 2(雜湊表,精確和) |
模板 11:列對壓縮 — 把二維壓成一維前綴和 — LC 363Priority 4 of 5 — High value — a gap here costs you rounds
核心想法: 每個子矩陣都由一組列對 (top, bottom) 加上一段行區間決定。固定列對,把這兩列之間每一行的和壓成一維陣列 colSum,二維問題就變成對應的一維子陣列問題 — 那個你早就會解了。
fix top/bottom rows 1-D array of column sums
┌───────────────┐
│ . . . . . . . │
│ a b c d e f g │ ← top colSum = [a+h, b+i, c+j, ...]
│ h i j k l m n │ ← bottom
│ . . . . . . . │ then answer the 1-D version of the problem
└───────────────┘
O(m²) row pairs × O(n · cost of the 1-D solver)
以 LC 363(最大矩形和 ≤ k) 來說,一維子問題是「和 ≤ k 的最大子陣列」:
run - prefix_j <= k ⟹ prefix_j >= run - k ⟹ 在有序集合中查大於等於 run − k 的最小前綴和(ceiling / bisect_left)。
// java
// LC 363 - Max Sum of Rectangle No Larger Than K
// IDEA: fix row pair -> column sums -> 1D "max subarray sum <= k" via TreeSet ceiling
// time = O(m^2 * n * log n), space = O(n)
public int maxSumSubmatrix(int[][] matrix, int k) {
int m = matrix.length, n = matrix[0].length;
int best = Integer.MIN_VALUE;
for (int top = 0; top < m; top++) {
int[] colSum = new int[n]; // reset per top row
for (int bot = top; bot < m; bot++) {
for (int c = 0; c < n; c++) colSum[c] += matrix[bot][c]; // extend downward
// ---- 1D: max subarray sum <= k ----
TreeSet<Integer> seen = new TreeSet<>();
seen.add(0); // empty prefix sentinel
int run = 0;
for (int c = 0; c < n; c++) {
run += colSum[c];
// want smallest prefix_j with prefix_j >= run - k
Integer lo = seen.ceiling(run - k);
if (lo != null) best = Math.max(best, run - lo);
seen.add(run);
}
}
}
return best;
}
# python
# LC 363 - Max Sum of Rectangle No Larger Than K
# IDEA: fix row pair -> column sums -> 1D "max subarray sum <= k" via bisect on sorted prefixes
# time = O(m^2 * n * log n), space = O(n)
from bisect import bisect_left, insort
def maxSumSubmatrix(matrix, k):
m, n = len(matrix), len(matrix[0])
best = float('-inf')
for top in range(m):
col = [0] * n
for bot in range(top, m):
for c in range(n):
col[c] += matrix[bot][c]
# ---- 1D: max subarray sum <= k ----
seen = [0] # sorted prefix sums, 0 = empty prefix
run = 0
for c in range(n):
run += col[c]
idx = bisect_left(seen, run - k) # smallest prefix >= run - k
if idx < len(seen):
best = max(best, run - seen[idx])
insort(seen, run)
return best
換掉一維解法,就變成另一題。 外層雙迴圈完全相同,只換內層那段:
- LC 1074(統計和為 target 的子矩陣個數)→ 內層解法 = 模板 2 的雜湊表。
- LC 363(最大和 ≤ k)→ 內層解法 = 有序集合 +
ceiling,如上。- 如果
n < m,先轉置,讓平方那一項落在比較小的維度上。
模板 12:前綴 XOR — LC 1310Priority 4 of 5 — High value — a gap here costs you rounds
核心想法: XOR 是自己的反運算(a ^ a = 0),這正是減法對加法所做的事。所以整套前綴和工具只要把 +/- 換成 ^ 就能直接搬過來:
| 和 | XOR | |
|---|---|---|
| 建表 | p[i+1] = p[i] + a[i] |
p[i+1] = p[i] ^ a[i] |
區間 [l, r] |
p[r+1] - p[l] |
p[r+1] ^ p[l] |
| 哨兵 | p[0] = 0 |
p[0] = 0 |
// java
// LC 1310 - XOR Queries of a Subarray
// IDEA: prefix XOR; xor(l..r) = p[r+1] ^ p[l] (XOR is its own inverse)
// time = O(n + q), space = O(n)
public int[] xorQueries(int[] arr, int[][] queries) {
int n = arr.length;
int[] p = new int[n + 1];
for (int i = 0; i < n; i++) p[i + 1] = p[i] ^ arr[i];
int[] res = new int[queries.length];
for (int i = 0; i < queries.length; i++) {
res[i] = p[queries[i][1] + 1] ^ p[queries[i][0]];
}
return res;
}
# python
# LC 1310 - XOR Queries of a Subarray
# time = O(n + q), space = O(n)
from itertools import accumulate
def xorQueries(arr, queries):
p = list(accumulate(arr, lambda a, b: a ^ b, initial=0))
return [p[r + 1] ^ p[l] for l, r in queries]
變形 — 把 XOR 位元遮罩當成奇偶指紋(LC 1915)
轉折在於:不是對數值做 XOR,而是對一個每個字母佔一個 bit 的遮罩做 XOR,讓遮罩的第 c 個 bit 代表「到目前為止字母 c 出現了奇數次」。於是子字串 (j, i] 中每個字母都出現偶數次,等價於 mask[i] == mask[j] — 就是模板 2 的雜湊表查詢,只是查的是遮罩而不是和。
// java
// LC 1915 - Number of Wonderful Substrings
// IDEA: prefix XOR bitmask (parity fingerprint) + counting map, like Template 2
// time = O(10n), space = O(2^10)
public long wonderfulSubstrings(String word) {
long[] cnt = new long[1 << 10]; // mask -> how many prefixes had it
cnt[0] = 1; // empty prefix sentinel (== the {0:1} trick)
int mask = 0;
long res = 0;
for (char ch : word.toCharArray()) {
mask ^= 1 << (ch - 'a'); // flip this letter's parity bit
res += cnt[mask]; // all letters even (mask ^ mask == 0)
for (int b = 0; b < 10; b++) {
res += cnt[mask ^ (1 << b)]; // exactly ONE letter odd
}
cnt[mask]++;
}
return res;
}
# python
# LC 1915 - Number of Wonderful Substrings
# time = O(10n), space = O(2^10)
def wonderfulSubstrings(word):
cnt = [0] * 1024
cnt[0] = 1 # empty prefix
mask = res = 0
for ch in word:
mask ^= 1 << (ord(ch) - ord('a'))
res += cnt[mask] # 0 odd letters
for b in range(10):
res += cnt[mask ^ (1 << b)] # exactly 1 odd letter
cnt[mask] += 1
return res
同一副骨架的其他口味: LC 1738(Find Kth Largest XOR Coordinate Value)是模板 5 的排容原理,把
+/-換成^;LC 1829(Maximum XOR for Each Query)則是一路把元素剝掉的後綴 XOR。
模板 13:用雜湊表做稀疏差分陣列(掃描線) — LC 2021Priority 5 of 5 — Must know — expect it in almost every loop
核心想法:就是模板 4 的差分陣列,只是座標空間大到(或負到)開不成陣列。把陣列換成雜湊表,然後走訪 sorted(keys) 而不是 range(n)。
什麼時候該用:
- 座標很大(
-10^8 <= pos <= 10^8)→ 開一個2 * 10^8大小的陣列會爆記憶體 - 座標可能是負的 → 陣列索引得先做偏移
- 真正有意義的位置只有
O(n)個 — 兩個相鄰事件之間的值不會變,所以只有事件點可能是答案
| 陣列差分(模板 4) | 雜湊表差分(模板 13) | |
|---|---|---|
| 儲存 | [0] * (maxCoord + 2) |
defaultdict(int),只有 2n 個 key |
| 空間 | O(座標範圍) | O(n) |
| 走訪 | for i in range(n) |
for k in sorted(d) |
| 時間 | O(range + n) | O(n log n)(排序) |
| 負座標 | 需要偏移 | 直接可用 |
+1 這個小技巧:區間 [p-r, p+r] 是閉區間,所以「結束」標記要放在 p + r + 1,不是 p + r。這裡的差一錯誤是這個模式的頭號 bug。
# python
# LC 2021 - Brightest Position on Street
# IDEA: hashmap difference array + line sweep over sorted event keys
# time = O(n log n), space = O(n)
from collections import defaultdict
class Solution:
def brightestPosition(self, lights):
events = defaultdict(int)
# 1) mark events: +1 where coverage starts, -1 right AFTER it ends
for p, r in lights:
events[p - r] += 1 # starts illuminating at (p - r)
events[p + r + 1] -= 1 # stops illuminating AFTER (p + r) <-- note the +1
max_brightness = curr = ans_pos = 0
# 2) sweep positions in ascending order (sorted keys == the prefix sum walk)
for pos in sorted(events.keys()):
curr += events[pos]
# STRICT `>` + ascending order => ties keep the SMALLEST position
if curr > max_brightness:
max_brightness = curr
ans_pos = pos
return ans_pos
// java
// LC 2021 - Brightest Position on Street
// IDEA: TreeMap keeps keys sorted, so the sweep is just an in-order walk
// time = O(n log n), space = O(n)
public int brightestPosition(int[][] lights) {
TreeMap<Integer, Integer> events = new TreeMap<>();
for (int[] l : lights) {
int p = l[0], r = l[1];
events.merge(p - r, 1, Integer::sum);
events.merge(p + r + 1, -1, Integer::sum);
}
int max = 0, curr = 0, ans = 0;
for (Map.Entry<Integer, Integer> e : events.entrySet()) { // already ascending
curr += e.getValue();
if (curr > max) { // strict > => smallest position wins ties
max = curr;
ans = e.getKey();
}
}
return ans;
}
兩個值得背起來的細節
- 為什麼
sorted()會給出最小的答案 — 前綴和只有在事件由左往右套用時才正確。再配上嚴格的>(不是>=),第一個達到新最大值的位置會被記下來,之後同分的也不會覆蓋它。LC 2021 明確要求「最小的那一個」。 - 為什麼雜湊表比排序過的事件清單好用 — 用 key 分桶,代表同一個座標上的所有
+1/-1在掃描看到它們之前就已經合併好了。改用List<int[]>的話,你還得煩惱同一座標內部的排序先後;用 map 這問題根本不存在。
同一個模式的其他題目
| 題目 | LC # | 事件是什麼 | 備註 |
|---|---|---|---|
| Brightest Position on Street | 2021 | 路燈 [p-r, p+r] |
閉區間 → +r+1 |
| Meeting Rooms II | 253 | 開始 +1、結束 -1 |
結束是開區間 → 不用 +1 |
| Car Pooling | 1094 | 上車 +num、下車 -num |
範圍固定且小 → 用陣列就好 |
| Corporate Flight Bookings | 1109 | 在 [first, last] 上 +seats |
閉區間 → last+1 |
| Maximum Population Year | 1854 | 出生 +1、死亡 -1 |
死亡年份是開區間 |
| My Calendar III | 732 | 預約區間 | 需要即時的 TreeMap(線上處理) |
| Describe the Painting | 1943 | 有顏色的線段 | map 的值 = 顏色總和 |
經驗法則:座標範圍 ≤ 約 10^6 且非負 → 用一般陣列(模板 4)。否則,或座標為負 → 用 HashMap/TreeMap(模板 13)。
模板 14:樹上的前綴和(DFS + HashMap + 回溯) — LC 437Priority 5 of 5 — Must know — expect it in almost every loop
關鍵想法:樹上一條往下走的路徑,其實就是那條 root→node 鏈的一段子陣列。所以
模板 2(在 HashMap 裡找 cur - k)原封不動就能用 —— 「那個陣列」只是換成了 DFS 的呼叫堆疊,
而不是 nums。唯一多出來的動作是:這條鏈是一根分支,不是某個全域陣列的前綴,
所以遞迴離開節點時,必須把 map 裡的那筆復原。
| 陣列(模板 2) | 樹(模板 14) | |
|---|---|---|
| 「那個陣列」 | nums[0..i] |
root→node 的鏈 = 目前的 DFS 堆疊 |
| 累加值 | cur += nums[i] |
cur += node.val |
| 數以此結尾的路徑 | cnt += map[cur - k] |
一樣 |
| 記下這個前綴 | map[cur] += 1 |
一樣 |
| 把它取消記錄 | 永遠不用 —— 陣列只會變長 | 兩個子節點都走完後 map[cur] -= 1 |
| 結果 | 和為 k 的子陣列個數 |
和為 k 的往下路徑個數 |
什麼時候該拿它出來 —— 三個條件都要成立:
- 路徑必須只能往下(父 → 子),這樣每個候選路徑才是
chain[i..j]; - 路徑起點與終點都可以是任意節點 —— 不綁在 root 或 leaf 上(這正是 tree_backtrack.md 那種單純 root→leaf DFS 解不了的原因);
- 你要做的是計數(或判斷存在性)某個目標和的路徑,而不是在一條可能轉彎的路徑上取最大值 —— 見下面的 什麼時候不要用它。
為什麼那個復原是必須的
少了它,兄弟子樹留下來的前綴還在 map 裡,於是一條橫著跳過樹的「路徑」就會被算進去。
最小的失敗案例,targetSum = 1:
tree: 1 chains: 1 (prefix 1)
/ \ 1 -> 4 (prefix 5)
4 5 1 -> 5 (prefix 6)
correct answer = 1 (the single node `1`)
DFS pre-order, map starts {0: 1}:
node 1 : cur=1 cnt += map[1-1=0] = 1 -> cnt=1 map{0:1, 1:1}
node 4 : cur=5 cnt += map[5-1=4] = 0 -> cnt=1 map{0:1, 1:1, 5:1}
<< no undo here: prefix 5 stays in the map >>
node 5 : cur=6 cnt += map[6-1=5] = 1 -> cnt=2 WRONG
^ that "5" is the left branch's prefix; 4 is not an ancestor of 5
在離開節點時做 map[cur] -= 1,就會在進入右分支之前把前綴 5 丟掉,計數維持在 1。
這樣 map 裡非零的項目,剛好就是當前節點所有祖先的前綴 —— 最多 h 筆,
而這就是 O(h) 空間的由來。
**
-= 1不等於del。**被減過的 key 會以值0留在 dict 裡,所以跑久了 map 可能累積到O(n)個死 key。這對正確性無害 —— 計數0對cnt沒有貢獻 —— 但如果你要那個O(h)是字面上的成立,就在計數歸零時del prefix[cur](Java:prefix.remove(cur))。
Python 特有的細節:那個 map 是每一層 frame 共用的可變物件,所以那個復原是唯一在 限制它作用範圍的東西。單純的
cur(一個int)不需要復原 —— 它是以值的形式一路傳下去的。
Python 模板
# python
# LC 437 - Path Sum III
# IDEA: pre-order DFS + prefix sum HashMap + backtrack ("2-sum on the root->node chain")
# time = O(n), space = O(h)
from collections import defaultdict
class Solution:
def pathSum(self, root, targetSum):
prefix = defaultdict(int)
prefix[0] = 1 # the empty prefix: a path starting AT the root
self.cnt = 0
def dfs(node, cur):
if not node:
return
cur += node.val # 1) extend the chain
# 2) cur - old = targetSum -> old = cur - targetSum
# every recorded ancestor prefix `old` is one path ending at `node`
self.cnt += prefix[cur - targetSum]
prefix[cur] += 1 # 3) publish this prefix to the subtree
dfs(node.left, cur)
dfs(node.right, cur)
prefix[cur] -= 1 # 4) BACKTRACK - leave no trace for siblings
dfs(root, 0)
return self.cnt
Java 模板
// java
// LC 437 - Path Sum III
// IDEA: pre-order DFS + prefix sum HashMap + backtrack
// time = O(n), space = O(h)
public int pathSum(TreeNode root, int targetSum) {
Map<Long, Integer> prefix = new HashMap<>();
prefix.put(0L, 1); // empty prefix
return dfs(root, 0L, targetSum, prefix);
}
private int dfs(TreeNode node, long cur, int target, Map<Long, Integer> prefix) {
if (node == null) return 0;
cur += node.val; // 1) extend
int res = prefix.getOrDefault(cur - target, 0); // 2) count
prefix.merge(cur, 1, Integer::sum); // 3) publish
res += dfs(node.left, cur, target, prefix);
res += dfs(node.right, cur, target, prefix);
prefix.merge(cur, -1, Integer::sum); // 4) BACKTRACK
return res;
}
**Java 的累加值請用
long。**LC 437 允許-10^9 <= node.val <= 10^9,而節點可以到 1000 個, 所以用int累加鏈上的和會溢位。那麼 key 也必須是Long,否則查表會靜悄悄地找不到。
基本情況的兩種寫法 —— 選一種,絕對不要兩種都寫
| 哨兵(推薦) | 明確判斷 | |
|---|---|---|
| 初始化 | prefix = {0: 1} |
prefix = {} |
| 計數 | cnt += prefix[cur - k] |
if cur == k: cnt += 1cnt += prefix.get(cur - k, 0) |
| 怎麼處理「路徑從 root 起算」 | 那筆 0 就處理掉了 |
靠那個 if |
| 風險 | 沒有 | 兩種都寫,每條從 root 起算的路徑都會被重複計算 |
這個哨兵和陣列模板裡的 prefix[0] = 0 是同一個想法:它代表空前綴,
也正是它讓「從 root 開始的路徑」不需要任何特例。
步驟追蹤 — LC 437,最左邊那條鏈
root = [10,5,-3,3,2,null,11,3,-2,null,1], targetSum = 8
chain 10 -> 5 -> 3 -> 3 map = {0:1}
10 : cur=10 need 10-8=2 map[2]=0 cnt=0 map{0:1,10:1}
5 : cur=15 need 15-8=7 map[7]=0 cnt=0 map{...,15:1}
3 : cur=18 need 18-8=10 map[10]=1 <-- HIT cnt=1 map{...,18:1}
the hit prefix 10 is node `10`, so the path is (10..18] = 5 -> 3 = 8 ✓
3 : cur=21 need 21-8=13 map[13]=0 cnt=1
... unwind: 21, 18, 15, 10 each -= 1
the other two paths are found the same way: 5 -> 2 -> 1, and -3 -> 11
複雜度,以及它取代了什麼
| 做法 | 時間 | 空間 | 備註 |
|---|---|---|---|
從每個節點都做一次 DFS(pathSum(root) = dfs(root) + pathSum(left) + pathSum(right)) |
最壞 O(n²),平衡時 O(n log n) | O(h) | 最直覺的第一個答案;先講出來,再改進 |
| BFS 走每個節點 + 各自做 DFS | O(n²) | O(n) | 同樣的工作量,但多花空間 |
| 模板 14 | O(n) —— 每個節點只走一次 | O(h) —— map 只裝祖先 | 把那個 O(h) 講出來;面試官在這題期待的是 O(n) |
同一套骨架,其他題目
| 題目 | LC # | 變的是什麼 | 模板 |
|---|---|---|---|
| Path Sum III | 437 | — 最標準的形式 | 模板 14 |
| Subarray Sum Equals K | 560 | 陣列版;樹只多了那個復原 | 模板 2 |
| Number of Submatrices That Sum to Target | 1074 | 同一個計數表,「鏈」換成固定的一對列 | 模板 11 + 2 |
| Path Sum | 112 | 路徑釘在 root→leaf → 不需要 map,只要帶著 cur |
模板 14 退化版 |
| Path Sum II | 113 | root→leaf 並且要收集路徑 → 帶一個 list,並且要復原它 | 模板 14 退化版 + 回溯 |
| Binary Tree Paths | 257 | 一樣,只是收字串而不是和 | — |
| Sum Root to Leaf Numbers | 129 | cur = cur * 10 + val —— 累加的是一個數字,但一樣是往下的前綴 |
— |
| Sum of Root To Leaf Binary Numbers | 1022 | cur = cur * 2 + val |
— |
| Path Sum IV | 666 | 樹是用 depth-position-value 三元組給的;先重建父子關係,再套同樣的鏈上求和 |
— |
| Sum of Nodes with Even-Valued Grandparent | 1315 | 往下帶的是最近兩個祖先,而不是一個累加和 | — |
把合併運算換掉,只要它是可逆的,恆等式一樣成立 (見上面的 關鍵性質):
- XOR —— 數往下路徑中 XOR 等於
k的個數:cur ^= node.val,查cur ^ k(模板 12); - 取模 —— 數往下路徑中和可被
k整除的個數:以cur % k當 key (模板 3); - min / max —— 不行。
min沒有反運算,所以「前綴 min」沒辦法再被減回去; 那類題目要改用後序遍歷。
什麼時候不要用它 —— 路徑會轉彎,或答案是以子樹為單位
如果路徑可以在某個節點轉彎(left → node → right),或者要求的量是某棵子樹的性質,
那麼「鏈」這個框架就是錯的,答案是後序 DFS 把值往上回傳:
| 題目 | LC # | 為什麼不能用前綴和 |
|---|---|---|
| Binary Tree Maximum Path Sum | 124 | 路徑會轉彎;要在每個節點用 left + val + right 取最大 |
| Diameter of Binary Tree | 543 | 會轉彎;leftDepth + rightDepth |
| Longest Univalue Path | 687 | 會轉彎 |
| Most Frequent Subtree Sum | 508 | 是對子樹和做的 HashMap,由下往上算 —— 不是鏈上的前綴 |
| Count Nodes Equal to Average of Subtree | 2265 | 需要每棵子樹往上回傳 (sum, count) |
面試中的提示語:「不必從 root 或 leaf 開始或結束,但必須往下走」 → 模板 14。「路徑可以經過某個節點」/「任意兩個節點之間」→ 後序遍歷 (或 LCA,見 tree_lca_distance.md)。
模板 9-14 — 題目索引
| 題目 | LC # | 關鍵技巧 | 難度 | 模板 |
|---|---|---|---|---|
| Maximum Points You Can Obtain from Cards | 1423 | 總和 − 最小固定視窗 | Medium | 模板 9 |
| Minimum Operations to Reduce X to Zero | 1658 | 總和 − 和為 total−x 的最長視窗 | Medium | 模板 9 變形 |
| Shortest Subarray with Sum at Least K | 862 | 前綴和 + 單調雙端佇列 | Hard | 模板 10 |
| Minimum Size Subarray Sum | 209 | 全正 → 單純滑動視窗 | Medium | 模板 10(對照組) |
| Max Sum of Rectangle No Larger Than K | 363 | 列對壓縮 + 有序集合 | Hard | 模板 11 |
| Number of Submatrices That Sum to Target | 1074 | 列對壓縮 + 雜湊表 | Hard | 模板 11 + 2 |
| XOR Queries of a Subarray | 1310 | 前綴 XOR 區間查詢 | Medium | 模板 12 |
| Number of Wonderful Substrings | 1915 | 前綴 XOR 位元遮罩奇偶 + 計數 | Medium | 模板 12 變形 |
| Find Kth Largest XOR Coordinate Value | 1738 | 二維前綴 XOR(排容原理) | Medium | 模板 12 + 5 |
| Number of Sub-arrays With Odd Sum | 1524 | 前綴奇偶計數(模板 3 取 k = 2) | Medium | 模板 3 變形 |
| Max Consecutive Ones III | 1004 | 0/1 轉換,最多 k 個 0 的最長視窗 | Medium | 模板 6/滑動視窗 |
| Number of Good Ways to Split a String | 1525 | 前綴相異數 vs 後綴相異數 | Medium | 模板 1(前綴 + 後綴) |
| Minimum Number of Operations to Move All Balls to Each Box | 1769 | 左→右與右→左的累計(數量、成本)掃描 | Medium | 模板 7 變形 |
| Plates Between Candles | 2055 | 前綴盤子數 + 最近蠟燭索引陣列 | Medium | 模板 1(離線查詢) |
| Find Good Days to Rob the Bank | 2100 | 前綴非遞增/後綴非遞減的連續長度 | Medium | 模板 1 變形 |
| Product of the Last K Numbers | 1352 | 前綴乘積(遇到 0 就重設清單) | Medium | 模板 1 變形 |
| Brightest Position on Street | 2021 | 雜湊表差分陣列 + 依 key 排序掃描 | Medium | 模板 13 |
| Describe the Painting | 1943 | 雜湊表差分陣列,值 = 顏色總和 | Medium | 模板 13 |
| My Calendar III | 732 | TreeMap 差分陣列,線上求最大重疊 | Hard | 模板 13 |
| Path Sum III | 437 | 在 root→node 的鏈上做前綴和 + 回溯 | Medium | 模板 14 |
| Path Sum | 112 | root→leaf 的鏈上求和,不需要 map | Easy | 模板 14(退化版) |
| Path Sum II | 113 | root→leaf 的鏈 + 路徑回溯 | Medium | 模板 14(退化版) |
| Sum Root to Leaf Numbers | 129 | 沿著鏈往下累一個數字(cur*10 + val) |
Medium | 模板 14(退化版) |
| Path Sum IV | 666 | 從 depth-pos-val 重建樹,再做鏈上求和 |
Medium | 模板 14(退化版) |
交叉參考: 精確和的雜湊表補集寫法(
prefix_sum - k)在n_sum.md裡也被寫成「在前綴和上做 2-sum」的模板 — 本文一律使用上面模板 2 的版本。模板 14 就是同一個補集技巧沿著 DFS 堆疊跑;它在樹那一側的視角寫在tree.md與binary_tree.md。