分享|线段树到底存什么?用一份 Python 模板串起 4 道题

29
2026.11.01
2026.11.01
发布于 上海
线段树到底存什么?用一份 Python 模板串起 4 道题
1. 先把线段树理解成“分块记账”
一块里,究竟记什么账?
lazy:整块都加一个数,先记下来
2. 四题共用的线段树模板
3. 第 60 题:反复选出“剩余数字中的第几个”
为什么能用线段树?
先用阶乘算出:这一位该选第几个?
再用线段树找出:这个数字是谁?
套用公共模板
4. 第 4099 题:统计前面有多少个数比当前数字大
先判断哪些位置必须解锁
为什么还能用线段树?
叶子按数字值来排,别按原数组位置来排
套用公共模板
5. 第 2916 题:一批子数组的不同元素数同时加 1
为什么能用线段树?
当前数字上次出现在哪里?
树里存计数,平方和怎么求?
套用公共模板
6. 第 4086 题:找所有能接上的区间中,谁到得最远
先想每次该选谁
为什么能用线段树?
目标先合并,已经覆盖到的地方要接着用
两个边界,别顺手改掉
套用公共模板
7. 把四题放在一起看
线段树到底存什么?用一份 Python 模板串起 4 道题
最近刷题,碰到了四道都能用线段树来写的题:排列序列、锁定数组排序、不同元素数目的平方和,还有区间覆盖。
刚开始看模板,我最容易卡在这一串东西上:p * 2、p * 2 + 1、push、pull、lazy。
代码看着都认识,合起来却不知道为什么要这样写。尤其是第 60 题:题目让我找一个排列,怎么就扯上线段树了?
后来我发现,得先把每道题翻译成一句具体的话,再去看模板。
这篇就按这个顺序来:先解释题目为什么能用线段树,再确定每个位置存什么,最后把代码填进同一份模板。 四题都使用下面这个 SegmentTree,内部代码不换。
1. 先把线段树理解成“分块记账”
假设有数组 [1, 2, 3, 4],我们想反复查询某一段的和,还会修改里面的数字。
可以先把它分成两半,分别记下总和,再继续分,直到每块只剩一个数字:
PlainText
                     [0,3]:和为10
                    /            \
             [0,1]:和为3     [2,3]:和为7
               /     \          /     \
          [0]:1   [1]:2    [2]:3   [3]:4
