保序回归:从 PAVA 到可并堆的单调约束优化

今天给大家带来保序回归这个实用的算法

1. 引言:一个被低估的问题

算法竞赛中有这样一类问题:给定一个序列 a1,...,ana1​,...,an​,要求构造一个满足某种单调性约束的序列 b1,...,bnb1​,...,bn​(例如 b1≤b2≤⋯≤bnb1​≤b2​≤⋯≤bn​),使得总代价 ∑iwi∣ai−bi∣p∑i​wi​∣ai​−bi​∣p 最小。最朴素的想法是动态规划,但状态空间是值域,往往不可接受。更精细的做法是利用代价函数的凸性,而保序回归提供了一套完整的理论工具,把这类问题从"枚举值域"推进到"合并连续段"的框架中。

它在竞赛中的曝光率远低于斜率优化、决策单调性等"主流"凸优化技巧,却在 BalticOI 2004、HNOI2019、USACO 2021 January 等题目中以不同的面貌反复出现。其冷僻之处在于,大多数人只记住了"单调栈+均值"的模板,却不知道这个模板为什么正确、什么时候失效、以及当均值不再足够时应该用什么替代。本文试图补上这个缺口:从 PAVA 的交换论证出发,经过 L1 情形下中位数的可并堆维护,最终到达一般偏序集与动态修改的入口。

2. 问题定义与基本结构

给定偏序集 (V,⪯)(V,⪯),每个点 ii 有初始值 aiai​ 和权重 wi>0wi​>0,代价函数为 ϕi(x)=wi∣ai−x∣pϕi​(x)=wi​∣ai​−x∣p。保序回归要求

min⁡x ∑i∈Vwi∣ai−xi∣ps.t.i⪯j  ⟹  xi≤xj.xmin​ i∈V∑​wi​∣ai​−xi​∣ps.t.i⪯j⟹xi​≤xj​.

当偏序关系是一条单链 1⪯2⪯⋯⪯n1⪯2⪯⋯⪯n 时,约束退化为 x1≤x2≤⋯≤xnx1​≤x2​≤⋯≤xn​,这是竞赛中最常见的情形,也是本文的主体。

问题的核心观察是一个交换论证 :如果相邻两个块(各自已经被约束为内部相等)的"代表值"出现逆序,那么把它们合并成一个块永远不会更差。以 L2(p=2p=2)为例,若 xi>xi+1xi​>xi+1​ 在某个最优解中出现,令 m=(wiai+wi+1ai+1)/(wi+wi+1)m=(wi​ai​+wi+1​ai+1​)/(wi​+wi+1​),将两者都调整为 mm,则代价变化为

wi(ai−m)2+wi+1(ai+1−m)2≤wi(ai−xi)2+wi+1(ai+1−xi+1)2,wi​(ai​−m)2+wi+1​(ai+1​−m)2≤wi​(ai​−xi​)2+wi+1​(ai+1​−xi+1​)2,

严格小于当且仅当 xi≠xi+1xi​=xi+1​。因此最优解中不会存在"应该相等却取了不同值"的相邻位置,一旦发现逆序,合并就是安全的。这构成了 PAVA(Pool Adjacent Violators Algorithm)的全部直觉。

对于一般下凸代价函数,同样的论证仍然成立,只需把"均值"换成该块代价函数的极小值点:如果两个相邻凸块的极小值点 p>qp>q,则合并后总会在 pp 与 qq 之间的某处取得更优值。这就是保序回归能够脱离 L2 的特定形式、适用于任何下凸 ϕiϕi​ 的根本原因。

3. L2 情形:PAVA 与单调栈

3.1 算法

L2 是保序回归中最"友好"的情形,因为一个块 B=l,rB=l,r 的最优代表值就是加权算术平均:

m(B)=∑i=lrwiai∑i=lrwi.m(B)=∑i=lr​wi​∑i=lr​wi​ai​​.

于是 PAVA 在单链上的实现极其简洁:维护一个栈,栈中每个元素是一个连续段及其均值。从左到右扫描,每次把当前元素作为一个新段入栈,然后只要栈顶两个段的均值出现逆序(前者大于后者),就弹出并合并,直到栈中均值单调不降。

cpp 复制代码
struct Block { int l, r; long double mean; };

long double isotonic_L2(vector<long double>& a, vector<long double>& w) {
    int n = a.size();
    vector<Block> stk;
    vector<long double> sw(n + 1, 0), swa(n + 1, 0);
    for (int i = 0; i < n; ++i) {
        sw[i + 1] = sw[i] + w[i];
        swa[i + 1] = swa[i] + w[i] * a[i];
    }
    for (int i = 0; i < n; ++i) {
        stk.push_back({i, i, a[i]});
        while (stk.size() >= 2) {
            auto &B1 = stk[stk.size() - 2];
            auto &B2 = stk.back();
            long double m1 = (swa[B1.r + 1] - swa[B1.l]) / (sw[B1.r + 1] - sw[B1.l]);
            long double m2 = (swa[B2.r + 1] - swa[B2.l]) / (sw[B2.r + 1] - sw[B2.l]);
            if (m1 <= m2) break;
            stk.pop_back();
            stk.back().r = B2.r;
        }
    }
    return 0;
}

