引言
传统线段树可以高效处理区间求和、区间最值、区间加减等操作,时间复杂度均为 O(log n)。但当问题升级到区间取模、区间取 min/max(即每个元素与某个值取较小/较大值)时,普通的 lazy propagation 会失效——因为这些操作不满足”区间整体可加性”。
线段树 Beats(由 ILO 于 2017 年在 Codeforces 博客中提出并命名)正是为解决这类问题而生的高级数据结构。它在传统线段树的基础上增加了次大值维护和计数统计,使得区间 chmin/chmax(change min / change max)操作也能在均摊 O(log n) 时间内完成。
本文将用 Java 完整实现线段树 Beats,支持区间求和、区间取 min、区间取 max 操作。
算法原理
为什么传统线段树处理不了区间 chmin?
考虑对一个区间 [l, r] 执行 a[i] = min(a[i], x)。如果区间内所有元素都大于 x,那没问题,直接更新即可。但如果只有部分元素大于 x,传统线段树的 lazy tag 无法表达这种”只影响部分元素”的操作。
核心洞察
线段树 Beats 的关键洞察是:对于 chmin 操作,只有大于 x 的元素会改变。如果区间内最大值 mx 都 <= x,则无需任何操作。如果有元素大于 x,那么:
- 所有大于
x的元素都会被压到x - 如果次大值
se< x < mx,则只有最大值需要改变——这可以用类似 lazy tag 的方式批量处理 - 如果
x <= se,则需要继续下推到子节点
维护的信息
每个节点需要维护:
– sum:区间和
– mx:区间最大值
– cmx:最大值出现的次数
– se:区间次大值(严格小于 mx 的最大值,若不存在则为 -∞)
– mn:区间最小值(对称维护,用于 chmax)
– cmn:最小值出现次数
– se_min:区间次小值
Java 实现
1. 核心数据结构
/**
* 线段树Beats节点
* 同时维护最大值和最小值信息,支持区间chmin/chmax
*/
class BeatsNode {
long sum; // 区间和
// 最大值相关信息
long mx; // 区间最大值
int cmx; // 最大值出现次数
long se; // 次大值(严格小于mx)
// 最小值相关信息
long mn; // 区间最小值
int cmn; // 最小值出现次数
long seMin; // 次小值(严格大于mn)
public BeatsNode() {
this.sum = 0;
this.mx = Long.MIN_VALUE;
this.cmx = 0;
this.se = Long.MIN_VALUE;
this.mn = Long.MAX_VALUE;
this.cmn = 0;
this.seMin = Long.MAX_VALUE;
}
}
/**
* 线段树Beats完整实现
* 支持:区间求和、区间chmin、区间chmax
*/
public class SegmentTreeBeats {
private final int n;
private final long[] arr;
private final BeatsNode[] tree;
public SegmentTreeBeats(long[] arr) {
this.n = arr.length;
this.arr = arr.clone();
this.tree = new BeatsNode[n * 4];
for (int i = 0; i < tree.length; i++) {
tree[i] = new BeatsNode();
}
build(1, 0, n - 1);
}
/**
* 构建线段树
*/
private void build(int node, int l, int r) {
if (l == r) {
tree[node].sum = arr[l];
tree[node].mx = arr[l];
tree[node].cmx = 1;
tree[node].se = Long.MIN_VALUE;
tree[node].mn = arr[l];
tree[node].cmn = 1;
tree[node].seMin = Long.MAX_VALUE;
return;
}
int mid = (l + r) >>> 1;
build(node << 1, l, mid);
build(node << 1 | 1, mid + 1, r);
pull(node);
}
/**
* 从子节点合并信息到父节点
*/
private void pull(int node) {
BeatsNode left = tree[node << 1];
BeatsNode right = tree[node << 1 | 1];
BeatsNode cur = tree[node];
// 合并和
cur.sum = left.sum + right.sum;
// 合并最大值信息
if (left.mx > right.mx) {
cur.mx = left.mx;
cur.cmx = left.cmx;
cur.se = Math.max(left.se, right.mx);
} else if (left.mx < right.mx) {
cur.mx = right.mx;
cur.cmx = right.cmx;
cur.se = Math.max(left.mx, right.se);
} else {
cur.mx = left.mx;
cur.cmx = left.cmx + right.cmx;
cur.se = Math.max(left.se, right.se);
}
// 合并最小值信息
if (left.mn < right.mn) {
cur.mn = left.mn;
cur.cmn = left.cmn;
cur.seMin = Math.min(left.seMin, right.mn);
} else if (left.mn > right.mn) {
cur.mn = right.mn;
cur.cmn = right.cmn;
cur.seMin = Math.min(left.mn, right.seMin);
} else {
cur.mn = left.mn;
cur.cmn = left.cmn + right.cmn;
cur.seMin = Math.min(left.seMin, right.seMin);
}
}
/**
* 对节点应用chmin操作:将所有大于x的元素变为x
* 前提条件:x >= node.se(确保只有最大值需要改变)
*/
private void pushChmin(int node, long x) {
BeatsNode cur = tree[node];
if (cur.mx <= x) return; // 无需操作
// 更新和:将cmx个最大值从mx改为x
cur.sum -= (cur.mx - x) * cur.cmx;
cur.mx = x;
// 注意:mn也可能等于旧mx,需要同步更新
if (cur.mn > x) {
cur.mn = x;
}
// 如果mn等于旧mx,且只有一个最小值,需要更新
// 但这种情况在se维护正确时不会发生
}
/**
* 对节点应用chmax操作:将所有小于x的元素变为x
*/
private void pushChmax(int node, long x) {
BeatsNode cur = tree[node];
if (cur.mn >= x) return;
cur.sum += (x - cur.mn) * cur.cmn;
cur.mn = x;
if (cur.mx < x) {
cur.mx = x;
}
}
/**
* 下推懒标记到子节点
*/
private void push(int node) {
BeatsNode cur = tree[node];
// 下推chmin
if (tree[node << 1].mx > cur.mx) {
pushChmin(node << 1, cur.mx);
}
if (tree[node << 1 | 1].mx > cur.mx) {
pushChmin(node << 1 | 1, cur.mx);
}
// 下推chmax
if (tree[node << 1].mn < cur.mn) {
pushChmax(node << 1, cur.mn);
}
if (tree[node << 1 | 1].mn < cur.mn) {
pushChmax(node << 1 | 1, cur.mn);
}
}
}
2. 区间操作实现
/**
* 区间chmin:将[L,R]内所有大于x的元素变为x
*/
public void rangeChmin(int L, int R, long x) {
rangeChmin(1, 0, n - 1, L, R, x);
}
private void rangeChmin(int node, int l, int r, int L, int R, long x) {
if (r < L || l > R || tree[node].mx <= x) {
return; // 无交集或无需操作
}
if (L <= l && r <= R && tree[node].se < x) {
// 完全覆盖且次大值<x:可以直接打标记
pushChmin(node, x);
return;
}
push(node); // 下推后再递归
int mid = (l + r) >>> 1;
rangeChmin(node << 1, l, mid, L, R, x);
rangeChmin(node << 1 | 1, mid + 1, r, L, R, x);
pull(node);
}
/**
* 区间chmax:将[L,R]内所有小于x的元素变为x
*/
public void rangeChmax(int L, int R, long x) {
rangeChmax(1, 0, n - 1, L, R, x);
}
private void rangeChmax(int node, int l, int r, int L, int R, long x) {
if (r < L || l > R || tree[node].mn >= x) {
return;
}
if (L <= l && r <= R && tree[node].seMin > x) {
pushChmax(node, x);
return;
}
push(node);
int mid = (l + r) >>> 1;
rangeChmax(node << 1, l, mid, L, R, x);
rangeChmax(node << 1 | 1, mid + 1, r, L, R, x);
pull(node);
}
/**
* 区间求和查询
*/
public long rangeSum(int L, int R) {
return rangeSum(1, 0, n - 1, L, R);
}
private long rangeSum(int node, int l, int r, int L, int R) {
if (r < L || l > R) return 0;
if (L <= l && r <= R) return tree[node].sum;
push(node);
int mid = (l + r) >>> 1;
return rangeSum(node << 1, l, mid, L, R)
+ rangeSum(node << 1 | 1, mid + 1, r, L, R);
}
/**
* 区间最大值查询
*/
public long rangeMax(int L, int R) {
return rangeMax(1, 0, n - 1, L, R);
}
private long rangeMax(int node, int l, int r, int L, int R) {
if (r < L || l > R) return Long.MIN_VALUE;
if (L <= l && r <= R) return tree[node].mx;
push(node);
int mid = (l + r) >>> 1;
return Math.max(rangeMax(node << 1, l, mid, L, R),
rangeMax(node << 1 | 1, mid + 1, r, L, R));
}
/**
* 区间最小值查询
*/
public long rangeMin(int L, int R) {
return rangeMin(1, 0, n - 1, L, R);
}
private long rangeMin(int node, int l, int r, int L, int R) {
if (r < L || l > R) return Long.MAX_VALUE;
if (L <= l && r <= R) return tree[node].mn;
push(node);
int mid = (l + r) >>> 1;
return Math.min(rangeMin(node << 1, l, mid, L, R),
rangeMin(node << 1 | 1, mid + 1, r, L, R));
}
/**
* 单点修改(用于初始化和特殊场景)
*/
public void pointUpdate(int idx, long val) {
pointUpdate(1, 0, n - 1, idx, val);
}
private void pointUpdate(int node, int l, int r, int idx, long val) {
if (l == r) {
tree[node].sum = val;
tree[node].mx = val;
tree[node].mn = val;
return;
}
push(node);
int mid = (l + r) >>> 1;
if (idx <= mid) {
pointUpdate(node << 1, l, mid, idx, val);
} else {
pointUpdate(node << 1 | 1, mid + 1, r, idx, val);
}
pull(node);
}
3. 完整可运行示例
/**
* 线段树Beats完整测试与演示
*/
public class SegmentTreeBeatsDemo {
public static void main(String[] args) {
// 初始化数组
long[] arr = {5, 3, 8, 12, 7, 2, 10, 15, 4, 9};
System.out.println("初始数组: " + java.util.Arrays.toString(arr));
SegmentTreeBeats st = new SegmentTreeBeats(arr);
// 查询区间和 [0, 4]
System.out.println("区间[0,4]的和: " + st.rangeSum(0, 4)); // 5+3+8+12+7 = 35
// 查询区间最大值 [2, 6]
System.out.println("区间[2,6]的最大值: " + st.rangeMax(2, 6)); // max(8,12,7,2,10) = 12
// 执行区间chmin [1, 7] x=6:将所有大于6的元素变为6
// 原数组: [5, 3, 8, 12, 7, 2, 10, 15, 4, 9]
// chmin后: [5, 3, 6, 6, 6, 2, 6, 6, 4, 9]
st.rangeChmin(1, 7, 6);
System.out.println("\n执行 rangeChmin[1,7] x=6 后:");
System.out.println("区间[0,4]的和: " + st.rangeSum(0, 4)); // 5+3+6+6+6 = 26
System.out.println("区间[2,6]的最大值: " + st.rangeMax(2, 6)); // max(6,6,6,2,6) = 6
// 执行区间chmax [3, 8] x=5:将所有小于5的元素变为5
// 当前: [5, 3, 6, 6, 6, 2, 6, 6, 4, 9]
// chmax后: [5, 3, 6, 6, 6, 5, 6, 6, 5, 9]
st.rangeChmax(3, 8, 5);
System.out.println("\n执行 rangeChmax[3,8] x=5 后:");
System.out.println("区间[3,8]的和: " + st.rangeSum(3, 8)); // 6+6+5+6+6+5 = 34
System.out.println("区间[3,8]的最小值: " + st.rangeMin(3, 8)); // 5
// 连续多次chmin(测试均摊复杂度)
System.out.println("\n连续chmin测试:");
for (int i = 10; i >= 1; i--) {
st.rangeChmin(0, 9, i);
System.out.println("chmin x=" + i + ", 总和=" + st.rangeSum(0, 9));
}
// 最终所有元素应该都被压到1
System.out.println("最终区间[0,9]的最大值: " + st.rangeMax(0, 9)); // 1
}
}
4. 区间取模扩展
/**
* 区间取模操作:a[i] %= x
* 利用性质:若 a[i] < x,则 a[i] % x = a[i](不变)
* 若 a[i] >= x,则 a[i] % x < x,相当于一次chmin操作
*/
public void rangeModulo(int L, int R, long x) {
rangeModulo(1, 0, n - 1, L, R, x);
}
private void rangeModulo(int node, int l, int r, int L, int R, long x) {
if (r < L || l > R || tree[node].mx < x) {
return; // 所有元素都小于x,无需操作
}
if (l == r) {
// 叶子节点直接取模
tree[node].sum %= x;
tree[node].mx = tree[node].sum;
tree[node].mn = tree[node].sum;
return;
}
push(node);
int mid = (l + r) >>> 1;
rangeModulo(node << 1, l, mid, L, R, x);
rangeModulo(node << 1 | 1, mid + 1, r, L, R, x);
pull(node);
}
复杂度分析
时间复杂度
线段树 Beats 的核心复杂度在于chmin/chmax 操作:
- 单次 chmin/chmax:均摊 O(log n)
- 均摊分析依据:每个元素被修改的次数有限。对于 chmin 操作,一个元素只有在其为区间最大值时才会被修改,而每次修改后它至少变成次大值或更小。一个元素最多被修改 O(log V) 次(V 为值域),因此均摊复杂度为 O(log n)
- 区间求和/最值查询:O(log n)
- 区间取模:均摊 O(log n × log V)
空间复杂度
- 树节点数组:O(4n),每个节点存储 7 个字段(sum, mx, cmx, se, mn, cmn, seMin)
- 总空间:约 4n × (8×4 + 4×2) = 4n × 40 字节 ≈ 160n 字节
应用场景
1. 资源限制调度
假设有 n 个任务,每个任务有一个资源需求量。需要支持:
– 查询某段任务的资源总和
– 将某段任务的资源需求上限设为 x(超过的削减)
– 将某段任务的资源需求下限设为 x(不足的补齐)
这正是线段树 Beats 的标准应用场景。
2. 游戏数值系统
在 RPG 游戏中,角色的多个属性需要同时维护:
– 对某类敌人伤害不超过某个阈值(chmin)
– 对某类敌人伤害不低于某个阈值(chmax)
– 查询当前属性总和
3. 区间取模问题
经典问题:维护一个数组,支持区间求和、区间取模。由于 a % x 当 a < x 时不变,当 a >= x 时结果 < x,可以转化为多次 chmin 操作。
进阶扩展
支持区间加法和区间覆盖
在完整版的线段树 Beats 中,还可以同时支持:
– 区间加法:传统 lazy tag 处理
– 区间覆盖:优先级最高,直接覆盖所有信息
实现时需要注意操作优先级:覆盖 > chmin/chmax > 加法
空间优化
对于 n <= 10^5 的场景,4 倍空间足够。对于 n <= 10^6 的大规模数据:
– 使用迭代式线段树(非递归)减少常数
– 动态开点(只有需要的节点才创建)
总结
线段树 Beats 通过次大值维护这一巧妙设计,突破了传统线段树无法高效处理区间 chmin/chmax 的瓶颈。本文的 Java 实现展示了:
- 双端信息维护:同时维护最大值和最小值,支持双向区间限制
- 次值判断:利用
se < x < mx的条件实现 O(1) 的批量更新 - 均摊复杂度:每个元素修改次数有限,保证整体效率
- 区间取模扩展:将取模转化为 chmin 的巧妙转化
在算法竞赛中,线段树 Beats 是处理区间最值修改问题的终极武器;在实际工程中,它也是资源调度、数值限制等场景的高效解决方案。