CC++ & Algorithm

线段树的原理与构建

较难2
语言版本:通用
概述:通过分治思想将数组分段管理,学习如何用递归算法构建一棵表示区间信息的二叉树。

线段树入门:像分组统计成绩一样管理区间信息

想象一下,你是班长,班上有8个同学,学号1到8。老师突然问:“学号3到6号同学的总分是多少?”如果每次都要挨个把3号、4号、5号、6号的分数加起来,那得多慢啊,而且老师可能每隔几分钟就问一次不同区间的成绩。你灵机一动:为什么不先把同学分成几个小组,每个小组提前算出自己组的总分呢?比如把1-4号分为第一组,5-8号分为第二组,第一组再分成1-2和3-4两个小组……这样一层层分下去,每个小组只管自己范围内的分数。当老师问3-6号时,你只需要把涉及的小组结果(3-4组和5-6组)拼起来,根本不用算每个同学。这个“分组管理”的思想,就是线段树的核心。

线段树是一种专门用来高效处理区间查询区间更新的二叉树结构。你可以把它想象成一张“区间信息地图”:每个节点代表一个连续区间,节点里存着这个区间的“关键信息”(比如总和、最大值、最小值等)。通过递归地把大区间分成两个子区间,直到每个区间只包含一个元素(叶子节点),我们就能用一棵树把整个数组的信息组织起来。建树之后,无论是查询任意区间的总和,还是修改某个元素的值,都只需沿着树走O(log n)步,比挨个遍历快得多。

生活中的更多例子

除了班级成绩,线段树还能用在很多地方:

  • 零花钱统计:你每天往存钱罐里放零花钱,月底想知道从第5天到第15天一共存了多少钱。用线段树把每天的金额存成叶子节点,父节点存区间和,就能快速查询。
  • 游戏排行榜:一个游戏有100个玩家,每个玩家有经验值。你想知道从第10名到第20名玩家的总经验值,或者谁的经验值最高。线段树可以帮你快速得到任意排名区间的最大值。
  • 排队问题:操场上同学们排队做操,每隔一段时间有人插队或离开。你想知道当前队伍里从第3个到第7个同学的总身高。线段树支持动态更新,随时调整节点值。

线段树的结构:用数组模拟二叉树

线段树是一棵满二叉树(不一定完全,但通常用数组存储)。我们用数组的索引来模拟树的父子关系:

  • 根节点编号为1,代表整个数组区间[1, n]。
  • 对于编号为p的节点,它的左孩子编号为p*2,右孩子编号为p*2+1
  • 叶子节点代表长度为1的区间,即原始数组的单个元素。

为什么需要开4倍于原始数组大小的空间呢?因为一棵有n个叶子的满二叉树,节点总数最多为2n-1,但在用数组存储时,由于索引跳跃(比如p*2可能远大于2n),为了安全地容纳所有节点,通常开4*n的空间。这相当于给树多预留了一些位置,避免数组越界。例如n=8,节点编号最大可能用到4*8=32,而实际需要不超过15个节点,但编号可能跳得很开(比如左孩子一路下去编号到16、32),所以4倍是经典的安全做法。

构建过程:递归地分而治之

构建线段树的过程很像分治算法:把大问题拆成两个小问题,分别解决后再合并结果。具体步骤用函数build(p, l, r)表示,其中:

  • p:当前节点在tree数组中的编号
  • lr:当前节点负责的区间左端点和右端点

步骤分解

  1. 到达叶子:如果l == r,说明这个区间只有一个元素,那就直接把原始数组a[l]的值存到tree[p]中,然后返回。
  2. 分治:否则,先计算区间中点mid = (l + r) / 2。然后:
    • 递归构建左孩子:build(p*2, l, mid)
    • 递归构建右孩子:build(p*2+1, mid+1, r)
  3. 合并:左右孩子构建完成后,把左右孩子的值合并起来存到当前节点。合并操作根据需求定义,比如区间和就是tree[p] = tree[p*2] + tree[p*2+1];区间最大值就是tree[p] = max(tree[p*2], tree[p*2+1])

