警示自己&后人
从 2024.6 开始维护。
文章目录
基础
想到一种方案之后,看看到底假了没有
第一次发癫叉正解:
场上算错了复杂度,自己把自己的可持久化线段树的正解给叉掉了。
对于复杂度不是很好算的题目,要认真算。像这样也就一百来行的代码,思路又已经证明是正确的,可以把它实现出来,再拿极限数据去测。
第二次叉正解:
如果构造反例的话,一定记得看看分析是否全面。
离散化
离散化的时候,如果会多次重复使用,那要把旧的对应新的编号在原数组给存起来,这样后面就不需要每次都要 O ( log n ) O(\log n) O(logn) 查询,极大地节约时间。不过后面只会查一次就算了。
离散化 cnt = unique(hdld + 1, hdld + n + 1) - hdld - 1;,-1 不能丢!
指针
指针 delete 之后,指针变量不会自动归零,要记得把指针变量归零!
一个指针变量占 8 个字节,很多时候比数组模拟指针更占空间。指针慎用。
应该因为指针:数组开小了未必会 Segmentation Fault,可能 只是 WA or TLE!
位运算
注意运算优先级,移位的优先级低于加减。
int 类型,通过 x^1 实现 0 变 1,1 变 0。~ 运算是把所有位全部取反,注意区分。
and > xor > or。
除以二下取整,可以 (x-1>>1)+1,也可以 x+1>>1。
20241019 联测,wssb。
dp 每一位的值都是 0 或者 1,那么就算用 char 数组直接一位一个还是浪费,bitset 既省空间也带一个 O ( 1 ω ) O(\frac{1}{\omega}) O(ω1)。
__builtin 系列常数很大,可能会被卡,建议
O
(
2
n
)
O(2^n)
O(2n) 手写 ppc 和 ctz。
一些低级&降智问题
2024年8月24日,SMS OI队的两个…,在多重循环内,需要退出掉包括最外层的所有循环,只写了一个break,挂分并几乎爆零,这两位因此疯狂掉Rate,蓝变青。
No goto, No break, you can code a function, with a return 0 or return 1.
i j 检查有没写反,发现之后更正的时候,注意 有没有全部都更正掉。(upd0824,今天调题有发现了这种问题。)
多测清空,估算要不要 long long,这些都是基础操作。
看明白题,注意边界应该怎么处理。
跑大样例的时候,vscode 或者机房的 Devc++,里面不要打开输入文件或者输出文件,否则会死机。
不管什么时候,只要是递归(深搜),都要检查边界特判写了没有,没写就会死循环然后爆栈。
有的时候,注意会不会前面写了 2k 的函数,然而后面 main() 里面没有调用。
仿函数有两个括号!(?
bool operator() (int x, int y) { return x < y; }
几乎每次模拟赛都有人 把数组开小或者开大 导致 WA/RE/TLE/MLE,做下一道题之前记得检查一下 const int MAXN = ...;
非法状态下系数为 0 0 0,但是访问会越界。。。
int meet = fplus(win[pa[y]][x], win[pa[x]][y]);
if (pa[x] == pa[y]) meet = 1;
if (!meet) continue;
win[x][y] = (LL)meet * siz[x] % mod * inv[siz[x]+siz[y]] % mod;
win[y][x] = (LL)meet * siz[y] % mod * inv[siz[x]+siz[y]] % mod;
模拟
大模拟一般考虑要面向对象。
2023 CSP-S T3,本质就是DAG,注意转化。
大模拟把题目读懂就成功了一半。
可以写一点调一点检查一点。
数据结构
很多时候,动态开点是个好东西。(e.g. 主席树、线段树合并、FHQ-Treap、01-Trie)
线段树
240629:线段树,update() 和 query() 里面的 mid,应该是 mid = (s[cur].l + s[cur].r) >> 1 而非 mid = (l+r)>>1。
这个 l 和 r 是函数参数传入的修改/查询左右端点。
void update(int cur, int l, int r) { // 区间修改
if (l <= s[cur].l && r >= s[cur].r) {
change(s[cur]);
return;
}
spread(cur); //别丢了
int mid = (s[cur].l + s[cur].r) >> 1; // !!!
if (l <= mid) update(cur * 2, l, r);
if (r > mid) update(cur * 2 + 1, l, r);
s[cur].sum = s[cur*2].sum + s[cur*2+1].sum;
}
int query(int cur, int l, int r) { // 区间查询
if (l <= s[cur].l && r >= s[cur].r) {
return s[cur].sum;
}
spread(cur);
int mid = (s[cur].l + s[cur].r) >> 1, res = 0;
if (l <= mid) res += query(cur*2, l, r);
if (r > mid) res += query(cur*2+1, l, r);
return res;
}
int query(int cur, int p) { // 单点查询
if (s[cur].l == s[cur].r) return s[cur].sum;
spread(cur);
int mid = (s[cur].l + s[cur].r) >> 1;
if (p <= mid) return query(cur*2, p);
else return query(cur*2+1, p);
}
240630:前面的 if-return 写完之后,spread(cur) 记得要写。
修改操作,递归完左右子树之后需要把两个子树的信息重新合并,即 pushup 操作。
线段树非常重要,应用十分广泛,代码要熟悉!(包熟练的)
240701:
时刻关注 long long 问题!!!算法本身没问题很有可能是数据类型的问题。
利用和角公式更新的时候:
(线段树sin和 问题)
sin
(
α
+
β
)
=
sin
α
cos
β
+
cos
α
sin
β
cos
(
α
+
β
)
=
cos
α
cos
β
−
sin
α
sin
β
\sin(\alpha + \beta) = \sin\alpha\cos\beta + \cos\alpha\sin\beta\\ \cos(\alpha + \beta) = \cos\alpha\cos\beta - \sin\alpha\sin\beta
sin(α+β)=sinαcosβ+cosαsinβcos(α+β)=cosαcosβ−sinαsinβ
void change(Segment &x, long long v) { // 就是这里这个 long long
double sinv = sin(v), cosv = cos(v), tmp = x.stot;
x.stot = x.stot * cosv + x.ctot * sinv;
x.ctot = x.ctot * cosv - tmp * sinv;
x.add += v;
}
类似于 exgcd 的问题。
int exgcd(int a, int b, int &x, int &y) {
if (b == 0) {
x = 1, y = 0;
return a;
}
int d = exgcd(b, a%b, x, y);
int z = x;
x = y;
y = z + a / b * y;
return d;
}
tmp = x.stot 和 z = x 作用相同,因为会覆盖原来的值,所以要把原来的存起来。
当然 exgcd 有更优美的写法。
11 月发现 7 月的线段树码风还是那么 清 澈。
240703
必要时 query 可以返回整一段 Segment,方便处理。(对于复杂信息/多种询问方式,建议这样处理)
240719
一般的线段树一定要 四倍空间,动态开点一定要 二倍空间。
240724
主席树老老实实开到 MAXN<<5!
主席树维护可持久化并查集,要开到 (MAXN+MAXM)<<5。
并查集启发式合并,在 x 和 y 的祖先不同时才能合并(否则会产生错误大小),相同时应令 root[i] = root[i-1]。
李超线段树,左端点是自变量的最小取值,右端点是自变量的最大取值,不是平常的 [ 1 , n ] [1, n] [1,n]。
240919
QOJ5425 / CF_GYM104128L(ICPC2022 南京) https://qoj.ac/submission/576389
typedef vpi vector<pair<int, int>>;
void queryL(int cur, int l, int r, int L, int R, vpi& res) {
if (l == r) return res.push_back(make_pair(c[l], l)), void();
spread(cur);
int mid = (l + r) >> 1;
if (L <= mid && s[cur<<1].mn < L) queryL(cur<<1, l, mid, L, R, res);
if (R > mid && s[cur<<1|1].mn < L) queryL(cur<<1|1, mid+1, r, L, R, res);
}
线段树上每查一个区间,一次查找多个满足条件的值,可以使用 std::vector(前提是 复杂度正确)。
我们用线段树维护区间最远前驱和最远后继,就能 O ( c n t log n ) O(cnt \log n) O(cntlogn) 地找到所有被切开的位置,其中 c n t cnt cnt 是被切的位置数量。由于每次被切会增加双向链表的数量,且最多只有 n − 1 n − 1 n−1 条链(每个链边单独一条)(即 ∑ c n t ≤ n − 1 \sum cnt \le n-1 ∑cnt≤n−1),因此这里复杂度为 O ( n log n ) O(n \log n) O(nlogn) 。
这个 trick 很经典, 另一道经典题 “观光公交” 中我的做法也用到了.
动态开点的时候,一般以 0 号节点作为空子树。需要注意对空子树的处理,如算左右儿子的较小值,一个儿子为空,但是 s[0].mn 没有赋值 INF,会寄掉。
(小心 0 号节点的情况,可以 assert 一下它为空。)
李超线段树合并:
树形 DP+李超线段树合并,时间复杂度是 O ( n log n ) O(n \log n) O(nlogn) 的。
合并的时候,相当于原地插入。
李超树不是 leafy 的,因为标记永久化。
merge 特判返回条件 !x || !y。
发现大 BUG 改掉之后,及时检查一下。
LL dsc[MAXN];
namespace SGT {
int lc[MAXN<<4], rc[MAXN<<4], tot;
Line s[MAXN<<4];
#define xl s[x](dsc[l])
#define nl ln(dsc[l])
#define xm s[x](dsc[mid])
#define nm ln(dsc[mid])
#define xr s[x](dsc[r])
#define nr ln(dsc[r])
void update(int& x, int l, int r, Line ln) {
if (!x) x = ++tot;
int mid = (l + r) >> 1;
if (xl <= nl && xm <= nm && xr <= nr) return s[x] = ln, void();
if (xl >= nl && xm >= nm && xr >= nr) return;
if (xm <= nm) swap(s[x], ln);
if (xl <= nl) update(lc[x], l, mid, ln);
if (xr <= nr) update(rc[x], mid+1, r, ln);
}
void merge(int& x, int y, int l, int r) {
if (!x) swap(x, y);
if (!x || !y) return;
update(x, l, r, s[y]);
int mid = (l + r) >> 1;
merge(lc[x], lc[y], l, mid), merge(rc[x], rc[y], mid+1, r);
}
LL query(int x, int l, int r, int p) {
if (!x) return Line()(dsc[p]);
if (l == r) return s[x](dsc[p]);
int mid = (l + r) >> 1;
if (p <= mid) return max(s[x](dsc[p]), query(lc[x], l, mid, p));
else return max(s[x](dsc[p]), query(rc[x], mid+1, r, p));
}
}
分块
240716
注意边界处理,右端点不能超过右边界。
莫队
void update(int lastL, int lastR, int curL, int curR) {
if (lastR < curR)
for (int i = lastR + 1; i <= curR; ++i) inc(a[i]);
if (lastL > curL)
for (int i = curL; i < lastL; ++i) inc(a[i]);
if (lastR > curR)
for (int i = curR + 1; i <= lastR; ++i) dec(a[i]);
if (lastL < curL)
for (int i = lastL; i < curL; ++i) dec(a[i]);
}
void solve() {
int len = sqrt(m);
int x = 1, y = 0; // !!!
for (int lt = 1, rt; lt <= m; lt = rt + 1) {
rt = min(m, lt + len - 1);
if ((lt / m) & 1) sort(q + lt, q + rt + 1, cmp22);
else sort(q + lt, q + rt + 1, cmp21);
for (int i = lt; i <= rt; ++i) {
update(x, y, q[i].l, q[i].r);
q[i].ans = ans;
x = q[i].l, y = q[i].r;
}
}
}
模板 Luogu 2709。注意让 x = 1, y = 0,最初区间长度为 0。
莫队 奇偶不同,排序方法不同。
平衡树
FHQ-Treap
如果有 rotate (区间翻转,文艺平衡树)操作,注意 spread 的位置:
pii split(int cur, int rank) {
if (!cur) return make_pair(0, 0);
spread(cur);
if (s[s[cur].lc].siz >= rank) {
// spread(s[cur].lc); WA
pii res = split(s[cur].lc, rank);
s[cur].lc = res.second;
pushup(cur);
return make_pair(res.first, cur);
} else {
// spread(s[cur].rc); WA
pii res = split(s[cur].rc, rank-s[s[cur].lc].siz-1);
s[cur].rc = res.first;
pushup(cur);
return make_pair(cur, res.second);
}
}
int merge(int l, int r) {
if (!l) return r;
if (!r) return l;
if (s[l].dat > s[r].dat) {
spread(l);
s[l].rc = merge(s[l].rc, r);
pushup(l);
return l;
} else {
spread(r);
s[r].lc = merge(l, s[r].lc);
pushup(r);
return r;
}
}
应该先 spread 整一棵子树再进行后续处理。
线段树也好,平衡树也罢,只要有延迟标记,都要在开始处理当前节点之前完成 标记下传 操作。
如果有必要,一些高级数据结构可以进行封装。
平衡树建议把值相同的存一个点上面。
维护父指针,可以做查询某个节点在它所在的 fhq 中的排名。
pii findroot(int x) {
int y, ans;
for (y = rc[x], ans = 0; x; y = x, x = pa[x]) {
if (y == rc[x]) ans += s[lc[x]].siz + 1;
}
return make_pair(y, ans);
}
OldDriverTree
struct Itv {
int l, r, c;
Itv(int _l, int _r, int _c=0) {
l = _l, r = _r, c = _c;
}
bool operator<(const Itv& x) const {
return r < x.l;
}
};
Interval 结构体里面,重载小于号为 r < x.l,此时直接通过 set<Itv>::find() 就可以找到相交的区间。
离线分治
vector<int> solve(int l, int r) {
if (l == r) return {l};
int mid = l + r >> 1;
vector<int> lv = solve(l, mid), rv = solve(mid+1, r), res;
auto it1 = lv.begin(), it2 = rv.begin();
while (it1 != lv.end() || it2 != rv.end()) {
int x;
if (it1 != lv.end() && (it2 == rv.end() || s[*it1].pos <= s[*it2].pos)) {
x = *(it1++);
if (!s[x].op) BIT::update(s[x].pre, s[x].tp);
} else {
x = *(it2++);
if (s[x].op) ans[s[x].tim] += BIT::query(s[x].pre) * s[x].tp;
}
res.emplace_back(x);
}
for (int x : lv) if (!s[x].op) BIT::update(s[x].pre, -s[x].tp);
return res;
}
写一个可以返回 vector 的 CDQ。
树
树上差分
树上差分分为边差分和点差分,边差分:
int a = lca(x, y);
++val[x], ++val[y];
val[a] -= 2;
点差分:
int a = lca(x, y);
++val[x], ++val[y];
--val[a], --val[pa[a][0]];
这个问题之前听人在哪里说过。当然要先判断 pa[a][0] 是否存在。
树上差分,查询需要遍历整个子树,如果一边修改一边查询的话,就不太适用。
树链剖分
240707
void dfs2(int x) {
dfn[x] = ++cnt;
oldID[cnt] = x;
if (!top[x]) top[x] = x;
if (!hvy[x]) return; // !!!
top[hvy[x]] = top[x];
dfs2(hvy[x]);
for (int i = head[x]; i; i = nxt[i]) {
int y = ver[i];
if (y == pa[x] || y == hvy[x]) continue;
dfs2(y);
}
}
这个 if(!hvy[x]) return; 千万不能掉。叶子节点就需要回溯了,不能再 dfs 下去。
丢了这个就会 RE!
如果树剖查询一条路径上的信息,可以重复利用 pushup 函数(改为 merge 了),减少代码量,减少出错的可能。
240719 续上一条:
避免惯性思维:
void merge(Segment &x, Segment lc, Segment rc);
merge(xseg, query(1, dfn[top[x]], dfn[x]), xseg); // AC
merge(xseg, xseg, query(1, dfn[top[x]], dfn[x])); // WA
原因:
线段树维护的时候,查询得到的线段一定是左端点 < 有端点,从点 u 到 lca(u,v) 之间的路径,一定有 dfn[lca(u,v)] < dfn[u],而小的在前,所以新加入的这一部分应该作为左儿子,旧的作为右儿子来合并。
完整的 queryPath(Luogu 3976):
Segment queryPath(int x, int y) {
Segment xseg, yseg;
xseg.add = yseg.add = -1;
while (top[x] != top[y]) {
if (dep[top[x]] >= dep[top[y]]) {
merge(xseg, query(1, dfn[top[x]], dfn[x]), xseg);
x = pa[top[x]];
} else {
merge(yseg, query(1, dfn[top[y]], dfn[y]), yseg);
y = pa[top[y]];
}
}
if (dep[x] >= dep[y]) merge(xseg, query(1, dfn[y], dfn[x]), xseg);
else merge(yseg, query(1, dfn[x], dfn[y]), yseg);
swap(xseg.inc, xseg.dec);
merge(xseg, xseg, yseg);
return xseg;
}
如果用重载加法写 pushup() 的话:xseg = query(1, dfn[top[x]], dfn[x]) + xseg;
树分块
void dfs(int x, int pa) {
int lst = tp;
for (int i = head[x]; i; i = nxt[i]) {
int y = ver[i];
if (y == pa) continue;
dfs(y, x);
if (tp - lst >= m) {
rt[++cnt] = x;
for (; tp > lst; --tp) bel[sta[tp]] = cnt;
}
}
sta[++tp] = x;
if (tp - lst >= m) {
rt[++cnt] = x;
for (; tp > lst; --tp) bel[sta[tp]] = cnt;
}
}
小清新构造题 Luogu 2325,当前节点入栈的时候也判一下,就可以规避题解中跑完之后 c n t = 0 cnt = 0 cnt=0 的情况。
主函数内调用:
dfs(1, 0);
for (; tp; --tp) bel[sta[tp]] = cnt; // 记得把剩下不能构成整块的接到最后一块取去。
出栈序
第一遍 DFS 记录出栈序,第二遍:
y y y 是 x x x 的祖先,那么从 o u t x out_x outx 到 o u t y out_y outy 之间的,要么是还没有访问过的,要么是 y y y 到 x x x 的链上的。
点分治
点分治适合解决的问题:
- 一般是树上路径统计问题
- 直接树形 DP、DSU On Tree、线段树合并不好做
- 钦定路径必须经过某个点为根节点后好做
树形 DP
换根的时候,不要把包含这个子节点的当成由父亲传来的,然后错误地传下去了。
树上背包是 O ( n 2 ) O(n^2) O(n2) 的。
考虑树上背包的板子,类似下面的伪代码。
def dfs(x):
siz[x] = 1
for y in son(x):
dfs(y)
for i in [0, siz(x)]:
for j in [1, siz(y)]:
do sth.
// 上面操作 O(siz[x] * siz[y])
siz[x] += siz[y]
考虑累加这些 siz[x] * siz[y] 的过程,对于点对
(
x
,
y
)
(x, y)
(x,y),只会在 LCA 上被计入贡献一次。
所以时间复杂度就是 O ( n 2 ) O(n^2) O(n2)。
动态 DP
在 dfs2 的时候,如果是叶子节点,除了更新 ed[top[x]] = x 之外,如果有计入当前节点权值的
f
,
g
,
h
f, g, h
f,g,h,也要记得维护。
动态树
目前只学了 LCT。
留心 0 号节点应该为空,所有值都应该是 0。
保险起见,link 操作和 cut 操作之前,都要判断一下是否合法。
理论上 cut 操作之后应该是需要 pushup 的。
splay 之前记得 spreadanc 递归下传祖先的标记。
个人习惯,翻转标记:已交换左右子,未交换左右子的左右子。
idt 特判父亲节点是 0,此时一定返回 -1。
namespace LCT {
int son[MAXN][2], pa[MAXN], tag[MAXN];
#define lc(x) son[x][0]
#define rc(x) son[x][1]
struct Data {
int val, sum, siz;
int add, mul;
Data(){}
Data(int v) {
val = sum = v, siz = 1;
add = 0, mul = 1;
}
void modify(int x, int y) { // add, mul
val = (1ll * val * y + x) % mod, sum = (1ll * sum * y + 1ll * x * siz) % mod;
mul = 1ll * mul * y % mod, add = (1ll * add * y + x) % mod;
}
} s[MAXN];
void pushup(int x) {
s[x].sum = (s[lc(x)].sum + s[rc(x)].sum + s[x].val) % mod;
s[x].siz = s[lc(x)].siz + s[rc(x)].siz + 1;
}
void addtag(int x) {
tag[x] ^= 1, swap(lc(x), rc(x));
}
void pushdown(int x) {
if (tag[x]) {
if (lc(x)) addtag(lc(x));
if (rc(x)) addtag(rc(x));
tag[x] = 0;
}
if (lc(x)) s[lc(x)].modify(s[x].add, s[x].mul);
if (rc(x)) s[rc(x)].modify(s[x].add, s[x].mul);
s[x].add = 0, s[x].mul = 1;
}
int idt(int x) {
if (!pa[x]) return -1;
if (x == lc(pa[x])) return 0;
if (x == rc(pa[x])) return 1;
return -1;
}
void spreadanc(int x) {
if (~idt(x)) spreadanc(pa[x]);
pushdown(x);
}
void rotate(int x) {
int f = pa[x], ff = pa[f], tp = idt(x);
if (~idt(f)) son[ff][idt(f)] = x; pa[x] = ff;
son[f][tp] = son[x][tp^1], pa[son[x][tp^1]] = f;
son[x][tp^1] = f, pa[f] = x;
pushup(f), pushup(x);
}
void splay(int x) {
spreadanc(x);
for (int f = pa[x]; ~idt(x); rotate(x), f = pa[x]) {
if (~idt(f)) rotate(idt(f) == idt(x) ? f : x);
}
}
void access(int x) {
for (int s = 0; x; s = x, x = pa[x]) {
splay(x), rc(x) = s, pushup(x);
}
}
void makeroot(int x) {
access(x), splay(x), addtag(x);
}
int findroot(int x) {
access(x), splay(x);
while (pushdown(x), lc(x)) x = lc(x);
return splay(x), x;
}
void split(int x, int y) {
makeroot(y), access(x), splay(x);
}
void link(int x, int y) {
makeroot(x);
if (findroot(y) != x) pa[x] = y;
}
void cut(int x, int y) {
split(x, y);
if (lc(x) == y && !rc(y)) lc(x) = pa[y] = 0, pushup(x);
}
}
图论
只要是从 floyd 改过来的算法,都具有 dp 的特性,既然是 dp,就必须 k-i-j 的循环次序。f[i][j] = min(f[i][j], f[i][k] + f[k][j]);。
注意重边和自环是否需要特殊处理。题目有没有明确交代无重边无自环。
差分约束,求最短路, ∀ y − x ≤ z \forall y-x\le z ∀y−x≤z, x x x 向 y y y 连权值为 z z z 的边。
相当于 y ≤ min { x + z } y \le \min \{x + z\} y≤min{x+z},取等号即可。其他情况都要转化成上面的情况。
spfa 不要乱用,如果有必要,加双端队列成为 slf,slf 注意 if (!dq.empty() && dis[y] < dis[dq.front()]),避免越界。
Tarjan
注意是否有已知根节点,还是每个点都要检查一遍。最好就对每个点都进行遍历。
无向图,low[y] > dfn[x] 割边,low[y] >= dfn[x] 割点。
割边传入参数 from,从哪条边来的,要 if (i ^ from ^ 1) 检查一下,同时注意链式前向星 tot 初始值为 1。
割点,如果是根节点要特殊处理,如果根节点只有一个子节点,那么删去它也没法分成不连通的两部分。
有向图强连通分量模板,注意区分 low[x] = min(low[x], low[y]); 和 low[x] = min(low[x], dfn[y]);:
void tarjan(int x) {
dfn[x] = low[x] = ++cnt;
sta[++top] = x, ins[x] = 1;
for (int i = head[x]; i; i = nxt[i]) {
int y = ver[i];
if (!dfn[y]) {
tarjan(y);
low[x] = min(low[x], low[y]);
} else if (ins[y]) {
low[x] = min(low[x], dfn[y]);
}
}
if (dfn[x] == low[x]) {
++num;
while (top) {
int y = sta[top--];
ins[y] = 0;
c[y] = num;
if (y == x) break;
}
}
}
边双缩点之后建边 DFS,要判断这个是桥边且对方在之前的某一轮 DFS 中已经访问过(即 c y ≠ 0 c_y \ne 0 cy=0)。
void getbcc(int x) {
c[x] = num, ++siz[num];
for (int i = head[x]; i; i = nxt[i]) {
int y = ver[i];
if (bri[i] && c[y]) addc(c[x], c[y]), addc(c[y], c[x]); // 外
else if (!bri[i] && !c[y]) getbcc(y); // 内
}
}
2-SAT 问题
for (int i = 1; i <= n * 2; ++i) if (!dfn[i]) tarjan(i);
这个 *2 不能丢,因为它 实际上有 2 n 2n 2n 个节点。
for (int i = 1; i <= n; ++i) {
printf("%d ", c[i] > c[i + n]);
}
最后输出方案的时候,因为 tarjan() 得到的是逆拓扑序,而给方案是 拓扑序小的优先,所以:
c[i] < c[i+n] 时,取 0;c[i] > c[i+n] 时,取1。
(当然 c[i] == c[i+n] 要记得判 No Solution。)
无向图所属环完全相同的边
Luogu 6914 关键性质(怎样找非割边且所属环编号完全相同的边)
观察图发现,桥边一定不在环上,不做考虑,对于非桥边,暴力枚举,把当前边删除,然后再跑一遍 tarjan,所有新增的桥边都是和当前所删的的边所属环编号完全相同的边,这些边共同组成一条符合上述条件的链。( x x x 和 y y y 所属环的集合完全相同,当且仅当: x x x 不是桥, y y y 不是桥,删去 x x x 后 y y y 是桥。)
- 原先不是桥:必然属于某些环上;
- 断开这条边之后成为桥:
- 显然没有 x x x 的环,一定没有 y y y(桥的性质,即:有 y y y 的环一定有 x x x)。
- 下面证明有 x x x 的环一定有 y y y。
假设存在一个环 A A A,使得 x ∈ A x \in A x∈A 且 y ∉ A y \notin A y∈/A。
根据上述结论,存在一个环 B B B,使得 x ∈ B x \in B x∈B 且 y ∈ B y \in B y∈B。
则 x ∈ A ∩ B x \in A \cap B x∈A∩B, y ∉ A ∩ B y \notin A \cap B y∈/A∩B。
记 A ⊕ B = ∁ A ∪ B A ∩ B A \oplus B = \complement_{A \cup B} A \cap B A⊕B=∁A∪BA∩B(对称差,类似 xor 运算)。
显然 A ≠ B A \neq B A=B, A ⊕ B ≠ ∅ A \oplus B \neq \varnothing A⊕B=∅, x ∉ A ⊕ B x \notin A \oplus B x∈/A⊕B, y ∈ A ⊕ B y \in A \oplus B y∈A⊕B。
与 ∀ x ∉ X , y ∉ X \forall x \notin X, y \notin X ∀x∈/X,y∈/X 矛盾。
所以 ∀ x ∈ X , y ∈ X \forall x \in X, y \in X ∀x∈X,y∈X。
二分图
写书上了。
通用模板:
int n, head[MAXN], ver[MAXM], nxt[MAXM], tot;
int vis[MAXN], mch[MAXN];
void add(int x, int y) {
ver[++tot] = y, nxt[tot] = head[x], head[x] = tot;
}
bool dfs(int x, int root) {
for (int i = head[x]; i; i = nxt[i]) {
int y = ver[i];
if (vis[y] == root) continue;
vis[y] = root;
if (!mch[y] || dfs(mch[y], root)) {
mch[y] = x;
return 1;
}
}
return 0;
}
int solve() {
int res = 0;
for (int i = 1; i <= n; ++i) {
if (dfs(i, i)) ++res;
}
return res;
}
solve 里面千万不要 memset,会因此 TLE 到爆!干脆让 vis 数组变成 int 类型,每一次访问 记录/比较 根节点。(这是一个防止 memset 爆炸的好技巧)
注意空间有没有开够。
动态规划
转移的时候如果出现了类似关于 i i i 和 j j j 的乘积式,既要尝试斜率优化,也可以考虑决策单调性优化。
Luogu 6932 Money for Nothing 这道题就差这一步!!!
Luogu 7688 大水题,挂了 5 分,因为最后计算最终答案的时候,没有加上第一个 2 贡献之前的 1 贡献。注意不要犯类似错误。
int ans = (f[p] << 1) + min(n - f[p], g[p] + (s[p].pos - 1) / k);
有些问题,按照原来的顺序,很难划分状态进行 DP,可以考虑按照某些特征排序。
例如 QOJ 8351,直接做容斥不明白,按照极长的约束链进行排序,然后 DP+容斥 就变得比较简单。
单调队列优化
单调队列主要有两种情况,一种是先加入这个点的可能决策再进行计算当前状态,另一种是先计算当前状态再把当前状态加入队列。两种都应该在计算当前状态之前排除队列中不合法决策。但是,前一种情况应当注意入队和出队的顺序。
某年普及组 T4,状态转移方程形如:
f ( x ) = min p x − a ≤ p y ≤ p x − b { f ( y ) } + c x f(x) = \min_{p_x-a \le p_y \le p_x-b}\{f(y)\} + c_x f(x)=px−a≤py≤px−bmin{f(y)}+cx
显然单调队列优化。这里先加入可能决策再进行计算。
可能发生下面这种情况:
a = 8 , b = 6 , p 1 = 1 , p 2 = 5 , p 3 = 7 , p 4 = 14 a = 8, b = 6, p_1 = 1, p_2 = 5, p_3 = 7, p_4 = 14 a=8,b=6,p1=1,p2=5,p3=7,p4=14,此时 f ( 3 ) f(3) f(3) 只能由 f ( 1 ) f(1) f(1) 转移得到,而 f ( 4 ) f(4) f(4) 只能由 f ( 3 ) f(3) f(3) 转移得到, f ( 2 ) f(2) f(2) 不会成为任何位置的合法决策。
那么必须先把满足 p y ≤ p x − b p_y \le p_x - b py≤px−b 的入队,再把满足 p y < p x − a p_y < p_x - a py<px−a 的出队,这样能够很好地解决以上问题。
所以:
- 前一种类型
- 新增合法入队
- 非法出队
- 用队首计算当前状态
- 后一种类型
- 非法出队
- 计算当前状态
- 新增合法入队
然后你会发现,后一种其实是前一种的变体,后一种的新增合法入队其实是针对下一个决策点的新增合法,在为下一个点考虑。
斜率优化
struct Point {
LL x, y;
} q[MAXN];
double rate(Point A, Point B) {
return 1.0 * (B.y - A.y) / (B.x == A.x ? 1e-9 : B.x - A.x);
}
int n, m, t;
LL f[MAXN], g[MAXN], h[MAXN];
int main() {
// ... 略
int l = 1, r = 0;
for (int i = 0; i < t + m; ++i) {
if (i >= m) {
Point cur = {h[i-m], f[i-m] + g[i-m]};
while (l < r && rate(q[r-1], q[r]) >= rate(q[r], cur)) --r;
q[++r] = cur;
}
while (l < r && rate(q[l], q[l+1]) <= i) ++l;
if (l <= r) f[i] = q[l].y - i * q[l].x + h[i] * i - g[i];
else f[i] = h[i] * i - g[i];
}
}
这个写法能够合适的处理决策点 x x x 相同但是 y y y 不同的情况。
斜率优化的关键思想:
前提,乘积项能表示成 a ( x ) ⋅ b ( y ) a(x) \cdot b(y) a(x)⋅b(y),方程的一般形式:
f ( x ) = min { c ( x ) + d ( y ) − a ( x ) b ( y ) } f(x) = \min\{c(x) + d(y) - a(x)b(y)\} f(x)=min{c(x)+d(y)−a(x)b(y)}
那么假定某一个 y y y 时:
f ( x ) = c ( x ) + d ( y ) − a ( x ) b ( y ) f(x) = c(x) + d(y) - a(x)b(y) f(x)=c(x)+d(y)−a(x)b(y)
移项:
d ( y ) = a ( x ) b ( y ) + f ( x ) − c ( x ) d(y) = a(x)b(y) + f(x) - c(x) d(y)=a(x)b(y)+f(x)−c(x)
把 ( b ( y ) , d ( y ) ) (b(y), d(y)) (b(y),d(y)) 当成平面上的点, a ( x ) a(x) a(x) 是斜率, f ( x ) − c ( x ) f(x)-c(x) f(x)−c(x) 是截距。欲求最小化 f ( x ) f(x) f(x),即求最小截距。所以每次计算当前状态,是在处理一条已知斜率的直线,决策就是平面上的点。显然这些点应该是下凸的。
总结:当前状态是直线,决策是点,要求最小截距,维护决策凸性。
(乘积项那两茬东西都单调增才能够单调队列,否则平衡树 or 李超线段树,哪个好写选哪个。)
如果遇到一大茬与 x x x 和 y y y 都有关的东西,那么可能三大优化只剩决策单调性了?
组合数学
计算 C x y m o d 2 C_x^ y \bmod 2 Cxymod2 时,考虑 Lucas。
int lucas(int x, int y) {
if (y > x)
return 0;
if (x <= 1 && y <= 1)
return 1;
return lucas(x & 1, y & 1) & lucas(x >> 1, y >> 1);
}
其实可以优化掉这只
log
\log
log。当且仅当在某一位的
x
=
0
x=0
x=0 且
y
=
1
y=1
y=1 时为
0
0
0。相应地,
y
⊆
x
y \subseteq x
y⊆x 时答案为
1
1
1。类似 FWT 中的集合运算与位运算的转化,那么可以写成 (x & y) == y。
线性代数
矩阵乘法
单位矩阵, ∀ 0 ≤ i < n \forall 0 \le i < n ∀0≤i<n, a i , i = 1 a_{i,i} = 1 ai,i=1,其他位置为 0 0 0。显然单位矩阵十分有用。
可以认为,矩阵乘法的本质是对应行向量和列向量的点积。
如果封装的话:
原来的不够美,来个新的:(这个是 ( + , max ) (+, \max) (+,max) 广义矩阵乘法。)
int n, len;
const LL INF = 1e18;
struct mat {
LL t[250][250];
LL operator()(const int& x, const int& y) const { return t[x][y]; }
LL& operator()(const int& x, const int& y) { return t[x][y]; }
void init() {
for (int i = 0; i < len; ++i)
for (int j = 0; j < len; ++j)
t[i][j] = -INF;
}
};
struct vec {
LL t[250];
LL operator()(const int& x) const { return t[x]; }
LL& operator()(const int& x) { return t[x]; }
void init() {
for (int j = 0; j < len; ++j) t[j] = -INF;
}
};
mat operator*(const mat& a, const mat& b) {
mat res;
for (int i = 0; i < len; ++i) {
for (int j = 0; j < len; ++j) {
res(i, j) = -INF;
for (int k = 0; k < len; ++k)
res(i, j) = max(res(i, j), a(i, k) + b(k, j));
}
}
return res;
}
vec operator*(const vec& a, const mat& b) {
vec res;
for (int j = 0; j < len; ++j) {
res(j) = -INF;
for (int k = 0; k < len; ++k)
res(j) = max(res(j), a(k) + b(k, j));
}
return res;
}
广义矩阵乘法
抽不明白啊。。。
若 C i j = ⨁ k = 1 n A i k ⊗ B k j C_{ij} = \bigoplus_{k=1}^n A_{ik} \otimes B_{kj} Cij=⨁k=1nAik⊗Bkj,则记为 ( ⊗ , ⊕ ) (\otimes, \oplus) (⊗,⊕) 的矩阵乘法。
这一广义矩阵乘法满足结合律的条件:
- ⊕ \oplus ⊕ 满足交换律;
- ⊗ \otimes ⊗ 满足交换律和结合律;
- ⊗ \otimes ⊗ 对 ⊕ \oplus ⊕ 满足分配律。
展开后观察,即可证明。
举例:
(
×
,
+
)
(\times, +)
(×,+)(矩阵乘法),
(
+
,
max
)
(+, \max)
(+,max),
(
+
,
min
)
(+, \min)
(+,min)(各种 DDP),
(
and
,
xor
)
(\operatorname{and}, \operatorname{xor})
(and,xor)(ONLINE#3 魔法值)。
多项式乘法与离散卷积
下标:0 起!!!0 起!!!0 起!!!
注意构造 rev 数组的策略。
for (n = 1; n <= (m<<1); n <<= 1); // m << 1 可以换成两个多项式的次数之和
for (int i = 0; i < n; ++i) {
rev[i] = rev[i>>1] >> 1;
if (i&1) rev[i] |= n >> 1;
}
傅里叶逆变换,最后结果一定要除以 n n n。
void fft(Complex* a, int tp) {
for (int i = 0; i < n; ++i) {
if (i < rev[i]) swap(a[i], a[rev[i]]);
}
for (int len = 1; len < n; len <<= 1) {
Complex w(cos(pi / len), tp * sin(pi / len));
for (int l = 0; l < n; l += len << 1) {
Complex cur(1.0, 0.0);
for (int i = l; i < l + len; ++i, cur *= w) {
Complex x = a[i], y = a[i+len] * cur;
a[i] = x + y, a[i+len] = x - y;
}
}
}
}
int w = qpow(tp == 1 ? 3 : inv, (mod-1) / (len<<1)); // NTT
如果是处理整数,最好用 NTT,否则可以预见的精度爆炸。
998244353 998244353 998244353 的原根是 3 3 3,
单位根直接反方向转就行了,原根的话就要求一下逆。
根据 FFT 的原理,y = a[i+len] * cur,对于奇数次的位置,记得乘上一个 cur。
广义卷积
CF 1712F
f ( d ) = max x + y = d max { d i s ( p x , p y ) , d i s ( q x , p y ) , d i s ( p x , q y ) , d i s ( q x , q y ) } f(d) = \max_{x + y = d} \max \{dis(p_x, p_y), dis(q_x, p_y), dis(p_x, q_y), dis(q_x, q_y)\} f(d)=x+y=dmaxmax{dis(px,py),dis(qx,py),dis(px,qy),dis(qx,qy)}
x ≤ m , y ≤ m x \le m, y \le m x≤m,y≤m,则 d ≤ 2 m d \le 2m d≤2m。赛时一激动写成了 m m m,显然挂大分。
for (int d = 0; d <= m * 2; ++d) {
for (int x = 0; x <= d; ++x) {
int y = d - x;
if (x > m || y > m) continue;
ans[d] = max({ans[d], dis(p[x],p[y]), dis(q[x],p[y]), dis(p[x],q[y]), dis(q[x],q[y])});
}
}
字符串
KMP
单模 KMP,多模 ACAM。
原串 - 最长公共前后缀(
border
\text {border}
border)(nxt[i]) = 最小循环元(可能最后一个循环有多出来的)
体会公共前后缀与循环元两两之间的关系。
ACAM
多模匹配 ACAM(AC 自动机),在 Trie 的基础上,fail 指针一方面用于应对失配的情况(自动机),另一方面可以借此找在 Trie 上出现过的后缀。
void calcFail() {
queue<int> q;
for (int i = 0; i < sigma; ++i) {
if (son[0][i]) q.push(son[0][i]);
}
while (!q.empty()) {
int x = q.front(); q.pop();
for (int i = 0; i < sigma; ++i) {
int y = son[x][i];
if (y) fail[y] = son[fail[x]][i], q.push(y);
else son[x][i] = son[fail[x]][i];
}
}
}
在 dp 计数的时候,为了应对串与串之间包含的情况,应该在上述代码第 10 行加上 ed[y] |= ed[fail[y]]。
深入体会 ACAM 中 fail 指针的妙处,尤其是 fail tree(构成树形结构)。
如果要显式建立 fail tree,记得在初始加入 根的子节点 的时候也要连边 r t → s o n r t rt \rightarrow son_{rt} rt→sonrt。
PAM
namespace PAM {
int son[MAXN][sigma], a[MAXN], fail[MAXN], len[MAXN];
int tot, last, cnt;
void init() {
len[0] = 0, len[1] = -1;
tot = 1, last = 0, cnt = 0;
fail[0] = 1, a[0] = -1;
}
int getFail(int x) {
while (a[cnt-len[x]-1] != a[cnt]) x = fail[x];
return x;
}
void add(int x) {
a[++cnt] = x;
int p = getFail(last);
if (!son[p][x]) {
++tot;
fail[tot] = son[getFail(fail[p])][x];
son[p][x] = tot; // fail 和 son 这两行代码顺序不可调换
// 如果调换了的话,getFail 会陷入死循环
len[tot] = len[p] + 2;
}
last = son[p][x];
}
}
SA
基排里面的四行内容,注意
n
n
n 和
m
m
m 不要混淆。
n
n
n 是字符串的长度,
m
m
m 是当前不同 rk 值的个数。
sa[cnt[rk[tmp[i]]]--],四层中括号,分清楚每一层的含义,-- 应该是针对 cnt 的。
tmp 作为临时数组,被利用了两次,两次含义不同。
前半部分是基排先按照第二关键字排好序(然后基排),后面则是保存上一轮的排名。
lambda 的中括号记得写个 k。里面的 tmp 不要写成这一轮正在被处理的 rk。
for 循环条件特判
k
=
1
k = 1
k=1,原因是
k
=
1
k = 1
k=1 时
m
m
m 是初始的字符集大小,可能会大于等于
n
n
n(然而此时仍然需要进行第一遍循环)。循环至少一边后可以保证
m
≤
n
m \le n
m≤n。
当然有必要的时候可以对字符集进行离散化。(就是第一遍使用快排。)
若对多个字符串建 SA, n n n 应该是串长之和加上串的个数,因为要考虑分隔符。
SA + DSU 是个好东西。
// 2025.1.19 更新了一个更加优美的板子
int n, m;
int str[MAXN], sa[MAXN], rk[MAXN], tmp[MAXN], cnt[MAXN], ht[MAXN];
void rsort() {
for (int i = 1; i <= m; ++i) cnt[i] = 0;
for (int i = 1; i <= n; ++i) ++cnt[rk[i]];
for (int i = 1; i <= m; ++i) cnt[i] += cnt[i-1];
for (int i = n; i; --i) sa[cnt[rk[tmp[i]]]--] = tmp[i];
}
void suffixSort() {
for (int i = 1; i <= n; ++i) rk[i] = str[i], tmp[i] = n - i + 1;
rsort();
for (int k = 1; k == 1 || m < n; k <<= 1) {
int x = 0;
for (int i = 1; i <= k; ++i) tmp[++x] = n - i + 1;
for (int i = 1; i <= n; ++i) if (sa[i] > k) tmp[++x] = sa[i] - k;
rsort();
for (int i = 1; i <= n; ++i) tmp[i] = rk[i];
auto cmp = [k](int x, int y) { return tmp[x] != tmp[y] || tmp[x+k] != tmp[y+k]; };
rk[sa[1]] = m = 1;
for (int i = 2; i <= n; ++i) rk[sa[i]] = m += cmp(sa[i], sa[i-1]);
}
for (int i = 1, j, x = 0; i <= n; ++i) {
if (rk[i] == 1) { ht[rk[i]] = x = 0; continue; }
for (x -= !!x, j = sa[rk[i]-1]; i+x <= n && j+x <= n && str[i+x] == str[j+x]; ++x);
ht[rk[i]] = x;
}
}
SAM
link 指针在缩减前面的,构成树(其实就是 反串后缀树的虚树);trans 指针在拓展后面的,构成 DAG。
// p 是上一个前缀对应节点
int extend(int p, int ch) {
int x = ++tot; len[x] = len[p] + 1;
for (; ~p && trans[p][ch] == 0; p = link[p]) trans[p][ch] = x;
if (p == -1) return link[x] = 0, x;
int q = trans[p][ch];
if (len[q] == len[p] + 1) return link[x] = q, x;
int y = ++tot; len[y] = len[p] + 1;
link[y] = link[q], link[x] = link[q] = y;
memcpy(trans[y], trans[q], 26<<2);
for (; ~p && trans[p][ch] == q; p = link[p]) trans[p][ch] = y;
return x;
}
HASH
一般写双模 HASH 差不多合适。可以双模 + unsigned long long 手写 HASH TABLE。
namespace TB {
const int LIM = 1e7+19; // is prime.
int head[LIM], val[LIM], nxt[LIM], tot;
ULL key[LIM];
void add(int x, ULL k, int v) {
key[++tot] = k, val[tot] = v, nxt[tot] = head[x], head[x] = tot;
}
void update(ULL k, int v) { // 随手写的玄学东西,我也不知多少
int x = ((k << 3) | (k >> 24) ^ (k << 21)) % LIM;
for (int i = head[x]; i; i = nxt[i]) {
if (key[i] == k) return val[i] += v, void();
}
add(x, k, v);
}
int query(ULL k) {
int x = ((k << 3) | (k >> 24) ^ (k << 21)) % LIM;
for (int i = head[x]; i; i = nxt[i]) {
if (key[i] == k) return val[i];
}
return 0;
}
}

4178

被折叠的 条评论
为什么被折叠?