每个元素至多入栈一次、出栈一次,时间复杂度 O(n)O(n)(不计浮点精度问题)。这是保序回归在 L2 下最广为人知的结论,也容易给人造成一种错觉:保序回归 ≈ 单调栈 + 均值。L1 会立刻打破这个错觉。

3.2 正确性的边界

PAVA 的合并规则依赖于"两个块的极小值点逆序时合并安全"这一事实。对于 L2,极小值点是均值,合并后的极小值点仍然是合并后新块的均值,因此算法可以自洽地持续运行。但对于一般的下凸函数 ,两个块合并后的极小值点未必等于原来两个极小值点的某种简单平均;它可能落在两者之间,也可能受到权重和函数具体形状的影响。然而交换论证只要求合并后的极小值点位于两者之间,并不要求它等于某种闭式平均。因此 PAVA 的框架仍然正确,只是**"如何快速维护合并后块的极小值点"变成了真正的困难所在**。L1 正是这一困难最纯粹的表现。

4. L1 情形:中位数、可并堆与折线

4.1 为什么 L1 不同

当 p=1p=1 时,一个块的代价是 ∑wi∣ai−x∣∑wi​∣ai​−x∣,其极小值点是该块的加权中位数 。两个块合并后的中位数不是两个中位数的平均,而是两个多重集合并后的中位数。如果仍然用数组暴力维护中位数,每次合并都要重新排序,PAVA 退化为 O(n2)O(n2) 甚至更差。这就需要一种支持快速合并并查询中位数的数据结构。

可并堆(左偏树) 正好满足需求。维护一个块内的所有 aiai​(带权处理需要将权重拆成多个副本或使用带权中位数的技巧),合并两个块等价于合并两个堆。左偏树的合并复杂度为 O(log⁡n)O(logn),在整个 PAVA 过程中每个元素至多参与一次合并,总复杂度为 O(nlog⁡n)O(nlogn)。

cpp 复制代码
int merge(int x, int y) {
    if (!x || !y) return x | y;
    if (val[x] < val[y]) swap(x, y); 
    rs[x] = merge(rs[x], y);
    if (dist[ls[x]] < dist[rs[x]]) swap(ls[x], rs[x]);
    dist[x] = dist[rs[x]] + 1;
    return x;
}

在 PAVA 的单调栈框架下,每个栈元素持有一个左偏树的根。当需要合并两个相邻块时,调用 merge 合并两个堆,然后弹出堆顶直到堆的大小等于该块所需的中位数位置。对于无权情形,中位数是第 ⌈size/2⌉⌈size/2⌉ 小的数,因此合并后只需把堆调整到大小 ⌈(size1+size2)/2⌉⌈(size1​+size2​)/2⌉ 即可,多余的堆顶弹出。

4.2 L1 的另一种视角:Slope Trick 与折线

L1 保序回归还有一个等价的刻画,它在处理有上下界约束 或多维拓展时比可并堆更灵活。定义前缀代价函数

cpp 复制代码
Fi(x)=min⁡x1≤⋯≤xi=x ∑j=1iwj∣aj−xj∣.Fi​(x)=x1​≤⋯≤xi​=xmin​ j=1∑i​wj​∣aj​−xj​∣.

FiFi​ 是一个下凸的分段线性函数,其斜率的整数值变化点恰好对应着一些 ajaj​。维护 FiFi​ 的"拐点集合"可以用两个堆(Slope Trick):一个最大堆存斜率小于零的部分,一个最小堆存斜率大于零的部分。每次加入新的 ∣x−ai∣∣x−ai​∣ 相当于向折线中插入两个拐点 aiai​,并通过弹出堆顶来维持凸性。

Slope Trick 的优势在于它可以自然地处理下界和上界 :如果要求 xi∈Li,Rixi​∈Li​,Ri​,只需要在每一步把折线在区间外的部分"削平",这对应于在堆中调整或裁剪拐点。而可并堆方案在处理上下界时需要额外记录每个块的可行区间,合并时取交,实现上更繁琐。因此在实际竞赛中,L1 的单调栈 + 可并堆是主流,但当问题带有区间约束时,Slope Trick 往往更直接。

5. 从单链到一般偏序集:整体二分与动态修改

单链上的 PAVA 已经足够解决 BalticOI 2004 Sequence 这类模板题,但保序回归的完整图景远不止于此。当偏序关系是一棵有根树(祖先的值不大于后代)或一般 DAG 时,单调栈的"相邻合并"不再有定义。这时需要换一种视角。

IOI2018 中国国家集训队论文《浅谈保序回归问题》给出了一个整体二分的框架:二分答案值域 midmid,将所有点按照"最优值是否 ≤mid≤mid"划分成两个集合,递归求解。划分的依据是在 midmid 处的次梯度 :对于 L1 或 L2,如果某个点在无约束下的最优值小于等于 midmid,但它有一个祖先被强制大于 midmid,那么这个点就必须被归入"大于 midmid"一侧。这个过程可以转化为一个最小权闭合子图或简单的树形 DP,从而在 O(nlog⁡V)O(nlogV) 的时间内求解一般偏序集上的保序回归。这个方向的复杂度分析和实现细节远超单链 PAVA,是保序回归真正"复杂"的一面,也是它在高难度题目中的价值所在。

