NeFut Logo NeFut
EN 管理员登录

二分答案与分治结构设计

发布于:2026-05-27 09:17 最后更新:2026-06-23 12:25
#algorithm #Divide and Conquer #Binary Search

核心逻辑与数学原理

二分答案(Binary Search on Answer)

原始问题通常为全局极值最优化问题。

二分答案,是将难以直接求解的"最优化问题",转化为可高效正向判定的"判定性问题"(即验证:当目标值设为 $x$ 时,是否存在合法方案)。 该方法成立的关键前提是答案空间具有单调性。 在此基础上,我们便能利用二分搜索,在有序的答案空间中快速锁定全局最优解,从而避免了直接穷举求解时可能面临的多维搜索空间爆炸。


单调性是二分答案的数学基石。

设解空间 $D$ 为可行的有界全序集(通常为一维数域区间),判定函数(Check Function)为 $f: D \to \{0, 1\}$。

若 $f(x)$ 满足分段常数单调性,即:

$$\forall x_1, x_2 \in D, x_1 < x_2 \implies f(x_1) \le f(x_2) \quad (\text{或 } f(x_1) \ge f(x_2))$$

则可通过对解空间 $D$ 进行二分划分,在 $O(\log |D|)$ 次判定内精确锁定或无限逼近最优解 $x^*$。

该算法的本质是以对数级别的计算开销,将连续或离散的最优化问题规约为判定性问题。

每次决策均可剔除一半的不合法值域,从而实现高维搜索空间的快速剪枝。


分治二叉树结构(Divide and Conquer Tree)

原始问题通常为全局序列组合性问题。 其表现形式为:给定一个长度为 $N$ 的序列,要求统计、检索或维护满足某种特定拓扑/代数关系的"所有元素对 $(i, j)$"或"所有连续子区间 $[i, j]$"。

典型如逆序对统计问题(寻找所有满足 $i < j$ 且 $A[i] > A[j]$ 的非线性数对),或最大连续子段和问题(在所有可能的 $[i, j]$ 中检索元素之和最大的子区间)。

由于长度为 $N$ 的序列包含 $O(N^2)$ 个子区间和数对,其全局关系的暴力计算成本天然具有 $O(N^2)$ 的复杂度基底。

分治二叉树利用"空间割裂"的几何手段,消灭了跨越全域的暴力穷举。它将全局跨度的复杂关系分类规约为三种局部形态:纯粹位于左子区间的关系、纯粹位于右子区间的关系、以及横跨中点的跨区间关系。通过树形结构的预处理或同层扫描,它成功将最难处理的"跨区间关系"的合并成本压缩至 $O(N)$ 甚至 $O(1)$。


将序列 $A[1..N]$ 的空间子区间,映射为算法执行流中时间轴上的树形递归拓扑。

对于任意子问题区间 $[l, r]$,取分治中点 $mid = l + \lfloor \frac{r-l}{2} \rfloor$ 以防数值溢出,进而递归构造左子树 $[l, mid]$ 与右子树 $[mid+1, r]$。

整个分治轨迹在逻辑上抽象为一棵高度为 $O(\log N)$ 的正则平衡二叉树(因区间无法保证完美平分,其并非严格意义上的完全二叉树)。

通过对该逻辑树进行自底向上的信息合并(如线段树的 PushUp)、自顶向下的延迟传导(如线段树的 PushDown)或同层跨区间的贡献合成(如 CDQ 分治中左区间对右区间的离线贡献),全局 $O(N^2)$ 的暴力复杂度被转化为 $O(\log N)$ 层局部区间特征的叠加。

得益于树冠到树根的层数严格限制在 $O(\log N)$,若树的每一层总工作量被控制在 $O(N)$,则总体时间复杂度将被严格界定为 $O(N \log N)$。

维度 分治二叉树 线段树
概念属性 抽象的逻辑轨迹 / 算法思想 具象的实体 / 高级数据结构
生命周期 "随用随销":通常存在于递归的系统栈中(如归并排序、CDQ分治),算法跑完树就消失了。 "常驻内存":开辟了真实的数组或指针空间(如 tree[MAXN << 2]),用于支持后续频繁的在线修改和查询。
解决场景 主要解决离线的、一次性的全局统计问题。 主要解决在线的、伴随动态修改的区间维护问题。

当你把分治算法的每一次递归轨迹用实实在在的内存数组(或指针)保存下来,用来应对后续不断的在线修改和查询时,这棵分治二叉树就变成了线段树。


算法推导与分治结构设计

二分答案的边界推导

对于满足"全 $1$ 后全 $0$"单调性的判定函数,求解最后一个使 $f(x)=1$ 成立的极大值 $x^*$:初始化双指针 $l = \min(D), r = \max(D)$。

  1. 令 $mid = l + \lfloor \frac{r-l+1}{2} \rfloor$,此处的上取整修正能有效避免当 $r = l+1$ 且 $f(mid)==1$ 时,因指针不右移而导致的死循环。
  2. 若 $f(mid) == 1$,说明最优解在 $[mid, r]$ 闭区间内,更新 $l = mid$。
  3. 若 $f(mid) == 0$,说明最优解在 $[l, mid-1]$ 闭区间内,更新 $r = mid - 1$。
  4. 当 $l == r$ 时循环安全终止,$l$ 即为所求的精准极大值。

