CS30016.02 算法设计与分析
算法设计与分析
递归表达式的通项
时间复杂度渐进记号:
-
\(f(n) = \Theta(g(n)) \Leftrightarrow \exists n_0, c_0, c_1, \forall n > n_0: c_0 \cdot g(n) \le f(n) \le c_1 \cdot g(n)\)
-
\(f(n) = O(g(n)) \Leftrightarrow \exists n_0, c_1, \forall n > n_0: f(n) \le c_1 \cdot g(n)\)
-
\(f(n) = \Omega(g(n)) \Leftrightarrow \exists n_0, c_0, \forall n > n_0: c_0 \cdot g(n) \le f(n)\)
主定理
对于常数\(a \ge 1, b > 1\),函数\(f(n)\),定义在\(Z^+\)上的递归式\(T(n) = a T(\dfrac{n}{b}) + f(n)\),有:
记 \(a f(\dfrac{n}{b}) = k f(n)\),若:
- \(k > 1\),则 \(T(n) = \Theta(n^{\log_b a})\);
- \(k = 1\),则 $T(n) = \Theta(f(n) \log n)\ = \Theta(n^{\log_b a} \log n) $;
- \(k < 1\),则 \(T(n) = \Theta(f(n))\)。
证明:对于单调递增函数\(f(n)\),常数\(a \ge 1, b > 1\),满足\(f(n)=a f(\dfrac{n}{b})\),则 \(f(n) = \Theta(n ^{\log_b a})\)。
令\(g(n) = f(b^n)\),则 \(g(n) = f(b^n) = a f(b^{n-1}) = a \cdot g(n-1)\),故 \(g(n) = a^n \cdot g(0)\)。
所以 \(f(n) = g(\log_b n) = a^{\log_b n} \cdot g(0) = n^{\log_b a} \cdot g(0) = \Theta(n^{\log_b a})\)。
Akra-Bazzi 定理
对于常数\(a_i \ge 1, b_i > 1, k \ge 1\),函数\(f(n)\),定义在\(Z^+\)上的递归式\(T(n) = \sum\limits_{i=1}^{k} a_i T(\dfrac{n}{b_i}) + f(n)\),有:
若方程 \(\sum\limits_{i=1}^{k}\dfrac{a_i}{{b_i}^p} = 1\) 存在唯一解 \(p\),则 \(T(n) = \Theta(n^p (1 + \displaystyle\int_{1}^{n}\dfrac{f(u)}{u^{p+1}} \mathbb{d}u))\) 。
最小生成树
问题描述:给定一个有\(n\)个点\(m\)条边的简单无向图\(G=(V, E)\),边有边权。求图\(G\)中的一棵生成树使得其边权和最小。
以下假设各边权均不相同,保证最小生成树唯一。
由于是简单图,视 \(m = O(n^2)\)。
前置定理
割定理:图中任意割的最小边一定在最小生成树中。
证明:割将图分为不连通的两个分支,生成树将两个分支相连,则必然选取权值最小的一条边。
环定理:1) 图中任意环的最大边一定不在最小生成树中。2) 不在最小生成树中的边一定是某个环上的最大边。
证明:1) 假设某条边是环上最大边且在最小生成树中,那么删去该边,总可以在环上找到一条更小的边连接生成树的两部分构成一个新的边权和更小的生成树。
2) 非树边对应的基环上的边一定有比这条非树边的权值小,否则可以删去那条树边,将非树边加入,得到一个边权和更小的生成树。
Kruskal 算法
- 令所有点各自属于自己的连通分支;
- 从小到大遍历所有边,如果边相连的两点属于不同的连通分支(用并查集维护),则用该边将两点所在的连通分支相连,并将该边加入最小生成树边集中;
- 直至加入\(n-1\)条边,此时得到最小生成树。
时间复杂度为\(O(m \log m)\),瓶颈在给边排序。
Prim 算法
- 任取一个节点作为生成树的根,生成树一开始只有根节点;
- 每次迭代,寻找与生成树相连的点中与生成树相连的边权最小的一个(用堆维护。堆中一共有\(n\)个点,维护每个点与最小生成树相连边的最小权),将该点和该边加入最小生成树,并更新与被加入点相连的非树点堆中存的与最小生成树相连边的最小权(松弛);
- 直至加入\(n-1\)条边,此时得到最小生成树。
如果堆是二叉堆,时间复杂度为\(O(m \log n)\)。如果堆是斐堆,时间复杂度为\(O(m + n \log n)\)。
Boruvka 算法
- 初始化每个点属于仅含自己的连通块,需要对每个连通块维护与其相连的非内部边的最小权;
- 每次迭代,初始化每个连通块相连边的最小权为正无穷;
- 遍历每条边,如果该边所连两个点不在同一连通块中,则用这条边分别更新两个点所属连通块相连边的最小权;
- 遍历完所有边后,用每个连通块保存的相连最小权边扩张连通块,之后回到第2步重复迭代直至所有点均属于同一个连通块。
合并的时候通过并查集实现,但其并不会对总时间复杂度产生影响,因为merge操作至多执行\(n-1\)次,剩余操作都是get_father,因此每个节点只会进行一次\(\log n\)的操作,之后的操作由于路径压缩都是\(O(1)\)的。因此一轮的时间复杂度是\(O(m)\)的。
由于每次迭代连通块个数至少减半,所以总时间复杂度为\(O(m \log n)\)。
Fredman-Tarjan 算法
- 任取一个未标记节点作为生成树的根,将其标记,用类Prim算法向外扩张生成一棵生成树\(T\),将加入\(T\)的节点予以标记;
- 关注\(T\)的临域\(N(T)\),若加入某个点后,满足\(|N(T)|\ge K\)(\(K\)是一个和轮次有关的常数),或者加入的那个点已经被标记了(属于其它生成子树),则停止扩展,将整个\(T\)缩为1个被标记的点。若当前只剩下1个节点,则说明得到生成树,否则回到第1步;
时间复杂度\(O(m \log^* n)\),其中 \(\log^* n\) 表示经过若干轮次的取对数操作使\(n \le 1\)的最小轮次。
证明:
设第 \(p\) 轮后,已经被标记的连通分支为\(c_1, c_2, \cdots, c_l\),显然有 \(\forall i, \sum\limits_{v_j \in c_i}d(v_j) \ge K\),又 \(\sum\limits_{v \in V} d(v) = 2m\),故 \(l \le \dfrac{2m}{K}\). 记\(n_p, m_p, K_p\)分别表示当前轮标记的节点数、标记节点相连边数、取的\(K\)值。则对于每一轮有 \(K_p \le \dfrac{2m_p}{n_{p+1}}\),而总轮数即满足 \(n \le \dfrac{2m_q}{K_q}\) 的最小 \(q\) 值。
由Prim算法性质知,第\(p\)轮的时间开销为\(O(m_p + n_p \log k_p)\)。令\(k_p = 2^{{2m}/{n_p}}\),则每一轮的时间开销为\(O(m_p + 2m) = O(m)\). 由于 \(K_p \le \dfrac{2m_p}{n_{p+1}} = \log K_{p-1}\),故\(q = \log^* n\),即证明总时间复杂度为 \(O(m \log^* n)\)。
KKT 算法
注:该算法是Karger-Klein-Tarjan算法。
- KKT算法是一个递归函数,只接受一个参数为图\(G\),函数\(KKT(G)\)返回图\(G\)的最小生成树。KKT函数的具体操作步骤如下:
- 如果\(G\)只剩一个节点,则直接返回其本身;
- 执行3次Boruvka迭代,记录已经被Boruvka迭代标记的边集\(E_0\),将属于同一个连通块的点缩成一个点,得到子图\(G^\prime=(V^\prime, E^\prime)\)。\(G^\prime\)的节点数至多是\(\dfrac{n}{8}\),不属于\(G^\prime\)的边一定属于\(G\)的最小生成树,所以问题变为求解\(G^\prime\)的最小生成树;
- 在\(E^\prime\)中随机取边,每条边被选择的概率是\(\frac{1}{2}\),得到的边集记作\(E_1\),生成子图\(G_1 = (V^\prime, E_1)\);
- 用KKT算法递归求得\(G_1\)的最小生成森林\(F_1 = KKT(G_1)\);
- 遍历\(E^\prime\)中所有的边,根据环定理利用\(F_1\)删去其中的重边,得到边集\(E_2\),生成子图\(G_2 = (V^\prime, E_2)\);
- 用KKT算法递归求得\(G_2\)的最小生成森林\(F_2 = KKT(G_2)\);
- 返回\((F_2 \cup E_0)\)即为\(G\)的最小生成树。
证明:
结论1:\(\mathbb{E}(|E_1|) = \frac{1}{2}|E^\prime|\)。
结论2:\(\mathbb{E}(|E_2|) = 2(|V^\prime| - 1)\)。
证明:利用延迟决策原理(Principle of deferred decision),将一开始抽样然后决策的问题转化为边抽样边决策。原做法是 \(E^\prime \xrightarrow{\text{random sampling}} E_1 \xrightarrow{\text{get MST}} F_1 \xrightarrow{\text{del heavy edges(get light edges)}} E_2\)。该做法等价于,在Kruskal算法(求MST算法不影响\(E_2\)的结果)运行中,判断一条边即将加入\(F_1\)时,进行\(\frac{1}{2}\)概率的抛硬币,为正才加入,否则不加入。最终进行抛硬币的次数(无论对应边是否加入\(F_1\))就是\(F_1 - \text{light}\)的边数,也就是\(|E_2|\)。由于我们知道最终加入\(F_1\)边数是\(|V^\prime|-1\),所以期望抛了\(2(|V^\prime| -1)\)。
引理1:Dixon-Rauch-Tarjan算法(DRT算法)可以在\(O(m+n)\)内做到,给出图\(G\)和其生成树\(F\),求出\(G\)中所有的非树边是重边还是轻边。
下面证明KKT算法的期望时间复杂度是\(O(m + n)\):
记 \(T_G(n, m)\) 表示KKT算法求点数为\(n\)边数为\(m\)的图\(G\)的期望时间开销。
由于一步Boruvka迭代和DRT算法都是\(O(m + n)\)的,所以:\(T_G(n,m) = c \cdot O(m + n) + T_{G_1}(n^\prime, m_1) + T_{G_2}(n', m_2)\) .
归纳奠基:显然\(T_G(1,0) \le 2c \cdot O(1)\)。
应用第二类数学归纳法,假设 \(\forall m_0 < m, T_G(n,m_0) \le 2 c \cdot O(m_0 + n)\) 的,
代入原式,得到 \(T_G(n,m) \le c \cdot O(m + n) + 2 c \cdot O(m_1 + n^\prime) + 2 c \cdot O(m_2 + n^\prime)\),
由结论1、结论2得 \(T_G(n,m) \le c \cdot O(m + n) + 2 c \cdot O(\dfrac{1}{2}m^\prime + n^\prime) + 2 c \cdot O(2(n^\prime -1) + n^\prime) = c \cdot O(m+n+m^\prime +8n^\prime)\)
由于执行了3次Boruvka迭代,所以有 \(n^\prime \le \dfrac{n}{8}, m^\prime \le m\),
于是上式化为 \(T_G(n,m) \le c \cdot O(2m + 2n) = 2 c \cdot O(m + n)\)。
由是,归纳成立,\(T_G(n,m) = O(m+n)\)。
最短路
以下假设\(G=(V,E)\)是有向简单图。
单源最短路算法
Dijkstra 算法
- 标记已经被处理过的节点,并用小根堆维护所有未标记节点到\(s\)的最短路径距离\(d_u\)。初始化\(d_s=0\),其余节点\(d_v = +\infty\) 。所有节点均入堆且未标记。
- 每一轮,取出并弹出堆顶\(u\),则\(u\)是当前所有未标记节点中到\(s\)最短路径最小的节点。此时将\(u\)标记,并用\(u\)去松弛\(N(u)\)中未标记节点的最短路径;
- 最终所有节点被标记时,求得\(s\)到全图所有节点的最短路径长度。
Dijkstra算法仅能用于非负权图。若堆为二叉堆,则时间复杂度为\(O(m \log n)\)。若堆是斐堆,则时间复杂度为\(O(m +n \log n)\)。
Bellman-Ford 算法
- 第\(k\)轮,\(d_u\)表示经过至多\(k\)条边到\(s\)的最短路径距离。初始化\(d_s=0\),其余节点\(d_v = +\infty\) 。
- 每一轮,遍历所有边\((u,v,w)\),尝试用\(d_u\)和\(w\)松弛\(d_v\);
- 由于最短路经过的边数至多为\(n-1\),所以经历\(n-1\)轮循环后\(d_u\)就是\(s\)到\(u\)的最短路径长度。
Bellman-Ford算法可以用于任意权图,且可以用于判断负环,方法是执行第\(n\)轮循环,发现还有松弛成立则说明图中有负环。称第\(n\)轮被松弛成功的节点为witness,则至少存在一个witness在负环上。从这些witness开始dfs即可获得所有负环可达的节点,它们的最短路长度是\(-\infty\)。算法时间复杂度为\(O(mn)\)。
全源最短路算法
Johnson 算法
- 新建超级源\(S_0\),对于所有其它节点,建立\(S_0\)指向该节点的零权边。用Bellman-Ford算法求\(S_0\)到其它任意节点\(u\)的单源最短路,记作\(\Phi(u)\)。
- 对原图中所有边\((u,v,w)\)重新赋权为\((u,v,w + \Phi(u) - \Phi(v))\)。在新图上以所有节点为起点\(s_i\),用Dijkstra算法求以\(s_i\)为起点的单源最短路\(d_{s_i, v}\),则两点间真正最短路长度为\(d_{s_i, v} - \Phi(s_i) + \Phi(t)\)。
- 如此求得图的全源最短路。
Johnson算法可以作用于任意权图(无负环),通过重新赋权,利用多次Dijkstra算法实现全源最短路。时间复杂度为\(O(mn + n^2 \log n)\)。
算法正确性证明:
首先证明重新赋权后边权非负,即证明\(\Phi\)满足三角不等式:\(\Phi(u) + w(u,v) \ge \Phi(v)\)。显然,如果\(\exist u,v: \Phi(u) + w(u,v) \ge \Phi(v)\),则Bellman-Ford算法依然可以利用边\((u,v)\)进行松弛,算法不会结束,因此得证重新赋权后边权非负。
然后证明重新赋权后原本两点之间路径的长度排名不变(新图的最短路径还是原图的最短路径,只是权值和有只和起点终点相关的差值)。对于原图中\(s, t\)之间的路径 \(s \rightarrow u_1 \rightarrow u_2 \rightarrow \cdots \rightarrow u_k \rightarrow t\),其原边权和 \(W = w(s, u_1) + w(u_1, u_2) + \cdots + w(u_{k-1}, u_k) + w(u_k, t)\),则其在新图的边权和 \(W^\prime = w(s, u_1) + \Phi(s) - \Phi(u_1) + w(u_1, u_2) + \Phi(u_1) - \Phi(u_2) + \cdots + w(u_{k-1}, u_k) + \Phi(u_{k-1}) - \Phi(u_k) + w(u_k, t) + \Phi(u_k) - \Phi(t)\),整理得 \(W^\prime = W + \Phi(s) - \Phi(t)\)。
Floyd 算法
- 第\(q\)轮,最短路矩阵\(d_{u,v}\)表示\(u\)到\(v\)至多经过\(q\)个节点的最短路长度。初始化\(d_{u,v} = \begin{cases} w(u,v), &\mathbb{if}\, (u,v) \in E \\ +\infty , &\mathbb{if}\, (u,v) \notin E \\ 0 , &\mathbb{if}\, u=v \end{cases}\) 。
- 三重循环,最外层遍历松弛中间节点\(k\),内两层遍历松弛起点\(u\)和松弛终点\(v\)。尝试用\(d(u,k) + d(k,v)\)去松弛\(d(u,v)\)。
- 循环结束后\(d_{u,v}\)就是\(u,v\)的最短路长度。
Floyd算法可以作用于任意权图,时间复杂度为\(O(n^3)\)。
分治思想
van Emde Boas 树
目标:维护\(n\)个值域在\([0,U-1] \cap \mathbb{N}\)中的元素,支持在\(O(\log \log U)\)内实现插入一个元素、删除一个元素、查找某个元素的后继。
(上图为存储16个元素的vEB树结构。最外层是容量为16的块,其由1个容量为4的summary块和4个容量为4的cluster块构成;而每一个容量为4的块都是由1个summary基块和2个cluster基块构成。基块不再分解,其直接存储2个数)
vEB树是树套树结构。每个vEB块由两部分组成,上层的summary块和下层的若干cluster块。递归地,每个块也是一个vEB树。直至大小为2的块不再递归分解,称为vEB基块。称能表示\(m\)个bit的vEB块的容量是\(m\)。
一整棵vEB树的最底层对接的是一个\([0, 1, \cdots, U-1]\)的bit vector(该vector是虚拟的,由最底层的基块们构成)。该vector需要一棵容量为\(U\)的vEB树存储。
一个容量为\(m\)的vEB块由1个summary块和\(\sqrt{m}\)个cluster块构成,两种块的容量都是\(\sqrt{m}\)。其中,cluster块负责递归向下存储真实数据,summary块存储的这\(\sqrt{m}\)个数据分别表示各个cluster块中是否有存储数据(summary块第i位是cluster[i]存储数据的逻辑或)。
每个块同时维护min和max,表示该块对应区间内下标的最小值和最大值。特别地,最小值不直接插入vEB树,作为该块实际存储信息的一部分(即使递归到最底层bit vector里V.min下标的值是0,vEB树依然视为存储了V.min这个值,且V.min对应的下标必须是0而非1)。如果该块仅存储了一个数,那么一定是min有值而底层全为0,不允许出现底层有1但是min是None的情况,同时此时不递归地向下进行赋值,也就是说此时该块下的所有summary和cluster块的min都是None。
max不具备存储能力,它就是一个普通的区间下标max。如果没有元素,则其为None;如果只有一个元素,则其等于min的值。
基块只有min和max,存储能力是2。基块的min和max都表示实际信息,对应值是None表示没有存储,不是None就表示存储了对应值。
建树代码:
class VEBTree: # Build vEB tree
def __init__(self, u):
self.u = u
self.min = None
self.max = None
if u > 2:
self.sqrt_u = int(math.isqrt(u))
self.summary = VEBTree(self.sqrt_u)
self.cluster = [VEBTree(self.sqrt_u)
for _ in range(self.sqrt_u)]
以下记hi(x)表示x对应子块id,lo(x)表示x进入子块后对应id。由于\(x = \text{hi}(x) \cdot \sqrt{V} + \text{lo}(x)\),(\(V\)是当前块的容量)所以二者都可以\(O(1)\)求出。以上面存储16个数的最外层vEB块为例,hi(6)=1, lo(6)=2,表示下标6是第1个子块中第2个元素。(每个块的id都是从0开始的)
用index(i, j)表示从外层来看hi=i, lo=j的下标应该是多少,即\(\text{index}(i, j) = i \cdot \sqrt{V} + j\),用于还原下标。
-
插入
def INSERT(V, x): if V.min == None: V.min := x V.max := x ret if x < V.min: swap(x, V.min) # V.min records x, and the original V.min will be inserted then if x > V.max: V.max := x if V.cluster[hi(x)].min == None: INSERT(V.summary, hi(x)) INSERT(V.cluster[hi(x)], lo(x))两个INSERT总恰有一个会递归下去,另一个要么不进入,要么只递归一次就返回了。递推式\(T(u) = T(\sqrt{u}) + O(1)\),于是插入操作是\(O(\log \log U)\)的。
-
删除
def DELETE(V, x): if x == V.min: i := V.summary.min if i == None: V.min := None V.max := None ret V.min := Index(i, V.cluster[i].min) # save the new global min x := V.min # fresh the global min in cluster to 0 then DELETE(V.cluster[hi(x)], lo(x)) if V.cluster[hi(x)].min == None: DELETE(V.summary, hi(x)) if x == V.max: # updating max if V.summary.max == None: V.max := V.min else: V.max := Index(V.summary, V.cluster[i].max)如果要删除的值是全局最小值V.min,那么找到除V.min之外的最小值(如果连这个次小值都找不到那就直接赋None返回),把这个次小值提到V.min里,然后清空原本内层块存储它的1(相当于接着递归下去删除它)。
如果删除后某个cluster从有变空,那么需要把summary中对应值置0。同理,两个DELECT至多只有一个递归深入下去。因为如果前一个深入,则说明内层至少有2个数,不会删空;若后一个深入,则说明内层仅有min存数,只进行一次递归就返回了。满足\(T(u) = T(\sqrt{u}) + O(1)\)的形式,推知\(T(U) = O(\log \log U)\)。
-
查后继
def Successor(V, x): if x < V.min: ret V.min if V.cluster[hi(x)].max != None and lo(x) < V.cluster[hi(x)].max: i := hi(x) j := Successor(V.cluster[hi(x)], lo(x)) else: i := Successor(V.summary, hi(x)) j := V.cluster[i].min ret Index(i, j)
所有操作时间递推式都满足\(T(u) = T(\sqrt{u}) + O(1)\)的形式,推知\(T(U) = O(\log \log U)\)。
快速傅里叶变换 FFT
函数的模长:\(||f|| = \int_0^{2\pi} |f(x)|^2 \mathbf{d} x\) .
傅里叶变换 FT:对于任意周期为\(T\)的函数\(f(t)\),记\(\omega_0 = \frac{2\pi}{T}\)其总可以表示为 \(f(t) = a_0 + \sum\limits_{n=1}^{+\infty}[a_n \cos(n \omega_0 t) + b_n \sin(n \omega_0 t)] = \sum\limits_{-\infty}^{+\infty} c_n e^{i n \omega_o t}\)。
离散傅里叶变换 DFT:对于一串有限数列\(\{ x_n \}_{n=0}^{N-1}\),其DFT为 \(y_k = \sum\limits_{n=0}^{N-1}x_n e^{-i\frac{2\pi k n}{N}}\)。该式可以视为矩阵乘法:\(Y = \Omega X\)。
逆离散傅里叶变换 IDFT:离散傅里叶变换的逆操作:\(x_k = \frac{1}{N} \sum\limits_{n=0}^{N-1}y_n e^{i\frac{2\pi k n}{N}}\)。
快速傅里叶变换 FFT:直接求DFT是\(O(n^2)\)的,FFT是快速求DFT(或IDFT,二者形式类似)的算法,能做到\(O(n \log n)\).
Cooley-Tukey算法
记基旋转因子\(W_N = e^{-i \frac{2\pi}{N}}\),于是DFT公式变为 \(y_k = \sum\limits_{n=0}^{N-1} x_n W_N^{kn}\)。注意到,\(\Omega\)矩阵中第n行k列的元素正是\(W_N^{kn}\)。
\(W_N\)具有如下性质:
- 周期性:\(W_N^r = W_N^{r+N}\);
- 半周期对称:\(W_N^r = - W_N^{r + \frac{N}{2}}\);
- 可约性:\(W_N^{2r} = W^{r}_{N/2}\).
不妨假设\(N-1\)是奇数,如果不是则可以在\(\{x\}\)中补一个系数为0的最高次项。
对DFT公式分别提取出偶数项和奇数项,得到:\(y_k = \sum_\limits{r=0}^{N/2-1}x_{2r}W_N^{k\cdot2r} + \sum_\limits{r=0}^{N/2-1}x_{2r+1}W_N^{k\cdot(2r+1)}\);
对奇数项提出一个\(W_N^k\),得到:\(y_k = \sum_\limits{r=0}^{N/2-1}x_{2r}W_N^{2kr} + W_N^{k} \cdot \sum_\limits{r=0}^{N/2-1}x_{2r+1}W_N^{2kr}\);
由\(W\)的可约性,得到:\(y_k = \sum_\limits{r=0}^{N/2-1}x_{2r}W_{N/2}^{kr} + W_N^{k} \cdot \sum_\limits{r=0}^{N/2-1}x_{2r+1}W_{N/2}^{kr}\);
记\(g_k = \sum_\limits{r=0}^{N/2-1}x_{2r}W_{N/2}^{kr}\),\(h_k = \sum_\limits{r=0}^{N/2-1}x_{2r+1}W_{N/2}^{kr}\),所以原式化为\(y_k = g_k + W_{N}^{k} \cdot h_k\)。两子项都是规模减半的标准DFT形式,可以分别递归求解。
求解\(g_k\)和\(h_k\)后合并求解\(y_k\)。对于\(k = 0,1, \cdots, N/2 - 1\),直接\(y_k = g_k + W_N^k \cdot h_k\) 即可。对于下标高的一半,记\(k + N/2\),其中 \(k = 0,1, \cdots, N / 2 - 1\),有\(y_{k + N/2} = G_{k + N/2} + W_N^{k + N/2} \cdot h_{k + N/2} = g_k - W_N^{k} \cdot h_k\) 。
总时间复杂度\(O(N \log N)\)。
多项式乘法
定义
以下\(a\)和\(b\)都是\(N-1\)次多项式,如果不是则可以在高位补系数0得到\(N\)次多项式。
线性卷积:多项式 \(c = a \times b\) 指 \(\forall m \in [0, 2N-2], c_m = \sum\limits_{0 \le i,j\le m, i+j=m}a_ib_j = \sum\limits_{k=\max(0,m-N+1)}^{\min(N-1, m)} a_k b_{m-k}\),这是多项式乘法的卷积。
循环卷积:多项式 \(c = a \otimes b\) 指 \(\forall m \in [0, N-1], c_m = \sum\limits_{k=0}^{N-1} a_k b_{(m-k)\mod N}\),这是DFT满足的卷积。
前置定理
代数基本定理:一个\(N-1\)次多项式可由平面上\(N\)个不同点唯一确定。
卷积定理:记\(F\)表示傅里叶变换,对于两个\(N-1\)次多项式\(a, b\),有\(F(a \otimes b) = F(a) \cdot F(b)\)。其中\(\otimes\)是多项式卷积,\(\cdot\)是对应项相乘(即 \(c_k = a_k b_k\))。
证明:$F(a \otimes b) = \sum\limits_{n=0}^{N-1} (\sum\limits_{k=0}^{N-1} a_k b_{(n-k) \mod N}) W_N^{kn} = \sum\limits_{n=0}^{N-1} a_k (\sum\limits_{k=0}^{N-1} b_{(n-k) \mod N} W_N^{kn}) $,
令\(p = (n-k) \mod N\),原式化为 \(\sum\limits_{n=0}^{N-1} a_k (\sum\limits_{k=0}^{N-1} b_{p} W_N^{k((p+n) \mod N)})\)。由于内层求和顺序不影响结果,于是进一步将原式化为 \(\sum\limits_{n=0}^{N-1} a_k (\sum\limits_{p=0}^{N-1} b_{p} W_N^{k(p+n)}) = \sum\limits_{n=0}^{N-1} a_k (\sum\limits_{p=0}^{N-1} b_{p} W_N^{kp} W_N^{kn}) = \sum\limits_{n=0}^{N-1} a_k W_N^{kn} (\sum\limits_{p=0}^{N-1} b_{p} W_N^{kp})\)。后面括号内的求和不依赖\(n\),于是提取公因子得到原式\(= F(a) \cdot F(b)\)。
多项式乘法
卷积定理借助FFT可以在\(O(n \log n)\)的时间复杂度内实现多项式循环卷积,想要计算多项式乘法需要通过操作利用循环卷积得出线性卷积。
方法是将\(a, b\)的高位补零,直至补到\(L\)位,其中\(L\)是满足大于等于\(2N-1\)的最小整数(一般会进一步补到2的整数次幂,便于之后FFT算法执行)。此时对补零后的两个多项式计算其循环卷积,得到的结果的前\(2N-1\)位即为所求。
证明:线性卷积中对最终结果有实际贡献当且仅当\(k\)满足\(0 \le k \le N-1\)且\(0 \le m-k \le N-1\)(即原式中求和符号的上下界)。当 \(0 \le m \le 2N - 2\)时:
当\(k\)满足\(0 \le k \le N-1\)且\(0 \le m-k \le N-1\)时,循环卷积中的取模可以去掉,此时该式等于线性卷积。
当\(k > N - 1\)或\(m - k < 0\)或\(m - k > N - 1\)时,由于补的0至少有\(N\)个,所以这些下标对应的数都是0,不会对结果做出贡献。
综上补零后的循环卷积的前\(2N-1\)位就是线性卷积的结果,而由卷积定理 \(a \otimes b = F^{-1} (F(a) \cdot F(b))\)。从而在\(O(N \log N)\)的时间复杂度内计算多项式乘法。
动态规划
SRTBOT 模型
Subproblems 子问题定义 - Relate 递归关系 - Topology 拓扑顺序(计算图)- Base case 边界条件 - Original problem 原问题的解 - Time complexity 时间复杂度分析
反阿克曼函数alpha技术
Range Partial Sum Query 问题
RPSQ问题:某运算满足结合律、交换律,但不满足任意元素有逆元(如min),支持静态查询其任意区间和。
注:RPSQ问题不一定满足可重复贡献,所以ST表对RPSQ问题不适用。
利用Yao算法可以将RPSQ问题在\(O(n \alpha (n))\)预处理,\(O(\alpha(n))\)静态查询解决。
基础算法:\(P_0 = (O(n \log n), O(1))\)。
考虑取区间中点,分别记录中点左边元素到中点的后缀和,右边元素到中点的前缀和。这样跨越区间中点的查询可以\(O(1)\)得到。对于仅在左侧或右侧的区间,递归地进行取中点求左侧后缀和右侧前缀和的操作,共计\(\log n\)层,每层预处理\(O(n \log n)\)。查询时只需要找到区间跨过的中点即可\(O(1)\)求出区间和。
找到区间中点需要先把总区间扩展到2的整次幂(补充元素任意,只需保证中点一定落在有意义的值中)。如此,每个区间中点较其左端点只有某个位上的0变1。而RPSQ问题的递归树是一棵二叉查找树,中线节点的最高位在左子树中是0,在右子树中是1,且位数不会比中线节点多(左子树小于,右子树等于)。找到\([l,r]\)跨过的中线,只需找到\(l\)和\(r\)对应节点的LCA即可(任意公共祖先均可),即找\(l\)和\(r\)的最长公共前缀,之后的位补0求得即LCA节点。
求最长公共前缀只需将\(l\)与\(r\)异或,计算异或结果的二进制位数(直接取log,可以\(O(n)\)预处理,用的时候直接\(O(1)\)查表),再将1左移对应位数后减一得到掩码,\(l\)减去\(l\)与掩码按位与的结果就是所求的值。
路径压缩并查集
维护数据结构,支持:(1)合并两个元素所在的两个集合,(2)查找某元素所在集合的代表元素。
秩:每个节点有秩,定义为其到其最深儿子的距离。一个点的秩是0。
带路径压缩的并查集在合并时将深度浅的集合合并到深度深的集合上,查询时将集合内查找路径上所有遍历到的节点的父节点指向代表元素。
def union(u, v):
if find(u).rank > find(v).rank:
swap(u, v)
find(u).parent = find(v)
def find(u):
if u != u.parent:
u.parent := find(u)
ret u.parent
证明:
假定先执行所有的union操作,再执行find操作。将每个find操作对应其遍历到父节点的路径\(\{p_i\}\),一次find的开销即\(\text{cost}|\{p_i\}|\)。由于假设先执行了union操作,find操作在最后的大树上对应的路径可能并不是最终到达大树的根节点,而是到某一子树的根节点。
将大树按秩分为上下两部分,记作top和bottom。
对任意整数 \(s\),\((X_{\leq s}, X_{>s})\) 构成森林 \(F\) 的一个分解,满足:
1. \(F(X_{\leq s})\) 是秩 \(\leq s\) 的森林:低秩层诱导出的子森林中,所有节点秩不超过 \(s\)。
2. \(F(X_{>s})\) 是高秩森林:高秩层诱导出的子森林。
3. \((X_{\leq s}, X_{>s})\) 是向上闭的(upward closed):若某节点 \(x \in X_{>s}\),则 \(x\) 的所有祖先也必须在 \(X_{>s}\) 中。
4. 高秩节点极少:\({|X_{>s}| \leq \frac{|X|}{2^{s+2}}}\)
主引理:
设 \(C\) 是森林 \(F\) 上节点集 \(X\) 中的一系列 \(\text{Find}/\text{Compress}\) 操作。任取分解 \((X_+, X_+)\)(即上述 \((X_{\leq s}, X_{>s})\)),令:
- \(C_b\):完全落在低秩子森林 \(F(X_{\leq s})\) 中的操作(bottom)。
- \(C_t\):涉及高秩节点的操作(top)。
则有:
操作数控制:\({|C_b| + |C_t| \leq |C|}\)
代价分解:
\({\text{cost}(C) \leq \text{cost}(C_b) + \text{cost}(C_t) + |X_b| + |C_t|}\)
解释:
- 路径压缩时,低秩节点被直接连到高秩祖先上,产生"跳跃"代价。
- \(|X_b|\) 控制低秩节点被直接挂到高层的总次数。
- \(|C_t|\) 控制高秩部分操作本身的代价。
- 因此总代价 = 低秩子问题代价 + 高秩子问题代价 + 线性额外项。
由主引理,对森林 \(F\) 和操作序列 \(C\):
\(T(F,C) \leq T(F_b, C_b) + T(F_t, C_t) + m_t + n_b}\)
其中:
- \(F_b = F(X_{\leq s})\),最大秩 \(r_b = s\),节点数 \(n_b \leq n\)
- \(F_t = F(X_{>s})\),最大秩 \(r_t = r - s < r\),节点数 \(n_t \leq n / 2^{s+2}\)
- \(m_t = |C_t|\),高秩部分的操作数
Bound 0:平凡上界 \(T(m,n,r) \leq n \cdot r\)
解释:每个节点最多被访问 \(r\) 次(秩严格递增,路径长度 \(\leq r\))。
Bound 1:迭代对数界 \(T(m,n,r) \leq m + 2n \cdot \log^* r}\)
证明概要:
1. 在递推中取阈值 \(s = \log^* r\)。
2. 对高秩部分 \(F_t\) 用 Bound 0:
\(T(F_t, C_t) \leq n_t \cdot r_t \leq \frac{n}{2^{s+2}} \cdot (r-s)\)
由于 \(s = \log^* r\) 的选取使得高秩节点极少,此项被控制在 \(O(n)\)。
3. 递推变为:\(T(F,C) \leq T(F_b, C_b) + m_t + 2n\)
4. 对低秩部分 \(F_b\)(最大秩 \(s = \log^* r\))递归应用同样的分解。
5. 递归深度为 \(\log^* r\)(每次将最大秩替换为其迭代对数)。
6. 每层产生 \(m_t + O(n)\) 的代价。由于 \(|C_b| + |C_t| \leq |C|\),所有层的 \(m_t\) 之和 \(\leq m\)。
7. 总代价:\(\sum m_t + O(n \cdot \log^* r) \leq m + 2n \cdot \log^* r\)
继续迭代。定义 \(\log^{**,k} r\) 为 \(k\) 重迭代对数(对数塔迭代 \(k\) 次):
Bound 2:\(T(m,n,r) \leq 2m + 3n \cdot \log^{**} r\)
Bound \(k\):\({T(m,n,r) \leq k \cdot m + (k+2) \cdot n \cdot \log^{(*,k)} r}\)
其中 \(\log^{(*,k)}\) 表示第 \(k\) 级迭代对数。
定义反阿克曼函数:\(\alpha(n) = \min\left\{k \;\big|\; \log^{(*,k)} n < c\right\}\)
即:迭代对数塔进行多少次后,值会降到某个常数 \(c\) 以下。
当 \(k = \alpha(n)\) 时,\(\log^{(*,\alpha(n))} n = O(1)\),递归停止:
\({T(m,n,r) \leq m \cdot \alpha(n) + n \cdot \alpha(n) \cdot c = O(m \cdot \alpha(n))}\)
摊还分析
聚合分析 aggregate method
核方法 banker's method
势能法 potential method:定义势能函数\(\Phi(S_i)\)将数据结构的经过\(i\)次操作的状态\(S_i\)映射为一个实数。保证\(\forall i >0, \Phi(S_i) \le \Phi(S_0)\),则有 \(\bar{c_i} = c_i + \Phi(S_i) - \Phi(S_{i-1})\)。最终有 \(\sum c_i \le \sum \bar{c_i}\)。注:允许某个\(\bar{c_i}\)是负数,但最终\(\sum \bar{c_i}\) 一定是正的。
动态数组
动态数组是一个变长数组,记\(A(n,c)\)表示容量为\(c\)、实际存储\(n\)个元素的动态数组,其支持:
(1) init(): 创建一个平凡动态数组\(A(0,1)\);
(2) insert(x): 插入一个元素\(x\)。如果数组已满(\(n=c\)),则需先进行扩张grow;之后将第\(n\)个位置赋值为\(x\),然后\(n\)自增;
(3) get(i): 获得第\(i\)个位置的数;
(4) grow(): 新建一个\(A(0,2c)\),然后将原数据赋值进入新数组得到\(A(n,2c)\)。
以下用三种方法证明insert是均摊\(O(1)\)的。
聚合分析法
引理:任意一个长为\(m\)的insert操作序列的开销至多是\(3m\)。
证明:若没有grow,则开销是\(m\);
若有grow,记\(p\)为大于等于\(m\)的最小2的整数幂。有\(p \le 2m\)。最大一次grow移动的开销是\(\dfrac{p}{2}\),之前的grow移动的开销依次是\(\dfrac{p}{4}, \dfrac{p}{8}, \cdots, 1\)。由于\(p \le 2m\),故第一项\(\dfrac{p}{2} \le m\),第二项\(\dfrac{p}{4} \le \dfrac{m}{2}\),依次类推,放缩后求和即\(1 + 2+ 4 + \cdots + 2^k + \cdots+ m \le 2m\)。
故\(m\)次insert的总开销是\(m + 2m = 3m\),单次insert的开销是均摊\(O(1)\)的。
核方法
每个insert操作,除了自身插入的1开销外,再存入2点。每次grow的时候,此时有\(n=c\),后半\(\dfrac{n}{2}\)个元素是在上一次grow之后加入的,则用这些元素存入的共\(n\)点支付grow原本的移动\(n\)次开销。故每次insert开销3,grow开销0,总计均摊开销是\(O(1)\)的。
势能法
构造\(\Phi(n,c) = 2n - c\)。检验:\(\Phi_0 = \Phi(A(0,1)) = - 1, \Phi_i \ge 0 \ge \Phi_0\) 成立。
对于没有grow的insert,只有\(n\)加了1,\(\bar{c_i} = c_i + \Delta\Phi = 1 + 2 = 3\);
对于有grow的insert,此时有\(n=c\),\(c\) 变成 \(2c\),\(n\)加了1,\(\bar{c_i} = c_i + \Delta\Phi = n + 1 + ((2n+2 - 2c) - (2n - c)) = 3\);
故均摊开销是\(O(1)\)的。
二项堆
二项堆是一个堆森林,支持:
(1) make_heap(): 新建一个子堆;
(2) insert(H, x): 在子堆H中插入元素x;
(3) extract_min(H): 找到子堆H中的最小元素并将其弹出;
(4) union(H1, H2): 合并两个子堆H1和H2;
(5) decrease_key(H, k, x): 将子堆H中的元素k减小x;
(6) delete(H, k): 删除子堆H中的元素k。
其中(1)是\(O(1)\)的,(2)是均摊\(O(1)\)的,(3)(4)是\(O(\log n)\)的,不支持(5)(6)。
二项树:\(B_0\)是一个孤立节点。\(B_k\)由2个\(B_{k-1}\)构成,其中一个\(B_{k-1}\)作为最右子树连接到另一个\(B_{k-1}\)的根节点上。
性质:
- \(B_k\)有\(2^k\)个节点;
- \(B_k\)的高度是\(k\);
- \(B_k\)中深度为\(d\)的节点数有\(C_k^i\)个(二项式系数);推论:\(B_k\)的根节点的度是\(k\);
- \(B_k\)可以视为\(B_0, B_1, \cdots, B_{k-1}\)连接在同一个根节点得到;
二项堆是一系列\(B_k\)的森林。有一个指针数组\(p_k\),若非空,则\(p_k\)指向一个满足最小堆性质的\(B_k\)。另设置全局\(\min\)值,并记录全局min是来自哪课子树。
两个同阶二项树可以\(O(1)\)合并,只需将根节点权值更大的一个连接到根节点权值更小的一个即可。
union(H1, H2):新建一个二项堆空间(或者扩张原本某个堆的空间),然后将原本两个二项堆对应阶二项树合并(合并后可能产生进位,类似二进制加法),得到合并后的新堆。该操作时间复杂度是\(O(\log n)\)的。
insert(H, x):建立只有一个元素x的二项堆,然后将其与H合并。该做法等价于每次加一的二进制计数器,最坏时间复杂度是\(O(\log n)\)的,均摊时间复杂度是\(O(1)\)的。
extract_min(H): 找到H全局min来自的子树T,由性质4,用T去除根节点的若干子树构成一个新二项堆,删除原堆中的T,然后将新堆和原堆合并。该操作是\(O(\log n)\)的。
斐波那契堆
斐波那契堆是二项堆的优化。斐堆仍为若干二项树的森林,但允许一个堆里有多个同阶二项树,指针不再存在数组中,而是存在一个双向链表里。同时,堆中的二项树可能残缺,因为斐堆支持切分操作,将二项树的某个子树从二项树中切除并直接挂到双向链表中。
union:直接合并双向链表,时间复杂度\(O(1)\)。
consolidate:将斐堆转化成二项堆。新建一个二项堆,然后逐棵将斐堆中的子树插入二项堆。该操作是均摊\(O(\log n)\)的。
如需要,再将二项堆转化成斐堆。新建双向链表,将剩下\(O(\log n)\)个二项树插入,时间复杂度\(O(\log n)\)。
extract_min: 利用consolidate将斐堆转化成二项堆,然后执行二项堆的extract_min即可。时间复杂度是均摊\(O(\log n)\)的。
斐堆中的二项树可能是不完整的,对于每一个节点,使用一个mark去标记。一开始mark置false,一旦某个节点失去一个儿子,则mark置为true。当某个节点失去第二个儿子时,重新将mark置为false,然后切断该节点和其父节点的连接,将以该节点为根节点的子树挂到双向链表上。然后递归地更新原本父节点的mark。
一个被切割出来的子树在之后的操作中可能被继续切割,但一个根节点即使失去2个儿子也不会再次从双向链表中分离,换言之,根节点的mark标记无效。
斐堆的操作(1)(2)(4)是\(O(1)\)的,(3)是均摊\(O(\log n)\)的,(5)(6)是均摊\(O(1)\)的。
计算理论
复杂度类
P: 能在多项式时间内被确定性图灵机解决的问题。
NP: 能在多项式时间内被验证的问题。
NP-hard: 所有NP问题都可以在多项式时间内归约到该问题。 (至少和NP最难的问题一样难)
NPC:既是NP又是NP-hard的问题。
多项式时间
记输入元素个数为\(n\),输入元素值域为\(U\)。
强多项式 strongly polynomial:\(T(n) = O(n^k)\)
弱多项式 weakly polynomial:\(T(n, U) = O(n^k \cdot \log^d U)\)
伪多项式 pseudo polynomial:\(T(n, U) = O(n^k \cdot U^d)\)
强多项式只与输入元素个数有关,弱多项式与输入元素个数和需要存储输入元素的bits数有关,伪多项式与输入元素个数和输入元素值域有关。
其中,强多项式和弱多项式是P问题,伪多项式不是。
规约
对于两类问题A、B,若存在一个多项式内可计算函数\(f\),使得\(\forall x \in A, f(x) \in B\),则成\(A\)可以多项式内规约到\(B\),记作\(A \le_p B\)。
若\(A \le_p B\),则\(B\)至少比\(A\)难,即若\(B \in P\),则\(A \in P\);若\(A \in NP\text{-hard}\),则\(B \in NP\text{-hard}\)。
3-SAT 问题
问题描述:有\(n\)个bool变量\(\{p_i\}\),\(m\)个3元或子句,是否存在一个赋值方式使得这些子句的与为真?
该问题是NPC的。故若能证明\(\text{3-SAT} \le_p A\),则\(A\)是NP-hard的。
3DM 问题
问题描述:有\(n\)个元素,每个元素属于集合\(X, Y, Z\)之一,其中\(|X| = |Y| = |Z|\)。有\(m\)个三元组形如\((x_i, y_j, z_k)\)。判定能否找出若干三元组满足不重不漏地覆盖了所有元素。
尝试从3-SAT规约到3DM。构造\(T = X \times Y \times Z\),解决\(T\)的3DM问题就能解决原本的3-SAT问题。
方便起见,从n-SAT问题规约到3DM(因为3-SAT \(\le_p\) n-SAT是易证的,转化方法是给每个子句中未出现的项拆成两个子句,分别补\(p\)和\(\neg p\)即可)。以下记\(n\)个元素\(\{p_i\}\)和\(m\)个子句的n-SAT问题(所有子句都是满项的)。
变量gadget:记在第\(j\)个子句中出现的第\(i\)个逻辑变量为\(p_i[j]\)。对于这\(m\)个\(p_i\),每个变量对应\(X\)中2个值\(x_i[j]\)和\(\bar{x_i}[j]\),分别表示\(p_i[j]\)取真和取假。同时设置\(Y\)中共\(mn\)个辅助值\(a_i[j]\)和\(Z\)中共\(mn\)个辅助值\(b_i[j]\)。建边\(\forall i,j: (x_i[j], a_i[j], b_i[j]), (\bar{x_i}[j], a_i[j], b[(j+1)\%m])\),这样保证同一个\(p_i\),要么全取\(x_i[]\),要么全取\(\bar{x_i}[]\)。
子句gadget:设置\(Y\)中共\(m\)个辅助值\(p[j]\)和\(Z\)中共\(m\)个辅助值\(q[j]\),对应第\(j\)个子句\(C_j\)。若\(p_i\)在\(C_j\)中无取反号则用\(x_i[j]\)相连,有取反号则用\(\bar{x_i}[j]\)相连,记作\(\hat{x_i}[j]\)。建边\(\forall j, \forall i: (\hat{x_i}[j], p[j], q[j])\)。这样保证改子句\(C_j\)中只要有1一个对应\(\hat{x_i}[j]\)为真则子句取值就是真。
垃圾回收:由于变量gadget只保证取真或假,剩下的另一半没有匹配;以及子句gadget每个子句中只有一个变量被覆盖,剩下\(n-1\)个元素没有匹配,因此需要垃圾回收保证每个元素都有覆盖。对于原本的\(2mn\)个\(x_i[j]\)在经过变量gadget(匹配了\(mn\)个)和子句gadget(匹配了\(m\)个)剩下的\(m(n-1)\)个\(x_i[j]\),设置\(Y\)中共\(m(n-1)\)个辅助元素\(g_1[k]\)和\(Z\)中共\(m(n-1)\)个辅助元素\(g_2[k]\),对应每个\(k\)从\(1\)到\(m(n-1)\)与这些剩下的\(x_i[j]\)连边,保证不重不漏地覆盖。
最终\(X, Y, Z\)中都有\(2mn\)个元素。
Subset Sum 问题
问题描述:有\(n\)个正整数\(\{a_1, a_2, \cdots , a_n\}\)和一给定正整数\(T\),判定是否存在\(\{a_n\}\)的子集满足子集元素和等于\(T\)。
尝试从3DM规约到Subset Sum。设\(|X| = |Y| = |Z| = n\)。设置一个足够大(确保不会产生进位)进制的\(B\),对于每个边\((x_i, y_j, z_k)\),记其对应数\(a_l = B^{3n-i}+B^{2n-j}+B^{n-k}\),Subset Sum的目标\(t\)为\(B\)进制下\(3n\)位全为1的数。则解决这\(m\)个\(\{ a_l \}\)的Subset Sum的问题就可以解决原本的3DM问题。
Partition 问题
问题描述:有\(n\)个正整数\(\{a_1, a_2, \cdots , a_n\}\),记\(\sigma = \sum\limits_{i=1}^{n}a_i\),判定是否存在\(\{a_n\}\)的子集满足子集元素和等于\(\dfrac{\sigma}{2}\)。
下证Subset Sum \(\le_p\) Partition:
Subset Sum问题是说:有\(n\)个正整数\(\{a_1, a_2, \cdots , a_n\}\)和一给定正整数\(t\),判定是否存在\(\{a_n\}\)的子集满足子集元素和等于\(t\)。将其转化为Partition问题再予以解决,就能证明上述命题。记\(\sigma = \sum\limits_{i=1}^n a_i\)。
添加两个值\(a_{n+1} = \sigma + t, a_{n+2} = 2\sigma - t\)。对这\(n+2\)个数做Partition问题,则分成两部分各自和为\(2\sigma\),且易知\(a_{n+1}\)和\(a_{n+2}\)分局两部分。在\(a_{n+2}\)所在的那一部分,剩余的元素之和为\(t\)。于是解决这\(n+2\)个数的Paritition问题就相当于解决了\(n\)个数的Subset Sum问题。由于Subset Sum是NP-hard的,所以Partition至少是NP-hard的。

浙公网安备 33010602011771号