线段树

在计算机科学领域,处理区间相关的问题是常见的任务。无论是在算法竞赛中,还是在实际的应用开发中,如游戏排行榜系统、文本编辑器、系统监控、地理信息系统等,都需要高效地对区间进行查询和修改操作。线段树(Segment Tree)作为一种强大的数据结构,为这类问题提供了高效的解决方案。

Part-1. 线段树的概念

1.1 什么是线段树

线段树本质上是一种二叉搜索树,将区间划分为多个小区间,并用树状结构管理这些区间的数据结构。它是一棵二叉树,每个节点代表一个区间,节点存储这个区间的某种统计信息,例如区间和、区间最大值、区间最小值、区间内不同元素的个数等。
1534760b7d2def13e7d15418159c1bf7

1.2 为什么需要线段树

想象有一个长度为 n 的数组,我们经常需要进行以下两种操作:
区间查询:查询数组中某个区间内元素的某种统计信息,比如区间和、最大值、最小值等。
区间修改:对数组中某个区间内的所有元素进行相同的修改操作,如增加一个固定的值。
对于这类问题,如果直接使用数组进行操作,区间查询的时间复杂度可能会达到 O (n),而区间修改操作如果涉及大量元素,时间复杂度也会很高。而线段树通过巧妙的区间划分和信息聚合机制,能够在 O (log n) 的时间复杂度内完成区间查询和修改操作,大大提高了效率。

1.3 线段树的本质

线段树的本质是一种基于二分思想的数据结构。它将一个大区间递归地划分为更小的区间,直到区间长度为 1(即叶子节点)。每个父节点存储的信息是由其子节点的信息聚合而来,这种结构使得线段树在处理区间问题时具有高效性。

Part-2. 线段树的设计思想

2.1 核心思想
区间划分:将大区间递归地划分为更小的区间,直到不能再分(区间长度为 1)。例如,对于区间 [1, 8],首先会被划分为 [1, 4] 和 [5, 8],然后 [1, 4] 又会被划分为 [1, 2] 和 [3, 4],以此类推,直到每个子区间只包含一个元素,成为叶子节点。
信息聚合:父节点存储的信息是由其子节点的信息聚合而来。比如,如果我们要构建一个用于求区间和的线段树,那么父节点的区间和就是其左子节点区间和与右子节点区间和之和。
懒惰更新:当需要对一个大区间进行修改时,不立即修改所有子节点,而是先标记父节点,等到需要用到子节点时再更新。这一策略大大减少了不必要的节点更新操作,提高了区间修改的效率。

2.2 为什么这样设计是高效的

二分思想:在查询时,如果当前区间完全包含在目标区间内,直接返回结果,避免了对该区间子节点的不必要遍历;如果部分重叠,则递归处理子区间。由于每次递归都会将区间规模减半,平均只需要访问 O (log n) 个节点,大大提高了查询效率。
区间管理:每个节点管理一个连续区间,父节点区间等于左子节点区间加上右子节点区间。这种层次分明的区间管理方式使得区间操作变得高效,无论是查询还是修改,都能快速定位到相关的区间节点。

Part-3. 线段树的应用场景

算法题中的应用
区间统计类问题:例如查询区间内有多少个不同的数、查询区间内的众数等。线段树可以通过维护区间内的元素信息,高效地解决这类问题。
动态维护区间信息:比如维护区间最大连续子段和、维护区间 GCD(最大公因数)等。线段树的灵活性使得它能够在数据动态变化的情况下,依然保持高效的区间信息维护能力。

4. 线段树的优化技巧

4.1 内存优化

动态开点:适用场景为区间范围很大但实际数据稀疏。实现方式是用 map 或指针动态分配节点,而不是预先分配一个固定大小的数组来存储所有可能的节点。这样可以大大减少内存使用,避免因区间范围过大导致的内存浪费。
离散化:当数值范围很大但数据量较小的时候,离散化是一种有效的优化方法。它将原始值映射到更小的范围,减少了树的大小,从而提高效率。例如,原始数据中的值可能在 1 到 10^9 之间,但实际只有 100 个不同的值,通过离散化可以将这些值映射到 1 到 100 之间,大大减小了线段树的规模。

4.2 性能优化

启发式合并:在需要合并两个线段树的场景中适用。实现方式是总是将小树合并到大树上,这样可以减少合并操作的时间复杂度。因为将小树合并到大树上,对大树的结构影响相对较小,从而减少了重新构建和调整节点信息的开销。
位运算优化:在计算父子节点关系时使用位运算可以提高效率。例如,对于一个节点的编号 i,其左子节点的编号可以通过 (i << 1) + 1 计算得到,右子节点的编号可以通过 (i << 1) + 2 计算得到。在一些区间操作中,合理使用位运算也可以优化代码性能。

