☰
线段树区间最大子段和:四元信息合并与完整模板
2026/9/29 5:53:44 网站建设 项目流程

1. 从一道题说起:区间最大子段和到底难在哪

线段树配上区间最大子段和,算是数据结构里一个经典的"组合技"题目。我第一次遇到它的时候,第一反应是:最大子段和不是有 O(n) 的 DP 吗,一个循环就能解决的事,为什么非要套个线段树上去?后来才明白,问题根本不在"算一次",而在于边算边改、边改边问——数组里的元素会被单点修改,同时要回答任意区间内的最大子段和。这时候 O(n) 的 DP 每次查询都跑一遍,配合 m 次询问就直接是 O(nm),n 和 m 都到 1e5 量级的话,稳稳超时。

所以要解决的问题定义很清晰:给定一个长度为 n 的数组,支持两种操作——把某个位置的值改成 v;查询区间 [l, r] 内所有连续子段中,和最大的那个值。注意"连续"和"非空"这两个约束,它们决定了后面的所有细节。适合谁看?如果你已经会写基础的线段树(建树、单点修改、区间最值/区间求和),但对"维护一个能合并的复合信息"还没形成感觉,这篇正好可以当成从"单值维护"跨到"结构体维护"的过渡案例。

为什么这个题值得单独拿出来讲?因为它是"信息扩容"思路的最佳教学样本。很多人以为最大子段和没法用线段树维护,理由是"两个区间的最大子段和没法拼出整体的最大子段和"——这句话只对了一半。单个 tmax 确实拼不出来,但如果你愿意多存三个量,整件事就豁然开朗了。这种"为了让信息可合并,主动增加维护维度"的思维,在后面做线段树合并、做树上问题、做扫描线时都会反复用到,属于一招通吃的基本功。

我们从最直白的地方切入:假如数组是 [1, -2, 3, 4, -1, 2, -5, 3],问区间 [2, 6](即 -2, 3, 4, -1, 2)的最大子段和是多少。目测答案是 3+4-1+2 = 8。但如果问的是 [1, 8],答案会变成 3+4-1+2 = 8 还是 1-2+3+4-1+2 = 7?要现场手算就有点烦了。而这还只是一次查询。真实场景里是十万次查询叠十万次修改,手算和暴力都不现实,我们需要一个每步都对数级别、且能稳定合并的结构。

2. 节点信息设计:四个量撑起整个结构

2.1 sum、lmax、rmax、tmax 各自的职责

线段树的每个节点对应数组的一段连续区间。在做区间和的时候,节点只存一个 sum 就够;但在最大子段和这里,光有 sum 不够用,因为"最大"这个操作和"求和"这个操作没法互相推导。我们的做法是让每个节点同时维护四个量:sum表示这段区间的元素总和;lmax表示这段区间内所有前缀(必须以区间左端点开头)的最大和;rmax表示所有后缀(必须以区间右端点结尾)的最大和;tmax表示这段区间内所有连续子段的最大和,也就是我们最终要回答的答案。

用一个生活化的类比:把区间想象成一条街上的店铺,sum 是这条街所有店铺的净利润总和,lmax 是"从左往头数,连续开着的店铺能带来的最大收益",rmax 是"从右往头数"的版本,tmax 则是"整条街上任意一段连续店铺的最大收益"。单独看 tmax,你会觉得它和邻居的 tmax 没法定量拼接;但有了 lmax 和 rmax 这两个"接口",拼接就有了抓手。

关键点在于:这四个量对同一个区间是自洽的,任意一个都能由子节点拼出来。这也正是线段树能维护它的前提——父节点的信息必须能完全由两个子节点的信息推导,不然 pushup 就无从下手。设计节点信息的时候,第一件事永远是问自己:我需要哪些量,才能让合并式封闭?

2.2 为什么这四个量刚好够用,而不是三五个

有人会想,能不能少存一个,比如把 sum 省掉?不行。合并 lmax 和 rmax 的时候要用到 sum:父区间的最大前缀,要么完全落在左子区间里(左.lmax),要么吃掉整个左子区间再往右延伸(左.sum + 右.lmax)。没有 sum 这个量,第二种情况就没法算。那能不能省掉 rmax,只留 lmax?也不行,因为跨越中点的最大子段是"左区间的后缀 + 右区间的前缀",后缀信息必须由 rmax 提供,lmax 在这件事上帮不上忙。四个量之间是相互咬合的,缺一个合并式就断链。