这个过程就像班长先分小组,小组再分小小组……每个小组长(节点)记录自己组的总分,然后上报给上级小组长。最终根节点知道全班总分。

ASCII示意图:一目了然

假设数组a[1..8] = {1, 3, 5, 7, 9, 11, 13, 15},构建的线段树如下(每个节点显示区间和):

                 [1,8] sum=64
                /         \
          [1,4] sum=16    [5,8] sum=48
          /     \          /     \
     [1,2] s=4  [3,4] s=12 [5,6] s=20 [7,8] s=28
     /   \      /   \      /   \      /   \
   [1,1] [2,2] [3,3] [4,4] [5,5] [6,6] [7,7] [8,8]
     1     3     5     7     9    11    13    15

叶子节点就是原始数据。注意,每个非叶子节点的值都是其两个子节点值的和。例如[1,4]的和=左孩子4 + 右孩子12 = 16。

代码实现:C++和Python

下面给出完整可运行的代码。代码中变量使用简短英文单词,每行变量定义都加了中文注释,方便理解。

C++代码

#include <iostream>
using namespace std;

const int MAXN = 10000;  // 根据题目调整大小

int a[MAXN];        // 原始数组,下标从1开始
int tree[4 * MAXN]; // 线段树数组,存储区间和

// 构建线段树
// p: 当前节点编号,l: 区间左端点,r: 区间右端点
void build(int p, int l, int r) {
    if (l == r) {                   // 叶子节点,区间只包含一个元素
        tree[p] = a[l];
        return;
    }
    int mid = (l + r) / 2;         // 计算中点,将区间一分为二
    build(p * 2, l, mid);          // 构建左子树(左半区间)
    build(p * 2 + 1, mid + 1, r);  // 构建右子树(右半区间)
    tree[p] = tree[p * 2] + tree[p * 2 + 1]; // 合并左右子树的和
}

int main() {
    int n = 8;
    // 初始化原始数组:1,3,5,...,15
    for (int i = 1; i <= n; i++) a[i] = 2 * i - 1;
    build(1, 1, n);  // 从根节点1开始构建区间[1,n]
    // 输出线段树(调试用)
    for (int i = 1; i <= 4 * n; i++) {
        if (tree[i] != 0) cout << "tree[" << i << "]=" << tree[i] << endl;
    }
    return 0;
}

Python代码

class SegmentTree:
    def __init__(self, data):
        """
        data: 原始数组,索引从0开始(但内部统一用1-based方便理解)
        """
        self.n = len(data)
        # 将data转换为1-based,方便递归处理
        self.a = [0] + data  # a[1..n],a[0]占位不用
        self.tree = [0] * (4 * (self.n + 1))  # 线段树数组,大小4*(n+1)

    def build(self, p, l, r):
        """构建线段树
        p: 当前节点在tree中的编号
        l, r: 当前节点负责的区间左右端点(1-based)
        """
        if l == r:
            self.tree[p] = self.a[l]
            return
        mid = (l + r) // 2
        self.build(p * 2, l, mid)          # 左子树
        self.build(p * 2 + 1, mid + 1, r)  # 右子树
        self.tree[p] = self.tree[p * 2] + self.tree[p * 2 + 1]  # 合并

    def show(self):
        """打印非零节点用于调试"""
        for i in range(1, 4 * (self.n + 1)):
            if self.tree[i] != 0:
                print(f"tree[{i}] = {self.tree[i]}")

# 测试
if __name__ == "__main__":
    arr = [1, 3, 5, 7, 9, 11, 13, 15]
    st = SegmentTree(arr)
    st.build(1, 1, st.n)
    st.show()