另一个重要的拓展是动态修改 。HNOI2019 序列 要求支持单点修改,询问之间独立:给定序列 aa,每次将某个 apap​ 改为 kk,求修改后的 L2 保序回归最小代价。由于修改只影响包含 pp 的连续段,可以预处理前缀和后缀的 PAVA 单调栈结果,然后从修改位置向两侧合并。朴素的左右交替合并可能退化,但利用"连续合并方向切换次数不超过 O(log⁡n)O(logn)"这一性质,配合对每个方向上的二段性二分,可以做到 O(nlog⁡n)O(nlogn) 预处理、单次询问 O(log⁡2n)O(log2n) 或更优。这道题几乎是保序回归在竞赛中"冷僻度"的顶点:即使知道了单链 PAVA 的模板,如果没有理解合并过程的单调性和可二分性,也无从下手。

6. 实现要点与常见陷阱

第一,L1 的权重要正确处理。如果 wiwi​ 是整数,不能简单地把 wiwi​ 当作重复 wiwi​ 次的元素存入左偏树,那样空间会爆炸。标准做法是使用带权中位数:左偏树节点存储 (ai,wi)(ai​,wi​),合并时保持按 aiai​ 的大根堆序,同时维护子树权重和。弹出时减去权重,直到总权重满足中位数的条件。对于无权情形,这退化为普通的弹出计数。

第二,浮点数与整数。L2 的均值是浮点数,但在很多题目中最终答案要求整数(例如输出最小总代价的整数部分,或要求 bibi​ 为整数)。如果 aiai​ 是整数且 wi=1wi​=1,L2 的最优 bibi​ 可能是分数,但最小代价一定是整数或有理数。如果题目要求输出整数序列 bb,通常需要将均值四舍五入或取整,但这会改变代价,因此必须确认题目允许实数或代价为实数。竞赛中更常见的是 L1 或 L2 但允许实数输出;如果强制整数 L2,保序回归的连续解不再充分,需要额外的取整论证。

第三,PAVA 的栈实现中,均值比较的精度。对于 L2,如果 aiai​ 是整数且规模较大,直接用 long double 比较通常安全,但严格来说应使用交叉相乘比较两个块的均值大小,避免浮点误差导致错误的合并或漏合并。

7. 总结

保序回归的冷僻不在于它本身难以陈述,而在于竞赛选手往往只记住了 L2 单调栈的模板,却没有掌握它背后的交换论证、L1 情形下数据结构替换的必要性,以及一般偏序集和动态修改所要求的更精细的合并分析。它的知识谱系是连续的:从 PAVA 的"逆序即合并"出发,L2 用均值闭合了循环,L1 用可并堆或 Slope Trick 闭合了循环,一般偏序集用整体二分闭合了循环,动态修改则用前后缀单调栈与二分合并闭合了循环。每一步的闭合都需要不同的工具,而每一步的失效条件又恰好指向下一步。

对于做题而言,最直接的收益是:当你看到"把序列改成单调不降/不升,最小化绝对差或平方差"时,第一反应不应是 DP,而应是保序回归;当你发现 L1 下无法用均值合并时,应当立刻想到可并堆维护中位数;当你发现偏序不是单链时,应当想到整体二分与树形 DP 的结合。这套框架在 BalticOI、HNOI、USACO 和 Petrozavodsk camp 中反复出现,而它的完整形态------尤其是动态修改下的二分合并------仍然是区分"会模板"和"会分析"的一道分水岭。

(创作不易,感谢点赞,原创切勿抄袭,抄袭可耻)

相关推荐
青少儿编程课堂1 小时前
蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:字典树前缀树与单词检索统计
c++·python·算法·bfs·信息学竞赛
Nuanyt1 小时前
LeetCode Hot 100 链表专题2 3 题 (Java)
java·笔记·算法·leetcode·链表
海绵宝宝转agent2 小时前
LeetCodehot100题解
java·笔记·学习·算法·leetcode·深度优先
Goway_Hui2 小时前
【KMP for OpenHarmony】Cache4k 0.14.0 鸿蒙适配
华为·kotlin·harmonyos
octopus_c2 小时前
C++ 运算符重载详解 && 日期类的实现
开发语言·c++
青少儿编程课堂2 小时前
2026蓝桥杯·全国青少年信息素养大赛·电子学会Python/C++考级:堆排序与优先队列解析
c++·python·算法·bfs·信息学竞赛
听风吹等浪起2 小时前
第27章:EfficientNet全家桶|钢材表面6类缺陷分类完整Pytorch实现
pytorch·分类·数据挖掘
j7~2 小时前
【C++ 面试笔记】(篇二):类与数据抽象 & STL 容器与算法
开发语言·c++·面试·stl容器和算法·类和数据抽象
Fuyo_11192 小时前
深入解析 C++ unordered_set 与 unordered_map 的核心特性与性能实战
开发语言·c++