本篇为《挑战程序设计竞赛(第2版)》读书笔记系列,旨在:
-
梳理算法逻辑
-
探索优化思路
-
深入代码细节
-
穷竭搜索
-
贪心
-
动态规划
-
数据结构
-
图论
-
数论
配套习题及详解同步发布在GitHub仓库 acm-challenge-workbook,持续更新。
Google Code Jam(GCJ) Peking University Online Judge(POJ) CodeForces(CF) LeetCode(LC) Aizu Online Judge(AOJ)
-
深度优先搜索(DFS):从某个状态开始,不断转移,直至无法转移,回退到前一步, 再继续转移到其他状态,直到找到最终解。通常采用递归函数或者栈(Stack)来实现。
-
宽度优先搜索(BFS):从初始状态开始,总是先搜索至距离初始状态近的状态。 每个状态都只经过一次,因此复杂度为O(状态数*转移方式数)。通常采用循环或队列(Queue)实现。
-
特殊状态枚举:可行解空间多数可采用DFS,但当其比较特殊时,可简短地实现。
-
全排列使用STL中的next_permutation
-
组合或子集使用位运算
-
-
剪枝:明确知道从当前状态无论如何转移都不会存在解的情况下,不再继续搜索而是直接跳过。
-
栈内存与堆内存:
-
main函数中的局部变量存储在栈内存中,统一分配后不再扩大,影响栈深度,与机器设置有关。通常,C++中执行上万次递归是可行的。
-
new或malloc的分配的是堆内存,全局变量存储在堆内存中,使用全局变量代替局部变量可减少栈溢出的风险。
-
-
加深深度优先搜索(IDDFS):初始的DFS递归深度限制为1,在找到解之前不断增加递归深度。
-
贪心算法:遵循某种规律,不断贪心选取当前最优策略。
-
贪心证明:
-
与其它选择方案相比,该算法并不会得到更差的解(归纳法)
-
不存在其他的解决方案(反证法)
-
-
动态规划(DP):通过定义某种最优子状态,进行状态间转移达到最终解。
-
记忆化搜索:将重复状态通过标记降低复杂度。
-
多种形式的DP:搜索的记忆化或利用递推关系的DP,或从状态转移考虑的DP。状态定义和循环顺序都会影响复杂度。
-
使用memset初始化
-
重复循环数组
-
dp仅bool是一种浪费
-
根据规模改变DP对象
-
背包问题(01背包,完全背包)
-
最长子序列(LCS,LIS)
-
划分数(第二类Stirling数,Bell数)
-
优先队列:包含两类操作插入和取值。插入一个数值,获取最小值并删除。堆可高效实现优先队列。
-
堆:儿子的值一定不小于父亲的值的一种二叉树。插入时先在堆末插入,不断上移直至无大小颠倒。取值时,删除最小值, 将堆末节点复制到根,不断下移直至无大小颠倒。插入和取值复杂度都为O(logn)。
-
二叉搜索树:对所有节点都满足,左子树上的所有节点比自己小,右子树上的所有节点比自己大。 插入与查询类似二分,删除时将删除节点左子树最大值或右子树(无左子树时)上移,每种操作复杂度都为O(logn)。
-
并查集:一种管理元素分组情况的数据结构。可以查询两个元素是否同组,可以合并两组元素,但不能进行分割操作。 一次操作复杂度为阿克曼函数反函数a(n),比O(logn)快。
-
平衡二叉树(AVL):当左右子树深度差超过1时,将更深的子树旋转上移,达到整棵树的平衡,避免二查搜索树退化后复杂度升至O(n)。
-
路径压缩:并查集向上的递归操作中,沿途所有节点一旦向上走到一次根节点,就把其到父亲的边直接连向根。
-
并查集的同组:广义可表示组内所有元素代表的情况同时发生或不发生。
-
STL标准库:
-
优先队列:priority_queue(默认根为最大值)
-
二查搜索树:set(集合)、map(键和值对应)、multiset和multimap(可存放重复键值)
-
-
图:顶点集合为V、边集为E的图记作G=(V,E),从u到v的边记作e=(u,v)。根据边是否有向分为有向图和无向图, 根据是否有环分为有环图和无环图。图可由邻接表和邻接矩阵两种方式表示。
-
Bellman-Ford算法(单源最短路):记录起点到每个点i的最短距离d[i],用所有的边条件持续更新d[i], 直到每个d[i]都已经为最小无法更新。图可包含负权边,包含负环的判断方法为将所有d[i]初始化为0,第V次d[i]是否仍存在更新。复杂度为O(EV)。
-
Dijkstra算法(单源最短路):从起点出发<s, d[s]=0>出发,更新s所有可到达的边j,若d[j]有更新, 则加入最小堆,以便下次找到剩余集合中d[i]最小的点i,再从i出发BFS,直到到达终点t。不能处理包含负权边的图。复杂度为O(ElogV)。
-
Floyd-Warshall算法(多源最短路):定义从i到j且经过k的最短路为d[i][j]用d[i][k]+d[k][j]来更新, 三重循环直接得到任意两点间的最短路。图可包含负权边,包含负环的判断方法为是否存在顶点i使d[i][i]为负。复杂度O(V^3)。
-
Prim算法(最小生成树):假设V的子集X已经构造了部分最小生成树,那么接下来就是选取从X到X的补集中权值最小的边加入。 可使用最小堆维护待选的边,复杂度为O(ElogV)。
-
Kruskal算法(最小生成树):将所有边升序排列,依次取出每条最小的边, 若该边的两个端点不在相同并查集内,则将该边加入最小生成树,并将两点用并查集连接。 耗时最多的操作为边的排序,复杂度O(ElogE)。
-
最短路本质是动态规划,最小生成树本质是贪心。
-
Bellman-Ford算法和Floyd-Warshall算法可处理包含负权边的图,并结合各自特性判断是否存在负环。
-
差分约束:将不等式组转化为包含负权边的单源最短路问题,一般采用Bellman-Ford方法解决。 若d[i]+x>=d[j],则创建有向边e(i,j)=x。从起点s到终点t的最短路d[t]为s和t允许的最大差。 若存在负环,则不等式组无解;若d[t]=INF,则s和t相差可任意。
-
辗转相除算法(最小公约数):gcd(a,b)=gcd(b,a%b),循环至b为0,此时得到最小公约数为a。
-
扩展欧几里德算法(解二元一次方程):求解ax+by=gcd(a,b),类似辗转相除法。 求extgcd(a,b,&x,&y)时,递归求得d=extgcd(b,a%b,y,x)的解存入y和x。则ax+by=gcd(a,b)的解为x和y-(a/b)*x。
-
素数筛法:通过已求得的素数,将所求范围内所有该素数的倍数都标记为合数。 依序遍历空间,未被筛掉的即为新的素数。复杂度O(nloglogn),可看作线性的。
-
反复平方法(快速幂):求x的n次幂,可二分递归求x的n/2次幂,即x^n=(x^(n/2))^2 * x^(n&1)。复杂度为O(logn)。
-
ax+by=gcd(a,b)的解大小:x的绝对值不大于b,y的绝对值不大于a。 若要求得满足某个范围的解,可通过参数k调节,x+=k(b/d)、y-=k(a/d)为原方程的解簇。
-
线性素数筛法:遍历解空间,无论当前数是否为素数,将已经求得得素数集合中的数乘以它得到合数标记筛去。 并且若该数为合数,它乘以的素数为它的因子,则对该数不再继续循环已有的素数集合。 上述可保证,每个合数都只通过乘以它最小的因子得到,即复杂度为线性。 注意,该方法使得已有的素数集合中的组合并不一定被立即筛去,在以后遍历到特定合数时才会被标记。
-
模运算:用64位处理对32数的模,避免发生溢出。模运算对加减乘可以直接应用, 但对同模的两边做除法时,若原始ac=bc(mod m),则a-b=m*k/c,则a=b(mod m/gcd(m,c))。