错题本杂记

警示自己&后人

从 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) 手写 ppcctz

一些低级&降智问题

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

这个 lr 是函数参数传入的修改/查询左右端点。

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.stotz = x 作用相同,因为会覆盖原来的值,所以要把原来的存起来。

当然 exgcd 有更优美的写法。

11 月发现 7 月的线段树码风还是那么 清 澈。

240703

必要时 query 可以返回整一段 Segment,方便处理。(对于复杂信息/多种询问方式,建议这样处理)

240719

一般的线段树一定要 四倍空间,动态开点一定要 二倍空间

240724

主席树老老实实开到 MAXN<<5

主席树维护可持久化并查集,要开到 (MAXN+MAXM)<<5

并查集启发式合并,在 xy 的祖先不同时才能合并(否则会产生错误大小),相同时应令 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 n1 条链(每个链边单独一条)(即 ∑ c n t ≤ n − 1 \sum cnt \le n-1 cntn1),因此这里复杂度为 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 yxz x x x y y y 连权值为 z z z 的边。

相当于 y ≤ min ⁡ { x + z } y \le \min \{x + z\} ymin{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 xA y ∉ A y \notin A y/A
根据上述结论,存在一个环 B B B,使得 x ∈ B x \in B xB y ∈ B y \in B yB
x ∈ A ∩ B x \in A \cap B xAB y ∉ A ∩ B y \notin A \cap B y/AB
A ⊕ B = ∁ A ∪ B A ∩ B A \oplus B = \complement_{A \cup B} A \cap B AB=ABAB(对称差,类似 xor 运算)。
显然 A ≠ B A \neq B A=B A ⊕ B ≠ ∅ A \oplus B \neq \varnothing AB= x ∉ A ⊕ B x \notin A \oplus B x/AB y ∈ A ⊕ B y \in A \oplus B yAB
∀ 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 xX,yX

二分图

写书上了。

通用模板:

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)=pxapypxbmin{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 pypxb 的入队,再把满足 p y < p x − a p_y < p_x - a py<pxa 的出队,这样能够很好地解决以上问题。

所以:

  • 前一种类型
    • 新增合法入队
    • 非法出队
    • 用队首计算当前状态
  • 后一种类型
    • 非法出队
    • 计算当前状态
    • 新增合法入队

然后你会发现,后一种其实是前一种的变体,后一种的新增合法入队其实是针对下一个决策点的新增合法,在为下一个点考虑。

斜率优化

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 yx 时答案为 1 1 1。类似 FWT 中的集合运算与位运算的转化,那么可以写成 (x & y) == y

线性代数

矩阵乘法

单位矩阵 ∀ 0 ≤ i < n \forall 0 \le i < n ∀0i<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=1nAikBkj,则记为 ( ⊗ , ⊕ ) (\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 xm,ym,则 d ≤ 2 m d \le 2m d2m。赛时一激动写成了 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} rtsonrt

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 mn

当然有必要的时候可以对字符集进行离散化。(就是第一遍使用快排。)

若对多个字符串建 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;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值