高级数据结构(二):线段树、树状数组、RMQ与LCA

好,咱们接着聊高级数据结构。上一章我们讲了堆、并查集和Trie树,这次要聊的这几个——线段树、树状数组、RMQ和LCA,说实话,是算法竞赛和工程性能优化里的“硬骨头”。

我个人习惯把这类问题归为“区间操作三件套”。你想想看,实际项目中,我们经常要问:某个区间的最小值是多少? 某段数组的和能不能快速更新? 树上两个节点的最近公共祖先是谁? 这些问题,用朴素方法做,O(n)甚至O(n²)的复杂度,数据量一上来就崩了。

嗯,今天我们就一个一个把它们啃下来。

一、线段树(Segment Tree)

线段树,说白了就是一棵二叉树,每个节点代表一个区间。根节点是整个数组[1, n],左孩子是左半区间,右孩子是右半区间。叶子节点就是单个元素。

我在项目中遇到过这样一个场景:一个实时监控系统,每秒要更新上千个传感器的数值,同时要查询任意时间段的平均值。用数组硬算?不行。用前缀和?更新太慢。最后我上了线段树,查询和更新都是O(log n),稳得很。

核心思想: 把区间信息存储在树中,通过递归分治实现快速查询与更新。

线段树的基本操作

  1. 建树:递归构建,每个节点存储区间和(或最大值、最小值等)。
  2. 区间查询:从根节点开始,如果查询区间完全覆盖当前节点区间,直接返回;否则递归左右子树。
  3. 单点更新:找到叶子节点,更新值,然后回溯更新父节点。
  4. 区间更新(懒标记):这是线段树的精髓。如果每次更新都递归到叶子,太慢了。懒标记的意思是:先记着“这个区间要加一个值”,等真正需要查询子区间时再下推。
避坑指南: 我曾经在写懒标记时,忘记在查询时也下推标记,结果查出来的数据全是错的。记住:查询和更新都要处理懒标记的下推
// 线段树 - 区间求和(带懒标记)
#define MAXN 100005
int tree[MAXN << 2], lazy[MAXN << 2];

void push_down(int p, int l, int r) {
    if (lazy[p]) {
        int mid = (l + r) >> 1;
        tree[p << 1] += lazy[p] * (mid - l + 1);
        tree[p << 1 | 1] += lazy[p] * (r - mid);
        lazy[p << 1] += lazy[p];
        lazy[p << 1 | 1] += lazy[p];
        lazy[p] = 0;
    }
}

void update(int p, int l, int r, int ql, int qr, int val) {
    if (ql <= l && r <= qr) {
        tree[p] += val * (r - l + 1);
        lazy[p] += val;
        return;
    }
    push_down(p, l, r);
    int mid = (l + r) >> 1;
    if (ql <= mid) update(p << 1, l, mid, ql, qr, val);
    if (qr > mid) update(p << 1 | 1, mid + 1, r, ql, qr, val);
    tree[p] = tree[p << 1] + tree[p << 1 | 1];
}

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

二、树状数组(Fenwick Tree / BIT)

树状数组,我更喜欢叫它BIT(Binary Indexed Tree)。它比线段树更轻量,代码更短,但能解决的问题范围也窄一些——主要就是单点更新 + 区间查询,或者区间更新 + 单点查询

它的核心就一个函数:lowbit(x) = x & -x。这个lowbit能取出x二进制表示中最低位的1。树状数组就是靠这个来“跳”的。

为什么叫树状数组? 因为它用数组模拟了一棵树。每个下标i,存储的是区间[i - lowbit(i) + 1, i]的信息。
// 树状数组 - 单点更新 + 区间求和
int bit[MAXN], n;

void add(int idx, int delta) {
    while (idx <= n) {
        bit[idx] += delta;
        idx += idx & -idx;
    }
}

int sum(int idx) {
    int res = 0;
    while (idx > 0) {
        res += bit[idx];
        idx -= idx & -idx;
    }
    return res;
}

// 区间[l, r]的和 = sum(r) - sum(l-1)
个人经验: 如果问题只需要前缀和/差分,我首选树状数组。代码量少,常数小,不容易写错。但如果你需要区间更新+区间查询,或者需要维护最大值/最小值,那就老老实实上线段树吧。

三、RMQ问题(Range Minimum Query)

RMQ,就是区间最值查询。给你一个数组,问任意区间的最小值(或最大值)。

