In mathematics you don't understand things, you just get used to them.

标签 dp 下的文章


date: '2023-10-14'
title: 'Solution -「ARC 112E」Cigar Box'


Desc.

Link.

给定目标排列 $Q = (p_1, \dots, p_n)$ 和初始排列 $P = (1, \dots, n)$. 你可以对初始排列进行如下操作:

  • 选择一个下标 $i \in [1, n]$, 将其置于排列的开头或结尾.

问通过 $m$ 次操作将初始排列变化为目标排列的方案数, 对 $\textbf{998244353}$ 取模.

$n, m \leqslant 3000$.

Sol.

发现对于每一个数, 我们只关心对它的最后一次操作. 枚举 $L$ 和 $R$ 分别表示将某个数移动到开头的重要操作数和将某个数移动到结尾的重要操作数, 那么考虑从 $P$ 到 $Q$ 的操作过程, 最终留在 $[L+1, N-R]$ 的数的相对顺序并没有改变, 因此 $Q$ 中的 $[L+1, N-R]$ 部分一定单调递增.

设 $f_{i, L, R}$ 表示已经填了操作序列的 $[i, m]$ 部分 1 的, 前 $L$ 个数和后 $R$ 个数已经还原的方案数. 有如下转移:

$$ f_{i + 1, L, R} += f_{i, L, R} \times 2 \times (L + R) \\ f_{i + 1, L + 1, R} += f_{i, L, R} \\ f_{i + 1, L, R + 1} += f_{i, L, R} $$

[TODO /阐释转移方程/]

后面还似懂非懂, 就不放出来误人子弟了...

int main()
{
    ios::sync_with_stdio(0);
    cin.tie(nullptr);
    const int MOD = 998244353;
    using mint = modint<MOD>;
    int n, m; cin >> n >> m;
    vi a(n); rds(a);
    vector comb(n+1, vector<mint>(n+1));
    for (int i=0;i<=n;++i) {
        comb[i][0] = 1;
        for (int j=1;j<=i;++j) comb[i][j] = comb[i-1][j]+comb[i-1][j-1];
    }
    vector f(m+1, vector<mint>(n+1));
    f[0][0] = 1;
    for (int i=0;i<m;++i) {
        for (int j=0;j<=n;++j) {
            f[i+1][j] += f[i][j]*2*j;
            if (j < n) f[i+1][j+1] += f[i][j];
        }
    }
    mint ans = 0;
    for (int l=0;l<=n;++l) {
        for (int r=n-l;r>=0;--r) {
            if (n-l-r >= 2 && a[n-r-2] > a[n-r-1]) break;
            ans += f[m][l+r]*comb[l+r][l];
        }
    }
    cout << ans << "\n";
}

从未发觉 白昼已到尽头

何时 我未留意的腐朽

在暮雨之中尝试绽放

—— COP - hello&bye, days ft. 洛&乐


  1. 为什么倒序进行 DP? 因为这样我们才能获得「当前操作是否重要」的信息.


date: '2023-10-13'
title: 'Solution -「GYM 104128E」Color the Tree'


Desc.

Link.

给出树 $T=(V,E)$, 一个代价数组 $a_0,\dots, a_{n-1}$. 现欲对该树进行黑白染色, 所有节点初始皆为白色, 每次你可以令节点 $u$ 的子树中距离 $u$ 为 $i$ 的节点以 $a_i$ 的代价染为黑色. 问将整棵树染为黑色的最小代价.

$n \leqslant 10^5$.

Sol.

朴素做法即, 设 $f_{u, d}$ 为将子树 $u$ 中距离 $u$ 为 $d$ 的节点染为黑色的最小代价, 直接转移即可. $\mathcal O(n^2)$. 以下是朴素部分的代码:

void dfs2(int u) {
    for (int v : grp[u]) dfs2(v);
    for (int i=0;i<=m-dep[u];++i) dp[u][i] = a[i];
    static ll sum[N + 5];
    memset(sum, 0, (m-dep[u])*8);
    for (int v : grp[u]) {
        for (int i=0;i<=m-dep[v];++i) sum[i] += dp[v][i];
    }
    for (int i=0;i<m-dep[u];++i) chkmin(dp[u][i+1], sum[i]);
}