运行结果会打印出前面示意图中各个非零节点的值。注意,Python代码中self.tree数组的大小为4 * (n+1),这是因为我们用了1-based索引,比实际需要的最大节点编号多了一些余量,但更安全。

新手容易犯的常见错误

  1. 数组越界:开线段树数组时忘记乘4,只开了2n或3n。当n很大时,递归过程中节点编号可能超出数组范围,导致运行时错误或数据错乱。一定要开4倍空间(或更多,比如4*n+5)。

  2. 递归边界条件写错:比如在l == r时没有return,导致继续递归陷入无限循环。或者忘记处理l > r的情况(通常不会出现,但严谨起见可加判断)。

  3. 合并操作搞错:建树时,合并左右孩子的操作必须与查询需求一致。如果需求是区间最大值,却写成了加法,结果就会出错。务必明确任务:是求和、求最大还是求最小?

  4. 索引从0还是1开始:在很多教材中,原始数组和区间都使用0-based索引(从0到n-1)。但上面的代码为了方便理解使用了1-based。如果你要处理0-based的数组,需要在递归中调整区间端点。建议统一约定,并在代码开头用注释说明。

  5. 忘记初始化原始数组:建树依赖于原始数组a的值。如果a没有正确赋值,或者使用了未初始化的内存,结果会是垃圾值。记得在调用build之前给a赋值。

总结与延伸

  • 线段树用二叉树表示区间,每个节点对应一个连续区间,叶子节点对应单个元素。
  • 建树过程是递归的,先递归左右子区间,再合并信息(分治思想)。
  • 需要4倍于原数组大小的空间来存储树节点,这是安全余量。
  • 建树的时间复杂度为O(n),因为每个节点恰好被访问一次,而节点总数约为2n个。
  • 合并操作灵活多变,可以是求和、求最大值、求最小值、求最大公约数等,只要满足结合律即可。

掌握了构建线段树,你就可以在此基础上实现区间查询(比如快速求任意区间和)和单点更新(比如修改某个同学的成绩并更新整棵树)。下一篇文章将带你学习如何用线段树做区间查询,让你真正感受它的高效。如果你对树状数组或ST表也感兴趣,它们同样擅长处理区间问题,但各有优劣——比如树状数组代码更短,但只能处理前缀和;ST表查询极快,但不支持更新。线段树则兼顾了查询和更新,是数据结构和算法竞赛中的利器。

继续探索吧,你会发现线段树的世界里还有很多有趣的变种!

例题精讲

1单选题

在递归构建线段树时,对于长度为 n 的数组,通常需要多大的数组来存储线段树节点(假设使用连续数组存储)?

A2n
B4n
Cn
Dn+1
2单选题

构建线段树的递归函数中,若当前区间左端点等于右端点(l == r),则以下哪项操作是正确的?

A将当前节点值设为左端点下标
B将当前节点值设为原数组中下标为 l 的元素值
C直接返回,不设置节点值
D递归调用左右子节点
3判断题

在构建线段树的过程中,每个非叶子节点的值都是由其左右子节点的值通过某种运算(如求和、取最大值等)合并得到的。

4填空题
以下是一段构建线段树的递归函数(求区间和),请补全空白处的代码。\n\nint arr[100];\nint tree[400];\n\nvoid build(int node, int l, int r) {\n    if (l == r) {\n        tree[node] = arr[l];\n        return;\n    }\n    int mid = (l + r) / 2;\n    build(node*2, l, mid);\n    build(node*2+1, mid+1, r);\n    tree[node] = ___;\n}
5填空题
以下代码是构建线段树的递归函数模板,请补全空白处的终止条件判断。\n\nint arr[100];\nint tree[400];\n\nvoid build(int node, int l, int r) {\n    if (___) {\n        tree[node] = arr[l];\n        return;\n    }\n    int mid = (l + r) / 2;\n    build(node*2, l, mid);\n    build(node*2+1, mid+1, r);\n    tree[node] = tree[node*2] + tree[node*2+1];\n}