区间问题表面上都在问“这一段的和是多少”或“这一段都加上多少”,但两类操作的重心恰好相反。前缀和把许多次查询共同依赖的历史累积起来,让一次区间查询变成两个边界值的相减;差分把一次覆盖一整段的更新压缩成两个边界事件,最后再统一还原。它们都不是某个题目的技巧,而是把“区间”改写成“边界”的一套坐标语言。

这一章用半开区间 [l, r) 统一一维、二维和可修改结构的叙述:左端属于区间,右端只是边界。这样,长度恒为 r - l,空区间自然是 l == r,相邻区间 [l, m)[m, r) 不重不漏。读完后,应当能从操作类型判断是做前缀和、差分、正数滑动窗口,还是改用树状数组或线段树。

先把区间写成边界

设数组 a 的长度为 n,并约定所有外部接口均使用半开区间:

[l,r)={l,l+1,,r1},0lrn[l,r)=\{l,l+1,\dots,r-1\},\qquad 0\le l\le r\le n

这里的右端 r 不是最后一个元素的下标,而是最后一个元素之后的位置。这个看似微小的约定,能消除绝大部分 +1-1

  • [0, n) 恰好是整个数组;
  • [i, i) 是合法的空区间,和为零;
  • 删除或拼接边界时无需转换;
  • 前缀的“长度”与前缀数组的下标恰好相同;
  • 差分的结束标记就落在 r,不必猜测是否还需要再加一。

算法题常把查询写成闭区间 [left, right]。处理时只有一个可靠动作:立刻翻译成 [left, right + 1),并把转换固定在输入边界处。不要让一种函数同时混用两种坐标系;它会在数组末尾、单点区间和空数组上制造极难定位的错误。

一维前缀和:查询是两个前缀的差

定义与不变量

定义长度为 n + 1 的数组 pre

pre[0]=0,pre[i+1]=pre[i]+a[i]pre[0]=0,\qquad pre[i+1]=pre[i]+a[i]

因此 pre[i] 表示前 i 个元素 a[0..i) 的和,而不是“下标 i 之前若干不清楚的元素”。对任意合法半开区间,望远镜相消给出:

i=lr1a[i]=(a[0]++a[r1])(a[0]++a[l1])=pre[r]pre[l]\begin{aligned} \sum_{i=l}^{r-1} a[i] &=(a[0]+\cdots+a[r-1])-(a[0]+\cdots+a[l-1])\\ &=pre[r]-pre[l] \end{aligned}

不变量是:构建结束后,pre[i] 永远等于原数组 [0, i) 的元素和。 查询只读取两个已经稳定的边界,不扫描区间内部,所以每次查询为 O(1)O(1)。初始化花 O(n)O(n) 时间与 O(n)O(n) 额外空间;若问题需要保留原数组,这个空间通常很值得。

一维前缀和用 pre r 减去 pre l,直接取得半开区间的元素和

图中 pre[4] 覆盖 a[0..4)pre[1] 覆盖 a[0..1);两者相减恰好留下 a[1..4)。这也是为什么 pre 必须有长度 n + 1:当查询触及数组末尾时,r == n 需要一个真实存在的 pre[n]

可复用模板

下面的类把数据和查询统一为 long long,即使输入元素是 int,累加也不会在中间步骤先溢出。sum(l, r) 的契约是 [l, r);非法边界通过异常暴露,而不是悄悄返回一个似乎合理的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <cstddef>
#include <stdexcept>
#include <vector>

class PrefixSum {
public:
explicit PrefixSum(const std::vector<int>& values)
: pre_(values.size() + 1, 0) {
for (std::size_t i = 0; i < values.size(); ++i) {
pre_[i + 1] = pre_[i] + values[i];
}
}

long long sum(std::size_t left, std::size_t right) const {
if (left > right || right >= pre_.size()) {
throw std::out_of_range("invalid half-open range");
}
return pre_[right] - pre_[left];
}

private:
std::vector<long long> pre_;
};

right >= pre_.size() 正是在检查 right > n:由于 pre_.size() == n + 1right == n 合法。空数组也不需要特殊分支;它构造出单个元素 {0},唯一合法查询是 [0, 0)

LeetCode 303:Range Sum Query - Immutable

303 的名字已经给出关键条件:数组是 immutable。一次性构建前缀和之后,sumRange(left, right) 查询闭区间。题目接口仍是闭区间,但内部不要让闭区间扩散:把它当作 [left, right + 1) 查询即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <cstddef>
#include <vector>

class NumArray {
public:
explicit NumArray(std::vector<int> nums)
: pre_(nums.size() + 1, 0) {
for (std::size_t i = 0; i < nums.size(); ++i) {
pre_[i + 1] = pre_[i] + nums[i];
}
}

int sumRange(int left, int right) const {
const std::size_t begin = static_cast<std::size_t>(left);
const std::size_t end = static_cast<std::size_t>(right + 1);
return static_cast<int>(pre_[end] - pre_[begin]);
}

private:
std::vector<long long> pre_;
};

题目保证索引有效,故代码没有加入无关的输入恢复逻辑。实际工程中,如果 right 可能等于 INT_MAX,应先转为更宽的类型再加一;更常见的方案是直接把公开接口定义为半开区间,从类型与契约上杜绝这次加一。

前缀和不是“任意查询”的答案

前缀和缓存的是静态历史。若构建后修改 a[p] += delta,那么所有 pre[i] (i > p) 都需要同步加上 delta;单点修改最坏为 O(n)O(n)。因此前缀和适合“数组不变、查询很多”,而不适合“每次修改后还要继续查询”。后文会回到这个选型边界。

另一个常见误区是把前缀和当成滑动窗口的替代品。它可以在含负数的数组上回答任意固定区间和,却不能凭自身在 O(n)O(n) 内搜索“和至少为 target 的最短区间”;搜索还需要额外结构。若元素全为正数,窗口和对右扩、左缩具有单调性,应该优先使用/algo/two-pointers/中的滑动窗口,而不是枚举每个端点再套前缀和。

二维前缀和:矩形是四个左上前缀的容斥

从一条线到一个左上矩形

