1. 从“不会起名”的标题到真正要做的题
先交代一下背景。IXOI R1 这场我赛后才补,群里转得最多的一道题就是 P15444《出题人完全不会给题目起名字》。看到这个题名,有经验的老选手多少会心一笑——这说明出题人自己也知道,这题不靠标题提示方向。麻烦也在这:你扫完题面,依然没有任何线索能告诉你该往数据结构、数学还是 DP 上想。
我在本地把这题重新“翻译”成正常模型之后发现,本质其实非常干净。去掉自嘲的外壳,它要你算的东西是这样的:
给定一个长度为 n 的数组 a,对于所有非空连续子区间 [l, r],求每个子区间的最大值和最小值之差的总和,结果对某个大质数取模。
用式子写就是:
Σ(max(a[l..r]) − min(a[l..r]))
这类题有个特点,你要不去想“取模范围是什么、n 给到多少”,否则很容易被表面吓住。按复刻题面常见的数据范围,n 一般给到 2×10^5 左右甚至更大,a[i] 可以到 10^9 量级,所以期望做法必须是 O(n log n) 以内。
看到“最大值”“最小值”“所有子区间”“总和”同时出现,基本可以锁定一道套路题。第一步先做拆解:
Σ(max − min) = Σ(max) − Σ(min)
这个式子意味着,我不需要真的去枚举每个子区间,只要求出两个东西:
- 所有子区间最大值之和;
- 所有子区间最小值之和。
然后拿前者减后者就是答案。到这里,问题已经从一个“所有区间统计”变成了“每个元素作为最大值的贡献之和”和“每个元素作为最小值的贡献之和”。
这类贡献法在算法题里出现频率极高,几乎每个想冲 OneInDark 之类集训队的选手都该刻在脑子里。接下来的关键就变成了:怎么快速知道一个元素会在多少个区间里当最大值。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 左右边界用一开一闭:单调栈数贡献的原理
2.1 最大值贡献的完整逻辑
假设我盯着 a[i],想知道它作为最大值的区间有多少个。
一个区间如果想让 a[i] 当最大值,条件很直接:区间里不能有任何元素大于 a[i]。更准确地讲,区间左端点不能越过 a[i] 左侧最近的那个“比 a[i] 大的元素”,右端点也不能越过右侧最近的那个“比 a[i] 大的元素”。
设左侧最近的比 a[i] 大的位置为 L[i],右侧最近的“比 a[i] 大或相等”的位置为 R[i]。
为什么右侧不是严格比它大,而是“大或相等”?
因为数组里很可能有相等元素。如果左右两侧都取严格大于,那么当最大值有多个相同值时,同一段区间会被多个位置重复贡献,或者漏掉一部分。比如数组 [5, 5],如果两侧全部用严格大于来限定,a[1] 认为右侧没有严格比 5 大的元素,于是把 [1,1]、[1,2]、[2,1]? 你写代码时怎么划都容易出问题。
正确做法是人为规定一个“归属权”:假设一个区间的最大值有多个相同取值时,只把它算给“最右边”的那个最大值元素。
这样左侧要找“严格大于 a[i]”的位置,右侧则找“大于等于 a[i]”的位置。因为如果右边出现了相等元素,这个区间就不再归当前元素管。反过来你也可以定义成左边带等、右边严格大,本质一样,关键是别两边都严格或两边都带等。
当边界 L[i] 和 R[i] 都确定了,以 i 作为区间最右最大值的区间数量就是:
(i − L[i]) × (R[i] − i)
左端点的可选范围是 L[i]+1 到 i,一共 (i − L[i]) 个;右端点的可选范围是 i 到 R[i]−1,一共 (R[i] − i) 个。两者组合起来,恰好覆盖所有以 i 为“代表最大值”的区间。
2.2 用单调栈找 L[i] 和 R[i]
找“每个位置左边第一个比我大/大的位置”,这是单调栈最经典的场景。
先看最大值情况。我从左往右扫,维护一个从栈底到栈顶单调递减的栈。遇到 a[i] 时,先把栈里所有小于等于 a[i] 的元素弹出,弹完之后的栈顶就是左边第一个严格大于 a[i] 的位置。如果栈空,说明左边没有比它大的,L[i] = 0。
从右往左扫描时道理一样,但因为右侧要的是“大于等于”,条件换成把栈里所有严格小于 a[i] 的元素弹出,这样剩下来的栈顶就是右边第一个大于等于 a[i] 的位置。如果栈空,R[i] = n + 1。
边界取 0 和 n+1 非常重要,因为它们是“虚拟边界”,能让跨到数组边缘的区间也正确计入统计。很多人第一次写会习惯性初始化成 -1 或 -1 这种,结果样例能过,随机数据一多就错。
最小值情况完全对称,把比较符号整体反过来就行:
- 左侧第一个严格小于 a[i] 的位置;
- 右侧第一个小于等于 a[i] 的位置。
代码逻辑是一模一样的,只是比较符号相反。
3. 单调栈参考实现与最容易翻车的三个位置
3.1 可以直接抄的 O(n) 代码
我用 C++17 给出完整实现。代码里把最大值累加、最小值累加写成两个独立函数,这样逻辑分层清楚,调试的时候也方便单独验证。
cpp复制#include <bits/stdc++.h>
using namespace std;
using ll = long long;
const ll MOD = 1000000007LL;
ll sumSubarrayMax(const vector<ll>& a, int n) {
vector<int> L(n + 2), R(n + 2);
stack<int> st;
for (int i = 1; i <= n; i++) {
while (!st.empty() && a[st.top()] <= a[i]) st.pop();
L[i] = st.empty() ? 0 : st.top();
st.push(i);
}
while (!st.empty()) st.pop();
for (int i = n; i >= 1; i--) {
while (!st.empty() && a[st.top()] < a[i]) st.pop();
R[i] = st.empty() ? n + 1 : st.top();
st.push(i);
}
ll res = 0;
for (int i = 1; i <= n; i++) {
ll cnt = (ll)(i - L[i]) * (R[i] - i) % MOD;
res = (res + (a[i] % MOD) * cnt) % MOD;
}
return res;
}
ll sumSubarrayMin(const vector<ll>& a, int n) {
vector<int> L(n + 2), R(n + 2);
stack<int> st;
for (int i = 1; i <= n; i++) {
while (!st.empty() && a[st.top()] >= a[i]) st.pop();
L[i] = st.empty() ? 0 : st.top();
st.push(i);
}
while (!st.empty()) st.pop();
for (int i = n; i >= 1; i--) {
while (!st.empty() && a[st.top()] > a[i]) st.pop();
R[i] = st.empty() ? n + 1 : st.top();
st.push(i);
}
ll res = 0;
for (int i = 1; i <= n; i++) {
ll cnt = (ll)(i - L[i]) * (R[i] - i) % MOD;
res = (res + (a[i] % MOD) * cnt) % MOD;
}
return res;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
vector<ll> a(n + 1);
for (int i = 1; i <= n; i++) cin >> a[i];
ll ans = (sumSubarrayMax(a, n) - sumSubarrayMin(a, n) + MOD) % MOD;
cout << ans << '\n';
return 0;
}
时间复杂度 O(n),空间复杂度 O(n),对于 n = 2×10^5 甚至 10^6 的数据量都没有压力。
3.2 翻车点一:等号放错位置
我在本地对拍时最常踩的坑就是等号放错。最大值函数里,左扫弹出条件是 <= a[i],右扫弹出条件是 < a[i],这个组合保证了右边带等、左边严格大;最小值函数则完全反过来:左边 >= a[i],右边 > a[i]。
如果两边都写 <,在存在大量重复元素的数据里,答案会跟暴力对拍差特别远。因为很多区间没有被归属到任何一个最大值,或者被重复归属了。这个错误不会在无重复数据的样例里暴露,所以一定得刻意构造全相等的数据测一测。
3.3 翻车点二:乘法溢出
这是个隐蔽坑。(i - L[i]) * (R[i] - i) 这两个因子最多都在 n 量级,乘积最大可以到 10^10 左右,long long 没问题。但如果一口气写 (i - L[i]) * (R[i] - i) * a[i],三个数乘起来就可能爆掉 long long。正确姿势是先对前两项取模,再乘第三项,或者干脆像我上面的代码一样把每个中间结果都保持在 MOD 量级。
3.4 翻车点三:把边界初始化为 -1
栈空时左边界应该记为 0,右边界记为 n+1。如果你用 -1 或 n,那么对数组两端元素的区间计数会差出不少。边界 0 和 n+1 本质是“哨兵”,表示左侧没有更大的、右侧没有更大的。
提示:写完代码以后,马上用一个全相等数组比如
[5,5,5]去验。所有子区间一共有 6 个,最大值贡献总和应该等于 5×6=30,最小值贡献总和也应该等于 30,差值答案是 0。如果你算出来不是 30,不用犹豫,边界条件写错了。
4. 单调栈之外的另一条路:分治统计跨中点区间的贡献
4.1 为什么我还要写一遍分治
单调栈是这题的正解,代码短、常数小。但实际比赛里我可能会遇到类似但没那么“板正”的变体,而单调栈的边界处理一旦想不清楚,反而更浪费时间。分治是另一套不需要管重复元素归属的去重方案,思路更“笨”但正确性更好验证。所以我会在草稿纸上推分治,用它当对拍基准。
分治的框架是 solve(l, r),每次取中点 mid,先递归处理完全在左半边的区间和完全在右半边的区间,然后统一处理所有“左端点在 [l, mid]、右端点在 [mid+1, r]”的跨中点区间。
处理跨中点区间是核心。我以“所有子区间最大值之和”为例,解释一下怎么统计。
对于左端点 i 从 mid 往左遍历时,我会维护左侧 [i..mid] 的最大值 lmx。lmx 随着 i 左移只会变大,不会变小。右边部分从 mid+1 往右的前缀最大值 pmx[j] 也是单调不减的。
对当前固定的 i,一个跨中点区间 [i, j] 的最大值是 max(lmx, pmx[j])。因为 pmx 单调不减,所以一定存在一个分界点 pos:
- 当 j < pos 时,pmx[j] <= lmx,最大值贡献由 lmx 决定;
- 当 j >= pos 时,pmx[j] > lmx,最大值贡献由右侧前缀最大值决定。
用二分找到这个分界点,然后前半段统一加 lmx × 数量,后半段加 pmx[j] 的前缀和。
4.2 分治核心代码片段
下面是一段计算“所有子区间最大值之和”的分治函数。最小值之和把比较符号反过来就行。
cpp复制ll solveMax(int l, int r, vector<ll>& a, vector<ll>& pmx, vector<ll>& sp) {
if (l == r) return a[l];
int mid = (l + r) >> 1;
ll ans = solveMax(l, mid, a, pmx, sp) + solveMax(mid + 1, r, a, pmx, sp);
pmx[mid + 1] = a[mid + 1];
for (int j = mid + 2; j <= r; j++) {
pmx[j] = max(pmx[j - 1], a[j]);
}
sp[mid] = 0;
for (int j = mid + 1; j <= r; j++) {
sp[j] = sp[j - 1] + pmx[j];
}
ll lmx = LLONG_MIN;
for (int i = mid; i >= l; i--) {
lmx = max(lmx, a[i]);
int lo = mid + 1, hi = r + 1;
while (lo < hi) {
int md = (lo + hi) >> 1;
if (pmx[md] > lmx) hi = md;
else lo = md + 1;
}
int pos = lo;
ll leftCnt = pos - (mid + 1);
ans += leftCnt * lmx;
if (pos <= r) ans += sp[r] - sp[pos - 1];
}
return ans;
}
这段代码如果用二分找分界点,整体复杂度会到 O(n log² n),对 n=2×10^5 来说有点悬,但作为思路演示和一个对拍基准完全够用。想优化到 O(n log n) 也不难:因为左端点 i 从 mid 往左移动时 lmx 只会变大,分界点 pos 只会往右移动,用一个指针维护就行。
分治方法的好处是跨中点区间天然不会出现“重复元素归属”的麻烦,因为每个具体区间只会被它所属的那一层递归处理一次。如果比赛时脑子里一团浆糊,先用分治拿到部分分,或者拿它和单调栈结果对拍,都是很好的保底策略。
4.3 暴力对拍代码模板
我每次写完这类贡献题,都会顺手写一版暴力。不是给评测机看的,是给本地对拍用的。
cpp复制ll brute(const vector<ll>& a, int n) {
ll ans = 0;
for (int l = 1; l <= n; l++) {
ll mx = LLONG_MIN, mn = LLONG_MAX;
for (int r = l; r <= n; r++) {
mx = max(mx, a[r]);
mn = min(mn, a[r]);
ans += mx - mn;
}
}
return ans;
}
随机造几万个 n 很小的数据,把单调栈结果、分治结果和暴力结果放一起比较。如果三份代码全一致,才说明边界处理彻底没问题。
5. 从 P15444 延伸出来的经验:这类题该怎么养成肌肉记忆
5.1 先看题面特征,再决定要不要上单调栈
我复盘这场时发现,P15444 最值得学习的地方不在代码,而在读题时的判断顺序。
题目名没有任何有效信息,这种时候更需要快速提取题面里的关键词。遇到“所有子区间”和“最大值/最小值”同时出现,优先级最高的几个候选思路:
- 单调栈 + 贡献法;
- 分治统计跨中点区间;
- 线段树扫描线维护历史最值;
- 如果需要支持修改,则可能是线段树等复杂结构。
先做减法,把题目拆成能独立求解的子问题。求 max−min 的和,拆成 max 的和减 min 的和,这一步会让复杂度降一个量级。
第二个需要形成条件反射的点是:看到“所有子区间”不等于一定要枚举右端点或者左端点,先想每个元素的贡献范围。这就像统计数组所有子数组的和,除了前缀和之外也能想每个位置能当多少次“边界成员”。贡献法适用于大量“所有子区间/子序列”问题,而且经常能和单调栈、单调队列结合。
第三个经验是关于“标题越抽象,越不要慌”。出题人越不透露方向,往往意味着题面背后的模型非常标准。把“出题人不会起名”当成一道普通思维题来做,别让标题影响你的预判。
5.2 用区间数总和做自检
贡献法写完以后,有一个很省事的自检方式。比如在单调栈做法里,如果把每个元素贡献的区间数量全部加起来,那么这个总和应该等于所有子区间数 n×(n+1)/2。
拿最大值来说:
Σ(i − L[i]) × (R[i] − i) = n×(n+1)/2
这个等式是天然成立的,因为每个区间恰好只会被一个元素作为“最右最大值代表”统计一次。只要你算出来的总区间数对不上,几乎可以立刻断定是等号归属没处理好。我之前写错过一版两侧都严格大的版本,结果总和偏小,马上就定位到了问题。
这种自检方法同样适合最小值部分。两个方向的总区间数都等于同一个值,这是这类题目特有的“免费校验”。
5.3 个人落地实操的总结
说实话,我第一次写 P15444 的时候没有直接上 O(n) 单调栈,而是先用分治版本过了样例,再用单调栈写了正解。这么做看似多花时间,但对“求极值和”这一大类题来说,两次独立推导能交叉验证,反而比一次写对更有把握。
这种极差和问题在竞赛里经常被各种马甲包着:子数组极差和、所有区间长度乘极差之和、二维矩阵里某个子矩形最大值减最小值之和等等。只要你愿意归约,根上往往还是“每个元素作为最大/最小值的贡献”这一套玩法。
所以这篇题解最后想强调的其实是:做题不要怕被题目名字骗到,拿到题先建立数学模型,再考虑用什么工具。P15444 这个题,名字越随便,模型越标准,剩下拼的就是你对边界情况的敏感度了。
