Chengyuan Ma's Blog

幽居默处而观万物之变,尽其自然之理,而断之于中

0%

Day -1

在中午太阳晒得最狠的时候到了学校。宿舍爬楼累死人,也不知道为啥学校楼要建那么高。幸好宿舍里的空调给力,好评。

宿舍的环境还行。个人觉得比当年WC2018的要好。

信号出奇地差,必须在直接靠窗的地方才有4G,不然连2G也别想收到,听宿管和小卖部阿姨说当初就是设计成这个样子的。差评。

插座很多好评。

但是还是没有桌子,无论用什么姿势看电脑都贼难受。同寝室的jtl带了一个床上架的桌子,看了直呼内行。床板贼硬,差评。

伙食还可以,豆浆我觉得挺好喝的,就是湖南菜多多少少带点辣个人不是很能接受,而且菜很细碎的样子。

睡前随随便便背了点笔试题。

Day 0

早上迷迷糊糊地去参加了开幕式。听到dzd说有剩饭扣1分大惊。其他就没啥印象了=_=。

结束后拱火mr押题,mr说看到才艺表演一个跳舞的转来转去暗示会考平衡树,一本正经胡说八道.jpg。

感觉周围大佬都贼多,互相之间也都认识,我一个蒟蒻在当中不知所措。自己看来对于在竞赛圈内的信息闭塞的可以。

中午又背了一会笔试题,然后下午就去试机了。笔试没有想象中的难,但是确实是有超纲的,纠结了很久。幸好最后还是满分飘过了。唯一值得吐槽的或许是CCF十年不变的远古测评系统。

随后在试机场上敲了一波LCT和FFT。看到jtl在写MTT有想敲三模数的冲动但是最后的合并调了好一会才勉强调出来,于是就很慌。

晚上寝室里大家都在欢快地打板子。对面两个人都在打带花树,然后惊奇地发现带花树的代码似乎也没有想象中那么长。三个人讨论了一下觉得似乎有概率考分块的样子,但是笑一笑也就过去了。我自己把各种字符串的算法全部过了一遍。

Day 1

看到T1愣了好一会,愣是没有第一时间看出DP。不知道为什么当时满脑子都是Tarjan和缩点然后沿着这个方向陷入了死胡同。于是先敲了一个DFS,觉得自己要完蛋。

看了T2又愣了一会,先敲了个暴力,然后觉得m比较小的情况可以动态维护链并+容斥解决。先放着。

看到T3深切地感受到这或许是个数据结构毒瘤?先敲了一个二维树状数组的暴力。当时脑子大概是坏掉了不是枚举矩形直接算而是枚举点算贡献。总之暴力复杂度似乎\mathcal O(nm\log^2 n)非常差。然后认真思考了一下部分分,发现可以莫队。于是基于二维树状数组写了一个\mathcal O\left(n\sqrt m \log^2 n\right)的算法。然后觉得\mathcal O(\log ^2n)的二维数点不妥,改成了\mathcal O(\log n)的可持久化线段树,于是暴力和莫队的复杂度都少了一个\log。再一看发现莫队我可以用树状数组干嘛要二维数点,于是莫队的常数又降下来一点。瞄了一眼后面觉得应该可以用\mathcal O(n^{7/4})的四维莫队,可惜当初没认真学高维莫队不知道块大小咋算了,于是作罢。最后敲了一个莫队和暴力的对拍放着。

回到T2开始敲树剖和容斥,写了一个\mathcal O\left(m2^m\log^2n\right)的算法。和暴力结合在一起觉得至少能拿32分,常数小一点也可以冲冲40样子。

然后回到T1,突然发现这不就一个裸的DP吗,直骂自己前面傻逼。于是花5分钟敲完朴素DP,然后再花10分钟敲完环的部分分。

然后发现边权至多为5,意识到正解显然是用max-plus algebra下的矩阵快速幂进行优化,于是开始敲。此时离考试结束还有60分钟,心中贼慌。等到敲完离考试结束还有30分钟,心态爆炸,然后死活调不出来。只能把这个正解例程写在程序里作为最后之选。离交卷还有5分钟的时候不改了。检查其他两题的程序无误后就开始坐着怀疑自己前三个小时脑子到底在想什么……

出考场觉得自己已经成为了时代的眼泪(笑)。

下午三点去查分。听jtl说这次CCF准时出分没有咕简直是奇迹。结果就是50+32+40=122。和预想的完全一致。这个时候就很后悔。如果当初早点看出矩阵快速幂把T1的正解调出来就好了。这个分觉得铁牌已经在向我招手。

晚上讲题。T1的确是快速幂正解。T2的正解是线段树合并维护树形DP这个之前也在寝室里有了大概的想法,但是一看这个DP的状态设计果然神仙。出题人怒斥了我们打32分树剖暴力的,说是什么数据结构学傻了……然后说写个虚树不就40了吗。我下面听着就很无语:我也想打虚树,但是我不会啊…… T3的出题人原来就是各种OJ上人们一直吐槽的lxl。这个题目的内部名称似乎叫“第十三分块”?正解似乎是先建一个树套树然后再分治再分块……讲到一半就lost了,内心大骂出题人毒瘤。

回寝室后所有人都是颓废的状态,gyc在打Splay的板子。剩下我们两个人开始摸鱼。

Day 2

T1一看给人一种网络流的既视感,然后发现图建不出来。退而求其次试图写出线性规划进行代数化建图,发现线性规划必须使用Big M的办法才能建出来,而且直观一看integrity gap大的离谱是不可能建图的。因为是求可行解也不知道目标函数咋写,所以也不能从对偶下手,只能作罢。敲了一个非常粗暴的DFS暴力枚举每道菜用哪两个原材料分别用多少,发现这个DFS在最坏情况下跑得巨慢无比——难不成我暴力骗分都不成?

这个时候有了一个乱搞的intuition,就是枚举原料的排列然后按照排列来确定所用的原料。然后发现过不了样例,于是作罢。

之后稍微改进了一点暴力,只枚举每道菜用哪两个原材料,最后时候用多少最后用网络流来判。这下终于拿到了15分的暴力。顺便基于这个敲了一个随机化,但是似乎表现也不佳的样子。

T2题面长度属实劝退。读完题面之后觉得似乎不是很可做。看了样例之后有了一点暴力的想法,写了一个复杂度为\mathcal O\left(2^{2^{h_{\text{max}}-1}}\right)的算法。简单来说就是把输入的树补成最大树高然后枚举能否扩张成最大树高下的所有可能的二叉树形态。觉得除了12分纯暴力还可以拿h_{\text{max}}=4的分?之后就不会了。

T3一看给我整懵了。可真就暴力不会写呗。直觉上似乎可以写Dijkstra然后在转移的时候排除掉当前路径下的割边。但是很快意识到Dijkstra的本质是DP而这个转移方案是有后效性的。事实上也是如此,样例都没有过。然后试验性地写了一个不可行的判定方法:求出最短路,如果把最短路去掉之后全图不连通,则不可行——也不知道这样对不对。

5个小时就在三个题目的来回懵逼当中度过。

这次CCF出分直接咕了将近两个小时。我们很明智地从一开始就待在寝室里,那些下去等分的就苦逼了。分数出来是15+12+5=32、也差不多是我预想的这个水平,T2树高为4的点我还是超时了,大概还是没有判同构去重的原因?

下午去听讲题。T1正解的思路源于m=n-1的思考,其他的情况都是向m=n-1情况的规约,感觉是非常巧妙的。T2的最优算法居然是线性的?有点听蒙了。听到T3讲解的时候才意识到T3当中的图叫做弦图,而正解源于对于弦图性质的思考,最后用过两次类Dijkstra来解决,感觉是非常非常神奇,不明觉厉,自愧不如。

讲的时候就出榜了,一看果然Cu,心情复杂,但是一开始也没有期望,所以也没有太沮丧。感觉心态真正崩盘的大概是jtl,人家Cu第一……差一分就Ag了。但是看榜还是有500分以上的,觉得这些人真的很厉害。我或许不比他们笨,但是他们确实历练的比我这种多多了。我这种常年边缘划水的OIer果然不能和这些人比。

Day 3

看着手中的铜牌,意识到自己划水的OI之路至少到此为止暂时地画上了句号,颇有些不真实感。

我一直在想,如果D1T1的正解我调出来了,我就是Ag了,会不会好一点呢?但是这终究也只是一个幻想而已罢了。一是比赛不能重来,二是我似乎也不知道缓存矩阵乘方的套路所以真写快速幂有可能复杂度还是会炸的样子。

一如既往,自己复习的算法完美地和考试算法错开了。没有LCT,没有FFT,没有字符串。我深切意识到OI果然还是靠平时积累的,这种比赛临时抱佛脚很大概率是不靠谱的。

还是要谢谢NOI最后给了我一个意识到自己有多菜的机会。还有那么多我没有学的啊~

大概在没有竞赛压力之后我还是会对这些算法认真地研究一番的吧。觉得自己的心态是一个很奇妙的东西。在被父母逼着学竞赛的时候总觉得这些算法很烦,但是意识到自己远离竞赛之后,反而又觉得它们有趣起来了。

网上看过很多这样的游记,一般到最后作者不是Au就是进队了,这种好事终究不会发生在我身上。

唉。这就退役了。

写于Day 3从长沙回上海的火车上。自己的思路一如既往地混乱。

最近真的是被支付宝的操作恶心到了。

跑到医院门口,要出示健康码了。打开支付宝选择随申码,登录授权时要刷脸,然后告诉我手机不支持刷脸?场面一度非常尴尬。

“您的手机不支持刷脸”。为什么不支持?缺少硬件?缺少驱动?含糊其辞,搞得我一开始真以为XDA上下下来的ROM不贴合我国国情少预装了一套刷脸的库。

查了好久才意识到原来是支付宝检测到Magisk和Xposed的缘故。

好啊,这可以理解嘛,人脸验证和支付都是安全性极高的,关键步骤被hook是会出大事的。

于是停用了Riru和EdXposed的模块。但是还是不支持。

三清之后重装,又支持了。

然后我遵循网友的经验,安装Magisk,然后开启随机包名重装和Magisk Hide。没有出问题。

然后再安装EdXposed,即时模块打开,黑名单打开把支付宝加进去,然后开启强制SafetyNet检验。

然后支付宝安全检查之后就又告诉我不支持了。

唉,我啥模块都没装呢,拉黑之后就算装了也hook不到你支付宝紧张啥?

网上逛了一圈,有不少遇到这个问题的,大家都没有好的解决办法,有的说双开,有的说双手机,有的说回退到play上的19年9月份的75版本。最后一条亲测确实有效,但是一直停留在老版本也不是个事啊。

也看了CSDN上一些关于阿里系应用反hook机制的研究,大概是18年左右写的。当时的机制是通过反射检测有没有XposedHelper这个类,有这个类之后检测缓存里面有没有alipay等关键词。这个机制非常巧妙,而且我觉得强度也足够了。

但是现在似乎已经不是这个机制了。通过有些解决方案当中关闭读取应用列表权限这一项我隐约可以猜出这支付宝大概是直接读取手机安装的所有应用,然后发现带Xposed就是不安全?

这也太过了吧,我个人甚至觉得有些流氓之嫌。

看到Xposed,立刻想到自己被hook,立刻想到这hook一定会让用户亏钱,立刻想到自己要赔钱,然后就紧张的要命。支付宝的想像惟在这一层能够如此跃进。——鲁迅

这个逻辑链有两处是有待商榷的。

为什么所有Xposed插件一定要来hook你?我手机一般就装两模块,一个修改界面的locale让不够本地化的地方本地化,一个修改图标让系统更美观。这两个模块都不用hook进支付宝,而且还都是我自己一行一行写出来的。我脑子坏掉了去搞支付宝自己坑自己?

为什么觉得因为用户因为hook造成的损失一定要支付宝负责?还真就社会责任心爆棚呗。服务条款里面加一行“由于用户使用第三方软件自行修改系统框架进而影响本应用的正常运行,导致财产损失的,责任自负,支付宝概不负责”有那么困难吗?Xposed必须是用户自己装的,模块一定是用户自己下载并且启用的,考虑到使用Xposed基本上都是安卓的发烧友,翻车了自己负责这条道理大家都认,也没有人来说是支付宝的不对。

退一步,如果我真的要搞尽一切代价搞支付宝,支付宝检测有没有被hook的代码可不可能被hook呢?我能不能不用Xposed而用一些更底层的框架例如Riru来搞呢?支付宝作为一个正常权限的应用软件,面对因用户主动选择破坏承载其运行的,本来安全的安卓系统框架,而导致的可能来自底层的,高权限的修改与破坏一定是无能为力的。支付宝完全没有必要对一个受害者主动引发,且本质上自身无能为力的损失负责。