矩阵 arows 行、cols 列。为了继续让下标表示前缀长度,令:

pre[i][j]=0x<i,  0y<ja[x][y]pre[i][j]=\sum_{0\le x<i,\;0\le y<j}a[x][y]

所以 pre 的尺寸是 (rows + 1) × (cols + 1),第 0 行与第 0 列全为零。构建 pre[i][j] 时,把新单元 a[i-1][j-1] 加进来:上方矩形 pre[i-1][j] 与左方矩形 pre[i][j-1] 都包含左上重叠部分 pre[i-1][j-1],它被重复计算一次,必须减回去:

pre[i][j]=a[i1][j1]+pre[i1][j]+pre[i][j1]pre[i1][j1]pre[i][j]=a[i-1][j-1]+pre[i-1][j]+pre[i][j-1] -pre[i-1][j-1]

这里的“减”不是记忆技巧,而是容斥原理:两个集合的并等于二者之和减去交集。二维前缀和正确性的核心不变量是:每个 pre[i][j] 都精确记录原矩阵左上角 [0,i) × [0,j) 的和。

半开矩形查询

对查询矩形 [r1, r2) × [c1, c2),先从最大的左上前缀 pre[r2][c2] 出发:减去目标上方的 pre[r1][c2],再减去目标左方的 pre[r2][c1]。左上角 [0,r1) × [0,c1) 被减了两次,最后补回:

query=pre[r2][c2]pre[r1][c2]pre[r2][c1]+pre[r1][c1]query=pre[r2][c2]-pre[r1][c2]-pre[r2][c1]+pre[r1][c1]

二维前缀和以四个左上前缀通过容斥保留目标半开矩形

如果把二维公式写错,最有效的检查不是盯着符号,而是给每一项上色:最大矩形保留为底图;上方与左方各减一次;它们的重叠区域被减了两次,因此必须加回一次。每项的坐标都应是一个边界,不是最后一个元素下标。

完整二维模板

模板支持空矩阵,也检查矩阵是否为矩形。构建时只读原矩阵,查询仍是 O(1)O(1)。这里返回 long long,因为一个 int 矩阵中成千上万个元素相加极易超过 32 位范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <cstddef>
#include <stdexcept>
#include <vector>

class MatrixPrefixSum {
public:
explicit MatrixPrefixSum(
const std::vector<std::vector<int>>& grid)
: rows_(grid.size()),
cols_(grid.empty() ? 0 : grid[0].size()),
pre_(rows_ + 1, std::vector<long long>(cols_ + 1, 0)) {
for (const std::vector<int>& row : grid) {
if (row.size() != cols_) {
throw std::invalid_argument("grid must be rectangular");
}
}
for (std::size_t i = 1; i <= rows_; ++i) {
for (std::size_t j = 1; j <= cols_; ++j) {
pre_[i][j] = grid[i - 1][j - 1] + pre_[i - 1][j];
pre_[i][j] += pre_[i][j - 1] - pre_[i - 1][j - 1];
}
}
}

long long sum(std::size_t r1, std::size_t c1,
std::size_t r2, std::size_t c2) const {
if (r1 > r2 || c1 > c2 || r2 > rows_ || c2 > cols_) {
throw std::out_of_range("invalid half-open rectangle");
}
return pre_[r2][c2] - pre_[r1][c2]
- pre_[r2][c1] + pre_[r1][c1];
}

private:
std::size_t rows_;
std::size_t cols_;
std::vector<std::vector<long long>> pre_;
};

注意 grid.empty() ? 0 : grid[0].size() 只在空矩阵时读取零列;非空但所有行均为空的矩阵也合法,cols_ == 0,所有合法查询的列边界只能是 0。很多实现把 matrix[0] 写在任何判断之前,因而在 LeetCode 304 的空输入变体或真实服务的空结果集上越界。

LeetCode 304:Range Sum Query 2D - Immutable

304 使用闭矩形 (row1, col1)(row2, col2)。接口需要的只是输入翻译,数据结构本身无需为题目风格改变:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <cstddef>
#include <vector>

class NumMatrix {
public:
explicit NumMatrix(std::vector<std::vector<int>> matrix)
: rows_(matrix.size()),
cols_(matrix.empty() ? 0 : matrix[0].size()),
pre_(rows_ + 1, std::vector<long long>(cols_ + 1, 0)) {
for (std::size_t i = 1; i <= rows_; ++i) {
for (std::size_t j = 1; j <= cols_; ++j) {
pre_[i][j] = matrix[i - 1][j - 1] + pre_[i - 1][j];
pre_[i][j] += pre_[i][j - 1] - pre_[i - 1][j - 1];
}
}
}

int sumRegion(int row1, int col1, int row2, int col2) const {
const std::size_t r1 = static_cast<std::size_t>(row1);
const std::size_t c1 = static_cast<std::size_t>(col1);
const std::size_t r2 = static_cast<std::size_t>(row2 + 1);
const std::size_t c2 = static_cast<std::size_t>(col2 + 1);
const long long result = pre_[r2][c2] - pre_[r1][c2];
return static_cast<int>(result - pre_[r2][c1] + pre_[r1][c1]);
}

private:
std::size_t rows_;
std::size_t cols_;
std::vector<std::vector<long long>> pre_;
};

304 的约束保证矩阵是矩形且索引有效,所以这一版聚焦题意。若你的函数没有这种外部保证,优先复用前一节的检查版本。二维前缀和的构建为 O(rowscols)O(rows\cdot cols),空间同阶;若只查询极少数矩形,预处理反而可能浪费,直接扫描小矩形更简单。预处理的价值来自大量、重复、不可修改的查询。

前缀和加哈希表:把“区间”改写成两个同余前缀

前两节的查询端点是外部给定的。还有一大类题目要求“统计有多少个子数组满足某个和”,端点未知,暴力枚举所有 [l, r) 会有 O(n2)O(n^2) 个候选。前缀和仍然能降维:

pre[r]pre[l]=kpre[l]=pre[r]kpre[r]-pre[l]=k\quad\Longleftrightarrow\quad pre[l]=pre[r]-k

