USACO2025FEB Gold 题解

USACO2025FEB Gold 题解

碎碎念 赛时先看 T1,感觉很可做,推了一下发现能把问题转成在基环树上 dp。~~但我一下子没太想明白怎么解决“在基环树上找环”这个世纪难题~~,大概在一个小时 20 分钟的时候写完代码(值得一提的是这次罕见地一次就过了编译),交上去发现 WA 了大约一半的点。检查了一下没感觉有什么问题,然后想着打个暴力对拍一下,但写完之后发现暴力写假了,仔细思考发现这题的暴力其实没那么好写,又浪费了大约 20 分钟。(但我赛后发现暴力其实还是挺好写的,只是我当时没想出来)于是我放弃了写暴力,继续看之前的代码。这时我发现我的代码完全无法通过 “$\forall i, a_{i} \ge i$” 这个特殊性质,而别的部分分都能通过若干个点。琢磨了一下,在这个特殊性质中,所有环都是自环,于是我开始思考我的代码到底能不能处理这种情况。我先在代码中加上了一句 `assert(a[rt] != rt);`,评测结果中 RE 的点和之前 WA 的点完全相同,因此我确信是这种情况没处理好。手搓了一个小样例后,发现这种情况下的转移方程确实有些不同,改了之后就 A 了,此时大概过去一个小时。

然后看 T2,怎么这么简单?显然尽可能找 \(\texttt{1}\),之后拼上一段后缀。用动态开点线段树维护即可。这个思维难度远远不如 T1 啊,原来这就是我们 ds 题。但是我有一段时间没有写动态开点线段树了,因此先写了个原版的线段树,调了半天,大概在还剩半小时的时候通过了除了最后一个部分分之外的所有测试点。这时我感到燃尽了,于是润食堂了。因此最后的战绩是过了 1.5 个题。

总结:这个成绩勉强说的过去,但还是体现出基础不牢,写不出动态开点线段树非常不应该。并且写代码的速度也不是很理想,应该加强。

A. Bessie's Function

基环树,树形 dp

(link) 给定一个长度为 \(N\) 的序列 \(a\),定义映射 \(f: [1, N] \to [1, N]\)\(\forall 1 \le x \le N\)\(f(x) = a_{x}\)。你可以花费 \(c_{i}\) 的代价把 \(a_{i}\) 修改成 \([1, N]\) 中的任何整数,求:最少花费多少代价修改序列 \(a\),使得 \(\forall 1 \le x \le N\)\(f(f(x)) = f(x)\)

画个图有助于加深我们对问题的理解。如果把 \([1, N]\) 中的整数看作节点,对每个 \(x\),从 \(x\)\(a_{x}\) 连一条有向边,就得到了一个有向图。把 \(x\) 映射到 \(f(x)\) 就相当于从 \(x\) 出发走一步,把 \(f(x)\) 映射到 \(f(f(x))\) 就相当于从 \(x\) 出发走两步(由于每个点只有一条出边,所以走的路径唯一确定)。\(f(f(x)) = f(x)\) 就意味着:从 \(x\) 出发走一步和两步到达的节点相同。

如图所示:上层点代表 \(x\),下层点代表 \(a_{x}\)。第一次映射把上层的点映射到下一层,第二次映射在下层的点中沿出边行走一步。以样例 2 为例,只有节点 \(1\)\(2\) 是满足要求的。

\(a_{3}\)\(a_{4}\) 分别修改为 \(3\)\(4\) 后,所有点都满足了要求。

可以总结出,满足条件的点 \(x\) 有两种情况:

  1. \(x\) 有一个自环,即 \(a_{x} = x\)
  2. \(a_{x}\) 有一个自环,即 \(a_{a_{x}} = a_{x}\)

为了使 \(f(f(x)) = f(x)\) 对所有 \(x\) 都成立,要么 \(x\) 自身有一个自环,要么 \(a_{x}\) 有一个自环。有了这个观察之后,我们就知道:每次修改操作一定是把某个点改成自环,其它的改变一定不优于改成自环。(所以一个部分分是暴力枚举选择哪些点改成自环)

