简单九连环

GitHub跳转原题关系图返回列表

状态图是一棵树:把第 k 个环的桥递归成 T_k = T_{k-1} + T_{k-1},在树上用 O(n^2) 的区间 DP 求起点到终点距离。

OJ: luogu

题目 ID: P9229

难度:提高

标签:树形结构动态规划递推区间 DP状压 BFS

创建: 2026-10-02 15:07

更新: 2026-10-03 11:28

形式化题目

给定一个 0101 串 ss(∣s∣=n|s| = n),以及 m=n+1m = n+1 个环。游戏状态用 0101 串 tt 表示,ti=1t_i = 1 表示第 ii 个环在“剑”上。

一步操作可以翻转 tit_i,当且仅当

t1t2⋯ti−1  =  sn−i+2sn−i+3⋯sn, t_1 t_2 \cdots t_{i-1} \;=\; s_{n-i+2} s_{n-i+3} \cdots s_n ,

也就是 tt 的前 i−1i-1 位恰好等于 ss 的长度 i−1i-1 的后缀。特别地 i=1i=1 时前缀为空,第 11 个环总能翻转。

求把 t=00⋯0t = 00\cdots0 变成 t=11⋯1t = 11\cdots1 的最少步数,对 109+710^9+7 取模。1⩽n⩽20001 \leqslant n \leqslant 2000。

注意题面里 ∣s∣=∣t∣−1|s| = |t| - 1,所以 ss 的长度是 nn,环的数量是 n+1n+1。样例 11 中 s=011s = \texttt{011},环有 44 个,tt 的状态串长度是 44。

正解

思路

一、把游戏画成一张图

把每个状态 tt 看成点,能一步互相到达的状态之间连边。这张图有 2n+12^{n+1} 个点,题目要求的正是全 00 点到全 11 点的最短距离。n⩽2000n \leqslant 2000 时状态数是指数级,不可能真的建图,必须先看这张图长什么样。

先算一下边数。第 ii 个环可以翻转,只与 tt 的前 i−1i-1 位有关,所以可以翻转的状态恰好有 2m−i+12^{m-i+1} 个,总边数

E=12∑i=1m2m−i+1=2m−1. E = \tfrac12 \sum_{i=1}^{m} 2^{m-i+1} = 2^{m} - 1 .

点数 V=2mV = 2^{m},于是 E=V−1E = V - 1。再看连通性:T1T_1 只有两个状态、一条边,显然连通;而下一节会证明 TkT_k 是两份连通的 Tk−1T_{k-1} 用一条边连接而成,对 kk 归纳可知 TmT_m 也连通。

VV 个点、V−1V-1 条边、连通,所以这张图是一棵树。 于是问题变成:在一棵隐式的树上,求两点距离;树上的距离就是路径长度,不需要任何最短路算法。

二、树的递归结构

第 ii 个环的前置条件只用到 tt 的前 i−1i-1 位,所以前 kk 个环的翻转规则,只依赖 ss 的长度 k−1k-1 的后缀。记 TkT_k 为「只考虑前 kk 个环」时的状态树(规则仍用整串 ss,ss 的长度不够时前面补 00)。TkT_k 有 2k2^k 个点。

关键观察是 TkT_k 与 Tk−1T_{k-1} 的关系。第 kk 个环的翻转规则只看前 k−1k-1 位,把 Tk−1T_{k-1} 的两份拷贝记为「第 kk 位为 00」和「第 kk 位为 11」:

  • 两份拷贝各自内部的结构,恰好就是 Tk−1T_{k-1};
  • 跨份边只有翻转第 kk 位这一种,它要求 t1⋯tk−1=st_1 \cdots t_{k-1} = s 的长度 k−1k-1 后缀,所以全图只有一条这样的边,且两个端点在同一「位置」。

于是

Tk  =  Tk−1  的两个副本  +  连接同一位置的那条边. T_k \;=\; T_{k-1} \;\text{的两个副本} \;+\; \text{连接同一位置的那条边} .

以 s=011s = \texttt{011} 为例,前三个环构成的 T3T_3 如下(节点写成 t1t2t3t_1 t_2 t_3):

graph TD
  A000["000"] --- A100["100"]
  A100 --- A110["110"]
  A110 --- A010["010"]
  A110 --- A111["111"]
  A111 --- A101["101"]
  A101 --- A001["001"]
  A111 --- A011["011"]