从左到右扫描右边界 r 时,只要知道此前每种前缀和出现了几次,就能立刻知道有多少个左边界 l 能与当前 r 配对。哈希表的键是前缀和,值是此前出现次数。

最重要的扫描顺序

对当前元素更新出 prefix 后,顺序必须是:

  1. 先读取 freq[prefix - k],把此前可配对的前缀加入答案;
  2. 再执行 ++freq[prefix],把当前前缀交给未来的右边界使用。

为什么不能反过来?当 k == 0 时,若先插入当前 prefix,当前边界会和自己配对,等价于把空区间 [r, r) 错算进答案。我们需要的左边界严格早于当前右边界,因此哈希表在查询时只能代表“过去”。

初始化 freq[0] = 1 同样不是模板装饰。它表示尚未取任何元素的空前缀 pre[0],让从下标 0 开始、和为 k 的子数组自然满足 0 = prefix - k。没有它,所有前缀起点的答案都会漏掉。

LeetCode 560:Subarray Sum Equals K

数组可以有负数,故不能使用正数滑动窗口;窗口扩大时和不一定增加,缩小时也不一定减少。哈希前缀和不依赖元素符号,线性扫描即可统计所有连续子数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <unordered_map>
#include <vector>

int subarraySum(const std::vector<int>& nums, int target) {
std::unordered_map<long long, int> freq;
freq.reserve(nums.size() * 2 + 1);
freq[0] = 1;

long long prefix = 0;
int answer = 0;
for (int value : nums) {
prefix += value;
const auto found = freq.find(prefix - target);
if (found != freq.end()) {
answer += found->second;
}
++freq[prefix];
}
return answer;
}

复杂度是期望 O(n)O(n) 时间、O(n)O(n) 空间。reserve 不是正确性的必需品,却能降低大量不同前缀和时的重哈希次数;它不替代良好的哈希函数,也不改变最坏情况下哈希表可能退化的理论上界。prefix 使用 long long,因为即使答案受题目约束,途中累计值也可能溢出 int

手算 nums = [1, 2, 1, 2, 1]k = 3 时:扫描到第二个元素前缀为 3,读取 freq[0] 得到 [0,2);扫描到第四个元素前缀为 6,读取 freq[3] 得到 [2,4);扫描到第五个元素前缀为 7,读取 freq[4] 得到 [3,5)。每次都是“当前右边界”向历史索要所需的左边界。

LeetCode 525:Contiguous Array

525 表面上在问 0 和 1 数量相等的最长连续子数组。把 0 视为 -1、把 1 视为 +1 后,区间内 0、1 数量相同,等价于变换后区间和为 0:

count(1)count(0)=0count(1)-count(0)=0

这一次不再统计每个前缀出现次数,而是记录每个前缀和最早出现的位置。若同一前缀值在位置 ij 出现,i < j,则 (i, j] 的变换和为零;固定 j 时,最早的 i 给出最大长度。因此第一次出现后绝不能覆盖。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <algorithm>
#include <unordered_map>
#include <vector>

int findMaxLength(const std::vector<int>& nums) {
std::unordered_map<int, int> first_index;
first_index.reserve(nums.size() * 2 + 1);
first_index[0] = -1;

int balance = 0;
int answer = 0;
for (int i = 0; i < static_cast<int>(nums.size()); ++i) {
balance += nums[i] == 1 ? 1 : -1;
const auto found = first_index.find(balance);
if (found != first_index.end()) {
answer = std::max(answer, i - found->second);
} else {
first_index[balance] = i;
}
}
return answer;
}

这里 first_index[0] = -1 是 0 长度前缀的坐标表示:若位置 i 的平衡值第一次回到 0,合法区间就是 [0, i + 1),长度为 i - (-1)。注意 560 的 map 存“次数”,525 的 map 存“最早位置”;键都叫前缀和,但值的语义由题目目标决定,不能机械互换。

同一骨架还能处理什么

前缀和哈希的统一问题是:为每个右边界寻找满足某个代数关系的历史前缀。常见变体包括:

  • 子数组和可被 k 整除:存储 prefix mod k 的频次,余数应规范到 [0, k)
  • 和为零的最长子数组:记录每个前缀和最早位置;
  • 0、1 数量相等:把一类映射为 +1,另一类映射为 -1
  • 多类别计数相等:把若干计数差组成状态向量,状态相同意味着中段平衡;
  • 二维子矩阵和等于目标:固定上下边界,把列和压成一维,再调用 560 的计数器。

不同于/algo/dp/中“状态转移依赖此前最优解”的动态规划,这里的哈希表不保存最优子结构;它保存的是历史前缀的可计数、可定位信息。相同的线性扫描外观,正确性来源完全不同:DP 靠转移覆盖所有状态,前缀哈希靠代数等价把一对端点化为一个键查询。

一维差分:更新是两个边界事件

从相邻变化量出发

给定数组 a,定义差分数组 diff

diff[0]=a[0],diff[i]=a[i]a[i1](i>0)diff[0]=a[0],\qquad diff[i]=a[i]-a[i-1]\quad(i>0)

它记录的不是每个位置的绝对值,而是从前一格移动到这一格时发生了多大变化。反过来,原数组可以通过一次前缀累加还原:

a[i]=j=0idiff[j]a[i]=\sum_{j=0}^{i}diff[j]

现在把 [l, r) 的所有元素加上 delta。区间内部相邻两个位置同时增加 delta,它们的差不变;只有进入区间时产生一次 +delta,离开区间时产生一次 -delta

diff[l]+=delta,diff[r]=deltadiff[l]+=delta,\qquad diff[r]-=delta

r == n,结束事件仍需要一个落点,所以实际差分数组常分配为 n + 1。这不仅免去分支,也让“每个半开区间都在 r 停止”的规则不被数组末尾破坏。

半开区间的差分更新只标记开始和结束,随后以一次前缀和还原覆盖效果

这与前缀和形成漂亮的对偶:前缀和把“查询一个区间”化简为两个边界相减;差分把“更新一个区间”化简为两个边界修改。它们可以串联:先在差分上做许多 O(1)O(1) 更新,再一次前缀还原得到最终数组。

