学习笔记:《算法竞赛进阶指南》

退役前夕,我决定把之前写过的总结公布出来。由于之前它仅供个人复习用,还没有写完。其中或许有表述不够详尽之处,望读者理解。

结尾有彩蛋哦w

Initialize. - Notes

0x01 位运算

  • 注意利用无符号数的自然溢出来取模。

  • 运用二进制拆分来实现较大整数的乘方、乘法运算。

  • 注意long long在二进制下有64位,而在十进制下只有18位。

  • 位运算技巧:

    操作 运算
    取第 k k k (n >> k) & 1
    取后 k k k n & ((1 << k) - 1)
    将第 k k k位取反 n ^ (1 << k)
    k k k位赋1 ``n
    k k k位赋0 n &= (~(1 << k))
  • 一些操作符优先级(从左往右、从上往下,从高到低):

    非、按位取反 移位 大小比较 相等比较 按位与
    !~ >><< ><<=>= ==!= &
    按位异或 按位或 逻辑与 逻辑或 条件运算符
    ^ `` `` && ``

0x03 前缀和与差分

知识点

  • 在网格图上,“格子”与“格线交点”之间的转化:

    学习笔记:《算法竞赛进阶指南》_第1张图片

    上下两图分别对应不考虑正方形边界、考虑正方形边界时的情况。

  • 差分是前缀和的逆操作,对差分数组求前缀和得到原数组,对前缀和数组执行差分也得到原数组。

  • 对所有元素都相同的数组执行差分后得到一个全部为0的数组。换句话说,如果要把一个数组全都变成一个数,就是要使得其差分数组全为零。

  • 区间修改对应在差分数组上就是两个地方的修改。

题目

题号前加“~”的需要注意,加“^”标的,如果有时间最好实现一下。

~CH0304

考察对差分性质的掌握。把原数组转化为差分数组这一步不难想到,关键是最小操作步数和最终不同结果数的计算。

POJ3263

通过“某两头牛之间可以互相看见”想到”这两头牛之间的牛都比它们矮“这一点,就不难想到用单点修改代替区间修改了。这样应该可以把 Θ ( N 2 ) \Theta(N^2) Θ(N2)优化到 Θ ( N ) \Theta(N) Θ(N)

0x04 二分

知识点

序列上二分

可以使用STLlower_bound()upper_bound()来代替。

一般的用法是lower_bound(iterator begin, iterator end, type val),返回一个与begin相同类型的iterator

还可以重载运算符,或在外部写一个bool cmp(argument list)函数。对于后者,将cmp作为第四个参数传入即可。

实数域上二分
  • 若保留 k k k位小数,则 ϵ = 1 0 − ( k + 2 ) \epsilon = 10^{-(k + 2)} ϵ=10(k+2);可以使用固定次数的二分来获取更高的精度。
二分答案

一个不用考虑一切边界条件的二分模板:

int l = 1, r = n;

//设check()函数返回true表示当前是可行解。
while (r - l > 3) {
    int mid = (l + r) >> 1;
    if (check(mid)) r = mid; //或l = mid
    else l = mid + 1; //或r = mid - 1
}
for (int i = l;i <= r;++i) { //根据题意,升序或降序枚举。
    if (check(i)) {
        ...
        break;
    }
}
用于归并排序的二分
void mergeSort(int l, int r)
{
    if (l == r) return;
    int mid = (l + r) >> 1;
   	mergeSort(l, mid);
    mergeSort(mid + 1, r);
    ...
}

只有这种写法是正确的。以下是错误的写法:

...
mergeSort(l, mid - 1);
mergeSort(mid, r);
...

l = 1 , r = 2 l = 1, r = 2 l=1,r=2的时候, m i d = 1 mid = 1 mid=1,此时显然进入左子区间时 l > r l \gt r l>r。即使把第一行的条件变为if (l >= r) return;,进入右子区间后, l , r l,r l,r不变,陷入死循环。

在正数范围内,算术右移和除法的计算结果都是一样的,可以看做向零取整。在考虑二分的边界条件时,需要在这个基础上,按照区间区间长度为奇数和偶数(可以分别令 l = 1 , r = 2 l = 1, r = 2 l=1,r=2 l = 1 , r = 3 l = 1, r = 3 l=1,r=3),分别考虑是否会导致死循环或边界错误的问题。

