跳转至

第 118 章 分治进阶:整体二分与 CDQ

配套例题:BISHI72 中位数之和、BISHI129 区间增量与区间小于计数 来源:S4 模板.docx /「分治」「二分」「三分」「二分答案」「整体二分」(sources/03-pascal-template/模板.md 第 148–156 行的清单);S2 useful algorithm/Merge sort.cpp(归并分治) 前置40-排序44-二分39-树状数组与线段树111-偏序集与Dilworth定理

118.0 这一章为什么存在

S4 模板.docx 的最后一节标题是「分治」,底下列了四个条目:

分治
二分
三分
二分答案
整体二分

前四条在 44-二分 已经讲完了。 第五条「整体二分」只有标题,正文里一行代码都没有—— 和 117 章 的「k 短路(暂时没有)」一样, 是 S4 留下的空格。

而牛客题单里同样没有整体二分 / CDQ 题。原因很直白:

原因 说明
这两个算法都是离线 笔试题偏爱在线操作(更容易出、更容易判)
都需要「把询问全部读进来重排」 题面要写清楚「不强制在线」,篇幅长
在 Python 里都很险 见 118.6 的实测:\(n=q=10^5\) 时 3 秒起步

那为什么要学?因为「离线 + 分治」是 Python 选手对付 \(\log^2\) 类问题的唯一出路

39 章 给出的教学要点: 当线段树在 Python 里跑不动时,问一句「能不能不在线」。 一旦允许离线,就可以把数据结构换成排序 + 扫描, 或者把 \(O(q\log n)\) 的代价摊成 \(O(q\log q)\) 的小操作。 整体二分和 CDQ 分治就是这条路上最远的两站。

这一章的定位:

目标 说明
补上 S4 的「整体二分」空缺 原理 + 可运行的迭代模板
把分治的三种形态讲成一个体系 归并分治 → 整体二分 → CDQ,三者是同一个模式的三次加码
40 章 的逆序对、111 章 的偏序打通 CDQ 处理的就是「三维偏序」
给出诚实的 Python 取舍 递归深度安全,但 merge 步骤的 Python 层循环是瓶颈;什么时候该改树状数组

先给结论

分治的递归深度是 \(O(\log n)\),所以 Python 完全不用担心爆栈—— 这是分治相对于 DFS/线段树递归的一大优势。 但每一层的 merge 都是 Python 层的逐元素循环,\(n\log n\) 次迭代。 实测:归并求逆序对 \(n=10^5\) 只要 0.16 s ✅; 整体二分 \(n=q=10^5\) 要 3.0 s ⚠️;CDQ 三维偏序 \(n=10^5\) 要 2.1 s ⚠️。 分界线在于「每层是否只有一遍线性扫描」。


118.1 分治的三步框架

分治(divide and conquer)永远是同样的三步:

名字 做什么
1 划分(divide) 把规模 \(n\) 的问题分成若干个规模 \(n/b\) 的子问题
2 求解(conquer) 递归解决子问题(规模足够小时直接算)
3 合并(combine) 把子问题的解拼成原问题的解

主定理给出复杂度:若 \(T(n) = a\,T(n/b) + f(n)\),则

情形 结论 例子
\(f(n) = O(n^{\log_b a - \epsilon})\) \(T(n) = \Theta(n^{\log_b a})\) Karatsuba 乘法(\(a=3,b=2\)
\(f(n) = \Theta(n^{\log_b a})\) \(T(n) = \Theta(n^{\log_b a}\log n)\) 归并排序\(a=b=2, f=n\))→ \(n\log n\)
\(f(n) = \Omega(n^{\log_b a + \epsilon})\) \(T(n) = \Theta(f(n))\) 合并代价占主导

分治题的全部工夫在第 3 步「合并」。 划分几乎总是「对半砍」,求解是递归调用,只有合并需要想。 判断一道题能不能分治,就是判断「跨越中线的那部分答案,能不能在 \(O(n)\)\(O(n\log n)\) 内算出来」。

分治的三个层次(本章的路线图)

层次 分的是什么 合并时算什么 典型问题
归并分治 下标(序列对半砍) 「左半 × 右半」的跨中线贡献 逆序对、最近点对、最大子段和
整体二分 值域 + 询问集合 用一个数据结构判定每个询问该去左还是右 静态/动态区间第 \(k\) 小、二分答案的批处理
CDQ 分治 时间/下标 「前半的修改」对「后半的询问」的影响 三维偏序、带修改的偏序问题

三者的共同点:都是「把「所有对」拆成 \(O(\log n)\) 层,每层只处理跨中线的那些对」。 差别只在「分的是什么维度」。


118.2 归并分治:从逆序对说起

归并排序的合并步是「两个有序段合成一个有序段」,\(O(n)\)。 它的副产品是逆序对:

归并时若从右半取走一个元素 right[j], 说明左半剩下的 len(left) - i 个元素全都比它大且都在它前面, 一次性贡献这么多逆序对。

完整实现见 40-排序 · 归并的真正用途,这里只放核心一行:

# [片段]
while i < nl and j < len(right):             # 两半各有一个游标,谁小谁先出队
    if left[i] <= right[j]:                  # <= 保证稳定
        res.append(left[i]); i += 1
    else:
        res.append(right[j]); j += 1
        cnt += nl - i                        # ★ 左半剩余的元素都与它构成逆序对
                                             # left[i:] 里每个都 > right[j] 且下标更靠前

归并分治的通用模式

任何「统计满足某条件的下标对 \((i,j)\)\(i<j\)」的问题都可以试这个模式:

solve(l, r):
    if l == r: return
    mid = (l + r) // 2
    solve(l, mid); solve(mid+1, r)
    统计「i 在左半、j 在右半」且满足条件的对数        ← 唯一需要动脑的地方
    把两半按某个键归并成有序(为上层的统计做准备)
问题 跨中线的统计 复杂度
逆序对 归并时数「左半剩余个数」 \(O(n\log n)\)
满足 \(a_i > 2a_j\) 的对数 双指针在两个有序段上扫 \(O(n\log n)\)
最近点对 \(x\) 分,只检查中线两侧 \(\pm d\) 条带内按 \(y\) 排序的相邻 7 个点 \(O(n\log n)\)
最大子段和 跨中线的最大和 = 左半后缀最大 + 右半前缀最大 \(O(n\log n)\)(不如 DP 的 \(O(n)\)
区间 \([l,r]\) 内值在 \([x,y]\) 的个数(离线) 归并 + bisect \(O(n\log^2 n)\)

归并分治 vs 树状数组:这两者能解决的问题高度重叠。 逆序对既能归并分治也能「离散化 + 树状数组倒序扫」, 因为两者本质上都是「把二维偏序拆成一维扫描 + 一维数据结构」。 实测两者在 \(n=10^5\) 时都是 0.16 s,打成平手(见 118.6)。

选择依据: - 值域好离散化、只统计「个数」→ 树状数组(代码短、无递归); - 需要在合并时做复杂的双指针/bisect 逻辑 → 归并分治; - 需要「按时间分治」(带修改)→ 只能 CDQ


118.3 整体二分

问题背景

单个询问「区间 \([l,r]\) 的第 \(k\) 小」怎么做?

二分答案 v:统计 [l,r] 中 <= v 的元素个数 c
   c >= k  ->  答案 <= v,往左半值域找
   c <  k  ->  答案 >  v,往右半值域找(并把 k 减去 c)

每次判定需要「区间内 \(\le v\) 的个数」,用树状数组要 \(O(\log n)\), 所以单个询问是 \(O(\log V\log n)\)\(q\) 个询问是 \(O(q\log V\log n)\)

问题出在哪:每个询问独立二分,每次判定都要重新往树状数组里塞元素再撤掉, 这部分工作被重复了 \(q\) 遍。

核心思想:把 \(q\) 个二分「同时」做

整体二分(parallel binary search)= 把所有询问的二分过程按层同步推进。 在值域的第 \(d\) 层,所有还在这一层的询问共享同一次「把元素塞进数据结构」的操作。

先跨过一层观念转换:询问也是被分治的数据。 平时写二分,被分的是「值域」,询问只是站在外面下达指令的一方。 整体二分把询问降格成和数组元素同等地位的对象: 每一层递归都拿着两袋东西——一袋元素、一袋询问——同时切成两半往下传。 元素按「值 \(\le v_m\) 还是 \(> v_m\)」分,询问按「答案在左半值域还是右半」分。 递归树的每个节点因此都是一个「小规模的同类问题」, 这正是分治能成立的原因,也是理解整体二分唯一的门槛。

CDQ 的转换更彻底:修改和询问被混进同一条按时间排的序列, 然后按下标对半砍。看懂这一步,后面的代码都只是归并而已。

单独二分 整体二分
递归的对象 一个询问 (值域区间, 元素集合, 询问集合) 三元组
层数 \(\log V\) \(\log V\)
每层的数据结构操作 \(q \times\)(塞 + 撤) \(n\) 次塞 + \(n\) 次撤(整层共享)
总复杂度 \(O(q\log V\log n)\) \(O((n+q)\log V\log n)\)

\(q \gg 1\) 时整体二分把「\(q\) 倍的塞元素代价」压成了「1 倍」。

算法

solve(值域区间 [vl, vr], 元素集合 E, 询问集合 Q):
    if Q 为空: return
    if vl == vr: 所有 Q 中的询问答案都是 vl,return
    vm = (vl + vr) // 2
    把 E 中值 <= vm 的元素塞进树状数组
    for 每个询问 q in Q:
        c = 树状数组查 q 的区间和            ← 「区间内值 <= vm 的个数」
        if c >= q.k:  q 归入左半 QL
        else:         q.k -= c,归入右半 QR   ← ★ k 要减掉
    把刚塞进去的元素全部撤掉                  ← ★ 必须撤,否则污染兄弟分支
    solve([vl, vm],   E 中值 <= vm 的部分, QL)
    solve([vm+1, vr], E 中值 >  vm 的部分, QR)

两个必须记牢的细节

细节 为什么
归入右半时 k -= c 右半的询问已经确定「左半贡献了 \(c\) 个」,剩下要找的是第 \(k-c\)
每层结束必须撤销树状数组 递归到兄弟分支时数据结构必须是干净的。忘了撤是整体二分第一大 bug

模板(迭代版,静态区间第 \(k\) 小)

数组静态时,「值 \(\le v_m\) 的元素」在「按值排好序的元素列表」里恰好是一段前缀, 于是元素集合可以用下标区间 [ilo, ihi] 表示,不需要真的拆列表。

# [片段]
def kth_offline(a, queries):
    """整体二分求**静态**区间第 k 小。

    a       —— 0-indexed 数组
    queries —— [(l, r, k)],0-indexed 闭区间,k 从 1 开始;★ 会被就地修改(k 递减)
    返回每个询问的答案。

    复杂度 O((n + q) log V log n)。
    ★ 用显式栈而不是递归:层数只有 log V,递归也安全,但迭代少一层函数调用开销。
    """
    n = len(a)
    q = len(queries)
    vals = sorted(set(a))                    # 离散化后的值域
    ans = [0] * q
    bit = [0] * (n + 1)                      # 树状数组:下标 i 处放 1 表示「该位置的值已入库」

    def add(i, v):                           # 下标 i(0-indexed)处 += v
        i += 1                               # 树状数组从 1 计数,0 号位是 lowbit 的死循环点
        while i <= n:
            bit[i] += v
            i += i & -i                      # 逐级上传给管辖自己的父格

    def pre(i):                              # 下标 [0, i] 的和
        i += 1
        s = 0
        while i > 0:
            s += bit[i]
            i -= i & -i                      # 减 lowbit 逐段回退,最多 log n 步
        return s

    items = sorted(range(n), key=lambda i: a[i])   # 按值排序的下标 —— 全局只排一次
    # 栈里的五元组 = (值域左端, 值域右端, 元素段左端, 元素段右端, 询问编号表)
    stack = [(0, len(vals) - 1, 0, n - 1, list(range(q)))]
    while stack:
        vl, vr, ilo, ihi, qs = stack.pop()
        if not qs:
            continue                         # 没有询问落到这个分支,整棵子树都不用展开
        if vl == vr:                         # 值域收缩到一个点 -> 定答案
            v = vals[vl]
            for j in qs:
                ans[j] = v
            continue
        vm = (vl + vr) // 2
        # ---- 把值 <= vals[vm] 的元素塞进树状数组(它们在 items 里是一段前缀)----
        added = []
        p = ilo
        while p <= ihi and a[items[p]] <= vals[vm]:
            add(items[p], 1)                 # 按「原下标」入库,这样才能按区间查
            added.append(items[p])
            p += 1                           # 循环结束时 p 是左右两段元素的分界
        # ---- 分流询问 ----
        left = []
        right = []
        for j in qs:
            l, r, k = queries[j]
            c = pre(r) - (pre(l - 1) if l > 0 else 0)   # [l, r] 里值 <= vals[vm] 的个数
            if c >= k:                       # 小值够多,第 k 小就藏在左半值域
                left.append(j)
            else:
                queries[j] = (l, r, k - c)   # ★ k 要减掉左半的贡献
                right.append(j)              # 右半只需再找第 k-c 小
        # ---- 撤销:递归到兄弟分支前数据结构必须干净 ----
        for i in added:
            add(i, -1)                       # 只撤自己加过的,比整棵树清零快得多
        stack.append((vl, vm, ilo, p - 1, left))     # 左分支拿前缀那段元素
        stack.append((vm + 1, vr, p, ihi, right))    # 右分支拿剩下那段
    return ans

自测(与 sorted(a[l:r+1])[k-1] 暴力对拍 300 组随机小数据,全部一致)。

带修改的版本

数组会被单点修改时,元素集合不再是「按值排序的一段前缀」, 必须真的维护一个操作序列

操作类型 在整体二分里的行为
插入值 \(v\) 到位置 \(p\) \(v \le v_m\) 则塞进树状数组,归入左半;否则归入右半
删除位置 \(p\) 的值 \(v\) 同上,但塞的是 \(-1\)
询问 \((l,r,k)\) 查区间和 \(c\),按 \(c\)\(k\) 分流

「单点修改」拆成「删旧值 + 插新值」两个操作, 于是带修区间第 \(k\) 小(洛谷 P2617)就和静态版共用同一份框架。 复杂度 \(O((n+q)\log V\log n)\)和主席树 + 树套树同阶但常数小得多—— 这是整体二分在 C++ 里最大的卖点。

整体二分的适用信号

信号 说明
多个询问,每个都可以独立二分答案 这是前提
判定函数需要一个数据结构,且「加入元素」的代价可以被多个询问共享 这是收益来源
不强制在线 硬性要求
判定关于二分的量单调 与普通二分同一个要求

⚠️ 反例:可行性不单调就不能整体二分。 比如 BISHI91 拼接木棍(44-二分 的反例题)—— 可行性关于 \(L\) 不单调(需 \(L \mid T\)),连普通二分都不能用,整体二分更无从谈起。 整体二分只是把「多个二分」批处理,它不能让不能二分的东西变得能二分。


118.4 CDQ 分治

从「二维偏序」到「三维偏序」

111 章 建立了偏序的语言。回顾一下维数的递进:

维数 问题 做法
一维 排序 sort
二维 逆序对 / LIS / 二维数点 一维排序 + 一维数据结构(树状数组)
三维 三维偏序:对每个 \(i\)\(\lvert\{j: a_j\le a_i, b_j\le b_i, c_j\le c_i\}\rvert\) 一维排序 + 一维 CDQ + 一维数据结构
四维 CDQ 套 CDQ,或 K-D 树 / bitset 暴力

三维偏序的标准三段式

第一维 a:排序(把 a 这一维「消掉」)
第二维 b:CDQ 分治(左半的 b 与右半的 b 做归并)
第三维 c:树状数组(在归并过程中维护)

CDQ 的本质:让「前半」影响「后半」

cdq(l, r):
    if l == r: return
    mid = (l + r) // 2
    cdq(l, mid)                              ← 先递归,保证左半内部已算完
    cdq(mid+1, r)
    统计「左半的元素」对「右半的元素」的贡献      ← 唯一动脑的地方
    按第二维把两半归并(为上层准备)

为什么这样能不重不漏:任意一对 \((j, i)\)\(j < i\)(第一维已有序), 在递归树上恰好有一层\(j\) 落在左半、\(i\) 落在右半。 所以每一对被统计恰好一次

这是 CDQ 与归并分治的唯一区别: - 归并分治统计的是「对的贡献」(逆序对:左半元素与右半元素互相构成逆序对); - CDQ 统计的是「单向的贡献」(左半的「修改」影响右半的「询问」,反之不影响)。

所以 CDQ 又被叫作「按时间分治」——第一维通常就是时间/操作序号。

模板:三维偏序

# [片段]
def cdq3d(pts, K):
    """三维偏序(洛谷 P3810 陌上花开)。

    pts —— [(a, b, c)] 列表;K —— c 的值域上界
    返回 cnt,cnt[d] = 「f 值恰为 d」的元素个数,
    其中 f(i) = |{j != i : a_j<=a_i, b_j<=b_i, c_j<=c_i}|。

    三段式:a 排序 -> b 用 CDQ -> c 用树状数组。
    ★ 完全相同的三元组必须**合并去重**,否则它们之间会互相少算。
    复杂度 O(n log^2 n)。
    """
    n = len(pts)
    pts = sorted(pts)                        # 第一维 a:排序消掉
    uniq = []                                # 去重,w 记重数
    w = []
    for p in pts:
        if uniq and uniq[-1] == p:           # 排好序后相同三元组必然相邻,一次扫描即可去重
            w[-1] += 1
        else:
            uniq.append(p)
            w.append(1)
    m = len(uniq)
    f = [0] * m                              # f[i]:严格来自「别的三元组」的支配计数
    bit = [0] * (K + 1)                      # 树状数组按 c 的值建,所以开到值域上界 K

    def add(i, v):                           # 第三维 c:树状数组
        while i <= K:
            bit[i] += v
            i += i & -i

    def pre(i):
        s = 0
        while i > 0:                         # 前缀和 = 已入库元素里 c 值 <= i 的个数
            s += bit[i]
            i -= i & -i
        return s

    def solve(lo, hi, ids):
        """ids 是 [lo, hi] 内元素的 id 列表,按 a 序(即 id 升序)传入,
        返回按 b 排好序的 id 列表。"""
        if lo == hi:
            return [ids[0]]                  # 单个元素天然有序,且自己不会支配自己
        mid = (lo + hi) // 2
        L = solve(lo, mid, ids[:mid - lo + 1])       # 递归后 L 已按 b 有序
        R = solve(mid + 1, hi, ids[mid - lo + 1:])   # R 同理;两半内部的贡献已算完
        # ---- 归并:b 小的先处理;左半元素「入库」,右半元素「查询」 ----
        res = []
        touched = []                                 # 记下本层入过库的元素,末尾要撤
        i = j = 0
        while i < len(L) and j < len(R):
            if uniq[L[i]][1] <= uniq[R[j]][1]:       # <= :b 相等也满足偏序,左边要先入库
                add(uniq[L[i]][2], w[L[i]])          # 左半 -> 入库
                touched.append(L[i])
                res.append(L[i]); i += 1
            else:
                f[R[j]] += pre(uniq[R[j]][2])        # 右半 -> 查库
                res.append(R[j]); j += 1             # 库里此刻恰是 a 更小、b 不更大的那些
        while i < len(L):
            res.append(L[i]); i += 1                 # 右半查完了,左半剩的只需并进结果
        while j < len(R):
            f[R[j]] += pre(uniq[R[j]][2])            # ★ 左半已全部入库,右半剩余也要查
            res.append(R[j]); j += 1
        for x in touched:                            # ★ 撤销,别污染兄弟分支
            add(uniq[x][2], -w[x])
        return res                                   # 返回按 b 有序的合并结果,供上一层用

    solve(0, m - 1, list(range(m)))
    cnt = [0] * n
    for i in range(m):
        cnt[f[i] + w[i] - 1] += w[i]                 # 同值元素互相计入,故 +w-1
    return cnt

自测(与 \(O(n^2)\) 暴力对拍 300 组随机小数据,全部一致)。

三个坑: 1. 完全相同的三元组必须去重合并。若 \((1,1,1)\) 出现 3 次, CDQ 只会让先出现的贡献给后出现的,导致答案偏小。 去重后用 w(重数)参与计算,最后 f[i] + w[i] - 1 补回来; 2. 归并的比较必须是 <= 而不是 <\(b\) 相等时也算「满足偏序」, 所以左半的相等元素要先入库,才能被右半查到; 3. touched 必须撤销。和整体二分一样,兄弟分支要看到干净的数据结构。 「只撤销自己加过的」比「整棵树清零」快得多。

CDQ 的另一个大用途:动态问题静态化

「带修改的二维数点」是 CDQ 最常见的形态:

维度 内容
第一维 时间(操作序号)—— 天然有序,不用排
第二维 位置 —— CDQ 归并
第三维 值 —— 树状数组
把「修改」和「询问」混在一个序列里,按时间 CDQ:
    cdq(l, r) 的合并步只做一件事:
        统计「前半的所有修改」对「后半的所有询问」的贡献

CDQ 分治 = 用「分治」代替「一个维度的数据结构」。 树套树是「数据结构套数据结构」(\(O(\log^2 n)\),常数巨大、空间大); CDQ 是「分治套数据结构」(同样 \(O(\log^2 n)\),但常数小、空间 \(O(n)\))。 在 C++ 里这是压倒性优势;在 Python 里……见下节。


118.5 Python 的取舍

好消息:递归深度是安全的

算法 递归深度 Python 判断
DFS(链状图) \(O(n)\) 必须改迭代60 章
递归线段树 \(O(\log n)\),但每次操作都递归 ⚠️ 函数调用开销致命(39 章
归并分治 / CDQ / 整体二分 \(O(\log n) \approx 17\) 完全安全,不用 setrecursionlimit

关键差别:线段树是「\(q\) 次操作 × 每次 \(O(\log n)\) 层递归」= \(q\log n\)函数调用; 分治是「一共 \(O(n)\) 次函数调用,每次做 \(O(\text{段长})\) 的工作」。 分治把函数调用开销摊薄了,这是它在 Python 里比递归线段树好得多的根本原因。

坏消息:merge 步骤是纯 Python 层循环

算法 每层的工作 总迭代数
归并求逆序对 一遍线性归并 \(n\log n \approx 1.7\times10^6\)\(n=10^5\)
整体二分 一遍元素塞入 + \(q\) 次树状数组查询 \((n + 2q\log n)\log V \approx 1.2\times10^8\)
CDQ 三维偏序 一遍归并 + 每个元素一次树状数组操作 \(n\log n\log V \approx 3.5\times10^7\)

多出来的那个 \(\log\)(来自树状数组)就是分水岭。

实测数据(\(n = q = 10^5\)

算法 实测耗时 判断
归并分治求逆序对 0.16 s ✅ 稳
树状数组 + 离散化求逆序对 0.16 s 打成平手
CDQ 三维偏序\(c\) 值域 \(2\times10^5\) 2.1 s ⚠️ 险
整体二分(静态区间第 \(k\) 小) 3.0 s ⚠️ 险
树套树(线段树套线段树) \(10^8\) 量级 ❌ 完全不可行
主席树(可持久化线段树) 建树 \(n\log n\) 个节点 = \(1.7\times10^6\) 个 Python 对象/数组格 ❌ 内存与常数双杀

诚实结论

场景 Python 判断
只需要「统计满足偏序的对数」(二维) 用树状数组,不要写分治
三维偏序,\(n \le 10^5\),时限 \(\ge\) 5 秒 ⚠️ CDQ 可以试,2 秒左右
三维偏序,\(n \le 10^5\),时限 2 秒
区间第 \(k\) 小(静态),\(n=q\le10^5\),时限 \(\ge\) 5 秒 ⚠️ 整体二分 3 秒
区间第 \(k\) 小(静态),可以离线且值域小 改「分块 + 块内排序副本 + bisect」,见 116 章
带修区间第 \(k\) 小,\(n=q\ge10^5\) Python 放弃
任何需要主席树 / 树套树的题

什么时候该把分治换成树状数组离线

判断标准:需要「跨中线的复杂逻辑」吗?

需求 用什么 理由
统计二维偏序对数 树状数组 + 排序 一遍扫描,没有递归、没有切片
离线区间查询(莫队式) 排序 + 扫描 + 树状数组 同上
三维偏序 CDQ(没有更好的选择) 树状数组只能管一维
带修 + 多维 CDQ 分治是唯一能「消掉时间维」的手段
归并时要做 bisect / 双指针 归并分治 树状数组表达不了

Python 的通用降级路线(按优先级):

树套树 / 主席树   ❌
  ↓ 降级
CDQ 分治 / 整体二分   ⚠️ 3 秒量级
  ↓ 降级
分块 + 块内排序副本 + bisect   ⚠️ 但常数全在 C 层
  ↓ 降级
排序 + 树状数组(若能把维数降到 2)   ✅
  ↓ 降级
Counter / sorted / bisect 一遍扫   ✅ 最优

每往下一级,都是把 Python 层循环换成 C 层操作。 这条链条是整个第十部分反复出现的主题。

三个实现层面的 Python 优化

优化 效果
ids[:mid-lo+1] 的切片 → 改成传 (lo, hi) 下标 + 全局数组 省掉 \(O(n\log n)\) 次列表分配
归并用 res.append → 预分配 res = [0]*len + 下标赋值 快 10–20%
树状数组的 add/pre 内联进归并循环 省掉 \(n\log n\) 次函数调用,能快 30%
整体二分的递归 → 显式栈 层数只有 17,收益有限,但少一层开销

118.6 例题

BISHI72 中位数之和(较难)

给定长度 \(n\)二进制数组 \(a\)(元素为 0 或 1),\(k\)奇数。 求 \(a\) 的所有长度恰为 \(k\)子序列的中位数之和,对 \(P = 10^9+7\) 取模。 \(t \le 10^4\) 组,\(\sum n \le 2\times10^5\)。 时限:C/C++ 1 秒,其他语言 2 秒。 题面见 BISHI72 原题(牛客)

✅ 题解见 solutions/BISHI72.py已通过官方样例验证

这题为什么放在分治章:因为它是一道「看着像分治,实际不用分治」的题, 而分辨这一点的能力比会写 CDQ 更值钱。

为什么会想到分治:「所有子序列的某个统计量之和」这种句式, 在很多题里确实要靠分治(比如「所有子区间的最大值之和」用单调栈或分治)。 而且「中位数」天然带着「排序 / 第 \(k\) 小」的味道,容易联想到整体二分。

为什么分治在这里是错的路

尝试 问题
枚举子序列 \(\binom{n}{k}\) 是天文数字
分治:按下标对半,统计跨中线的子序列 子序列不要求连续,「跨中线」不构成有意义的划分
整体二分:二分中位数的值 中位数只能是 0 或 1,值域只有两个点,二分毫无意义
0/1 转化 + 组合计数 正解

关键观察(两步)

第一步:0/1 值下「求和 = 计数」。 中位数只能是 0 或 1,所以

\[\sum_{\text{子序列}} \text{中位数} = \big\lvert\{\text{中位数为 1 的子序列}\}\big\rvert\]

第二步:中位数为 1 的判据。 长度 \(k\)(奇数)的 0/1 序列,排序后第 \(h = \frac{k+1}{2}\) 个是 1 \(\iff\) 序列里 1 的个数 \(\ge h\)

于是设数组里有 \(c_1\) 个 1、\(c_0 = n - c_1\) 个 0:

\[\text{答案} = \sum_{j=h}^{\min(k,\,c_1)} \binom{c_1}{j}\binom{c_0}{k-j}\]

(从 1 里挑 \(j\) 个、从 0 里挑 \(k-j\) 个;按位置计数,同值元素在不同下标算不同子序列,正合题意。)

验算样例\(n=5, k=1\),全是 1 → \(c_1=5, c_0=0, h=1\)\(j=1\)\(\binom{5}{1}\binom{0}{0} = 5\)

import sys

P = 1000000007


def main() -> None:
    data = sys.stdin.buffer.read().split()
    t = int(data[0])
    idx = 1                                   # 游标:t 之后紧接着是第一组数据
    cases = []                                # 先把所有组读完,才知道阶乘表要开多大
    mx = 1                                    # 所有组里最大的 n
    for _ in range(t):
        n = int(data[idx]); k = int(data[idx + 1]); idx += 2
        c1 = 0                                # 本组里 1 的个数,数组本身不必存下来
        for j in range(idx, idx + n):
            if data[j] == b"1":               # 与 bytes 直接比,省去 int() 转换
                c1 += 1
        idx += n
        cases.append((n, k, c1))
        if n > mx:
            mx = n

    fact = [1] * (mx + 1)                     # ★ 全局只建一次阶乘表
    for i in range(2, mx + 1):                # 从 2 起:0! 和 1! 都是 1,初值已对
        fact[i] = fact[i - 1] * i % P
    inv_fact = [1] * (mx + 1)
    inv_fact[mx] = pow(fact[mx], P - 2, P)    # 费马小定理求一次逆元
    for i in range(mx, 0, -1):
        inv_fact[i - 1] = inv_fact[i] * i % P # 再线性递推出全部逆阶乘
                                              # 由 1/(i-1)! = (1/i!) * i,倒着推只花 O(n) 次乘法

    def C(a: int, b: int) -> int:
        if b < 0 or b > a:
            return 0                          # 越界统一返回 0,省掉一堆边界判断
        return fact[a] * inv_fact[b] % P * inv_fact[a - b] % P

    out = []
    for n, k, c1 in cases:
        c0 = n - c1                           # 0 的个数
        h = (k + 1) // 2                      # 至少要有 h 个 1,中位数才是 1
        s = 0
        for j in range(h, min(k, c1) + 1):    # j = 选中的 1 的个数,上界受 k 和 c1 双重限制
            s += C(c1, j) * C(c0, k - j) % P  # 从 1 里选 j 个、从 0 里选 k-j 个
        out.append(str(s % P))                # 循环内不取模也行,最后统一归约一次
    sys.stdout.write("\n".join(out) + "\n")


main()

复杂度:预处理 \(O(\max n)\) + 每组 \(O(k)\),总 \(O(\max n + \sum n)\)注意这是线性的——比任何分治做法都好。

四个坑(来自题解记录):

  1. 逐组重建阶乘表会退化成 \(O(t\cdot n)\)\(t=10^4\) 时直接爆。 必须在最外层建一次表,表长取所有测试用例里最大的 \(n\); → 所以要先把所有输入读完(本身就是一次「离线」!);
  2. 求和上界是 \(\min(k, c_1)\),下界还要满足 \(k-j \le c_0\)\(j \ge k-c_0\)—— 统一用 \(\binom{x}{y}=0\ (y>x \text{ 或 } y<0)\) 兜住最省心;
  3. 中位数是排序后第 \(\frac{k+1}{2}\) 个(1-indexed),\(k\) 奇数时 \(h = (k+1)//2\)
  4. 每组 \(n\) 可能很小但 \(t\) 很大,IO 必须整块读

本题给出的判断口诀看到「所有子序列/子集的某统计量之和」,先问「值域有多小」。 值域只有 \(\{0,1\}\) 时,「求和」退化成「计数」, 而计数往往有闭形式的组合公式,比任何分治都快一个数量级。

这与 116 章 的「先看值域再选数据结构」是同一条原则值域是解题时最该先找的那个数。

组合数取模的完整技术见 84-组合数学

BISHI129 区间增量与区间小于计数(中等)

\(n, q \le 10^5\)\(|a_i| \le 10^7\)1 l r x:区间加;2 l r x:查询 \([l,r]\)\(< x\) 的元素个数(\(|x| \le 10^9\))。 时限:C/C++ 5 秒,其他语言 10 秒。 题面见 BISHI129 原题(牛客)

✅ 题解见 solutions/BISHI129.py已通过官方样例验证完整代码与讲解在 116-平衡树与有序集合(分块 + 块内排序副本) 与 39-树状数组与线段树(分块视角)。 这里只回答一个问题:为什么整体二分 / CDQ 在这题用不上?

这题长得非常像整体二分的模板题:多个询问、每个都涉及「值的比较」、 而且允许离线。但三条路都堵死了:

尝试 为什么不行
整体二分(二分 \(x\) 询问给的 \(x\)固定的,不需要二分——我们要的不是「第 \(k\) 小是多少」,而是「\(< x\) 的有几个」。没有需要二分的未知量
整体二分(二分值域,把询问分流) 分流的判据是「区间内 \(\le v_m\) 的个数与 \(k\) 比较」,本题没有 \(k\);改成直接统计的话,就退化成「对每个询问在值域上跑一遍」= \(O(q\log V\log n)\)比分块还慢
CDQ 按时间分治 修改是区间加,它把区间内所有元素的值同时平移。CDQ 要求「前半的修改对后半询问的贡献可以用一维数据结构统计」,而「区间加」改变的是值这一维本身,无法在树状数组上表达
树套树 \(O(\log^2 n)\),Python \(10^8\) 量级 ❌
分块 + 块内排序副本 正解

根因「区间加」是一个会破坏值域有序性的操作

修改类型 对「值域数据结构」的影响 能否离线分治
单点赋值 / 单点加 只动一个元素 → 拆成「删旧值 + 插新值」 ✅ CDQ / 整体二分都行
区间加 \(O(r-l)\) 个元素的值 ❌ 拆不开
区间赋值 同上

而分块之所以能救命,正是因为它找到了一个局部不变量

整块加法只是「整体平移」,不改变块内元素的相对顺序。 于是块内的排序副本可以完全不动,只记一个偏移量 off[b], 查询时把阈值反向平移成 \(x - \text{off}[b]\) 即可。

结构 每次操作的代价 Python 层 vs C 层
整体二分 \(O(\log V\log n) \approx 350\) 次迭代 全在 Python 层
CDQ 同阶 全在 Python 层
分块 + bisect \(O(\sqrt n)\)bisect + \(O(\sqrt n)\) 次 C 层比较 约 250 次 C 层调用

这就是本章最重要的取舍结论在 Python 里,\(O(\sqrt n)\) 的 C 层操作常常打得过 \(O(\log^2 n)\) 的 Python 层操作。 \(n = 10^5\)\(\sqrt n = 316\)\(\log^2 n = 289\)——两者数量级相同, 而「C 层 250 次」和「Python 层 350 次」相差 5–20 倍。

所以拿到「离线 + 多维」的题,Python 选手的第一选择是分块,第二选择才是 CDQ。 这条结论与 39 章 「分块在 Python 里的特殊地位」完全一致。

想练手整体二分与 CDQ 的话

想练 推荐题 Python 可行性
归并分治求逆序对 洛谷 P1908 逆序对 ✅ 树状数组也一样快
最近点对(归并分治) 洛谷 P1429 平面最近点对 \(n\le10^5\)
三维偏序(CDQ) 洛谷 P3810 陌上花开 ⚠️ 2 秒左右,时限 1 秒则不行
整体二分(静态第 \(k\) 小) 洛谷 P3834【模板】可持久化线段树 2 ⚠️ 3 秒
带修区间第 \(k\) 洛谷 P2617 Dynamic Rankings ❌ Python 无解
CDQ 优化 DP 洛谷 P4093 [HEOI2016] 序列
分块替代方案 洛谷 P2801 教主的魔法 首选

118.7 本章速查

要点 结论
分治三步 划分 → 求解 → 合并
分治题的全部工夫 在「合并」这一步(划分几乎总是对半砍)
判断能不能分治 跨中线的贡献能不能在 \(O(n)\) / \(O(n\log n)\) 算出来」
主定理(\(a=b=2, f=n\) \(T(n)=\Theta(n\log n)\)
归并求逆序对的一行 从右半取元素时 cnt += nl - i
稳定性的关键 归并比较用 <= 而不是 <
归并分治的通用模式 递归两半 → 统计跨中线的对 → 按键归并
归并分治 vs 树状数组 二维偏序两者等价\(n=10^5\) 都是 0.16 s;优先树状数组(无递归、无切片)
整体二分是什么 \(q\) 个独立的二分按层同步推进,共享「塞元素」的代价
整体二分的递归对象 (值域区间, 元素集合, 询问集合)
整体二分复杂度 \(O((n+q)\log V\log n)\)
整体二分坑一 归入右半时 k -= c
整体二分坑二 每层结束必须撤销数据结构(第一大 bug)
静态数组的简化 「值 \(\le v_m\) 的元素」在按值排序的列表里是一段前缀 → 用下标区间表示
带修版怎么做 「单点修改」拆成「删旧值 + 插新值」两个操作
整体二分的前提 多个询问 + 判定单调 + 不强制在线
整体二分不能做什么 不能让不能二分的东西变能二分(如 BISHI91)
CDQ 是什么 分治代替「一个维度的数据结构」
CDQ 的三段式 第一维排序 → 第二维 CDQ → 第三维树状数组
CDQ 不重不漏的原因 任一对 \((j,i)\) 恰好有一层\(j\) 在左半、\(i\) 在右半
CDQ vs 归并分治 归并统计对称贡献;CDQ 统计单向贡献(前半修改 → 后半询问)
CDQ 的别名 按时间分治
三维偏序坑一 完全相同的三元组必须去重合并,用重数 w 补回
三维偏序坑二 归并比较用 <=\(b\) 相等也满足偏序)
三维偏序坑三 touched 必须撤销(只撤自己加过的,别整树清零)
CDQ vs 树套树 同为 \(O(\log^2 n)\),但 CDQ 常数小、空间 \(O(n)\)
分治的递归深度 \(O(\log n)\approx17\),Python 完全安全
分治 vs 递归线段树 分治总共 \(O(n)\) 次函数调用;线段树是 \(q\log n\) 次 → 分治摊薄了调用开销
分治在 Python 的瓶颈 merge 步骤的 Python 层逐元素循环
分水岭 每层只有一遍线性扫描(✅)vs 每层还多一个 \(\log\)(⚠️)
Python 降级路线 树套树/主席树 ❌ → CDQ/整体二分 ⚠️ → 分块 + bisect → 排序 + 树状数组 ✅ → 一遍扫 ✅
最重要的取舍结论 \(O(\sqrt n)\) 的 C 层操作常常打得过 \(O(\log^2 n)\) 的 Python 层操作
「区间加」为什么毁掉离线分治 同时改变 \(O(r-l)\) 个元素的值,拆不成单点操作
分块为什么能救 BISHI129 整块加只是整体平移,不改变块内相对顺序
BISHI72 的口诀 「所有子序列的统计量之和」→ 先看值域;值域 \(\{0,1\}\) 时求和 = 计数 = 组合公式
数据规模 → Python 现实性(本章算法)
归并分治求逆序对,\(n\le10^5\)
归并分治求逆序对,\(n\le10^6\)
树状数组 + 离散化求逆序对,\(n\le10^5\)
最近点对(归并分治),\(n\le10^5\)
CDQ 三维偏序,\(n\le10^5\)
整体二分(静态区间第 \(k\) 小),\(n=q\le10^5\)
整体二分,\(n=q\le10^4\)
带修区间第 \(k\) 小,\(n=q\ge10^5\)
树套树(线段树套线段树),\(n\ge10^5\)
主席树,\(n\ge10^5\)
分块 + 块内排序副本,\(n=q\le10^5\)
BISHI72 组合计数,\(\sum n\le2\times10^5\)