问 [0,1] 的和,树里已经有这一块的答案,直接拿走。修改下标 1 的数字,就更新它所属的 [1]、[0,1]、[0,3],另一半不用动。
这就是线段树的基本工作:把一个范围不断分成两半,每一块保存一个可以由左右两块合并出来的结果。
模板里的三个变量可以这样读:
变量
含义
p
当前这一块的节点编号
l, r
当前这一块负责的原数组下标范围,包含左右端点
tree[p]
当前这一块保存的结果
根节点编号为 1。编号为 p 的节点,它的左孩子编号是 p*2,右孩子编号是 p*2+1。
注意:p 是树里的编号,l 和 r 才是原数组的下标。
一块里,究竟记什么账?
这才是换题时最需要重新想的地方。
如果每个位置存 0 或 1,表示一个数字是否还可用,那么一段的和,就是这一段还有几个数字可以选。
如果每个位置存某个数字出现了几次,那么一段的和,就是某一段值域内出现过多少个数字。
如果每个位置存一个区间的右端点,那么一段的最大值,就是这些区间最远能到哪里。
同样在操作一棵树,叶子代表的东西可以不同,左右孩子的合并规则也可以不同。
lazy:整块都加一个数,先记下来
如果一块有 4 个数字,现在每个数字都加 2,那么这一块的总和直接加 8 就行。
暂时不用立刻修改下面的每个数字,先记录:
PlainText
tree[p]:这一块的总和已经加过8了。
lazy[p]:下面还没收到更新,每个数字以后还要加2。
所以,lazy 记录的是每个元素待下传的增加量,不是整块总和的增加量。
之后真要进入孩子时,再把这笔增加量传下去。
PlainText
push:把之前记下的增加量往下传。
pull:孩子修改完,把结果往上重新合并。
记住:当前节点的 tree[p] 已经更新过,push 时不会再把同一笔增加量重复加到当前节点。
2. 四题共用的线段树模板
这份模板支持:
调用
含义
SegmentTree(nums)
建一棵维护区间和的树
SegmentTree(nums, mode="max")
建一棵维护区间最大值的树
add(l, r, value)
闭区间内每个元素增加 value
query(l, r)
查询闭区间的和或最大值
update(i, value)
把下标 i 的元素直接改成 value
kth(k)
sum 模式下,找到第 k 个计数所在的下标
统一约定:原数组下标从 0 开始,区间都是闭区间,初始数组非空,传入的非空区间下标合法。
kth 额外要求每个叶子是非负整数,排名从 1 开始,而且不能超过总计数。例如叶子 [1,0,1,1],查询第 2 个,返回下标 2。
建树时间和空间都是 O(n),单次查询、修改和第 k 个查询都是 O(log n)。
Python
class SegmentTree:
    """区间加、区间查询、单点赋值;sum模式额外支持第k个计数。

    nums非空,下标从0开始,区间均为闭区间,调用者保证下标合法。
    mode='sum'维护区间和,mode='max'维护区间最大值。
    kth要求每个叶子的值都是非负整数,排名k从1开始。
    """

    def __init__(self, nums, mode="sum"):
        if not nums or mode not in ("sum", "max"):
            raise ValueError("nums必须非空,mode必须是sum或max")

        self.n = len(nums)
        self.mode = mode
        self.identity = 0 if mode == "sum" else float("-inf")
        self.merge = (lambda a, b: a + b) if mode == "sum" else max

        self.tree = [self.identity] * (4 * self.n)
        self.lazy = [0] * (4 * self.n)
        self._build(1, 0, self.n - 1, nums)

    def _pull(self, p):
        self.tree[p] = self.merge(
            self.tree[p * 2],
            self.tree[p * 2 + 1]
        )

    def _build(self, p, l, r, nums):
        if l == r:
            self.tree[p] = nums[l]
            return

        mid = (l + r) // 2
        self._build(p * 2, l, mid, nums)
        self._build(p * 2 + 1, mid + 1, r, nums)
        self._pull(p)

    def _apply(self, p, l, r, value):
        # 整段加value:
        # 区间和增加长度*value,区间最大值增加value。
        if self.mode == "sum":
            self.tree[p] += (r - l + 1) * value
        else:
            self.tree[p] += value

        self.lazy[p] += value

    def _push(self, p, l, r):
        if l == r or self.lazy[p] == 0:
            return

        mid = (l + r) // 2
        self._apply(p * 2, l, mid, self.lazy[p])
        self._apply(p * 2 + 1, mid + 1, r, self.lazy[p])
        self.lazy[p] = 0

    def add(self, left, right, value):
        """闭区间[left, right]内每个元素增加value。"""
        if left > right:
            return

        self._add(1, 0, self.n - 1, left, right, value)

    def _add(self, p, l, r, ql, qr, value):
        if ql <= l and r <= qr:
            self._apply(p, l, r, value)
            return

        self._push(p, l, r)
        mid = (l + r) // 2

        if ql <= mid:
            self._add(p * 2, l, mid, ql, qr, value)
        if qr > mid:
            self._add(p * 2 + 1, mid + 1, r, ql, qr, value)

        self._pull(p)

    def query(self, left, right):
        """查询区间和或最大值;空区间返回对应的单位元。"""
        if left > right:
            return self.identity

        return self._query(1, 0, self.n - 1, left, right)

    def _query(self, p, l, r, ql, qr):
        if ql <= l and r <= qr:
            return self.tree[p]

        self._push(p, l, r)
        mid = (l + r) // 2
        result = self.identity

        if ql <= mid:
            result = self.merge(
                result,
                self._query(p * 2, l, mid, ql, qr)
            )
        if qr > mid:
            result = self.merge(
                result,
                self._query(p * 2 + 1, mid + 1, r, ql, qr)
            )

        return result

    def update(self, index, value):
        """单点赋值:将nums[index]改为value。"""
        self._update(1, 0, self.n - 1, index, value)

    def _update(self, p, l, r, index, value):
        if l == r:
            self.tree[p] = value
            self.lazy[p] = 0
            return

        self._push(p, l, r)
        mid = (l + r) // 2

        if index <= mid:
            self._update(p * 2, l, mid, index, value)
        else:
            self._update(p * 2 + 1, mid + 1, r, index, value)

        self._pull(p)

    def kth(self, k):
        """sum模式:返回第k个计数所在的下标;叶子必须为非负整数。"""
        if self.mode != "sum" or not 1 <= k <= self.tree[1]:
            raise ValueError("kth要求sum模式,且1 <= k <= 总计数")

        return self._kth(1, 0, self.n - 1, k)

    def _kth(self, p, l, r, k):
        if l == r:
            return l

        # 区间加可能还未传给孩子,比较左子树计数前必须下推。
        self._push(p, l, r)

        mid = (l + r) // 2
        left_count = self.tree[p * 2]

        if k <= left_count:
            return self._kth(p * 2, l, mid, k)

        return self._kth(
            p * 2 + 1, mid + 1, r, k - left_count
        )