三分
double l = -25.0,r = 25.0;
if (a > 0)
{
    while (r - l > EPS)
    {
        double lmid = l + (r - l) / 3; //注意,这种方式得到lmid要用(r - l)。
        double rmid = r - (r - l) / 3;
        if (func(lmid) > func(rmid)) l = lmid;
        else                         r = rmid;
    }
    printf("x = %lf, f(x) = %lf",l,func(l));
}
else
{
    while (r - l > EPS)
    {
        double lmid = l + (r - l) / 3;
        double rmid = r - (r - l) / 3;
        if (func(lmid) > func(rmid)) r = rmid;
        else                         l = lmid;
    }
    printf("x = %lf, f(x) = %lf",l,func(l));
}

题目

~POJ2018

按照经验,拿到一道题之后我们应该先想一想,这道题的答案是否具有单调性?本题显然就是有的。

解决本题还需要转化。转化后题目就变成问你是否存在一个子段满足子段和非负。然后想到求序列的最大子段和,这是一个经典问题。

没有限制的最大子段和问题运用了贪心思想。而本题中,子段最小长度受到限制。既然如此,对于每个子段的右端点 r r r,其左端点的范围随着 r r r的增大而增大。也就是说,决策集合只增大不减小,所以可以只用一个变量来维护当前决策集合中的最优解。这个优化在后面LCIS一题中也用到了。

0x05 排序

知识点

函数 / 数据结构 less<type>() greater<type>()
sort() 升序排序 降序排序
STL priority_queue 大根堆 小根堆
货仓选址问题

在一个数轴上分布着一些点,位置分别是 p 1 , p 2 , … , p n p_1,p_2,\dots,p_n p1,p2,,pn。在数轴上选取一个点,使得这个点到其余各点的距离总和最小。

p 1 , p 2 , … , p n p_1, p_2, \dots, p_n p1,p2,,pn的中位数即可。如果 n n n是偶数,那么可行的所有位置构成一段区间。

均分纸牌问题

给出一个序列,第二个数到倒数第二个数可以将自己分配给两边的数,第一个数和最后一个数只可以向一个方向。问至少经过多少次操作可以使得所有数字相等。

在有解的情况下,先计算出所有数的平均数,然后依次考虑每一个数。如果当前的数比平均数大,那么就把超过平均的部分分配给下一个数,反之则从下一个数中取出一部分补上不足平均的部分。

这里虽然是从左往右处理的,但是对于任意两个相邻的数 i , i + 1 i,i + 1 i,i+1 i i i i + 1 i + 1 i+1中取相当于 i + 1 i + 1 i+1 i i i分配; i i i自然也可以分配给 i + 1 i + 1 i+1,符合题意。

如果规定一次只可以分配1,那么在上述做法中,每次分配对答案造成的贡献就不再是1,而是 i i i与平均数之间的差的绝对值,即
∑ i = 1 n ∣ i × ave + ∑ j = 1 i a i ∣ \sum_{i = 1}^{n}|i \times \text{ave} + \sum_{j = 1}^{i}a_i| i=1ni×ave+j=1iai

环形均分纸牌问题(~BZOJ3032)

假设一次只可以分配1。

对于环形问题,我们考虑破环成链。一种朴素的算法是枚举环断开的位置 k k k,将原问题转化为 n n n次均分纸牌问题。

这里在处理“平均数”的时候用到了一个技巧,就是把所有的数字全部减去这个平均数。这个技巧在前面POJ2018一题中也使用过。这样一来,我们就可以把“数字与平均数之间的大小关系”转化为“数字的正负性”,从而简化我们的推导。

关于具体细节书上P35、P36写的很清楚了。

纵横拆分法

对于像在“棋盘”上的操作,如果纵向的操作与横向的操作之间互不干扰,那么我们可以只处理其中的一个问题,而另一个问题采用同样的解法。

动态中位数

就是动态地维护一堆数的中位数。当读入奇数个数时,输出中位数。

使用“对顶堆”算法解决这个问题。声明两个优先队列,一个是大根堆,一个是小根堆。大根堆维护当前已经读入的所有数中较小的数的最大值,而小根堆维护剩下的较大的数中的最小值。规定大根堆中数的个数不超过当前已经读入的数的个数的一半(注意是向下取整)。那么此时小根堆的堆顶就是中位数。

每次读入一个数,就和两个堆顶比较大小,插入相应的堆中,再调整堆的大小。