图中最长的一条链是 000→100→110→111→101→001000 \to 100 \to 110 \to 111 \to 101 \to 001(直径 55),另外 010 挂在 110 下、011 挂在 111 下。可以把它读成两份 T2T_2(000,100,110,010 与 111,101,001,011)用 110 — 111 这条边拼起来。本例中 dist⁡T3(000,011)=4\operatorname{dist}_{T_3}(000,011) = 4、dist⁡T3(011,111)=1\operatorname{dist}_{T_3}(011,111) = 1,而 dist⁡T3(000,111)=3\operatorname{dist}_{T_3}(000,111) = 3。

用 s=011s = \texttt{011} 验证这个递归:T3T_3 内部的跨份边连接满足 t1t2=11t_1 t_2 = \texttt{11} 的两个状态,即 110 与 111;整个 T4T_4 的跨份边则连接 0110 与 0111(它要求 t1t2t3=s=011t_1 t_2 t_3 = s = \texttt{011})。对照样例:第 33 步后是 1110,此时不能直接翻第 44 个环,于是第 44 步在下方那份 T3T_3 内部从 1110 走到桥头 0110,第 55 步才翻第 44 个环跨过桥变成 0111。

三、答案的分解

全 00 状态 0n+10^{n+1} 与全 11 状态 1n+11^{n+1} 分别位于 Tn+1T_{n+1} 的两份 TnT_n 拷贝中(因为第 n+1n+1 位一个是 00 一个是 11)。树上的路径必须经过那条唯一的跨份边,因此

答案  =  dist⁡Tn ⁣(0n, s)⏟低半部分走到桥头  +  1  +  dist⁡Tn ⁣(s, 1n)⏟过桥后走到终点. \text{答案} \;=\; \underbrace{\operatorname{dist}_{T_n}\!\left(0^{n},\, s\right)}_{\text{低半部分走到桥头}} \;+\; 1 \;+\; \underbrace{\operatorname{dist}_{T_n}\!\left(s,\, 1^{n}\right)}_{\text{过桥后走到终点}} .

其中「桥头」就是 ss 本身作为 nn 位状态(因为第 n+1n+1 个环的前置条件正是 t1⋯tn=st_1 \cdots t_n = s)。

四、在 TkT_k 上刻画「区间状态」

TkT_k 上的目标状态 0k0^k、1k1^k 与桥头 ss 都能用「ss 的一段子串」统一表示。定义

W(k,p):ti=sp−k+i(i=1,…,k), W(k, p): \quad t_i = s_{p-k+i} \quad (i = 1, \dots, k),

即取 ss 中以位置 pp 结尾、长 kk 的那一段,越界的位置按 00 处理。那么

  • W(k,n)=sW(k,n) = s 的后 kk 位(桥头);
  • 0k0^k 可以看作 p⩽0p \leqslant 0(整段越界);
  • 1k1^k 不是 ss 的子串,需要单独处理。

在 TkT_k 中两点 W(k,p1),W(k,p2)W(k,p_1), W(k,p_2) 的距离,可以递归到 Tk−1T_{k-1}。按第 kk 位(也就是 sp1s_{p_1} 与 sp2s_{p_2})是否相同分类:

G(k,p1,p2)={G(k−1, p1−1, p2−1),sp1=sp2 (同侧,直接下移),G(k−1, p1−1, n)+1+G(k−1, n, p2−1),sp1≠sp2 (跨桥). G(k,p_1,p_2)= \begin{cases} G(k-1,\,p_1-1,\,p_2-1), & s_{p_1}=s_{p_2}\ \text{(同侧,直接下移)},\\[4pt] G(k-1,\,p_1-1,\,n) + 1 + G(k-1,\,n,\,p_2-1), & s_{p_1}\ne s_{p_2}\ \text{(跨桥)}. \end{cases}

跨桥式子的含义是:从 p1p_1 一侧出发,先走到本侧的桥头 W(k−1,n)W(k-1,n),过桥 11 步,再从另一侧桥头走到 p2p_2。

同理定义两个单点距离:

A(k,p)=dist⁡Tk ⁣(0k, W(k,p)),X(k,p)=dist⁡Tk ⁣(W(k,p), 1k). A(k,p) = \operatorname{dist}_{T_k}\!\left(0^k,\, W(k,p)\right), \qquad X(k,p) = \operatorname{dist}_{T_k}\!\left(W(k,p),\, 1^k\right).

当 sp=0s_p = 0 时 W(k,p)W(k,p) 的第 kk 位是 00,落在「全 00 那一侧」,只在 Tk−1T_{k-1} 内部走即可;当 sp=1s_p = 1 时它落在另一侧,要先走到本侧桥头、过桥、再从 Tk−1T_{k-1} 的桥头走到 0k−10^{k-1}:

A(k,p)={A(k−1, p−1),sp=0,A(k−1, n)+1+G(k−1, p−1, n),sp=1. A(k,p)= \begin{cases} A(k-1,\,p-1), & s_p = 0,\\[4pt] A(k-1,\,n) + 1 + G(k-1,\,p-1,\,n), & s_p = 1 . \end{cases}
X(k,p)={X(k−1, p−1),sp=1,G(k−1, p−1, n)+1+X(k−1, n),sp=0. X(k,p)= \begin{cases} X(k-1,\,p-1), & s_p = 1,\\[4pt] G(k-1,\,p-1,\,n) + 1 + X(k-1,\,n), & s_p = 0 . \end{cases}

最终答案

A(n,n)+1+X(n,n)(mod109+7). A(n,n) + 1 + X(n,n) \pmod{10^9+7}.

五、状态数与实现

G(k,p1,p2)G(k,p_1,p_2) 的直接记忆化看起来是 O(n3)O(n^3) 级别的状态,但利用上面三条递推可以证明实际只需要 O(n2)O(n^2) 个状态:

  • 递归时层数 kk 每次减一;
  • 只要「同侧」下降,p2−p1p_2 - p_1 不变;一旦跨桥,就会把一侧下标拉到 nn,此后该侧不再变化。

A,XA, X 两条链在每一层只产生一个 G(k−1,⋅,n)G(k-1,\cdot,n) 查询(固定一端为 nn),这些查询再沿 GG 的递推向下展开,总量是 O(n2)O(n^2)。

实现上把 nn 层状态分开存:先自上而下展开每层要用到的 (p1,p2)(p_1,p_2) 并排序去重,再自下而上按层递推数值;查询子层用二分。因为同一层内 GG 的转移只依赖更小的层,逐层递推不会出现循环依赖。

以 s=011s=\texttt{011}(n=3n=3)为例,A(k,k)A(k,k) 与 X(k,k)X(k,k) 的递推(脚本核算):

kk 桥头 W(k,n)W(k,n) A(k,k)A(k,k) X(k,k)X(k,k)
0 — 0 0
1 1 0 1
2 11 3 1
3 011 4 1

核对:dist⁡T3(000,011)=4\operatorname{dist}_{T_3}(000, \texttt{011}) = 4,dist⁡T3(011,111)=1\operatorname{dist}_{T_3}(\texttt{011}, 111) = 1(011 的父节点正是 111,翻转第 33 个环),于是 A(3,3)+1+X(3,3)=4+1+1=6A(3,3)+1+X(3,3) = 4+1+1 = 6,与样例输出一致。

代码

cpp
```cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-02 15:07
 * update_at: 2026-10-02 15:07
 */
// P9229 简单九连环
//
// 把 n+1 个环的所有状态看成图的点,一次环的上下是一次翻转,这张图是一棵树。
// 记 T_k 为“只看前 k 个环、规则仍用整串 s”的状态树,则 T_k 由两棵 T_{k-1}
// 拼成,中间用第 k 个环那条边相连;连接点恰是 t_1..t_{k-1} 等于 s 的长度
// k-1 后缀的那个状态。
//
// 设 W(k,p) 表示状态 t_i = s[p-k+i](s 上以 p 结尾的一段,越界当 0 看)。
// 树内距离可以递归:
//   G(k,p1,p2) = dist_{T_k}(W(k,p1), W(k,p2))
//     s[p1]==s[p2](同侧): G(k,p1,p2) = G(k-1,p1-1,p2-1)
//     否则(跨桥)        : G(k,p1,p2) = G(k-1,p1-1,n) + 1 + G(k-1,n,p2-1)
//   A(k,p) = dist_{T_k}(0^k, W(k,p))
//     s[p]==0: A(k,p) = A(k-1,p-1)
//     s[p]==1: A(k,p) = A(k-1,n) + 1 + G(k-1,p-1,n)
//   X(k,p) = dist_{T_k}(W(k,p), 1^k)
//     s[p]==1: X(k,p) = X(k-1,p-1)
//     s[p]==0: X(k,p) = G(k-1,p-1,n) + 1 + X(k-1,n)
//
// 起点 0^{n+1} 到终点 1^{n+1} 必然经过第 n+1 个环那条桥,桥的两端在 T_n 中
// 分别是 0^n 与 1^n(都对应 t_1..t_n = s),于是
//   答案 = A(n,n) + 1 + X(n,n)。
//
// 沿 A、X 两条链,每层只产生一个 G 查询;这些查询的 (p1,p2) 再向下展开,
// 总状态数是 O(n^2) 的。实现:先自上而下收集每层要用的 (p1,p2),再自下而上
// 逐层按上式计算数值。

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;
typedef long long ll;
typedef unsigned long long ull;

const int MOD = 1000000007;
const int maxn = 2005;
const int OFF = 3;          // 打包下标时的偏移,保证 -1 也能编码
const int SHIFT = 12;       // n <= 2000,12 位足够放下

int n;
char str[maxn];
int c[maxn];                // c[p] = s[p],p 越界时按 0 处理

inline int ch(int p) {
    if (p < 1 || p > n) return 0;
    return c[p];
}

// 把 (p1,p2)(可含 -1)打包成非负整数,并统一成 p1 <= p2
inline int pack_pair(int p1, int p2) {
    if (p1 > p2) swap(p1, p2);
    return ((p1 + OFF) << SHIFT) | (p2 + OFF);
}
inline int key_first(int key) { return (key >> SHIFT) - OFF; }
inline int key_second(int key) { return (key & ((1 << SHIFT) - 1)) - OFF; }

vector<int> layer[maxn];    // layer[k]:第 k 层需要计算的 (p1,p2),有序去重
vector<int> dp[maxn];       // dp[k][i]:layer[k][i] 对应的 G 值

// 查询已算出的 G(k,p1,p2)
int get_g(int k, int p1, int p2) {
    if (k <= 0 || p1 == p2) return 0;
    const vector<int> &vec = layer[k];
    if (vec.empty()) return 0;
    int key = pack_pair(p1, p2);
    int lo = 0, hi = (int)vec.size() - 1;
    while (lo < hi) {
        int mid = (lo + hi) >> 1;
        if (vec[mid] < key) lo = mid + 1;
        else hi = mid;
    }
    return dp[k][lo];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    cin >> n;
    cin >> (str + 1);
    for (int i = 1; i <= n; i++) c[i] = str[i] - '0';

    // ---------- 第一步:自上而下收集所有用到的 G 状态 ----------
    // A、X 两条链在每层各产生一个 G(k-1, ·, n) 查询
    int pa = n, px = n;
    for (int k = n; k >= 1; k--) {
        if (ch(pa) == 1) {
            layer[k - 1].push_back(pack_pair(pa - 1, n));
            pa = n;
        } else {
            pa = pa - 1;
        }
        if (ch(px) == 0) {
            layer[k - 1].push_back(pack_pair(px - 1, n));
            px = n;
        } else {
            px = px - 1;
        }
    }

    // 把每层状态沿递推式向下展开
    for (int k = n; k >= 1; k--) {
        sort(layer[k].begin(), layer[k].end());
        layer[k].erase(unique(layer[k].begin(), layer[k].end()), layer[k].end());
        const vector<int> cur = layer[k];
        for (int i = 0; i < (int)cur.size(); i++) {
            int p1 = key_first(cur[i]);
            int p2 = key_second(cur[i]);
            if (p1 == p2) continue;
            if (ch(p1) == ch(p2)) {
                layer[k - 1].push_back(pack_pair(p1 - 1, p2 - 1));
            } else {
                layer[k - 1].push_back(pack_pair(p1 - 1, n));
                layer[k - 1].push_back(pack_pair(n, p2 - 1));
            }
        }
    }
    sort(layer[0].begin(), layer[0].end());
    layer[0].erase(unique(layer[0].begin(), layer[0].end()), layer[0].end());

    // ---------- 第二步:自下而上按层计算 G ----------
    dp[0].assign(layer[0].size(), 0);
    for (int k = 1; k <= n; k++) {
        const vector<int> &cur = layer[k];
        dp[k].assign(cur.size(), 0);
        for (int i = 0; i < (int)cur.size(); i++) {
            int p1 = key_first(cur[i]);
            int p2 = key_second(cur[i]);
            if (p1 == p2) { dp[k][i] = 0; continue; }
            if (ch(p1) == ch(p2)) {
                dp[k][i] = get_g(k - 1, p1 - 1, p2 - 1);
            } else {
                ll v = get_g(k - 1, p1 - 1, n);
                v = (v + 1 + get_g(k - 1, n, p2 - 1)) % MOD;
                dp[k][i] = (int)v;
            }
        }
    }

    // ---------- 第三步:沿 A、X 两条链累加 ----------
    int sa = 0, sx = 0;
    pa = n; px = n;
    for (int k = n; k >= 1; k--) {
        if (ch(pa) == 0) {
            pa = pa - 1;                                     // A(k,p)=A(k-1,p-1)
        } else {
            sa = (sa + 1 + get_g(k - 1, pa - 1, n)) % MOD;   // A(k-1,n)+1+G
            pa = n;
        }
        if (ch(px) == 1) {
            px = px - 1;                                     // X(k,p)=X(k-1,p-1)
        } else {
            sx = (sx + (ll)get_g(k - 1, px - 1, n) + 1) % MOD;
            px = n;
        }
    }

    int ans = (sa + 1 + sx) % MOD;
    cout << ans << "\n";
    return 0;
}
text

### 复杂度

- 时间复杂度:$O(n^2 \log n)$(每层排序 + 每次查询二分),$n = 2000$ 时约 $2 \times 10^6$ 个状态,实测 $< 0.2$ s。
- 空间复杂度:$O(n^2)$。

## 暴力解法

### 思路

直接按定义建图:状态用 $n+1$ 位二进制表示,枚举每个状态、每个环判断前置条件,连边后从全 $0$ 状态 BFS 到全 $1$ 状态。状态数 $2^{n+1}$,只适用于 $n \leqslant 15$。

### 代码

```cpp
```cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-02 15:07
 * update_at: 2026-10-02 15:07
 */
// brute.cpp:小数据暴力解,直接按定义建图跑 BFS,用来对拍验证正解。
//
// 状态 t 用 n+1 位二进制表示(第 i 位表示第 i+1 个环,0 下标)。
// 第 i 个环(1..m)可以翻转,当且仅当 t_1..t_{i-1} 恰好等于 s 的长度 i-1 的后缀。
// 从全 0 状态 BFS 到全 1 状态,最短路长度就是答案。
#include <iostream>
#include <queue>
using namespace std;
const int maxn = 1 << 16;   // 支持 n+1 <= 16,即 n <= 15

int n, m;
char str[64];
int req[64];      // req[i]:第 i 个环可翻转时,低位 i-1 位应该是什么
int dist[maxn];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    if (!(cin >> n)) return 0;
    cin >> (str + 1);
    m = n + 1;

    // 预处理每个环的“前置条件”:t_1..t_{i-1} == s 的长度 i-1 的后缀
    req[1] = 0;
    for (int i = 2; i <= m; i++) {
        int k = i - 1;          // k <= n
        int val = 0;
        for (int j = 1; j <= k; j++) {
            // t_j 对应 s 的第 n-k+j 个字符
            if (str[n - k + j] == '1') val |= 1 << (j - 1);
        }
        req[i] = val;
    }

    int total = 1 << m;
    for (int i = 0; i < total; i++) dist[i] = -1;
    queue<int> q;
    dist[0] = 0;
    q.push(0);
    while (!q.empty()) {
        int st = q.front(); q.pop();
        for (int i = 1; i <= m; i++) {
            int k = i - 1;
            int low = st & ((1 << k) - 1);
            if (low != req[i]) continue;
            int ns = st ^ (1 << (i - 1));
            if (dist[ns] == -1) {
                dist[ns] = dist[st] + 1;
                q.push(ns);
            }
        }
    }

    cout << dist[total - 1] << "\n";
    return 0;
}
text

### 复杂度

时间与空间均为 $O(2^n)$。

### 瓶颈

状态数随 $n$ 指数增长,$n = 2000$ 完全无法承受。它同时也是验证正解的实验工具:小数据上正解与暴力必须逐位一致。

## 总结

这道题的背景是九连环,但真正要用的是三条结构观察,层层递进:

1. **状态图是树。** 数一数边:可翻转的状态恰有 $2^{m-i+1}$ 个,求和得 $E = 2^m - 1 = V - 1$,再加连通性,说明图就是树。于是「最少步数」就是树上两点距离。
2. **树可以递归。** 第 $k$ 个环只依赖前 $k-1$ 位,所以 $T_k$ 是两份 $T_{k-1}$ 加上一条唯一的跨份边。答案因此拆成 $A(n,n) + 1 + X(n,n)$。
3. **区间状态 + 树距离递推。** 用 $W(k,p)$($s$ 上以 $p$ 结尾的长度 $k$ 子串)统一表示桥头、全 $0$ 与全 $1$,得到同侧下移 / 跨桥分解的三组递推,按层 DP 即可在 $O(n^2 \log n)$ 内求出答案。

把「游戏状态」翻译成「树上的点」是本题最关键的一步;一旦看出树的递归结构,剩下的只是把树上距离写成区间下标的递推。