这段代码比较长,可以先对照函数名看它在做什么,不用一次背下来:
函数
可以怎么理解
_build
拆成左右两块,一直拆到单个元素,再合并
_pull
左右孩子已经算好,把当前节点重新算出来
_apply
当前这一整块都要加,先更新结果、记录 lazy
_push
要访问孩子了,把之前记录的增加量传下去
_add
整块命中就直接加;否则进入相关的孩子
_query
整块命中就直接返回;否则去相关的孩子里找
_update
找到一个叶子改值,再沿原路向上更新
_kth
看左边有几个,决定继续往哪边找
tree 和 lazy 都分配 4*n 个位置,是因为这份模板按父子编号存树,中间可能留空位。实际节点有 2*n-1 个,但最大编号不一定是 2*n-1,用 4*n 可以留够空间。
下面每题的代码,都配合上面这个公共类使用:复制公共类,再复制当前题的 Solution 和它使用的导入语句,就组成了完整代码。
3. 第 60 题:反复选出“剩余数字中的第几个”
60. 排列序列
为什么能用线段树?
题目给 n 和 k,要找由 1 到 n 组成的所有排列中,按字典序排在第 k 个的排列。
我们可以从左到右,一位一位决定答案。每一位都要做两件事:
  1. 1.在还没用的数字里,选出第几个小的。
  2. 2.选完,把这个数字标记为已使用。