从零开始的区间加模板

下面的函数接收长度和若干半开更新,每条更新为 {left, right, delta}。它在差分数组上合并事件,最后一次扫描恢复结果。初始化是零数组时,diff[0] 不需要从任何基数组复制;若要在已有数组上更新,下一小节给出做法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <cstddef>
#include <stdexcept>
#include <vector>

struct RangeAdd {
std::size_t left;
std::size_t right;
long long delta;
};

std::vector<long long> applyRangeAdds(
std::size_t size, const std::vector<RangeAdd>& updates) {
std::vector<long long> diff(size + 1, 0);
for (const RangeAdd& update : updates) {
if (update.left > update.right || update.right > size) {
throw std::out_of_range("invalid half-open range");
}
diff[update.left] += update.delta;
diff[update.right] -= update.delta;
}

std::vector<long long> values(size, 0);
long long running = 0;
for (std::size_t i = 0; i < size; ++i) {
running += diff[i];
values[i] = running;
}
return values;
}

这里可以放心写 diff[update.right],因为尺寸是 size + 1。即使更新是 [0, size),停止事件也落在合法的 diff[size]。还原循环只访问 0..size-1,因为输出数组没有位置 size;多出的哨兵格只承担“结束事件”的语义。

在已有数组上叠加更新

若基数组不是零,先构造它的差分:第一格为 base[0],后续格为相邻差;然后继续在边界上合并事件,最后前缀还原。无需对每条更新逐格修改原数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <cstddef>
#include <stdexcept>
#include <vector>

struct RangeAdd {
std::size_t left;
std::size_t right;
long long delta;
};

std::vector<long long> addToBase(
const std::vector<int>& base,
const std::vector<RangeAdd>& updates) {
const std::size_t size = base.size();
std::vector<long long> diff(size + 1, 0);
if (!base.empty()) {
diff[0] = base[0];
}
for (std::size_t i = 1; i < size; ++i) {
diff[i] = static_cast<long long>(base[i]) - base[i - 1];
}
for (const RangeAdd& update : updates) {
if (update.left > update.right || update.right > size) {
throw std::out_of_range("invalid half-open range");
}
diff[update.left] += update.delta;
diff[update.right] -= update.delta;
}

std::vector<long long> result(size, 0);
for (std::size_t i = 0; i < size; ++i) {
result[i] = diff[i] + (i == 0 ? 0 : result[i - 1]);
}
return result;
}

空数组依然自然:没有访问 base[0],所有合法更新只能是 [0,0),返回空结果。不要为了“看起来通用”把 base[0] 无条件读取;空输入不是异常情况,而是半开区间系统中应被良好定义的零长度对象。

LeetCode 1109:Corporate Flight Bookings

1109 的预订 [first, last, seats] 是一组闭区间航班编号,且编号从 1 开始。将它翻译为零基半开区间是 [first - 1, last):原来的最后一班 last 被包含,半开右端正好也写成 last。这个转换后,模板就完全不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <cstddef>
#include <vector>

std::vector<int> corpFlightBookings(
const std::vector<std::vector<int>>& bookings,
int flight_count) {
const std::size_t size = static_cast<std::size_t>(flight_count);
std::vector<long long> diff(size + 1, 0);

for (const std::vector<int>& booking : bookings) {
const std::size_t left =
static_cast<std::size_t>(booking[0] - 1);
const std::size_t right =
static_cast<std::size_t>(booking[1]);
const int seats = booking[2];
diff[left] += seats;
diff[right] -= seats;
}

std::vector<int> answer(size, 0);
long long running = 0;
for (std::size_t i = 0; i < size; ++i) {
running += diff[i];
answer[i] = static_cast<int>(running);
}
return answer;
}

题目保证每个 booking 有三个元素、编号范围有效且最终值可装入 int。在通用 API 中应先验证 booking.size() == 3 及转换前的正数范围,尤其不要把负数 booking[0] - 1 直接转换成 size_t;负数转为无符号数会变成巨大的下标。

二维差分:四个角记录一块矩形的开关

二维差分是“一维只改两个端点”的直积。要给半开矩形 [r1, r2) × [c1, c2) 统一加 delta,在差分矩阵的四个角标记:

diff[r1][c1]+=delta,diff[r2][c1]=delta,diff[r1][c2]=delta,diff[r2][c2]+=delta\begin{aligned} diff[r1][c1]&+=delta,\\ diff[r2][c1]&-=delta,\\ diff[r1][c2]&-=delta,\\ diff[r2][c2]&+=delta \end{aligned}

可以把它看作两个一维停止规则的叠加:沿行方向进入后加、离开后减;沿列方向也进入后加、离开后减。右下角被两个“停止”影响,符号重新变成正号。四角的 + - - + 与二维前缀和的容斥符号完全同构,只是一个在编码更新,一个在解码累积。

二维还原公式

二维差分还原同样做二维前缀和:

value[i][j]=diff[i][j]+value[i1][j]+value[i][j1]value[i1][j1]value[i][j]=diff[i][j]+value[i-1][j]+value[i][j-1] -value[i-1][j-1]

为了让 r2 == rowsc2 == cols 的停止标记有位置,diff 的尺寸也要是 (rows + 1) × (cols + 1)。还原过程可原地覆盖 diff,随后只取左上 rows × cols;额外的末行、末列是边界事件缓冲,不属于最终矩阵。

完整的二维区间加类

MatrixRangeAdder 将更新阶段和还原阶段分开。调用若干次 add 后调用一次 materialize,得到最终矩阵;之后不再允许继续添加更新,避免调用者误以为结果仍会自动同步。若需要多批独立计算,创建新的实例比隐藏状态重置更清晰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <cstddef>
#include <stdexcept>
#include <vector>