动态K小数

令大根堆中的元素个数不超过 K − 1 K - 1 K1即可。

题目

CH0503

这应该是一道结论题。奇数码问题两个局面相互可达,当且仅当将这两个局面按从上到下,从左到右的顺序写成一个序列(不考虑空格的位置)后,逆序对个数的奇偶性相同

偶数码问题两个局面相互可达,当且仅当把这两个局面按照上面的方法写成两个序列后,逆序对个数之差与两个局面中空格所在的行数之差的奇偶性相同

0x06 倍增

知识点

  • 倍增就是二分的逆运算。

    这一点我之前在做一道题(好像是考试题)的时候就遇到了,只不过当时我没有意识到。关于这一点,我们可以这样形象地体会一下:二分的时候,mid的移动方式是不是很像倍增时区间右端点的移动方式?

    有的时候,写倍增比写二分简单,而有的时候二分又比倍增易懂。我们应该时刻牢记这一点,在写程序遇到困难时不妨换个方式来写

  • 倍增算法适用于满足“递推的问题的状态空间关于2的次幂具有可划分性”的问题。

  • 在SHC-O中,T2用到了利用ST表在 Θ ( N log ⁡ N ) \Theta(N\log N) Θ(NlogN)的时间内计算任意区间内所有数字的GCD。这告诉我们,如果一道题需要维护关于区间的某个信息,可以考虑使用ST表的方式。另外,ST表在查询的时候是允许两个子区间存在交集的。

题目

~引入问题

二分有时是不如倍增的。如果给定的区间非常长,而目标区间(就是二分结束后,较二分之前扩展的区间)很小,这就会导致“慢收敛”现象。虽然二分是 Θ ( log ⁡ N ) \Theta(\log N) Θ(logN)的,但是这样的常数较大。

对于这个引入问题,我们可以像用倍增做LCA一样,在满足fa[u][i] != fa[v][i]的情况下,每次跳 2 k 2^k 2k步( k k k递减)。当然这里有点不一样,由于目标区间可能很小,我们先递增枚举 k k k,如果超出某个限制再不断调小 k k k

~^CH0601

倍增有时也可以和一些基于分治的算法相结合,起到降低时间复杂度的效果。比如在本题中,求“校验值”需要排序,不必每次对已经包含的整个区间排序,而是只用对新增的部分排序,然后与前面已经排好序的区间合并即可。

0x07 贪心

知识点

贪心算法的适用情形

使用贪心算法需要问题满足全局最优解可以由局部最优解导出

贪心算法的证明方式
  • 邻项交换

    典例就是《国王游戏》,对于一类让你先排队然后再计算答案的问题,可以使用这种方式来证明贪心算法。本质上就是证明不等式成立。

  • 范围缩放

  • 决策包容性

    证明当前做出局部最优策略后,在状态空间中的后续可达集合包含了做出其他非局部最优策略的可达集合。之所以要证明这一点,就是因为如果这一点成立的话,那么现在做出的任何局部非最优策略都可以遍历到做出局部最优策略可以遍历到的状态,显然做出局部最优策略是更优的选择

  • 反证法

  • 数学归纳法

    数学归纳法的步骤是:

    • 证明命题对于 n = k n = k n=k k k k通常是一个很小的数)成立;
    • 假设命题对于任意自然数 m m m成立,证明命题对于 n = m + 1 n = m + 1 n=m+1也成立。

题目

最大子矩形和问题(~POJ1050)

这个问题的朴素做法是 Θ ( N 4 ) \Theta(N^4) Θ(N4)的,但是可以优化到 Θ ( N 3 ) \Theta(N^3) Θ(N3)

大致思路就是,先 Θ ( N ) \Theta(N) Θ(N)地预处理出每一行的前缀和,再用用 Θ ( N 2 ) \Theta(N^2) Θ(N2)的时间枚举哪些连续的列,在此基础上使用一维的最大子段和的方法,总时间复杂度为 Θ ( N 3 ) \Theta(N^3) Θ(N3)

POJ3190(反证法)

用反证法来证明贪心,大致是要证明我们的算法在执行局部最优策略的时候,不存在一个更优的策略。

本题在存在一个畜栏满足其中最后一头牛结束吃草的时间小于当前这头牛开始吃草的时间的时候采取的策略应该已经是最优的,证明不存在上述畜栏时的策略的最优性。

