线段树是常用的区间数据结构。第一次学习时,容易被递归建树、区间拆分和下标映射这些细节绕住。实现主要包含三件事:建树、查询、修改。

这篇笔记使用 Java 和数组实现线段树。数组可以直接表示父子节点关系,也能减少对象分配。

1. 适用场景

如果题目同时满足下面两点,线段树通常就是候选答案:

  • 维护一个数组
  • 需要频繁做区间查询或单点修改

最常见的场景是:

  • 区间和
  • 区间最值
  • 区间 gcd
  • 区间统计类信息

静态查询可以优先考虑前缀和或稀疏表;区间修改则需要懒标记线段树。本文只讨论区间查询和单点修改。

2. 数组存储与空间开销

本文采用数组保存节点,利用下标表达父子关系,并减少 Java 对象分配。

1)线段树的结构是固定的

线段树的结构接近完全二叉树,节点关系可以直接用下标表达:

  • 父节点:p
  • 左儿子:p * 2
  • 右儿子:p * 2 + 1

因此无需保存指针,一个数组就能表示整棵树。

2)Java 里对象开销更大

如果你真的用 TreeNode 去建树,会带来这些额外成本:

  • 每个节点都要 new
  • 会有对象头、引用字段等额外内存开销
  • 垃圾回收压力更大
  • 代码也更长

对算法题来说,这些成本都没必要。

3)数组更快,也更稳

数组是连续内存,访问时局部性更好;而对象节点会有更多间接访问。对于线段树这种会频繁递归访问父子节点的结构,数组实现通常更简洁,也更容易写对。

4)空间也容易估算

线段树通常直接开:

int[] tree = new int[4 * n];

4n 是覆盖递归分裂结果的安全容量,实际节点数仍由区间分裂结果决定。

3. 核心思想

线段树把一个区间不断二分,形成一棵树。

设当前节点维护区间 [l, r]:

  • 左儿子维护 [l, mid]
  • 右儿子维护 [mid + 1, r]
  • 当前节点的答案由两个子区间合并得到

每个父节点都需要合并两个子区间的结果:

  • 区间和:左右儿子相加
  • 区间最小值:左右儿子取 Math.min
  • 区间最大值:左右儿子取 Math.max

因此,线段树可以理解为一种区间分治框架,具体合并规则由查询目标决定。

4. Java 标准模板

下面的模板支持区间最大值查询和单点修改。

代码模板

public class SegmentTree {
    private final int[] nums;
    private final int[] tree;

    public SegmentTree(int[] nums) {
        this.nums = nums.clone();
        this.tree = new int[nums.length * 4];
        if (nums.length > 0) {
            build(1, 0, nums.length - 1);
        }
    }

    public void update(int index, int value) {
        nums[index] = value;
        update(1, 0, nums.length - 1, index, value);
    }

    public int query(int left, int right) {
        return query(1, 0, nums.length - 1, left, right);
    }

    private void build(int node, int left, int right) {
        if (left == right) {
            tree[node] = nums[left];
            return;
        }

        int mid = left + ((right - left) >> 1);
        build(node * 2, left, mid);
        build(node * 2 + 1, mid + 1, right);
        tree[node] = Math.max(tree[node * 2], tree[node * 2 + 1]);
    }

    private void update(int node, int left, int right, int index, int value) {
        if (left == right) {
            tree[node] = value;
            return;
        }

        int mid = left + ((right - left) >> 1);
        if (index <= mid) {
            update(node * 2, left, mid, index, value);
        } else {
            update(node * 2 + 1, mid + 1, right, index, value);
        }

        tree[node] = Math.max(tree[node * 2], tree[node * 2 + 1]);
    }

    private int query(int node, int left, int right, int queryLeft, int queryRight) {
        if (queryRight < left || right < queryLeft) {
            return Integer.MIN_VALUE;
        }

        if (queryLeft <= left && right <= queryRight) {
            return tree[node];
        }

        int mid = left + ((right - left) >> 1);
        int leftAns = query(node * 2, left, mid, queryLeft, queryRight);
        int rightAns = query(node * 2 + 1, mid + 1, right, queryLeft, queryRight);
        return Math.max(leftAns, rightAns);
    }
}

5. 建树、查询与修改

build

建树就是把原数组的值一层层灌到树上。

  • 叶子节点对应原数组里的单个元素
  • 非叶子节点由左右儿子合并得到

query

查询时只关心“当前区间”和“目标区间”的关系:

  • 如果当前区间完全包含在目标区间里,直接返回
  • 如果完全不相交,返回一个不会影响结果的值
  • 如果只覆盖一部分,就继续往下拆

查询过程会跳过无交集的分支,只递归访问相关节点。

update

单点修改的逻辑也很简单:

  • 先一路找到要修改的位置
  • 改叶子节点
  • 再一路向上更新父节点

因为父节点依赖子节点,所以修改完叶子以后,祖先节点的值也必须重新计算。

6. 结合一个示例看思路

我们用一个很典型的场景来理解。

示例

数组:

nums = [2, 5, 1, 8]

支持两种操作:

  1. 查询区间 [1, 3] 的最大值
  2. 把 nums[2] 修改成 10

第一步:建树

线段树会把区间不断拆分:

               [0,3]
              max=8
             /      \
        [0,1]      [2,3]
        max=5      max=8
        /  \       /   \
     [0]  [1]   [2]   [3]
      2     5     1     8

根节点 [0,3] 保存整个数组的最大值 8。

第二步:查询 [1, 3]

查询时,根节点区间 [0,3] 和目标区间 [1,3] 有部分重叠,所以继续往下拆。

  • 左子树 [0,1] 和 [1,3] 部分重叠
  • 右子树 [2,3] 和 [1,3] 完全包含

最后合并左右结果,得到:

max(5, 8) = 8

所以第一次查询结果是 8。

第三步:修改 nums[2] = 10

修改时只需要沿着索引 2 对应的路径往下走:

  • 找到叶子节点 [2]
  • 把值改成 10
  • 回溯更新父节点

更新后数组变成:

[2, 5, 10, 8]

对应的根节点最大值也变成 10。

第四步:再次查询 [1, 3]

这时区间最大值变成:

max(5, 10, 8) = 10

查询和修改都只沿着树高递归,复杂度是 O(log n)。

7. 边界与默认值

1)区间边界混乱

建议全程统一成闭区间 [l, r],不要一会儿左闭右开,一会儿又写成闭区间。

2)mid 写错

常用写法是:

int mid = left + ((right - left) >> 1);

这种写法比直接写 left + right >> 1 更稳妥。

3)返回值的“默认值”要对

如果你维护的是最大值:

  • 无交集时返回 Integer.MIN_VALUE

如果你维护的是最小值:

  • 无交集时返回 Integer.MAX_VALUE

如果你维护的是区间和:

  • 无交集时返回 0

这个细节很容易写错。

4)题目如果有区间修改

标准模板只适合单点修改。

一旦题目出现:

  • 区间加
  • 区间赋值
  • 区间翻转

就需要懒标记,否则复杂度会退化,写法也会完全不一样。