CC++ & Algorithm

线段树的懒标记(Lazy Propagation)

极难3
语言版本:通用
概述:深入理解懒标记如何实现高效的区间修改,通过延迟更新子节点来保持O(log n)的复杂度。

懒标记(Lazy Propagation):让线段树“偷懒”却更高效

为什么线段树需要“懒标记”?

想象一下,你有一排连续书架(1到n个位置),每个位置放一本书。你想给某个区间(比如第2到第5本)的所有书都贴上标签“科幻”。如果每次贴标签都要走到每一本书前亲手贴,那贴一个区间可能花费很多时间。但如果你在每层的书架管理员那里记一个“这个区间里的书都该贴科幻标签”的标记,而不去实际触碰每一本书,等以后有人要查看某本书时,再根据标记处理,那效率就高多了——这就是“懒标记”的思想。

在线段树中,我们经常需要对一个连续区间进行修改(比如每个数加一个值、赋一个值)。如果每次都更新到叶子节点,那么修改一个区间的时间复杂度会变成O(n)(n是区间长度),失去了线段树O(log n)的优势。懒标记(Lazy Propagation)通过“延迟更新”解决了这个问题:只在完全覆盖的节点上打一个标记,记录“本区间需要统一做的操作”,而暂时不更新它的子节点。 只有当后续查询或修改需要深入到子区间时,才把标记“下传”给子节点,保证正确性。这样,每次区间修改只需在O(log n)个节点上操作。

生活中的类比:老师布置作业的“懒”方法

(保留原有内容)数学老师布置了30道练习题,说:“全班同学都要做,但我不现在批改,等下周再统一收上来改。”这其实就是“懒标记”。老师先记录下“全班需要做30题”这个信息,但暂时不检查每个同学是否真的做了。等到下周收作业时,再逐个检查。如果中间有同学转学进来,老师会把“30题”这个标记传递给他,他也要做。

在线段树中,懒标记就是类似的思想:当我们要把一个区间[L,R]内的每个数都加上一个值d时,我们不立即更新这个区间下的所有叶子节点,而是只在当前节点上打一个“加d”的标记(懒标记),并更新当前节点的值(因为当前节点代表区间,它的和可以直接加上d * 区间长度)。然后暂停向下更新。只有当后续查询或修改需要深入到某个子区间时,才把懒标记下传给子节点。这样每个区间修改操作只需要更新O(log n)个节点。

懒标记的原理与实现

数据结构定义

除了线段树的tree数组外,还需要一个lazy数组(懒标记数组),lazy[p]表示节点p上挂着的尚未下传的加法值(也可以是赋值标记等)。初始时lazy[p] = 0。

核心操作:pushdown(下传标记)—— 关键中的关键

函数pushdown(p, l, r) 将节点p的懒标记传递给它的左右子节点:

  • 如果lazy[p] != 0,说明当前节点有未下传的加法。
  • 计算左子树区间长度len_left = mid - l + 1,右子树区间长度len_right = r - mid。
  • 更新左子树的tree值和lazy标记:tree[p2] += lazy[p] * len_left,lazy[p2] += lazy[p]。
  • 同理更新右子树。
  • 清除当前节点的懒标记:lazy[p] = 0。

注意:pushdown只会在需要深入子节点时调用,比如在递归的update或query中,访问左右孩子之前先调用pushdown。

为什么要用pushdown? 因为懒标记只记录在父节点上,子节点的实际值还是旧的。如果我们直接访问子节点,得到的就是错误值。所以,只要我们需要进入子节点,就必须先把父节点上的标记“下传”给子节点,让子节点也更新自己的值和标记。这就像老师先把“30题”这个要求记录在班级大名单上,当有同学转班到另一个小组时,老师要把这个要求告诉那个小组长,小组长再负责组员。

区间修改(增加一个值)

函数update(p, l, r, L, R, d):

  • 如果当前区间完全被[L,R]覆盖,则直接更新tree[p] += d * (r-l+1),并打上懒标记lazy[p] += d,不再向下递归。这是“偷懒”的关键一步
  • 否则,先调用pushdown下传当前节点的懒标记,然后递归左右子树。
  • 回溯时合并左右子节点:tree[p] = tree[p2] + tree[p2+1]。

这样,一次区间修改只影响O(log n)个节点(覆盖路径上的节点和它们的孩子),但每次遇到部分覆盖的节点时都会下传标记,保证后续查询正确。