由于每个点的出边唯一,所以图实际上构成一个内向基环树森林。不妨假设图是连通的,如果不连通,则分别处理各个连通块。那么问题可以抽象成:给定一棵内向基环树,每个点有点权。对于每个没有自环的点 \(u\)(显然,在基环树上,最多有一个自环),必须在 \(u\)\(u\) 连向的点中选择至少一个,求选择的点的最小点权和。(有点类似最小点覆盖问题,但不完全相同。)

这个问题和树上最大权独立集很像,容易想到用 dp 解决这个问题。由于基环树可以看作树上多了一条边,不妨先忽略这条边,这样它就变成了一棵树。设 \(f(u, 1/0)\) 表示在 \(T(u)\) 内选择点,并且选/不选 \(u\) 时,选择的点的最小点权和。容易列出转移方程:

\[\begin{cases} f(u, 0) = \sum_{v \in son(u)} f(v, 1) \\ f(u, 1) = \sum_{v \in son(u)} \min(f(v, 1), f(v, 0)) \end{cases} \]

需要注意的是对于状态 \(f(u, 0)\),由于没有选择 \(u\),所以 \(T(u)\)\(u\) 没有被覆盖。(这就是本问题和最小点覆盖问题的区别:这个问题中边是单向的;如果是无向图,那么这种情况下 \(u\) 被覆盖)因此统计答案时 \(f(root, 0)\) 不能作为答案,因为根节点没有被选上。

现在回到基环树的问题。处理基环树问题的常见套路是把环上的任意一个节点视为根,然后忽略根节点的出边,按照一棵树处理,最后解决根节点的出边对答案的影响。(例如基环树最大权独立集 P1453 城市环路)本题中,由于根节点 \(root\)\(a_{root}\) 必须选择至少一个,所以我们做两次树形 dp,分别代表强制选择 \(root\) 和强制选择 \(a_{root}\)。第一次 dp 的答案为 \(f(root, 1)\),第二次 dp 时令 \(f(a_{root}, 0) \gets +\infty\),答案为 \(\min(f(root, 0), f(root, 1))\)

不过上述做法少考虑了一种情况:如果 \(a_{root} = root\),那么“根节点 \(root\)\(a_{root}\) 必须选择至少一个”这个限制就不存在了——\(root\) 本身就有自环,因此不用选。此外,\(f(root, 0)\) 也可以从 \(f(v, 0), v \in son(root)\) 转移过来了。所以我们要特殊处理这种情况。

代码实现上,一个需要解决的问题是找到基环树上的环。对于内向基环树,这个问题相对容易:用拓扑排序,依次删除入度为 \(0\) 的点,最后没有被删除的点就在环上。

B. The Best Subsequence

PS:这篇题解最初发在博客园的上的时候错误挺多的,现已修正/kk

离散化,二分

有一个长度为 \(N\)\(\texttt{01}\)\(s\),先进行 \(M\) 次操作,每次操作给定一个区间 \([l, r]\),把 \([l, r]\) 中的所有 \(\texttt{0}\) 变成 \(\texttt{1}\)\(\texttt{1}\) 变成 \(\texttt{0}\)。然后有 \(Q\) 次询问,每次询问给定区间 \([l, r]\) 和常数 \(k\)\(1 \le k \le r - l + 1\)),你需要求出 \([l, r]\)字典序最大的长度为 \(k\) 的子序列。把这个子序列看作一个数的二进制表示,输出这个数对 \(10^{9} + 7\) 取模的结果。

先思考一下我们的策略是什么。容易想到要贪心地在区间内选取 \(\texttt{1}\)。如果区间内 \(\texttt{1}\) 的个数不少于 \(k\),就可以选出全为 \(\texttt{1}\) 的子序列。否则,最优策略一定是先选择一堆 \(\texttt{1}\),然后选择区间的一段后缀(与前面选择的 \(\texttt{1}\) 不相交)。正确性不难证明:比较两个子序列,如果它们前缀的极长 \(\texttt{1}\) 连续段长度不同,那么 \(\texttt{1}\) 连续段长的字典序大。但如果只选 \(\texttt{1}\),最后子序列的长度可能不足 \(k\),所以还要补上一段后缀。