假设存在一种方案,使得需要的畜栏数量更少,记其需要的畜栏数量是 m m m。考虑在上述做法中,第一次新建第 m + 1 m+1 m+1个畜栏的时刻,不妨设当前处理的是第 i i i头牛。

由于所有牛是按开始时间从小到大排好序的,所以现在前 m m m个畜栏中最后一头牛的开始时间一定小于等于第 i i i头牛的开始时间。在这个时刻,前 m m m个畜栏中最小的结束时间一定也大于等于第 i i i 头牛的开始时间,所以前 m m m个畜栏里最后一头牛的吃草区间一定都包含第 i i i头牛的开始时间。

这样我们就找到了 m + 1 m+1 m+1个区间存在交集,所以至少需要 m + 1 m+1 m+1个畜栏,矛盾。所以不存在一个更优的策略。

~POJ3614(范围缩放)

用范围缩放法来证明贪心,大致是要证明对一个对象执行局部最优策略后,对其余尚未决策的对象的减益影响比不执行局部最优策略要小。

注意到在本节的五道题中,有四道都是基于排序的。这是否在提示我们,排序有利于我们对于贪心算法的设计?

0x11 栈

题目

例题1

这个栈可以 Θ ( 1 ) \Theta(1) Θ(1)地回答当前栈中的最值。

HDU4699

这题用到一个“对顶栈”算法,个人觉得扩展性不强。可以花一分钟思考一下这题的询问怎么做。经常去想,思维能力应该可以得到提高吧?不论是竞赛还是常规。

~^POJ2559

这题如果不告诉我是单调栈的话,我一定会想到DP的。

我觉得我就是缺乏“不局限于某一种思维方式”的能力,比如如果把这道题作为考试题,我在想不出DP的时候,就不会想“这道题能不能不用DP来做呢?”。

回到本题,我们之所以会想到某些题目应该使用一个怎样的算法或数据结构,或许是因为我们首先找到了一个看起来可解的方法,然后发现实现这个方法需要某某算法。对于本题,如果所有的矩形高度单调不降,那么答案是很好维护的,我们只需要从后往前统计长度,在遇到第一个比当前矩形矮的矩形时,计算已经处理过的矩形的面积,再更新答案即可。

如果不是单调不降的,也就是说存在一些矩形构成了“山峰”的样子。显然对于以后的矩形,“山峰”部分是不可能产生贡献的,能够产生贡献的只有“山脚”处的矩形。我们可以将“山峰”舍弃,这样最终留下的就是上述的一组高度单调不降的矩形。

“山峰”部分的答案如何统计呢?我们发现这部分依然是满足单调不降的,所以可以采用上述方式维护。最后,插入一个与“山脚”处矩形高度相同,长度就是原来“山峰”的长度的矩形即可。

0x12 队列

知识点

  • 整数可以自由移入、移出取整符号而不影响式子的值。

    这一点有的时候有助于我们推导公式。

题目

NOIP 2016 D1 T3

个人觉得这题根本没有用到什么算法,关键是要发现“对于任意时刻,后切割的蚯蚓分成的两部分和先切割的蚯蚓分成的两部分相比,后者更长”,这个性质可以大大简化我们找集合中最大值的过程。其余有一个技巧是:维护整个集合的偏移量

CH1201

连续的最大子序列和?这不就是最大子段和吗……不过,这题的最大子段和给出了最大长度限制,而前面在POJ2018一题中,分别给出了长度没有限制和限制最小长度的最大子段和问题。这里总结一下。

  • 没有长度限制的最大子段和问题

    运用了贪心的思想,当当前子段的权值和已经为负数的时候,不舍弃这个子段显然不可能更优。所以一旦出现这种情况就将当前子段和归零。

  • 限制最小长度的最大子段和问题

    容易发现对于每个子段,随着其右端点的增加,左端点的移动范围也在增加。也就是说,它的决策集合只增不减。因此只需用一个变量维护当前决策集合中的最值即可。

  • 限制最大长度的最大子段和问题

    本题中,需要使用单调队列。这个优先队列基于一个贪心的想法:如果两个数 a , b a,b a,b满足 a > b a \gt b a>b p o s a < p o s b pos_a \lt pos_b posa<posb,那么 a a a显然是无用的。另外,如果一个数已经“过时”,那么也要从决策集合中排除。这是“及时排除决策集合中一定不是最优解的选择”的思想。

