构造题的应用

在 $n$ 较小时,确定一个随机数的seed mt19937 rng(SOME_SEED);,如果在本地成功跑出来结果,就直接把这个seed交上去,这样就可以deterministic保证答案是对的。

color coding

一个很有趣的概念,意思是对于一个很难解决的问题,可以考虑先将问题中的一些变量染色,对于不同颜色分别解决。或者是染色后,根据某些与颜色有关的性质进行操作。

例题见例2和例3。

破坏input的性质

如果input是一些较小的正整数,并且它们之间可能有一定规律,将input map到一个较大的范围可以破坏掉input的一些限制。从而获得一些有用的特性。

例题

例1 CF364D. Ghd

题意

给定一个长度为 $n$ 的数组 $a_1,a_2,…,a_n$,定义其ghd为:

最大的一个数 $g$,使得 $a_i$ 中有至少一半的数 divisible by $g$。

求一个数组的 ghd。

其中,$n \leq 10^6, a_i \in [1,10^{12}]$。

题解

好题。考虑随机化:

显然,我们随机选一个 $a_i$,并且求出它的所有因数,那么有 50% 的概率使得ghd在 $a_i$ 的因数中。

但众所周知一个数的因数数量是 $O(n^{\frac{1}{3}})$ 级别的(证明见这里),$a_i \leq 10^{12}$,很明显这有点太大了。

我们需要快速解决以下问题:

给定一个数 $x$,求 $x$ 的每一个因数能够被多少个 $a_i$ 整除。

有一个 $O(d^2)$ 的基于 dp 的方法($d$ 是 $x$ 的因数数量)。

先将 $x$ 的所有因数求出来,然后sort一下,我们维护一个 cnt[] 数组,其中 cnt[i] 代表 $x$ 的第 $i$ 个因数能够被多少个 $a_i$ 整除。

然后将 $x$ 与每一个 $a_i$ 取 gcd,得到 $g_i$,那么 $g_i$ 就是满足 既是 $x$ 的因数,又是 $a_i$ 的因数 的最大因数。

那么我们先找到 $g_i$ 对应 $x$ 的第几个因数,先让对应的 cnt[j]++

然后从小到大 dp,对于小的因数,如果某个更大的因数能够整除它,那么就将大的 cnt 加到小的上去。

这样 cnt[] 有了就可以更新答案了。

这样随机个10次左右即可(我代码中随机3秒停止)。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e6+5;

ll gcd(ll a, ll b) {
    if (!b) return a;
    return gcd(b, a%b);
}
ll ans = 0;
int n;
ll a[maxn];
vector<ll> factorize(ll x) {
    vector<ll> vec;
    for (int i = 1; i <= sqrt(x); i++) {
        if (x % i == 0) {
            vec.push_back(i);
            if (x / i != i)
                vec.push_back(x/i);
        }
    }
    sort(vec.begin(), vec.end());
    return vec;
}

void solve() {
    ll x = a[randint(1,n)];
    vector<ll> fac = factorize(x);
    vector<int> cnt(fac.size(), 0);

    for (int i = 1; i <= n; i++) {
        ll g = gcd(x, a[i]);
        int p = lower_bound(fac.begin(), fac.end(), g) - fac.begin();
        cnt[p]++;
    }
    // 从小到大 dp
    for (int i = 0; i < cnt.size(); i++) {
        for (int j = i+1; j < cnt.size(); j++) {
            if (fac[j] % fac[i] == 0) cnt[i] += cnt[j];
        }
    }
    for (int i = cnt.size()-1; i >= 0; i--) {
        if (cnt[i] * 2 >= n) {
            ans = max(ans, fac[i]);
            return;
        }
    }
}
int main() {
    fastio;
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    while ((double)clock() / CLOCKS_PER_SEC <= 3) {
        solve();
    }
    cout << ans << endl;
}

例2 Vacuum Tubes

题意

给定 $n$ 个正整数 $a_1,a_2,…,a_n$ 和两个正整数 $L_1, L_2$。

从中选出四个数,$a_i, a_j, a_l, a_k$ 使得:

  1. $a_i+a_j \leq L_1$
  2. $a_l+a_k \leq L_2$
  3. $i,j,l,k$ 互不相同