Part-5. 线段树的扩展

5.1 二维线段树

二维线段树用于处理矩形区域的查询和修改问题。它可以看作是对一维线段树的扩展,将一维区间扩展为二维矩形区域。在实现上,通常需要使用两棵线段树,一棵用于处理行方向的区间,另一棵用于处理列方向的区间。时间复杂度为 O (log n * log m),空间复杂度为 O (n * m),其中 n 和 m 分别是矩形区域在行和列方向上的大小。

5.2 可持久化线段树

可持久化线段树支持查询历史版本,每次修改只需要 O (log n) 的额外空间。它适用于需要维护历史信息的场景,例如在一些版本控制系统中,需要记录文件在不同时间点的状态,可持久化线段树可以高效地实现这一功能。

Part-6. 线段树的基本操作

6.1 构建线段树

线段树的构建通常采用递归方式。以下是一个用 Go 语言实现的构建支持区间求和的线段树的示例代码:

type SegmentTree struct {
    tree   []int64
    lazy   []int64
    n      int
    merge  func(int64, int64) int64
}

func NewSegmentTree(arr []int64, mergeFunc func(int64, int64) int64) *SegmentTree {
    n := len(arr)
    tree := make([]int64, 4*n)
    lazy := make([]int64, 4*n)
    st := &SegmentTree{
        tree:   tree,
        lazy:   lazy,
        n:      n,
        merge:  mergeFunc,
    }
    st.buildTree(1, 0, n-1, arr)
    return st
}

func (st *SegmentTree) buildTree(node, start, end int, arr []int64) int64 {
    if start == end {
        st.tree[node] = arr[start]
        return st.tree[node]
    }
    mid := start + (end-start)/2
    leftNode := 2*node + 1
    rightNode := 2*node + 2
    leftSum := st.buildTree(leftNode, start, mid, arr)
    rightSum := st.buildTree(rightNode, mid+1, end, arr)
    st.tree[node] = st.merge(leftSum, rightSum)
    return st.tree[node]
}

在这段代码中,buildTree函数递归地构建线段树,node表示当前节点在tree数组中的索引,start和end表示当前节点表示的区间范围。如果是叶子节点(start == end),则直接将对应位置的数组元素值赋给当前节点;否则,先递归构建左右子树,然后通过merge函数将左右子树的结果聚合得到当前节点的值。

6.2 区间查询

区间查询操作在 O (log n) 的时间复杂度内完成。以下是区间查询的示例代码:

func (st *SegmentTree) queryRange(node, start, end, l, r int) int64 {
    if st.lazy[node] != 0 {
        st.tree[node] += st.lazy[node] * int64(end-start+1)
        if start != end {
            st.lazy[2*node+1] += st.lazy[node]
            st.lazy[2*node+2] += st.lazy[node]
        }
        st.lazy[node] = 0
    }
    if l <= start && r >= end {
        return st.tree[node]
    }
    if r < start || l > end {
        return 0
    }
    mid := start + (end-start)/2
    leftSum := int64(0)
    rightSum := int64(0)
    if l <= mid {
        leftSum = st.queryRange(2*node+1, start, mid, l, r)
    }
    if r > mid {
        rightSum = st.queryRange(2*node+2, mid+1, end, l, r)
    }
    return st.merge(leftSum, rightSum)
}

在queryRange函数中,首先检查当前节点的懒惰标记,如果有标记则进行下推更新。然后判断当前区间与目标查询区间的关系,如果当前区间完全包含在目标区间内,则直接返回当前节点的值;如果没有交集,则返回 0;如果部分重叠,则分别递归查询左右子树,并通过merge函数合并结果。

6.3 区间更新

区间更新操作同样利用懒惰标记来优化,避免对每个受影响的节点都进行即时更新。以下是区间更新的示例代码:

func (st *SegmentTree) updateRange(node, start, end, l, r int, val int64) {
    if st.lazy[node] != 0 {
        st.tree[node] += st.lazy[node] * int64(end-start+1)
        if start != end {
            st.lazy[2*node+1] += st.lazy[node]
            st.lazy[2*node+2] += st.lazy[node]
        }
        st.lazy[node] = 0
    }
    if l <= start && r >= end {
        st.tree[node] += val * int64(end-start+1)
        if start != end {
            st.lazy[2*node+1] += val
            st.lazy[2*node+2] += val
        }
        return
    }
    if r < start || l > end {
        return
    }
    mid := start + (end-start)/2
    st.updateRange(2*node+1, start, mid, l, r, val)
    st.updateRange(2*node+2, mid+1, end, l, r, val)
    st.tree[node] = st.merge(st.tree[2*node+1], st.tree[2*node+2])
}

