跳转至

BISHI128 区间加乘与单点求值

中等通过率 25.37%pypy3样例通过牛客 AC

牛客原题  源码

讲解章节树状数组与线段树

一句话

区间加 x、区间乘 x、单点查询 mod 998244353。

解题思路

这题考什么

1) 仿射变换的复合:加和乘都是 x -> k·x + b 的特例,两个变换复合成 (k1, b1) 然后 (k2, b2) ==> (k1·k2, b1·k2 + b2) 「先加 1 再乘 2」在 x=0 处得 2,「先乘 2 再加 1」得 1——顺序绝不能反, 这是双标记题的头号 WA 来源。 2) 离线换维:本题只有单点查询,这给了在线线段树之外的第二条路。

在线做法(区间仿射 + 单点查的懒标记线段树)每次修改要沿两条边界路径 push down, 约 200-300 次 Python 层操作,1e5 次修改就是 3e7 —— Python 下没戏。

本解法把维度换过来:

  • 扫描轴 = 下标 i(从 1 到 n);
  • 建一棵以操作时间为下标的线段树,叶子 j 存第 j 个修改操作的仿射变换, 未覆盖当前下标时是恒等 (1, 0);内部节点 = 左右儿子按时间顺序的复合;
  • 操作 (l, r, k, b) 在 i = l 处「激活」(写入叶子),在 i = r+1 处「失效」(写回恒等)—— 每个操作只做 2 次单点修改
  • 查询 (下标 x, 该查询之前有 T 个修改) 时,取时间前缀 [0, T) 的复合, 作用在 a_x 上即可。

每次单点修改 O(log q),每次查询 O(log q),总量约 3q·log q ≈ 5e6 次迭代。

数据规模与复杂度

n, q <= 1e5。时间 O((n + q) log q),空间 O(n + q)。

提交语言:本题必须用 PyPy3 提交。

时限是「其他语言 2 秒」,而本做法约需 5e6 次 Python 层循环迭代,
每次迭代还带 2 次模乘,CPython 下实测在 3-5 秒量级,交上去过不了;
同一份代码在 PyPy3 下可以通过。
离线换维已经比在线懒标记线段树快 3-5 倍,结构上没有更省的写法了,
剩下的差距来自解释器常数,只能靠换提交语言补。

坑在哪

  1. 复合顺序:新操作作用在旧结果之后,所以 b <- b·k_new + b_new;
  2. a_i 和 x 都可能是负数,先 % MOD 化到 [0, MOD)(Python 的 % 对负数返回非负,省心);
  3. 查询取的是时间前缀 [0, T),不是整棵树的根—— 根含有比该查询更晚的操作,直接读根是错的;
  4. 若一个修改也没有(全是查询),线段树要能退化到「只有一个恒等叶子」。

参考实现

solutions/BISHI128.py
import sys

MOD = 998244353