例如,剩余数字是:
PlainText
1、2、3、4
选第 2 小,选中 2。接下来剩余数字是:
PlainText
1、3、4
再选第 2 小,这次就应该选 3。
线段树要帮忙的,就是数字不断被用掉后,仍然能快速找到剩余的第几个。
先用阶乘算出:这一位该选第几个?
用 n=4, k=9 推演。第一位选定后,后面 3 个数字有 3! = 6 种排列,所以排列会按第一位分成四组:
PlainText
第1~6个:   1开头
第7~12个:  2开头
第13~18个: 3开头
第19~24个: 4开头
第 9 个在第二组,因此第一位选择第 2 小的可用数字,也就是 2。
把排列编号改成从 0 开始,原来的第 9 个就变成编号 8。每组有 6 个,整除就能得到它属于哪一组:
PlainText
rank = 8 // 6 + 1 = 2
进入这一组后,前面已经跳过了 6 个排列,组内编号就是:
PlainText
k = 8 % 6 = 2
剩 m 个数字时,对应的代码就是:
Python
size = fact[m-1]       # 当前位选定后,后面有多少种排列
rank = k // size + 1   # 选择剩余数字中第几小的
k %= size             # 进入这一组,继续找组内的排列
再用线段树找出:这个数字是谁?
每个数字对应一个位置,1 表示可用,0 表示已经使用:
PlainText
数字:1  2  3  4
下标:0  1  2  3
可用:1  1  1  1
这里树里维护的是可用数字数量,不是数字本身的和。
根的左半边有 2 个可用数字。找第 2 个,就进入左边。左边再看自己的左孩子,只有 1 个可用数字,于是跳过它,去右孩子找第 1 个。最终找到下标 1,对应数字 2。
选完执行 update(1, 0):
PlainText
数字:1  2  3  4
可用:1  0  1  1
位置不会移动。下一轮找第 2 个时,线段树会跳过 0,找到数字 3。
完整过程如下,表里的 k 已经改成从 0 开始:
剩余数字
k
每组排列数
选第几小
选中数字
更新后的 k
1、2、3、4
8
6
2
2
2
1、3、4
2
2
2
3
0
1、4
0
1
1
1
0
4
0
1
1
4
0
最终得到 2314。
套用公共模板
Python
class Solution:
    def getPermutation(self, n: int, k: int) -> str:
        # fact[i]表示i!,包括fact[0]=1
        fact = [1] * (n + 1)
        for i in range(1, n + 1):
            fact[i] = fact[i-1] * i

        # 下标i对应数字i+1;所有数字一开始都可用
        seg = SegmentTree([1] * n)
        answer = []

        # 排列编号转成从0开始,方便整除分组
        k -= 1

        for m in range(n, 0, -1):
            # 剩m个数字时,每个候选数字开头的排列有(m-1)!个
            size = fact[m-1]
            rank = k // size + 1
            k %= size

            # 找出第rank个可用数字,返回的是原下标
            index = seg.kth(rank)
            answer.append(str(index + 1))

            # 选过的数字改成0,后面不会再被选中
            seg.update(index, 0)

        return ''.join(answer)
时间复杂度 O(n log n),空间复杂度 O(n)。
这题里 lazy 一直为 0,因为没有使用区间加。主要关注 kth 找数字、update 删除数字这两个操作就行。
4. 第 4099 题:统计前面有多少个数比当前数字大
nums 是 1 到 n 的一个排列。有些位置被锁定,只有两个相邻位置都解锁后,才能交换它们。也可以花一次操作解锁一个位置,但最多解锁 k 次。求排成升序需要的最少总操作数。
先判断哪些位置必须解锁
排序后的下标 i 应该放数字 i+1。
如果某个锁定位置上的数字不等于 i+1,那它必须移动,因此这个位置必须解锁。
但数字已经放对,也不代表它一定可以保留锁定。例如:
PlainText
nums = [1, 4, 3, 2]
              ↑
          下标2锁定
3 确实已经在正确位置,可 4 和 2 还需要跨过它完成交换。相邻交换无法穿过锁定位置,所以这里仍然需要解锁。
因此,锁定位置 i 能够保留,需要同时满足:
PlainText
nums[i] == i+1
前缀最大值 <= i+1
为什么这两个条件就够了?nums 是一个排列,前 i+1 个数互不相同,且都不超过 i+1,那它们恰好就是 1 到 i+1。当前位置已经是 i+1,所以它左边都是更小的数,右边都是更大的数,两边不需要跨过它交换。
把不满足条件的锁定位置全部计入 lock_cnt。如果超过 k,直接返回 -1。
为什么还能用线段树?
必须解锁的位置处理完后,还需要计算交换次数。
一个前面的大数,要和后面的小数交换顺序,这样的一对就叫逆序对。通过相邻交换排序,最少交换次数等于逆序对数:每次交换相邻的逆序元素,恰好消掉一个逆序对。
保留的锁定位置已经把数字正确分开,两边没有跨过它的逆序对。因此可以分别排序各段,总交换次数仍然是整个数组的逆序对数。
于是答案为:
PlainText
必须解锁的位置数 + 逆序对数
问题落到这里,就变成了:
从左往右扫描,每遇到一个数字,查询前面出现过的数字里,有多少个比它大,然后把它记为已出现。
线段树能维护这个“出现过多少个”的计数。
叶子按数字值来排,别按原数组位置来排
数字 num 对应下标 num-1,叶子记录这个数字在已扫描前缀中出现了几次。
扫描到原数组下标 i 时,前面已经有 i 个数字。查询:
Python
seg.query(0, num-1)
得到前面小于等于 num 的数量。用 i 减掉它,就是前面大于 num 的数量。
例如扫描 [3,1,2]:
当前数字
前面已有几个数
前面小于等于它的数量
新增逆序对
3
0
0
0
1
1
0
1
2
2
1
1
一共 2 个逆序对:(3,1) 和 (3,2)。
每轮都是先查询,再把当前数字计入线段树。
套用公共模板
Python
class Solution:
    def minOperations(self, nums: list[int], locked: list[int], k: int) -> int:
        n = len(nums)
        max_num = 0
        lock_cnt = 0

        for i, num in enumerate(nums):
            max_num = max(max_num, num)
            if locked[i]:
                # 自己没放对,或者左边有大数需要跨过自己
                if num != i+1 or max_num > i+1:
                    lock_cnt += 1
                    if lock_cnt > k:
                        return -1

        # 下标num-1记录数字num在已扫描前缀中的出现次数
        seg = SegmentTree([0] * n)
        swap_cnt = 0

        for i, num in enumerate(nums):
            # 前面共有i个数字,减掉其中小于等于num的数量
            swap_cnt += i - seg.query(0, num-1)

            # 当前数字加入已扫描前缀:对应位置加1
            seg.add(num-1, num-1, 1)

        return swap_cnt + lock_cnt
