「Booksort」 排书

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

每次取一段连续的书插入别处,用“坏相邻接口数除以 3 上取整”做可采纳估价,IDA* 迭代加深到深度 4 求最少操作数。

OJ: roj

题目 ID: 3073

难度:提高+/省选-

标签:搜索IDA*迭代加深

创建: 2026-10-01 14:25

更新: 2026-10-07 13:50

形式化题目

给定一个 1∼n1 \sim n 的排列。一次操作:从中抽出连续的一段,插入到其余部分的某个位置。求把排列变成 1,2,…,n1,2,\dots,n 的最少操作次数;若答案 ⩾5\geqslant 5,输出 5 or more。

1⩽n⩽151 \leqslant n \leqslant 15,多组数据。

样例 1 的操作路线(n=6n=6,初始 1 3 4 6 2 5,答案 2;粗体为本次取走的连续段):

步骤 操作前 取走的段 插入后
初始 — — 1 3 4 6 2 5
第 1 步 1 3 4 6 2 5 3 4 1 6 2 3 4 5
第 2 步 1 6 2 3 4 5 6 1 2 3 4 5 6

第 1 步把 3 4 抽走插到末尾(2 5 之后),得到 1 6 2 3 4 5;第 2 步把 6 抽走插到最末尾,排列归位。注意每一步插入的都是一整块连续区间,中间不能拆开。

样例 2 的操作路线(n=5n=5,初始 5 4 3 2 1,答案 3):

步骤 操作前 取走的段 插入后
初始 — — 5 4 3 2 1
第 1 步 5 4 3 2 1 5 4 3 2 5 4 1
第 2 步 3 2 5 4 1 2 5 3 4 1 2 5
第 3 步 3 4 1 2 5 3 4 1 2 3 4 5

正解

思路

朴素做法:BFS 最短路。 把每个排列看成结点,"移动一段连续区间"为边,从起点搜到目标排列。n=15n=15 时选择取段区间约 (162)=120\binom{16}{2}=120 种、插入位置平均约 7 种,单层分支约 16001600;深度 4 的状态数约 16004≈6.5×10121600^4 \approx 6.5\times10^{12},且 n!n! 本身就高达 1.3×10121.3\times10^{12}——任何全量搜索都不可行。

关键观察:一次操作最多修复 3 个"错误的相邻关系"。 把排列写成 a0a1…an−1a_0a_1\dots a_{n-1},并约定末尾哨兵 an=n+1a_n = n+1。每个相邻接口 (ai−1,ai)(a_{i-1}, a_i) 若满足 ai≠ai−1+1a_i \neq a_{i-1}+1 就称为坏接口,坏接口总数记为 EE。目标排列的 E=0E=0。

取走 [l,r][l,r] 再插入别处时,段内部的相邻对原样搬家,好坏不变;正确性可能变化的接口恰好只有 3 处:

  1. 断口闭合处:al−1a_{l-1} 与 ar+1a_{r+1}(若存在)新接成的接口;
  2. 插入处左侧:插入点前一个元素与 ala_l 的接口;
  3. 插入处右侧:ara_r 与插入点后一个元素的接口。

要让 EE 下降最多,希望这 3 个新接口全是好的、而被拆掉的 3 个旧接口原本全是坏的——所以每次操作 EE 至多减少 3,最少操作次数 ⩾⌈E/3⌉\geqslant \lceil E/3 \rceil。

IDA*(迭代加深 + 估价剪枝)。 ⌈E/3⌉\lceil E/3\rceil 是个"永远不高估剩余步数"的估价函数,于是:

  • 依次以深度上限 d=0,1,2,3,4d = 0,1,2,3,4 做限定深度 DFS;
  • 搜索中若 $\lceil E/3 \rceil > $ 剩余深度,直接剪枝——哪怕每步都修满 3 个坏接口也来不及了;
  • 第一个能把排列归位的 dd 就是最少操作数;d=4d=4 仍失败则输出 5 or more。

由于估价非常紧,能活下来的分支必须让 EE 下降得足够快,深度 4 的有效搜索树极小。样例 1 每一步坏接口数的实际变化(末尾比到哨兵 6+16+1,即 5 · 这个接口):

状态 排列 断开的接口 EE ⌈E/3⌉\lceil E/3\rceil
初始 1 3 4 6 2 5 1 3、4 6、6 2、2 5、5 · 5 2
第 1 步后 1 6 2 3 4 5 1 6、6 2 3 1
第 2 步后 1 2 3 4 5 6 无 0 0