并且求出 $a_i+ a_j+ a_l+ a_k$ 可能的最大值,如果无解输出 $-1$。

其中,$n \leq 2000, a_i, L_1,L_2 \in [1,10000]$。

题解

显然 $a_i,a_j$ 和 $a_l,a_k$ 是两个独立的问题。

对于其中一个问题,显然可以用二分来做。但现在有两个问题。

所以不妨将原数组随机分成两等份,然后分别对两份数组进行操作。

假设最优答案为 $a_i’,a_j’,a_l’,a_k'$,那么 $a_i’, a_j'$ 在第一份数组,且 $a_l’,a_k'$ 在第二份的数组的概率为 $\frac{1}{8}$。

重复很多次即可。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 1e5+55;
const int maxm = 4e4+55;

int l1, l2, n, a[maxn];
int solve(int l, int r, int L) {
    sort(a+l, a+r+1);
    int res = -1;
    for (int i = l; i <= r; i++) {
        int p = upper_bound(a+l, a+r+1, L - a[i]) - a;
        p = max(p, l);
        p = min(p, i-1);
        while (p-1 >= l && a[p] + a[i] > L) p--;
        if (p < i && p >= l && a[i] + a[p] <= L) res = max(res, a[i] + a[p]);
    }
    return res;
}
int main() {
    cin >> l1 >> l2 >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    int ans = 0;
    while ((double)clock() / CLOCKS_PER_SEC * 1000 <= 2000) {
        shuffle(a+1, a+n+1, rng);
        int r1 = solve(1, n/2, l1);
        int r2 = solve(n/2+1, n, l2);

        if (r1 > 0 && r2 > 0)
            ans = max(ans, r1 + r2);
    }
    if (!ans) cout << "Impossible\n";
    else cout << ans << "\n";
}

例3 Dragon Ball II

题意

给定一个 $n$ 个点,$m$ 条边的无向图。边有边权。

现在有 $k$ 个龙珠,每个龙珠会拥有一个颜色 $d_i$,并且这个龙珠会存在于节点 $c_i$ 中。

我们从节点 $1$ 出发,经过一个节点时会获得这个节点里的所有龙珠。

求最短路径的长度,使得我们收集了至少 $7$ 个不同颜色的龙珠。如果无解输出 $-1$。

其中,$n,k \leq 1000, m \leq 10000, w_i \in [0,10000], c_i, d_i \in [1,n]$。

题解

我们只需要收集 $7$ 个不同的颜色,但原图中的颜色有 $n$ 种,怎么办?

考虑 color coding 的思想:

我们不妨假设只有 $7$ 种颜色,然后对于原图中的每一种颜色,将它们随机 map 到七种颜色中的一种,然后就只有 $7$ 种颜色了。

只有 $7$ 种颜色后,明显可以直接维护大小为 $2^7$ 的mask,然后跑dijkstra。


什么情况下答案会出错呢?

假设答案需要颜色 $d_{i_1}, d_{i_2} … d_{i_7}$,那么如果有任意两个颜色被map到同一种,就说明答案错误了。

因此,答案正确的情况有 $A_7^7 = 7!$ 种。

答案正确的概率为 $\frac{7!}{7^7} = 0.006$。

这个题目时限为 $16$ 秒,大概可以跑 $10^9$。这个dijkstra相当于在 $M = 10^4 * 2^7 = 1.28 * 10^6$ 的情况下跑 $O(M\log M)$(实际上吃不了这么满)。

实际上大概可以跑 $800-1000$ 次。失败概率约为 $5$ % 左右,运气好还是可以跑过去的。

• 另外如果我们提升随机颜色的数量,运行次数只会大幅度缩小,而成功概率不会提升很多,所以 $7$ 种颜色就是最好的。

代码
#include <bits/stdc++.h>
using namespace std;
const int M = 7;
struct Edge {
    int v, w;
};
vector<Edge> adj[maxn];
int n, m, k;
vector<int> ball[maxn], cur[maxn];