询问时,在区间 \([l, r]\) 中找到最后一个 \(p\),满足 \([l, p)\)\(\texttt{1}\) 的个数加上 \(r - p + 1\) 不小于 \(k\),那么答案子序列就是若干个 \(\texttt{1}\) 拼接上 \([p, r]\)。由于 \([l, p)\)\(\texttt{1}\) 的个数加 \(r - p + 1\) 随着 \(p\) 的右移单调不升,所以 \(p\) 可以二分。设 \([1, p)\)\(\texttt{1}\) 的数量为 \(c\)\([p, r]\) 子串的二进制表示为 \(x\),则答案为 \((2^{c} - 1)2^{x} + x\)

问题来了:如何维护区间中 \(\texttt{1}\) 的个数以及区间的二进制表示这些量呢?我们大致有两种方法:

方法I:动态开点线段树

这个方法比较直接,在线段树上维护区间的翻转标记和区间中 \(\texttt{1}\) 的个数即可。对于区间的二进制表示,实际上就是维护区间中 \(\texttt{1}\) 对应的位置 \(i\)\(2^{n - i}\) 之和。

但动态开点线段树的空间复杂度为 \(O((m + q) \log n)\),时间复杂度为 \(O(m \log n + q \log^{2} n)\),二者都需要卡常。

对于空间复杂度的优化,由于所有修改操作都在查询操作之前,所以可以采取这样的策略:查询时,如果线段树上的某个节点的左右子都不存在,则直接返回该节点维护的区间和查询区间的交的信息:

Node query(int id, int l, int r, int L, int R) {
    if(!id) return {0, 0};
    if(l == L && r == R) return info[id];
    if(!lson[id] && !rson[id]) {
        // 左右子都不存在,直接返回。
        if(!info[id].cnt) return {0, 0};
        int cnt = R - L + 1; i64 sum = S(L, R);
        return {sum, cnt};
    }
    // 以下省略
}

为什么这是对的呢?如果一个节点没有左右子,说明每次修改操作要么完全包含这个节点对应的区间,要么完全不包含。所以这个区间中要么全是 \(\texttt{0}\),要么全是 \(\texttt{1}\),因此就没有必要继续查询子区间。由于所有修改操作都在查询操作之前,所以这种做法可以有效地减少查询时新建的节点数,从而减少空间占用,同时也减少了时间复杂度中的常数。

现在 MLE 的问题解决了。对于时间的优化,除了快读快输,一个比较有效的方法是使用 \(O(\sqrt{p}) - O(1)\) 的光速幂(\(p\) 是模数)。一篇题解中说他使用这种方法卡过去了,但我并没有成功,最高分数是 85 分/kk

提交记录

Code
#include<bits/stdc++.h>
#define debug(...) fprintf(stderr, __VA_ARGS__)

using namespace std;

typedef long long i64;
constexpr i64 MOD = 1000000007, inv2 = 500000004;
constexpr int B = (int)(sqrt((double)MOD)), N = 1000000000;
array<i64, B + 1> pw0, inv0;
array<i64, N / B + 1> pw1, inv1;

namespace IO {
    char buf[1 << 20], *p1, *p2;
    #define gc() (p1 == p2 && (p2 = (p1 = buf) + fread(buf, 1, 1 << 20, stdin)), p1 == p2) ? EOF : *p1++
    template<typename T> void read(T &x) {
        x = 0; int ch = gc();
        while(!isdigit(ch)) ch = gc();
        while(isdigit(ch)) x = x * 10 + ch - '0', ch = gc();
    }
    template<typename T> void write(T x) {
        static int stk[34]; int top = 0;
        do {
            stk[++top] = (int)(x % 10), x /= 10;
        } while(x);
        for(int i = top; i >= 1; i--) {
            putchar(stk[i] + '0');
        }
    }
    #undef gc
}
using IO::read; using IO::write;

