提高组部分考点简单讲解
免责声明 写在前面
⚠️您可以将鼠标悬浮于任意标题行的右侧,点击图标查看目录并跳转至相应条目。
这篇总结性的博客一般是我做题做不动的时候跑来对以前学的知识点的回忆与总结。主要参考了 OI-wiki、蓝书和 GM 的 PPT。
我在半年前更改了码风,使代码有了较高的可读性;我不了解读者的具体情况,盲目地删去我自认为不重要的代码也不太好,因为它们可能对读者的理解起着至关重要的作用。不过真的会有读者吗(小声)代码基本只会保留可以通用的部分,有轻微的不打空格、大括号不不换行和压行现象,请注意。
好羡慕现在的初二,博客里想写什么就写什么,不像我,连废话都不敢写。
想了一下,废话还是可以写的,就写写做这类知识点的题目的时候遇到的奇怪坑点吧。
因为我有时会找到自己很久以前半途而废的博客,为了避免浪费就会稍加改动后添加至本篇,所以有很多条目后面都会跟一个 「待续」(对于此类条目,标题行末尾会有一个「≒」标记,无具体意义)。
ST 表
定义
求解 RMQ 问题的一种在线算法。在经过 \(\mathcal O(n\log n)\) 的预处理后,能够以 \(\mathcal O(1)\) 的时间复杂度查询区间最值。不支持对原数列作出任何更改。
以最小值为例,令 \(f_{i,j}\) 表示从第 \(i\) 位开始连续 \(2^j\) 个数的最小值,即 \(\min\limits_{i\leq k\leq i+2^j-1}A_k\)。如 \(f_{2,1}\) 表示从第 \(2\) 位开始,连续 \(2^1\) 个数的最小值,即 \(\min\,(\,A_2,A_3\,)\)。
通过对第二维状态的应用,可以使得问题求解的空间复杂度与时间复杂度均降至 \(\mathcal O(n\log n)\)。
预处理
分析状态转移方程。\(f_{i,j}\) 可以由 \(f_{i,j-1}\) 与 \(f_{i+2^{j-1},j-1}\) 转移而来。实质上是通过 \(2^{j-1}\) 这个中间点,将求解区间 \([i,i+2^j-1]\) 分割成了 \([i,i+2^{j-1}-1]\) 和 \([i+2^{j-1},i+2^j-1]\) 两个区间。
由此可得出代码。
int f[maxn][35][2]; // f[i][j][0] 存储区间最大值,f[i][j][1] 存储区间最小值。
inline void init(int n) {
for (int i = 1; i <= n; ++i)
f[i][0][0] = f[i][0][1] = a[i];
for (int j = 1; (1 << j) <= n; ++j) {
for (int i = 1; i + (1 << j) - 1 <= inf; ++i) {
f[i][j][0] = max(f[i][j - 1][0], f[i + (1 << j - 1)][j - 1][0]);
f[i][j][1] = min(f[i][j - 1][1], f[i + (1 << j - 1)][j - 1][1]);
}
}
return;
}
这里要注意一个点,外层循环变量为 \(j\),而内层为 \(i\)。循环变量顺序不可更改。
模拟外层循环变量为 \(i\) 时的求解顺序。求解 \(f_{i,j}\) 时,需要用到并没有计算过的 \(f_{i+2^{j-1},j-1}\) 的状态,是不可行的。反之,若外层循环变量为 \(j\),仅需用到已经计算的 \(j-1\) 维 \(i\) 的状态。
查询
至此,我们已经完成了 ST 算法的预处理阶段。
处理查询问题。假设询问区间为 \([x,y]\),令 \(k\) 的值为 \(x\) 与 \(y\) 之间数的个数取 \(\log\)。为了简便,我们可以直接取 \(f_{x,k}\)(即以 \(x\) 为左端点,连续 \(2^k\) 个数的最值)和 \(f_{y-2^k+1,k}\)(即以 \(y\) 为右端点,连续 \(2^k\) 个数的最值)中的最值。思考发现,两端区间有重合,但绝对不会有都未覆盖的区域。
得出代码。
inline int QueryMax(int x, int y) {
int k = (int)(log(y - x + 1.0) / log(2.0));
return max(f[x][k][0], f[y - (1 << k) + 1][k][0]);
}
inline int QueryMin(int x, int y) {
int k = (int)(log(y - x + 1.0) / log(2.0));
return min(f[x][k][1], f[y - (1 << k) + 1][k][1]);
}
注:几乎不存在题目会专门针对本方法进行考察,同时因为 ST 表空间复杂度较高( \(n\log n\) )并且无法动态查询,ST 表更多地是作为工具使用。单纯需要求解 RMQ 的题目,更多时候会使用更加简洁的树状数组,抑或功能更加广泛的线段树。但是它们的查询分别为 \((\,\log n\,)^2\) 和 \(\log n\),有的题目会为了考察选手对 RMQ 的熟悉度而刻意卡掉后两种方法。
::marker 一些坑点
-
::marker
有的时候 ST 表用于维护前缀和最大值,这个时候记得内层循环的 $i$ 要从 $0$ 开始。 例题:
线段树
一种码量大、原理简单的树形数据结构。如欲获取更加详尽的解析,请移步至 Link、Link。
线段树的主要作用,是求解部分对一段完整区间进行整体询问具有「区间可加性」的信息的题目。利用分治思想,对一段完整的区间进行若干次对半分解操作,直到最后的区间内仅剩一个元素,可以直接对问题进行求解,再逐层向父区间转移。
线段树使用线性树形结构存储每一段区间的信息。值得注意的是,线段树支持区间修改,一般使用「懒标记」对一段区间延迟更改。
此处给出具备单点修改、区间修改、区间查询最值功能的线段树代码。
#define lt (p << 1)
#define rt (lt | 1)
#define mid (a[p].l + a[p].r >> 1)
namespace Segment_Tree {
struct _{
int l, r, sum, u;
} a[maxn << 2];
// 向上转移
inline void PushUp(int p) {
a[p].sum = a[lt].sum + a[rt].sum;
return;
}
// 下传懒标记
inline void PushDown(int p) {
a[lt].sum += (a[lt].r - a[lt].l + 1) * a[p].u;
a[rt].sum += (a[rt].r - a[rt].l + 1) * a[p].u;
a[lt].u += a[p].u;
a[rt].u += a[p].u;
a[p].u = 0;
return;
}
// 建树
void Build(int p, int l, int r) {
a[p].l = l;
a[p].r = r;
a[p].sum = 0;
if (l == r) {
a[p].sum = ::a[l];
return;
}
Build(lt, l, mid);
Build(rt, mid + 1, r);
PushUp(p);
return;
}
// 单点修改
void Update(int p, int x, int v) {
if (a[p].l == a[p].r) {
a[p].sum += v;
return;
}
PushDown(p);
if (x <= mid)
Update(lt, x, v);
else
Update(rt, x, v);
PushUp(p);
return;
}
// 区间修改
void Update(int p, int l, int r, int v) {
if (a[p].l >= l && a[p].r <= r) {
a[p].sum += (a[p].r - a[p].l + 1) * v;
a[p].u += v;
return;
}
PushDown(p);
if (l <= mid)
Update(lt, l, r, v);
if (r >= mid)
Update(rt, l, r, v);
PushUp(p);
return;
}
// 区间查询
int Query(int p, int l, int r) {
if (l <= a[p].l && a[p].r <= r)
return a[p].sum;
int res = 0;
Pushdown(p);
if (l <= mid)
res += Query(lt, l, r);
if (r > mid)
res += Query(rt, l, r);
return res;
}
} using namespace Segment_Tree;
练习:Link、Link、Link、Link、Link、Link、Link、Link、Link、Link。
字典树
其实是比较简单的数据结构啦。只是它的应用神奇到令人无法理解。
顾名思义,是一棵树,但与大多数以结点存储信息的数据结构不同,字典树使用边存储数据。
接下来上图。
图片来自郭老师的 PPT
如上图所示,点内数字为该结点编号。
字典树通常来说是多叉树(如比较常见的是 \(26\) 叉树),父结点通过指针的方式指向子结点。Trie 内存储了很多字符串,从根结点到任意叶结点所有边上存储的字符连在一起就是原字符串。
所以,根据定义,字典树的一个应用是字符串算法。
操作一:插入
对于一个字符串 \(s\) 中的每一个 \(s_i\),令 \(T_{f,c}\) 表示以 \(f\) 为父结点编号,\(c\) 为边权(即 \(s_i\))的这条边通向的子结点编号。
若该结点不存在(即 \(T_{f,c}=0\)),则令当前编号 \(cnt\) 自增并赋值给 \(T_{f,c}\)。
接下来 \(f\gets T_{f,c}\),重复执行该操作直到整个字符串都有与其对应的结点。
时间复杂度 \(\mathcal O(|S|)\)。
操作二:查询
假设我们要查询有多少个已知的 \(s\) 是 \(t\) 的前缀,只需要将所有 \(s\) 插入到 Trie 中,从根结点沿 \(t\) 向下查询。途中若遇到 \(T_{f,t_i-1}=0\) 的情况,说明后面不会有更多为 \(t\) 前缀的字符串了,返回当前答案。
问题就在于,怎样求当前的字符串个数呢?如果 \(t=\texttt{abcd}\),而 \(s_1=\texttt{abc}\),\(s_2=\texttt{ab}\),该如何统计?
很简单,我们在插入时多加一个 \(tot\) 数组,表示是否有字符串以当前位置结尾。
字符串插入结束后,\(f\) 的值为 \(s_{last}\) 所对应的编号。此时将 \(tot_f\) 标记即可。
因为可能会存在两个一模一样的字符串,所以我们将标记换为数量统计,插入完后 tot[f]++
。
时间复杂度 \(\mathcal O(|S|)\)。
int cnt = 1;
int T[maxn][26];
inline void Insert(void) {
int f = 1, len = strlen(s + 1);
for(int i = 1; i <= len; ++i) {
if (!T[f][s[i] - 'a'])
T[f][s[i] - 'a'] = ++cnt;
f = T[f][s[i]-'a'];
}
tot[f]++;
return;
}
inline int Search(void) {
int f = 1, res = 0, len = strlen(t + 1);
for (int i = 1; i <= len; ++i) {
f = T[f][t[i] - 'a'];
if (!f)
return res;
res += tot[f];
}
return res;
}
应用
Trie 有时会与 异或(xor) 联系在一起。其中一个比较简单的例子是:
给定 \(a_1\sim a_n\),求其中任意两个数异或起来的最大值。\(n\le10^5\)。
对于一个数 \(x\) 来说,怎样的 \(y\) 才能使得 \(x\operatorname{xor}y\) 取最大值呢?不妨回到异或的定义上:当两数某一二进制位上的值不同时,其异或结果的对应二进制位为 \(1\)。
而我们要使这个结果尽量的大,也就是说让高位尽量为 \(1\)。
所以,我们把 \(a_1\sim a_n\) 的二进制序列插入字典树,枚举数 \(a_i\),若 \(a_i\) 的第 \(x\) 位取反在字典树中有对应的结点,则向这个结点继续求解。否则,只能选择 \(a_i\) 的第 \(x\) 位相同的二进制数值。
该算法时间复杂度 \(\mathcal O(n\log n)\)。
平衡树 ≒
前置知识:BST
首先我们有必要了解一下什么是 BST(Binary Search Tree,二叉查找树)。
BST 顾名思义是一种二叉树,观察它的中文名,同学们可能会问了,「二叉查找」和「二分查找」有什么关系呢?
其实,BST 的内部结构和二分查找有很大的关系,是具有单调性的,其任意形态下的中序遍历均为有序数列。
假设现在我们有一个数列 \(A\)(为了方便实现,我们假设 \(A\) 中元素两两不等)。当我们拿到 \(A_1\),也就是第一个数的时候,将其当作树根。
接下来,处理 \(A_2\) 。把 \(A_2\) 和树根,也就是 \(A_1\) 的大小作比较,如果 \(A_2\) 比 \(A_1\) 小,我们就把 \(A_2\) 当做 \(A_1\) 的左儿子,否则就把它放在 \(A_1\) 的右儿子上。
那么 \(A_3\) 呢?如果 \(A_2<A_1\) 且 \(A_3>A_1\),那么我们可以把 \(A_3\) 放在 \(A_1\) 的右儿子上。那如果 \(A_3<A_1\) 且 \(A_2<A_1\) 呢?很简单,我们把 \(A_3\) 与 \(A_2\) 比较,再把 \(A_3\) 放到 \(A_2\) 的左 / 右儿子上,以此类推。
所以我们可以简单地实现出建树和插入结点的代码:
inline void Init(void) {
t[tot = 1].u = -inf;
t[tot].r = tot + 1;
t[++tot].u = inf;
return;
}
// 这里的 p 是引用变量,在实际调用可以传一个 root 变量以代替 1
inline void Insert(int &p, int x) {
if (!p) {
p = ++tot;
t[p].u = x;
return;
}
if (x < t[p].u)
Insert(t[p].l, x);
else
Insert(t[p].r, x);
return;
}
为了实现与判断边界方便,我们预先在 BST 中插入一个 -inf
和 -inf
结点。初始 BST 形态大致如下图所示。
BST 初始形态图示
二叉查找树明显具有查找(检索)功能。按照其性质可得:
int Search(int p, int x) {
if (!p) // 未检索到 x
return -1;
if (x == t[p].u)
return p;
if (x < t[p].u)
return Search(t[p].l, x);
return Search(t[p].r, x);
}
数值的前驱、后继,我们通过在递归时记录求得。
令 \(y\) 存储当前前驱 / 后继,以根部的 \(\inf\) / \(-\inf\) 为初始值。
int GetPre(int p, int x) {
static int y = 1; // -inf 结点
if (p == 1)
y = 1;
if (!p)
return y;
if (t[p].u < x && t[p].u > t[y].u)
y = p;
if (x > t[p].u)
return GetPre(t[p].r, x);
return GetPre(t[p].l, x);
}
int GetNex(int p, int x) {
static int y = 2; // inf 结点
if (p == 1)
y = 2;
if (!p)
return y;
if (t[p].u > x && t[p].u < t[y].u)
y = p;
if (x < t[p].u)
return GetNex(t[p].l, x);
return GetNex(t[p].r, x);
}
结点的删除则略显复杂。我们假设已经检索到了待删除结点 \(x\)。
-
若 \(x\) 为叶子结点
直接删除即可。
-
若 \(x\) 仅有一边子树
将该子树「上移」至 \(x\) 原来的位置即可。
-
其他情况
找到 \(x\) 的后继结点(即 \(x\) 的右子树中的最小值),用该结点代替 \(x\)。
原因很简单,因为该节点是在所有比 \(x\) 大的点中最小的,所以它一定满足:
- 比 \(x\) 右子树中的所有其他点小
- 比 \(x\) 左子树中所有结点大
故其能替代 \(x\)。
void Delete(int &p, int x) {
if (!p) // 未检索到 x
return;
if (x == t[p].u) { // 检索到 x
if (!t[p].l && !t[p].r)
p = 0;
else if (!t[p].l)
p = t[p].r;
else if (!t[p].r)
p = t[p].l;
else {
p = t[p].r;
while (t[p].l)
p = t[p].l;
int q = t[p].l;
t[p].l = 0;
p = q;
}
}
else if (x < t[p].u)
Delete(t[p].l, x);
else
Delete(t[p].r, x);
return;
}
带旋 Treap
易发现 BST 的形态根据输入的顺序发生变化。
显然,BST 任意操作的复杂度随 BST 的深度而变化。当输入本身有序时,BST 退化成链,深度为 \(n\),任意操作复杂度退化为 \(\mathcal O(n)\)。
理想中的 BST 自然应该深度平衡,任意一边子树都不能太深。即于完全二叉树略似,任意叶子深度接近 \(\log n\)。
让 BST 维持基本平衡的方法(一般称使用这些方法维护的 BST 为平衡树)有很多,这里先介绍基于随机化的 Treap。
Treap 是 Tree 和 Heap 的合成单词,中文名「树堆」,因为中文难听又不普及,所以平时都叫英文。
维护 Treap 的方法称作「单旋转」。
何为单旋转?单旋转就是指将一个子树进行视觉上的「转」,结构上,单旋转不改变 BST 的内容和性质,但会改变内容的排列方式。
Treap 左旋、右旋示意图
「右旋」操作相当于是把 \(y\) 放到 \(x\) 的右子树上,而原本的右子树 \(B\) 则到了 \(y\) 空缺的左子树上。
对于实现,我们先将 \(y\) 的左子树置为 \(x\) 的右子树,再将 \(x\) 的左子树置为 \(y\)。
那么最后差的只有一步了,\(y\) 的父亲记录的子节点仍然为 \(y\)。但我们并未在结构体中记录 \(y\) 的父亲。
这里提前让读者了解:我们将会在进行任意递归更改 BST 操作后检查其被修改的子树是否满足某条件,若不满足则旋转。
假设现在正从 \(x\) 的递归中推出到 \(y\) 的递归中,我们不妨从上一层递归的角度考虑,假设 \(y\) 的父亲为 \(f\),上一层递归传入引用变量 \(y\)(因为是更改操作,所以一定有引用),即 t[f].l
或 t[f].r
,我们此时可以将 \(y\) 作为引用变量传入 zig
函数,在完成上两步操作后令 y = x
,即 t[f].l / t[f].r = x
。
所以:
inline void Zig(int &y) {
int x = t[y].l;
t[y].l = t[x].r;
t[x].r = y;
y = x;
return;
}
「左旋」操作同理。
inline void Zag(int &x) {
int y = t[x].r;
t[x].r = t[y].l;
t[y].l = x;
x = y;
return;
}
回到起始的问题,「旋转」操作应该在何时使用呢?
在没有经过特殊构造的完全随机的数据下,BST 本身就是较平衡的。
Treap 的思想是,将特殊的数据变得随机。
为每一个节点设置一个随机的值(所有节点的随机值互不相同),如果节点的随机值与其父亲的随机值不满足大根堆性质,即左右儿子随机值大于父节点随机值,则对该子树进行「旋转操作」。
因为在本次更改前,BST 满足堆性质,本次旋转前,只有这个子树不满足堆性质,所以一次旋转就可以让这个子树满足堆性质。但由于这次旋转操作可能让更高一级的子树不满足,所以我们回溯地判定、旋转。
对于删除操作,按照原来的删除方法会导致整个 BST 形态发生巨大的改变
咕。
二分图
二分图总体概念不难。主要是其应用广泛,需要注意什么样的题目可以联系到二分图上来。
概念
若图 \(G\) 可将点集 \(V\) 分成两个互不相交的子集 \(X\) 和 \(Y\),且每条边连接的两个点都满足一个在 \(X\) 中,一个在 \(Y\) 中,则称 \(G\) 为二分图。
也就是说,如果一个图有任何一种分组方式满足:把图中的点分成两组,每一组的点两两之间没有连边,那么这个图就是二分图。
举个例子:
每一组中的点两两之间没有连边,所以该图是二分图。
性质
-
二分图的每条边连接的点属于不同的集合。
显然。
-
二分图中可能存在环,且长度一定为偶数。
我们指定环中任意一个点,从该点出发,易得,经过奇数条边时到达另一个集合,反之回到该集合。因为路径是一个环,所以我们最后一定会回到起点所在集合,即经过偶数条边。
判定
通常,我们使用图的深度优先遍历每一个点 \(u\)。
显然,若已知点 \(u\) 在 \(X\) 集,那么所有与 \(u\) 有连边的点 \(v\) 一定在 \(Y\) 集(反之同理)。
当然,很多图是有环的,不免会产生 \(v\) 已经被分组的情况。若此时 \(v\) 恰好在 \(Y\) 集,皆大欢喜;若 \(v\) 也在 \(X\) 集,那么该图一定不为二分图。
由于每个点最多搜索一次,时间复杂度 \(\mathcal O(n)\)。
int color[maxn];
bool dfs(int x, int c) {
for (auto i : g[x]){
if (color[i]) {
if (color[i] == c)
return 0;
}
else if (!dfs(v, 3 - c))
return 0;
}
return 1;
}
int main() { dfs(1, 1); }
匹配
定义:对于一个二分图中的若干条边,若这些边没有任何公共点,则称这些边组成的集合 \(M\) 是数量为 \(|M|\) 的 匹配。
图中红色边展示了一个数量为 4 的匹配
容易看出,对于点 \(u\),只会存在 「有一条 \(M\) 集合内的边与 \(u\) 相连接」 和 「\(u\) 连接的边均不在 \(M\) 集合内」 两种情况。也就是说,从 \(u\) 出发的 \(M\) 集合内的边,最多有 \(1\) 条。
接下来,我们称 「有任何一条与之相连的边在匹配集合内」 的点为匹配点,「在匹配集合内的边」 为匹配边。
完备匹配
如果 \(|M|=\dfrac n2\),即 \(M\) 恰好连接了 \(1\sim n\) 所有点,我们就称匹配 \(M\) 为 完备匹配。
一个完备匹配的例子
比方说,现在我们知道一些男孩和女孩,他们之间有若干条互相喜欢的关系,我们把此关系抽象成一个二分图,如果每个人都能与自己喜欢的异性配对,那么我们认为这个关系网存在完备匹配。
显然,完备匹配存在,仅当两集合大小相等。
匈牙利算法
匈牙利算法一般用于求解 \(\max\{|M|\}\)。
我们将图上满足下列条件的路径 \(P\) 称为增广路:
- \(P\) 的起点和终点均是非匹配点
- \(P\) 的起点和终点不在二分图的同一组内
- 路径 \(P\) 经过的边按 非 匹配边,匹配边,\(\cdots\),非 匹配边的规律交替。
最终,\(P\) 会呈类 「\(\text Z\)」 形。
显然,非匹配边比匹配边的数量始终多 \(1\)。
此时,我们对 \(P\) 上匹配的状态取反。也就是说,原来的非匹配边变成匹配边,匹配边变成非匹配边。
读者自行画图可以发现,这样做相当于是在匹配边集仍然合法的情况下将匹配边集的大小扩大了 \(1\)。
那么增广路经过的边按非匹配边,匹配边,\(\cdots\),非匹配边顺序交替的原因就很显而易见了。取反前,匹配边不可能连续出现;取反后,匹配边(即取反前的非匹配边)也不可能连续出现。
而匈牙利算法的主要思路,就是反复寻找增广路,直到无法找到为止。
这里就必须再提到一个性质:\(M\) 为图 \(G\) 的最大匹配,当且仅当无法在 \(M\) 的基础上找到增广路。
证明如下:
-
有引理:对于图 \(G\) 的任意两个匹配 \(M\) 和 \(M'\),它们的 对称差 \(M\Delta M'\) 中的每一个连通块都是一条链或一个包含边数为偶数的环。
证明:
根据对称差的定义,对于任意边 \(e\in M\Delta M'\),\(e\) 要么是 \(M\) 中的一条匹配边,要么是 \(M'\) 中的一条匹配边,但不同时被 \(M\) 和 \(M'\) 包含。
因为在同一个匹配中,任意两条匹配边不存在公共顶点,所以对于任意与 \(e\) 有公共顶点的匹配边 \(e'\),\(e\) 和 \(e'\) 必然来自两个不同的匹配。
由此可得,对于任意匹配点 \(u\),\(u\) 的度数为 \(1\) 或 \(2\)。
所以,对称差中的每一个连通块都是链或环。
对于其中的环,所有相邻的边必定来自不同的匹配,所以环包含的边数为偶数。
-
必要性:当 \(M\) 为最大匹配时,无法找到 \(M\) 的增广路。
我们已经知道了,找到某匹配的增广路 \(P\) 并将其匹配状态取反,可以使匹配大小加一。
如果 \(M\) 存在增广路,则我们可以将其取反,得到一个比 \(M\) 大小更大的匹配。与 \(M\) 是最大匹配矛盾。
所以一定不存在 \(M\) 的增广路。
-
充分性:如果不存在 \(M\) 的增广路,\(M\) 为 \(G\) 的最大匹配。
设 \(M'\) 是一个比 \(M\) 更大的匹配。
由引理得:
在它们的对称差 \(M\Delta M'\) 中,连通块为链或环。
其中,环包含边的数量为偶数,所以必然有同样多的边来自 \(M\) 和 \(M'\)。所以我们可以忽视这些环。
由于 \(|M|<|M'|\),存在至少一条链 \(L\),且 \(|L|=k-1\),包含 \(k\) 条 \(M\) 中的边,\(k+1\) 条来自于 \(M'\) 的边。
显然,\(L\) 就是一条 \(M\) 的增广路,所以我们必然可以找到一条 \(M\) 的增广路,命题成立。
对于 「寻找增广路」 这个过程,我们使用 DFS 算法实现。
对于点 \(x\),若与 \(x\) 有连边的点 \(y\) 可匹配上 \(x\),需要满足下列两个条件之一:
- \(y\) 是非匹配点,此时 \(x\to y\) 构成一条增广路,非匹配边的数量已经比匹配边数量多 \(1\)。
- \((u,y)\) 是已匹配边,且 \((u,v)\) 是未匹配但合法的边,此时 \(x\to y\to u\to v\) 构成一条增广路。
在实现中,我们依次令 \(1\sim n\) 内 所有的非匹配点 作为起始点 \(x\) 尝试找到任何一条增广路。当碰到任意非匹配点时结束(增广路判定:起点与终点均为非匹配点),否则向 该匹配点匹配的点 继续搜索。
时间复杂度为 \(\mathcal O(n^2+nm)\),但一般二分图题目的 \(X\) 与 \(Y\) 部间的连边偏稠密,所以简化为 \(\mathcal O(nm)\)。
int now; // 时间戳
int vis[maxn]; // 记录一个点是否访问过的基础 DFS 数组。因为算法需要执行多次,以时间戳的方式避免了多次 memset。
int match[maxn]; // match[x] 存储 x 的匹配点,若不存在则置为 0
bool find(int x) {
for (auto i : g[x]) {
if (vis[i] == now)
continue;
vis[i] = now;
if (!match[i] /* 碰到任意非匹配点时结束 */ || find(match[i]) /* 向该点匹配的点继续搜索 */ ) {
match[x] = i; // 更改增广路中各点的匹配点
return 1;
}
}
return 0;
}
inline int Hungarian(int s, int t) { // 对 X 组中 s 到 t 的点寻找最大匹配边数
int ans = 0;
for (int i = s; i <= t; ++i) {
if (match[i]) {
++now; // 更新时间戳
vis[i] = now;
if (find(i))
++ans; // 每次取反只会让边集大小增加 1
}
}
return ans;
}
观察 find()
函数,可以发现递归的过程相当于找到一条匹配边,循环中遍历的过程相当于找到一条非匹配边。
为什么说『循环中遍历的过程相当于找到一条非匹配边』呢?
思考,进入 find()
函数的条件无非只有两种:
-
函数
Hungarian()
中判定 \(x\) 为非匹配点因为 \(x\) 为非匹配点,不存在从 \(x\) 出发的匹配边。
-
find()
函数判定 \(x\) 为其父亲的匹配点所以 \(x\) 的匹配点为其父亲。
-
当 \(i\) 为其父亲时
因 \(x\) 的父亲一定已被访问,\(i\) 会被上面判断
vis[i] == now
,跳过循环。 -
当 \(i\) 不为其父亲时
因一个匹配点对应的匹配点最多只有一个,\(i\) 不为 \(x\) 的匹配点,故 \((x, i)\) 不为匹配边。
-
综上,循环中找到的一定是非匹配边。
一般来说,二分图题目对点、边、分组方法、匹配范围的识别较为模糊。但一般的二分图题目都会有一些特点:
- 结点能分为两组,且各组内结点间没有连边
- 每个结点只能与一条边匹配
有时候,题目要求判定是否存在 「完备匹配」,也就是说,\(ans=n\)。即任意一次 find(i)
返回 false
时,完备匹配不存在。
最后给出与匈牙利算法有关的两个问题:
-
最小点覆盖:给定一个二分图,求出一个最小的点集,使得这个点集发出的所有边可以覆盖整个二分图。
定理:该点集的大小是二分图的最大匹配包含的边数。
-
最大独立集:给定一个无向图,求出一个最大的点集,使得该点集中的所有点两两之间没有边相连。
定理:当该无向图是二分图时,最大独立集的大小等于 \(n\) 减去最大匹配数。
KM 算法 ≒
不会,待学。先放链接。
草,草,看不懂蓝书和 OI-wiki,GM 的 ppt 也被我丢了,要不就,就,就不学了吧(逐渐小声(心虚(眼神游离(流汗
哈希 ≒
指字符串哈希。其他的哈希方法可以简单地使用 std::unordered_map<>
替代。
字符串哈希的核心思想,是将本需要 \(O(n)\) 的字符串比较操作通过哈希将字符串映射为整型优化成 \(O(1)\)。
通过哈希函数 \(f\),使得 \(s_1=s_2\iff f(s_1)=f(s_2)\)。当然,哈希冲突是不可避免的,但我们可以通过一些手段,是的冲突发生的概率尽可能地小。
\(f\) 的具体实现方法是,选择一个质数 \(p\)( \(p>\) 字符串中最大字符的 ASCII 码),本文中 \(p=13331\),令 \(h_i\) 表示将 \(s_1\sim s_i\) 转换为 \(p\) 进制数的结果 (此处,更多人喜欢令 \(s_i\) 为最低位)。而由于字符串的长度通常很长,这个 \(p\) 进制数在十进制下的表示可能就会有 \(p^{|s|}\),基本数据类型完全无法存储。此时,我们通过无符号长长整形(unsigned long long
)的自然溢出解决。
则有:
const int p = 13331;
unsigned long long h[maxn];
inline void init() {
for(int i = 1; i <= n; ++i)
h[i] = h[i - 1] * p + s[i];
return;
}
时间复杂度 \(\mathcal O(n)\)。
对于查询,假设查询 \(s_l\sim s_r\) 的哈希值,其中 \(h_{l-1}=1234\) 且 \(h_r=1234567\),那么利用前缀和的思想,可以用这两个值得到 \(s_l\sim s_r\) 的哈希值。
想象一个类似于竖式减法的过程:
我们如果想要在这个竖式中得到正确的结果,需要在减数后面添加若干个 \(0\),即在该进制下「左移」若干位。即:
其中,\(h_{l-1}\) 每左移一位,就会离 \(r\) 这个「标准」更近一位。为了使 \(h_{l-1}\) 与这个「标准」平齐,以进行直接的减法操作,需要将其「左移」\(r-(l-1)\),即 \(r-l+1\) 位。
所以有:
inline int GetHash(int l, int r) {
return h[r] - h[l - 1] * bas[r - l + 1]; // bas 中存储预处理的 p 的次方
}
待续
LCA
即树上最近公共祖先。共有两种求解方式。
倍增
假设有两个点 \(x\) 和 \(y\),思考暴力求解其 LCA 的方式:
首先我们让深度更大的结点向上「跳」,即向自己的父亲结点移动,直到 \(x\) 与 \(y\) 的深度相同。\(x\) 与 \(y\) 同时向上「跳」,直到 \(x\) 与 \(y\) 相遇,此时的 \(x\) 与 \(y\)(其实是同一个点)就是原来 \(x,y\) 的 LCA。
这个过程的复杂度最坏是 \(\mathcal O(n)\) 的,主要就是将时间浪费在了一步步向上「跳」的过程。我们如何优化这个过程呢?
我们使用 f[x][k]
表示 \(x\) 向上「跳」 \(2^k\) 步后到达的结点,则有 f[x][k] = f[f[x][k - 1]][k - 1]
,含义为 \(x\) 向上跳 \(2^{k-1}\) 步,再跳 \(2^k-1\) 步,等同于向上直接跳了 \(2^k\) 步。等效替代法
用一个搜索预处理出每一个点的深度 dep[x]
,则原来的暴力过程可被优化为:
钦定 dep[x] > dep[y]
(否则可 swap(x,y)
),从大到小枚举 \(k\),查看 dep[f[x][k]]
是否小于等于 dep[y]
,如果是则令 x = f[x][k]
,否则就说明跳的步数太多了,不跳这一步,尝试最小的 \(k\)。
该方法的正确性可以参考二进制拆分原理和托盘天平的砝码放置方法。(你怕不是物理学魔怔了
此时的 \(x\) 深度就与 \(y\) 相同了。若 \(x=y\)(即 \(y\) 为 \(x\) 的祖先),直接返回 \(x\)。
接下来,用类似的方法,从大到小枚举 \(k\),让 \(x\) 尝试跳到 f[x][k]
,\(y\) 跳到 f[y][k]
,若此时的 \(x\) 和 \(y\) 已经相同,则说明跳多了,否则就跳这一步。
因为通过上述的方法,\(x\) 和 \(y\) 在枚举结束后一定相遇不了,但 f[x][0]
和 f[y][0]
就一定是同一个点。将其返回即可。
LCA 有时会以树上两点距离的方式出现,则 \(\operatorname{Dis}(x,y)=\operatorname{Dis}(root,x)+\operatorname{Dis}(root,y)-2\times\operatorname{Dis}(root,\operatorname{LCA}(x,y))\),预处理出每个结点距离根结点的 Dis 即可。
预处理时间复杂度 \(\mathcal O(n\log n)\),每次查询为在线的 \(\mathcal O(\log n)\)。
std::queue<int> q;
int siz = (int)(log(n) / log(2.0)) + 1;
void DFS(int x, int fa) {
for (auto i : g[x]) {
if (i.v == fa)
continue;
f[i.v][0] = x;
dep[i.v] = dep[x] + i.w;
for(int j = 1; j <= siz; ++j)
f[i.v][j] = f[f[i.v][j - 1]][j - 1];
DFS(i.v, x);
}
return;
}
inline int getLCA(int x, int y) {
if (x == y)
return x;
if (dep[x] < dep[y])
swap(x, y);
for (int i = siz; ~i; --i) {
if (dep[f[x][i]] >= dep[y])
x = f[x][i];
}
if (x == y)
return x;
for (int i = siz; ~i; --i) {
if (f[x][i] != f[y][i]) {
x = f[x][i];
y = f[y][i];
}
}
return f[x][0];
}
Tarjan
好妙啊。注意遗忘的时候需要自行画图配合食用。
我们把算法流程过一遍。将所有询问以邻接表的方式进行双向存储。所以,Tarjan 是个离线算法。
我们用一个搜索完成三个操作:
-
正常遍历图。对于点 \(u\),在 遍历完 以某个 \(v\) 引导的子树后,用并查集记录 \(v\) 的父亲为 \(u\)(
fa[v] = u
)。注意这个先遍历再标记的顺序,是算法核心。 -
遍历完 \(u\) 引导的整个子树后,枚举包含 \(u\) 的所有询问 \((u,v)\),如果 \(v\) 所引导的子树已经全部遍历完毕(以标记的形式记录),则询问 \((u,v)\) 的答案为
find(v)
(并查集操作)。我们通过分讨理解该操作的正确性:
因为 \(v\) 已经完全遍历完毕且标记,而 \(u\) 完全遍历而没有标记,所以 \(v\) 不可能为 \(u\) 的直系祖先。
-
当 \(v\) 为 \(u\) 的子孙时:
因为之前的 1 操作顺序,\(v\) 一代代向上标记自己的父亲,而 \(u\) 这一层搜索还没有结束,所以
fa[v] == u
且fa[u] == u
,进而得到find(v) == u
。 -
当 \(v\) 不为 \(u\) 的子孙时:
同样是因为 1 操作的顺序,\(v\) 会一直向上找到第一个还没有遍历完的祖先(该祖先的 \(fa\) 还没有被更新),而这个祖先还没有遍历完的原因就是 \(u\) 这一层搜索还在进行,导致 \(u\) 的所有祖先都没有完成遍历,所以 \(v\) 找到的这个祖先(即当前的
find(v)
值)就是LCA(u, v)
的值。
-
-
标记 \(u\),退出当前搜索。
时间复杂度 \(\mathcal O(n+Q)\)。值得注意的是当中并查集的时间复杂度,结合上面并查集的使用方法大致意会一下,其实每次查询是 \(\mathcal O(1)\) 的。
std::vector<Query> u[maxn];
inline void add_q(int x, int y, int id){
u[x].push_back(Query(y, id));
return;
}
int find(int x) { return x == f[x] ? x : f[x] = find(f[x]); }
void Tarjan(int x) {
vis[x] = 1;
for (auto i : g[x]) {
if (vis[i.v])
continue;
dis[i.v] = dis[x] + i.w;
Tarjan(i.v);
f[i.v] = x; // 尤其注意顺序!
}
for(auto i : u[x]){
if (vis[i.v] == 2)
ans[i.id] = find(i.v);
}
vis[x] = 2;
return;
}
A* ≒
即启发式搜索。与普通搜索的区别在于估价函数 \(h\)。下面分 BFS 和 DFS 两个板块介绍。
BFS
通常用于求解问题的最优解。原理是对当前状态到终点状态所需要的代价进行估计,让代价最小的方案优先进入搜索队列。
一个合理的估价函数很重要。如果函数对状态进行了错误的估价,与实际代价偏差过大,则可能导致实际花费较大的方案优先达到终点,最终返回错误的答案。
为了避免这种情况,我们认为,若令状态 \(x\) 到达终点状态的实际代价为 \(g(x)\),则 \(h(x)\) 在大于等于 \(g(x)\) 的条件下(最不利),两个值越接近,\(h\) 函数设计得越好。
通常情况下,我们使用以代价为关键字的优先队列代替普通队列,在向队列末插入新状态,以实际已计算代价 + 估价为权值
待续
图的连通性问题 ≒
主要内容:割点、割边(桥)、v-gcc、e-gcc、强连通分量、缩点
DFN
图上一点 \(x\) 的 DFN 值表示的是 DFS 第一次访问到结点 \(x\) 的时间戳(序号),用数组记录。
显然,每个 DFN 值是唯一的(因为 DFS 不可能在同一时间同时访问多个结点),且 DFN 值越小的结点越先被 DFS 遍历到。
所以 DFN 和我们要讲的问题有什么关系呢?
假设我们的手上有一个无向联通图,如何将它的边分为两类呢?
其中一种可行的方式,是按 树边 和 返祖边 / 反向边 分类。
从图的随便一个点出发,对于边 \((u,v)\):
-
若 \(v\) 从未被 DFS 访问过,则 \((u,v)\) 为一条树边;此时:
标记 \(v\) 已访问过, 以 \(v\) 为起点 DFS,记录 \(v\) 的DFN 值。
-
若 \(v\) 之前被访问过,则 \((u,v)\) 为返祖边,此时:
不进行 DFS,否则会在一个环上反复搜索。
-
特殊地,若 \(v\) 为 \(u\) 的父结点(即搜索顺序为 \(v\to(v,u)\to u\to(u,v)\) 时),不对这条边分类。此时:
仍然不进行 DFS,因为在无向图中,\((u,v)\) 和 \((v,u)\) 是同一条边。
另一种可行的理解方式是,\((u,v)\) 的分类与 \((v,u)\) 相同。
然后这个无向图在被我们分类的同时,变成了一个有向图,如下图所示。
DFS 遍历图示
显然,返祖边只在环中出现。
我们知道,一个环中去掉任意一条边后就不再构成环,所以如果我们把所有的返祖边去掉,只剩树边,这个图就变成了一个有向无环、除起点,各结点入度为 \(1\) 的图,又称树。
所以剩下的那些边才被称为「树边」。
low
low 也是每个结点都有的值,low[x]
表示 \(x\) 所在的环上最小的 dfn[v]
。如果 \(x\) 被多个环同时包含,则取最小的 dfn[v]
。
求 low 值的方式有点复杂,我们还是假设当前在 \(u\) 点,枚举与 \(u\) 之间有连边的每个 \(v\)。但是我们不像 dfn 那样即时更新,而是在回溯时更新。
由于要取最小的 dfn[v]
,并且单独一个点也算作一个环,所以 low[u]
的初始值就是 dfn[u]
。
-
若 \((u,v)\) 为返祖边,也是回溯的一种方式,此时
low[u] = min(low[u], dfn[v])
。因为返祖边总是伴随环一起出现,所以可以更新
low[u]
。 -
若 \((u,v)\) 为树边,则需要等 \(v\) 向 \(u\) 回溯时再更新。此时
low[u] = min(low[u], low[v])
。此时简单地分类讨论一下:
-
若 \(u,v\) 不在同一个环上:
因为先访问 \(u\),再访问 \(v\) 及 \(v\) 所在环上的所有结点,所以
dfn[u]
和low[u]
一定比low[v]
小,不会被更新。 -
若 \(u,v\) 在同一个环上:
既然 \(u,v\) 在同一个环上,则 \(v\) 或 \(v\) 再往后的结点一定会延伸出一条返祖边, \(low_v\) 被该返祖边或回溯操作更新得可能比
low[u]
小,此时low[u]
可能被low[v]
更新。
-
如下图所示。
low 值求解图示
割点
简单来说,割点就是一个无向联通图中,若将点 \(x\) 以及与 \(x\) 有关的所有边删除后,图不再联通,则称 \(x\) 为这个图的割点。
同一个图中,割点可能不止一个。
如图所示,一个图可能包含多个割点
待续
杂项
次小生成树
给定 \(N\) 个点,\(M\) 条边,求 严格 次小生成树。
严格次小生成树的定义:边权和最小的满足边权和 严格大于 最小生成树边权和的生成树。
我们来捋一下整道题的思路。
-
最小生成树
看到次小生成树,许多人都会联想到最小生成树。
相信大家都会用 Kruskal 算法求解最小生成树。所以这一步我们跳过。
-
不严格次小生成树
如何把最小生成树 \(M\) 转换为 不 严格次小生成树 \(M'\)?
对于任意一条 不存在于 \(M\) 中的 边 \((u,v,w)\),我们将其加入最小生成树。
此时的最小生成树已经不是树了,因为它有 \(n\) 个点,\(n\) 条边。也就是说,这是一个基环树。删除该基环树上的任意一条边,图还原为树。
这个环是在加入边 \((u, v, w)\) 后产生的,所以点 \(u\) 和点 \(v\) 一定在环上。
显然,令 \(t\) 表示原生成树上 \(u,v\) 的最近公共祖先,则这个环由原生成树上两条简单路径 \(u\to t\)、\(v\to t\) 和边 \((u,v,w)\) 组成。
为了让图还原为树,我们需要删除这个环上的边。这条边不能是我们刚刚加入最小生成树的边 \((u, v, w)\),因为我们要用它尝试让最小生成树的权值变大;所以我们就只能在 \(u\to t\) 和 \(v\to t\) 中选择。
我们找到 \(u\to t\) 和 \(v\to t\) 上权值最大的边,用 \((u, v, w)\) 替换,就能得到不严格次小生成树。
在使用倍增法维护最小生成树的 LCA 的同时,我们维护一个最大值数组 \(p\),其定义与倍增祖先数组 \(f\) 的定义类似:
p[i][j]
表示点 \(i\) 向上(树根方向)的 \(2^j\) 条边中,边权的最大值。其转移则类似于 ST 表求 RMQ:
for(int j = 1; j <= 20; ++j) { f[i][j] = f[f[i][j - 1]][j - 1]; p[i][j] = max(p[i][j - 1], p[f[i][j - 1]][j - 1]); }
于是我们可以轻松地得到路径上的最大边权 \(w'\),则最后的答案为 \(M'=M-w'+w\)。
比较对于所有边生成的 \(M'\),取其最小值为答案,因为 \(M'\) 必须满足在所有可生成的树中,除了 \(M\) 权值最小。
-
严格次小生成树
我们思考,不严格次小生成树操作过程中的哪一步使得其不严格?其实就是因为 \(w'\) 可能与 \(w\) 相等。
我们专门针对这个问题处理,同时记录 \(u\to t\) 和 \(v\to t\) 中的最大值和 严格 次大值,当最大值与 \(w\) 相等时,就弃用最大值,使用次大值。严格次大值的维护方法与最大值类似。
#include <cstdio>
const int maxk = 25;
const int inf = 1e18;
const int maxn = 3e5 + 5;
const int LEN = (1 << 20);
struct _ {
int v, w;
_() {}
_(int V, int W) {
v = V;
w = W;
}
};
struct __ {
int u, v, w;
__ () {}
__ (int U, int V, int W) {
u = U;
v = V;
w = W;
}
bool operator< (const __ &q) const {
return w < q.w;
}
};
__ u[maxn], d[maxn];
std::vector<_> g[maxn];
int dep[maxn], fa[maxn];
int n, m, x, y, w, siz, res, pos, cnt, ans;
int f[maxn][maxk], p[maxn][maxk], q[maxn][maxk];
inline int abs (int x) {
return x >= 0 ? x : -x;
}
inline int min (int x, int y) {
return x < y ? x : y;
}
inline int max (int x, int y) {
return x > y ? x : y;
}
inline void swap (int &x, int &y) {
x ^= y ^= x ^= y;
return;
}
int find (int x) {
return x == fa[x] ? x : fa[x] = find(fa[x]);
}
inline void merge (int x, int y) {
fa[find(x)] = find(y);
return;
}
inline void init (int n) {
for(int i = 1; i <= n; ++i)
fa[i] = i;
return;
}
inline void add(int x, int y, int w) {
g[x].push_back(_(y, w));
return;
}
void DFS(int x, int fa) {
dep[x] = dep[fa] + 1;
q[x][0] = -inf; f[x][0] = fa;
for (int i = 1; i <= siz; ++i) {
f[x][i] = f[f[x][i - 1]][i - 1];
int t[4] = {
p[x][i - 1], p[f[x][i - 1]][i - 1],
q[x][i - 1], q[f[x][i - 1]][i - 1]
};
std::sort(t, t + 4, std::greater<int>());
p[x][i] = t[0];
pos = 1;
do {
if (t[pos] < t[0]) {
q[x][i] = t[pos];
break;
}
++pos;
} while (pos < 4);
}
for (auto i : g[x]) {
if (i.v == fa)
continue;
p[i.v][0] = i.w;
DFS(i.v, x);
}
return;
}
inline int getLCA (int x, int y) {
if (x == y)
return x;
if (dep[x] < dep[y])
swap(x, y);
for (int i = siz; ~i; --i) {
if (dep[f[x][i]] >= dep[y])
x = f[x][i];
}
if (x == y)
return x;
for (int i = siz; ~i; --i) {
if (f[x][i] != f[y][i]) {
x = f[x][i];
y = f[y][i];
}
}
return f[x][0];
}
inline int getmax(int x, int y, int w) {
int res = -inf;
for (int i = siz; ~i; --i) {
if (dep[f[x][i]] < dep[y])
continue;
if (p[x][i] == w)
res = max(res, q[x][y]);
else res = max(res, p[x][i]);
x = f[x][i];
}
return res;
}
#ifdef ONLINE_JUDGE
inline int nec(void) {
static char buf[LEN], *p = buf, *e = buf;
if (p == e) {
e = buf + fread(buf, 1, LEN, stdin);
if (e == buf)
return EOF;
p = buf;
}
return *p++;
}
#else
#define nec getchar
#endif
inline bool read(int &x) {
x = 0;
bool f = 0;
char ch = nec();
while (ch < '0' || ch > '9') {
if (ch == EOF)
return 0;
if (ch == '-')
f = 1;
ch = nec();
}
while (ch >= '0' && ch <= '9') {
x = x * 10 + ch - '0';
ch = nec();
}
if (f)
x = -x;
return 1;
}
int main() {
read(n);
read(m);
init(n);
siz = log(n) / log(2.0) + 1;
for (int i = 1; i <= m; ++i) {
read(x); read(y); read(w);
u[i] = __(x, y, w);
}
std::sort(u + 1, u + m + 1);
for (int i = 1; i <= m; ++i) {
if (find(u[i].u) == find(u[i].v))
d[++cnt] = u[i];
else {
add(u[i].u, u[i].v, u[i].w);
add(u[i].v, u[i].u, u[i].w);
merge(u[i].u, u[i].v);
res += u[i].w;
}
}
dep[1] = 1;
DFS(1, 0);
ans = inf;
for (int i = 1; i <= cnt; ++i) {
w = getLCA(d[i].u, d[i].v);
x = getmax(d[i].u, w, d[i].w);
y = getmax(d[i].v, w, d[i].w);
if (max(x, y) > -inf)
ans = min(ans, res - max(x, y) + d[i].w);
}
printf("%d", ans);
return 0;
}
待补充
- 树剖
- TWBFS
- IDDFS、IDBFS
- 搜索状态存储
- prim
- 树形 DP
- 状压 DP
- DP 的优化
- CDQ 分治
- 莫队
- 分块
- KMP
- 可持久化数据结构
- 网络流
- 数学(草为啥我感觉除了矩阵和逆元啥都不会)
- 欧拉定理和欧拉函数
- 费马小定理
- 威尔逊定理
- 裴蜀定理
- 逆元
- 拓展欧几里得
- 中国剩余定理
- 矩阵
- 杂项
- 悬线法
- DLX
- 哈夫曼树
- 基环树
- Flood - fill
- 笛卡尔树
- 基数排序
- 拓扑排序
- 欧拉路
- Manacher
跳转至底部
—— · EOF · ——
真的什么也不剩啦 😖