解决RMQ问题,有几种思路:

  • 线段树:O(log n)查询,O(n)建树,支持更新。
  • ST表(Sparse Table):O(1)查询,O(n log n)预处理,不支持更新
  • 树状数组:可以求最值,但实现起来比求和麻烦,不推荐。

我个人在静态数据(比如离线查询)中,特别喜欢用ST表。它基于倍增思想:st[i][j]表示从i开始,长度为2^j的区间的最小值。

// ST表 - RMQ
int st[MAXN][LOG], log2[MAXN];

void build(int arr[], int n) {
    log2[1] = 0;
    for (int i = 2; i <= n; i++) log2[i] = log2[i/2] + 1;
    for (int i = 1; i <= n; i++) st[i][0] = arr[i];
    for (int j = 1; (1 << j) <= n; j++)
        for (int i = 1; i + (1 << j) - 1 <= n; i++)
            st[i][j] = min(st[i][j-1], st[i + (1 << (j-1))][j-1]);
}

int query(int l, int r) {
    int k = log2[r - l + 1];
    return min(st[l][k], st[r - (1 << k) + 1][k]);
}
注意: ST表虽然查询快,但不能处理动态更新。如果你需要一边修改数组一边查询,请用线段树。

四、LCA问题(Lowest Common Ancestor)

LCA,最近公共祖先。给定一棵树,问两个节点的最近公共祖先是谁。

这个问题在项目里其实挺常见的。我记得有一次做社交网络的关系分析,需要判断两个用户是否在同一个“圈子”里,本质上就是找他们在组织树上的LCA。

解决LCA的常用方法:

  • 倍增法:预处理每个节点的2^k级祖先,查询时先让深度大的节点往上跳,再一起跳。O(log n)查询。
  • Tarjan离线算法:用并查集,一次DFS处理所有查询。O(n + q)。
  • 树链剖分:把树剖成链,用线段树维护。适合需要同时处理路径查询的场景。

我个人最常用倍增法,因为它好写、好理解,而且在线查询。

// 倍增法求LCA
int up[MAXN][LOG], depth[MAXN];

void dfs(int u, int p) {
    up[u][0] = p;
    depth[u] = depth[p] + 1;
    for (int j = 1; j < LOG; j++)
        up[u][j] = up[up[u][j-1]][j-1];
    for (int v : adj[u]) {
        if (v != p) dfs(v, u);
    }
}

int lca(int u, int v) {
    if (depth[u] < depth[v]) swap(u, v);
    int diff = depth[u] - depth[v];
    for (int j = 0; j < LOG; j++)
        if (diff & (1 << j)) u = up[u][j];
    if (u == v) return u;
    for (int j = LOG-1; j >= 0; j--)
        if (up[u][j] != up[v][j]) {
            u = up[u][j];
            v = up[v][j];
        }
    return up[u][0];
}
避坑指南: 我曾经在写倍增时,把LOG设成了16,结果树深度超过65535就崩了。建议LOG取20或更大,反正空间开销不大。

五、知识体系总览

下面这张图,是我自己整理的本章节知识结构。你可以把它当作一个“地图”,学完后再回来看,会更有感觉。

高级数据结构(二)知识体系 线段树 区间更新/查询 树状数组 前缀和/差分 RMQ 区间最值查询 LCA 最近公共祖先 常用实现方式 线段树 • 递归/非递归实现 • 懒标记(区间更新) • 动态开点(权值线段树) 树状数组 • lowbit核心操作 • 单点更新 + 区间查询 • 区间更新 + 单点查询(差分) RMQ • ST表(倍增,O(1)查询) • 线段树(支持更新) LCA • 倍增法(在线,O(log n)) • Tarjan离线算法(O(n+q))

六、如何选择?

说实话,很多初学者会纠结“到底用哪个”。我的建议很简单:

场景 推荐数据结构 原因
区间求和,单点更新 树状数组 代码短,常数小
区间更新,区间查询 线段树(懒标记) 树状数组做不了
静态区间最值查询 ST表 O(1)查询,快
动态区间最值查询 线段树 支持更新
树上两点路径问题 LCA(倍增) 好写,够用
一句话总结: 树状数组是“轻量级选手”,线段树是“全能选手”,ST表是“静态专家”,LCA是“树上导航员”。选对工具,事半功倍。

好了,这一章的内容就到这儿。代码示例我都尽量精简了,你可以在自己的项目里直接套用。记住,数据结构这东西,光看没用,一定要动手写。我当年也是被线段树的递归搞到头大,但写个几十遍,自然就熟了。


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