进一步,今天支付宝可以读取应用看到Xposed就不让刷脸,明天如果腾讯和阿里干架,支付宝也可以读取应用列表看到有微信就不让用(3Q大战既视感)。现在看来“读取应用列表”不是安卓原生自带的权限实在是一个设计漏洞。支付宝这个行为也显然开了一个不好的先例。

最后,我其实一直不理解的一点是,是什么让支付宝觉得原来合理的Xposed框架检测逻辑有问题,进而换上了这套长臂管辖似的一刀切呢?

在不知道更多信息以前,我只能认为是支付宝自身在有些情境下不必要的责任感以及过度的被害妄想症在作祟。

在此,向设计这个安全检查模块的所有阿里员工以及十八代亲属致以最诚挚的问候。

最后的最后,讲一讲在保留Magisk和Xposed功能的情况下,可能的解决办法吧。

  1. 停留在老版本的支付宝。
  2. 让EdXposed也有和Magisk类似的随机包名重装功能。
  3. 让支付宝找一个更合理的办法检验自己有没有被hook。
  4. 让支付宝目前这套方案失效,比如说禁止支付宝调用getInstalledPackages来获取已经安装的应用。这个我知道在一些国产的系统里面是有类似功能的。但是目前类原生的系统还没有。虽可以通过Xposed模块来实现,但是在这个背景下毫无意义。唯一的一个方案是把系统的源码clone一份下来,然后直接改代码。

This should be the very first English post I write on my blog and I expect there to be some minor errors. This is a popular technique in the Chinese competitive programming community but there doesn't seem to be a lot of documentation about its application in the English CP community. The posts I found on Codeforces doesn't seem to be very clear to me...

Prerequisites

  • A decent proficiency in competitive programming.
  • A basic understanding of the Cooley-Tukey FFT and its application in competitive programming.
  • A decent understanding of bitwise operations.

Why do we need FWHT?

Recall what we would do if we are to quickly calculate the following convolution of two sequences a and b, each of length n:

\{a\circledast b\}_i=\sum_{j+k=i}a_{j}b_k \Bigg/ \{a\circledast b\}_i=\sum_{j=0}^ia_{j}b_{i-j}
We use FFT, which applies the following transformation to the input sequence:
\mathcal{F}\{a\}_i = \sum_{j=0}^n a_j\omega_{n}^{ij}
Since the calculation of this transformation (and its inverse) can be done in a divide-and-conquer manner in \mathcal O(n\log n) and the element wise product of the transformation is equivalent to the convolution on the original series, we are able to calculate the convolution in \mathcal O(n\log n).

Now we try to generalize our findings to a more general case:

\{a\circledast b\}_i=\sum_{j \star k=i}a_jb_k
where \star is some binary operation. The convolution we see at the beginning is a special case where \star = +.

FWHT is an algorithm that borrows similar notions from FFT and is able to compute the convolution in \mathcal O(n \log n) time for \star =\vee,\wedge,\oplus (bitwise OR, bitwise AND, and bitwise XOR). Why do the convolutions of these bitwise operations matter? Observe that binary representation is a way of encoding sets and these three operations correspond to set union, set intersection and set symmetric difference respectively, therefore, FWHT can be used to accelerate set-based DPs.

Bitwise OR convolution

Let's start with the convolution with respect to bitwise OR:

\{a\circledast b\}_i = \sum_{j\vee k=i}a_jb_k
We start by exploiting an interesting property of bitwise OR:
x \vee z = z,y\vee z = z \Leftrightarrow (x\vee y)\vee z=z
or its clearer equivalent in set-based notations:
X\subseteq Z,Y\subseteq Z \Leftrightarrow (X\cup Y)\subseteq Z
Claim: The following transformation can turn OR convolutions to element-wise multiplications:
\mathcal{FWHT}\{a\}_i=\sum_{j\vee i=i} a_j
Proof:
\begin{aligned}
\mathcal{FWHT}\{a\}_i\cdot\mathcal{FWHT}\{b\}_i &= \left(\sum_{j\vee i=i}a_j\right) \left(\sum_{k\vee i=i}b_k\right) \\
&= \sum_{j\vee i=i}\sum_{k\vee i=i}a_jb_k \\
&= \sum_{(j\vee k)\vee i=i} a_jb_k \\
&= \sum_{l\vee i = i}\sum_{j \vee k = l} a_jb_k\\
&= \mathcal{FWHT}\{a\circledast b\}_i
\end{aligned}
Then how are we able to compute \mathcal{FWHT}\{a\} quickly? A trivial implementation still takes \mathcal O(n^2) time.

Recall what we did in FFT: we divide a into two subsequences based on parity of indices, a.k.a, the last bit of indices. We did this because the root of unity has such amazing property as \omega_n^k=\omega_{n/2}^{k/2}. We could do that here as well, but a limitation of dividing based on the last bit is that the order of elements changes in the process, so an efficient in-place implementation has to do a pre-shuffle to cope with that. Since OR is a bitwise operation, which bit based on which we divide doesn't really matter much. Why not simply divide based on the first, or the most significant bit, such that the order of elements is preserved in the process? Dividing based on the highest bit of indices, simply put, is to split a into the first half, a^0, and the second half, a^1, in their natural order.

Here I introduce a notation, 1|a or 0|a. In the context where the length of the sequence is n (and n is a power of 2), 1|a=\frac{n}{2}+a where 0\le a<n/2, and 0|a is just a. In other words, 1|a has 1 as the highest bit and 0|a has 0 as the highest bit.

(Note using this notation, a^0_i = a_{0|i} and a^1_i = a_{1|i})

To make our writing clearer, denote

\begin{aligned}
\mathcal{FWHT}\{a\} &= A \\
\mathcal{FWHT}\left\{a^0\right\} &= A^0 \\
\mathcal{FWHT}\left\{a^1\right\} &= A^1 \\
\end{aligned}
We want to express each element of A as a combination of some element in A^0 and A^1.

We first look at the first half of A. Using the notation I defined above, these elements can be expressed as A_{0|i}.

\begin{aligned}
A_{0|i}&=\sum_{j\vee (0|i)=0|i}a_j \\
&= \sum_{(0|j)\vee (0|i)=0|i}a^0_j + \sum_{(1|j)\vee (0|i)=0|i}a^1_j \\
\end{aligned}
We know that the highest bit of (1|j)\vee (0|i) should always be 1, so the condition in the second summation is never satisfied, and we can simply throw the second term away. And (0|j)\vee (0|i)=0|i simplifies to j\vee i =i, so we get, by definition of A^0:
A_{0|i} = A^0_i
What about the second half, A_{1|i}?
\begin{aligned}
A_{1|i}&=\sum_{j\vee (1|i)=1|i}a_j \\
&= \sum_{(0|j)\vee (1|i)=1|i}a^0_j + \sum_{(1|j)\vee (1|i)=1|i}a^1_j \\
&= A_i^0+A_i^1
\end{aligned}
Together we get:
A=\left(A^0, A^0+A^1\right)
with the trivial recursion boundary A_0=a_0 when n=0.

(here I use the tuple notation to denote concatenation, and + to denote element-wise addition).

This is something we can write an in-place implementation for with ease:

void fwht_or(int n, int *a, int *A) {
    copy(a, a + n, A);
    for (int s = 2, h = 1; s <= n; s <<= 1, h <<= 1)
        for (int l = 0; l < n; l += s)
            for (int i = 0; i < h; i++)
                A[l + h + i] += A[l + i]
}

Its time complexity is obviously \mathcal O(n\log n) with a really small constant factor.

Its reverse transform turns out to be simple as well, suppose we know A and let