例子: 假如数组长度为8,初始全为0。执行区间[2,5]加10。参考下面ASCII示意图,我们会发现:节点[3,4]被完全覆盖,所以直接打上标记lazy=10并更新tree=20,而不去更新叶子3和4。只有节点[2,2]和[5,5]因为部分覆盖,才不得不递归到叶子处理。这样,整个操作只访问了少数节点。

区间查询

查询函数query(p, l, r, L, R)在递归时,如果当前区间完全被覆盖则直接返回。否则,先调用pushdown,然后递归左右子树。因为我们需要到子节点获取准确的信息,所以必须先把本节点的懒标记下传。

注意: 查询时也要调用pushdown!因为如果某个父节点还挂着懒标记,它的子节点的实际值还没有更新,直接访问子节点会得到错误结果。例如,上面[3,4]节点打了加10的标记,如果我们要查[3,3]的值,就必须先下传标记,更新叶子3和4,然后再取叶子3的值。

ASCII示意图

(保留原有内容)假设初始数组全为0,长度为8。执行区间[2,5]加10的过程:

                                    [1,8] lazy=0
                                   /        \
                              [1,4]         [5,8] 
                              /    \        /    \
                           [1,2] [3,4]   [5,6] [7,8]
                           /  \   /  \   /  \   /  \
                          1   2  3   4  5   6  7   8

首先从根节点[1,8]开始,完全覆盖?不,区间[2,5]部分覆盖。所以根节点先调用pushdown(但lazy=0,无事)。然后递归左孩子[1,4]:区间[2,5]与[1,4]部分覆盖(重叠[2,4])。在[1,4]节点上,它不是完全覆盖,于是又递归进入左孩子[1,2]和右孩子[3,4]。

  • [1,2]节点:与[2,5]重叠[2,2],部分覆盖,继续递归左右。
    • 左孩子[1,1]:不相交,返回。
    • 右孩子[2,2]:完全覆盖([2,2]在[2,5]内),更新tree[2,2] += 10,lazy[2,2] += 10(但叶子节点没有后代)。回溯时更新[1,2]的和。
  • [3,4]节点:完全覆盖(因为[3,4]在[2,5]内),直接更新tree[3,4] += 10*2=20,lazy[3,4] += 10,不再向下递归。 回溯更新[1,4]的tree,然后返回根节点,再递归右孩子[5,8]。
  • [5,8]与[2,5]重叠[5,5],部分覆盖,进入左孩子[5,6]。
    • [5,6]与[2,5]重叠[5,5],部分覆盖,进入左孩子[5,5]完全覆盖,更新+10;右孩子[6,6]不相交。 回溯更新。

最终只有少数节点被更新或打了标记,而[3,4]这样的区间直接打标记,没有深入到叶子。由于标记的存在,之后查询[3,4]时就会得到正确值,而查询[3,3]时会先下传标记再得到正确值。

新手容易犯的常见错误

  1. 忘记在query中调用pushdown
    如果query函数在递归子节点前没有调用pushdown,那么子节点的值可能是过时的。比如上面例子中,查询[3,3]时如果忘记下传[3,4]上的标记,就会返回0(错误),实际应该是10。

  2. 在pushdown中更新子节点时忘了加上父节点的标记
    注意是 tree[p*2] += lazy[p] * left_len,而不是直接赋值。因为子节点可能本身也有自己的标记(来自之前的更新),我们要叠加。

  3. 区间修改时,对完全覆盖的节点忘了更新tree值
    有些人只打了标记,但没更新当前节点的tree,导致父节点向上合并时出错。必须同时更新tree[p] += d * (r-l+1) 和 lazy[p] += d。

  4. 递归边界判断错误
    比如 if (L <= l && r <= R) 中的条件写反成 l <= L && r <= R,会导致区间不匹配时的错误覆盖。

  5. 数组大小开得不够
    线段树通常需要 4 * n 的空间,有人只开 2*n 会导致越界。尤其是带有懒标记时,也可能需要 4*n

完整代码实现(区间加+区间求和,带懒标记)

C++ 实现

#include <iostream>
using namespace std;

const int MAXN = 10000;
int a[MAXN];                // 原始数组
int tree[4 * MAXN];         // 线段树节点存储区间和
int lazy[4 * MAXN];         // 懒标记数组,记录未下传的加法值