反过来说,也不需要更多。比如加个"最小子段和",那是另一类问题的需求;加个"区间长度",对纯最大子段和而言没有用武之地。判定"够不够"的标准只有一个:把所有需要区分的边界情况列出来,看它们能否只用现有量表达。最大子段和的所有情况无非三种——全在左、全在右、跨中点——四种量刚好把第三种也覆盖住。

提示:节点信息的设计没有标准答案,但有标准流程。先写合并式,写到最后发现缺什么量就补什么量,补到合并式闭合为止。这比拍脑袋列一堆量要靠谱得多,也能避免维护一堆用不上的信息拖慢常数。

3. pushup 合并式逐项推导

3.1 四条公式是怎么推出来的

设左子节点为 L,右子节点为 R,父节点为 P。合并式一共四条,逐条来推。

第一条,P.sum = L.sum + R.sum。这个最直白,总和就是两半相加。

第二条,P.lmax = max(L.lmax, L.sum + R.lmax)。父区间的最大前缀只有两种可能:完全落在左区间内,答案是 L.lmax;或者跨过中点进入右区间,此时必然包含左区间的全部元素,再拼上右区间的某个前缀,取最大就是 L.sum + R.lmax。取两者较大值即可。

第三条,P.rmax = max(R.rmax, R.sum + L.rmax)。和第二条对称,注意"主体"换成了右区间,所以是 R.sum 加上左区间的后缀 L.rmax,别把顺序写反。

第四条,P.tmax = max(max(L.tmax, R.tmax), L.rmax + R.lmax)。父区间的最大子段有三种可能:整段在左、整段在右、跨中点。前两种直接取子节点的 tmax,第三种必然是"左区间的某个后缀 + 右区间的某个前缀",而要让这个和最大,后缀和前缀得各自取最大,也就是 L.rmax + R.lmax。这里有个容易理解的直觉:跨越中点的子段一定是"贴着中点的",左边必须顶到右边界的某个后缀,右边必须顶到左边界的某个前缀,中间不能断。

这四条式子里最反直觉的是第四条里为什么用 rmax + lmax 而不是别的组合。你可以这样想:任何跨越中点的合法子段,都恰好是"左区间的后缀"和"右区间的前缀"的并集,而这两种形态的最大值分别是 rmax 和 lmax,由于两者相互独立、互不影响,直接相加就得到了这类子段的最优解。这个独立性是成立的,因为后缀和前缀的选择不会互相约束。

3.2 单点初始化与边界的正确性

叶子节点是最基本的单位,它对应的区间只有一个元素 val。此时sum = lmax = rmax = tmax = val。四个量全都等于这个值,因为对于单元素区间而言,"总和""最大前缀""最大后缀""最大子段"都只能取这个元素本身。这个初始化看起来平凡,但它决定了整棵树在递归底部是否正确。

这里必须强调一个实战里经常翻车的点:题目通常要求子段非空。也就是说,即便数组全是负数,答案也必须是那个最大的单个负数,而不是 0。如果你在初始化时把 lmax、rmax、tmax 设成了 0,那么合并时这些 0 会污染结果,最后会输出一个 0,答案就错了。所以单点初始化一定要老老实实用 val 本身,不能想当然地取 max(0, val)。

注意:这四条公式我第一次写的时候把第四条写成了L.tmax + R.tmax,还理直气壮地觉得"最大值加最大值肯定最大"。实测直接错,因为最大子段和不是简单叠加,两个正的最大值拼起来可能恰好跨越了负的中段,反而不如别的组合。合并式必须逐项对应"全左、全右、跨中"三种情况,不能凭感觉。

4. 代码落地:建树、修改、查询完整实现

4.1 建树与单点修改的标准写法

建树是标准的递归模板。用数组模拟线段树,节点 p 的左儿子是 2p,右儿子是 2p+1,数组开 4n 保险。递归到叶子直接初始化单点,回溯时用 merge 函数合并两个儿子。单点修改也类似:递归找到对应叶子,改成新值,回溯时沿途重新 pushup。这两个操作几乎是所有线段树题的通用骨架,把 merge 换成你的合并逻辑就能直接复用。