i64 norm(i64 x, i64 y) {
    return (x + y) < MOD ? (x + y) : (x + y - MOD);
}

int n, m, q;
i64 S(int l, int r);
struct SGT {
    struct Node {
        i64 sum; int cnt;
        friend Node operator + (const Node &L, const Node &R) {
            return {norm(L.sum, R.sum), L.cnt + R.cnt};
        }
        void apply(int l, int r) {
            sum = (S(l, r) - sum + MOD) % MOD, cnt = (r - l + 1) - cnt;
        }
    };

    int tot, rt;
    vector<int> tag, lson, rson;
    vector<Node> info;

    void init() {
        int sz = (int)8e6;
        info.resize(sz), tag.resize(sz), lson = rson = tag;
    }

    void update(int id) {
        info[id] = info[lson[id]] + info[rson[id]];
    }

    void apply(int &id, int l, int r) {
        if(!id) id = ++tot;
        info[id].apply(l, r), tag[id] ^= 1;
    }

    void pushdown(int id, int l, int r) {
        if(tag[id]) {
            int mid = (l + r) >> 1;
            apply(lson[id], l, mid), apply(rson[id], mid + 1, r);
            tag[id] = 0;
        }
    }

    void change(int &id, int l, int r, int L, int R) {
        if(!id) id = ++tot;
        if(l == L && r == R) {
            apply(id, l, r); return;
        }
        int mid = (l + r) >> 1;
        pushdown(id, l, r);
        if(R <= mid) change(lson[id], l, mid, L, R);
        else if(L > mid) change(rson[id], mid + 1, r, L, R);
        else change(lson[id], l, mid, L, mid), change(rson[id], mid + 1, r, mid + 1, R);
        update(id);
    }
    void change(int L, int R) { change(rt, 1, n, L, R); }

    Node query(int id, int l, int r, int L, int R) {
        if(!id) return {0, 0};
        if(l == L && r == R) return info[id];
        if(!lson[id] && !rson[id]) {
            if(!info[id].cnt) return {0, 0};
            int cnt = R - L + 1; i64 sum = S(L, R);
            return {sum, cnt};
        }
        pushdown(id, l, r);
        int mid = (l + r) >> 1;
        if(R <= mid) return query(lson[id], l, mid, L, R);
        else if(L > mid) return query(rson[id], mid + 1, r, L, R);
        else return query(lson[id], l, mid, L, mid) + query(rson[id], mid + 1, r, mid + 1, R);
    }
    Node query(int L, int R) { return query(rt, 1, n, L, R); }
}tr;

void init() {
    pw0[0] = pw1[0] = inv0[0] = inv1[0] = 1;
    for(int i = 1; i <= B; i++) {
        pw0[i] = pw0[i - 1] * 2 % MOD;
        inv0[i] = inv0[i - 1] * inv2 % MOD;
    }
    for(int i = 1; i <= N / B; i++) {
        pw1[i] = pw1[i - 1] * pw0[B] % MOD;
        inv1[i] = inv1[i - 1] * inv0[B] % MOD;
    }
}

i64 pw(i64 x) {
    return pw1[x / B] * pw0[x % B] % MOD;
}

i64 inv(i64 x) {
    return inv1[x / B] * inv0[x % B] % MOD;
}

i64 S(int l, int r) {
    //   sum_{i = l}^{r} 2^{n - i}
    // = sum_{i = n - r}^{n - l} 2^{i}
    // = 2^{n - l + 1} - 1 - (2^{n - r} - 1)
    // = 2^{n - l + 1} - 2^{n - r}
    return pw(n - r) * (pw(r - l + 1) - 1) % MOD;
}