时间复杂度 O(n log n),空间复杂度 O(n)。
这题和第 60 题都在维护数量。第 60 题是“用掉一个,改成 0”,这题是“遇到一个,加 1”;一个查询第几个,一个查询某段里有几个。
5. 第 2916 题:一批子数组的不同元素数同时加 1
题目要求:对所有非空子数组,统计其中不同元素的数量,再把这些数量的平方加起来。
为什么能用线段树?
我们从左到右枚举子数组的右端点 r。右端点固定后,剩下的区别就是左端点在哪里。
例如当前扫描到了 [1,2] 的末尾,可以考虑:
PlainText
左端点0:[1,2],不同元素数为2
左端点1:[2],  不同元素数为1
因此可以让下标 i 这个叶子,保存以 i 为左端点、以当前 r 为右端点的子数组有多少种不同元素。
如果新的数字加入后,一整段左端点对应的计数都会加 1,就能用线段树的一次区间加来完成。
关键是先找出:到底是哪一段会加 1?
当前数字上次出现在哪里?
设新数字 x 在下标 r,它上次出现在 prev,没有出现过就记为 -1。
如果左端点不超过 prev,这个子数组本来就包含 x,再加入一个 x,不同元素数不变。
如果左端点大于 prev,这个子数组之前没有 x,新加入后,不同元素数增加 1。
所以需要加 1 的左端点范围恰好是:
PlainText
[prev+1, r]
比如从 [1,2] 扩展到 [1,2,1],新的 1 上次出现在下标 0:
PlainText
左端点0:[1,2] → [1,2,1],不同元素数仍为2
左端点1:[2]   → [2,1],  不同元素数从1变成2
左端点2:空    → [1],    不同元素数从0变成1
变化的是下标 [1,2] 这一段。
右端点还没到达的位置,叶子暂时存 0。这样新左端点 r 的贡献,也能一起包含在这次加 1 里。
树里存计数,平方和怎么求?
原来某个子数组有 c 种不同元素,现在变成 c+1 种,平方增加了多少?
PlainText
(c+1)² - c² = 2c+1
假设受影响的一段有 length 个左端点,它们的旧计数之和为 old_sum。那么总平方增量就是:
PlainText
2 * old_sum + length
所以只要先查这一段旧计数的和,就能算出平方和增加多少,不需要把每个叶子的平方都算一遍。
维护两个变量:
  • ending:以当前 r 为右端点的所有子数组,不同元素数的平方和。
  • answer:已经处理过的所有右端点的 ending 之和。