const int MAXN = 100005; const int NEG = -0x3f3f3f3f; // 约 -1.06e9,后续会解释为什么用它 struct Node { int sum; // 区间和 int lmax; // 最大前缀和 int rmax; // 最大后缀和 int tmax; // 最大子段和 }; int a[MAXN]; Node tree[MAXN << 2]; Node merge(const Node &L, const Node &R) { Node res; res.sum = L.sum + R.sum; res.lmax = std::max(L.lmax, L.sum + R.lmax); res.rmax = std::max(R.rmax, R.sum + L.rmax); res.tmax = std::max(std::max(L.tmax, R.tmax), L.rmax + R.lmax); return res; } Node make_node(int val) { Node res; res.sum = res.lmax = res.rmax = res.tmax = val; return res; } void build(int p, int l, int r) { if (l == r) { tree[p] = make_node(a[l]); return; } int mid = (l + r) >> 1; build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r); tree[p] = merge(tree[p << 1], tree[p << 1 | 1]); } void update(int p, int l, int r, int x, int v) { if (l == r) { tree[p] = make_node(v); return; } int mid = (l + r) >> 1; if (x <= mid) update(p << 1, l, mid, x, v); else update(p << 1 | 1, mid + 1, r, x, v); tree[p] = merge(tree[p << 1], tree[p << 1 | 1]); }

代码里用了>=关系的 max 调用,命名空间统一用std::max,避免写using namespace std带来的一些命名冲突(虽然竞赛里一般无所谓)。MAXN << 2就是 4 倍,这是线段树数组的常用上界,原因是递归划分的节点总数不会超过 4n,别省这个量,省了就等着数组越界。

4.2 区间查询:返回值必须是一个结构体

区间查询是整个题最容易写错的地方,因为查询区间可能横跨左右子树,也可能只落在一侧。经典的写法是让查询函数直接返回一个 Node,如果查询区间完全覆盖当前节点,直接把tree[p]返回;如果只和左儿子相交,递归左儿子;如果只和右儿子相交,递归右儿子;如果两边都相交,就分别查左右再 merge。

Node query(int p, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[p]; int mid = (l + r) >> 1; if (qr <= mid) return query(p << 1, l, mid, ql, qr); if (ql > mid) return query(p << 1 | 1, mid + 1, r, ql, qr); Node L = query(p << 1, l, mid, ql, qr); Node R = query(p << 1 | 1, mid + 1, r, ql, qr); return merge(L, R); }

这个版本的好处是不存在空区间。因为只要查询区间和当前节点有交,那么递归下去要么整个落在左半边,要么整个落在右半边,要么真的两边都要。三个分支互斥且完备,不会出现"左半边返回空、需要特殊处理"的情况。很多朋友写查询喜欢先递归两边再 merge,结果遇到空区间就不知道怎么合了,还要专门构造一个空节点去兜底,代码一下子复杂起来。三分支写法直接把这个问题消灭在源头。

给一个调用示例:读入 ql、qr 之后,Node ans = query(1, 1, n, ql, qr);然后输出ans.tmax即可。注意建树时区间是 [1, n],所以数组从下标 1 开始存,这也是竞赛里的惯例。

4.3 完整可编译模板

把上面的片段拼起来,加上输入输出,就是一个可以直接提交的程序。

#include <cstdio> #include <algorithm> const int MAXN = 100005; struct Node { int sum, lmax, rmax, tmax; }; int a[MAXN]; Node tree[MAXN << 2]; Node merge(const Node &L, const Node &R) { Node res; res.sum = L.sum + R.sum; res.lmax = std::max(L.lmax, L.sum + R.lmax); res.rmax = std::max(R.rmax, R.sum + L.rmax); res.tmax = std::max(std::max(L.tmax, R.tmax), L.rmax + R.lmax); return res; } Node make_node(int val) { Node res; res.sum = res.lmax = res.rmax = res.tmax = val; return res; } void build(int p, int l, int r) { if (l == r) { tree[p] = make_node(a[l]); return; } int mid = (l + r) >> 1; build(p << 1, l, mid); build(p << 1 | 1, mid + 1, r); tree[p] = merge(tree[p << 1], tree[p << 1 | 1]); } void update(int p, int l, int r, int x, int v) { if (l == r) { tree[p] = make_node(v); return; } int mid = (l + r) >> 1; if (x <= mid) update(p << 1, l, mid, x, v); else update(p << 1 | 1, mid + 1, r, x, v); tree[p] = merge(tree[p << 1], tree[p << 1 | 1]); } Node query(int p, int l, int r, int ql, int qr) { if (ql <= l && r <= qr) return tree[p]; int mid = (l + r) >> 1; if (qr <= mid) return query(p << 1, l, mid, ql, qr); if (ql > mid) return query(p << 1 | 1, mid + 1, r, ql, qr); Node L = query(p << 1, l, mid, ql, qr); Node R = query(p << 1 | 1, mid + 1, r, ql, qr); return merge(L, R); } int main() { int n, m; scanf("%d%d", &n, &m); for (int i = 1; i <= n; ++i) scanf("%d", &a[i]); build(1, 1, n); while (m--) { int op, x, y, v; scanf("%d", &op); if (op == 0) { // 单点修改:a[x] = v scanf("%d%d", &x, &v); update(1, 1, n, x, v); } else { // 区间查询:[x, y] 的最大子段和 scanf("%d%d", &x, &y); printf("%d\n", query(1, 1, n, x, y).tmax); } } return 0; }

