LOJ 3400 Storm & CodeChef SELEDGE 命题报告(上)
关于本文
SELEDGE 是我在 CodeChef 网站上命制的题目。
后来我将该题改编为集训队作业自选题,并由 EI 鸽鸽将其公开在了 LOJ 上(赞美 EI !),题号是 LOJ 3400 。本文将以 LOJ 上的题目版本为准。
本文分为上、下两个部分,前者基本上是复制粘贴自己先前写的题解,后者是对解法二的复杂度上界进行了改进。
题目大意
给定一张 \(N\) 个点 \(M\) 条边的无向图,点和边都有非负权,寻找一个边集的 \(\leq K\) 元子集,以最大化:该边集覆盖的点集的权值和减掉该边集本身的权值和。
数据范围
多组数据,每个测试点内至多 5 组数据,满足一个测试点中 \(\sum 2^K(N+M)\leq 10^6\) 。
\(2\text{s},512\text{MB}\)
标准算法(解法一)
观察:如果选出的边中有三条边构成一条链,则删掉中间的那条一定不劣;如果选出的边中有若干条构成环,则删掉任何一条一定不劣。
推论:最优解选出的边集,一定构成若干棵直径不超过 2 的树,即若干个不相交的菊花图。
推论:最优解选出的边集,一定构成一张二分图。
我们对每个点等概率独立随机地染上黑白两种颜色之一,并要求这一染色方案,恰好也是最优解所对应的二分图的黑白染色方案。
尝试计算最优解符合这一要求的概率:
- 考虑一张 \(n\) 个点的菊花图,显然它有 2 种染色方案,所以它被染对颜色的概率是 \(\dfrac 2{2^n}=2^{1-n}\) 。
- 假设最优解中每个菊花的结点数分别为 \(a_1,\cdots,a_l\) ,则一定有 \((a_1-1)+\cdots+(a_l-1)\leq K\) ,其中 \(K\) 表示最多能够选出的边数。
- 从而所有菊花都被染对颜色的概率是 \(2^{1-a_1}\cdots 2^{1-a_l}\geq 2^{-K}\) 。
下面,我们尝试计算满足上述要求的解中的最优者。建立费用流模型计算答案:
- 建立二分图,白点在左侧并与 \(S\) 相连,黑点在右侧并与 \(T\) 相连。
- 对于白点 \(v\) ,从 \(S\) 向它连一条容量为 1、费用为 \(-A_v\) 的边,和一条容量为 \(\infty\) 、费用为 0 的边。
- 对于黑点 \(v\) ,从它向 \(T\) 连一条容量为 1、费用为 \(-A_v\) 的边,和一条容量为 \(\infty\) 、费用为 0 的边。
- 对于原图中的边 \((u,v,B)\) 满足 \(u\) 为白色、 \(v\) 为黑色,连一条从 \(u\) 到 \(v\) 的边,容量为 1,费用为 \(B\) 。
- 在该图中限制流量不超过 \(K\) ,则最小费用的相反数就是答案。
解释:
- 一单位从 \(S\) 到白点 \(u\) 再经边 \((u,v,B)\) 到达黑点 \(v\) 再到达 \(T\) 的流量,表示在原图中选择边 \((u,v,B)\) 。
- 结点与 \(S/T\) 的连边的作用是处理点权。当每个点拥有第一份流量时即会产生对应的贡献,流量继续增加时则不会产生贡献。
用基于 SPFA 的连续最短路算法求解该费用流模型,则复杂度是 \(O\big(K^2(N+M)\big)\) ,证明:
- 首先,显然 SPFA 的运行次数 \(\leq K\) 。
- 然后,在一次 SPFA 中,任何一个结点至多入队 \(O(K)\) 次。这是因为:
- 任意时刻有流量的边不会超过 \(3K\) 条,否则就意味着在原图中选了超过 \(K\) 条边。
- 对于任何一条长为 \(L\) 的增广路,其中至少有 \(\dfrac L2-2\) 条边是某条有流量的边的反向边,因为正向边都是从图的左侧指向右侧,只有这些反向边才会从右侧指向左侧。
- 综合以上两条,得到任意一条增广路的长度不超过 \(6K+4\) ,因此每个点的入队次数 \(\leq 6K+5\) 。
- 综上,复杂度是 \(O\big(K^2(N+M)\big)\) 。
引理:如果一枚硬币以 \(p\) 的概率掷出正面,则连续掷 \(\dfrac 1p\cdot -\log\epsilon\) 次能以 \(1-\epsilon\) 的概率掷出至少一次正面。
- 证明:全反面的概率 \((1-p)^{\frac 1p\cdot -\log\epsilon}=\big((1-p)^{\frac 1p}\big)^{-\log \epsilon}\leq \Big(\dfrac 1e\Big)^{-\log\epsilon}=\epsilon\) 。
注意到我们已经能够以 \(O\big(K^2(N+M)\big)\) 的复杂度获得以 \(2^{-K}\) 的概率正确的解。那么我们把整个过程重复 \(-2^K \log\epsilon\) 次以得到 \(1-\epsilon\) 的正确率,则总复杂度 \(O\big(2^KK^2(N+M)\cdot -\log\epsilon\big)\) ,可获得满分。
针对较小的 \(K\) 的解法(解法二)
该解法是作者与 Istvan Nagy ( CodeChef 验题人)在讨论中得到的,在此致谢。
该解法对应于子任务 2 ,复杂度为 \(O\big(N+M+f(K)\big)\) ,在 \(N,M\) 远大于 \(K\) 时优于本文中其他两个算法。
考虑搜索,假设已经钦定一个边集 \(S\) 要出现在答案中,考虑如何选取剩下的边以最大化目标函数。
对于边 \(e\notin S\) ,我们定义其“当前贡献”为:它的未被 \(S\) 覆盖的端点的权值和,减掉它自己的权值。
考虑当前贡献最大的边 \(e_0\) ,分类讨论:
- 如果 \(e_0\) 出现在答案中,皆大欢喜。
- 如果 \(e_0\) 没有出现在答案中,继续分类讨论:
- 如果 \(e_0\) 的两个端点都未被 \(S\) 覆盖,继续分类讨论:
- 如果两个端点在最优解中仍然都没被覆盖:这种情况不可能出现,否则可以随便把另一条边替换成 \(e_0\) 而使得答案不变劣。
- 如果两个端点恰有一个(记为 \(v\) )在最优解中被覆盖:也不可能出现,否则把 \(v\) 的邻边替换成 \(e_0\) 一定不劣。
- 如果两个端点在最优解中都被覆盖:这是唯一可能的情况,记为情形 I 。
- 如果 \(e_0\) 的两个端点中有一或两个被 \(S\) 覆盖,与上一情况同理进行分类讨论,可知 此种情形不可能出现。
- 如果 \(e_0\) 的两个端点都未被 \(S\) 覆盖,继续分类讨论:
假设我们遇到了情形 I ,则需要选择两条边来分别覆盖 \(e_0\) 的两个端点。设 \(v\) 为 \(e_0\) 的一个端点,考虑如何选择边来将其覆盖:
- 先把 \(e_0\) 从图中删去。
- 将 \(v\) 的邻边按当前贡献降序排列,假设其中第 \(k\) 条边 \(e_k\) 是这些边中首条出现在答案中的,则:
- \(e_1,e_2,\cdots,e_{k-1}\) 的另一个端点一定也被答案中其他边覆盖,否则把 \(e_k\) 换成 \(e_{1,\cdots,k-1}\) 中的某一个一定不劣。
- 等等,如果 \(e_{1,2,\cdots}\) 中有些边的另一端点已经被覆盖了呢?那么轮到这条边(即确定了排在它前面的边都不选)的时候一定会选它,否则必定可以做不劣的调整。
于是得到搜索算法:(下述伪代码经过简化,忽略了若干种不影响复杂度分析的边界情况)
S: set /* set of chosen edges */
L: set /* set of edges left on graph */
res: int /* maximum value over all schemes */
procedure DetermineBestEdge /* called at the beginning of the program */
res := max(res,cost(S))
if |S| > K
return
e := edge in L with largest current contribution
S := S∪{e}
L := L\{e}
DetermineBestEdge() /* suppose that e is chosen */
S := S\{e}
CoverVertices({e's endpoints}) /* suppose that e is not chosen */
L := L∪{e} /* restore to prior state */
procedure CoverVertices( todo: stack /* stack of vertices to cover, passed by value not by reference */ )
if todo is empty
DetermineBestEdge()
return
v := todo.pop()
es := array of v's neighbouring edges in L, sorted by current contribution
if |S| >= K
return
for e ∈ es
S := S∪{e}
L := L\{e}
CoverVertices(todo) /* suppose that e is chosen */
S := S\{e}
push e's other endpoint into todo /* suppose that e is not chosen */
L := L∪es /* restore to prior state */
尝试分析该算法的状态数:
- 考虑任一时刻的递归栈(注意区分递归栈与
todo
栈;递归栈中包含了每一层递归中的todo
栈信息),可以发现存在一个 从递归栈到(儿子有序的)有根树的单射。具体地说:- 以下我们将忽略“无用”的函数实例。具体地说:
- 如果一个
CoverVertices
函数进入了todo is empty
这个分支,则我们把它从递归栈中删去,并认为是它的前驱(即递归栈中的上一个函数)直接调用了DetermineBestEdge()
。
- 如果一个
- 对每个
DetermineBestEdge
函数的实例 \(a\) ,建立结点 \(X_a\) ;对每个CoverVertices
函数的实例 \(b\) ,建立结点 \(Y_b\) 。 - 对每个 \(Y_b\) ,在函数 \(b\) 中都一定执行了
todo.pop()
。设所弹出的元素是在结点 \(Z\) 所对应的函数实例中被压入的,则把 \(Y_b\) 的父亲定为 \(Z\) 。这样会得到一个森林,森林中的树根有且仅有全体 \(X\) 类型结点。 - 建立一个“超级根结点” \(R\) ,然后把森林中每棵树的树根作为 \(R\) 的儿子。
- 把每个结点的儿子按照在递归栈中的出现顺序排列。
- (从一棵上述的有根树,容易基于刚刚的伪代码唯一地还原出递归栈,所以这是一个单射)
- 以下我们将忽略“无用”的函数实例。具体地说:
- 考虑压缩有根树的结点数。
- 对于一个
DetermineBestEdge
的实例对应的结点 \(X\) :- 如果 \(X\) 有儿子(易见一定是两个),则把它的两个儿子结点直接连向 \(X\) 的父亲 \(R\) ,并把 \(X\) 删除。
- 如果 \(X\) 没有儿子,则不做任何修改。
- 这样有一个问题:\(R\) 可能会有连续多个儿子,而我们不知道这些儿子中哪些是单独的 \(X\) 类型结点,哪些是两个一组的 \(Y\) 类型结点。所以我们还需要记录 把 \(R\) 的儿子划分成长为1或2的连续段的方案。易见这样的方案有 \(F_{d}\) 种,其中 \(F\) 表示斐波那契数列,\(d\) 表示 \(R\) 的度数。
- 对于一个
- 观察:这棵树中的每个结点(除了 \(R\) 以外)在递归栈中对应的函数实例 \(c\) ,都一定选择了至少一条边(即执行了
S := S∪{e}
)。- 这里指的不是“ \(c\) 在其函数体中选择了一条边”,而是“ \(c\) 的前驱 \(d\) 一定是先选择一条边,随后再调用 \(c\) ”。
- 这里“前驱”的定义是:递归栈中在 \(c\) 之前的最后一个函数实例,满足 在压缩结点数的时候 它没有被删除。
- 推论:树的大小 \(\leq K+1\) 。
- 推论:树的个数 \(\leq \sum\limits_{i=2}^{K+1} C_{i-1}\) ,其中 \(C\) 表示卡特兰数。
- 再算上至多 \(F_d\) 种划分,我们得到:可能的递归栈数量(即状态数)不超过 \(\sum\limits_{i=2}^{K+1} F_{i-1}C_{i-1}=O(F_KC_K)\) 。
- 这个等号是因为斐波那契数指数级增长的性质。
经过 \(O\big((N+M)\log N\big)\) ( \(\log\) 来自排序)的预处理后,每个状态的复杂度开销可以做到 \(O(K)\) 。具体地说:
- 预处理是把每个点的邻边按初始时的当前贡献排序,再把所有边按当前贡献排序。
- 每个状态中要查询一个点的当前贡献最大的邻边,而 这些邻边中当前贡献相比初始时改变了的 显然只有 \(O(K)\) 条。于是可以 \(O(K)\) 地查询。
- 至于寻找全局最大的当前贡献,可以通过维护每个点的最大当前贡献得到。
于是总复杂度为 \(O\big((N+M)\log N+F_KC_KK\big)\) 。
注意到 \(O\big((N+M)\log N\big)\) 的部分可以用 \(O\big(N+M\big)\) 的过程替代:(虽然没啥实际意义)
- 预处理时:
- 对每个点用快速选择求出前 \(K\) 优的边(代替前述预处理中对每个点邻边排序的过程)
- 再从所有边中快速选择求出前 \(K\) 优的那些(代替前述预处理中对所有边排序的过程)
- 这样我们得到了若干个大小为 \(K\) 的边集。
- 在算法过程中,每次要找一条“最优的边”(包括找全局最优和一个点邻边中的最优)时,显然不会用到前 \(K\) 优的边以外的边。那么找到对应的 \(K\) 元边集,然后在里面暴力即可。
- 这样,我们在预处理时就没有排序,排序的复杂度将被转嫁到 \(O\big(F_KC_KK\big)\) 这一部分上。总复杂度 \(O\big(N+M+f(K)\big)\) 。
基于带权带花树的解法(解法三)
(该解法可获得满分,其前半部分对应于子任务 1 )
类比边覆盖问题的解法,考虑按任意顺序加入答案中的边,则每条被加入的边:
- 要么首次覆盖了其两个端点之一
- 要么首次覆盖了其全部两个端点
于是我们考虑先决定哪些边覆盖了其全部两个端点,再用其余的边每次覆盖一个端点。
于是可以建出这样的一般图匹配模型:
-
对原图的每个结点,建立模型中的对应结点。
-
对原图中的每条边 \((u,v,B)\) ,在模型中连边 \((u,v,\textit{weight}(u)+\textit{weight}(v)-B)\) 。
-
对原图中每个结点 \(v\) ,找出其邻边的最小权值 \(B_0\) ,在模型中建立新点 \(v'\) 并连边 \((v,v',\textit{weight}(v)-B_0)\) 。
则该模型中恰包含 \(K\) 条边的最大权匹配就是答案。
解释:
- (2)中的每条边表示一条“首次覆盖了其全部两个端点”的边。每选择一条这样的边,就意味着其两个端点不再能够选择(3)中的边,这与原问题是相符的。
- (3)中的每条边表示一条“首次覆盖了其两个端点之一”的边。易见对于被覆盖的 \(v\) ,我们只会选择 \(v\) 的权值最小的邻边。
- 如果一条实际上覆盖了两个端点的边,被当成只覆盖了一个端点呢?
- 这样答案一定不会减少,所以这样的方案不优。
如何求出恰包含 \(K\) 条边的最大权匹配?
- 引理:记 \(f(K)\) 为恰包含 \(K\) 条边的最大权匹配,则 \(f\) 是凸函数。证明:
- 任取一个 \(L\) ,考虑 \(f(L-1)\) 和 \(f(L+1)\) 对应方案的边集的并集。
- 显然这个边集构成了若干条链和若干个偶环的并,而且其中包含奇数条边的链的数量为偶数。
- 因此,这个边集显然可以划分为两组等大小(即大小为 \(L\) )的匹配,这两组中至少有一组的权值 \(\geq \dfrac{f(L-1)+f(L+1)}2\) 。
- 由此得到 \(f(L-1)+f(L+1)\leq 2f(L)\) ,所以 \(f\) 为凸函数。
- 那么采用WQS二分的技巧,将额外的权值加在边上,然后用带权带花树算法跑最大权匹配即可。
- 复杂度 \(O(N(N+M)\log N\log W)\) ,其中 \(W\) 表示边权上限。稍微注意一下常数即可通过子任务 1 。
这个算法可以进一步优化以获得满分:
- 在带权带花树算法的增广过程中,把对每个点 DFS ,改成以全体未匹配点为起点集合做 BFS 。
- 那么,直接进行 \(K\) 次增广,即可得到大小为 \(K\) 的最大权匹配。复杂度 \(O(K(N+M)\log N)\) 。
- 在 WC2020 营员交流中,徐翊轩题为《浅谈二分图最大权匹配》的报告提到了这一算法,并给出了一个并不严格的正确性说明。暂未见过严格的证明。