i64 query(int l, int r, int k) {
    auto [s, c] = tr.query(l, r);
    if(c >= k) return pw(k) - 1;

    int pos = -1, lo = l, hi = r;
    while(lo <= hi) {
        int mid = (lo + hi) >> 1;
        auto [sum, cnt] = tr.query(l, mid);
        if(cnt + (r - mid) >= k) pos = mid, lo = mid + 1;
        else hi = mid - 1;
    }
    // assert(pos != r);

    if(pos == -1) {
        auto [s1, c1] = tr.query(l, r);
        return s1 * inv(n - r) % MOD;
    }

    auto [s0, c0] = tr.query(l, pos);
    auto [s1, c1] = tr.query(pos + 1, r);
    i64 res = (s1 * inv(n - r) + (pw(c0) - 1) * pw(k - c0)) % MOD;
    return res;
}

int main() {

    read(n), read(m), read(q);
    init(), tr.init();
    for(int i = 1, l, r; i <= m; i++) {
        read(l), read(r);
        tr.change(l, r);
    }
    for(int i = 1, l, r, k; i <= q; i++) {
        read(l), read(r), read(k);
        write(query(l, r, k)), putchar('\n');
    }

    return 0;
}

方法II:离散化

这是本题的正解。操作完以后整个串可以表示为 \(O(m)\) 个极长的 \(\texttt{01}\) 连续段,所以把修改操作的端点离散化之后按段维护,就只需要维护 \(O(m)\) 个信息。具体而言,按段预处理 \(\texttt{1}\) 的数量以及二进制表示的后缀和即可。

实现细节:假设修改的端点排序后的数组为 \(num\),我们希望每个字符串中每个 \([num_{i}, num_{i + 1})\) 区间构成一个 \(\texttt{01}\) 连续段(可能不是极长的,但不重要)。这里使用的是左闭右开区间,能为实现带来便利。对于修改操作 \((l, r)\),加入到离散化数组中的数应该是 \(l\)\(r + 1\),也相当于左闭右开区间。如果加入的是 \(l\)\(r\) 就错了,因为不能保证 \(r\) 开头的那一段全为 \(\texttt{0}\)\(\texttt{1}\),考虑这个例子:\(n = 10\) 时翻转 \((3, 7)\),此时字符串分为 \([1, 3)\)\([3, 8)\)\([8, 11)\) 三个连续段,因此加入 \([3, 7)\) 是不正确的。除此之外,为了方便,还可以把 \(1\)\(n + 1\) 也加入到 \(num\) 中。

修改操作可以看作在 \(num\) 数组上做异或差分,修改完之后求一次前缀异或和就可以得到每一段是 \(\texttt{0}\) 还是 \(\texttt{1}\),然后就可以预处理区间 \(\texttt{1}\) 的数量和区间二进制表示的后缀和。

查询时二分 \(p\) 再查找 \(p\) 在哪个连续段中,单次查询的时间复杂度为 \(O(\log n \log m)\),此时已经可以通过。(下面这个做法没写,是口胡的)进一步,不难发现 \(p\) 一定是一个 \(\texttt{0}\) 连续段的开头,所以可以直接二分 \(num\) 数组的下标,时间复杂度优化到 \(O(\log m)\)

下面是单次询问 \(O(\log n \log m)\) 的做法:

Code
#include<bits/stdc++.h>
#define debug(...) fprintf(stderr, __VA_ARGS__)

using namespace std;

typedef long long i64;
constexpr i64 MOD = 1'000'000'007, inv2 = (MOD + 1) >> 1;

int n, m, q, tot;
vector<int> num, a, cnt;
vector<i64> sum;

struct Node {
    int l, r;
};
vector<Node> op;

int idx(int x) {
    auto it = upper_bound(num.begin(), num.end(), x);
    return (int)distance(num.begin(), it) - 1;
}

int getcnt(int l, int r) {
    if(l > r) return 0;
    int lid = idx(l), rid = idx(r);
    if(lid == rid) {
        return a[lid] * (r - l + 1);
    } else {
        int lres = a[lid] * (num[lid + 1] - l), rres = a[rid] * (r - num[rid] + 1);
        int mres = cnt[lid + 1] - cnt[rid];
        return lres + mres + rres;
    }
}

