算法竞赛中常见trick

本文主体译自 Collection of little techniques 并有所删改

前言略

1.bitset优化空间

考虑 DAG上的可达性 ,给定一个 \(n\) 个节点和 \(m\) 条边的 DAG,包含 \(q\) 次查询,其中查询的形式为 "顶点 \(v\) 是否可由顶点 \(u\) 到达" ,其中 \(1\leq n,m,q \leq 10^5\) 且不允许有类似 \(\Omicron(n^2)\) 的空间复杂度,其中包括开 \(n\) 个长度为 \(n\) 的 bitset

一个比较经典的解法是让 $\text{dp[v]} $ 成为一个 bitset ,当 \(v\) 可以到达 \(u\) 时令 \(\text{dp[v][u] = 1}\) 。这个方法在大多数情况是可行的,但是 \(10^5\) 个长度为 \(10^5\) 的 bitset 也会占用大量内存,因此我们可以用 \(64\) 位整数来代替 bitset ,并重复该算法 \(\frac{n}{64}\) 次。在第 \(k\) 次执行该算法时,我们令 \(\text{dp[v]}\) 成为一个 \(64\) 位整型用来储存 \(64k, \; 64k + 1 \; ... \; 64k + 63\) 是否可以到达 \(v\)

2.避免在莫队中出现 log

考虑如下问题:给定一个长度为 \(n\) 的序列和 \(n\) 次询问,询问区间 mex ,不强制在线。

线段树固然可行,但我们现在要考虑莫队。

在利用莫队维护 mex 的时候,我们同时也需要一个 set<int> 来维护在该区间所有未出现的整数,这样在每次指针后我们都可以用 \(\Omicron(\log n)\) 的复杂度来维护答案,这样莫队总的复杂度就做到了 \(\Omicron(n\sqrt n\log n)\) ,但是这样太慢了!

显然复杂度的瓶颈是 set<int> ,我们之所以用它是为了支持以下操作:

  1. 插入一个元素,当前单次复杂度 \(\Omicron(\log n)\) ,需要操作 \(\Omicron(n \sqrt n)\)
  2. 删除一个元素,当前单次复杂度 \(\Omicron(\log n)\) ,需要操作 \(\Omicron(n \sqrt n)\)
  3. 查询最小值,当前单次复杂度 \(\Omicron(\log n)\) ,需要操作 $\Omicron(n) $ 次

我们注意到前两种操作更频繁一点,但他们的时间复杂度相同,因此我们可以牺牲最后一个操作的复杂度,使得前两种操作更高效。

我们需要这样一个数据结构:

  1. \(\Omicron(1)\) 插入元素
  2. \(\Omicron(1)\) 删除元素
  3. \(\Omicron(\sqrt n)\) 查询最小值

因此我们可以将值域分块,插入删除显然可以 \(\Omicron(1)\) 完成,同时我们维护是否每个块中的元素已经全部出现,自小到大找到一个非满的块暴力查询即可。

这是在莫队中至关重要的一件事,确保指针能在 \(\Omicron(1)\) 复杂度下移动,即使有时需要牺牲查询的复杂度

3.根号优化背包/ “3k trick”

假设有 \(n\) 个物品,每个物品都有一个非负数权值 \(a_i\) ,且 \(\sum a_i = m\) ,询问是否能从中选出一些物品使得其权值和为 \(w\)

我们假设有三个相同权值的物品 \(a,a,a\) 。注意到用 \(a,2a\) 可以替换掉这三个物品,我们可以重复这个过程直到每个权值至多有两件物品,且有 \(\sum a_i = m\) ,因此至多只有 \(\Omicron{( \sqrt m)}\) 个物品。因此我们可以直接进行背包,同时物品的数目减少至 \(\Omicron{(\sqrt m)}\) 个,这在大部分情况下是更好的复杂度

这个技巧大多出现在序列 \(a\) 能被某些特殊形式划分的时候,例如他们可能代表一个图的分量,请看示例:

问题链接

$ \text{给定一个含有 n 个字母的字母表以及一个含有 m 个单词的字典}$
$\text{你想用两种颜色给字母染色,使得每个单词中相邻字母都是不同颜色的,并尽量减少两种颜色字母的数量差} $

我们考虑把整个过程看成二分图,最初的输入会产生一定的分量,我们可以考虑翻转其中的一部分使得两边大小尽量相同。最初我们可以翻转全部的分量,使得左边的分量变小。然后我们可以每次选择翻转一部分分量,使得左侧部分大小增加 \(a_i\) ,且 $ \sum a_i $ 为定值。 这样就可以利用上述技巧解决掉整个问题了。

4. 不同种元素的划分方案数

该问题与上一个问题有关,对于长度为 \(n\) 的非负整数序列有 \(\sum a_i = m\) ,我们能得到不同的 \(a_i\) 的取值至多只有 \(\Omicron{(\sqrt m)}\) 种。

证明:

取出 \(a\) 中全部各不相同的值,并将其按升序排序得到 \(b_0,b_1,\cdots,b_{k - 1}\) ,由于 \(b\) 序列中均为非负整数,因此有 \(b_i \ge i\) ,进而可以得到

\[\sum b \ge 0 + 1 + 2 +...+(k - 1) = \frac{k(k - 1)}{2} \]

且有 \(\sum b \le m\) ,所以 \(k\) 至多只有 \(\Omicron{(\sqrt m)}\) 种。

5.从背包中删除元素

假设有 \(n\) 个物品,第 \(i\) 个物品的价值是 \(w_i\) 。你需要维护一个 \(dp\) 数组,其中 \(dp_i\) 表示得到价值总和恰好为 \(i\) 的物品有多少种方式。

向背包中加入物品是个经典问题

// 从大到小转移保证不会被重复选择
for(int i = MAX_Val; i >= val; i--) {
    dp[i] += dp[i - val];
}

要是想撤销刚才的操作,我们只需要将一切都倒过来即可

// 撤销新加入的状态
for(int i = val; i <= MAX_Val; i++) {
    dp[i] -= dp[i - val];
}

需要注意的是 \(dp\) 数组的状态与物品加入的顺序无关。实际上,刚才所展示的代码可以撤销掉任意一个价值为 \(val\) 的物品,我们可以通过假设该物品是最后加入的来证明其正确性。

如果我们仅仅是为了检验是否有得到价值恰好为 \(i\) 的方案,而不关心有多少种方案的时候依然可以用这个技巧的变种。我们依然是计算方案数,并检查方案数是否为 \(0\) ,由于方案数可能会很大,我们可以选取一个大素数作为模数。虽然可能会出现错误,但是如果我们随机选择一个足够大的模数,该方法的成功率是相当可观的。

6.调和级数

有结论

\[H_n = \frac11 + \frac12 + \frac13 + \cdots + \frac1n = \Theta(\log n). \]

该结论可以用来计算某些算法的复杂度。通常来说这类算法都类似这样:

for(int i = 1; i <= n; i++)
	for(int j = i; j <= n; j += i)
	// 某些操作

对于一个确定的 \(i\) 来说,有着不超过 \(\frac ni\)\(j\) 的取值,且他们总的数目同上述结论可以推得

\[\frac{n}{1} + \frac{n}{2} + \cdots + \frac{n}{n} = n H_n, \]

因此该算法时间复杂度为 \(\Omicron(n \log n)\)

posted @ 2023-08-06 20:06  Jadebo1  阅读(274)  评论(0编辑  收藏  举报