魔鬼冲刺学习笔记
高二是大部分 OIer 的最后一段竞赛时光,这真是 “\(One \ Last \ Olympiad\)” 了。所以我们开始魔鬼冲刺了!这里就用来记录这段时期的一些收获,还有学到的知识。由于 停课后学习笔记 给人的感觉略显凌乱,故在本文中笔者简单优化一下文章架构。以内容主题为核心,日期只在标题下面简单记录一下。
TSOI2022 魔鬼冲刺!!! —— wqw123(小 w 老师)
暑假模拟赛 解题报告
暑假打了三场模拟赛,解题报告放在这里。因为比较长单独拉出去了。
存图奇技淫巧
- UPD. 9.4
传统邻接表 和 链式前向星 显然各有所长,但并不是说两者有非常强的独一无二性质,其实两个在相当多的使用场景上可以相互代替。这里梳理以 std::vector
为代表的 传统邻接表 和 链式前向星 两种存图方式的相同之处和对比。
注:本部分主要注重于图的遍历,不包括需要特殊存图的算法的存图,比如 Dijkstra 和 Kruskal 边权排序的需求。包含这些算法的题,如果需要遍历,那么同样需要本部分中提到的用于遍历的存图方法。
1. 存图原理
事实上,两者都是邻接表,存图的方法是 储存每个节点的儿子。但存在细微的差别。
1.1 std::vector
直接存储每个节点的儿子。加边 \((u,v)\) 的方法就是在 \(u\) 的出边集 \(E_u\) 中加入 \(v\)。
E[u].push_back(v);
基于 std::vector
进行存图,码量及其少,但可能会因为 STL 本身性能的原因无法获得最优秀的时间效果。不过在开启 \(O2\) 的情况下基本无需顾虑 std::vector
是否会被卡。
std::vector
性能较差归根结底是push_back()
的性能问题。你可以考虑先记录出度,进行resize()
,在进行存图,不过这样做十分繁琐,违背了大多数使用者使用std::vector
的初衷——易于实现。
1.2 链式前向星
融合了链表和邻接表两种方法,就像 儿子兄弟表示法,以边为存储单位,只记录每个节点的 最新一条出边,通过 单向链表 的方式串联该节点的所有出边。具体地,采用 \(head_u\) 存储节点 \(u\) 最新出边,\(to_i\) 存储边 \(i\) 的终点,\(next_i\) 存储边 \(i\) 的邻边。
inline void add(int u,int v) { to[++cnt]=v,next[cnt]=head[u],head[u]=cnt; }
链式前向星直接使用 C++ 底层数据结构(数组或指针),码量上较 std::vector
更多一些,但具有稳定的优秀时间性能。缺点是遍历顺序固定,因为链表的顺序是 固定 的。
也有人会开结构体,
struct edge { int to,next; } E[M];
但我不建议你这么做。因为结构体的 时间性能略低,而且在后面遍历的时候会让你的代码很长。(而且全是调用结构体成员的 E[p].
。)
2. 边权的存储
2.1 std::vector
采用 std::pair
、std::tuple
或结构体封装边相关信息。如果边相关信息较多,或双向建边只希望其中一条被使用,可以为每一条边赋一个编号,将相关信息存入不同数组的对应下标处。
E[u].push_back({v,w}); // pair 存储边的终点、边权
// --------------------------------------------------
E[u].push_back({v,++cnt}); // 为每条边赋编号
wt[cnt]=w,... // 存入边相关信息
2.2 链式前向星
由于基本单位本身是边,多开几个数组就可以了。相关信息有几个就开几个数组。
inline void add(int u,int v,int w) { to[++cnt]=v,::w[cnt]=w,next[cnt]=head[u],head[u]=cnt; }
3. 图的遍历
3.1 std::vector
直接遍历出边集即可。这里以存入 {v,cnt}
的 std::pair
举例。
void dfs(int u) {
for(auto i:E[u]) {
int v=i.first,p=i.second;
// 如果你需要判断或处理一些东西
dfs(v);
// 或者在这里判断或处理一些东西
}
}
3.2 链式前向星
从节点的 \(head\) 开始枚举边的编号,不断跳 \(next\),直到 \(next\) 是 \(head\) 的初值 \(0\)。
void dfs(int u) {
for(int p=head[u];p;p=next[p]) {
int v=to[p];
// 如果你需要判断或处理一些东西
dfs(v);
// 或者在这里判断或处理一些东西
}
}
不过这个循环确实有点长,也可以 #define
一下。
#define forE(u) for(int p=head[u],v=to[p];p;p=next[p],v=to[p])
// 可以顺便把 v 定义了,这样就不需要每次都先声明 v 了
void dfs(int u) {
forE(u) {
// 太清爽了
dfs(v);
}
}
这就是为什么我不建议开结构体,因为不开的话哪怕不 #define
看起来也简单很多,而且几乎只需要定义最常用的 v
,其他的边相关信息,诸如 w[p]
,或者网络流中的 lim[p]
和 cst[p]
看起来也比结构体中大量的调用成员 E[p].
要清爽多了,简短的表达使得你不需要提前定义,直接打出这些也很快速。何况声明新变量也是需要时间的,不开结构体几乎能获得无敌的码量和极限的时间性能。
4. 双向边需要同时处理
如网络流的 反边流量增加,遍历时 不能回到父亲,无向图欧拉路径 双向边只走一次 等,他们都需要 同时调整 双向边在实际操作时建立的两条 单向边 的信息。
4.1 std::vector
采用上一部分中提到的方法,为每个边赋一个编号,将边相关信息放在 std::vector
外即可。如果编号从 \(2\) 开始,那么每对双向边的编号是 异或为 \(1\) 的关系,即 编号 ^1
就是反边编号。
for(auto i:E[u]) {
int v=i.first,p=i.second;
vis[p]=vis[p^1]=1; // 欧拉路径中常用的双向边打标
dfs(v);
lim[p]-=fl,lim[p^1]+=fl; // 网络流中常用的反边流量增加
}
4.2 链式前向星
同样,将 \(cnt\) 初值赋为 \(1\),让编号从 \(2\) 开始就可以了。
forE(u) {
vis[p]=vis[p^1]=1;
dfs(v);
lim[p]-=fl,lim[p^1]+=fl;
}
5. 对儿子遍历顺序有要求
最经典的场景是欧拉路径,他会要求你 按照字典序遍历。
5.1 std::vector
由于传统邻接表直接存储所有儿子,直接对边集排序即可。
for(int i=1;i<=n;++i) std::sort(E[i].begin(),E[i].end(),[](const int &x,const int &y){ return x<y; });
5.2 链式前向星
遍历顺序固定是前向星的弊端。在存图后,我们无法调整边之间的顺序,所以我们只能采取另一种方法:先排序再存边。
struct edge { int u,v; } E[M];
for(int i=1;i<=m;++i) {
// 读入一条边的起终点
E[i].u=u,E[i].v=v;
}
std::sort(E+1,E+m+1,[](const edge &x,const edge &y){ return x.v<y.v; });
for(int i=1;i<=m;++i) add(E[i].u,E[i].v);
这是前向星唯一的弊端,使用的人应该务必注意。
6. 屏蔽一条边 / 删边 / 操控特殊边
在有源汇上下界网络流中,你可能会需要删掉那条从 汇点 \(T\) 到 源点 \(S\) 的 流量 \(+ \infty\) 的边,其他的情况也有可能会有类似的删边或屏蔽边的操作。还有一些题(尤其是网络流)会需要你只考虑一些边(比如网络流处理二分图之后需要使用 KM 或匈牙利 二次处理,这时候那些连接源汇点的边只能用来添乱)。
6.1 std::vector
最理想的情况是你能得知那些你将来在屏蔽的边,使他们在最后添加。这样,你只需要不考虑边集中的后几项就可以了。
for(int i=0;i<E[u].size-x;++i) // 你要屏蔽最后的 x 条边
如果你不能得知,你可以想想是否可以判定一条边是否需要屏蔽,(特定的边权?指定的编号?)在遍历时直接 continue;
就好了。
操控特殊边是 std::vector
不太擅长的,你不如再开一个结构体按顺序记录都有哪些边,或者给那些你需要的边打个标记,再或者,你可以先存入你后期不要的边,再存入你后期需要的边,记下你第一条后期需要的边的下标。
6.2 链式前向星
永远不要忘记链式前向星作为 链表 的特性。对于刚才提到的最理想的情况,你可以让你的 \(head\) 不停跳 \(next\)。
for(int i=1;i<=x;++i) head[u]=next[head[u]]; // 屏蔽 u 的最新 x 条出边
操控特殊边则需要你记住,前向星的基本单位是 边。如果你可以知道你要操纵的边是第几条你存的边,那么操纵特殊边对于你来说就是小儿科。比如下面这一段摘自一道网络流题目:
for(int i=1;i<=n;++i) if(!f.lim[4*i-2]) for(int p=f.head[i];p;p=f.next[p]) if(!f.lim[p]) { match[i]=f.to[p]-n; break; } // 在存图时刻意按顺序操作,第 4*i-2 条边是源点到 i 的边,lim 是 0 证明满流了。
7. 当前弧优化
网络流和欧拉路径中经常要用到的操作。使用一个 \(head\)(前向星本身就有)来记录当前边即可。
#define forE(u) for(int &p=head[u],v=to[p];p;p=next[p],v=to[p]) // p 变成引用,head 的意义从最新边变为当前边
// ------
for(int &p=head[u];p<E[u].size;++p) dfs(E[u][p]) // head 记录当前边在边集中的下标
但是要注意 当前边 是在什么时候 更新 的。如在网络流中,当前边未流满但流量流尽则需要 break;
,不让当前弧继续跳;在欧拉回路中,std::vector
的写法经常是 for(int &p=head[u];p<E[u].size;) dfs(E[u][p++])
,因为需要在下一次迭代到 u
(不一定是在 这一层)的时候当前弧 已经跳到下一个。
8. 初始化
8.1 std::vector
需要清空所有边集。
for(int i=1;i<=n;++i) E[i].clear();
8.2 链式前向星
只需清空 \(head\),并将 \(cnt\) 调回初始值。\(to,next,w\) 等边相关信息都会被 覆盖。
cnt=1; memset(head,0,sizeof head);
单调栈 / 单调队列 总结
- UPD. 9.20
单调栈和单调队列经常用于维护一类具有单调子序列。两者的区别是是否具有 时效性。
我们在 ZROI 学习笔记之动态规划 中说过,单调栈用于解决 NGE 问题,而单调队列用于解决 滑动窗口 问题。基本上任何使用单调栈与单调队列的问题都可以被规约为这两个问题。在这里,我们不再赘述两个单调 DS 的基本问题,读者可以去 ZROI 学习笔记 中自行查看。这里,我们将简单快速地说明单调栈、单调队列的常见大众使用方法。
1. 单调栈问题
单调栈和单调队列最常见的使用场景莫过于辅助动态规划。在动态规划中,解决当前阶段的子问题通常依赖于前面数个阶段的决策。在一些问题中,我们会发现依赖(可能即将被依赖)的决策具有以下特点:
- 在所有符合条件的决策中越 新 越好,即用于转移的阶段越接近当前阶段越好。
- 有一定的要求,即并非所有阶段都能转移到当前阶段。
NGE 便是典型的符合这个特点的问题。根据上面的特点,我们会发现:
- 如果你已经是比较旧的阶段,又劣于新的阶段,那么你就没用了。
这很显然,也是衡量能否使用单调栈的关键标准。单调栈便维护一个具有如下特点的序列:
- 从栈底到栈顶,时效性 递增;
- 从栈顶到栈底,最优性 单调。
2. 从单调栈到单调队列
在 ZROI 学习笔记 中,我们也提到了两者的区别:单调队列要维护比单调栈 更强的时效性。一般来说,这表现为:
- 栈底的元素会 过期。
所以我们需要打通栈底,使得我们可以 pop_front()
,因此称之为队列。之所以称之为队列,还有另一个重要的特点,就是他的条件优先级一般与栈不太一样。一般来说,两者解决的问题是:
- 单调栈:可行里找最新的。(时效性 > 最优性)
- 单调队列:范围里找最优的。(最优性 > 时效性)
所以会发现一般单调栈决策都从栈顶状态转移,而单调队列从队首状态转移。你可以理解为:
- 使用队首是因为 它最优;
- 维护队列是为了 有替补;
- 维护替补是因为 会弹出;
- 队首弹出是因为 会过期。
所以都是时效性惹的祸咯。
3. 灵活看待单调队列
单调栈和单调队列终归是一个方法。面对时效性和最优性不同的问题,我们应该做到能随时调整我们的战略。包括但应该不限于:
- 栈或队列;(取决于时效性是否要求你的队首弹出)
- 首或栈底。(取决于时效性与最优性哪个需求更强)
面对时效性更更强的问题,我们也要灵活调整压入与弹出顺序。比如 跳房子 这道题,时效性就有两个要求 —— 最小和最大能跳的距离。这时候,就不能直接压入当前阶段的元素。因为当前阶段的元素可能会太新了,不满足最小跳跃距离的要求。这时候就应该延迟压入,每到下一个阶段把最新满足最小跳跃距离的格子送进队列。
手写链表魔术
- UPD. 9.27
昨天做 ZROI 的 CSP 七连 D3,T3 的需要用到链表,而笔者几乎从来没有用到过链表,所以今天就来总结一下,当你想到链表时,你应该注意什么。
1. 动机
你为什么要是用链表?当你想清楚这个问题之后,你 \(80 \%\) 可能已经不准备使用链表了。链表确实得益于其简单的逻辑和快速的连通性维护方法广泛应用,但由于它太平庸了,以至于从各个角度我们几乎都可以找到替代品:
- 维护连通性:链表的主打优点。但如果只需要连通而不需要断连就可以使用 并查集,使用更小的码量得到更优的效果。(并查集可以随机访问。链表只能从首尾开始。)
- 动态维护清单:那么你可以选择的余地就更多了。最简单的方法就是使用
std::vector
来代替;同时,标准库中也有自带的链表std::list
,你不妨试试它;如果你需要带 rank,那么你可以考虑std::set
;如果需要查询信息,也可以使用std::map
。后两者可以增加你的功能,而代价是一个 \(\log\)。有一说一,在 \(O2\) 的时代,大部分的标准库容器都可以放心使用了。(永远不包括可爱的std::deque
和它的衍生产品。不得不说一个水平中上的任意选手都应该具备随时手写栈和队列的能力;而双端队列可以通过循环队列来实现。)
所以,确定好动机再下手。链表的逻辑和码量其实不严格成正比。
2. 实现
继续刚才所说的,链表的逻辑简单,但写出来并不很容易。链表需要动态分配名额,我们一般考虑三种方案:
- 开结构体数组作为 节点池,每个节点包含三个值:前驱节点编号、后继节点编号、自身记录值。
- 同上,但使用指针来维护前驱节点和后继结点。
- 全部使用指针,每次需要节点时通过
new
获取。
他们各有利弊。方案一的好处是数组的隐形保护机制更好,但代价是你需要下标套下标,这其实不太好受。笔者认为,在链表中使用指针是一种更为直观的展现。这也是笔者会列出后两种的原因。尤其是方案三,当你在不知道链表节点总数,或者需要为你的程序缩小常数时,它一般是更优的方法。而且如果你有能力编写自己的迭代器,那么后两者能带给你最接近标准库链表的体验,笔者个人认为这种统一连贯的风格很舒适。但付出的代价也是显然的,nullptr
、野指针等问题必须严格提防,否则随时有可能 RE。
3. 代码
使用数组作为节点池的双向链表,分配节点的实现类似于其他动态开点数据结构,(如 splay
和动态开点线段树。)这里暂时不给出代码。(因为笔者也没有具体写过……)
由于笔者对于指针数据结构太感兴趣了,所以不小心把指针的双向链表实现了。所以这里给出的是使用指针实现、存储在堆内存中的双向链表。
template<class E> class linked_list {
private:
struct node {
node *last,*next;
E val;
node(node *last,node *next,E x) { this->last=last,this->next=next,val=x; }
};
class iterator: std::iterator<std::bidirectional_iterator_tag,node> {
private:
node* _ptr;
public:
iterator(node *p=nullptr) { _ptr=p; }
iterator& operator= (const iterator &it) { _ptr=it._ptr; }
iterator& operator++() { _ptr=_ptr->next; return *this; }
iterator& operator--() { _ptr=_ptr->last; return *this; }
iterator& operator++(int) { auto ret=*this; return _ptr=_ptr->next,ret; }
iterator& operator--(int) { auto ret=*this; return _ptr=_ptr->last,ret; }
bool operator== (const iterator &it) { return _ptr==it._ptr; }
bool operator!= (const iterator &it) { return _ptr!=it._ptr; }
node* operator->() { return _ptr; }
node& operator*() { return *_ptr; }
} front_it,back_it;
public:
linked_list() { front_it=nullptr,back_it=nullptr; }
inline void push_back(E x) {
node *now=new node(back_it,nullptr,x);
back_it&&(back_it->next=now),!front_it&&(front_it=now);
back_it=now;
}
inline iterator remove(node *x) {
x->last&&(x->last->next=x->next),x->next&&(x->next->last=x->last);
x==front_it&&(front_it=x->next),x==back_it&&(back_it=x->last);
auto ret=x->next; delete x;
return ret;
}
inline iterator begin() { return front_it; }
inline iterator end() { return back_it==nullptr?nullptr:back_it->next; }
inline E& front() { return front_it->val; }
inline E& back() { return back_it->val; }
};
4. 应用
比如这次的 T3,因为一直没写过链表,决定写写试试。所有的东西,一眼别人程序没看,搞出来的:
// Author: MichaelWong
// Code: C++14(GCC 9)
// Date: 2023/9/27
// File: [2023CSP七连 Day3] 记忆碎片.cpp
// Customized Double Linked_List Huzzah!
#include<bits/stdc++.h>
#define ll long long
#define ld long double
#define pii std::pair<int,int>
#define fsp(x) std::fixed<<std::setprecision(x)
#define forE(u) for(int p=head[u],v=to[p];p;p=next[p],v=to[p])
const int N=1e6+6;
struct linked_list {
struct node {
node *last,*next;
pii val;
node(node *last=nullptr,node *next=nullptr,pii x={0,0}) { this->last=last,this->next=next,val=x; }
inline int& fr() { return val.first; }
inline int& sc() { return val.second; }
};
class iterator: std::iterator<std::bidirectional_iterator_tag,node> {
private:
node* _ptr;
public:
iterator(node *p=nullptr) { _ptr=p; }
iterator& operator++() { _ptr=_ptr->next; return *this; }
node* operator->() { return _ptr; }
operator node*() { return _ptr; }
};
iterator front,back;
linked_list() { front=nullptr,back=nullptr; }
inline void push_back(pii x) {
node *now=new node(back,nullptr,x);
back&&(back->next=now),!front&&(front=now);
back=now;
}
inline iterator remove(node *x) {
x->last&&(x->last->next=x->next),x->next&&(x->next->last=x->last);
x==front&&(front=x->next),x==back&&(back=x->last);
auto ret=x->next; delete x;
return ret;
}
inline iterator begin() { return front; }
inline iterator end() { return back==nullptr?nullptr:back->next; }
} list[N][26];
int n,node[N][26],tot,ans[N],anstop,top=0;
std::string stc[N];
bool vis[N],isend[N];
inline void decode(std::string &s) { std::rotate(s.begin(),s.begin()+anstop%s.length(),s.end()); }
inline void query(int id,const std::string &s,int base=0) {
int now=0,i;
for(i=0;i<s.length();++i) {
int to=s[i]-'a',nxt=isend[now]?node[0][to]:node[now][to];
if(!nxt) { list[isend[now]?0:now][to].push_back({id,base+i}); break; }
now=nxt;
}
if(i==s.length()&&isend[now]) vis[id]=1,ans[++anstop]=id;
}
inline void throwto(linked_list &lst,int now,bool last) {
for(auto it=lst.begin();it!=lst.end();) {
auto &u=*it;
if(++u.sc()==stc[u.fr()].length()) {
if(last) vis[u.fr()]=1,ans[++anstop]=u.fr();
it=lst.remove(it); continue;
}
int to=stc[u.fr()][u.sc()]-'a';
list[now][to].push_back(u.val);
it=lst.remove(it);
}
}
inline void freed(int now) {
for(int i=0;i<26;++i) for(auto it=list[now][i].begin();it!=list[now][i].end();) {
auto &u=*it;
query(u.fr(),stc[u.fr()].substr(u.sc()),u.sc());
it=list[now][i].remove(it);
}
}
inline void insert(const std::string &s) {
int now=0;
for(int i=0;i<s.length();++i) {
int to=s[i]-'a';
if(!node[now][to]) node[now][to]=++tot,throwto(list[now][to],tot,i+1==s.length());
now=node[now][to];
}
isend[now]=1,freed(now);
}
int main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr); std::cout.tie(nullptr);
std::cin>>n;
for(int i=1;i<=n;++i) {
char opt; std::string s;
std::cin>>opt>>s,decode(s),anstop=0;
if(opt=='+') insert(s);
else stc[++top]=s,query(top,s);
std::cout<<anstop;
std::sort(ans+1,ans+anstop+1);
for(int i=1;i<=anstop;++i) std::cout<<' '<<ans[i];
std::cout<<'\n';
}
return 0;
}
// The code was submitted on ZROJ.
// Version: 1.
// If I filled in nothing on the statement,
// it means I'm in a contest and I have no time to do this job.
因为题目不是公开的,大部分人如果真的看了上面那个代码的话应该都云里雾里,简单来说,就是对于字典树的每一个点开了 \(|\Sigma|\) 个链表,记录转移进度,可以使用 std::vector
,std::map
等。笔者在这里使用了自己手写的双向链表,跑的很快,感觉很爽。