i64 qpow(i64 x, i64 k) {
    if(k < 0) return 0;
    i64 res = 1, base = x;
    while(k) {
        if(k & 1) {
            res = res * base % MOD;
        }
        base = base * base % MOD;
        k >>= 1;
    }
    return res;
}

i64 S(int l, int r) {
    //   sum_{i = l}^{r} 2^{n - i}
    // = sum_{i = n - r}^{n - l} 2^{i}
    // = 2^{n - l + 1} - 1 - (2^{n - r} - 1)
    // = 2^{n - l + 1} - 2^{n - r}
    return qpow(2, n - r) * (qpow(2, r - l + 1) - 1) % MOD;
}

i64 getsum(int l, int r) {
    int lid = idx(l), rid = idx(r);
    if(lid == rid) {
        return a[lid] * S(l, r);
    } else {
        i64 lres = a[lid] * S(l, num[lid + 1] - 1), rres = a[rid] * S(num[rid], r);
        i64 mres = sum[lid + 1] - sum[rid];
        return (lres + mres + rres) % MOD;
    }
}

i64 solve(int l, int r, int k) {
    // debug("In solve: l = %d, r = %d, k = %d\n", l, r, k);

    if(getcnt(l, r) >= k) {
        return qpow(2, k) - 1;
    }

    int lo = l, hi = r, pos = -1;
    while(lo <= hi) {
        int mid = (lo + hi) >> 1;
        int lcnt = getcnt(l, mid - 1), rlen = r - mid + 1;
        // debug("lo = %d, hi = %d, mid = %d, lcnt = %d, rlen = %d\n", lo, hi, mid, lcnt, rlen);
        if(lcnt + rlen >= k) lo = mid + 1, pos = mid;
        else hi = mid - 1;
    }
    // debug("pos = %d\n", pos);
    assert(pos != -1);
    int lcnt = getcnt(l, pos - 1);
    i64 lres = ((qpow(2, lcnt) - 1) * qpow(2, r - pos + 1)) % MOD, rres = getsum(pos, r) * qpow(inv2, n - r) % MOD;
    // debug("lres = %lld, rres = %lld, lcnt = %d\n", lres, rres, lcnt);
    return (lres + rres) % MOD;
}

int main() {
    // cin.tie(nullptr) -> sync_with_stdio(false);

    cin >> n >> m >> q;
    num.push_back(0);
    for(int i = 1, l, r; i <= m; i++) {
        cin >> l >> r;
        num.push_back(l), num.push_back(r), num.push_back(r + 1);
        op.push_back({l, r});
    }
    num.push_back(n + 1);

    sort(num.begin(), num.end());
    num.erase(unique(num.begin(), num.end()), num.end());
    tot = (int)num.size() - 2;
    // for(int x: num) { cerr << x << ' '; } cerr << '\n';

    vector<int> dif(tot + 2);
    for(auto [l, r]: op) {
        int lid = idx(l), rid = idx(r);
        dif[lid] ^= 1, dif[rid + 1] ^= 1;
    }
    a.resize(tot + 1);
    for(int i = 1; i <= tot; i++) {
        a[i] = a[i - 1] ^ dif[i];
    }

    cnt.resize(tot + 2), sum.resize(tot + 2);
    for(int i = tot; i >= 1; i--) {
        cnt[i] = cnt[i + 1] + a[i] * (num[i + 1] - num[i]);
        sum[i] = sum[i + 1] + a[i] * S(num[i], num[i + 1] - 1);
    }
    for(int i = 1; i <= tot; i++) {
        // debug("[%d, %d): %d, cnt = %d, sum = %lld\n", num[i], num[i + 1], a[i], cnt[i], sum[i]);
    }

    for(int i = 1, l, r, k; i <= q; i++) {
        cin >> l >> r >> k;
        cout << solve(l, r, k) << '\n';
    }

    return 0;
}

AC 记录

C. Friendship Editing

状压 dp

想不出来/kk

称满足题目给出性质的图是“好的“图。首先肯定要找到一种简洁的方式来刻画”好的“图,但我第一步就输了