分治二叉树的区间分解

在序列分治中,任意询问区间 $[L, R]$ 在分治二叉树上会被切割为 $O(\log N)$ 个互不相交的完备子区间节点。

设当前分治节点为 $u$,管辖序列区间 $[l, r]$。

  1. 若 $[L, R] \supseteq [l, r]$,直接提取该节点预处理的区间特征并终止递归。
  2. 若 $L \le mid$,递归进入左子树 $(u \ll 1)$。
  3. 若 $R > mid$,递归进入右子树 $(u \ll 1 | 1)$。

跨越 $mid$ 的跨区间统计,其数学本质是左子树的后缀特征与右子树的前缀特征,基于结合律算子进行的信息融合。


C++ 标准源码

二分答案模板

在一条长为 $L$ 的直线上有 $N$ 块石头,要求移走 $M$ 块石头,使得剩下的石头中任意两块相邻石头之间的最短距离最大。

#include <iostream>
#include <algorithm>
using namespace std;

const int MAXN = 50005;
int stones[MAXN], L, N, M;

// 判定函数:检查在最小段长为 val 的约束下,能否至少划分出 K 段
bool chk(int val, int K) {
    int cnt = 0, last = 0; 
    for (int i = 1; i <= N; ++i) {
        if (stones[i] - last >= val) {
            cnt++;
            last = stones[i]; // 满足约束,更新采样点
        }
    }
    // 终点结算:若最后一段距离不足 val,说明不合法,需强行撤销多算的一段
    if (L - last < val) cnt--; 
    return cnt >= K;
}

int main() {
    cin >> L >> N >> M;
    for (int i = 1; i <= N; ++i) cin >> stones[i];

    // 二分搜索主体:在有界全序集 [1, L] 上进行单调划分
    int l = 1, r = L, ans = 0;
    while (l <= r) {
        int mid = l + ((r - l) >> 1); // 规避整型溢出
        if (chk(mid, N - M)) {
            ans = mid;    // 记录可行解
            l = mid + 1;  // 寻找极大值:答案区间向右收缩
        } else {
            r = mid - 1;  // 不可行:答案区间向左收缩
        }
    }
    cout << ans << "\n";
    return 0;
}

分治二叉树模板

给定一个长度为 $N$ 的整数序列,求出该序列中最大的连续子段元素之和(允许全为负数)。

#include <iostream>
#include <algorithm>
using namespace std;

const int MAXN = 50005;
int A[MAXN];

// 封装满足结合律的多元代数状态
struct Node {
    long long sum, lmax, rmax, tmax;
} tree[MAXN << 2]; // 4倍空间防越界

// 结合律算子融合:自底向上 PushUp
void pushup(int u) {
    int ls = u << 1, rs = u << 1 | 1;
    tree[u].sum = tree[ls].sum + tree[rs].sum;
    tree[u].lmax = max(tree[ls].lmax, tree[ls].sum + tree[rs].lmax);
    tree[u].rmax = max(tree[rs].rmax, tree[rs].sum + tree[ls].rmax);
    // 跨越 mid 融合的核心逻辑:左子树右后缀与右子树左前缀进行代数合成
    tree[u].tmax = max({tree[ls].tmax, tree[rs].tmax, tree[ls].rmax + tree[rs].lmax});
}

// 递归构造分治二叉树
void build(int u, int l, int r) {
    if (l == r) { // 递归基:叶子节点初始化
        tree[u] = {A[l], A[l], A[l], A[l]};
        return;
    }
    int mid = l + ((r - l) >> 1); // 位运算优先级低,必须加外层括号
    build(u << 1, l, mid);
    build(u << 1 | 1, mid + 1, r);
    pushup(u); // 同层信息横向融合
}

int main() {
    int n; cin >> n;
    for (int i = 1; i <= n; ++i) cin >> A[i];
    build(1, 1, n);
    cout << tree[1].tmax << "\n"; // 根节点即为全序列的最大连续子段和
    return 0;
}

分治二叉树模板(纯递归无tree数组版)

#include <iostream>
#include <algorithm>
using namespace std;

const int MAXN = 50005;
int A[MAXN];

// 封装满足结合律的多元代数状态
struct Node {
    long long sum, lmax, rmax, tmax;
};