这份代码可以直接拿去跑最大子段和的标准题,建树 O(n),每次修改和查询都是 O(log n)。实测下来 n、m 到 1e5 规模,运行时间在几十毫秒量级,非常稳。如果你的数据到 2e5,把 MAXN 调大即可,其它不用动。

5. 查询合并顺序与常见坑速查

5.1 左右顺序颠倒会出什么错

前面第四条合并式里L.rmax + R.lmax这个顺序是有讲究的,交换成L.lmax + R.rmax就错了。原因在于:跨越中点的子段,左边部分必须是"贴着中点的后缀",右边部分必须是"贴着中点的前缀"。如果把 L 和 R 的位置调换,那么语义就变成了"左区间的前缀 + 右区间的后缀",这两部分在位置上根本不连续,拼出来的不是合法子段,结果自然离谱。

查询的时候也要特别注意顺序。三分支写法里,先查左再查右,merge 的时候把左结果放第一个参数、右结果放第二个参数,这个顺序和数组的物理顺序一致,不会出问题。但如果有人为了省事写成"先合并右边再合并左边",就会出现 rmax 和 lmax 用反的 bug。这类 bug 很隐蔽,因为小数据下可能凑巧过,大数据才炸。我的习惯是在 merge 函数里加注释,明确标注哪个参数是左、哪个是右,隔一段时间回看也不会混。

另外提醒一句:如果你选择用"空节点"方案处理查询(即允许返回一个无效区间,再和有效结果合并),那个空节点的构造也得和顺序配合好。空节点通常设成sum = 0, lmax = rmax = tmax = NEG,这样和有效节点合并时才会被"吸收"掉。NEG 要选足够小的负数,但又要保证相加不溢出 int。用 -0x3f3f3f3f(约 -1.06e9)是稳妥的选择,两个 -0x3f3f3f3f 相加约 -2.12e9,刚好在 int 范围内(int 下限约 -2.147e9),不会溢出。如果用 -1e9,两两相加就可能越界。这个"负无穷取值"的细节在竞赛里是常识,但自己写的时候确实容易忽略。

5.2 常见错误现象对照表

把实战里踩过和见过的问题整理成一张表,方便对照排查。

错误现象可能原因排查方法
全负数数据输出 0初始化 lmax/rmax/tmax 时取了 max(0, val)检查make_node,叶子必须等于 val
部分查询结果偏小merge 里 tmax 漏掉L.rmax + R.lmax这一项对照四条合并式逐项核对
结果偏大或为奇怪正数空节点 tmax 设成了 0,污染了合并空节点 tmax 用 NEG,或改用三分支查询
跨中点查询结果错误合并时左右参数顺序颠倒确认 merge(左, 右) 的实参顺序
数组越界、程序崩溃线段树数组只开了 2n 或 n开到MAXN << 2
大数据超时查询里反复值拷贝大结构体加引用传参,或改用更紧凑的写法
单点修改后查询不变update 递归后忘了 pushup检查递归返回后是否重新 merge

这张表里的每一条都是真实会遇到的。尤其是第一条和第三条,全负数据和空节点污染,堪称这个题型的两大新手杀手。全负数据的坑在于测试用例往往都是正负数混合的,本地手测根本发现不了,直到提交才 WA。而空节点污染的问题,我见过不止一个人栽在上面,原因就是构造空节点时想当然地认为"空的和就是 0,那最大值也取 0 吧",结果全负区间被这个 0 顶掉。