这里 ⌈E/3⌉\lceil E/3\rceil 恰好等于答案 2:估价是下界,这条路线第 1 步修复 2 个坏接口、第 2 步修复 3 个,迭代加深在 d=1d=1 时全部失败、d=2d=2 时成功,首个可行深度就是最少操作数。

为什么 d=1d=1 必然失败?初始时 ⌈E/3⌉=2>1\lceil E/3\rceil=2 > 1,一步之内无论怎么取段插入都不可能把 EE 降到 0,搜索树在根处就被剪空。

实现细节:

  • 状态用 tuple 存排列,天然可哈希;每个搜索结点内用 set 对后继去重,避免同一段的不同枚举方式重复扩展;
  • 插回原位(k=lk=l)等价于不操作,跳过;
  • wrong_links 末尾比较 books[-1] != len(books),等价于补上哨兵 an=n+1a_n=n+1 后统一数接口。

代码

Python 版:

python
#!/usr/bin/env python3
# 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-01 14:25
# update_at: 2026-10-01 14:25

import sys
from math import ceil

MAX_DEPTH = 4  # 题面保证答案一旦达到 5 就输出 "5 or more",所以迭代加深只到 4


def wrong_links(books: tuple[int, ...]) -> int:
    """数被破坏的相邻接口:正确排列里每个数 i 应紧挨在 i-1 后面。"""
    return sum(a + 1 != b for a, b in zip(books, books[1:])) + (books[-1] != len(books))


def dfs(books: tuple[int, ...], depth: int) -> bool:
    """IDA*:剩 depth 次操作能否排好,估价值 = 坏接口数 / 3 上取整。"""
    w = wrong_links(books)
    if w == 0:
        return True
    if ceil(w / 3) > depth:  # 每取一段书最多修好 3 个接口,修不完必剪掉
        return False
    seen: set[tuple[int, ...]] = set()  # 同一节点的重复后继状态只搜一次
    n = len(books)
    for l in range(n):
        for r in range(l + 1, n):
            seg = books[l:r]
            rest = books[:l] + books[r:]
            for k in range(len(rest) + 1):
                if k == l:  # 插回原位等于没动
                    continue
                nxt = rest[:k] + seg + rest[k:]
                if nxt in seen:
                    continue
                seen.add(nxt)
                if dfs(nxt, depth - 1):
                    return True
    return False


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    out: list[str] = []
    T = next(data)

    for _ in range(T):
        n = next(data)
        books = tuple(next(data) for _ in range(n))
        # 迭代加深:第一个能排好的深度就是最少操作次数
        depth = next((d for d in range(MAX_DEPTH + 1) if dfs(books, d)), None)
        out.append(str(depth) if depth is not None else "5 or more")

    print('\n'.join(out))


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

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-06 17:13
 * update_at: 2026-10-06 17:13
 */

// 排书:IDA*,估价函数 = 坏接口数 / 3 上取整(一次操作最多修复 3 个接口)

#include <cstdio>

typedef long long ll;

const int MAXN = 16;
const int HASH_MOD = 1000003; // 哈希模数,只做后继去重标记,不要求严格无冲突
const int MAX_DEPTH = 4;      // 答案一旦 >= 5 就输出 "5 or more",迭代加深只到 4

int start[MAXN];    // 本组数据的初始排列
int a[MAXN];        // 当前搜索结点的排列,下标 0..n-1;末尾哨兵 a[n] = n+1
int backup[MAX_DEPTH + 1][MAXN]; // backup[depth] = 该层结点进入枚举前的排列
                 // 必须按深度分层:全局单份会被子结点覆盖,回溯就还原错了
int rest[MAXN];     // 取走一段后剩下的书
int nxt[MAXN];      // 取走再插回之后的后继排列
int seen_id[HASH_MOD]; // 后继状态哈希去重:存的是最近一次标记它的结点编号
int node_counter;      // 搜索结点编号计数器(全局递增不重置,保证 seen_id 永不串号)
int n;
bool ok; // 当前深度限制下能否排好

// 数坏接口数 E:正确排列里 a[i] 应该恰好等于 a[i-1]+1,末尾比到哨兵 n+1
int wrong_links() {
    int cnt = 0;
    for (int i = 0; i < n; ++i)
        if (a[i] + 1 != a[i + 1])
            ++cnt;
    return cnt;
}

