线段树:从零开始理解这个“万能”数据结构

线段树,说实话,是我在数据结构里最常用的工具之一。它不像链表那样基础,也不像红黑树那样复杂到让人头疼。它就是一个——嗯,怎么形容呢——一个专门处理区间问题的“瑞士军刀”。

我记得刚学线段树那会儿,总觉得它很神秘。后来在项目中处理一个实时股票数据的区间统计,才真正体会到它的威力。今天我就带你从最基础的概念开始,一步步把线段树吃透。

线段树是什么?

说白了,线段树就是一棵二叉树。每个节点代表一个区间。根节点是整个数组,叶子节点是单个元素。

你想想看,如果我们要查一个数组里某段区间的最大值,最笨的办法就是遍历。但数据量一大,比如几十万条,那就慢得没法用了。线段树就是来解决这个问题的——它能在 O(log n) 的时间里完成区间查询和更新。

核心思想:把大区间拆成小区间,每个节点存这个区间的“汇总信息”(最大值、最小值、和等)。查询时,只访问必要的节点,不用遍历整个数组。

线段树的结构长什么样?

假设我们有一个数组 arr = [1, 3, 5, 7, 9, 11]。线段树的结构是这样的:

[0,5] sum=36 [0,2] sum=9 [3,5] sum=27 [0,1] sum=4 [2,2] sum=5 [3,4] sum=16 [5,5] sum=11 [0,0] 1 [1,1] 3 [3,3] 7 [4,4] 9

看到没?每个节点存的是区间和。根节点 [0,5] 存的是整个数组的和 36。左孩子 [0,2] 存前三个数的和 9,右孩子 [3,5] 存后三个数的和 27。这样一层层分下去,直到叶子节点。

建树:把数组变成线段树

建树的过程,其实就是递归。我习惯用数组来存线段树,因为完全二叉树可以用数组完美表示。父节点下标是 i,左孩子就是 2*i,右孩子是 2*i+1。

#define MAXN 100005
int tree[MAXN * 4];  // 线段树数组,一般开4倍空间

void build(int node, int start, int end, int arr[]) {
    if (start == end) {
        // 叶子节点,直接赋值
        tree[node] = arr[start];
        return;
    }
    int mid = (start + end) / 2;
    int left = node * 2;
    int right = node * 2 + 1;
    
    build(left, start, mid, arr);
    build(right, mid + 1, end, arr);
    
    // 回溯时合并左右孩子的信息
    tree[node] = tree[left] + tree[right];  // 区间和
    // tree[node] = max(tree[left], tree[right]);  // 最大值
    // tree[node] = min(tree[left], tree[right]);  // 最小值
}

小技巧:为什么开4倍空间?因为线段树最坏情况下需要 4n 个节点。我刚开始写的时候只开了 2n,结果数组越界,查了半天才找到问题。嗯,这个坑我替你踩过了。

区间查询:找最大值、最小值、区间和

查询的逻辑其实很直观。我们要查一个区间 [L, R],就从根节点开始往下走。有三种情况:

  • 完全覆盖:当前节点区间完全在 [L, R] 内,直接返回该节点的值。
  • 没有交集:当前节点区间和 [L, R] 没关系,返回一个“空值”(比如查和就返回0,查最大值就返回极小值)。
  • 部分重叠:继续往下递归,合并左右孩子的结果。
int query_sum(int node, int start, int end, int L, int R) {
    if (R < start || L > end) return 0;  // 没有交集
    if (L <= start && end <= R) return tree[node];  // 完全覆盖
    
    int mid = (start + end) / 2;
    int left = node * 2;
    int right = node * 2 + 1;
    
    int left_sum = query_sum(left, start, mid, L, R);
    int right_sum = query_sum(right, mid + 1, end, L, R);
    return left_sum + right_sum;
}

// 查最大值类似,只是把 + 换成 max
int query_max(int node, int start, int end, int L, int R) {
    if (R < start || L > end) return -2147483648;  // 极小值
    if (L <= start && end <= R) return tree[node];
    
    int mid = (start + end) / 2;
    int left = node * 2;
    int right = node * 2 + 1;
    
    int left_max = query_max(left, start, mid, L, R);
    int right_max = query_max(right, mid + 1, end, L, R);
    return (left_max > right_max) ? left_max : right_max;
}