def main() -> None:
    data = sys.stdin.buffer.read().split()
    n = int(data[0]); q = int(data[1])
    # 前面补一个占位 0,让下标与题面的 1..n 对齐,省掉后面每处的 -1。
    # a_i 可能是负数,读进来就先取模:Python 的 % 对负数返回非负,正好落进 [0, MOD)
    a = [0] + [int(v) % MOD for v in data[2:2 + n]]

    # ---- 先把所有操作读进来,按下标挂事件 ----
    act = [[] for _ in range(n + 2)]         # 在下标 i 处激活的修改编号
    dea = [[] for _ in range(n + 2)]         # 在下标 i 处失效的修改编号
    qs = [[] for _ in range(n + 2)]          # 下标 i 上的查询:(此前修改数 T, 输出序号)
    ops = []                                 # 第 j 个修改的 (k, b)
    p = 2 + n                                # 操作段在 data 里的起始位置
    nq = 0                                   # 已读到的查询个数,兼作输出序号
    for _ in range(q):
        op = data[p]                         # 保持 bytes 原样比较,省一次 int() 转换
        if op == b"3":
            x = int(data[p + 1]); p += 2
            # len(ops) 就是「这次查询之前已经发生的修改条数」,
            # 记下来,扫描到下标 x 时只复合时间前缀 [0, len(ops)) 的那些操作
            qs[x].append((len(ops), nq))
            nq += 1
        else:
            l = int(data[p + 1]); r = int(data[p + 2]); v = int(data[p + 3]) % MOD
            p += 4
            j = len(ops)                     # 修改编号即时间戳,也是它在线段树里的叶子号
            # 加数是 x -> 1·x + v,乘数是 x -> v·x + 0,统一成仿射变换 (k, b)
            ops.append((1, v) if op == b"1" else (v, 0))
            # 差分式挂事件:扫到左端点时装上这个变换,扫过右端点后一格再卸掉,
            # 于是每个操作全程只产生 2 次单点修改,而不是覆盖 r-l+1 个位置
            act[l].append(j)
            dea[r + 1].append(j)

    # ---- 时间轴线段树:叶子存仿射变换,内部节点存「左儿子 then 右儿子」的复合 ----
    # 叶子数补齐到 2 的幂,这样「叶子 j 的下标 = j + size、父亲 = i >> 1」永远成立。
    # max(1, ...) 保证一条修改都没有时也有一个叶子,树不会退化成空数组
    size = 1
    while size < max(1, len(ops)):
        size <<= 1
    km = [1] * (2 * size)                    # 乘法系数
    kb = [0] * (2 * size)                    # 加法系数,(1, 0) 就是恒等变换

    def assign(j, k, b):
        """把叶子 j 设为 (k, b),并沿路更新祖先,O(log q)。"""
        i = j + size                         # 叶子 j 在数组里的位置
        km[i] = k; kb[i] = b
        i >>= 1
        while i:                             # 一路向上重算到根,i 变成 0 时停
            lc = i << 1; rc = lc | 1
            kr = km[rc]
            # 左儿子的时间早于右儿子,所以是「先左后右」:
            # x -> k_l·x + b_l -> k_r·(k_l·x + b_l) + b_r = (k_l·k_r)·x + (b_l·k_r + b_r)
            km[i] = km[lc] * kr % MOD
            kb[i] = (kb[lc] * kr + kb[rc]) % MOD
            i >>= 1

    ans = [0] * nq
    # 扫描轴是下标 i:走到 i 时,树里恰好装着「覆盖下标 i 的全部修改」
    for i in range(1, n + 1):
        for j in act[i]:                     # 左端点落在 i 的修改,现在生效
            k, b = ops[j]
            assign(j, k, b)
        for j in dea[i]:                     # 右端点在 i-1 的修改,到此为止
            assign(j, 1, 0)                  # 写回恒等
        for T, oi in qs[i]:
            # 时间前缀 [0, T) 的复合:左段顺序累积,右段逆序累积,最后拼起来。
            # 只能取前缀——树根还含有比这次查询更晚的操作,直接读根会算错
            l = size; r = T + size           # 自底向上的半开区间 [l, r)
            kl = 1; bl = 0                   # 左半部分(靠前的时间)
            kr = 1; br = 0                   # 右半部分(靠后的时间)
            while l < r:
                if l & 1:                    # l 是右儿子,这块不能整体上提,先吃掉
                    bl = (bl * km[l] + kb[l]) % MOD   # 新块接在已有结果之后
                    kl = kl * km[l] % MOD
                    l += 1
                if r & 1:                    # r-1 是右儿子,同样先吃掉
                    r -= 1
                    br = (kb[r] * kr + br) % MOD      # 右段是倒着扫的,新块接在前面
                    kr = km[r] * kr % MOD
                l >>= 1; r >>= 1             # 上升一层,两端都换成父节点
            # 左段在前、右段在后,合成 (kl·kr, bl·kr + br),再作用到 a[i] 上
            ans[oi] = (a[i] * (kl * kr) + (bl * kr + br)) % MOD
    sys.stdout.write("\n".join(map(str, ans)) + "\n")


main()
[:octicons-arrow-left-16: BISHI127](BISHI127.md) [BISHI129 :octicons-arrow-right-16:](BISHI129.md)