每一轮做三件事:
PlainText
先查受影响区间的旧计数和。
用2*旧和+区间长度,更新ending。
给这段计数加1,再把ending加入answer。
用 [1,2,1] 推演:
右端点 r
当前数字
更新后的叶子计数
ending
answer
0
1
[1,0,0]
1
1
1
2
[2,1,0]
5
6
2
1
[2,2,1]
9
15
最后一轮,受影响区间是 [1,2],旧计数为 [1,0],旧和为 1、长度为 2,平方增量为 2*1+2=4。因此 ending 从 5 变成 9。
一定要先查询旧和,再给区间加 1。 顺序反过来,公式里代入的就不再是旧计数了。
套用公共模板
Python
class Solution:
    def sumCounts(self, nums: list[int]) -> int:
        mod = 10**9 + 7
        seg = SegmentTree([0] * len(nums))
        last = {}
        ending = 0
        answer = 0

        for r, x in enumerate(nums):
            prev = last.get(x, -1)
            left = prev + 1

            # 先查旧计数之和,计算平方和增加多少
            old_sum = seg.query(left, r)
            ending += 2 * old_sum + (r-left+1)

            # 这些左端点对应的不同元素数,都增加1
            seg.add(left, r, 1)

            # 每个子数组只在它的右端点这一轮被加入总答案
            answer = (answer + ending) % mod
            last[x] = r

        return answer
时间复杂度 O(n log n),空间复杂度 O(n)。
这一次用到区间加,lazy 就真正起作用了:某一整块计数都加 1 时,先更新这一块的总和、记下标记,等需要进入孩子时再下传。
6. 第 4086 题:找所有能接上的区间中,谁到得最远
4086. 覆盖目标区间的最少区间|第 524 场周赛 Q4
题目有两组闭区间:availableIntervals 表示可以选择的区间,targetIntervals 表示必须覆盖的范围。选尽量少的可选区间,覆盖目标里的每一个实数点,做不到返回 -1。
先想每次该选谁
假设当前需要从位置 x 继续覆盖。
下一条区间必须能接上,也就是左端点不超过 x。在这些区间里,我们选右端点最远的那一条。
为什么?假设某个最优方案这一步选得短一些,把它换成到得更远的区间,当前需要覆盖的位置仍然能覆盖到,后面能走到的位置也不会更差,使用数量没有增加。
因此每次都可以这样选,再继续处理后面的需求。
为什么能用线段树?
每次真正需要回答的问题是:
所有左端点不超过 x 的可选区间中,最大的右端点是多少?
先把可选区间按左端点排序,满足条件的区间就集中在最前面的一段。
如果叶子存对应区间的右端点,节点维护最大值,查询这一段,就能得到下一步最远能走到哪里。
这次树里保存的是右端点的最大值。
例如:
PlainText
可选区间:[1,4] [1,7] [3,9] [5,10] [10,15]
左端点:    1     1     3      5       10
叶子值:    4     7     9     10       15
当前 x=1,查询前两个位置,得到 7。当前 x=7,查询前四个位置,得到 10。
代码里用 bisect_right(starts, x)-1 找到最后一个左端点不超过 x 的位置 index,然后查:
Python
seg.query(0, index)
它查询的是排好序后的候选区间下标,原坐标有多大、是否连续,都不影响这个索引方式。
目标先合并,已经覆盖到的地方要接着用
目标区间先按左端点排序,再合并重叠或端点相接的区间。例如:
PlainText
[1,3]、[2,5]、[7,8]、[10,13]、[11,15]
                     ↓
             [1,5]、[7,8]、[10,15]
维护 covered_end,表示之前选中的区间最远覆盖到哪里。它要放在外层循环之外,换目标时也保留。
比如目标是 [1,2]、[8,9],可选区间是 [1,10],选一次就都覆盖了。
目标之间的空白不用专门覆盖。开始处理下一段时,从 max(start, covered_end) 继续。
两个边界,别顺手改掉
题目覆盖的是实数点,[1,2] 和 [3,4] 中间有空隙,不能接成连续的 [1,4]。因此接到 x 之后,下一条还是从 x 接,不能把位置改成 x+1。
失败判断用:
Python
if next_end < cur_start or next_end == covered_end:
    return -1
前者表示连当前需要接的位置都到不了;后者表示已经无法继续延伸。
不要把条件改成 next_end <= cur_start。目标和候选都只有 [5,5] 时,覆盖这个单点是合法的,答案为 1。
套用公共模板
Python
from bisect import bisect_right