0x17 二叉堆

题目

POJ2442

本题中用到一些思考和代码实现的技巧,可供借鉴。

  • 第一个是运用数学归纳法的思想,先计算出前两个式子的最小和、次小和……k小和,然后把这些和看作一个新的序列与第三个序列执行同样的操作。最终完成整个问题的求解。同样的思想还用在扩展中国剩余定理的实现当中。
  • 第二个,是从较小的数据规模开始考虑的思想。本题给出的序列个数很多,考虑直接对这么多序列计算答案比较困难,可以先从 M = 2 M = 2 M=2开始思考,得到上面的方法,才能解决整个问题。当然,这个方法似乎不是对每个题都适用的,尤其是一些树上问题。
  • 第三个,是对程序对于状态空间的遍历的优化。当同一个状态可以被多个状态遍历到(比如本题两个指针先移动一个,在移动另一个可以得到同一个状态),或者可以用多种方式表示时(比如在有些DP中),我们可以通过规定某种遍历顺序或是表示顺序,从而避免了重复。

0x21 树与图的遍历

  • 通过计算一棵树的DFS序,把树上操作转化为区间操作。当然,前提是题目应该有遍历整棵树的意思,比如像后面的《金字塔》一题。
树的重心
int centre, maxPart = 0x3f3f3f3f;
int size[MAXN];

void dfs(int u, int f)
{
    size[u] = 1;
    int tmpSize = 0;
    for (int i = head[u];i;i = edge[i].nxt) {
        int v = edge[i].to;
        if (v == f) continue;
        dfs(v, u);
        size[u] += size[v];
        tmpSize = max(tmpSize, size[v]);
    }
    tmpSize = max(tmpSize, n - size[u]);
    if (tmpSize < maxPart) {
        maxPart = tmpSize;
        centre = u;
    }
}
有向图的拓扑排序

拓扑排序可以判断有向图中是否存在环。而SPFA能够判断有向图或无向图中是否有负环。

0x31 质数

知识点

结论1

对于正整数 N N N,不超过它的质数个数大约有 N ln ⁡ N \dfrac{N}{\ln N} lnNN个。具体地说,若 N = 1 0 8 N = 10^8 N=108,那么比它小的质数不超过 5500000 5500000 5500000个。

这让我想起之前做的一道考试题,他询问小于正整数 N N N的质数的准确个数。那道题可以使用分块打表的方式得到80pts,好像也是可以AC的。

结论2

若正整数 N N N为合数,那么存在一个能够整除 N N N的整数 T T T,满足 2 ≤ T ≤ N 2 \le T \le \sqrt{N} 2TN

这个结论可以用于筛去一个区间内的所有合数,当然前提是区间的右界不是特别大。对于这种问题,我们可以先预处理出 1 ∼ R 1 \sim \sqrt{R} 1R 之间的所有素数,然后用埃氏筛筛去区间内的所有合数。

质数判定

  • 基础算法:试除法, Θ ( N ) \Theta(\sqrt{N}) Θ(N )

    这个方法利用了上面的结论2。我们只需要枚举 2 ∼ N 2 \sim \sqrt{N} 2N 之间的所有数,然后逐个检查是否能够整除 N N N即可。

    if (n < 2) return false;
    for (int i = 2;i <= sqrt(n);++i) {
        if (n % i == 0) return false;
    }
    return true;
    
  • 高级算法:Miller_Robbin素性测试, Θ ( log ⁡ N ) \Theta(\log N) Θ(logN),有一个一般为 8 8 8的常数。

埃氏筛, Θ ( N log ⁡ log ⁡ N ) \Theta(N \log \log N) Θ(NloglogN)

埃氏筛法虽然和线性筛相比,近似带有一个略小于3的常数,但是它的扩展性比较强。上面讲的区间筛素数,就是基于埃氏筛法的。

对于每个数 x x x,我们只把 ≥ x 2 \ge x^2 x2 x x x的倍数筛去。因为对于那些小于 x 2 x^2 x2 x x x的倍数,设其为 k x kx kx,那么显然有 k < x k \lt x k<x,那么在枚举 k k k的时候这个数一定已经被筛去了一次。

memset(vis, false, sizeof vis);
for (int i = 2;i <= n;++i) {
    if (vis[i]) continue;
    prime[++pid] = i;
    for (int j = i;j <= n / i;++j) vis[i * j] = true;
}
线性筛