class MatrixRangeAdder {
public:
MatrixRangeAdder(std::size_t rows, std::size_t cols)
: rows_(rows), cols_(cols),
diff_(rows + 1, std::vector<long long>(cols + 1, 0)),
materialized_(false) {}

void add(std::size_t r1, std::size_t c1,
std::size_t r2, std::size_t c2, long long delta) {
if (materialized_) {
throw std::logic_error("updates are already materialized");
}
if (r1 > r2 || c1 > c2 || r2 > rows_ || c2 > cols_) {
throw std::out_of_range("invalid half-open rectangle");
}
diff_[r1][c1] += delta;
diff_[r2][c1] -= delta;
diff_[r1][c2] -= delta;
diff_[r2][c2] += delta;
}

std::vector<std::vector<long long>> materialize() {
if (materialized_) {
throw std::logic_error("materialize may be called once");
}
materialized_ = true;
for (std::size_t i = 0; i <= rows_; ++i) {
for (std::size_t j = 0; j <= cols_; ++j) {
if (i > 0) {
diff_[i][j] += diff_[i - 1][j];
}
if (j > 0) {
diff_[i][j] += diff_[i][j - 1];
}
if (i > 0 && j > 0) {
diff_[i][j] -= diff_[i - 1][j - 1];
}
}
}

std::vector<std::vector<long long>> result(
rows_, std::vector<long long>(cols_, 0));
for (std::size_t i = 0; i < rows_; ++i) {
for (std::size_t j = 0; j < cols_; ++j) {
result[i][j] = diff_[i][j];
}
}
return result;
}

private:
std::size_t rows_;
std::size_t cols_;
std::vector<std::vector<long long>> diff_;
bool materialized_;
};

这段还原循环的三个条件项是零边界的显式形式。另一种写法是额外再加一圈零边界,从 1 开始循环;两者都正确,但不要在同一实现中混用“diff 已有哨兵行列”与“循环又偏移一格”的坐标。二维题最常见的 bug 不是容斥本身,而是把原矩阵、前缀矩阵、差分矩阵各自多出的那一圈忘在不同地方。

二维差分的适用条件

二维差分的单次更新是 O(1)O(1),所有更新后还原一次是 O(rowscols)O(rows\cdot cols),空间也是同阶。它特别适合离线问题:航班、日期、棋盘覆盖、热力图标注、多个矩形叠加后只需最终状态。若每做一次矩形更新都要求立刻查询某个矩形和,差分会迫使你频繁还原,失去优势;这时应使用支持在线修改的结构。

二维差分还可以先把初始矩阵转换成二维差分,再把更新四角叠上去,最后还原。转换公式为:

diff[i][j]=a[i][j]a[i1][j]a[i][j1]+a[i1][j1]diff[i][j]=a[i][j]-a[i-1][j]-a[i][j-1]+a[i-1][j-1]

越界项视为零。它是二维前缀公式的逆运算:一个保存“区域总量”,另一个保存“局部变化”。理解这对逆变换,比背下四角符号更可靠。

选型边界:先问操作,再选数据结构

前缀和、差分、滑动窗口看起来都在处理连续区间,却服务于不同的操作序列。判断的第一问不应是“这题像哪道模板”,而应是:输入是否可修改?更新和查询谁更多?元素符号是否提供单调性?答案是否必须在线给出?

静态区间和:前缀和

数组或矩阵固定、要回答很多范围和查询时,前缀和是最短且常数最小的方案。预处理后每次查询 O(1)O(1),而且没有树状数组和线段树的复杂不变量。它还可以派生出“和为目标的子数组数量”“二维子矩阵和”等端点关系题。

但一旦更新插入查询序列,静态前缀和不再合适:更新一个位置会影响其后所有前缀。不要为了保留一个熟悉的模板而在每次更新后重建整个 pre;那是把数据结构选择错误藏在循环里。

批量区间加:差分

若操作是很多次区间加、最后只读取一次最终数组或最终矩阵,差分最合适:每次更新 O(1)O(1),最后还原一次线性完成。它不提供快速的中途查询;还原前 diff[i] 只是变化量,不是位置值,直接把它当结果会得到局部跳变而非真实累计量。

“最后只读一次”是关键限定。若题目让你每次更新后都输出当前最大值,有时可以额外维护事件扫描或离线排序;若每次更新后都要任意范围和,则应转到在线结构,而不是重复前缀还原。

正数数组的最短/最长约束:滑动窗口

对于全为正数(或某些单调非负量)的数组,窗口和随着右端扩张不减,随着左端收缩不增。求“和至少为目标的最短子数组”“和不超过阈值的最长子数组”时,双指针能让左右边界都只前进,总时间 O(n)O(n),空间 O(1)O(1)。这比“前缀和 + 二分”通常更直接。

一旦允许负数,窗口和不再单调:加一个负数可能使和变小,删一个负数反而使和变大。此时不要沿用滑窗的“违反约束就缩左端”规则;针对精确和的计数,使用前缀和哈希;针对更复杂的最短子数组和约束,往往需要单调队列、平衡树或其他题目特定结构。双指针的移动理由必须来自可证明的单调性,详见/algo/two-pointers/

在线可修改的区间结构:树状数组与线段树

“在线”意味着更新与查询交错,且每个查询都必须立即看到此前更新。对于单点加、区间和,树状数组(Fenwick tree)是常用的 O(logn)O(\log n) 解;对于区间加、区间和,可以使用两棵树状数组;对于区间最值、区间赋值、懒标记组合,则通常选线段树。

以下双树状数组模板提供半开区间加与半开区间和。它不是前缀和或差分的替代写法,而是两者在“不断在线维护边界事件”时的升级:第一棵树维护差分事件,第二棵树维护事件乘位置的校正项。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <cstddef>
#include <stdexcept>
#include <vector>