然后可以发现, 我们对于不同深度染色最小代价的计算是独立的, 于是枚举 $d$, 我们求解将原树中深度为 $d$ 的节点染黑的最小代价. 后面的做法就很暴力了, 直接将深度为 $d$ 的节点拉出来建虚树, 跑朴素 DP 即可. $\mathcal O(n\log_2 n)$ / $\mathcal O(n)$, 根据具体实现复杂度不同.

void solve() {
    int n; cin >> n;
    vi a(n); rds(a);
    const int LG = __lg(n)+1;
    vvi mn(LG);
    mn[0] = a;
    for (int i=1;i<LG;++i) {
        mn[i].resize(n-(1<<i)+1);
        for (int j=0;j<n-(1<<i)+1;++j) mn[i][j] = min(mn[i-1][j], mn[i-1][j+(1<<i-1)]);
    }
    auto getMin = [&](int l, int r) {
        assert(0 <= l && l <= r && r < n);
        int k = __lg(r-l+1);
        return min(mn[k][l], mn[k][r-(1<<k)+1]);
    };
    vvi grp(n);
    for (int i=1,u,v;i<n;++i) {
        cin >> u >> v; u--; v--;
        grp[u].pb(v);
    }
    vvi fa(LG, vi(n+1, n));
    function<void(int)> dfs2 = [&](int u) {
        for (int i=1;i<LG;++i) fa[i][u] = fa[i-1][fa[i-1][u]];
        for (int v : grp[u]) fa[0][v] = u, dfs2(v);
    };
    dfs2(0);
    vi dep(n), dfn(n);
    int timeStamp = 0;
    function<void(int)> dfs = [&](int u) {
        dfn[u] = timeStamp++;
        for (int v : grp[u]) dep[v] = dep[u]+1, dfs(v);
    }; dep[0] = 1; dfs(0);
    auto getLca = [&](int u, int v) {
        if (dep[u] < dep[v]) swap(u, v);
        for (int i=LG-1;i>=0;--i)
            if (fa[i][u] < n && dep[fa[i][u]] >= dep[v]) u = fa[i][u];
        if (u == v) return u;
        for (int i=LG-1;i>=0;--i)
            if (fa[i][u] != fa[i][v]) u = fa[i][u], v = fa[i][v];
        return fa[0][u];
    };
    int m = *max_element(allu(dep));
    vvi idep(m+1);
    for (int i=0;i<n;++i) idep[dep[i]].pb(i);
    ll ans = 0;
    vvi virt(n);
    auto createVirtualTree = [&](vi h) {
        sort(allu(h), [&](int x, int y) {
            return dfn[x] < dfn[y];
        });
        for (int i=0,sz=h.size();i<sz-1;++i) h.pb(getLca(h[i], h[i+1]));
        sort(allu(h), [&](int x, int y) {
            return dfn[x] < dfn[y];
        });
        h.erase(unique(allu(h)), h.end());
        for (int i=0,sz=h.size();i<sz-1;++i) virt[getLca(h[i], h[i+1])].pb(h[i+1]);
        return h[0];
    };
    for (int i=m;i>=1;--i) {
        int rt = createVirtualTree(idep[i]);
        function<ll(int)> dfs3 = [&](int u) {
            if (!virt[u].empty()) {
                ll res = 0;
                for (int v : virt[u]) res += dfs3(v);
                virt[u].clear();
                return min(res, (ll)getMin(i-dep[u], i-1));
            } else return (ll)getMin(0, i-1);
        };
        ans += dfs3(rt);
    }
    cout << ans << "\n";
}

/ 看着蔚蓝的天空被电线分割了 /

/ 天空下的你和我擦肩过 /

/ 就像风起风落 /

/ 曾有一张白纸在面前铺陈着 /

