第二十八章:树状数组与线段树——区间问题的两把利器

说实话,刚接触算法竞赛那会儿,我特别怕区间问题。给你一个数组,动不动就要查询某段的和,或者更新某个位置的值。暴力做吧,O(n) 的复杂度,数据量一大就崩。后来我学到了树状数组和线段树,才真正体会到什么叫「数据结构之美」。

这一章,我们就来聊聊这两个东西。它们都能处理区间问题,但各有各的脾气。我个人习惯是:能上树状数组就上树状数组,搞不定再请线段树出场。

一、树状数组:轻量级的区间武器

树状数组(Binary Indexed Tree,BIT)是个很有意思的结构。它用数组来模拟树形结构,核心思想是利用二进制拆分。

你想想看,一个整数可以拆成若干个 2 的幂次之和。比如 7 = 4 + 2 + 1。树状数组就是基于这个原理,把区间查询拆成若干个前缀的累加。

1.1 lowbit 函数——树状数组的灵魂

树状数组里有个核心操作叫 lowbit,它返回一个数二进制表示中最低位的 1 所对应的值。比如 lowbit(6) = 2,因为 6 的二进制是 110,最低位的 1 对应 2。

int lowbit(int x) {
    return x & (-x);
}

这个写法很巧妙。负数的补码表示是取反加一,x & (-x) 就能把最低位的 1 保留下来。我在项目中第一次看到这个写法时,愣了半天才想明白。

1.2 单点更新

更新操作很简单:从当前位置开始,不断加上 lowbit,更新所有受影响的节点。

void update(int i, int delta) {
    while (i <= n) {
        tree[i] += delta;
        i += lowbit(i);
    }
}

比如更新位置 3,它会更新 tree[3]、tree[4]、tree[8]…… 为什么?因为树状数组里,每个节点管理的区间长度就是 lowbit(i)。

1.3 区间查询

查询前缀和是反向操作:从当前位置开始,不断减去 lowbit,累加沿途的值。

int query(int i) {
    int sum = 0;
    while (i > 0) {
        sum += tree[i];
        i -= lowbit(i);
    }
    return sum;
}

要查询 [l, r] 的区间和,就是 query(r) - query(l-1)。

核心要点:树状数组只能处理前缀可合并的问题,比如求和、求异或。对于区间最值,它处理起来比较麻烦,这时候就要请线段树出场了。

我的经验:树状数组的代码量很小,常数也小。我在做在线评测系统时,处理大量单点更新、区间求和请求,树状数组比线段树快 30% 左右。能用树状数组解决的问题,我绝不写线段树。

二、线段树:全能型区间选手

线段树就灵活多了。它把区间递归地分成两半,每个节点代表一个区间。建树、更新、查询都是 O(log n) 的复杂度。

我曾经在做一个股票交易系统时,需要同时维护区间和、区间最大值、区间最小值。树状数组搞不定,线段树一把梭全搞定。

2.1 建树

线段树通常用数组实现,根节点是 1,左孩子是 2*i,右孩子是 2*i+1。建树的过程就是递归地构建每个节点。

void build(int node, int l, int r) {
    if (l == r) {
        tree[node] = arr[l];
        return;
    }
    int mid = (l + r) >> 1;
    build(node*2, l, mid);
    build(node*2+1, mid+1, r);
    tree[node] = tree[node*2] + tree[node*2+1];
}

嗯,这里要注意:如果维护的是最大值,那合并操作就是取 max。不同的需求对应不同的合并逻辑。

2.2 单点更新

更新操作也是递归的。找到叶子节点,更新它,然后回溯更新父节点。

void update(int node, int l, int r, int pos, int val) {
    if (l == r) {
        tree[node] = val;
        return;
    }
    int mid = (l + r) >> 1;
    if (pos <= mid)
        update(node*2, l, mid, pos, val);
    else
        update(node*2+1, mid+1, r, pos, val);
    tree[node] = tree[node*2] + tree[node*2+1];
}

2.3 区间查询

查询时,如果当前节点区间完全在查询范围内,直接返回;否则递归查询左右子树。

int query(int node, int l, int r, int ql, int qr) {
    if (ql <= l && r <= qr)
        return tree[node];
    int mid = (l + r) >> 1;
    int res = 0;
    if (ql <= mid)
        res += query(node*2, l, mid, ql, qr);
    if (qr > mid)
        res += query(node*2+1, mid+1, r, ql, qr);
    return res;
}

避坑指南:我曾经在写线段树时,忘记处理区间完全包含的情况,结果递归到叶子节点才返回,直接超时。记住:能提前返回就提前返回,这是线段树性能的关键。

三、区间最值问题

区间最值(RMQ,Range Minimum/Maximum Query)是线段树的经典应用。你只需要把建树和查询中的「求和」改成「取 max」或「取 min」就行。

树状数组也能做 RMQ,但只支持前缀最值,不支持任意区间。而且更新操作比较麻烦。所以,遇到区间最值问题,我一般直接上线段树。

// 建树时维护最大值
tree[node] = max(tree[node*2], tree[node*2+1]);

// 查询时取最大值
int res = -INF;
if (ql <= mid)
    res = max(res, query(node*2, l, mid, ql, qr));
if (qr > mid)
    res = max(res, query(node*2+1, mid+1, r, ql, qr));

性能对比:

操作 树状数组 线段树
单点更新 O(log n) O(log n)
区间求和 O(log n) O(log n)
区间最值 不支持 O(log n)
代码量 中等
常数 较大

四、知识体系总览

下面这张图是我自己整理的,把树状数组和线段树的核心知识点串了起来。你看一眼就能明白它们各自擅长什么。

区间问题数据结构 树状数组 (BIT) 核心:lowbit 函数 更新:i += lowbit(i) 查询:i -= lowbit(i) 适用场景: • 单点更新 + 区间求和 • 单点更新 + 前缀最值 • 逆序对计数 ⚠ 不支持任意区间最值 ⚠ 不支持区间更新 线段树 (Segment Tree) 核心:递归分治 建树:自底向上合并 更新:递归到叶子再回溯 适用场景: • 单点/区间更新 + 区间查询 • 区间最值 (RMQ) • 区间求和/异或/乘积 ✓ 支持任意区间操作 ✓ 支持懒标记实现区间更新 复杂度相同 功能互补

说白了,树状数组是「专才」,线段树是「通才」。专才效率高,通才更灵活。你根据实际需求选就行。

我的建议:初学者先掌握树状数组,代码短,容易调试。等树状数组用熟了,再学线段树。我当年就是这么过来的,一步一个脚印,稳得很。

好了,这一章的内容就到这里。记住:数据结构是工具,不是目的。理解它们的原理,知道什么时候用哪个,比死记硬背代码重要得多。


公众号:蓝海资料掘金营,微信deep3321