// 简单多项式哈希:同一搜索结点内对后继排列去重(p 指向长度为 n 的排列)
int hash_state(const int *p) {
    int h = 0;
    for (int i = 0; i < n; ++i)
        h = (h * 31 + p[i]) % HASH_MOD;
    return h;
}

// IDA*:还剩 depth 次操作能否把排列变成 1..n
// 剪枝:每次操作最多修好 3 个坏接口,所以剩余步数下界是 ceil(E/3)
void dfs(int depth) {
    int w = wrong_links();
    if (w == 0) { // 坏接口为 0,排列已经归位
        ok = true;
        return;
    }
    if ((w + 2) / 3 > depth) // ceil(E/3) > 剩余深度,必然来不及,剪掉
        return;

    // 本结点独立的去重空间:用结点编号当时间戳,避免误杀兄弟结点的后继
    int myid = ++node_counter;

    for (int i = 0; i < n; ++i) // 备份当前排列到本层专属的一行,枚举被破坏后好恢复
        backup[depth][i] = a[i];

    // 枚举取走的连续段 [l, r](闭区间)
    for (int l = 0; l < n && !ok; ++l) {
        for (int r = l; r < n && !ok; ++r) {
            int len = 0; // rest = 取走 [l,r] 后剩下的书
            for (int i = 0; i < l; ++i)
                rest[len++] = a[i];
            for (int i = r + 1; i < n; ++i)
                rest[len++] = a[i];

            // 枚举插入位置 k:插到 rest 下标 k 之前
            for (int k = 0; k <= len && !ok; ++k) {
                if (k == l) // 插回原位等于没操作,跳过
                    continue;

                // nxt = rest[0..k) + a[l..r] + rest[k..len)
                for (int i = 0; i < k; ++i)
                    nxt[i] = rest[i];
                for (int i = l; i <= r; ++i)
                    nxt[k + i - l] = a[i];
                for (int i = k; i < len; ++i)
                    nxt[i + (r - l + 1)] = rest[i];
                nxt[n] = n + 1; // 补回末尾哨兵

                int h = hash_state(nxt);
                if (seen_id[h] == myid) // 这个后继在本结点已经搜过,去重
                    continue;
                seen_id[h] = myid;

                for (int i = 0; i < n; ++i) // 走到后继
                    a[i] = nxt[i];
                dfs(depth - 1);
                for (int i = 0; i < n; ++i) // 回溯恢复本结点排列
                    a[i] = backup[depth][i];
            }
        }
    }
}

int main() {
    int T;
    scanf("%d", &T);
    while (T--) {
        scanf("%d", &n);
        for (int i = 0; i < n; ++i) {
            scanf("%d", &start[i]);
            a[i] = start[i];
        }
        a[n] = n + 1; // 末尾哨兵:让最后一个数和"序列结尾"的接口也被统计

        // 迭代加深:第一个能排好的深度就是最少操作次数
        int ans = -1;
        for (int d = 0; d <= MAX_DEPTH && ans == -1; ++d) {
            ok = false;
            // node_counter 全局递增不重置:seen_id 不用清空也不会串轮
            for (int i = 0; i < n; ++i) // 从初始排列重新出发
                a[i] = start[i];
            a[n] = n + 1;
            dfs(d);
            if (ok)
                ans = d;
        }
        if (ans == -1)
            printf("5 or more\n");
        else
            printf("%d\n", ans);
    }
    return 0;
}

复杂度

  • 时间:理论最坏 O((n3)d)O\bigl((n^3)^d\bigr),d⩽4d \leqslant 4;但估价剪枝后实际搜索树很小,n⩽15n\leqslant15 时 Python 也能在官方数据上瞬间跑完(实测最长单组 0.093s,7 组数据全部通过)。
  • 空间:O(n×d)O(n \times d) 存路径上的排列,加上单层去重的 set,远小于限制。

总结

  • 排书问题的状态空间是 n!n!,全量 BFS 不可行;突破口是"一次操作最多修复 3 个坏接口"这一结构性上界。
  • 由此得到可采纳估价 ⌈E/3⌉\lceil E/3\rceil,套上迭代加深(IDA*)后,深度上限 4 恰好对应题面的 5 or more 输出规则。
  • 这类"操作步数上界很小 + 存在可采纳估价"的题,IDA* 是标准套路:先找估价,再迭代加深,剪枝条件写成"估价 > 剩余深度"。