线性筛通过“从大到小累积质因子”的方式标记每个合数。

for (int i = 2;i <= n;++i) {
    if (!vis[i])  prime[++pid] = i;
    for (int j = 1;j <= pid;++j) {
        if (i * prime[j] > n) break;
        vis[i * prime[j]] = true;
        if (i % prime[j] == 0) break;
    }
}

这个线性筛法和书上介绍的代码在效率上是相同的,但是这个方法占用更少的空间。对于vis数组,我们可以使用bool类型;而对于prime数组,根据结论1,它的长度比vis数组小得多。

质因数分解
  • 基础算法:试除法, Θ ( N ) \Theta(\sqrt{N}) Θ(N )

    for (int i = 2;i <= sqrt(n);++i) {
        if (n % i == 0) {
            p[++pid] = i;
            while (n % i == 0) {
                n %= i, cnt[pid]++;
            }
        }
    }
    if (n > 1) {
        p[++pid] = n;
        cnt[pid] = 1;
    }
    
  • 高级算法:Pollard’s Rho质因数分解, Θ ( N ) \Theta(\sqrt{\sqrt{N}}) Θ(N )

题目

~^CH3101

0x41 并查集

知识点

  • 并查集只使用路径压缩,复杂度为 Θ ( log ⁡ N ) \Theta(\log N) Θ(logN)
  • 并查集不仅仅可以维护连通性,还可以维护具有传递性的关系。而如果有多个关系可以互相导出,那么可以使用扩展域的并查集

题目

~POJ1456

这题首先基于一个贪心思想。我们优先卖出价值最大的商品,在此基础上,让这个商品的卖出时间尽可能地晚。对于那些过期时间比它早的商品,如果这个商品在较早的时间卖出,那么可能会导致这些商品过期;而如果尽量晚地卖出的话,期望卖出的商品个数更多。这就是“决策包容性”。

此题的关键在于维护每个商品的最晚可卖出时间。当然,朴素的方法就是从这个商品的过期日期当前向前查找,找到的第一个未被标记的日期就是答案。这样的时间复杂度是线性的。可以使用并查集优化,让每个日期直接指向他前面第一个可用的日期。

这个优化在后面POJ3694中也使用了。在那道题目里,并查集用于从树上的一个节点直接跳到满足 E ( u , f a [ u ] ) E(u, fa[u]) E(u,fa[u])不是割边的节点 u u u上,避免了每次仅仅是向上移动到 f a [ u ] fa[u] fa[u]的低效操作。

我们发现,在这两道题中,并查集都优化了在一个线性结构上寻找某个元素的前驱元素这一操作。这能够给我们带来一定启发。

边带权的并查集

对于这样的并查集,我们使用 d [ ] d[] d[]数组维护节点 u u u f a [ u ] fa[u] fa[u]的路径的边权。我们在使用路径压缩的过程中,就可以维护出每个节点到根节点之间的路径的信息。在CH4101一题中,这个信息就是某艘飞船距离队首的距离。如果询问两艘飞船之间的距离,可以用差分计算得到。

0x42 树状数组

知识点

大区间的划分

对于区间 [ 1 , r ] [1,r] [1,r],设 r r r的二进制表示中,为1的位分别是0、1、3,那么这个区间就被分成了 [ 1 , 7 ] , [ 8 , 10 ] , [ 11 , 11 ] [1, 7],[8,10],[11,11] [1,7],[8,10],[11,11],长度分别为3、1、0。

维护区间范围

对于树状数组中的元素 c [ x ] c[x] c[x]而言,它维护区间 [ x − lowbit ( x ) + 1 , x ] [x - \text{lowbit}(x) + 1, x] [xlowbit(x)+1,x]的前缀和。

树状数组的初始化
Θ ( N log ⁡ N ) \Theta(N \log N) Θ(NlogN)

N N N个数逐个插入树状数组。单次插入 Θ ( log ⁡ N ) \Theta(\log N) Θ(logN)

Θ ( N ) \Theta(N) Θ(N)

根据树状数组中每个元素维护的区间,我们可以 Θ ( N ) \Theta(N) Θ(N)地计算原序列的前缀和,然后 Θ ( N ) \Theta(N) Θ(N)赋值。

题目

~POJ2182

这道题让你构造一个满足要求的序列,一开始序列是空的。像这样的题目的突破口一般是最后一个元素,因为关于它的信息已经考虑了除它以外的所有元素了。