在updateRange函数中,同样先处理当前节点的懒惰标记。如果当前区间完全包含在要更新的区间内,则更新当前节点的值,并给子节点打上懒惰标记;如果部分重叠,则递归更新左右子树,最后更新当前节点的值。

下面是一个比较通用的线段树模板,支持区间加法 + 区间乘法 + 区间求和:

using namespace std;
typedef long long ll;

class SegmentTree {
public:
    struct Node {
        ll l, r;
        ll sum, add, mul;
    };

    vector<Node> tree;
    vector<ll> data;

    SegmentTree(const vector<ll>& input) {
        data = input;
        int size = input.size() - 1; // 1-based
        tree.resize(size * 4 + 10);
        build(1, 1, size);
    }

    void build(int i, int l, int r) {
        tree[i] = {l, r, 0, 0, 1};
        if (l == r) {
            tree[i].sum = data[l];
            return;
        }
        int mid = (l + r) >> 1;
        build(i << 1, l, mid);
        build(i << 1 | 1, mid + 1, r);
        push_up(i);
    }

    void range_add(int l, int r, ll val) {
        add(1, l, r, val);
    }

    void range_mult(int l, int r, ll val) {
        mult(1, l, r, val);
    }

    ll range_query(int l, int r) {
        return query(1, l, r);
    }

private:
    void push_up(int i) {
        tree[i].sum = tree[i << 1].sum + tree[i << 1 | 1].sum;
    }

    void push_down(int i) {
        ll mul = tree[i].mul, add = tree[i].add;
        apply(i << 1, mul, add);
        apply(i << 1 | 1, mul, add);
        tree[i].mul = 1;
        tree[i].add = 0;
    }

    void apply(int i, ll mul, ll add) {
        tree[i].sum = tree[i].sum * mul + (tree[i].r - tree[i].l + 1) * add;
        tree[i].mul *= mul;
        tree[i].add = tree[i].add * mul + add;
    }

    void add(int i, int l, int r, ll val) {
        if (tree[i].l >= l && tree[i].r <= r) {
            apply(i, 1, val);
            return;
        }
        push_down(i);
        if (tree[i << 1].r >= l) add(i << 1, l, r, val);
        if (tree[i << 1 | 1].l <= r) add(i << 1 | 1, l, r, val);
        push_up(i);
    }

    void mult(int i, int l, int r, ll val) {
        if (tree[i].l >= l && tree[i].r <= r) {
            apply(i, val, 0);
            return;
        }
        push_down(i);
        if (tree[i << 1].r >= l) mult(i << 1, l, r, val);
        if (tree[i << 1 | 1].l <= r) mult(i << 1 | 1, l, r, val);
        push_up(i);
    }

    ll query(int i, int l, int r) {
        if (tree[i].l >= l && tree[i].r <= r)
            return tree[i].sum;
        push_down(i);
        ll res = 0;
        if (tree[i << 1].r >= l) res += query(i << 1, l, r);
        if (tree[i << 1 | 1].l <= r) res += query(i << 1 | 1, l, r);
        return res;
    }
};
int main() {
    ll n, m;
    cin >> n >> m;
    vector<ll> arr(n + 1); // 1-based index
    for (int i = 1; i <= n; i++) {
        cin >> arr[i];
    }

    SegmentTree seg(arr);
    while (m--) {
        int op;
        cin >> op;
        if (op == 1) {
            ll l, r, val;
            cin >> l >> r >> val;
            seg.range_mult(l, r, val);
        } else if (op == 2) {
            ll l, r, val;
            cin >> l >> r >> val;
            seg.range_add(l, r, val);
        } else if (op == 3) {
            ll l, r;
            cin >> l >> r;
            cout << seg.range_query(l, r) << endl;
        }
    }

    return 0;
}

Part-7. 总结

线段树是一个强大的数据结构,特别适合处理区间查询和修改操作。虽然其实现相对复杂,但在需要频繁进行区间操作的场景中,它的效率远超普通的数组操作。通过合理运用线段树的优化技巧和扩展形式,能够进一步提升其在不同场景下的性能表现。掌握线段树的原理和实现,对解决区间相关问题具有很大的帮助,无论是在算法竞赛中,还是在实际的软件开发中,都能发挥重要作用。

posted @ 2025-08-16 17:04  Rookie青果  阅读(122)  评论(0)    收藏  举报