/ 提起笔勾勒出了轮廓 /

—— 孤寂code - 平行 ft. 洛天依


date: '2023-10-12'
title: 'Solution -「ABC 313F」Flip Machines'


Desc.

Link.

有 $N$ 张卡片,第 $i$ 张卡片正面印着一个数 $A_i$,反面印着一个数 $B_i$。一开始所有数正面朝上。

有 $M$ 种操作,第 $i$ 种操作表示为:

  • 有 $50\%$ 的概率将卡片 $X_i$ 翻转,否则将 $Y_i$ 翻转。

要求你求一个集合 $S\subseteq \mathbb{N} \bigcap [1,m]$,使得进行了集合中所有的编号的操作之后正面朝上的所有数的和的期望最大。输出这个最大值。

Sol.

容易发现有概率被任意正数台机器翻转的卡片对答案的贡献是 $\frac{a_i+b_i}{2}$, 其他卡片的贡献是 $a_i$. 将卡片分为两个集合 $A$ 和 $B$, 其中 $\forall i \in A$ 有 $a_i > b_i$, $\forall i \in B$ 有 $a_i \leqslant b_i$. 把一台机器的操作 $(x_i, y_i)$ 分为三个集合 $P, Q$ 和 $X$, 其中 $P$ 中的边只涉及 $A$ 内的卡片, $Q$ 中的边只涉及 $B$ 中的卡片, $X$ 中的边同时涉及两边的卡片. $P$ 中的边一定不会连, $Q$ 中的边一定会连, 留给我们决策的是 $X$ 中的边.

分类讨论 $|A|$ 和 $|B|$ 的相对大小:

  • $|A| > |B|$:

此时 $|B|<\frac n2$, 采取以下的动态规划, 设 $f_{i, S}$ 表示 $A$ 中的前 $i$ 个点与 $B$ 的点集 $S$ 有连边的最大期望答案. 有转移

$$ f_{i, S}\rightarrow f_{i+1, S\cup\{j\}} $$

  • $|A| \leqslant |B|$:

朴素的应用搜索即可.

代码有点脑溢血, 抄了官方题解的实现.

int main()
{
    ios::sync_with_stdio(0);
    cin.tie(nullptr);
    int n, m; cin >> n >> m;
    vi a(n), b(n), u(m), v(m);
    for (int i=0;i<n;++i) cin >> a[i] >> b[i];
    for (int i=0;i<m;++i) cin >> u[i] >> v[i];
    for (int i=0;i<m;++i) u[i]--, v[i]--;
    for (int i=0;i<m;++i) {
        if (u[i] == v[i] && a[u[i]] < b[u[i]]) swap(a[u[i]], b[u[i]]);
    }
    int ans = 0;
    vi A, B, id(n);
    for (int i=0;i<n;++i) {
        ans += a[i]*2;
        if (a[i] >= b[i]) id[i] = A.size(), A.pb(a[i]-b[i]);
        else id[i] = B.size(), B.pb(b[i]-a[i]);
    }
    int nA = A.size(), nB = B.size();
    vector<ll> ls(nA);
    const int INF = 1e9;
    for (int i=0;i<m;++i) {
        bool xp = a[u[i]] >= b[u[i]],
             yp = a[v[i]] >= b[v[i]];
        if (xp == yp) {
            if (!xp) {
                ans += B[id[u[i]]]+B[id[v[i]]];
                B[id[u[i]]] = B[id[v[i]]] = 0;
            }
               
        } else {
            if (!xp) swap(u[i], v[i]);
            ls[id[u[i]]] |= 1ll<<id[v[i]];
        }
    }
    if (nA <= nB) {
        int opt = 0;
        for (int i=0;i<1<<nA;++i) {
            int cur = 0; ll st = 0;
            for (int j=0;j<nA;++j) if (i&(1<<j)) cur -= A[j], st |= ls[j];
            for (int j=0;j<nB;++j) if (st&(1ll<<j)) cur += B[j];
            chkmax(opt, cur);
        }
        ans += opt;
    } else {
        vector dp(nA+1, vi(1<<nB, -INF));
        dp[0][0] = 0;
        for (int i=0;i<nA;++i) {
            for (int j=0;j<1<<nB;++j) {
                chkmax(dp[i+1][j], dp[i][j]);
                chkmax(dp[i+1][j|ls[i]], dp[i][j]-A[i]);
            }
        }
        int mx = 0;
        for (int j=0;j<1<<nB;++j) {
            int cur = dp[nA][j];
            for (int i=0;i<nB;++i) if (j&(1<<i)) cur += B[i];
            chkmax(mx, cur);
        }
        ans += mx;
    }
    cout << fixed << ans / 2. << "\n";
}

