题解:P1753 矩阵链排序问题
Chegada_
·
·
题解
题解:P1753 矩阵链排序问题
可能是缘分吧,我在随机跳题的时候刚好跳到了这道题,
看一眼数据范围,n\le 2\times 10^6。可是我只会 O(n^3) 的做法,于是我迅速的打开这道题的题解区,准备借鉴一下大佬的代码,可是空空如也,于是我下定了决心。
ps:因为网上关于 Hu-Shing 算法的资料非常之少,作者只能自己摸索,代码可能不是很严谨,没有实现的很完美,见谅。
## Descreption
- 给定 $n$ 个矩阵按顺序连乘 $M_1 M_2\cdots M_n$,第 $i$ 个矩阵尺寸为 $w_{i-1}\times w_i$(输入给出 $w_0,w_1,\dots,w_n$ 共 $n+1$ 个数)。
- 一个 $a\times b$ 的矩阵乘一个 $b\times c$ 的矩阵,代价为 $a\cdot b\cdot c$。矩阵乘法满足结合律,请通过合理加括号,最小化整条链乘完的总代价。
- 数据范围:$1\le n\le 2\times 10^6$,$1\le w\le 10^4$。其中 $30\%$ 满足 $n\le 500$,另 $30\%$ 满足 $n\le 2\times 10^5$。
因为 $w$ 最大 $10^4$、三角形代价最大 $(10^4)^3=10^{12}$、三角形数量约 $2\times 10^6$,答案最大可到 $2\times 10^{18}$,所以中间运算要用 `__int128` 实现较为稳妥,这一点后面会反复用到。
## Preparations
**先上前置芝士**:
- [区间DP](https://www.luogu.com.cn/problem/P1775)。
- [四边形不等式/Knuth 优化概念](https://oiwiki.moe/dp/opt/quadrangle/)。
- [四边形不等式/Knuth 优化例题](https://www.luogu.com.cn/problem/P4767)。
- [双向链表](https://www.luogu.com.cn/problem/B4324)。
- [可并堆](https://www.luogu.com.cn/problem/P3377)。
如果你准备好了,那么跟着我的思路开始吧!
## 区间 DP(30pts)
这是几乎所有人的第一反应,也是《算法导论》的教科书例题。
记 $f(l,r)$ 为把 $M_l,M_{l+1}\cdots M_r$ 乘起来的最小代价。枚举最后一次乘法发生在哪里(即最外层括号的分界点 $k$),左半 $M_l\cdots M_k$ 得到一个 $w_{l-1}\times w_k$ 的矩阵,右半 $M_{k+1}\cdots M_r$ 得到一个 $w_k\times w_r$ 的矩阵,二者相乘再花 $w_{l-1}\cdot w_k\cdot w_r$:
$$
f(l,r)=\min_{l\le k<r}\Big\{f(l,k)+f(k+1,r)+w_{l-1}\,w_k\,w_r\Big\}, f(i,i)=0
$$
答案就是 $f(1,n)$。按区间长度从小到大转移即可。
```cpp
#include<bits/stdc++.h>
#define int long long
using namespace std;
const int N=510;
int n,i,j,k,l,r,len;
int w[N],f[N][N];
signed main()
{
ios::sync_with_stdio(0);
cin.tie(0);
cin>>n;
for(i=0;i<=n;i++)
cin>>w[i];
for(len=2;len<=n;len++)
{
for(l=1;l+len-1<=n;l++)
{
r=l+len-1;
f[l][r]=4e18;
for(k=l;k<r;k++)
{
f[l][r]=min(f[l][r],f[l][k]+f[k+1][r]+w[l-1]*w[k]*w[r]);
}
}
}
cout<<f[1][n];
}
```
时间复杂度 $O(n^3)$,空间 $O(n^2)$。$n\le 500$ 稳过,但是再往上就不行了,所以这份代码只能吃到那 $30$ 的分,大概**普及**难度吧。
## 一个想当然的坑:四边形不等式在这里用不了
很多人(包括当时的我)看到**区间 DP+求最优分割点**,第一反应是上**四边形不等式/Knuth 优化**,把这个一套,$O(n^3)$ 变 $O(n^2)$。
我兴冲冲写完,样例过了,然后开始心虚 —— 于是写了个暴力对拍,专门检验决策点是否单调,即是否恒有
$$
\mathrm{opt}(l,r-1)\ \le\ \mathrm{opt}(l,r)\ \le\ \mathrm{opt}(l+1,r).
$$
结果**很快就被打脸**。这里给一个具体反例(我从随机数据里抓出来的最小之一),相邻五个维度是
$$
\dots,\ w=2,\ 25,\ 18,\ 23,\ 9,\ \dots
$$
在对应区间上算出来:$\mathrm{opt}(l,r)=6$,而 $\mathrm{opt}(l+1,r)=5$。也就是**区间右端不动、左端右移一格,最优分割点反而往左跳了**,这明显违反单调性,而且不是取值相同,而是货真价实的反例。
原因我找了很久:Knuth 优化要求转移里那项**合并代价 $w(l,r)$ 只与区间 $(l,r)$ 有关**。但矩阵链里的合并代价是 $w_{l-1},\,w_k,\,w_r$,它**死死依赖点 $k$**(中间那维 $w_k$ 会随 $k$ 变),这就不满足四边形不等式的前提,决策单调性自然也保不住。
唉!所以,$O(n^3)$ 往后**并没有一个便宜的 $O(n^2)$ 可以捡**。
## 正解:Hu-Shing 算法(100pts)
这个算法由 T. C. Hu 与 M. T. Shing 在 1981—1984 年提出,是矩阵链问题目前的最优复杂度做法,并且 $O(n\log n)$ 已被证明是这类问题的下界。它在国内资料很少,但是在韩国竞赛社区被讲得比较透(Baekjoon 上就有 $n\le 2\times 10^5$ 的同题)。顺带一提,它的核心引理(论文里的 Lemma1)最初那版证明其实是**错的**,多年后才由 Shing 本人补上正确证明 —— 一个算法能又快又正确地落地,中间的曲折比想象中多。所以下面我讲清**它在做什么与我的代码是怎么实现的**,严格的最优性证明会在文末给出参考链接,~~因为我太蒻了~~。
### 1.矩阵链就是凸多边形三角剖分
Hu-Shing 的第一记妙招,是把矩阵链问题**转化**成一个几何问题。~~妙!!!!~~
把 $n+1$ 个维度 $w_0,w_1,\dots,w_n$ 看成一个凸多边形的 $n+1$ 个顶点,第 $i$ 个顶点的权值就是 $w_i$。那么:
- 多边形的每条边,对应链里的一个矩阵,而底边对应最终结果。
- 把这个 $(n+1)$ 顶点的多边形用不相交的对角线剖成 $n-1$ 个三角形,**每一种三角剖分方案恰好对应一种加括号方案**。
- 一个顶点为 $w_i,w_j,w_k$ 的三角形,代价就是 $w_i\cdot w_j\cdot w_k$,对应「**把两段子链在中间那个公共维度上乘起来**」。
于是,**最优加括号=让所有三角形权值之和最小的三角剖分**。整个问题被搬到了几何上,接下来就能用~~几何直觉~~去啃它。
### 2.Hu-Shing 三部曲
**(1)找最小顶点。** 找到权值最小的顶点,把它转到 $v_0$ 的位置。直觉上,最便宜的那个维度像一个「**万能因子**」,它出现在越多三角形里越划算,所以以它为顶点拉一把「**扇形**」是很强的候选方案。代码里就是先找最小值下标 $p$,再旋转,最后把多边形闭合。
**(2)扇形分解(利用单调栈一次扫描)。** 一个「**扇形**」指一堆共用同一个顶点(局部最小值)当扇柄的三角形。Hu-Shing 证明了:最优解具有**嵌套扇形**的结构。代码用一个单调栈一次扫过所有顶点,每遇到一个不大于栈顶的顶点,就切出一个扇形单元,记下它的左右边界,并把落在它区间内的、更小的扇形收作「**儿子**」。这些父子关系,就是为后面奠基。
**(3)比值判定 + 两次归约**。一个扇形直接按「**锚在较小端顶点**」来剖不一定最优,有时候应该把某个儿子扇形并进父亲(或反过来)。判断依据是一个**比值** $\dfrac{kp}{kq}$:
- $kp$ 是这一块**分子**,$kq$ 是**分母**,如何求出这个值在代码中会体现。
- **第一次归约**:当**最大**的儿子比值 $\ge \min(v_{l},v_{r})$(较小的那个边界权值)时,把这个儿子吸收进来。
- 然后算出「**本扇形锚在较小端顶点** $g$」时自己的最优代价 $kp=num\cdot v_g$。
- **第二次归约**:当本扇形的比值 $\le$ 剩下最大儿子的比值时,继续把儿子并上来。
这里 $\text{span}(l,r)=\Big(\sum_{k=l+1}^{r} v_k v_{k-1}\Big)-v_l v_r$,这个是**边界折线的代价减去弦**,用前缀和就能轻松 $O(1)$ 查询。
每个扇形要反复取出「**比值最大的儿子**」并合并子结构,这天然是一个**可并堆**能干的活 —— 配对堆,来吧。
### 3.代码里的数据结构必须手写
这份代码之所以长、之所以满屏单字母数组,全是为了 $2\times 10^6$ 这个规模。三处设计,每一处都是被数据逼出来的:
- 利用**侵入式双向链表**存每个扇形的儿子列表:归约时要频繁「**把某个儿子从中间删掉**」,链表 $O(1)$ 删除,且**不额外开内存**。
- 利用**手写配对堆**维护儿子比值:这里必须用**可并堆**。配对堆的 meld 是均摊 $O(1)$,正是我们需要的。
- **前缀和**:$span$ 靠前缀和 $O(1)$;而 $num\cdot v_g$、以及累加的 $ans$ 都可能瞬间超过 long long,需要使用 `__int128`,最后再回到 long long 输出。
还有一个容易被忽略但很致命的点:**空间**。这份代码开了 $13$ 个长度 $2.1\times 10^6$ 的 long long 数组。如果像很多教科书实现那样「**给每个堆结点 $new$ 一下**」,指针开销加上分配器碎片,$2\times 10^6$ 个结点会直接 MLE。**所以还得是手写侵入式结构。**
### 完整代码
[code](https://blog.csdn.net/Tsntsn123/article/details/163528129?spm=1001.2014.3001.5502)
### 复杂度
单调栈一次扫描是 $O(n)$;每个扇形单元至多被弹出、合并常数次,配对堆操作均摊 $O(\log n)$,总时间 $O(n\log n)$,空间 $O(n)$。
### 只想要 $60$ 分?Hu-Shing 还有弱化版
那 $n\le 2\times 10^5$ 的 $60\%$,本质上是**逃不掉 Hu-Shing**的本质思路的。但好消息是在这个规模下,你可以**不用手写**链表和配对堆了!一下这两处可以这样改:
- 儿子列表用 list 或干脆 vector 存下标;
- 比值集合用 priority_queue/multiset,合并时**启发式合并(小的并进大的)**,但是复杂度退化到 $O(n\log^2 n)$。
$n=2\times 10^5$ 时加上 STL 常数也稳过。代价是常数和内存都会有点大 —— 到 $2\times 10^6$ 就会 TLE / MLE,但是核心逻辑与正解**一模一样**,这里就不再贴一份几乎重复的长代码占版面了,把上面那份换成 STL,把小到大合并加上,就是一份稳拿 $60$ 分的实现。
## 结语
这题的价值,其实不在最后那份代码本身,而在这条路:当你写下暴力 $O(n^3)$ 时,你理解了问题,四边形不等式的失败后,你看清了「**转移代价依赖决策点**」这个本质障碍,而 Hu-Shing 用「**几何问题+扇形结构+比值计算**」漂亮地绕了过去。如果这篇题解帮你省下了在题解区枯坐的那几个夜晚,那它就没白写。
> The purpose of computing is insight, not numbers. —— Richard Hamming
祝AC。
## 参考资料
- [洛谷 P1753 矩阵链排序问题](https://www.luogu.com.cn/problem/P1753)。
- [HandWiki, Matrix chain multiplication](https://handwiki.org/wiki/Matrix_chain_multiplication)。
- [node-weighted 多边形三角剖分归约与修正证明的整理](https://arxiv.org/pdf/2104.01777)。
- [On the Parenthesisations of Matrix Chains](https://arxiv.org/pdf/2303.17352)。
- [GitHub, junodeveloper/Hu-Shing](https://github.com/junodeveloper/Hu-Shing)。
- [知乎,矩阵链乘的 O(nlogn) 算法是怎么实现的](https://www.zhihu.com/question/644452577)?
- [EagleBear2002 的博客 P1753 笔记](https://eaglebear2002.github.io/51406/)。
- 《算法导论》。