ll ans = 1e18;
ll dis[maxn][(1<<M) + 5];
bool vis[maxn][(1<<M) + 5];
struct Node {
    int u;
    ll d;
    int mask = 0;
    bool operator<(const Node& other) const {
        return d > other.d;
    }
};
void solve() {
    map<int, int> mp;
    for (int i = 1; i <= n; i++) {
        cur[i].clear();
        for (int x : ball[i]) {
            if (!mp.count(x)) {
                mp[x] = randint(0, M-1);
            }
            cur[i].push_back(mp[x]);
        }
    }
    memset(vis, 0, sizeof(vis));
    memset(dis, 63, sizeof(dis));

    int smask = 0;
    for (int x : cur[1]) smask |= (1<<x);
    dis[1][smask] = 0;

    priority_queue<Node> pq;
    pq.push({1, 0, smask});
    while (pq.size()) {
        auto [u, d, mask] = pq.top();
        pq.pop();
        if (vis[u][mask]) continue;
        vis[u][mask] = 1;
        for (auto [v, w] : adj[u]) {
            int nmask = mask;
            for (int x : cur[v]) {
                nmask |= (1<<x);
            }
            if (dis[v][nmask] > dis[u][mask] + w) {
                dis[v][nmask] = dis[u][mask] + w;
                pq.push({v, dis[v][nmask], nmask});
            }
        }
    }
    for (int i = 1; i <= n; i++) {
        for (int mask = (1<<7) - 1; mask < (1<<M); mask++) {
            if (__builtin_popcount(mask) >= 7) {
                ans = min(ans, dis[i][mask]);
            }
        }
    }
}

int main() {
    cin >> n >> m >> k;

    for (int i = 1; i <= m; i++) {
        int u, v, w; cin >> u >> v >> w;
        adj[u].push_back({v, w});
        adj[v].push_back({u, w});
    }

    for (int i = 1; i <= k; i++) {
        int c, d; cin >> c >> d;
        ball[c].push_back(d);
    }

    while ((double)clock() / CLOCKS_PER_SEC < 14.5) {
        solve();
    }

    cout << (ans > 1e16 ? -1 : ans) << endl;
}

例4 Spooky Scary Skeletons

题意

给定一个长度为 $n$ 的正整数数组 $a_1,a_2,…,a_n$。

有 $q$ 个询问,每次询问 $[L,R]$ 之间是否存在恰好一个出现次数为奇数的数字,如果存在,输出它,否则输出 $0$。

其中,$n \leq 3 \times 10^6, q \leq 10^6, a_i \in [1,10^5]$。

题解

我们知道一个区间 $[L,R]$ 内,如果恰好有一个出现次数为奇数的数字,那么这个区间的XOR和就是这个数字。

但,有一些例外,比如有多个出现次数为奇数的数字时,XOR和不能说明任何问题。

例如对于 $[1,2,3]$,有 1 ^ 2 ^ 3 = 0,更关键的是 $0$ 甚至都没出现过。

所以我们考虑将每个数字随机 map 到一个很大的范围,如 $[1,2^{63}]$。

这样,询问 $[L,R]$ 时,我们计算这个 XOR 和,假设为 $x$。那么 $x$ 就很有可能是我们的答案。

当然,也有可能 $x$ 压根就没出现在 $[L,R]$ 这个范围内,所以我们预处理一下每一个数出现的位置,然后二分一下验证 $x$ 是否在 $[L,R]$ 范围内即可。


什么情况下会失败?

假设有 $n$ 个出现次数为奇数的数字,而我们得到的XOR和 $x$ 恰好是这 $n$ 个数字中的一个,就会错误的判断 $x$ 为答案。

不过这样的概率只有 $\frac{n}{2^{63}} = 3 \times 10^{-13}$。

$10^6$ 次询问均正确的概率为 $(1-3 \times 10^{-13})^{10^6} = 0.999999…$

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 3e6+55;
const int maxm = 1e5+55;

int n, k, q;
ll a[maxn], sum[maxn];
unordered_map<int, ll> mp;
unordered_map<ll, int> rev_mp;