A=(A',A'')
(Assuming n is a power of 2 and A' and A'' each have length n/2)

Then we can recover A^0 and A^1:

\begin{cases}
    A^0=A'\\
    A^1=A''-A'
\end{cases}
Implementation:

void ifwht_or(int n, int *a, int *A) { 
    std::copy(A, A + n, a);
    // If n is guaranteed to be a power of 2 then we don't need n_ and the min(...) in the inner loop.
    int n_ = 1; while (n_ < n) n_ <<= 1; 
    // n_ = 1 << (32 - __builtin_clz(n - 1));
    for (int s = n_, h = n_ / 2; h; s >>= 1, h >>= 1)
        for (int l = 0; l < n; l += s)
            for (int i = 0; i < std::min(i, n - l - h); i++)
                a[l + h + i] -= a[l + i]
}

And an amazing thing about this, which I haven't quite figured out why, is that the order of the outermost loop above can be reversed and both functions can be merged into one:

void fwht_or(int n, int *a, int *A, int dir = 1) {
    std::copy(a, a + n, A);
    for (int s = 2, h = 1; s <= n; s <<= 1, h <<= 1)
        for (int l = 0; l < n; l += s)
            for (int i = 0; i < h; i++)
                A[l + h + i] += dir * A[l + i]
}

(Fast bitwise OR / set union convolution is sometimes aliased "Fast Mobius Transform" in Chinese CP community. Both are essentially the same.)

Bitwise AND convolution

The bitwise AND convolution

\{a\circledast b\}_i = \sum_{j\wedge k=i}a_jb_k
can be accelerated in a similar way.

(Actually, by de Morgan's Law we can always reduce an AND convolution to an OR convolution)

Note that AND also has this interesting property:

x \wedge z = z,y\wedge z = z \Leftrightarrow (x\wedge y)\wedge z=z
or in set notations:
Z\subseteq X,Z\subseteq Y \Leftrightarrow Z\subseteq(X\cap Y)
Thus, we can prove in a way similar to what we did in OR convolution that the transform
\mathcal{FWHT}\{a\}_i=\sum_{j\wedge i=i} a_j
can turn convolutions to element-wise multiplications.

We still adopt the same divide-and-conquer approach and continue to use the notations a, a^0,a^1,A,A^0,A^1.

Consider the first half of A, which can be expressed as A_{0|i}:

\begin{aligned}
A_{0|i}&=\sum_{j\wedge (0|i)=0|i}a_j \\
&= \sum_{(0|j)\wedge (0|i)=0|i}a^0_j + \sum_{(1|j)\wedge (0|i)=0|i}a^1_j \\
\end{aligned}
And by the properties of AND, both (0|j)\wedge (0|i)=0|i and (1|j)\wedge (0|i)=0|i simplify to j\wedge i=i. So by definition we get
A_{0|i}=A^0_i+A_i^1
Then consider the other half of A:
\begin{aligned}
A_{1|i}&=\sum_{j\wedge (1|i)=1|i}a_j \\
&= \sum_{(0|j)\wedge (1|i)=1|i}a^0_j + \sum_{(1|j)\wedge (1|i)=1|i}a^1_j \\
&= A_i^1
\end{aligned}
Together we have:
A=\left(A^0+A^1,A^1\right)
with the trivial recursion boundary A_0=a_0 when n=0.

This gives an efficient implementation very similar to fwht_or above:

void fwht_and(int n, int *a, int *A) {
    std::copy(a, a + n, A);
    for (int s = 2, h = 1; s <= n; s <<= 1, h <<= 1)
        for (int l = 0; l < n; l += s)
            for (int i = 0; i < h; i++)
                A[l + i] += A[l + h + i]
}

The inverse transform is simple as well. Let A=(A', A''), then

\begin{cases}
A^0=A'-A'' \\
A^1=A''
\end{cases}
The code:

void ifwht_and(int n, int *a, int *A) { 
    std::copy(A, A + n, a);
    // If n is guaranteed to be a power of 2 then we don't need n_ and the min(...) in the inner loop.
    int n_ = 1; while (n_ < n) n_ <<= 1; 
    for (int s = n_, h = n_ / 2; h; s >>= 1, h >>= 1)
        for (int l = 0; l < n; l += s)
            for (int i = 0; i < std::min(i, n - l - h); i++)
                a[l + i] -= a[l + h + i]
}

The order of the outermost loop can be reversed and we can also merge the functions above together.

Bitwise XOR convolution

The XOR operation does not have such nice property as

x\oplus z=z, y\oplus z=z\Leftrightarrow (x\oplus y)\oplus z=z
So accelerating the convolution
\{a\circledast b\}_i = \sum_{j\oplus k=i}a_jb_k
is not as straightforward as we did above.

We first introduce an auxiliary operation, define x \otimes y= \operatorname{popcount}(x\wedge y) \bmod 2, where \operatorname{popcount}(x) denotes the number of 1s in the binary representation of x.

Claim: The transformation below turns convolutions to element-wise multiplications:

\mathcal{FWHT}\{a\}_i=\sum_{j\otimes i=0} a_j - \sum_{j\otimes i=1} a_j
Proof:
\begin{aligned}
\mathcal{FWHT}\{a\}_i\cdot\mathcal{FWHT}\{b\}_i &= \left(\sum_{j\otimes i=0} a_j - \sum_{j\otimes i=1} a_j\right) \left(\sum_{k\otimes i=0} b_k - \sum_{k\otimes i=1} b_k\right) \\
&= \sum_{j\otimes i=0}\sum_{k\otimes i=0}a_jb_k +\sum_{j\otimes i=1}\sum_{k\otimes i=1}a_jb_k \\
&- \sum_{j\otimes i=1}\sum_{k\otimes i=0}a_jb_k - \sum_{j\otimes i=0}\sum_{k\otimes i=1}a_jb_k
\end{aligned}
How to simplify those terms?

Observe that by the definition of XOR we have

\operatorname{popcount}(x\oplus y) = \operatorname{popcount}(x)+\operatorname{popcount}(y)-2\operatorname{popcount}(x\wedge y)
So if we apply modulo 2 on both sides,
\operatorname{popcount}(x\oplus y) \equiv \operatorname{popcount}(x)+\operatorname{popcount}(y) \pmod 2
Plug in x=j\wedge i,y=k\wedge i and we get
\operatorname{popcount}((j\wedge i)\oplus (k\wedge i)) \equiv \operatorname{popcount}(j\wedge i)+\operatorname{popcount}(k\wedge i) \pmod 2
We are almost there. Apply the identity below, whose proof I simply omit here,
(j\wedge i)\oplus (k\wedge i)=(j\oplus k)\wedge i
(This is something good about bitwise operations: if you cannot prove an identity the smart way you can always fall back on the dumb method -- making a truth table)

We finally get

(j \oplus k)\otimes i \equiv j\otimes i+k\otimes i \pmod 2
(We are actually quite familiar with this if we remove the circles outside +s and \timess)

With this conclusion we can simplify the four terms above:

\begin{aligned}
\mathcal{FWHT}\{a\}_i\cdot\mathcal{FWHT}\{b\}_i &= \cdots \\
&= \sum_{(j\oplus k)\otimes i=0}a_jb_k - \sum_{(j\oplus k)\otimes i=1}a_jb_k \\
&=  \mathcal{FWHT}\{a\circledast b\}_i
\end{aligned}
which completes the proof.

We then explore how to compute \mathcal{FWHT}\{a\} efficiently. Divide and conquer is still our friend, and dividing a based on the highest bit works here so we continue to use those notations.

Consider the first half of A...

\begin{aligned}
A_{0|i} &= \sum_{j\otimes (0|i)=0} a_j - \sum_{j\otimes (0|i)=1} a_j \\
&= \sum_{(0|j)\otimes (0|i)=0} a_j^0+  \sum_{(1|j)\otimes (0|i)=0}a_j^1 -  \sum_{(0|j)\otimes (0|i)=1} a_j^0-  \sum_{(1|j)\otimes (0|i)=1}a_j^1 \\
&= \sum_{j\otimes i=0} a_j^0+  \sum_{j\otimes i=0}a_j^1 -  \sum_{j\otimes i=1} a_j^0-  \sum_{j\otimes i=1}a_j^1 \\
&= A_i^0
+A_i^1\end{aligned}
and the other half:
\begin{aligned}
A_{1|i} &= \sum_{j\otimes (1|i)=0} a_j - \sum_{j\otimes (1|i)=1} a_j \\
&= \sum_{(0|j)\otimes (1|i)=0} a_j^0+  \sum_{(1|j)\otimes (1|i)=0}a_j^1 -  \sum_{(0|j)\otimes (1|i)=1} a_j^0-  \sum_{(1|j)\otimes (1|i)=1}a_j^1 \\
&= \sum_{j\otimes i=0} a_j^0+  \sum_{j\otimes i=1}a_j^1 -  \sum_{j\otimes i=1} a_j^0-  \sum_{j\otimes i=0}a_j^1 \\
&= A_i^0 - A_i^1
\end{aligned}
So together we get
A=\left(A^0+A^1,A^0-A^1\right)
and the inverse transform
A=(A',A'') \Rightarrow \begin{cases}
\displaystyle A^0=\frac{A'+A''}{2} \\
\displaystyle A^1=\frac{A'-A''}{2}
\end{cases}
The code for both transforms are a bit longer than those for OR and AND, but not by too much:

void fwht_xor(int n, int *a, int *A) {
    std::copy(a, a + n, A);
    for (int s = 2, h = 1; s <= n; s <<= 1, h <<= 1) {
        for (int l = 0; l < n; l += s) {
            for (int i = 0; i < h; i++) {
                int t = A[l + h + i];
                A[l + h + i] = A[l + i] - t;
                A[l + i] += t;
            }
        }
    }
}
void ifwht_xor(int n, int *a, int *A) {
    std::copy(A, A + n, a);
    // If n is guaranteed to be a power of 2 then we don't need n_ and the min(...) in the inner loop.
    int n_ = 1; while (n_ < n) n_ <<= 1; 
    for (int s = 2, h = 1; s <= n; s <<= 1, h <<= 1) {
        for (int l = 0; l < n; l += s) {
            for (int i = 0; i < std::min(i, n - l - h); i++) {
                int t = a[l + h + i];
                a[l + h + i] = (a[l + i] - t) / 2;
                a[l + i] = (a[l + i] + t) / 2;
            }
        }
    }
}

They can be merged as well:

void fwht_xor(int n, int *a, int *A, bool inv = false) {
    std::copy(a, a + n, A);
    for (int s = 2, h = 1; s <= n; s <<= 1, h <<= 1) {
        for (int l = 0; l < n; l += s) {
            for (int i = 0; i < h; i++) {
                int t = A[l + h + i];
                A[l + h + i] = A[l + i] - t;
                A[l + i] += t;
                if (inv) A[l + h + i] /= 2, A[l + i] /= 2;
            }
        }
    }
}

This code above is what Wikipedia refers to as the authentic Fast Walsh-Hadamard Transform.

Some sidenotes

Note that though FFT and FWHT shares the same idea of divide and conquer, FWHT does not require n to be a power of 2 whereas FFT does. (Well actually neither of them "require" n to be a power of 2, but to apply FFT when n is not a power of 2 you either need to pad with 0s or you have to make your implementation really complicated).

Also, I just came to know that if we express WHT in the language of matrices and vectors, the matrix is called a Hadamard Matrix.

Another fact that I didn't quite understand is why the order of the inverse FWHT can be reversed.

For instance, when n=8, after fully dividing the sequence into individual elements, we first merge (0,1),(2,3),(4,5),(6,7), then we merge (0,1,2,3),(4,5,6,7) and finally (0,1,2,3,4,5,6,7). Naturally when we do the inverse transform we have to start with (0,1,2,3,4,5,6,7), recover (0,1,2,3),(4,5,6,7), then recover (0,1),(2,3),(4,5),(6,7) and then recover the individual elements. But the popular implementation seems to suggest that the inverse transformation algorithm works in another direction as well. I am now puzzled why this is true and currently I'm just taking this for granted. Perhaps I derived the inversion in a different way than others did? If you have a simple explanation please leave a comment :)

莫比乌斯反演是OI中应对数论问题的一大杀器,可以把很多以前需要脑子想的问题无脑化,把很多脑子想不通的问题机械化,非常方便。

狄利克雷卷积

对于函数f, g,定义它们的狄利克雷卷积f \times g为:

(f \times g)(n) = \sum_{d \mid n} f(d)g\left(\frac{n}{d}\right)

性质

积性

f, g为积性函数,那么f \times g继承它们的积性。

证明:n, m为互素正整数,则易证nm的全体约数可以不重不漏地表示为\{d_nd_m \mid d_n \mid n, d_m \mid m\}

因此:

\begin{aligned}
(f \times g)(nm) &= \sum_{d | n} f(d)g\left(\frac{nm}{d}\right) \\
&= \sum_{d_n\mid n} \sum_{d_m \mid m} f(d_nd_m)g\left(\frac{nm}{d_nd_m}\right)
\end{aligned}
n, m互素可知d_n, d_m互素以及\frac{n}{d_n}, \frac{m}{d_m}互素,结合f, g的积性:
\begin{aligned}
\begin{aligned}
(f \times g)(nm) &= \sum_{d_n\mid n} \sum_{d_m \mid m} f(d_n)f(d_m)g\left(\frac{n}{d_n}\right)g\left(\frac{m}{d_m}\right) \\
&= \sum_{d_n\mid n} f(d_n)g\left(\frac{n}{d_n}\right) \sum_{d_m \mid m} f(d_m)g\left(\frac{m}{d_m}\right) \\
&= (f \times g)(n) \cdot (f \times g)(m)
\end{aligned}
\end{aligned}
证毕。

交换律

f \times g = g \times f

证明:由于约数是成对出现的,显然。

结合律

(f \times g) \times h = f \times (g \times h)

证明:我们需要稍微改变一下下标的写法,然后就非常清楚了:

\begin{aligned}
\left[(f \times g) \times h \right](n) &= \sum_{ab = n} (f \times g)(a) h(b) \\
&= \sum_{ab = n} \sum_{cd = a}f(c)g(d) h(b) \\
&= \sum_{bcd = n} f(c)g(d)h(b) \\
&= \sum_{ce = n} f(c) \sum_{bd = e} g(d)h(b) \\
&= \sum_{ce = n} f(c) (g \times h)(b) \\
&= \left[f \times (g \times h)\right](n)
\end{aligned}

得证。

单位元

定义

\varepsilon(n) = [n = 1]
易证\varepsilon为狄利克雷卷积的单位元,也是一个积性函数,在之后的反演当中,我们也经常会把\varepsilon作为艾弗森括号[]的替代品,因为它具有更优良的性质,简单来说:
[f(x) = y] = \varepsilon\left(\frac{f(x)}{y}\right)

推论

关于狄利克雷卷积我们有两条非常重要的推论:

\begin{aligned}
\mu \times 1 &= \varepsilon \\
\varphi \times 1 &= I
\end{aligned}
当中1为值恒为1的常值函数,I(x) = x为恒等函数,易证这两个都是完全积性函数。

证明:我们先证明第一条,假设n > 1r个素因子,从当中挑去若干个相乘组成n不含平方素因子的约数(如果约数含平方素因子则约数的\mu0,可以直接忽略)则我们有:

\begin{aligned}
(\mu \times 1)(n) &= \sum_{i = 0}^r \binom{r}{i} (-1)^i \\
&= \sum_{i = 0}^r \binom{r}{i} (-1)^i\cdot 1^{r - i}
\end{aligned}
结合二项式定理,得到(\mu \times 1)(n) = (1-1)^r=0,而如果n = 1,手动展开得到(\mu \times 1)(1) = \mu(1) = 1。两种情况结合起来得到\mu \times 1 = \varepsilon,第一条推论得证。

对于第二条推论,我们将其展开:

\sum_{d \mid n} \varphi(i) = n
我们先考察n为素数(或其幂次)的情况,设n = p^k,则原式左边变为:
\begin{aligned}
\sum_{i = 0}^k \varphi(p^i) &= 1 +\sum_{i = 1} ^ k p^i\left(1 - {1 \over p}\right) \\
&= 1 + \left(1 - {1 \over p}\right)\sum_{i = 1} ^ k p^i \\
&= 1 + \frac{p - 1}{p} \cdot \frac{p^{k + 1} - p}{p - 1} \\
&= 1 + p^k - 1 \\
&= p^k
\end{aligned}
因此在n为素数或其幂次时推论得证,而我们知道\varphi \times 1继承了积性,因此我们可以利用积性把推论推广到n不为素数的情况(通过分解素因数即可),证毕。

莫比乌斯反演

定理:f \times 1 = g当且仅当f = g \times \mu

证明:本质上是狄利克雷卷积的推论的推论,先正着来一轮,两边同时乘以\mu

\begin{aligned}
f \times 1 &= g \\
\Rightarrow f \times 1 \times \mu &= g \times \mu \\
\Rightarrow f \times \varepsilon &= g \times \mu \\
\Rightarrow f &= g \times \mu \\
\end{aligned}
我们再反着来一轮,两边同时乘上1
\begin{aligned}
f &= g \times \mu \\
\Rightarrow f \times 1 &= g \times \mu \times 1 \\
\Rightarrow f \times 1 &= g \times \varepsilon \\
\Rightarrow f \times 1 &= g
\end{aligned}
(所以说不要迷行网上那种大力出奇迹几个\sum展开来回套的证明,看这个多简单)

虽然存在这个莫比乌斯反演定理,但是我们更多的时候还是习惯逆用狄利克雷卷积的那两个推论。

示例:\varphi(n)

\begin{aligned}
\varphi(n) &= \sum_{i = 1}^n [\gcd(i, n) = 1] \\
&= \sum_{i = 1}^n \varepsilon(\gcd(i, n)) \\
\end{aligned}

此时我们使用推论\varepsilon = \mu \times 1

\begin{aligned}
\varphi(n) &= \sum_{i = 1}^n \varepsilon(\gcd(i, n)) \\
&= \sum_{i = 1}^n \sum_{d \mid \gcd(i, n)} \mu(d) \\
&= \sum_{i = 1}^n \sum_{d \mid i, d \mid n} \mu(d) \\
\end{aligned}
接下来我们把d的求和拖到前面去,本质上是从对约数求和变为对倍数算贡献
\begin{aligned}
\varphi(n) &= \sum_{i = 1}^n \sum_{d \mid i, d \mid n} \mu(d) \\
&= \sum_{d \mid n} \mu(d) \sum_{i = 1, d \mid i}^n 1 \\
&= \sum_{d \mid n} \mu(d) \sum_{i = 1}^{n \over d} 1 \\
&= \sum_{d \mid n} \mu(d) \frac{n}{d} \\
&= \sum_{d \mid n} \mu(d) I\left(\frac{n}{d}\right) \\
&= (\mu \times id)(n)
\end{aligned}
因此我们证明了\varphi = \mu \times id,虽然这个结论是可以直接从莫比乌斯反演定理推得的,但是呢,这个例子确实展示了莫比乌斯反演的很多常用套路:

  1. 把式子写下来
  2. id或者\varepsilon修饰
  3. 逆用狄利克雷卷积的推论,把求和式后面反演成卷积形式
  4. 通过算贡献的方式设法把d的求和项连带着只和d有关的函数值一起拖到最前面
  5. 化简后半部分

这一路下来其实是非常套路的,但往往效果拔群。

示例:\sum_{i = 1}^n \varphi(i)

以下为了方便起见,如果出现n, m,皆假设n \le m

我们试试看刚刚学到的套路,来反演欧拉函数的前缀和:

\begin{aligned}
\sum_{i = 1}^n \varphi(i) &= \sum_{i = 1}^n \sum_{j = 1}^i [\gcd(i, j) = 1] \\
&= \sum_{i = 1}^n \sum_{j = 1}^i \varepsilon(\gcd(i, j) = 1) \\
&= \sum_{i = 1}^n \sum_{j = 1}^i \sum_{d \mid \gcd(i, j)} \mu(d) \\
&= \sum_{i = 1}^n \sum_{j = 1}^i \sum_{d \mid i, d \mid j} \mu(d) \\
&= \sum_{d = 1}^n \mu(d) \sum_{i = 1, d \mid i}^n \sum_{j = 1, d \mid j}^i 1\\
&= \sum_{d = 1}^n \mu(d) \sum_{i = 1}^{\left\lfloor \frac{n}{d} \right\rfloor} \sum_{j = 1}^i 1\\
&= \sum_{d = 1}^n \mu(d) \sum_{i = 1}^{\left\lfloor \frac{n}{d} \right\rfloor} i\\
&= \sum_{d = 1}^n \mu(d) \frac{\left\lfloor \frac{n}{d} \right\rfloor \left(\left\lfloor \frac{n}{d} \right\rfloor + 1\right)}{2}\\
&= \frac{1}{2} \sum_{d = 1}^n \mu(d) \left\lfloor \frac{n}{d} \right\rfloor \left(\left\lfloor \frac{n}{d} \right\rfloor + 1\right)
\end{aligned}
嗯,很好,很强大,但是这个有什么用呢?

示例:\sum_{i = 1}^n \sum_{j = 1}^n [\gcd(i, j) = 1]

这个式子对应POJ3090,即有一个n \times n个格点,问从最左下角能够看到几个格点,我们发现如果格点(i, j)不互素的话,它一定不会被看到,因此题目就被转化成了我们要求的式子,常规做法是通过把格点劈成三角形,进行转化得到答案为2\sum_{i = 1}^n \varphi(n) - 1,但是这毕竟还需要一点智商,而如果你莫比乌斯反演熟练的话,这个东西推起来是非常机械化的:

\begin{aligned}
\sum_{i = 1}^n \sum_{j = 1}^n [\gcd(i, j) = 1] &= \sum_{i = 1}^n \sum_{j = 1}^n \varepsilon(\gcd(i, j) = 1)\\
&= \sum_{i = 1}^n \sum_{j = 1}^n \sum_{d \mid i, d \mid j} \mu(d)\\
&= \sum_{d = 1}^n \mu(d) \sum_{i = 1, d \mid i}^n \sum_{j = 1, d \mid j}^n 1 \\
&= \sum_{d = 1}^n \mu(d) \sum_{i = 1}^{\left\lfloor \frac{n}{d} \right\rfloor} \sum_{j = 1}^{\left\lfloor \frac{n}{d} \right\rfloor} 1 \\
&= \sum_{d = 1}^n \mu(d) \left\lfloor \frac{n}{d} \right\rfloor^2 \\
&= 2 \left( \frac{1}{2} \sum_{d = 1}^n \mu(d) \left\lfloor \frac{n}{d} \right\rfloor \left(\left\lfloor \frac{n}{d} \right\rfloor + 1\right) \right) - \sum_{d = 1}^n \mu(d) \left\lfloor \frac{n}{d} \right\rfloor \\
&= 2\sum_{i = 1}^n \varphi(i) - \sum_{d = 1}^n \mu(d) \left\lfloor \frac{n}{d} \right\rfloor
\end{aligned}
那么\sum_{d = 1}^n \mu(d) \left\lfloor \frac{n}{d} \right\rfloor又是什么呢?我们借着套路反着反演:
\begin{aligned}
\sum_{d = 1}^n \mu(d) \left\lfloor \frac{n}{d} \right\rfloor &= \sum_{d = 1}^n \mu(d) \sum_{i = 1, d \mid i}^n 1 \\
&= \sum_{i = 1}^n \sum_{d \mid i} \mu(d) \\
&= \sum_{i = 1}^n (\mu \times 1)(d) \\
&= \sum_{i = 1}^n \varepsilon(i) \\
&= 1
\end{aligned}
原来这个是1啊,那么带回去我们就得到了:
\sum_{i = 1}^n \sum_{j = 1}^n [\gcd(i, j) = 1] = 2\sum_{i = 1}^n \varphi(i) - 1
以上推导中我们完全没有用到\varphi, \mu的数学意义,而是顺着我们推导出的狄利克雷卷积的规则和推论进行着机械化的推导,最后也殊途同归,也许有些人会觉得这样没有卵用,那么试解:
\sum_{i = 1}^n \sum_{j = 1}^m [\gcd(i, j) = 1]
借助图形和数学意义的人看到这个式子大概就去世了,但是如果我们利用反演的话,类似以上的过程可以推得:
\sum_{i = 1}^n \sum_{j = 1}^m [\gcd(i, j) = 1] = \sum_{d = 1}^n \mu(d) \left\lfloor \frac{n}{d} \right\rfloor \left\lfloor \frac{m}{d} \right\rfloor
结合整除分块和O(n)预处理莫比乌斯函数的前缀和我们就可以在O(\sqrt n)的时间里回答每一个询问,这就是莫比乌斯反演的强大之处。

示例:\sum_{i = 1}^n \sum_{j = 1}^m \gcd(i, j)

观察这个式子,我们发现和上面实例中式子的唯一不同就是我们把\varepsilon换成了id,对应下来就是\mu换成了\varphi,因此我们可以得到:

\sum_{i = 1}^n \sum_{j = 1}^m \gcd(i, j) = \sum_{d = 1}^n \varphi(d) \left\lfloor \frac{n}{d} \right\rfloor \left\lfloor \frac{m}{d} \right\rfloor
依然是O(n)预处理O(\sqrt n)查询!

什么是主对偶方法?

对于所有可以写成整数规划/线性规划形式的问题(不妨假设是最小化问题),由对偶定理可得,对偶最优解一定是\mathrm{OPT}的一个下界。

因此,对偶可行解也是\mathrm{OPT}的一个下界。

所谓的主对偶方法(Primal-dual Method),就是从一个对偶规划的可行解出发,不断优化这个对偶可行解,并在这个过程中利用对偶规划的特性指引我们求得原问题的解。

太抽象?

叕访顶点覆盖

(顶点覆盖问题属实牛逼嗷,那么多近似方法都可以用)

顶点覆盖问题的主线性规划是

\begin{aligned}
    \text{minimize}\quad &\sum_{v\in V}w_vx_v \\
    \text{subject to}\quad &x_u+x_v\ge 1\quad \forall (u, v)\in E \\
    &x_v \in \{0,1\}\quad \forall v\in V
\end{aligned}
其对偶规划是
\begin{aligned}
    \text{maximize}\quad &\sum_{v\in E} y_e \\
    \text{subject to}\quad &\sum_{v\in e} y_e \le w_v \quad \forall v \in V\\
    &y_e \ge 0\quad \forall e\in E
\end{aligned}
显然,y_e=0就是一个平凡的对偶可行解。基于主对偶方法的思想,我们可以得到两个算法:

算法1

  1. 初始化y_e\gets0,C\gets \emptyset
  2. 当还有一条边(u,v)没有被覆盖时,
    1. \varepsilon\gets \min\left\{w_u-\sum_{u\in e} y_e,w_v-\sum_{v\in e} y_e\right\}
    2. y_e\gets y_e+\varepsilon(以上两步旨在尽可能增加y_e直至两端点中一个点对应的约束收紧)。
    3. 假设增加y_e之后u对应的约束收紧了,那么C\gets C\cup\{u\}
    4. 同时删除所有与u邻接的边。
  3. 返回C

(注意到,当w_u=1时,这个算法退化为我们最初学的极大匹配算法)

算法2

  1. 初始化y_e\gets0,C\gets \emptyset
  2. C不是一个合法的顶点覆盖时,
    1. \varepsilon\gets \min_{v\in V} \left\{\frac{1}{\deg(v)}\sum_{v\in e}y_e\right\}
    2. 对于所有边e\in Ey_e\gets y_e+\varepsilon(以上两步旨在同时增加所有y_e直至有约束收紧)。
    3. 将所有紧约束对应的顶点加入C
    4. 同时删去这些点和它们邻接的所有边。
  3. 返回C

其实还有很多这样的寻找局部极大对偶可行解的方法,以上两个算法是最容易想到的。

分析

两个算法的分析是共通的:

\begin{aligned}
    \sum_{v\in C}w_v &= \sum_{v \in C}\sum_{v\in e\in E} y_e \\
    &= \sum_{e\in E} y_e |C\cap e| \\
    &\le \sum_{e\in E} y_e \cdot 2 \\
    &= 2 \cdot \mathrm{OPT}_{\text{dual}} \\
    &\le 2 \cdot \mathrm{OPT}
\end{aligned}
以上步骤的第一步是因为我们加入覆盖集的所有顶点对应的约束都是紧的。

主对偶方法神奇的一点就在于:虽然我们在线性规划的指导下进行设计与分析,但是最后的算法却是一个完全不需要求解线性规划的纯粹的组合算法(Combinatorial Algorithm),因此非常优雅。

再探设施选址

回忆之前设施选址的线性规划。y_i表示设施i是否被选,x_{ij}表示用户j是否到设施i去:

\begin{aligned}
    \text{minimize}\quad &\sum_{i\in F}f_iy_i + \sum_{j\in D}\sum_{i \in F}c_{ij}x_{ij} \\
    \text{subject to}\quad &\sum_{i\in F}x_{ij} \ge 1 \quad \forall j\in D \\
    &y_i - x_{ij} \ge 0 \quad \forall i\in F, j\in D \\
    &x_{ij} \ge 0 \quad \forall i\in F, j\in D \\
    &y_i \ge 0 \quad \forall i \in F
\end{aligned}
其对偶为
\begin{aligned}
    \text{maximize}\quad &\sum_{j \in D}\alpha_j \\
    \text{subject to}\quad &\alpha_j - \beta_{ij} \le c_{ij}\quad \forall i \in F, j\in D \\
    &\sum_{j\in D}\beta_{ij} \le f_i\quad \forall i\in F
\end{aligned}

以下将对偶规划中第一行的约束称为“第一类约束”,第二行的约束称为“第二类约束”。

算法

我们设计一个基于主对偶方法的近似算法:

  1. 初始化\alpha_j\gets 0,\beta_{ij}\gets 0(平凡的对偶可行解)。
  2. 以相同的速率同时增加的值\alpha_j
  3. 当对于某些i,j出现\alpha_j=c_{ij}时,为了能够在不违反第一类约束的前提下让\alpha_j继续增加,我们开始同步增加\beta_{ij}的值。
  4. 随着一些\beta_{ij}值的增加某些第二类约束也会收紧,此时暂时开放该约束对应的设施。因为此时对应的\beta_{ij}不能再增加了,那些\alpha_j也自然不会再增加了,我们就此将这些\alpha_j的值冻结起来(其余的\alpha_j可以继续增加)。
  5. 不断重复以上步骤直至所有的\alpha_j都被冻结了。
  6. 按照第4步开放的先后顺序处理所有暂时开放的设施,
    1. 永久开放该设施,
    2. 并将所有和该设施共享一个“紧顾客”(tight client,指和设施对应约束是紧的顾客,这里实在是不怎么好翻,具体的解释参看下面的感性认识和接下来的分析),即那些两边的\beta都大于0的顾客的暂时开放的设施关闭(移出队列)。

听听这是人话吗?感性理解一下以上算法(也有助于接下来的分析):

  1. \alpha_j可以理解为每个顾客为了得到服务愿意付出的代价,一开始所有人都想白嫖。
  2. 显然如果大家都想白嫖的话没有一个人会得到服务,因此大家一起提升这个愿意付出的代价。
  3. \alpha_j=c_{ij}也就是某个顾客发现自己愿意支付的价位内出现了设施的时候他就很高兴。但是这个设施没开,所以顾客决定给这个设施的开放分摊成本(增加\beta_{ij}),但是这样也不是个事,万一不远处有个不需要他付钱或者只需要分摊很少开张费用的设施呢?所以\alpha_j的增长也不能落下。
  4. 当有第二类约束收紧,即几个顾客终于凑够了钱让这个设施开放,那么这个设施就暂时开张了。这几个用户很满足,就准备去这个设施了,所以他们的\alpha_j不增加了。
  5. 不断重复以上步骤直至满足所有顾客的需求。
  6. 按照第4步开放的先后顺序处理暂时开张的设施,
    1. 永久开张,
    2. 如果去这个设施的顾客里有人给其他设施也分摊成本了,那把那些设施全部关掉。

(如果我把上面变量的名字换一下,把“第一类约束”,“第二类约束”也换个名字,那么从上面的算法当中你根本看不到线性规划的痕迹,这就是一个听着很有道理实际上近似比也有保证的组合算法。这就是主对偶方法的优雅之处。)

分析

我们不妨称那些为最后永久开放的设施垫付开张成本的顾客为“幸运顾客”,形式上地来说,一个顾客j为幸运顾客当且仅当最后永久开张的设施中有一处设施i使得\alpha_j-\beta_{ij}=c_{ij},\beta_{ij}>0,也就是说顾客j和设施i之间在对偶规划对应的约束是紧的——这也是上面tight client的本意。

相对地我们称不是幸运顾客的那些顾客“倒霉顾客”,说他们倒霉是因为他们明明给某些设施分摊了开张成本,也都准备好去那里了,结果这个设施在最后关掉了。

不妨设最后决定开张的设施为F',幸运顾客的集合为Lj \to i表示顾客j到设施i的约束是紧的(也就是顾客j给设施i分摊了成本)。注意由于我们算法最后一步的清理操作,每个幸运顾客只可能垫付一个F中设施的成本,那么设施的开放费用和幸运顾客的服务成本可以一起表示为:

\begin{aligned}
    &\quad\,\sum_{i\in F'}f_i+\sum_{j\in L} \sum_{\substack{i\in F'\\ j\to i}} c_{ij}\\
    &= \sum_{i \in F'}\sum_{\substack{j\in L\\ j\to i}} \beta _{ij}+\sum_{j\in L} \sum_{\substack{i\in F'\\ j\to i}} \left(\alpha_j - \beta_{ij}\right) \\
    &=\sum_{j \in L} \alpha_{j}
\end{aligned}
我们接下来分析倒霉顾客的情况。

假设有一个倒霉顾客k,他分摊了设施b的开张成本并最终让设施b暂时开张(同时假设b是他分摊成本的设施中最先开张的——也就是\alpha_k恰在b开张后被冻结)。但是比b早开张的设施a中有一个幸运顾客j也为b分摊了成本,导致最后清理的时候b被关闭了。不妨假设k最后只能去a。注意到由三角不等式,

\begin{aligned}
c_{ak} &\le c_{aj} + c_{bj} +c_{bk} \\
&\le (\alpha_j-\beta_{aj}) + (\alpha_j - \beta_{bj}) + (\alpha_{k}-\beta_{bk}) \\
&\le \alpha_j+\alpha_j + \alpha_k \\
&\le 3\alpha_k
\end{aligned}
最后一步之所以成立,是因为a开张后\alpha_j必然冻结,\alpha_k停止增长要等到b开张后。而ab先开张,因此\alpha_k \ge \alpha_j。我们在上面假设k最后去了a,其实如果最后k去了比a还近的那自然他的服务成本更低了。

因此倒霉顾客的服务成本\le \sum_{k\in D\setminus L} \alpha_k

结合上述的分析,我们可以得到我们的总成本:

\begin{aligned}
    \text{total cost} &\le \sum_{j\in L} \alpha_j + \sum_{k\in D\setminus L} 3\alpha_k \\
    &\le 3\sum_{j\in D}\alpha_j \\
    &\le 3\cdot\mathrm{OPT}
\end{aligned}
也就是说,我们证明了我们这个算法的近似比为3

为什么最后一步要清理一些暂时开张的设施?

为什么我们算法的最后一步要清理暂时开张的设施呢?为什么不能一些用户分摊够开张费用就让这个设施永久开张下去呢?

考虑如下的一个实例:

n个设施,其中f_1=n+1f_i=n+2\quad\forall i =2,3,\cdots,n

2n个顾客。前n名顾客到所有设施的距离都是1。对于后面的n名顾客,第n+i名顾客只和设施i的距离为1,和其余设施的距离都为3。易证这样的边权满足三角不等式。

现在考察我们算法的运行情况:

  • t=0,初始化。
  • t=1,所有的\alpha_j=1,每名顾客开始为至少一个设施分摊成本了。
  • t=2,对于任何一个设施都有n+1名顾客为其人均分摊了1的开张成本,而注意到f_1=n+1,因此设施1开张了,相应地,前n名顾客和第n+1名顾客的\alpha_j被冻结。其余的n-1名顾客继续为对应的设施分摊费用。
  • t=3,剩下的n-1家设施全部开张。

如果没有最后的清理步骤,那么我们的算法会开放所有的设施。光是开放设施的代价就有n+1+(n-1)(n+2)=\Omega(n^2)。而只开一家设施的代价是O(n)的。因此最后的清理步骤非常重要!

定义

输入:二分图G,顶点集分为设施集F和客户集D。设施i的开放费用为f_i,用户j到设施i的有权为c_{ij}的边代表距离。边权满足三角不等式。

目标:寻找一组设施S,使得

\sum_{i \in S} f_i + \sum_{j\in D} \min_{i \in S} \{c_{ij}\}
即每个用户都会去离自己最近(或者说最划算)设施,求最小化开店费用加上服务成本(即每个顾客要走的距离之和)的开店方案。

线性规划表述

y_i表示设施i是否被选,x_{ij}表示用户j是否到设施i去,则设施选址问题可以表述为下列线性规划(松弛整数限制):

\begin{aligned}
    \text{minimize}\quad &\sum_{i\in F}f_iy_i + \sum_{j\in D}\sum_{i \in F}c_{ij}x_{ij} \\
    \text{subject to}\quad &\sum_{i\in F}x_{ij} \ge 1 \quad \forall j\in D \\
    &y_i - x_{ij} \ge 0 \quad \forall i\in F, j\in D \\
    &x_{ij} \ge 0 \quad \forall i\in F, j\in D \\
    &y_i \ge 0 \quad \forall i \in F
\end{aligned}
其中第一个约束表示所有顾客至少要去一个设施。第二个约束表示只有一个设施开的时候用户才可以去。

其对偶为

\begin{aligned}
    \text{maximize}\quad &\sum_{j \in D}\alpha_j \\
    \text{subject to}\quad &\alpha_j - \beta_{ij} \le c_{ij}\quad \forall i \in F, j\in D \\
    &\sum_{j\in D}\beta_{ij} \le f_i\quad \forall i\in F
\end{aligned}

(x^*,y^*)(\alpha^*,\beta^*)为主规划和对偶规划的最优解。显然,在线性规划当中很可能出现0<x^*_{ij}<1即“部分得到服务”的情况。

近似算法的设计思路与分析

引理:如果x_{ij}^* > 0那么\alpha_j^* \ge c_{ij}

证明:由主互补松弛条件,若x_{ij}^*>0\alpha_j^*-\beta_{ij}^* = c_{ij},又\beta_{ij}^* \ge 0,得证。

定义顾客j所有部分光顾的设施为j的邻域N(j)=\left\{ i \Big| x_{ij}^* > 0,i\in F\right\}

很容易想到一个近似算法:对于每一个顾客,我们开放邻域中离他最近的设施

由于我们的引理和弱对偶原理,我们的总服务成本\le \sum_{j\in D}\alpha_j^* \le \mathrm{OPT}。如果我们开放设施的成本也有个上界就好了。

然而并不行。

考虑如下反例:

如果按照我们的算法,我们会为jj'分别开放一个代价24的设施。而事实上开那个代价25的设施就够了。我们差不多浪费了两倍的钱,而这个倍数可以任意高(只要让代价稍高一点的设施处在所有顾客的邻域当中,而使每个顾客的邻域中都有比其代价略低的设施即可)。

但是我们知道,对于任何一组邻域不交的顾客,开放设施的代价确实是不大于\mathrm{OPT}(为什么?因为那些设施只服务一个顾客,而那个顾客有可能“部分”光顾其他设施。比如说一个顾客光顾了0.5的设施1(代价为10)和0.5的设施2(代价为20),那么这个顾客就给目标函数贡献了0.5\times10+0.5\times 20,显然这个时候如果我去开代价10的那个设施,开店费是10,是小于最优目标函数的)

那我不妨先任选一组邻域不交的顾客(不妨称之为“幸运顾客”)按照就近原则开放他们的设施,然后再让那些邻域和幸运顾客有交集的顾客(不妨称之为“倒霉顾客”)去这些设施。

根据我们之前的分析,幸运顾客的服务成本一定不大于\mathrm{OPT},考虑倒霉顾客的服务成本。假设倒霉顾客j和幸运顾客i的邻域共享一个设施b,幸运顾客可以去离他最近的设施a

那么根据三角不等式,让ja的距离为

c_{aj} \le c_{bj}+c_{bi}+c_{ai}
根据引理,
\begin{aligned}
c_{aj} &\le c_{bj}+c_{bi}+c_{ai} \\
&\le \alpha_j^* + \alpha_i^* + \alpha _i^* \\
&\stackrel{?}{\le} 3\alpha_j^* 
\end{aligned}
最后一步是我们希望得出的,因为如果这样,那么所有顾客的服务成本的上界就是(不妨令幸运顾客的集合为D_1,不幸运顾客的集合为D_2):
\begin{aligned}
    \sum_{j\in D_1} \alpha_j^* + \sum_{j\in D_2}3\alpha_j^* \le \sum_{j\in D_1}3 \alpha_j^* + \sum_{j\in D_2}3\alpha_j^* = \mathrm{OPT}_{\text{dual}} \le \mathrm{OPT}
\end{aligned}
但是按照目前的信息最后一步并不总是成立的。

那怎么办?改算法呗。我们只需要保证所有幸运顾客的\alpha^*都小于等于倒霉顾客的\alpha^*,也就是说排一下序就好了。

于是我们就得到了我们的算法:

  1. 解原线性规划和对偶线性规划,得到(x^*,y^*),(\alpha^*,\beta^*)
  2. 选一个\alpha^*最小的顾客并开放他邻域当中离他最近的店。
  3. 暂时移除所有和这个顾客有相交邻域的顾客。
  4. 持续以上两步直至所有顾客都被选中或移除。
  5. 让所有被移除的顾客去离他们最近的已开放设施。

这个算法的近似是多少呢?上面的分析已经说明了这个方案的设施成本不大于\mathrm{OPT},而服务成本不大于3\mathrm{OPT},因此总的来说这就是一个4倍近似算法。

近似难度

定理(Guha, Khuller 1999):如果设施选址问题存在近似比小于1.463的算法,则\mathsf{P}=\mathsf{NP}

用对偶规划解顶点覆盖

回顾一下,顶点覆盖问题的主线性规划(松弛后)是

\begin{aligned}
    \text{minimize}\quad &\sum_{v\in V}w_vx_v \\
    \text{subject to}\quad &x_u+x_v\ge 1\quad \forall (u, v)\in E \\
    &x_v \in \{0,1\}\quad \forall v\in V
\end{aligned}
其对偶规划是
\begin{aligned}
    \text{maximize}\quad &\sum_{v\in E} y_e \\
    \text{subject to}\quad &\sum_{v\in e} y_e \le w_v \quad \forall v \in V\\
    &y_e \ge 0\quad \forall e\in E
\end{aligned}
我们在此设计一个通过解对偶规划来求得原问题解的近似算法。依赖于对偶规划的好处是:在分析过程中我们必须给\mathrm{OPT}一个下界,而由对偶规划的定义,对偶规划的目标函数值就是一个非常好的下界,因此基于对偶规划的近似算法在分析上是比较便利的。

假设我们已经求得了对偶规划的最优解,如何以此确定原规划的变量取值呢?回忆我们在学对偶时学到的互补松弛条件

若主线性规划最优解中一变量非零,则其对偶规划中对应约束收紧。

其逆命题为

若对偶规划中对应约束收紧,则主规划中对应变量非零。

虽该命题未必正确,但给我们设计算法提供了思路。我们的算法就是:

  1. 求取对偶规划的最优解。
  2. 对于最优解中每一个紧约束,将其在主规划中对应的顶点加入顶点覆盖。

接下来我们分析一下这个算法:

引理:我们给出的顶点覆盖是可行的。

证明:不妨假设我们给出的顶点覆盖不合法,即有一条边e=(u,v)没有被覆盖到。那么由我们的算法可知u,v对应的对偶约束在对偶最优解中是松的,而既然如此为什么不增加y_e使其一收紧,同时增加目标函数的值呢?显然这和“我们求得对偶规划的最优解”是矛盾的。

定理:我们的算法是一个2倍近似算法。

证明:假设我们算法给出的覆盖集为C,则我们顶点覆盖的总代价就是\sum_{v\in C}w_v,而由我们算法选取顶点的条件,有

\begin{aligned}
    \sum_{v\in C}w_v &= \sum_{v \in C}\sum_{v\in e\in E} y_e \\
    &= \sum_{e\in E} y_e |C\cap e| \\
    &\le \sum_{e\in E} y_e \cdot 2 \\
    &= 2 \cdot \mathrm{OPT}_{\text{dual}} \\
    &\le 2 \cdot \mathrm{OPT}
\end{aligned}
(正如之前所说,对偶规划的目标函数给我们提供了\mathrm{OPT}的一个极佳的下界)

得证。

(后记:其实对偶解的最优性在这里并没有用到,是不是每一组对偶规划的极大解都可以呢?)

单机任务调度

定义

输入:一台机器n个任务,第i个任务的发布时间是r_i,耗时p_i

目标:设第i个任务的完成时刻为C_i,要求制定一个非竞争性(non-preemptive)的任务调度方案,使得\sum_{i=1}^nC_i最小。所谓非竞争性,是指一个任务一旦开始就不能被其他任务所打断,不能够一个任务做一会暂停再去做另一个任务,与其相对的成为竞争性调度。

思路与算法

我们不妨先考虑如果允许竞争性调度,我们应该怎么办呢?

一个显然的策略是不断选取剩余耗时最短的任务优先完成,这样其他任务等待的时间最少。

注意到,一个非竞争性的调度方案可以在允许竞争的情况下应用,而反之未必然。因此,若设C_i^{\text P}为在最优竞争性调度中任务i的结束时刻,那么就有

\sum_{i=1}^n C^{\text P}_i \le \mathrm{OPT}
一个竞争性的方案可以被转化为一个非竞争性的方案吗?这就是我们的算法:

  1. 求解在允许竞争的情况下的最优调度方案。
  2. 按照任务在竞争性最优调度中结束的顺序确定我们解当中各任务的执行顺序,并基于这个顺序给出我们非竞争性的调度方案。

分析

C_i^{\text N}为我们的非竞争性调度中任务i的结束时刻,那我们方案的代价就是\sum_{i=1}^n C^{\text N}_i

接下来假设我们已经按照任务在竞争性最优调度当中结束的顺序给任务排了序。

注意到

C^{\text N}_i \le \max_{1 \le k \le i} \{r_k\}+ \sum_{k=1}^i p_k
这是显然的,因为不等式的右边描述了一种可行的调度方案:等前i个任务全部发布后再连续把它们做完,而我们的方案不会比这个可行方案差。

令任务l为前i个任务当中最晚发布的那个任务,即r_l = \max_{1\le k \le i}\{r_k\}。因为我们是按照竞争性最优方案中任务结束的顺序调度的,因此C_i^{\text P} \ge C_l^{\text P}。常识告诉我们C_l^{\text P} \ge r_l,因此C_i^{\text P} \ge r_l。而显然又有C_i^{\text P} \ge \sum_{k=1}^i p_k,因此

C^{\text N}_i \le \max_{1 \le k \le i} \{r_k\}+ \sum_{k=1}^i p_k \le C_i^{\text P} + C_i ^{\text P} = 2C_i^{\text P}
因此
\sum_{i=1}^n C^{\text N}_i \le 2\sum_{i=1}^n C^{\text P}_i \le 2\cdot \mathrm{OPT}
所以我们的算法是一个2倍近似算法。

拓展:任务带权

我们现在对于这个问题进行拓展,每个任务都有一个权重w_i,我们要最小化\sum_{i=1}^nw_iC_i

对于这个问题,我们上面的思路就行不通了:我们上面的算法建立在可以最优求解竞争性调度的基础上,而可以证明,即使允许竞争,求解带权任务调度也是一个NP hard问题。

因此我们必须换一个思路,考虑给我们的问题放宽一下条件,再以这个松弛的问题为基础建立一个线性规划(LP-relaxation)。很容易就能想到如下的线性规划:

\begin{aligned}
    \text{minimize}\quad &\sum_{i=1}^n w_iC_i \\
    \text{subject to}\quad &C_i \ge r_i + p_i\quad \forall i=1,2,\cdots,n \\
    &C_i \ge 0\quad \forall i=1,2,\cdots,n
\end{aligned}
可是这个线性规划一看就松到了一个扯淡的地步。是个人就知道这个规划的最优解就是C_i=r_i+p_i,我们完全不能够指望这个破规划给我们任何有意义的结果或指引我们进行算法设计。我们还要加点条件——多多少少要体现出任务的非竞争性吧?

于是就有了这个加强版的线性规划:

\begin{aligned}
    \text{minimize}\quad &\sum_{i=1}^n w_iC_i \\
    \text{subject to}\quad &C_i \ge r_i + p_i\quad \forall i=1,2,\cdots,n \\
    & \sum_{i\in S}p_iC_i \le \frac{1}{2}p(S)^2 \quad \forall S\subseteq \{1,2,\cdots. m\}\\
    &C_i \ge 0\quad \forall i=1,2,\cdots,n
\end{aligned}
其中p(S) = \sum_{i \in S} p_i

这个第二个约束是怎么一回事呢?考虑所有任务都在一开始就发布,然后任务时间无缝衔接的情况。此时一个任务的完成时间就等于之前所有任务耗时之和。为方便起见,不妨令S=\{p_1,p_2,p_3,\cdots,p_{|S|}\},于是有

\begin{aligned}
    \sum_{i = 1}^{|S|} p_iC_i &=  \sum_{i = 1}^{|S|} p_i\sum_{j=1}^i p_j \\
    &= \sum_{i = 1}^{|S|} p_i^2 +\sum_{i = 1}^{|S|} p_i\sum_{j=1}^{i-1} p_j \\
    &= \frac{1}{2}\left(\sum_{i = 1}^{|S|} p_i^2 + 2\sum_{i = 1}^{|S|} p_i\sum_{j=1}^{i-1} p_j\right) + \frac{1}{2}\sum_{i = 1}^{|S|} p_i^2 \\
    &= \frac{1}{2}\left(\sum_{i = 1}^{|S|} p_i\right)^2+ \frac{1}{2}\sum_{i = 1}^{|S|} p_i^2 \\
    &\ge \frac{1}{2}p(S)^2
\end{aligned}

若嫌代数证明太不直观,则下图在几何上说明了这一点:

在实际的调度当中,几个任务未必无缝衔接,因此C_i \ge \sum_{j=1}^i p_j,而我们的证明依然成立。因此对于可行的非竞争性调度,以上约束总是成立。虽反之不然,却也比一开始那个过于简陋的线性规划更多地刻画了“非竞争性”的特点。对于我们的算法来说,这个线性规划已经足够。我们的算法为:

  1. 求解以上线性规划。
  2. 假设给出的解为C_i^*,我们按照C_i^*从小到大来确定各任务在我们调度方案中的顺序。

接下来我们分析一下这个算法。因为我们的线性规划对原问题进行松弛,原问题的解一定是线性规划的一组可行解,因此

\sum_{i=1}^n w_iC_i^* \le \mathrm{OPT}
回顾我们在不带权情形的算法分析中用到的
C^{\text N}_i \le \max_{1 \le k \le i} \{r_k\}+ \sum_{k=1}^i p_k
若令S=\{1,2,3,\cdots,i\},则上式可以写作
C^{\text N}_i \le \max_{1 \le k \le i} \{r_k\}+ p(S)
结合线性规划的第一个约束:
C^{\text N}_i \le C_i^*+ p(S)
线性规划的第二个约束告诉我们
p(S)^2 \le 2\sum_{j=1}^i w_jC_j^*
因为i是前i个任务当中最后完成的,因此C_i^*\ge C_j^*,\forall j<i,于是
\begin{aligned}
    p(S) &\le 2\sum_{j=1}^i w_jC_j^* \\
    &\le 2\sum_{j=1}^i w_jC_i^* \\
    &\le 2C_i^* p(S) \\
    \Rightarrow p(S) &\le  2C_i^*
\end{aligned}
因此
C_i^{\text N} \le 3C_i^* \Rightarrow \sum_{i=1}^nC_i^{\text N} \le 3\sum_{i=1}^nC_i^* \le 3\cdot\mathrm{OPT}
我们便证明了我们的算法是一个3倍近似算法。

整个算法唯一剩下的一个小问题便是:我们要解的这个线性规划的约束数量是指数级的,我们解线性规划所需要的时间难道不会变成指数级吗?事实上,有一类椭圆形法可以在多项式时间内解线性规划的同时处理这些子集约束,所以这个问题我们并不用担心。(具体的内容大概会在明天讲到吧)

定义

输入:n件物品,第i件物品的大小为a_i\in(0,1]

目标:将所有物品装进若干个大小为1的箱内,并使所用的箱最少。

见缝插针算法(First-fit)

(总觉得自己的这个翻译有些问题……又似乎觉得没有问题……还是觉得有些问题?“首次适应”看起来太傻了,“先到先得”看起来又不大对劲……)

我们可以很暴力地设计一个算法:

  1. 将所有物品任意排列。
  2. 依次处理每一个物品,如果已经用的箱子里有一个还装得下就装进去,否则就新开一个箱子装进去。

这个算法被称为first-fit algorithm,我将其翻译为见缝插针算法。

这个算法是不是太粗暴了一点?其实可以证明,这个算法是一个2倍近似算法。

引理:我们最终的打包方案当中,至多只有一个箱子是最多半满的(即在里面物品的体积\le \frac{1}{2})。

证明:考虑反证:如果有两个箱子都是最多半满,显然后开的那个箱子里的东西在算法运行的时候可以被塞进另外一个箱子里,就产生了矛盾。

假设我们的算法用了B个箱子,因为忽略可能有的一个半满的箱子,剩下的箱子里物品的大小都>\frac{1}{2},显然有

\frac{B-1}{2} < \sum_{i=1}^n a_i
因此
B < 2\sum_{i=1}^na_i + 1 \Rightarrow B\le 2\sum_{i=1}^na_i
又因为最优方案所用箱子的总大小一定不小于物品的大小之和,
\sum_{i=1}^n a_i \le \mathrm{OPT} \Rightarrow B\le 2\cdot \mathrm{OPT}

PTAS设计

定理:对于任意的\varepsilon \in \left(0,\frac{1}{2}\right],我们都能设计一个(1+2\varepsilon)倍的近似算法。

在证明这个定理之前,我们不妨思考一下:什么时候暴力搜索是可以接受的?

如果我们知道每个箱子最多装m件物品,物品的大小只有k种,那么对于一个箱子至多有几种装箱(装到放不下任何其他物品)的方案?运用一点组合数学的知识,不难得出答案是

\binom{m + k-1}{m}
不妨这个方案数为R,假设我们有n个箱子,最多有几种装箱的方案呢?
\binom{n+R-1}{R-1} = \mathcal{O}\left(n^{R-1}\right)
如果R不是常数,那么显然暴力枚举算法的复杂度是指数级的。

而如果R是一个比较小的常数,也就是说m, k都是比较小的常数的话,暴力搜索的时间复杂度就是多项式级别的。

如何确保m比较小?只要每件物品足够大就行了,于是我们把输入的物品分为

  1. a_i<\varepsilon的小物品,
  2. a_i\ge \varepsilon的大物品。

我们暂时先不管小物品。如何设计一个放大物品的(1+\varepsilon)倍近似算法呢?

由定义,每件大物品大小>\varepsilon,因此每个箱子能够放的大物品数目m<\frac{1}{\varepsilon},如果我们想要用暴力,只要再把k限制住就行了。

为此,我们将所有物品按大小升序(或者说不降)排序,然后将排完的物品分成k组,除了最后一组,每一组都有\lceil n/k \rceil个物品。

假设我们现在的要处理的问题实例为I,我们创建一个新实例J,其中每一个物品的大小被高估为它所在组最大物品的大小:

很显然,因为做J的时候我们高估了物品的大小,也就是让问题更难了,每个J的解都是I的可行解。而J当中物品的大小只有k种,加上m的限制,是可以暴力解决的。我们可以暴力把J解出来,然后套到I上面作为我们算法的解。但是如何分析呢?或者说,如何用I的最优解来给我们J的最优解一个上界呢?

出于分析的目的,我们再考虑另一个问题实例J',其中每一个物品的大小被低估为它所在组最小物品的大小:

类似地,我们可以得出:每一个I的可行解一定是J'的一个可行解,反之未必然,即

\mathrm{OPT}_{J'} \le \mathrm{OPT}_{I}
现在思考一个问题:如果我们有一组J'的最优解,如何把它尽可能地转换成一组J的可行解呢?

一个方案是让J的每个物品对应J'当中更大一组中的物品,然后就按照J’最优解的方式装箱,这样除了最大的一组以外J中的所有物品都有办法装箱了。令最大的那一组不知道怎么装箱的物品集合为Q

因为对于Q里面的物品我们大不了一个箱子装一个,因此我们得到

\mathrm{OPT}_{J} \le \mathrm{OPT}_{J'}+|Q| \le \mathrm{OPT}_{I} + \left\lceil\frac{n}{k}\right\rceil
注意因为我们是暴力解J的,现在不等式的左边\mathrm{OPT}_J就是我们算法的代价。为了证明近似比,我们希望上面不等式的右边变成一个(1+\varepsilon)\mathrm{OPT}_I

注意到在这个只有大物品的问题当中,\mathrm{OPT}_I \ge \lceil n\cdot \varepsilon \rceil,因此,若令k=\left\lceil\frac{1}{\varepsilon^2}\right\rceil。上面的分析就变成了

\begin{aligned}
    \mathrm{OPT}_{J} &\le \mathrm{OPT}_{I} + \left\lceil\frac{n}{k}\right\rceil \\
    &\le \mathrm{OPT}_{I} + \left\lceil n \cdot \varepsilon^2\right\rceil \\
        &\le \mathrm{OPT}_{I} + \varepsilon\left\lfloor n \cdot \varepsilon\right\rfloor + 1 \\
    &\le \mathrm{OPT}_{I} + \varepsilon\mathrm{OPT}_{I}+1 \\
    &= (1+\varepsilon)\mathrm{OPT}_{I} +1
\end{aligned}
我们就证明了我们装大物品的近似比是(1+\varepsilon)。注意到这个多出来的+1虽然让人不爽,但是在渐进意义下是可以忽略的。但是因为毕竟是在渐渐意义下,我们刚刚的算法不能被称为PTAS了,而是APTAS(Asymptotic PTAS)。(这个+1也不是没有办法去掉,只是如果要去掉那k的表达式可能要复杂一点)

接下来再处理小物品:我们现在对于大物品已经有了一个渐进近似比为(1+\varepsilon)的算法,我们如何在这个算法的解的基础上尽可能优地把小物品加进去呢?我们不妨使用见缝插针的策略。我们分析一下这样的效果:

  1. 如果我们使用见缝插针算法装小物品不需要开新的箱子,那么箱子数不变,我们的算法总体来说渐进近似比还是(1+\varepsilon)
  2. 如果我们开了新的箱子。我们可以证明:在我们方案中,至多有一个箱子,其里面物品的大小总和不大于(1-\varepsilon),且这个箱子一定是在装小物品的时候新开的。证明方式和之前分析见缝插针算法的时候是类似的:如果有两个(1-\varepsilon)满的箱子,那么后开的那个箱子里面装的小物品一定可以塞进另外一个先前开的箱子里面,于是便产生了矛盾。我们分析一下这个情况下我们算法的近似比。假设我们的算法开了B个箱子:

\left.\begin{aligned}
    (B-1)(1-\varepsilon) < \sum_{i=1}^na_i \le \mathrm{OPT} \Rightarrow B\le \frac{\mathrm{OPT}}{1-\varepsilon} \\
    \varepsilon \in \left(0,\frac{1}{2}\right] \Rightarrow \frac{1}{1-\varepsilon} = 1+2\varepsilon
+\frac{\varepsilon(2\varepsilon-1)}{1-\varepsilon} \le 1+2\varepsilon
\end{aligned} \right\}
\Rightarrow B\le (1+2\varepsilon) \mathrm{OPT}

综上,我们算法的渐进近似比是(1+2\varepsilon)

最后回顾一下我们的APTAS:

  1. 暂时忽略小物品。
  2. k=\left\lceil\frac{1}{\varepsilon^2}\right\rceil并以此创建新实例J
  3. 暴力搜索求得J的最优解。
  4. 把我们的最优解直接套到I上面,得到大物品的装箱方案。
  5. 使用见缝插针法将小物品装箱。

什么是次线性算法?

如何衡量算法的效率?一般来说,问题(以及对应的算法)可以分为以下几类:

  1. 不可判定问题。这种问题不存在算法。
  2. 指数时间可解。这种问题虽然存在算法但是就目前我们的技术水平,对于大输入,它们没办法在可接受的时间内返回结果。
  3. 多项式时间可解。这些算法可以在现在的电脑上较为高效地运行。
  4. 线性时间可解。这是大多数人认为的最高效算法,一般来说不会有比这更高效的了(读取输入就是线性的,不读完输入怎么计算输出?)
  5. 次线性时间算法。“次线性”(sublinear)即“线性以下”,指在利用低于与输入线性的资源(可以是时间也可以是空间)的情况下解决问题的一类算法。这类算法有着非常不可思议的特性:它甚至都不会看完完整的输入!

为什么我们需要次线性算法?

大数据时代我们要处理的数据是在是太多了,光是谷歌一天就会产生20PB的数据,我们根本没有足够的时空资源来哪怕运行线性的算法或者把它们全部过一遍!但是我们又希望从这些数据中挖掘有价值的结论,就只能求助于次线性算法了。

因为次线性算法没法看到完整的输入,给出错误的结果自然是情有可原的(在精确解没法在次线性时间内算出的情况下)。因此,次线性算法多是近似算法。同时,因为如果一个算法是确定性的,对手就可以预判这个算法会看那部分输入,并针对这个造出会让算法100%翻车的数据,因此随机化在次线性算法的设计也经常出现。

查询模型

好,既然次线性算法不能把输入完整地读一遍,那么要怎么样才能获取输入的信息呢?我们将次线性算法获取输入信息的方式称为查询(query)。而算法的难易自然和可以进行的查询方式有着很大的关系,因此我们还需要一组规则定义在一个问题中可以进行何种方式的查询,这种规则称一个问题的查询模型(query model)。例如,在以数组为输入的问题当中,常见的查询方式是按照一个下标询问对应的值,在以图为输入的问题当中,查询可以是按照两点查询边权……可以看到,查询模型是因问题而异的。

有序判定问题(Sortedness Problem)

定义

输入:一个n个元素的数组A

查询模型:对于一个整数1\le i \le n,查询A_i的值。

目标:在尽量少的时间内(假设一次查询时\mathcal{O}(1)的)判断这个数组是否是有序(此处单指升序)的。

很显然,任何一个确定性的算法都必须进行\Omega(n)次查询才能解决这个问题(不然肯定会有漏看的)。

因此我们退而求其次:如果运用随机化的话,我们能不能在o(n)次查询之内给出正确概率至少为\frac{1}{2}+\epsilon\ (\epsilon >0)的答案呢?

(正确概率在这里只要大于\frac{1}{2}就行,精确的值没有意义,因为我们总是可以重复运行不靠谱的算法提高正确率)

答案还是不行(证明课上似乎没讲?)

那我们怎么办?只好稍微把题目修改一下,再退而求其次看看能不能有高效的近似算法了。

修改后的定义

我们不妨寻找这么一个算法,它

  1. 在数组有序的时候输出是
  2. 在数组“非常无序”的时候输出否
  3. 而对于那种非常接近有序的数组,我们干脆不管。

设计这样的算法显然比设计能够精确解决这个问题的算法要容易多了。

那么怎么样算是“非常无序”,怎么样算是“接近有序”呢?显然,我们需要在数学上给予这些描述严谨的定义。

定义:对于\epsilon\in(0,1),如果需要至少修改\epsilon n个数才能让数组A变为有序,那么称数组A\epsilon无序(\epsilon-far from sorted)的。

那么我们就可以进一步精炼我们上面的问题了:我们需要设计一个算法,使其

  1. A有序的时候输出是
  2. A \epsilon无序的时候输出否

\epsilon是算法的一个参数)

从这里其实可以看到对于近似算法的一种设计思路:对于数值型的问题,我们通过对于最优解的放缩定义了近似比\alpha,而对于判定型的问题,我们试图放宽需要判定的条件。在上面的问题当中我们相当于变相假设,或者说承诺了A要么是有序要么是\epsilon无序的,因此这种问题被称作promise problem。

二元数组

我们先从最简单的情形开始分析:如果数组只有01两种元素呢?

初步分析

显然此时数组有序等价于所有的0都在1前面。

我们不妨定义两个集合,L_1R_0。如果将数组里的元素从左到右排列,L_1表示最靠左的\frac{\epsilon n}{2}1的下标集合,R_0表示最靠右的\frac{\epsilon n}{2}0的下标集合。

显然在数组有序时,L_1整体都在R_0右边。而当数组不有序时,有可能:

  1. L_1R_0的区间重叠。
  2. L_1R_0的左边。

接下来我们证明:

引理:如果A\epsilon无序的,那么L_1必定在R_0左边且不重叠。

证明:我们使用反证法,不妨假设L_1R_0有重叠的部分:

0,0,\cdots,\rlap{\ensuremath{\overbrace{1,\cdots,1,0,1}^{L_1}}}1,\cdots,1,\underbrace{ {\color{blue}{0} },{\color{red}{1} },0,\cdots,0}_{R_0},\cdots1,1

\color{red}1L_1中最右边的1\color{blue}0R_0中最左边的0

L_1,R_0的定义可知,\color{red}1左侧的1的数量严格小于\frac{\epsilon n}{2}\color{blue}0右侧的0的数量也严格小于\frac{\epsilon n}{2}。而注意到如果把\color{red}1的左侧1改成0,把\color{blue}0右侧的0的改成1,那么数组就变得有序了,而此时我们修改的元素数量严格小于\epsilon n,这和数组是\epsilon无序的前提是矛盾的。

算法

接下来我们给出能够以1-\delta的成功率判定二元数组有序性的算法:

  1. 从数组当中随机选取t=\frac{2}{\epsilon}\ln\frac{2}{\delta}个下标i_1,\cdots,i_t(为了简化分析,允许下标重复)。
  2. 查询A_{i_1},\cdots, A_{i_t}的值。
  3. 如果在\left\{A_{i_1},\cdots, A_{i_t}\right\}当中发现了逆序对,输出否,否则输出是。

分析

很显然,如果数组是有序的,那么我们的算法一定会输出是,成功率1>1-\delta

而如果数组是\epsilon-无序的,为了证明此时的成功率至少为1-\delta,我们只需要证明

\operatorname{Pr}[\text{wrong answer}] \le \delta
即错误(输出了是)概率不大于\delta

设随机事件E_L表示在随机选取的t个下标中,至少有一个下标来自L_1E_R表示至少有一个下标来自R_0

那么由之前的引理,如果E_L\cap E_R,我们一定找得到逆序对。

因此只有可能在\overline{E_L\cap E_R}才有可能出错:

\begin{aligned}
    \operatorname{Pr}[\text{wrong answer}] \le \operatorname{Pr}\left[\overline{E_L}\cup\overline{E_R} \right] \le \operatorname{Pr}\left[\overline{E_L}\right] +  \operatorname{Pr}\left[\overline{E_R}\right]
\end{aligned}
\begin{aligned}
    \operatorname{Pr}\left[\overline{E_L}\right] &= \left(1-\frac{\left|L_1\right|}{n}\right) ^ t \\
    &= \left(1-\frac{\epsilon}{2}\right)^{\frac{2}{\epsilon}\ln\frac{2}{\delta}} \\
    &\le \left(e^{-\frac{\epsilon}{2}}\right)^{\frac{2}{\epsilon}\ln\frac{2}{\delta}} \\
    &= \frac{\delta}{2}
\end{aligned}
倒数第二步我们运用了不等式1+x\le e^x

同理可证\operatorname{Pr}\left[\overline{E_R}\right] \le \frac{\delta}{2},因此

\operatorname{Pr}[\text{wrong answer}] \le \operatorname{Pr}\left[\overline{E_L}\right] +  \operatorname{Pr}\left[\overline{E_R}\right] \le \frac{\delta}{2} + \frac{\delta}{2} = \delta
证毕。

时间复杂度

随机采样花费的时间复杂度为\mathcal{O}(t)

查询子数组花费的时间复杂度也是\mathcal{O}(t)

而在子数组当中查找逆序对也可以被优化成\mathcal{O}(t),实现的方法有很多,例如可以计算1的下标的最小值和0的下标的最大值进行比较等。

因此这个算法的时间复杂度就是\mathcal O(t) = \mathcal O\left(\frac{1}{\epsilon}\ln\frac{1}{\delta}\right),和n无关。

一般情况

接下来我们来看数组元素可以是任意数字的情况。

我们之前随机采样的想法还能继续沿用吗?

考虑如下情况:

3,2,1{\color{red}|}6,5,4{\color{red}|}9,8,7
虽然这个数组是\frac{2}{3}无序的,但是如果我的随机采样在每个段中只挑了一个元素,那采样出来的子序列却一定是有序的,我们的算法就被糊弄过去了。

这就是问题所在:如果我们把一个数组分成若干段“总体有序,但局部无序”的连续段,显然这样的段最多可以有\mathcal O(n)个。而为了能够在子序列当中找到逆序对,就必须从同一个段当中至少选两个数。而由生日悖论可知,为了大概率确保能从一个段中选两个数,我们必须至少随机选\mathcal O\left(\sqrt n\right)个数——这让我们之前的分析几乎废掉。

那怎么办呢?

考虑一个有序的数组所具有的性质。在算法层面,一个熟悉的性质便是:可以进行二分查找

这给我们一个思路:如果我们不管三七二十一在给定的数组上跑二分,如果二分翻车了,那么数组一定是无序的,如果二分没翻车,数组或许就是有序的。

如何定义二分有没有“翻车”?或者说,如何定义一次正常的二分?

引入一致二分查找(Consistent Binary Search)的概念,如果对于一次二分查找,

  1. 我最后找到了我要找的那个数(也就是说,如果我要找的是A_i,而我的二分最后终止于下标i),
  2. 假设二分的左右端点和终点分别为l,r,m,二分的过程中A_l < A_m < A_r始终成立,

那么我们称这次二分查找是“一致的”或“相容的”(我觉得翻译成哪个似乎都不大合适)。

算法

在二分思想的指引下我们提出如下的算法:

  1. 随机选择一个下标i并查询A_i的值。
  2. 二分查找A_i
  3. 如果这次二分查找是一致的,那么输出是,反之输出否。

分析

引理:如果对于A_iA_j的二分查找都是一致的,而且i<j,一定有A_i<A_j

证明:虽然两次二分查找中前几层的搜索区间可能相同,但由于i < j,到了某一层一定会出现A_i往左边,A_j往右边的分歧。假设该层搜索区间的中点是A_m,显然对A_i的二分搜索之所以在这层之后往左走是因为A_i < A_m,同理可知A_j>A_m,将二式组合引理便得证。

假设C表示所有对下标对应的数进行的二分搜索是一致的下标的集合。则由刚刚的引理,由C中下标组成的原数组的子序列一定是有序的。因为原数组是\epsilon无序的,因此必然有|C|<(1-\epsilon) n(可以通过反证法证明)。

这个结论有什么意义呢?要知道我们的算法在原数组有序的时候一定输出正确的结果,而在原数组无序时,我们算法只有在抽到C中的下标时才会做出误判。因此我们算法的错误率的上界为1-\epsilon

在得出这个上界之后,我们就可以改进我们的算法了:

  1. 将之前的算法运行k=\frac{1}{\epsilon}\ln\frac{1}{\delta}遍。
  2. 如果k次运行的结果都为是,则输出是,反之则输出否。

这样的错误率是多少呢?

\begin{aligned}
    \operatorname{Pr}[\text{wrong answer}] &\le \left(1-\epsilon\right)^k \\
    &= \left(1-\epsilon\right)^{\frac{1}{\epsilon}\ln\frac{1}{\delta}} \\
    &\le \left(e^{-\epsilon}\right)^{\frac{1}{\epsilon}\ln\frac{1}{\delta}} \\
    & = \delta
\end{aligned}
因此正确率至少为1-\delta,符合我们的要求。而这个算法的时间复杂度是\mathcal O\left(\frac{1}{\epsilon}\ln\frac{1}{\delta}\log n\right),也是相当优秀的。

定义

定义所谓k中心问题为:

输入:完全图G=(V,E),边带正权,且满足三角不等式w_{uv} \le w_{uk} + w_{kv},以及一个正整数k

目标:求一个点集|S|=k(称为k个中心),定义一个点到一个点集的距离

d(u,S) = \min_{v\in S} w_{uv}
(显然,如果满足三角不等式,那么任意两点之间的最短距离就是直接连接两点的边权)

要求最小化\max_{u\in V} d(u,S)。即所有顶点到这k个中心的最短路程的最大值(不妨称之为这个这个k中心的代价)最小。

近似算法1(Gonzalez)

算法

  1. 任意选取一个顶点并将其加入S
  2. 选取离当前S最远的顶点加入S
  3. 重复步骤2直至|S|=k

第2步的操作是非常合乎直觉的:如果不把离S最远的顶点加入S的话,这条最远的边权就可能会被计入答案当中,这显然是不合算的,而如果将这个最远点作为新的中心,那么这条最远边就必定不会被计入答案。

分析

这个算法给出方案的代价是什么?在这个算法的背景下,我们不妨说:这个算法结果的代价是如果不跳出循环,下一个选取的中心到当前S的距离(依据第2步)。假设这个中心为x,我们不妨画图:

其中左边的S是我们的解,右边是最优解(我们这里把他们化成了不重叠的形式,事实上有重叠对于我们的论证也不造成影响)。

我们的解加上x总共有k+1个点,最优解有k个点,因此由抽屉原理,至少左边有两个点,它们在最优解中的最近点是相同的(即图中的两条横叉边),且由于是最优解,两条边的长度都\le \mathrm{OPT}

不妨设这两个点为u,v,由三角不等式,可以得出w_{uv}\le2\cdot\mathrm{OPT}

接下来不妨假设u,v中的后来者为v,为什么当时我们选择v而不是x加入S呢?肯定是因为当时d(v,S)\ge d(x,S)。而由d(v,S)的定义可知,d(v,S)\le w_{uv},且由于随着S的变大,d(x,S)不增,因此我们解的代价(即现在的d(x,S))一定也是不大于当时的d(v,S)。将这些不等式串联起来,便得到:

d(x,S) \le w_{uv} \le 2\cdot \mathrm{OPT}
也就是说这个算法的近似比为2

近似算法2(Hochbaum & Shmoys)

算法

我们不妨假设我们已经知道了最优解的代价\mathrm{OPT},那么我们要怎么做呢?

  1. 我们选取任意顶点u_1加入中心,并以为中心画一个半径为的球,钦定球内的所有点归u_1
  2. 我们接着选取一个在这个球以外的一个顶点u_2加入中心,并以u_2为中心画一个半径为2\cdot \mathrm{OPT}的球,钦定球里面的点归u_2
  3. 我们接着选取一个在u_1u_2的球外的点u_3作为中心……
  4. 重复上述步骤,直至图上的所有点都归一个至多2\cdot \mathrm{OPT}远处的中心管。

画在图上大概是这样的:

可以看到,可能会有点同时处在多个中心的“势力范围”之内,但这并不重要,我们只需要确保每个点离最近的一个中心至多2\cdot \mathrm{OPT}那么远就行了。

分析

我们接下来证明这是一个2近似算法。算法本身的流程确保了2的近似比,因此唯一需要证明的就是这个算法产生的中心不超过要求的k个。

不妨反证:如果产生了超过k个中心呢?

那么根据抽屉原理,至少有两个中心在最优解中的最近点是相同的(这和我们分析第一个算法时的逻辑是完全相同的),由三角不等式,这两个中心之间的距离\le 2\cdot \mathrm{OPT}。但是根据算法,中心之间两两距离应该大于2\cdot \mathrm{OPT},这就产生了矛盾。

因此必定不会有超过k个中心,证毕。

还剩下来一个问题:这个算法假定我们知道\mathrm{OPT},但是实际上我们不知道啊!

但是我们知道\mathrm{OPT}一定是某一条边的边权,因此我们只需要按边权从小到大枚举每一条边,把边权当做\mathrm{OPT}试试看,如果产生超过k个中心那必然不是\mathrm{OPT}(这是我们推论的逆否),而反之则可能就是\mathrm{OPT}(但无论如何,事实上是\mathrm{OPT}的那条边一定是没有问题的)。因为我们在这里只在意是否是多项式时间复杂度而不在意复杂度本身,而枚举边自然是多项式时间的,因此没有任何问题(当然,在实现过程中可以考虑使用二分,但这就是后话了)。

近似比可以更好吗?

定理:如果存在一个多项式时间的,近似比<2k中心问题的近似算法,那么\mathsf{P}=\mathsf{NP}

证明:考虑最小支配集问题的判定形式:

输入:无向图G=(V,E)以及正整数k

判定:是否存在一个大小为k的支配集?支配集定义为一个点集S\subseteq V,使得\forall u\in V,要么u\in S或者u的邻居\in S

最小支配集问题是NPC的。我们接下来将其规约到k中心问题(也是判定形式):

输入:完全图G',正整数k,b

判定:是否存在一个代价\le bk中心的方案?

如何规约?不难想到,我们可以让GG'用相同的点集,而对于所有在G中的边,其在G'中的边权为1。而对于所有不在G中的边,其在G'中的边权为2(为什么是2?直觉上我们会首先想到\infty,但是这样就不满足三角不等式了,因此改成2)。令两个问题当中的k相等,且b=1,我们就完成了规约。

为什么?

在这样的规约下,一个在G中的支配集必然对应一个G'当中代价为1的相同大小的k中心,反之亦然。

也就是说,如果我们可以解决这个规约后的k中心问题,那么我们就能解决原来的最小支配集问题了。

这个时候就可以看出为什么近似比<2的近似算法牛逼了:

  1. 如果G当中有符合要求的支配集,那么k中心的\mathrm{OPT}=1,而由于近似比<2且边权只有1,2,因此即使是近似算法也会次次给出1
  2. 如果G当中没有符合要求的支配集,那么k中心的\mathrm{OPT} = 2,而由于近似比<2且边权只有1,2,因此即使是近似算法也只能给出2

如此说来,即使是近似算法,通过判断输出代价是1还是2我们就能判断G中有没有大小k的支配集。而这个近似算法是多项式时间的,因此我们可以用多项式时间解决支配集问题这么一个NPC问题,即P=NP

刚刚用到的这个技巧被称为“gap reduction”。

整个定理的言下之意就是如果你也相信\mathsf{P}\neq \mathsf{NP},那么就可以洗洗睡了~