class FenwickRangeSum {
public:
explicit FenwickRangeSum(std::size_t size)
: size_(size), bit1_(size + 1, 0), bit2_(size + 1, 0) {}

void addRange(std::size_t left, std::size_t right,
long long delta) {
checkRange(left, right);
add(bit1_, left + 1, delta);
add(bit1_, right + 1, -delta);
add(bit2_, left + 1, delta * static_cast<long long>(left));
add(bit2_, right + 1, -delta * static_cast<long long>(right));
}

long long sumRange(std::size_t left, std::size_t right) const {
checkRange(left, right);
return prefixSum(right) - prefixSum(left);
}

private:
void checkRange(std::size_t left, std::size_t right) const {
if (left > right || right > size_) {
throw std::out_of_range("invalid half-open range");
}
}

static void add(std::vector<long long>& bit,
std::size_t index, long long delta) {
while (index < bit.size()) {
bit[index] += delta;
index += index & (~index + 1);
}
}

static long long query(const std::vector<long long>& bit,
std::size_t index) {
long long result = 0;
while (index > 0) {
result += bit[index];
index -= index & (~index + 1);
}
return result;
}

long long prefixSum(std::size_t end) const {
const long long count = static_cast<long long>(end);
return count * query(bit1_, end) - query(bit2_, end);
}

std::size_t size_;
std::vector<long long> bit1_;
std::vector<long long> bit2_;
};

这里树状数组内部采用一基索引,外部仍是零基半开坐标。addRange(left, right, delta) 在内部的 left + 1right + 1 放置差分事件;prefixSum(end) 返回外部 [0, end) 的和。index & (~index + 1) 等价于常见的 index & -index,但避免了对无符号 std::size_t 直接写一元负号。任何树状数组实现都必须保证更新循环的初始索引非零,否则 index += lowbit(index) 会永久停在 0。

线段树不应因为“功能更强”就默认选用。它代码长、常数大、懒标记容易错;当操作只是加法和求和时,树状数组更轻。只有需要区间最大/最小、区间赋值、复杂合并信息,或需要特定的更新查询组合时,再为线段树的通用性付出复杂度。

推导时怎样自检:从定义而不是公式开始

模板写得越熟,越容易在变体题里把正确公式套到错误坐标上。一个可靠的做法是把每个表项完整念出来,再检查边界是否满足这句话。

一维:前缀长度的归纳证明

pre[i] 的精确定义是“a 的前 i 个元素之和”。当 i == 0 时,空和为零,故 pre[0] = 0 正确。假设 pre[i] 已经代表 [0, i),将下一个元素 a[i] 加入即可得到 [0, i + 1)

pre[i+1]=pre[i]+a[i]pre[i+1]=pre[i]+a[i]

这也是循环为什么写作“读 values[i]、写 pre[i + 1]”,而不是把两者都写成 i。每一轮循环完成后,pre[0..i+1] 都已经满足定义;循环终止时,定义覆盖 0..n 的全部边界。

查询公式也可从“两个集合的差”而非记忆得到。[0,r) 包含 [0,l),且两者的集合差就是 [l,r);既然求和对不相交并集可加,就有 pre[r] = pre[l] + sum(l,r)。这种推导同时解释了为什么要求 l <= r:若边界倒置,两个前缀没有包含关系,差值虽有数值却不再表示普通区间和。

一个很小的手工用例足以发现大多数 off-by-one:

数组 apre查询正确结果
[][0][0,0)0
[7][0,7][0,1)7
[7][0,7][1,1)0
[2,-3,5][0,2,-1,4][1,3)2

若某份实现无法让四行同时通过,不要立刻加条件分支;通常是 pre 的大小、查询右端或循环写入位置的语义错了一处。

二维:按格子验证容斥

二维公式最容易在行列或 +/- 上失手,可以不依赖任何图形地逐格分类。对 pre[r2][c2] 覆盖的每个单元 (x,y),它可能位于四种区域:

单元位置出现在 pre[r2][c2]减上方减左方加左上最终系数
x < r1, y < c1+1-1-1+10
x < r1, c1 <= y < c2+1-1000
r1 <= x < r2, y < c1+10-100
r1 <= x < r2, c1 <= y < c2+1000+1

只有目标矩形中的格子以系数 1 留下,这就是公式的完整证明。二维差分的四角规则也能用同样的“固定一个格子、累积所有左上事件”检查:一个位置位于更新矩形内时,看到左上角的 +delta,不会越过任一停止边界;位置在右侧、下侧或右下时,至少被一个负角抵消,右下重叠又由正角补齐。

预处理、查询、答案的三个层次

前缀和题经常把这三层混为一谈:

  1. 预处理状态pre、二维 prediff 或哈希表;
  2. 单次操作:一次范围和、一次矩形加、一次读取历史频次;
  3. 最终答案:查询返回值、还原数组、累计计数或最优长度。

例如 560 中的 prefix 是扫描过程的当前状态,freq 是历史状态摘要,answer 才是最终答案;它们都可能是 long longint,但语义不同。再如差分题的 diff 不是“尚未完成的答案数组”,它是事件日志;只有前缀还原后才成为位置值。先分清这三层,再设计循环,能避免把“更新答案”与“更新数据结构”写成错误的顺序。

更多前缀状态变体

“前缀和”中的“和”可以替换为任何可逆或可比较的前缀状态。核心条件不是数组元素必须是数字,而是希望一个区间状态能由两个前缀状态迅速组合或判定。

前缀异或:相同规律,不同运算

异或满足结合律、交换律,且每个数是自己的逆元。因此定义:

px[0]=0,px[i+1]=px[i]a[i]px[0]=0,\qquad px[i+1]=px[i]\oplus a[i]

便有:

a[l]a[l+1]a[r1]=px[r]px[l]a[l]\oplus a[l+1]\oplus\cdots\oplus a[r-1] =px[r]\oplus px[l]

与加法前缀和不同,异或不需要减法;同一个值异或两次会消掉。若问题是“有多少个子数组异或为 target”,扫描时寻找的历史状态是 prefix_xor ^ target,顺序仍然是先查询频次、后插入当前状态。以下函数与 560 的结构几乎一致,只是代数关系变为异或。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <unordered_map>
#include <vector>

long long countSubarraysWithXor(
const std::vector<int>& nums, int target) {
std::unordered_map<int, long long> freq;
freq.reserve(nums.size() * 2 + 1);
freq[0] = 1;

int prefix_xor = 0;
long long answer = 0;
for (int value : nums) {
prefix_xor ^= value;
const auto found = freq.find(prefix_xor ^ target);
if (found != freq.end()) {
answer += found->second;
}
++freq[prefix_xor];
}
return answer;
}