/ 当最后一缕余温消亡 /

/ 空躯壳何处能够安放 /

/ 睁眼醒于温暖的慌 /

/ 一笑而过继续坚强 /

—— 孤寂code - 逆光 ft. 洛天依


date: '2023-10-11'
title: 'Solution -「CF 1610G」AmShZ Wins a Bet'


Desc.

Link.

给出一个括号序列, 在一次操作中, 你可以:

  • 选择一对匹配的括号, 将他们从原序列中删去.

请你规划每次操作, 使得若干次操作后得到的序列字典序最小.

Sol.

结论: 如果删除了 $i, j$, 那么 $(i, j)$ 一定被删除了.

证明考虑对 $s_{i+1}$ 分类讨论即可.

设 $f_i$ 表示从 $i$ 出发的最近合法括号序列的右端点, $g_i$ 表示考虑 $[i, n]$ 的答案. 转移有

$$ g_i = \begin{cases} \mathrm{CONCAT}(s_i, g_{i+1}) \\ \min(\mathrm{CONCAT}(s_i, g_{i+1}), g_{f_i+1}) \end{cases} $$

直接 DP 由于值域的处理需要 $\mathcal O(n)$ 的时间, 所以是 $\mathcal O(n^2)$ 的. 考虑用数据结构处理, 就是要支持如下的问题:

给定一个字符串, 支持

  • 新增版本, 并在旧版本字符串的前面添加一个字符;
  • 比较版本 $i, j$ 的字典序大小.

其中比较操作可以转化为求 LCP (Longest Common Prefix). 求解 LCP 的一个常见方式是二分 + Hash. 然而这样我们需要维护一个字符串的所有前缀 Hash 值, 显然不如直接比较. 我们是否可以不维护所有前缀呢? 当然可以, 我们仅存储 $h_{s, j}$ 表示字符串 $s$ 前 $2^j$ 位的 Hash 值即可. 那么二分就变成了倍增. 具体来说, 维护 $g_i$ 第 $2^j$ 位在原串中的位置和前缀 Hash 值即可.

/*=cirnovsky=*/
#include <bits/stdc++.h>
using namespace std;
using ull = unsigned long long;
const int N = 3e5;
const ull BASE = 1331;
int f[N + 5], g[N + 5], pos[21][N + 5], h[21][N + 5];
ull pw[N + 5];
static void* __tmp = ([]() {
    pw[0] = 1; for (int i=1;i<N+5;++i) pw[i] = pw[i-1]*BASE;
    return pw;
 })();
signed main() {
    ios::sync_with_stdio(0);
    cin.tie(nullptr);
    string s; cin >> s;
    int n = s.length();
    stack<int> stk;
    for (int i=0;i<n;++i) {
        if (s[i] == '(') stk.push(i);
        else if (!stk.empty()) f[stk.top()] = i+1, stk.pop();
    }
    pos[0][n] = g[n] = n;
    for (int i=n-1;i>=0;--i) {
        pos[0][i] = g[i+1], h[0][i] = s[i], g[i] = i;
        for (int j=1;j<=18;++j) pos[j][i] = pos[j-1][pos[j-1][i]];
        for (int j=1;j<=18;++j) h[j][i] = h[j-1][i]+h[j-1][pos[j-1][i]]*pw[1<<(j-1)];
        if (f[i]) {
            int x = i, y = g[f[i]];
            for (int j=18;j>=0;--j) if (h[j][x] == h[j][y]) x = pos[j][x], y = pos[j][y];
            if (y == n || s[x] > s[y]) g[i] = g[f[i]];
        }
    }
    for (int i=g[0];i<n;i=g[i+1]) cout << s[i];
    cout << "\n";
}