本题再次说明了”基于二分 / 倍增 / 分治思想的算法或数据结构与二分 / 倍增等算法一起使用时,将二者结合可以优化程序运行效率“这一点。

我们可以做个小结:

  • 倍增与归并排序的结合:

    用于优化《Genius ACM》一题中,区间“校验值”的计算。朴素算法需要在每次扩展区间范围后执行快速排序,时间复杂度为 Θ ( N log ⁡ 2 N ) \Theta(N \log^2 N) Θ(Nlog2N)。考虑每次只将扩展的部分与原先已经存在的部分合并,时间复杂度为 Θ ( N log ⁡ N ) \Theta(N\log N) Θ(NlogN)

  • 倍增与树状数组的结合:

    用于优化本题中“统计一个01序列中哪个位置的前缀和为 k k k”。朴素算法二分位置,并使用query函数查询当前位置的前缀和,时间复杂度为 Θ ( log ⁡ 2 N ) \Theta(\log^2 N) Θ(log2N)。考虑执行下图步骤:

    学习笔记:《算法竞赛进阶指南》_第2张图片

    如此可以将查找优化至 Θ ( log ⁡ N ) \Theta(\log N) Θ(logN)

0x43 线段树

知识点

  • 线段树的空间要开4倍。
  • 对于查询区间 [ l , r ] [l, r] [l,r],线段树会将其分为 Θ ( log ⁡ N ) \Theta(\log N) Θ(logN)个节点。
  • 线段树区间修改使用的“延迟”思想,值得我们在思考算法时参考。

题目

~CH4302

这道题询问区间 [ l , r ] [l, r] [l,r]的GCD。我们知道两个相邻或重叠区间的GCD是可以直接合并的。但是本题还有区间修改操作。显然是不能直接对GCD修改的。

解决这道题需要使用“更相减损术”求两个数的GCD的扩展:对于任意数量的整数 a 1 , a 2 , … , a k a_1,a_2,\dots,a_k a1,a2,,ak,它们的最大公约数 gcd ⁡ ( a 1 , a 2 , … , a k ) = gcd ⁡ ( a 1 , a 2 − a 1 , a 3 − a 2 , … , a k − a k − 1 ) \gcd(a_1, a_2, \dots, a_k) = \gcd(a_1, a_2 - a_1,a_3 - a_2,\dots,a_k - a_{k - 1}) gcd(a1,a2,,ak)=gcd(a1,a2a1,a3a2,,akak1)。我们可以计算出原数组的差分数组,然后计算出各个区间的GCD。对于修改操作,我们成功地将其转化为了单点修改,从而简化了GCD的维护。

除法线段树

这道题有操作将区间 [ l , r ] [l, r] [l,r]内的每一个数除以一个整数,结果下取整。

大致思路是:除法可以把一个数变小。而如果对许多同样的数同时除以一个数,那么就相当于区间减法。如何知道一个区间内的数是否都相同呢?首先对于一个数,他肯定是满足“都相同”这一点的,而对于其他区间,如果这里面的最大值等于最小值,那么这个区间内的所有数一定是相等的。

0x51 线性DP

知识点

DP状态的设计
  • 学习笔记:《算法竞赛进阶指南》_第3张图片

  • 对一个含有多个成员的结构(不一定是代码,可以是从题意中提炼出来的结构),对其某一个元素排序后,注意其他元素的顺序可能被打乱

  • 如果当前状态的信息和前一个状态密切相关,就要考虑记录每一个状态做的操作是什么。(《换教室》)

  • 背包容量的扩充和物品的放入抽象成对一个整数的加减(表示剩余容量),这既避免了复杂操作,又可以将这个整数作为状态。另外,在有些概率DP题目中,各个阶段的事件是没有影响的,我们可以对这些事件进行排序,使其更便于转移。(《守卫者的挑战》)

  • 有的时候,在DP的状态中,有些变量的取值范围理论上可能很大,这也许会阻止我们将其作为状态的一部分。我们可以思考是否在这个巨大的取值范围中,很大一部分是冗余的,从而考虑缩小取值范围,进而将其作为状态。(《守卫者的挑战》)

  • 如果DP状态表示的某一位可以是负数,就用偏移的方式处理。如果对这个变量有取绝对值的操作,那么最好不要把这个可能为负的变量的绝对值作为状态记录的值。否则还需要记录一个正数是由一个负数取绝对值后得到的,还是本来就是一个正数。对于绝对值我们可以考虑拆绝对值,而不要去考虑合并绝对值。(《BUY LOW BUY LOWER》)

  • 在设计线性DP的状态的时候,优先考虑DP的“阶段”。如果这样不足以表示一个状态,可以把所需的附加信息也作为状态的维度。(《Mobile Service》)

  • 怎样去发现这个“阶段”呢?回顾线性DP的性质:各个维度线性增长,这提示我们去寻找题目中满足“线性增长”的变量。在《传纸条》一题中,已经走过的路径长度显然符合这一点,我们考虑把它作为DP的阶段。(《传纸条》)

  • 在转移时,如果“无后效性”已经由“阶段”保证(如总是从一个阶段转移到下一个阶段),那么就无需关心其他维度的大小变化情况了。(《Mobile Service》)

  • 在设计状态的时候,应该尽量用最少的维度去覆盖整个状态空间。检查各个维度之间是否能够互相导出。(《Mobile Service》)