vector<int> pos[maxm];
int main() {
    fastio;
    cin >> n >> k >> q;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        pos[a[i]].push_back(i);
        if (!mp.count(a[i])) {
            ll r = randll(1, (1LL<<63));
            mp[a[i]] = r;
            rev_mp[r] = a[i];
        }
        a[i] = mp[a[i]];
    }

    for (int i = 1; i <= n; i++) sum[i] = sum[i-1] ^ a[i];
    while (q--) {
        int l, r; cin >> l >> r;
        ll x = sum[r] ^ sum[l-1];
        if (!x) cout << 0 << "\n";
        else {
            // 答案很可能是 x,查询 x 是否在 [L,R] 中出现过?
            x = rev_mp[x];  // 先 map 回原来的样子
            auto itr = lower_bound(pos[x].begin(), pos[x].end(), l);
            if (itr != pos[x].end() && *itr >= l && *itr <= r) {
                cout << x << "\n";
            } else cout << 0 << "\n";
        }
    }
}

例5 Zero Sum

题意

给定一个 $n \times (2k+1)$ 的矩阵。column的编号是从 $-k$ 到 $k$。

求一串数字 $x_1,x_2,…,x_n$,其中:

  1. $x_i \in [-k, k]$。
  2. $x_1+x_2+…x_n = 0$。
  3. $a_{1,x_1} + a_{2,x_2} + … + a_{n,x_n}$ 尽可能小。

其中,$n \leq 35000, 1 \leq k \leq 3$,矩阵元素在 $[-10^9, 10^9]$。

题解

注意到 $k$ 很小。

我们想象一下,答案是一条从第一行从上到下走到最后一行的一条路径。

这条路径一定是左拐右拐,最后才能保证 $x_1+x_2+…x_n = 0$。

那么一个很简单的思路是 dp[i][j] 代表走到第 $i$ 行,当前的 column 的编号和为 $j$ 的最小值。

但是 $j$ 的绝对值可能会很大,因为这条路径有可能是先拐到最右,再拐回最左。

于是我们想到利用随机化打乱input。将所有的row随机shuffle以后,答案不大可能出现上述的情况。

所以我们可以在shuffle之后,认为 dp[i][j] 里的 j 的绝对值不会很大。然后再跑 dp 就可以了。

• 需要滚动数组否则 MLE。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 35000+5;

const int M = 1500;
ll a[maxn][9];
ll pre[maxn], dp[maxn];
int n, k;
int main() {
    cin >> n >> k;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= 2*k+1; j++) cin >> a[i][j];
    }
    vector<int> perm;
    for (int i = 1; i <= n; i++) perm.push_back(i);
    shuffle(perm.begin(), perm.end(), rng);

    for (int j = 1; j <= 2*M+1; j++) dp[j] = pre[j] = 1e18;
    pre[M+1] = 0;

    for (int i = 0; i < n; i++) {
        int p = perm[i];
        for (int j = 1; j <= 2*M+1; j++) {
            for (int d = -k; d <= k; d++) {
                if (j + d >= 1 && j + d <= 2*M+1) {
                    dp[j+d] = min(dp[j+d], pre[j] + a[p][d+k+1]);
                }
            }
        }
        memcpy(pre, dp, sizeof(dp));
        for (int j = 1; j <= 2*M+1; j++) dp[j] = 1e18;
    }

    cout << pre[M+1] << endl;
}

例6 KTH Challenge 2014 - Pizza Problems

题意

我们有一个pizza,上面需要有一些种类的配料。

有 $n$ 个人,每个人有 $[1,30]$ 个愿望,每个愿望可以是希望某一种配料存在,也可以是希望某一种配料不存在。

保证一定存在一种pizza使得每个人至少有 $\frac{2}{3}$ 的愿望被满足,而我们只需要找出一种pizza使得每个人有严格大于 $\frac{1}{3}$ 的愿望被满足即可。

其中,$n\leq 10000$,总配料种类不超过 $250$ 种。

题解

我们从没有配料的pizza开始,我们设当前pizza与理想pizza的共同点有 $k$ 个。

我们每一步随机选择一个尚未被满足的人,随机的满足他的一个愿望。注意由于理想的pizza一定保证 $\frac{2}{3}$ 的愿望被满足,而这个人尚未被满足说明他被满足的愿望比例 $<\frac{1}{3}$。这意味着这次修改至少有 $\frac{1}{2}$ 使得我们离理想的pizza的共同点 +1,有 $\leq \frac{1}{2}$ 的概率使得共同点 -1。