class Solution:
    def minSelection(self, availableIntervals: list[list[int]],
                     targetIntervals: list[list[int]]) -> int:
        # 合并目标;新建内层列表,不修改传入的目标区间
        sorted_target = sorted(targetIntervals, key=lambda x: x[0])
        merged_target = []

        for start, end in sorted_target:
            if merged_target and start <= merged_target[-1][1]:
                merged_target[-1][1] = max(merged_target[-1][1], end)
            else:
                merged_target.append([start, end])

        # 按左端点排序,叶子存对应候选区间的右端点
        sorted_available = sorted(availableIntervals, key=lambda x: x[0])
        starts = [start for start, end in sorted_available]
        seg = SegmentTree(
            [end for start, end in sorted_available],
            mode="max"
        )

        ans = 0
        covered_end = float('-inf')

        for start, end in merged_target:
            # 目标之间可以跳空;之前覆盖过来的部分继续使用
            cur_start = max(start, covered_end)

            while covered_end < end:
                # 找到所有左端点不超过cur_start的候选
                index = bisect_right(starts, cur_start) - 1

                # 查询这些候选中,最远能到达的右端点
                # 没有候选时index=-1,空区间查询返回负无穷
                next_end = seg.query(0, index)

                if next_end < cur_start or next_end == covered_end:
                    return -1

                ans += 1
                covered_end = next_end
                cur_start = covered_end

        return ans
这题不需要修改线段树:所有候选都提前放好,每次查询能接上的那一段的最大右端点即可。
也不必删除选过的候选。选完后 covered_end 只会增大,旧候选的右端点不会超过已经到达的位置,无法让下一步获得额外进展。
设可选区间数为 a,目标区间数为 t。每次成功选择都会到达一个严格更大的候选右端点,所以最多成功选择 a 次。加上排序,总时间复杂度为 O(a log a + t log t),空间复杂度为 O(a+t)。
7. 把四题放在一起看
题目
一个叶子代表什么
每块维护什么
核心调用
60 排列序列
一个数字是否可用
可用数量之和
kth(rank)、update(index, 0)
4099 锁定数组排序
一个数字在已扫描前缀中出现几次
出现次数之和
query(0, num-1)、单点加 1
2916 不同元素数平方和
当前右端点下,一个左端点对应的不同元素数
计数之和
区间查旧和、区间加 1
4086 覆盖目标区间
一个候选区间的右端点
最大右端点
mode="max"、前缀查询
我觉得这几题最值得放在一起的地方,是同一个叶子下标,在不同题里含义完全不同。
第 60 题和第 4099 题按数字值索引,第 2916 题按子数组左端点索引,第 4086 题按排序后的候选区间索引。先想清楚这一层,才能知道传给 query 的两个数字到底代表什么。
最后把几个容易混的地方放在这里:
  1. 1.add(i, i, 1) 是原值加 1,update(i, 0) 是直接改成 0。
  2. 2.第 60 题的排列编号从 0 开始,kth 的排名从 1 开始,返回的下标又从 0 开始。
  3. 3.第 2916 题要先查询旧和,再区间加 1。
  4. 4.kth 需要非负整数计数,树里的计数保持准确,不取模。
  5. 5.sum 模式合并时相加,max 模式合并时取最大值;整段加 value 时,前者增加长度乘 value,后者增加 value。
  6. 6.第 4086 题覆盖实数点,保留全局覆盖末端,也要考虑单点区间。
再遇到一道题时,可以先试着填这句话:
我需要维护____,每次会修改____,并查询____。
比如第 60 题填出来就是:“我需要维护剩余数字,每次选走一个,并查询剩下的第几个。”
这句话说清楚了,再去看 p*2 和 p*2+1,它们就只是帮我们往左右两半走的编号。
大家学线段树时,最容易卡住的是“叶子到底存什么”,还是“lazy 什么时候下推”?欢迎拿具体例子一起推。
#线段树 #LeetCode #Python #算法模板 #刷题复盘
评论 (1)