/ 将 诗句通通掰碎 捡一双含情眉 /

/ 如何形容她?讽刺来得卑微 歌颂不够尖锐 /

/ 然后看向她 红唇正在颓废 墓志太过宏伟 /

/ 最后告别她 用尽言辞虚伪 发酵一滴眼泪 /

/ 再忘却 她是谁 /

—— 阿良良木键 - 乌云 ft. 乐正绫


date: '2023-09-28'
title: 'Solution -「JOISC 2020」建筑装饰 4'


朴素的 DP 形式是定义 $f_{i, j, A/B}$ 表示前 $i$ 个元素选择了 $j$ 个 $A$ 的可达性. $\mathcal O(n^2)$. 交换状态与值域, 定义 $f_{i, A/B, A/B}$ 表示前 $i$ 个元素中的最后一个元素 (即 $i$) 选择了 $A/B$, 在最大化 $A/B$ 的数量的目标下求得的 $A/B$ 的数量.

转移在代码注释里, 答案倒着构造.

/**
 * dp[i][A/B][A/B]: 前 i 个, 第 i 个选 A 还是 B, 最大化 A/B 的数量
 * a[i] >= a[i-1]: dp[i-1][A][A]+1 -> dp[i][A][A]; dp[i-1][A][B] -> dp[i][A][B]
 * a[i] >= b[i-1]: dp[i-1][B][A]+1 -> dp[i][A][A]; dp[i-1][B][B] -> dp[i][A][B]
 * b[i] >= a[i-1]: dp[i-1][A][B]+1 -> dp[i][B][B]; dp[i-1][A][A] -> dp[i][B][A]
 * b[i] >= b[i-1]: dp[i-1][B][B]+1 -> dp[i][B][B]; dp[i-1][B][A] -> dp[i][B][A]
*/
enum Element {A, B};
const int N = 1e6;
int n, a[N + 100], b[N + 100], f[N + 100][2][2];
char ans[N + 100];
int main()
{
    ios::sync_with_stdio(0);
    cin.tie(nullptr);
    rd(n), rds(a, 2 * n) , rds(b, 2 * n);
    rep (i, 1, 2 * n) {
        if (a[i] >= a[i - 1]) {
            chkmax(f[i][A][A], f[i - 1][A][A] + 1);
            chkmax(f[i][A][B], f[i - 1][A][B]);
        }
        if (a[i] >= b[i - 1]) {
            chkmax(f[i][A][A], f[i - 1][B][A] + 1);
            chkmax(f[i][A][B], f[i - 1][B][B]);
        }
        if (b[i] >= a[i - 1]) {
            chkmax(f[i][B][B], f[i - 1][A][B] + 1);
            chkmax(f[i][B][A], f[i - 1][A][A]);
        }
        if (b[i] >= b[i - 1]) {
            chkmax(f[i][B][B], f[i - 1][B][B] + 1);
            chkmax(f[i][B][A], f[i - 1][B][A]);
        }
    }
    int cnt[2] = {}, last = 1e9;
    drep (i, 2 * n, 1) {
        if (cnt[A] + f[i][A][A] >= n && cnt[B] + f[i][A][B] >= n && a[i] <= last) last = a[i], ans[i] = 'A', cnt[A]++;
        else if (cnt[A] + f[i][B][A] >= n && cnt[B] + f[i][B][B] >= n && b[i] <= last) last = b[i], ans[i] = 'B', cnt[B]++;
        else {
            cout << "-1\n"; return 0;
        }
    }
    copy_n(ans + 1, 2 * n, ostream_iterator<char>(cout, ""));
}