// 纯递归分治计算:求解区间 [l, r] 的最大子段和状态
Node solve(int l, int r) {
    if (l == r) { // 递归基:单点区间直接初始化并返回
        return {A[l], A[l], A[l], A[l]};
    }

    int mid = l + ((r - l) >> 1); // 划分中点
    Node left = solve(l, mid);     // 递归求解左半区间
    Node right = solve(mid + 1, r); // 递归求解右半区间

    // 结合律算子融合:将左、右子问题的局部特征合并为当前区间的全局特征
    Node res;
    res.sum = left.sum + right.sum;
    res.lmax = max(left.lmax, left.sum + right.lmax);
    res.rmax = max(right.rmax, right.sum + left.rmax);
    // 跨越 mid 融合的核心逻辑:左子树右后缀与右子树左前缀进行代数合成
    res.tmax = max({left.tmax, right.tmax, left.rmax + right.lmax});

    return res;
}

int main() {
    int n; cin >> n;
    for (int i = 1; i <= n; ++i) cin >> A[i];

    // 直接触发分治,系统栈返回最终根节点状态
    Node ans = solve(1, n);
    cout << ans.tmax << "\n"; 
    return 0;
}

NOIP 实战避坑指南

1. 位运算优先级导致的整型溢出

为了追求常数优化,选手常将 mid = (l + r) / 2 简写为 mid = l + r >> 1

由于加法运算符 + 的优先级高于移位运算符 >>,该表达式在底层会被解释为 mid = (l + r) >> 1

当 $l$ 和 $r$ 的数值较大(如接近 $2 \times 10^9$)时,优先执行的 l + r 会直接导致整型溢出(Integer Overflow)变为负数,使算出的中点完全错误。

正确范式:必须强制添加外层括号以孤立位运算,写成 mid = l + ((r - l) >> 1)

2. 二分边界不收缩导致的死循环漏洞

在编写"最大化最小值"的极大值二分时,若更新指针的逻辑为 l = mid,则必须警惕边界死循环。

mid 采用默认的向下取整 mid = l + ((r - l) >> 1),当区间收缩至临界状态 l = r - 1 时,mid 的计算结果将永远等于 l

此时若判定成功执行 l = mid,搜索区间将不再发生任何收缩,导致程序在有界空间内陷入死循环(TLE)

正确范式:对于 l = mid 的分支,mid 必须强制采用向上取整,写成 mid = l + ((r - l + 1) >> 1) 以强推指针右移。


经典 NOIP/洛谷 真题

洛谷 P2678 [NOIP2015 提高组] 跳石头

bool chk(int dist) {
    int cnt = 0, last = 0; 
    for (int i = 1; i <= N; ++i) {
        if (stones[i] - last < dist) cnt++; // 间距不足,必须移走当前石头
        else last = stones[i]; // 间距足够,保留当前石头并更新采样点
    }
    if (L - last < dist) cnt++; // 终点结算:若终点间距不足,必须强行移除最后一块石头
    return cnt <= M;
}

// 二分搜索核心主体
int l = 1, r = L, ans = 0;
while (l <= r) {
    int mid = l + ((r - l) >> 1);
    if (chk(mid)) {
        ans = mid;   // 记录可行解
        l = mid + 1; // 寻找极大值:答案区间向右收缩
    } else {
        r = mid - 1; // 不可行:答案区间向左收缩
    }
}

洛谷 P4145 上帝造题的七分钟 2 / 花神游历各国

题意描述: 给定一个序列,支持两种操作:区间开平方取整,查询区间和。元素值域 $\le 10^{12}$。

问题本质与核心思路: 线段树维护区间最大值的应用。本题的关键在于挖掘数据特性:一个 $\le 10^{12}$ 的数,经过至多 6 次开方取整操作后,必然退化并恒定为 $1$ 或 $0$。基于此,用线段树管理区间,每个节点维护区间最大值。在执行区间开方操作时,若递归到的当前节点区间最大值 $\le 1$,表明该子树内所有元素均已降至 $1$ 或 $0$,开方操作不再改变其值,可直接剪枝回溯。 利用了分治树形结构,对无效操作进行剪枝,其本质是均摊势能分析:尽管单次修改可能递归较深,但每个元素的数值被有效修改(缩减)的次数有严格上限。因此总修改次数是线性的,结合线段树结构,总体时间复杂度为 $O((N+M) \log N)$,其中 $M$ 为操作总数。

struct Node {
    long long sum, max_val;
} tree[MAXN << 2];

void pushup(int u) {
    tree[u].sum = tree[u << 1].sum + tree[u << 1 | 1].sum;
    tree[u].max_val = max(tree[u << 1].max_val, tree[u << 1 | 1].max_val);
}

// 区间开方修改:带有势能剪枝的分治向下传导
void update(int u, int l, int r, int L, int R) {
    if (tree[u].max_val <= 1) return; // 核心剪枝:最大值小于等于 1 时开方数值不变,直接封锁递归

    if (l == r) {
        tree[u].sum = sqrt(tree[u].sum);
        tree[u].max_val = tree[u].sum;
        return;
    }
    int mid = l + ((r - l) >> 1);
    if (L <= mid) update(u << 1, l, mid, L, R);
    if (R > mid) update(u << 1 | 1, mid + 1, r, L, R);
    pushup(u); // 重新融合子树特征
}

[h] 返回首页