状态转移方程的设计
  • 我们不一定只能从“如何计算出一个状态”的角度来考虑,这是“填表法”的思路。我们还可以考虑“一个已知状态应该更新哪些后续未知状态”,这是“刷表法”的思路。(《Mr. Young’s Picture Permutations》)
  • 学习笔记:《算法竞赛进阶指南》_第4张图片
  • (CH5105预留坑位)
DP的简单优化
  • 在实现状态转移方程时,要注意观察决策集合的范围随着状态的变化情况。对于“决策集合中的元素只增多不减少”的情况,可以只用一个变量来维护决策集合中的最值,从而避免了重复扫描。(《LCIS》)

输出方案的DP

对于那些还需要输出方案的DP,我们一般有以下几种处理方法:

  • 使用一个与DP数组同样大小的数组,用来记录DP过程中以每个状态为目标的最优解是由哪个状态转移过来的。这样,在我们求出全局最优解之后,使用一次递归即可得到最优解的方案。

    大致框架是这样的:

    ...
        if (F[i][k][p][q][x][y] > ans) {
            ans = F[i][k][p][q][x][y];
            now = Pre(i, k, p, q, x, y);
        }
    ...
    while (now.j) {
        for (int i = now.l; i <= now.r; ++i) printf("%d %d\n", now.i, i);
        now = pre[now.i][now.j][now.l][now.r][now.x][now.y];
    }
    

    (《I-Country》)

  • (CH5105预留坑位)

0x52 背包

综述

背包问题的几种形式
最优性

这是最基本的背包问题。

方案数

将最优化问题中的取最值改为求和即可。

可行性

是不是将最优化问题中的取最值改成或运算就可以了呢?

背包问题的几种变式
可变背包容量
可变物品体积
背包问题的初始化
要求恰好装满背包

除了 F [ 0 ] [ 0 ] = 0 F[0][0] = 0 F[0][0]=0之外,初始化为极值。

不要求恰好装满背包

初始化为0。

完全背包

题目
~POJ1015

本题是一道具有多个“体积维度”的背包问题。回想二维费用背包的处理方式,我们可以对于每个体积维度,都将其记录在状态之中。

本题启示我们,这种问题还有另一种做法,就是将其中一个体积维度作为DP转移时的“体积”,另外一个体积维度就作为“价值维度”。当然本题还用到了偏移等技巧,这里不再赘述。

如果题目已经指定了“价值维度”,在这种情况下该怎样处理呢?

多重背包

二进制拆分

作者还想说

作者还想谈谈学习与生活中的一些习惯……

作者还想告诉大家程序返回值的含义……

作者还想跟大家分享自己在学习算法时觉得比较好的参考博客……

作者还想推荐几个工具软件和网站……

  • Typora:一个小巧简洁的Markdown编辑器。
  • Hourglass:一个简单的倒计时工具。
  • Ubuntu云剪贴板
  • SM.MS图床
  • 函数图像绘制
  • 我的世界中文WiKi

作者还想推荐两个UP主……

  • 3Blue1Brown:深入浅出地介绍数学之美。
  • 大雪菜:视频讲解《算法竞赛进阶指南》上的题目。

到此为止,作者已经把自己想发布的全部都发布完毕,明天就有书念了。

你可能感兴趣的:(《算法竞赛进阶指南》)