// 建树:从原始数组a构造线段树
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];
}

// 下传懒标记:将节点p的标记传递给左右孩子
void pushdown(int p, int l, int r) {
    if (lazy[p] != 0) {
        int mid = (l + r) / 2;
        int left_len = mid - l + 1;    // 左子树区间长度
        int right_len = r - mid;       // 右子树区间长度
        // 更新左孩子
        tree[p * 2] += lazy[p] * left_len;
        lazy[p * 2] += lazy[p];
        // 更新右孩子
        tree[p * 2 + 1] += lazy[p] * right_len;
        lazy[p * 2 + 1] += lazy[p];
        // 清除当前节点标记
        lazy[p] = 0;
    }
}

// 区间修改:区间[L,R]每个元素加d
void update(int p, int l, int r, int L, int R, int d) {
    if (L <= l && r <= R) {                     // 当前区间完全被覆盖
        tree[p] += d * (r - l + 1);             // 更新当前节点和
        lazy[p] += d;                           // 打上懒标记
        return;
    }
    pushdown(p, l, r);                          // 否则先下传标记
    int mid = (l + r) / 2;
    if (L <= mid) update(p * 2, l, mid, L, R, d);
    if (R > mid) update(p * 2 + 1, mid + 1, r, L, R, d);
    tree[p] = tree[p * 2] + tree[p * 2 + 1];    // 回溯更新
}

// 区间查询:返回区间[L,R]的和
int query(int p, int l, int r, int L, int R) {
    if (L <= l && r <= R) return tree[p];       // 完全覆盖直接返回
    pushdown(p, l, r);                          // 必须下传,保证子节点正确
    int mid = (l + r) / 2;
    int res = 0;
    if (L <= mid) res += query(p * 2, l, mid, L, R);
    if (R > mid) res += query(p * 2 + 1, mid + 1, r, L, R);
    return res;
}

int main() {
    int n = 8;
    for (int i = 1; i <= n; i++) a[i] = 0; // 初始全0
    build(1, 1, n);
    cout << "Initial sum [1,8] = " << query(1, 1, n, 1, 8) << endl;
    update(1, 1, n, 2, 5, 10); // 区间加10
    cout << "After update [2,5]+10: sum [1,8] = " << query(1, 1, n, 1, 8) << endl;
    cout << "Sum [3,4] = " << query(1, 1, n, 3, 4) << endl; // 应为20
    cout << "Sum [2,2] = " << query(1, 1, n, 2, 2) << endl; // 应为10
    return 0;
}

输出:

Initial sum [1,8] = 0
After update [2,5]+10: sum [1,8] = 40
Sum [3,4] = 20
Sum [2,2] = 10

Python 实现

class SegmentTree:
    def __init__(self, data):
        self.n = len(data)
        # 将数据转成1-based索引,方便处理
        self.a = [0] + data
        self.tree = [0] * (4 * (self.n + 1))
        self.lazy = [0] * (4 * (self.n + 1))

    def build(self, p, l, r):
        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 pushdown(self, p, l, r):
        if self.lazy[p] != 0:
            mid = (l + r) // 2
            left_len = mid - l + 1
            right_len = r - mid
            # 更新左孩子
            self.tree[p * 2] += self.lazy[p] * left_len
            self.lazy[p * 2] += self.lazy[p]
            # 更新右孩子
            self.tree[p * 2 + 1] += self.lazy[p] * right_len
            self.lazy[p * 2 + 1] += self.lazy[p]
            # 清除当前节点标记
            self.lazy[p] = 0

    def update(self, p, l, r, L, R, d):
        if L <= l and r <= R:                       # 完全覆盖
            self.tree[p] += d * (r - l + 1)
            self.lazy[p] += d
            return
        self.pushdown(p, l, r)                      # 部分覆盖,先下传
        mid = (l + r) // 2
        if L <= mid:
            self.update(p * 2, l, mid, L, R, d)
        if R > mid:
            self.update(p * 2 + 1, mid + 1, r, L, R, d)
        self.tree[p] = self.tree[p * 2] + self.tree[p * 2 + 1]

    def query(self, p, l, r, L, R):
        if L <= l and r <= R:
            return self.tree[p]
        self.pushdown(p, l, r)
        mid = (l + r) // 2
        res = 0
        if L <= mid:
            res += self.query(p * 2, l, mid, L, R)
        if R > mid:
            res += self.query(p * 2 + 1, mid + 1, r, L, R)
        return res

    def range_update(self, L, R, d):
        self.update(1, 1, self.n, L, R, d)

    def range_query(self, L, R):
        return self.query(1, 1, self.n, L, R)