接下来要解决:大概需要随机走多少步才能使得当前pizza与理想pizza的共同点数量达到一个值?(这个值最高为 $250$),我们不关心这个值具体是多少,因为我们只要走到满足题目条件停止即可,而我们知道每走一步,共同点+1的概率都是 $\geq \frac{1}{2}$ 的。


这是一个随机游走(random walk)问题:

从 $x=0$ 出发,每次随机向左/向右走一步,问第一次走到 $x=\pm n$ 的期望步数?

结论:期望步数为 $O(n^2)$,证明见 这个知乎回答

• 注意这个题不太一样,不能走到负数位置,而且要走到的是 $x=n$。我们可以理解成可以走到负数,但是走到负数时就进行一次反射,把 $x$ 轴翻了过来,所以本质上还是走到 $x=\pm n$ 的期望步数,而本题中,期望步数为 $O(250^2)$。


最后一些注意点:

  1. 由于有 $n=10000$ 个人,每个人至多 $30$ 个愿望,而总共 $250$ 种配料,这意味着每一种配料至多与 $10000*30/250 = 1200$ 个人有关,所以每次满足愿望以后不需要修改所有人,只要记录和这个配料有关的人即可。
  2. 必须是随机满足愿望,无论这个愿望是希望某个配料存在,或者是不存在。不能将存在/不存在分开随机,否则会陷入死循环。在代码中利用了
     void fulfill(int i) {
         int r = randint(0,1);
         if (r == 0) {
             if (!fulfillgood(i)) fulfillbad(i);
         } else {
             if (!fulfillbad(i)) fulfillgood(i);
         }
     }
    

    来实现。

代码
#include <bits/stdc++.h>
using namespace std;
const int maxn = 15000+5;
int n;
vector<int> g[maxn], b[maxn];
map<string, int> mp;
string revmp[maxn];
int cnt[maxn], all[maxn];
bool cur[maxn];
vector<int> yes[maxn], no[maxn];

void upd(int x, int f) {
    if (f == 1) {
        cur[x] = 1;
        for (int i : yes[x]) cnt[i]++;
        for (int i : no[x]) cnt[i]--;
    } else {
        cur[x] = 0;
        for (int i : no[x]) cnt[i]++;
        for (int i : yes[x]) cnt[i]--;
    }
}

bool fulfillgood(int i) {
    shuffle(g[i].begin(), g[i].end(), rng);
    for (int x : g[i]) {
        if (!cur[x]) {
            upd(x, 1);
            return 1;
        }
    }
    return 0;
}

bool fulfillbad(int i) {
    shuffle(b[i].begin(), b[i].end(), rng);
    for (int x : b[i]) {
        if (cur[x]) {
            upd(x, -1);
            return 1;
        }
    }
    return 0;
}

void fulfill(int i) {
    int r = randint(0,1);
    if (r == 0) {
        if (!fulfillgood(i)) fulfillbad(i);
    } else {
        if (!fulfillbad(i)) fulfillgood(i);
    }
}

int main() {
    fastio;
    cin >> n;
    int id = 0;
    for (int i = 1; i <= n; i++) {
        int m; cin >> m;
        while (m--) {
            string s; cin >> s;
            int f = ((s[0] == '+') ? 1 : 0);
            s.erase(s.begin());
            int x;
            if (!mp.count(s)) mp[s] = ++id, revmp[id] = s;
            x = mp[s];
            if (f) g[i].push_back(x), yes[x].push_back(i);
            else b[i].push_back(x), no[x].push_back(i);
        }
        all[i] = g[i].size() + b[i].size();
        cnt[i] = b[i].size();
    }

    while (1) {
        for (int i = 1; i <= n; i++) {
            if (cnt[i] * 3 <= all[i]) {
                fulfill(i);
                goto nxt;
            }
        }
        for (int j = 1; j <= 250; j++) {
            if (cur[j]) cout << revmp[j] << "\n";
        }
        return 0;
        nxt:;
    }
}

参考PDF

NAC2023 - Randomized