这类变体提醒我们:不要因为题名里没有“sum”就放弃前缀思路。只要区间量可写成“前缀状态 A 与前缀状态 B 的关系”,就可能可以把枚举两端点降成扫描一个端点。

前缀计数差:多种类别的平衡

525 把二元类别转成一个平衡值。若有三类字符,希望区间内三种数量相等,可以固定其中一类为基准,记录两个差值,例如:

state=(count(A)count(C), count(B)count(C))state=(count(A)-count(C),\ count(B)-count(C))

两处前缀状态完全相同,表示中间区间三类的增量相同。此时哈希键不再是一个整数,而是一个二元组;对于最长长度,仍记录最早出现位置;对于数量统计,仍记录频次。状态维度随类别数增长,空间和哈希成本也会增长,因此应确认问题真的需要全量状态,而不是可以借助排序、计数或窗口条件简化。

下面展示最长“a、b、c 数量相同”子串的完整写法。State 的相等关系和哈希必须同时定义,且键保存的是差值,不是三个绝对计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <algorithm>
#include <cstddef>
#include <string_view>
#include <unordered_map>

struct BalanceState {
int a_minus_c;
int b_minus_c;

bool operator==(const BalanceState& other) const {
return a_minus_c == other.a_minus_c
&& b_minus_c == other.b_minus_c;
}
};

struct BalanceStateHash {
std::size_t operator()(const BalanceState& state) const {
const std::size_t first =
static_cast<std::size_t>(
static_cast<unsigned>(state.a_minus_c));
const std::size_t second =
static_cast<std::size_t>(
static_cast<unsigned>(state.b_minus_c));
return first * 1000003U ^ second;
}
};

int longestBalancedABC(std::string_view text) {
std::unordered_map<BalanceState, int, BalanceStateHash> first;
first.reserve(text.size() * 2 + 1);
first[{0, 0}] = -1;

BalanceState state{0, 0};
int answer = 0;
for (int i = 0; i < static_cast<int>(text.size()); ++i) {
if (text[i] == 'a') {
++state.a_minus_c;
} else if (text[i] == 'b') {
++state.b_minus_c;
} else if (text[i] == 'c') {
--state.a_minus_c;
--state.b_minus_c;
}
const auto found = first.find(state);
if (found != first.end()) {
answer = std::max(answer, i - found->second);
} else {
first.emplace(state, i);
}
}
return answer;
}

函数把非 a/b/c 字符视为状态不变,因此它们可被包含在平衡子串内;若题目要求输入只能含三类字符,应该在调用边界验证或把 else 改为拒绝输入。算法设计中这类“未定义字符如何处理”属于接口契约,不应由哈希表的偶然行为决定。

前缀最值不是可相减前缀

并非所有聚合都能通过两个前缀相减得到区间答案。最小值、最大值、按位或、最大公约数都没有普通减法逆元;存储 prefix_max 后,prefix_max[r] 无法删除 [0,l) 的影响。对静态区间最值可用稀疏表,对在线更新可用线段树;对某些可逆运算如异或可用前缀结构。这个区分非常重要:不要看到“区间”就盲目创建 pre,先确认操作能否从两个前缀恢复。

差分的叠加性与离线视角

差分真正强大的原因不只是一条更新公式,而是线性叠加。若有多次更新 (lt,rt,deltat)(l_t,r_t,delta_t),对某个位置 i,还原后的值为:

tdeltat[lti<rt]\sum_t delta_t\cdot [l_t\le i<r_t]

其中方括号是指示函数。每一条更新在 l_t 放入开始事件、在 r_t 放入结束事件;扫描到 i 时,所有尚未结束的事件之和恰好就是上式。这解释了为什么更新的输入顺序无关,也解释了为什么可以先把几百万条更新压进 diff,最后仅扫描一次。

用事件扫描理解最大重叠

有些题目不需要恢复每个位置,而是要找最多有多少区间同时覆盖一个点,例如会议室容量、航班座位峰值、时间线上并发任务。若坐标可压缩到数组下标,差分还原过程中的 running 最大值就是答案;无需真的保存完整 values

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <algorithm>
#include <cstddef>
#include <stdexcept>
#include <vector>

struct RangeAdd {
std::size_t left;
std::size_t right;
long long delta;
};

long long maxConcurrentCoverage(
std::size_t size, const std::vector<RangeAdd>& intervals) {
std::vector<long long> diff(size + 1, 0);
for (const RangeAdd& interval : intervals) {
if (interval.left > interval.right || interval.right > size) {
throw std::out_of_range("invalid half-open range");
}
diff[interval.left] += interval.delta;
diff[interval.right] -= interval.delta;
}

long long running = 0;
long long maximum = 0;
for (std::size_t i = 0; i < size; ++i) {
running += diff[i];
maximum = std::max(maximum, running);
}
return maximum;
}

若每个区间的 delta 均为 1,maximum 是覆盖数。半开区间还有一个现实含义:在同一时刻结束的任务与开始的任务不冲突,结束事件落在右边界,开始事件也落在该边界;数组差分的相加顺序自动体现了 [start,end) 的约定。若业务采用两端都闭合的离散日期,需要先明确“结束日是否占用”,再选择 right 还是 right + 1 放置结束事件。

坐标很大时:先离散化,再差分

差分数组需要连续下标。若区间端点是时间戳、经纬度或达到 10910^9 的坐标,直接分配数组不可行,但只要最终答案只在事件边界之间变化,便可以坐标离散化:收集所有 leftright,排序去重,用其秩代替原坐标。更新仍只改两个秩位置。

注意离散化后的“一个格子”通常代表一段原坐标,而不是一个单位长度。若目标是最大覆盖次数,秩之间的物理长度无关;若目标是加权面积、区间总长度或对每个整数位置累加,扫描相邻离散坐标时必须乘上真实跨度 coords[i + 1] - coords[i]。离散化不是把大坐标粗暴变小,而是保留所有会改变状态的边界。

差分不适合的更新类型