if __name__ == "__main__":
    arr = [0] * 8
    st = SegmentTree(arr)
    st.build(1, 1, st.n)
    print("Initial sum [1,8] =", st.range_query(1, 8))
    st.range_update(2, 5, 10)
    print("After update [2,5]+10: sum [1,8] =", st.range_query(1, 8))
    print("Sum [3,4] =", st.range_query(3, 4))
    print("Sum [2,2] =", st.range_query(2, 2))

输出同C++。

懒标记的更多用途与注意事项

懒标记不仅适用于区间加,还可以实现区间赋值、区间乘、区间覆盖等。例如:

  • 区间赋值:将某个区间所有元素设为同一个值c。此时懒标记记录的是“赋值”操作,下传时要注意覆盖子节点原有值。
  • 区间加和区间乘混合:需要维护两个懒标记(加法和乘法),下传时要注意顺序(先乘后加)。这更复杂,但原理相同。

注意事项:

  • 不同操作之间的懒标记可能会冲突,需要规定优先级。比如既有加法又有乘法时,通常让乘法先应用,加法后应用。
  • 下传标记时,如果子节点已经有标记,要叠加(加法)或覆盖(赋值)处理。

总结要点

  • 懒标记是线段树实现高效区间修改的关键技术。
  • 核心思想:对区间打标记,延迟更新子节点,只在需要时下传。
  • 每次区间修改和区间查询都需要调用pushdown来确保数据一致性。
  • 懒标记可实现区间加、区间赋值等操作,但需注意标记冲突(如先加后乘需谨慎处理)。
  • 有了懒标记,区间修改和查询的时间复杂度均为O(log n)。

掌握懒标记后,线段树就真正成为了解决区间动态问题的利器。接下来,可以学习线段树的另一种变体——权值线段树,它用线段树来维护值域,常用于统计排名、第k小数等问题。此外,还可以研究区间最值查询(RMQ)与懒标记的结合,或者扫描线算法等高级应用。

你可以试着用懒标记解决以下问题:

  • 给定一个数组,支持区间加和区间求和。
  • 支持区间赋值和区间求和。
  • 支持区间加和区间乘(需要两个懒标记)。

动手练习,你会发现懒标记是线段树中最有趣也最实用的部分!

例题精讲

1单选题

线段树的懒标记(Lazy Propagation)主要用于解决什么问题?

A加速单点更新查询
B在区间更新时避免递归更新所有叶子节点,实现O(log n)复杂度
C将线段树的存储空间从O(n)降低到O(log n)
D支持区间查询和单点更新同时高效进行
2判断题

在使用懒标记的线段树中,当执行区间查询时,如果遇到带有懒标记的节点,需要先将懒标记下传到子节点,然后才能继续查询子区间,否则查询结果可能错误。

3填空题
以下是一个线段树区间加法更新的代码片段,其中有一个函数用于将当前节点的懒标记下传到子节点。请填空完成该函数。

void push(int p) {
    if (lazy[p] != 0) {
        int left = p * 2, right = p * 2 + 1;
        tree[left] += lazy[p] * (mid - left + 1); // 假设mid已定义
        tree[right] += lazy[p] * (right - mid);
        lazy[left] += ___;
        lazy[right] += lazy[p];
        lazy[p] = 0;
    }
}

注意:函数中使用了mid变量,假设已从外部获取。
4单选题

假设有一个长度为n的线段树,使用懒标记进行区间赋值操作(将区间内所有值设为x)。在进行完一次区间赋值后,如果接着对该区间的某个子区间进行查询,则以下关于懒标记状态的描述正确的是?

A根节点的懒标记为x,子节点的懒标记均为0
B根节点的懒标记为0,子节点的懒标记为x
C在查询过程中,沿途会将经过节点的懒标记向下传递,最终被访问的叶子节点得到正确值
D懒标记只在更新时使用,查询时不需要处理懒标记
5判断题

在线段树的懒标记实现中,如果区间更新和区间查询操作都没有覆盖到某个节点的整个区间,那么该节点的懒标记可以永久保留而不被下传,不会影响正确性。