如果直接想比较困难,不妨考虑补图。事实上一个好的图必定满足:其补图的每个连通块都是完全图。

证明:在原图中,\((a, b)\) 有边 \(\Longrightarrow\) 对任意其它点 \(c\)\((a, c)\) 有边或 \((b, c)\) 有边。因此,在反图中,\((a, b)\) 没有边 \(\Longrightarrow\) 对任意其它点,\((a, c)\) 没有边或 \((b, c)\) 没有边。考虑逆否命题,在补图中,\((a, c)\) 有边 \((b, c)\) 有边 \(\Longrightarrow\) \((a, b)\) 有边。所以对于补图中的一个连通图,不断应用这个结论就可以得出:每个连通分量都必须是完全图时,原图才是好的。

下面默认在补图中考虑。

有了这个结论之后就可以直接 dp 了。具体而言,设 \(f(S)\) 表示使得点集 \(S\) 的导出子图成为若干个完全图的并的最小代价,\(g(S)\) 代表点集 \(S\) 的导出子图中的边数,\(cross(S, T)\) 表示两个点集 \(S\)\(T\) 之间的边数。

由于我们要让补图变成若干个(两两之间无边的)完全图的并,所以转移时枚举当前点集 \(S\) 的一个子集 \(T\) 作为新加入的完全图。我们还要要求 \(T\)\(S\) 的剩余部分 \(S \setminus T\) 之间没有边,所以需要删除原有的这些边,代价为 \(cross(T, S \setminus T)\)。综上所述,转移方程如下:

\[\boxed{f(S) = \min_{T \subseteq S} \{g(T) + f(S \setminus T) + cross(T, S \setminus T)\}} \]

(注意一个同方案可能被转移了多次,但这是最优化问题而不是计数题,所以对同一个状态的多次转移不影响答案)

\(g(S)\) 可以在 \(O(2^{n}n^{2})\) 的时间内平凡地暴力求出,\(cross(S, T)\) 可以通过容斥求出(两个点集之间的边数等于点集之并的边数减去两个点集内部的边数之和) 。转移时枚举子集的时间复杂度为 \(O(3^{n})\),因此总时间复杂度为 \(O(2^{n}n^{2} + 3^{n})\)

总结:对于这类要求结构满足某种性质的题,要先找到这种性质的更为简洁的等价刻画方式,才能入手解决问题。对于本题,考虑图上的问题时,如果不容易在原图上之间考虑,可以考虑补图。推出性质之后的状压 dp 是比较平凡的。

Code
#include<bits/stdc++.h>

using namespace std;

constexpr int INF = 0x3f3f3f3f;

int main() {
    cin.tie(nullptr) -> sync_with_stdio(false);

    int n, m;
    cin >> n >> m;
    vector<vector<int>> G(n + 1, vector<int>(n + 1));
    for(int i = 1, u, v; i <= m; i++) {
        cin >> u >> v;
        G[u][v] = G[v][u] = 1;
    }

    int st = 1 << n;
    vector<int> f(st, INF), g(st), h(st);
    for(int s = 1; s < st; s++) {
        int vcnt = __builtin_popcount(s), ecnt = 0;
        for(int i = 1; i <= n; i++) {
            for(int j = i + 1; j <= n; j++) {
                if(!(s & (1 << (i - 1))) || !(s & (1 << (j - 1)))) continue;
                if(G[i][j]) ecnt++;
            }
        }
        g[s] = ecnt, h[s] = vcnt * (vcnt - 1) / 2 - ecnt;
    }

    f[0] = 0;
    for(int s = 1; s < st; s++) {
        for(int t = s; t; t = (t - 1) & s) {
            int cross = h[s] - (h[t] + h[s - t]);
            f[s] = min(f[s], g[t] + f[s - t] + cross);
        }
    }

    cout << f[st - 1] << '\n';
    
    return 0;
}

AC 记录

posted @ 2025-02-26 12:04  DengStar  阅读(149)  评论(0)    收藏  举报