时间复杂度:每次查询只访问 O(log n) 个节点。为什么?因为每一层最多访问 4 个节点。这个结论我当年推导了很久,后来发现其实画个图就明白了——每一层被查询区间覆盖的节点不会超过 4 个。

单点更新:改一个元素的值

单点更新就简单了。找到那个叶子节点,改掉它,然后一路回溯更新父节点。

void update_point(int node, int start, int end, int idx, int val) {
    if (start == end) {
        tree[node] = val;
        return;
    }
    int mid = (start + end) / 2;
    int left = node * 2;
    int right = node * 2 + 1;
    
    if (idx <= mid)
        update_point(left, start, mid, idx, val);
    else
        update_point(right, mid + 1, end, idx, val);
    
    tree[node] = tree[left] + tree[right];  // 更新父节点
}

这个操作也是 O(log n)。我在项目中处理股票数据时,每秒可能有上千次更新,线段树完全扛得住。

区间更新与懒标记:真正的重头戏

单点更新好办,但要是给整个区间 [L, R] 里的每个元素都加上一个值呢?一个个更新?那复杂度就是 O(n log n),还不如直接遍历数组。

这时候就需要懒标记(Lazy Propagation)了。说白了就是:先记着,不急着改

我举个例子。你要给 [2, 5] 区间每个数加 3。走到某个节点,发现它完全在 [2, 5] 范围内,那就给这个节点打个标记“这里要加 3”,然后直接返回。等下次查询或者更新需要用到这个节点的子节点时,再把标记“下推”下去。

int lazy[MAXN * 4];  // 懒标记数组

void push_down(int node, int start, int end) {
    if (lazy[node] == 0) return;  // 没有标记,直接返回
    
    int left = node * 2;
    int right = node * 2 + 1;
    int mid = (start + end) / 2;
    
    // 把标记传给左右孩子
    lazy[left] += lazy[node];
    lazy[right] += lazy[node];
    
    // 更新左右孩子的值
    tree[left] += lazy[node] * (mid - start + 1);
    tree[right] += lazy[node] * (end - mid);
    
    lazy[node] = 0;  // 清除当前节点的标记
}

void update_range(int node, int start, int end, int L, int R, int val) {
    if (R < start || L > end) return;
    if (L <= start && end <= R) {
        tree[node] += val * (end - start + 1);
        lazy[node] += val;  // 打上懒标记
        return;
    }
    
    push_down(node, start, end);  // 下推标记
    int mid = (start + end) / 2;
    int left = node * 2;
    int right = node * 2 + 1;
    
    update_range(left, start, mid, L, R, val);
    update_range(right, mid + 1, end, L, R, val);
    
    tree[node] = tree[left] + tree[right];
}

注意:懒标记一定要在递归子节点之前下推。我曾经犯过一个错误——在递归之后才下推,结果子节点拿到的数据全是错的。调试了整整一个下午才找到原因。

区间查询(带懒标记版本)

有了懒标记,查询函数也要相应修改。每次进入一个节点,先下推标记,再递归。

int query_range(int node, int start, int end, int L, int R) {
    if (R < start || L > end) return 0;
    if (L <= start && end <= R) return tree[node];
    
    push_down(node, start, end);  // 查询前也要下推
    int mid = (start + end) / 2;
    int left = node * 2;
    int right = node * 2 + 1;
    
    int left_sum = query_range(left, start, mid, L, R);
    int right_sum = query_range(right, mid + 1, end, L, R);
    return left_sum + right_sum;
}

总结一下线段树的核心要点

操作 时间复杂度 关键点
建树 O(n) 递归构建,回溯合并
单点查询 O(log n) 找到叶子节点即可
区间查询 O(log n) 完全覆盖直接返回
单点更新 O(log n) 更新叶子,回溯父节点
区间更新 O(log n) 懒标记是关键

我的建议:刚开始学线段树,先别碰懒标记。先把建树、单点更新、区间查询这三个基础操作写熟练。等你能闭着眼睛写出这三个函数,再学懒标记。一口吃不成胖子,这个道理我深有体会。

线段树的应用场景其实很多。区间最值、区间和、区间 GCD、甚至区间翻转(用懒标记实现)。我在做算法竞赛的时候,线段树几乎是必考的数据结构。工作中处理日志分析、股票数据、传感器数据,也经常用到。

好了,这一章的内容就到这。代码你多敲几遍,画图理解递归过程,很快就能掌握。

专注资料整理