普通差分擅长“区间全体加同一个值”,因为区间内部的相邻差不变。若更新是区间乘法、区间取最小值、区间赋值或按位置递增,边界事件不再只需要两个标记:

  • 区间等差加法可以维护两层差分,或把贡献拆为斜率与截距;
  • 区间赋值会覆盖此前事件,通常要线段树懒标记;
  • 区间 chmin/chmax 需要更复杂的数据结构与不变量;
  • 每次更新后立即查询则需要在线结构,即使更新本身仍是加法。

选择差分的依据是“更新对区间内部变化量是否保持局部简单”,不是“更新的名字里是否有区间”。把不适合的操作硬塞进差分,常会产生大量补丁分支,最终既不快也不可靠。

由题目语言反推工具的决策流程

遇到数组或矩阵题时,可以按以下顺序提问,而不是先在脑中搜索题号:

  1. 查询还是更新? 仅查询静态数据,考虑前缀和;仅更新并在最后输出,考虑差分。
  2. 端点已给定吗? 已给定范围和直接前缀相减;端点未知但满足精确代数关系,考虑前缀状态加哈希。
  3. 元素是否保证非负或正? 若窗口量随边界单调,考虑滑动窗口;含负数时不要依赖窗口缩扩的直觉。
  4. 操作是否交错在线出现? 是则升级为树状数组、线段树或题目专用数据结构。
  5. 是一维还是二维? 二维把“两个端点”变成“四个角”;先分配好额外的一行一列,再写容斥。
  6. 题目使用的区间制式是什么? 闭区间、一基编号、行列坐标都只在输入输出处转换,内部始终用半开零基。

例如,“给定不可变矩阵,回答十万次子矩形和”在第一步已指向二维前缀和;“给定十万次矩形加,最后打印棋盘”指向二维差分;“更新和查询交替,查询区间最小值”则根本不应停留在前缀/差分族。这个流程的价值在于先排除错误工具,再精化实现细节。

复杂度对照

场景与结构构建/单次更新单次查询空间典型前提
一维静态前缀和构建 O(n)O(n)区间和 O(1)O(1)O(n)O(n)数据不修改、查询多
二维静态前缀和构建 O(rc)O(rc)矩形和 O(1)O(1)O(rc)O(rc)矩阵不修改、查询多
前缀和 + 哈希扫描 O(n)O(n)扫描中统计O(n)O(n)端点未知、需满足代数关系
一维差分每次区间加 O(1)O(1)最后还原 O(n)O(n)O(n)O(n)批量更新、最后读取
二维差分每次矩形加 O(1)O(1)最后还原 O(rc)O(rc)O(rc)O(rc)批量矩形更新、最后读取
正数滑动窗口扫描 O(n)O(n)扫描中求最值O(1)O(1)窗口量对边界单调
树状数组更新 O(logn)O(\log n)区间和 O(logn)O(\log n)O(n)O(n)在线加法/求和
线段树更新 O(logn)O(\log n)查询 O(logn)O(\log n)O(n)O(n)在线复合区间操作

其中 rc 分别为矩阵行数与列数。复杂度表的目的不是背答案,而是在读题时迅速淘汰不匹配的工具:静态查询选前缀和;离线覆盖选差分;正数窗口约束选滑窗;更新查询交错选在线树结构。

容易失分的陷阱清单

  1. 累加溢出。 int 元素并不意味着前缀和能放进 int。构建 prediff、运行和、哈希键以及树状数组节点都应优先使用 long long;转换发生得越晚越安全。二维中元素数量相乘,风险更高。
  2. 忘记 pre[0] pre[0] = 0 是空前缀,不是多余位置。它让 [0, r) 的查询无需特判,也让 560 的 freq[0] = 1 能统计从第 0 个元素开始的子数组。
  3. 空数组访问第一个元素。 values[0]matrix[0]base[0] 都必须在确认非空后读取。正确的 n + 1 前缀和与差分表示能自然定义空输入,不需要伪造一个元素。
  4. 半开边界与闭区间混用。 [l, r) 的查询是 pre[r] - pre[l];闭区间 [left, right] 必须在入口翻译为 [left, right + 1)。1109 的一基闭区间 [first,last] 翻译为零基半开 [first - 1,last)
  5. 差分忘记结束事件。 区间加不是只做 diff[l] += delta;必须同时 diff[r] -= delta。分配 n + 1 长度后,即使 r == n 也要写入结束标记。
  6. 哈希表更新顺序颠倒。 对 560,先读 freq[prefix - k],后写 freq[prefix]。否则 k == 0 时会把当前空区间计数。对 525,首次位置一旦记录就不能覆盖。
  7. 二维容斥符号错位。 最大矩形减上、减左、加左上;二维差分四角为 + - - +。若某一项缺失,重叠区域会被多算或少算。
  8. 二维维度少一圈。 二维 prediff 都需要 (rows + 1) × (cols + 1) 的边界空间。行数与列数不能互换;查询先传行边界再传列边界,并坚持 [r1,r2) × [c1,c2)
  9. 把差分数组直接当答案。 diff 是相邻变化量,只有经过一维或二维前缀还原才是最终值。多个更新可以先全部叠加,不能每次更新都把“还原结果”再当差分继续随意修改。
  10. 对有负数的和问题滥用滑动窗口。 窗口移动缺少单调依据时,局部缩扩无法保证不漏解;精确和计数应回到前缀和哈希,其他约束要重新推导不变量。

小结

前缀和与差分的共同语言是边界:

  • 前缀和把区间查询变成 pre[r] - pre[l]
  • 二维前缀和以四个左上前缀做容斥;
  • 前缀和加哈希把未知左端点变成对历史前缀状态的查询,且必须先查后写;
  • 差分把区间更新变成开始 +delta 与结束 -delta 两个事件;
  • 二维差分用四角标记矩形更新,再做二维前缀还原;
  • 静态查询、离线更新、正数窗口、在线更新查询分别对应不同的数据结构边界。

真正应记住的不是某一行模板,而是每个数组下标的语义:前缀数组的下标是“已经取了多少个元素”,差分数组的值是“跨过这个边界时发生什么变化”。一旦把区间固定成半开坐标,公式、实现、空输入和数组末尾都会自然对齐;题目换成矩形、哈希计数或在线树结构时,仍能从同一套不变量出发推导答案。