提示:调试这类问题时,建议手写一个小数据暴力程序,随机生成 n ≤ 10 的数组和若干操作,把线段树的输出和暴力答案逐条对比。这种对拍方法能在几分钟内定位出绝大多数逻辑错误,比盯着代码发呆高效得多。

6. 变形与扩展:从单点修改到更复杂的版本

6.1 带单点修改和区间限制的版本

单点修改的版本就是上面的模板,直接套用。稍微进阶一点的是查询带左右端点限制的版本:给定两段区间 [l1, r1] 和 [l2, r2](保证 l1 ≤ l2,r1 ≤ r2),要求选一个子段 [x, y],满足 x 在 [l1, r1]、y 在 [l2, r2] 且 x ≤ y,求这个子段的最大和。这类问题需要按两段区间的相对位置分类讨论,大概分成"两段不重叠""两段部分重叠"等几种情况,每种情况用不同的查询组合拼出答案。

分类讨论的时候,你会用到之前维护的 lmax、rmax、tmax 的不同组合。比如两段完全分离时,答案就是"左段的后缀 + 中间整段 + 右段的前缀";两段有重叠时又要细分。虽然讨论起来有点繁琐,但核心还是那四个量的拼接。这类题目的价值在于逼你真正理解 rmax 和 lmax 的语义,因为在分类讨论里你必须清楚地知道"我要的是贴着右边界的后缀"还是"贴着左边界的后缀",一旦概念模糊就没法下手。

6.2 区间加为什么会让问题突然变难

有人会问:如果是区间整体加一个值呢?这个问题就没那么简单了。单点修改时,叶子节点的四个量直接变成新值即可,其余节点 pushup 一次就能修正。但区间加不同:给一个区间整体加 k,这个区间的 sum 会增加 k 乘以区间长度,但 lmax、rmax、tmax 却不是简单加上 k 乘以长度,因为最大前缀/子段可能只取了区间的一部分,加的 k 只会作用在它覆盖的那部分长度上,而到底是多长,节点里并没有直接记录。

要正确处理区间加下的最大子段和,通常需要额外维护"最长前缀的长度"之类的信息,或者干脆放弃线段树,改用分块之类的结构。这也是为什么主流的最大子段和题目大多设计成单点修改,而不是区间加。认识到这一点很重要:不是所有信息都能在线段树上优雅地叠加 lazy 标记,判断一个操作能否 lazy 化的标准,就是看它对节点信息的影响能不能用常数个参数描述。区间的加法对 sum 可以,对 tmax 就不行。

6.3 其他解法与实际选型参考

除了线段树,这类问题还有别的解法,各有适用场景。如果完全没有修改操作,只有一堆静态查询,那可以离线处理,或者用分治(类似 CDQ 分治的思路)把查询挂到区间上,复杂度也是 O((n + m) log n)。如果只需要求整个数组的最大子段和,那 O(n) 的 DP 是最好用的,两个变量滚动一下就行,根本不用线段树。如果需要求的是"和最大的子段,且长度不超过 k",那又是另一套单调队列加前缀和的解法。

选型的判断其实很简单:看有没有修改、查询是不是任意区间。有单点修改、任意区间查询,线段树是首选;纯静态查询可以离线,但线段树写起来反而更省心,不必为了那点常数去折腾复杂的分治。至于分块,在 n 到 1e5 这个量级上,块长取 sqrt(n) 大约 300 多,查询是 O(sqrt(n)),比线段树慢一个量级,一般只在 lazy 标记难以维护时才会退而求其次。我个人的习惯是,只要线段树能写明白,就优先线段树,除非复杂度确实不允许。

最后分享一个我自己的练习方法:把这份模板敲熟之后,试着不看书默写一遍,尤其是 merge 的四条式子。默写的时候特别注意两个地方,一是 tmax 那条有没有漏掉跨界项,二是 rmax 的式子里到底该用 L 还是 R。写完拿小数据对拍,能全过说明你真的理解了,而不是背下来了。这个题看着简单,但它是线段树从"维护单值"进阶到"维护结构体"的分水岭,把这关过了,后面做更复杂的区间信息合并会顺很多。我自己在这一题上前后踩了三次坑,才把四个量的关系彻底捋清,希望你不用重复走这些弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询