[AHOI2018初中组] 分组

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

把每个组看成一条连续链,按实力值递增处理;有多条链可接时接最短的,链比人多时冻结最长的,断档时整段冻结,答案取所有链长的最小值。

OJ: luogu

题目 ID: P4447

难度:普及+/提高-

标签:贪心堆思维递进python

创建: 2026-06-20 13:47

更新: 2026-10-23 07:23

RBook 文章:
堆
greedy

形式化题目

给定 nn 个整数 a1,…,ana_1,\dots,a_n,要把它划分成若干组,满足:

  1. 每组内的数值互不相同;
  2. 每组内的数值排序后是连续整数(相邻两项恰好差 11)。

在所有合法划分中,让人数最少的组尽可能大,输出这个最大值。

样例:a=[4,5,2,3,−4,−3,−5]a=[4,5,2,3,-4,-3,-5],一种最优划分是 {2,3,4,5}\{2,3,4,5\} 与 {−5,−4,−3}\{-5,-4,-3\},最小组人数为 33;不存在让两组都超过 33 人的方案,故答案为 33。

数值可能为负,组数不限。

解法路线

题面自带五档子任务,正好对应五步推进。每步都复用上一步的观察,只移除一个新的瓶颈:

层 做法 复杂度 覆盖 突破的瓶颈
1 枚举分组编号 O(nn)O(n^n) 1∼21\sim2 ——
2 排序 + 逐元素接最短链 O(n2)O(n^2) 3∼43\sim4 从枚举变成确定性构造
3 扫描连续段 O(nlog⁡n)O(n\log n) 3∼63\sim6 利用互不相同,链数降为 1
4 桶 + 堆 O(nlog⁡n)O(n\log n) 7∼87\sim8 允许重复,需堆管理多条链
终 分块 + 双堆 O(nlog⁡n)O(n\log n) 9∼109\sim10 坐标压缩,容纳负数与 10910^9

其中「2」到「4」是两次真正的思维跳跃;「3」是「2」在无争夺情形下的自然退化,本身不构成新跳跃。

正式主解是正解,代码在 main.cpp。

暴力解法

适用范围

n⩽6n\leqslant 6,即 1∼21\sim2 号测试点。

思路

按定义直接枚举。方案就是一个「分组编号序列」:belong[i] 表示第 ii 个队员被分到第几组,每个位置有 nn 种选择,共 nnn^n 种。

到叶子节点时统一检查合法性:把同组的数值收集起来排序,逐项检查「组内不重复」和「相邻差 1」。全部合法就用「人数最少的组的人数」更新答案。

组号本身没有标号意义,所以同一划分会被重复枚举(两人一组时组号可交换),但这只影响枚举次数,不影响 max⁡min⁡\max\min 的结果,不必去重。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-06-20 13:47
 * update_at: 2026-10-22 23:20
 */
// brute.cpp:小数据暴力解,使用「分组编号序列」递归枚举所有分法。
//
// 这一层枚举的是「第 dep 个队员分到第几个组」,每个位置有 n 种选择(组号 1..n),
// 共 n^n 种。到叶子节点时统一检查合法性:
//   1. 每组内数值互不相同;
//   2. 每组内数值排序后相邻恰好差 1(即连续)。
// 全部合法就用「人数最少的组的人数」去更新答案。
//
// 组号本身没有标号意义,所以同一个划分会被重复枚举(比如两人一组时组号可交换),
// 但这只影响枚举次数,不影响 max-min 的结果,暴力不必去重。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 10;

int n;
int a[MAXN];

int belong[MAXN];  // belong[i] = 第 i 个队员被分到的组号(1..n)

int best_answer;  // 所有合法方案中「最小组人数」的最大值

// 到叶子节点:belong[] 已给出完整分法,检查它是否合法并更新答案
void check() {
    // 按组号收集每组的成员
    vector<int> group[MAXN];
    for (int i = 1; i <= n; i++) {
        group[belong[i]].push_back(a[i]);
    }

    int min_size = n;  // 本方案里人数最少的组的人数
    for (int g = 1; g <= n; g++) {
        if (group[g].empty()) continue;

        sort(group[g].begin(), group[g].end());

        // 条件一:组内不能有两个相同的实力值
        for (int k = 1; k < (int)group[g].size(); k++) {
            if (group[g][k] == group[g][k - 1]) return;
        }
        // 条件二:相邻两项必须恰好差 1(排序后连续)
        for (int k = 1; k < (int)group[g].size(); k++) {
            if (group[g][k] != group[g][k - 1] + 1) return;
        }

        min_size = min(min_size, (int)group[g].size());
    }

    best_answer = max(best_answer, min_size);
}

// 这一层在决定「第 dep 个队员分到第几组」
void dfs(int dep) {
    if (dep > n) {
        check();
        return;
    }
    for (int g = 1; g <= n; g++) {
        belong[dep] = g;
        dfs(dep + 1);
    }
    belong[dep] = 0;
}

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

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    best_answer = 0;
    dfs(1);
    cout << best_answer << '\n';
    return 0;
}

复杂度与瓶颈

时间 O(nn⋅n)O(n^n\cdot n),空间 O(n)O(n)。n=6n=6 时约 4.7×1044.7\times10^4 种分法,实测 1010 ms。

瓶颈是枚举的对象不对:它枚举了「每个队员进哪组」,而组本身是可以构造的。只要找到构造规则,就不需要枚举。

第一层:从枚举到确定性构造

适用范围

n⩽1000n\leqslant 1000,即 3∼43\sim4 号测试点。

思路

能不能换个视角看「分组」这件事?

可以。把每个组看成一条链:链上的值依次加 11,不跳也不重复。于是分组等价于「把每个值 vv 的 cvc_v 个队员分配到若干条链上,每条链在每个值上最多占一个位置」。

这样就得到一条很强的事实:

值 xx 的队员,只能接到当前以 x−1x-1 结尾的链上;接不上就只能以 xx 新开一条长度为 11 的链。

这条依赖关系把「全局划分」变成了「按值顺序的局部决策」——决策顺序被值域唯一确定,这是能贪心的前提。

多条链可接时,接哪一条?

接最短的。用交换论证:设某个最优解在这一步把 xx 接到了较长的链 AA 上,而较短的 BB 被冻结。改成接 BB、让 AA 冻结,则 BB 变长、AA 定格;由于 ℓB⩽ℓA\ell_B\leqslant\ell_A,改后链长集合的最小值不会下降。

直觉上,长链已经「安全」,短链才是随时会被问到最小值的那条。

没被接上的链怎么办?

它们停在原地,长度永久定格。这一步不需要额外处理——没被选中的链自然留在原处,冻结是自动发生的。而答案正是最小链长,所以这些定格值会直接决定答案上界。

于是算法只有一条规则:排序后逐个队员处理,把他接到「以 ai−1a_i-1 结尾、且长度最短」的那条链上;接不上就新开长度 11 的链。

代码

main2.cpp 忠实地按上述规则模拟:每次线性扫描所有链,找结尾值为 value−1value-1 且长度最短的那条。

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-22 23:20
 * update_at: 2026-10-22 23:20
 */
// main2.cpp:第二层解法,排序后逐元素模拟接链,O(n^2)。
//
// 层 1 枚举了所有分法但数量爆炸;能不能直接构造?
//
// 关键观察:把每个组看成一条「链」,链上的实力值依次加 1。
// 按实力值从小到大处理每个队员时,他只能接到「以 a_i - 1 结尾」的链上,
// 接不上就只能新开一条长度为 1 的链 —— 决策顺序被值域唯一确定。
//
// 有多条链可接时接哪条?接最短的。短链才是最可能成为「最小组」的那条,
// 把增长机会给它,最小值不会下降(交换论证)。这一条规则就是全部贪心:
// 没人被接的链自然停在原地,冻结这件事自动发生了,不需要额外处理。
//
// 实现上没有堆,每次线性扫描所有链找最短的可接链,所以是 O(n^2)。
// 能过 n <= 1000 的 3~4 号测试点;再往上要靠第三层的桶/堆优化。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;

int n;
int a[MAXN];

struct Chain {
    int end_value;  // 这条链的最后一个实力值
    int length;     // 这条链当前的组人数
};

Chain chains[MAXN];  // 最多 n 条链,每条至少 1 人
int chain_cnt = 0;


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

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    sort(a + 1, a + n + 1);

    for (int i = 1; i <= n; i++) {
        int value = a[i];

        // 在所有链里找「能以 value-1 结尾接上」且长度最短的那一条
        int pick = -1;
        for (int c = 0; c < chain_cnt; c++) {
            if (chains[c].end_value != value - 1) continue;
            if (pick == -1 || chains[c].length < chains[pick].length) {
                pick = c;
            }
        }

        if (pick != -1) {
            // 接到最短的链后面,链长加 1
            chains[pick].end_value = value;
            chains[pick].length++;
        } else {
            // 接不上任何链,只能新开一条长度 1 的链
            chains[chain_cnt].end_value = value;
            chains[chain_cnt].length = 1;
            chain_cnt++;
        }
    }

    // 所有队员处理完后,每条链的长度已经定型,取最小的那个
    int answer = n;
    for (int c = 0; c < chain_cnt; c++) {
        answer = min(answer, chains[c].length);
    }
    cout << answer << '\n';
    return 0;
}

复杂度与瓶颈

每处理一个队员要扫描全部链,共 O(n2)O(n^2),空间 O(n)O(n)。n=1000n=1000 实测 22 ms,能过 3∼43\sim4 号点。

瓶颈是**「找最短的可接链」这一步扫了全部链**。但可接的链只由「结尾值是否等于 ai−1a_i-1」决定,扫描所有链显然浪费。

第二层:互不相同时,选择规则消失

适用范围

3∼43\sim4 号测试点额外保证 aia_i 互不相同。

思路

第一层无论何时都要处理「多条链可接时接哪条」,因为同一个结尾值可能挂着好几条链。

但互不相同意味着每个实力值最多只有一个队员,于是「以某值结尾的链」最多只有一条——争夺消失了,「接最短」这条选择规则也就消失了。

剩下的问题变成:每个队员只问一句「以我结尾的那条链有多长?」

leni={1,ai−1 不存在len前驱+1,否则(前驱唯一) len_i=\begin{cases} 1, & a_i-1 \text{ 不存在}\\ len_{\text{前驱}}+1, & \text{否则(前驱唯一)} \end{cases}

这是一层链长 DP,找前驱直接线性扫描,所以仍是 O(n2)O(n^2),但不再需要任何选择。

两个实现细节必须注意:

  1. 必须先排序。DP 递推要求「前驱的链长已经算好」,而 ai−1a_i-1 只有排在 aia_i 之前才满足。若按输入原序递推,会出现「需要的前驱还没算」的情况——例如 4 5 2 3 -4 -3 -5 中,4 的前驱 3 排在它后面,此时 len 还是初始值。
  2. 答案只能在链终点收集。中间点的 len 是前缀长度而不是组大小,若把它也算进最小值会得到偏小的错误答案。所以要标记哪些点还有后继(ai+1a_i+1 存在),只在没有后继的终点上取最小值。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-22 23:55
 * update_at: 2026-10-22 23:55
 */
// main2-1.cpp:第二层的「互不相同」特化解,套一层链长 DP,O(n^2)。
// 本层针对 3~4 号测试点:n <= 1000,且 a_i 互不相同。
//
// 与 main2.cpp 的区别:main2.cpp 无论如何都要处理「多条链可接时接哪条」,
// 因为同一个结尾值可能挂着好几条链。但 3~4 保证 a_i 互不相同,
// 每个实力值最多只有一个队员,于是「以某值结尾的链」最多只有一条 ——
// 争夺消失了,「接最短」这条选择规则也就消失了。
//
// 剩下的问题变成:每个队员只问「以我结尾的那条链有多长?」
//   若 a_i - 1 这个值不存在,我就是一条新链的起点,长度为 1;
//   若存在,我就接在它后面,长度是它的链长加 1。
// 这就是一层链长 DP:
//
//   len[i] = 1                      , 若不存在 j 使 a[j] == a[i] - 1
//   len[i] = len[j] + 1             , 否则(那个 j 唯一)
//
// 找前驱 j 直接线性扫描,所以是 O(n^2)。
//
// ⚠ 必须先把 a[] 排序。这一层 DP 的递推要求「前驱的链长已经算好」,
// 而 a_i - 1 只有排在 a_i 之前才能保证这一点。若按输入原序递推,
// 会出现「需要的前驱还没算」的情况(例如 4 5 2 3 -4 -3 -5 中,
// 4 的前驱 3 排在它后面,此时 len 还是初始值),答案就错了。
//
// ⚠ 答案只能在「链的终点」上收集。中间点的 len 只是前缀长度,
// 若把它也算进去会得到偏小的错误答案;只有没有后继(a_i + 1 不存在)的点
// 才携带完整组大小。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 1005;

int n;
int a[MAXN];

int len[MAXN];     // len[i] = 以 a[i] 结尾的链的长度(a[i] 是链的最后一个值)
bool has_next[MAXN];  // has_next[i] = 是否存在 a[j] == a[i] + 1,即 i 不是链的终点

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

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }

    // 排序:让 a_i - 1 一定排在 a_i 之前,DP 递推才有意义
    sort(a + 1, a + n + 1);

    // 互不相同是本层前提,检查一遍:本层若遇到重复值会给出错误答案
    // (同一个值出现两次时,「以某值结尾的链只有一条」不再成立)
    for (int i = 2; i <= n; i++) {
        if (a[i] == a[i - 1]) {
            cerr << "[main2-1] 本层只适用于 a_i 互不相同的 3~4 号测试点,"
                 << "但读到了重复的实力值 " << a[i]
                 << ";请改用 main.cpp(分块 + 双堆)\n";
            return 1;
        }
    }

    // 第一步:线性扫描求每个点的前驱,得到链长
    for (int i = 1; i <= n; i++) {
        len[i] = 1;  // 默认自己是链的起点
        for (int j = 1; j <= n; j++) {
            if (a[j] == a[i] - 1) {
                // 找到了唯一的前驱,接在它后面
                len[i] = len[j] + 1;
                break;
            }
        }
    }

    // 第二步:标记哪些点还有后继。没有后继的点才是链的终点,
    // 只有终点的 len 是完整组大小;中间点的 len 只是前缀长度。
    for (int i = 1; i <= n; i++) {
        has_next[i] = false;
        for (int j = 1; j <= n; j++) {
            if (a[j] == a[i] + 1) {
                has_next[i] = true;
                break;
            }
        }
    }

    // 答案 = 所有链终点上的 len 的最小值
    int answer = n;
    for (int i = 1; i <= n; i++) {
        if (!has_next[i]) {
            answer = min(answer, len[i]);
        }
    }

    cout << answer << '\n';
    return 0;
}

复杂度与瓶颈

仍为 O(n2)O(n^2),空间 O(n)O(n)。相比第一层没有变快,但去掉了选择规则——这为下一层的观察腾出了空间。

瓶颈仍在「找前驱要扫描全部元素」。

第三层:连续段整体成组

适用范围

5∼65\sim6 号测试点:n⩽105n\leqslant 10^5,aia_i 互不相同(同一条观察也适用于 3∼43\sim4,只是那时 nn 小)。

思路

既然没有两个队员实力值相同,就不会有两个人争抢同一条链的位置。那么排序后,数组自动被切成若干段「连续区间」,区间之间差至少 22。

**每段连续区间整体就是一组。**两个理由:

  • 段内数值连续且互不相同,天然满足「连续」和「不重复」,可以直接作为一组;
  • 若把它切成更小的组,只可能让最小的组更小,不会让答案变大。

所以最优方案就是「每段连续区间各成一组」,扫一遍取最短的那一段。

注意是取最短而不是最长:题目要最大化最小组人数,而最短的连续段已经决定了上限。例如 [1,2,10,11,12][1,2,10,11,12] 分成 22 组,最短段长 22,答案就是 22 而不是最长段的 33。

这一层与前一层的联系:从「链」的视角看,互不相同时每个值最多一条链,链与链之间没有交互,于是最优策略必然是「一条链顶到不能再顶」——也就是把整段连续区间吃满。DP 在这里退化成了一次扫描。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-22 23:45
 * update_at: 2026-10-22 23:45
 */
// main3.cpp:第三层解法,扫描连续段,O(n log n)。
// 本层针对 5~6 号测试点:n <= 1e5,但 a_i 互不相同。
//
// 关键观察:既然没有两个队员实力值相同,就不会有两个人争抢同一条链的位置。
// 于是把 a[] 排序后,它自动被切成若干段「连续区间」,区间之间差至少 2。
//
// 为什么每段连续区间整体就是一组?
//   - 段内数值连续且互不相同,天然满足「连续」和「不重复」,可以直接作为一组;
//   - 若把它切成更小的组,只可能让最小的组更小,不会让答案变大。
// 所以最优方案就是「每段连续区间各成一组」,答案取这些段里最短的那一段。
//
// 注意是取「最短」而不是「最长」:题目要最大化最小组人数,而最短的连续段
// 已经决定了上限,段内再细切只会更差。例如 [1,2,10,11,12] 分成 2 组,
// 最短段长 2,答案就是 2,而不是最长段的 3。
//
// 与第四层的关系:本层其实是第四层在无重复情形下的退化 —— 每个值的桶里
// 最多只有一个元素,堆退化成普通栈,于是不需要堆,扫一遍连续段即可。
// 值一旦允许重复(7~8 号点),就必须用桶 + 堆,见 main4.cpp。
//
// ⚠ 本层直接用「整段作为一组」,一旦存在重复值就会给出错误答案
// (例如 {-1,0,0,1,1,2,3} 正确是 3,本层会算成 2)。所以读入后先检查
// 是否有重复,有则提示改用 main4.cpp,避免静默返回错解。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;

int n;
int a[MAXN];

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

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    sort(a + 1, a + n + 1);

    // 本层的前提是「实力值互不相同」,检查一遍,不满足就明确提示
    for (int i = 2; i <= n; i++) {
        if (a[i] == a[i - 1]) {
            cerr << "[main3] 本层只适用于 a_i 互不相同的 5~6 号测试点,"
                 << "但读到了重复的实力值 " << a[i]
                 << ";请改用 main4.cpp(桶 + 堆)\n";
            return 1;
        }
    }

    // 扫描排序后的数组,找每一段满足 a[j+1] == a[j] + 1 的连续区间
    int answer = n;  // 最小组人数,先取最坏上界
    int left = 1;
    while (left <= n) {
        int right = left;
        while (right + 1 <= n && a[right + 1] == a[right] + 1) {
            right++;
        }
        // 区间 [left, right] 就是一组,人数是 right - left + 1
        answer = min(answer, right - left + 1);
        left = right + 1;
    }

    cout << answer << '\n';
    return 0;
}

复杂度与瓶颈

排序 O(nlog⁡n)O(n\log n),扫描 O(n)O(n),总 O(nlog⁡n)O(n\log n),空间 O(n)O(n)。n=105n=10^5 实测 10∼1810\sim 18 ms。

瓶颈变成**「互不相同」这个前提本身**。一旦允许重复,同一段连续区间里会出现「人多位置少」的情况,就必须切成多条链——「整段成组」不再成立。

正解

关键观察

值 xx 有 cxc_x 个队员,当前以 x−1x-1 结尾的链有 kk 条。分三种情况:

  • k<cxk<c_x:旧链全部接上,多出的 cx−kc_x-k 个队员新开长度为 11 的链;
  • k=cxk=c_x:一一对接,不新开;
  • k>cxk>c_x:必须有 k−cxk-c_x 条链接不上而冻结,它们停在 x−1x-1 不再增长。

第三种是重复值带来的新情况,也是前一层的「整段成组」失效的地方。链一旦断掉长度就永久定格,而答案正是最小链长,所以冻结会直接决定答案上界。

必须冻结时,冻哪一条?冻最长的。

这是「接最短」的对偶:冻结是不可逆的惩罚,应当让最长的链去承受——它本来就大,冻住也不致命;反过来若冻最短,等于亲手制造一条很短的定格链。

两条规则方向相反但道理同源:把继续增长的机会给最需要的(最短的),把冻结的惩罚给最不痛的(最长的)。

思路

O(n2)O(n^2) 的瓶颈是「扫描全部链找最短的可接链」。但可接的链只取决于一个东西:这条链是否以 x−1x-1 结尾。于是按结尾值分桶:

bucket[v] = 所有以实力值 vv 结尾的链长度,用小根堆维护。

处理值 xx 的队员时只需看 bucket[x-1]:非空就弹出堆顶(堆顶就是最短链,「接最短」免费实现),接上后把长度加 11 放进 bucket[x];为空就新开一条长度 11 的链。

「没被接上的链自动冻结」在这里同样自动成立:取不完的链留在原桶里不再参与延长,而桶的键是结尾值,值推进后那些桶再也不会被访问,长度自然定型。

分桶的两种做法:

  • main4.cpp 直接把 aia_i 当数组下标,最简单,但只能装下 1⩽ai⩽1051\leqslant a_i\leqslant 10^5(7∼87\sim8 号点);
  • main.cpp 用「排序后按值分块」代替定长数组,等效于坐标压缩,负数与 10910^9 都能装,且没有 map 的额外常数。

正式主解用后者。

两个实现细节:

  1. 断档冻结必须排在延长之前。cur 里的链属于「以 x−1x-1 结尾」,断档时这个前提不成立,它们根本不该参与本轮延长。若把断档判断挪到最后,cur 已被错误地接上——例如输入 [1,2,10][1,2,10],正确答案是 11(两条链 {1,2}\{1,2\} 与 {10}\{10\}),挪序后会得到 33。
  2. 必须排序。规则「接以 x−1x-1 结尾的链」只在值从小到大推进时成立。漏掉排序会先给大值建链、再回头处理小值,接续关系全错。

代码

main4.cpp 是值域受限(ai⩽105a_i\leqslant 10^5)的简化版,直接用 aia_i 当桶下标,最贴第七层的叙述:

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-22 23:20
 * update_at: 2026-10-22 23:20
 */
// main4.cpp:第四层解法,按值域开桶 + 每个值一个堆,O(n log n)。
// 本层针对 7~8 号测试点:1 <= a_i <= 1e5,值域小,可以直接用值当下标。
//
// 层 2 的瓶颈在哪?它每个队员都要线性扫描所有链找最短的可接链,O(n^2)。
// 但「可接的链」只取决于一个东西:这条链是否以 value-1 结尾。于是按结尾值分桶:
//
//   bucket[v] = 所有以实力值 v 结尾的链,用一个小根堆存它们的长度。
//
// 处理实力值 value 的队员时,只需要看 bucket[value-1]:非空就弹出堆顶
// (堆顶就是最短的那条,规则「接最短」免费实现),接上后把长度加 1 放进
// bucket[value];为空就新开一条长度 1 的链放进 bucket[value]。
//
// 层 2 里那些「没被接上的链自动冻结」在这里同样自动成立:
// 队员只会从 bucket[value-1] 里取,取不完的链留在原桶里不再参与延长,
// 而桶的键是结尾值,value 推进后那些桶再也不会被访问,长度自然定型。
//
// ⚠ 必须先把 a[] 排序再按这个顺序处理。规则「接最短链」只在「值从小到大」
// 的推进顺序下成立;若按输入原序处理,会先给大值建链、再回头处理小值,
// 链的接续关系就错了(例如 3 2 2 3 1 3 2 1 按原序算得 1,排序后才是 2)。
//
// ⚠ 本层把 a_i 直接当桶下标,所以只对 7~8 号测试点的值域成立(1 <= a_i <= 1e5)。
// 数据里一旦出现 a_i <= 0 或 a_i > 1e5,下标就飞出数组 —— 下面在读入时直接拦住
// 并给出明确提示,而不是让它静默越界。一般值域请用 main.cpp 的坐标压缩版本。
#include <bits/stdc++.h>
using namespace std;

const int MAXV = 100005;  // 值域上限 1e5,桶开到这个大小多留一点余量
const int MAXA = 100000;  // 7~8 号点保证 a_i <= 1e5

int n;
int a[MAXV];

// 每个实力值配一个小根堆,存「以它为结尾」的各条链长度
priority_queue<int, vector<int>, greater<int> > bucket[MAXV];

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

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        // 本层用 a_i 直接做下标,越界就是数组越界,先拦住并说明原因
        if (a[i] < 1 || a[i] > MAXA) {
            cerr << "[main4] 本层只适用于 1 <= a_i <= " << MAXA
                 << " 的 7~8 号测试点,但读到了 a_i = " << a[i]
                 << ";请改用 main.cpp(坐标压缩版)\n";
            return 1;
        }
    }

    // 必须按实力值从小到大处理,规则「接最短链」才有意义
    sort(a + 1, a + n + 1);

    int answer = n;  // 链长的最小值即答案,先取最坏上界

    for (int i = 1; i <= n; i++) {
        int value = a[i];

        if (value >= 1 && !bucket[value - 1].empty()) {
            // 有链以 value-1 结尾,接最短的那条(堆顶)
            int len = bucket[value - 1].top() + 1;
            bucket[value - 1].pop();
            bucket[value].push(len);
        } else {
            // 没有可接的链,以 value 新开一条长度 1 的链
            bucket[value].push(1);
        }
    }

    // 扫一遍所有桶,取最小的链长
    for (int v = 0; v < MAXV; v++) {
        while (!bucket[v].empty()) {
            answer = min(answer, bucket[v].top());
            bucket[v].pop();
        }
    }

    cout << answer << '\n';
    return 0;
}

main.cpp 是正式主解,用「排序 + 按值分块」实现分桶,用 cur / nxt 两个堆轮换:处理值 xx 时 cur 是「以 x−1x-1 结尾」的链,接上的链长度加 11 后放进 nxt;每轮结束 swap 一次,nxt 在 swap 后为空可直接复用。

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-06-20 13:47
 * update_at: 2026-10-22 22:55
 */
// P4447 [AHOI2018初中组] 分组
//
// 把每个组看成一条「链」:链上的实力值依次加 1,不跳也不重复。
// 于是「分组」等价于「把每个实力值的队员分配到若干条链上」。
//
// 按实力值从小到大处理,因为值 x 的队员只能接到「以 x-1 结尾」的链上,
// 接不上就只能以 x 新开一条长度为 1 的链。决策顺序被值域唯一确定,可以贪心。
//
// 三条规则,按执行顺序编号(前两条用交换论证证明不会让答案变差):
//   一、实力值不连续时,前一段的链全部冻结,必须当场结算
//   二、有多条链可接时,接最短的 —— 短链最可能成为答案,优先给它增长机会
//   三、链比人多的部分接不上,只能冻结;冻结的应当是其中最长的那些
//
// 规则一必须先于规则二:cur 里的链属于「以 value-1 结尾」,断档时这个前提
// 不成立,它们根本不该参与本轮延长;若挪到后面,cur 已被规则二错误地接上。
// 规则一与规则三冻结链的动作完全一样,都归结为下面的 freeze_all()。
#include <bits/stdc++.h>
using namespace std;

const int MAXN = 100005;

typedef priority_queue<int, vector<int>, greater<int> > MinHeap;

int n;
int a[MAXN];

// 冻结堆里所有链:长度已定格,拿去更新答案后清空。
// 规则一(断档时冻掉整段)和规则三(冻掉接不上的最长链)都调用它。
void freeze_all(MinHeap &heap, int &answer) {
    while (!heap.empty()) {
        answer = min(answer, heap.top());
        heap.pop();
    }
}

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

    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    sort(a + 1, a + n + 1);

    // 排序后扫描一遍,把相等的实力值压成一块:
    //   blocks[k].first  = 这个实力值
    //   blocks[k].second = 拥有这个实力值的人数
    // 这样后面按值块处理时,块的先后顺序就是实力值从小到大的顺序。
    vector<pair<int, int> > blocks;
    for (int i = 1; i <= n; ) {
        int j = i;
        while (j <= n && a[j] == a[i]) j++;
        blocks.push_back(make_pair(a[i], j - i));
        i = j;
    }

    // cur:以「当前值块的实力值」结尾的各条链长度,小根堆让最短的链先被取出。
    // nxt:以「下一个值块的实力值」结尾的链。两者轮换,nxt 每次用完即空。
    MinHeap cur, nxt;

    int answer = n;  // 链长的最小值即答案,先取最坏上界
    // 上一块处理过的实力值。a_i 最小到 -1e9,用 long long 避免 value-1 溢出。
    long long last_value = LLONG_MIN;

    for (auto [value, count] : blocks) {
        // ── 规则一(断档冻结)────────────────────────────────
        // 与上一块不连续,cur 里的链无法再从 value-1 延续,整段冻结。
        // 这一步必须先于规则二:否则这些链会被错误地接到 value 上。
        // last_value 记录上一块的实力值,初值取一个不可能相邻的数。
        if (value != last_value + 1) {
            freeze_all(cur, answer);
        }
        last_value = value;

        // ── 规则二(延长最短链)──────────────────────────────
        // 能接的链里优先接最短的,所以从堆顶取。最多接 min(count, cur.size()) 条。
        int extend = min(count, (int)cur.size());
        for (int k = 0; k < extend; k++) {
            nxt.push(cur.top() + 1);
            cur.pop();
        }

        // ── 规则三(冻结最长链)──────────────────────────────
        // 链比人多的那些接不上,只能停在上一块的值。刚才取走的是最短的若干条,
        // cur 里剩下的正是最长的那些,全部冻结即「冻最长、保最短」。
        freeze_all(cur, answer);

        // 接不上旧链的队员,只能以当前值新开一条长度为 1 的链
        for (int k = 0; k < count - extend; k++) {
            nxt.push(1);
        }

        swap(cur, nxt);  // nxt 在 swap 后为空,下一轮直接复用
    }

    // 收尾:最后一个值块留下的链全部冻结
    freeze_all(cur, answer);

    cout << answer << '\n';
    return 0;
}

Python 版用 defaultdict(list) 给每个结尾值配一个小根堆,逐个人处理即可,不必像 C++ 那样按值分块——因为同一个值的两个人不可能接到同一条链上(链内不可重复),所以逐个处理与分块处理等价。

python
"""P4447 [AHOI2018初中组] 分组

把每个组看成一条「链」:链上的实力值依次加 1,不跳也不重复。
于是「分组」等价于「把每个实力值的队员分配到若干条链上」。

按实力值从小到大处理,因为值 x 的队员只能接到「以 x-1 结尾」的链上,
接不上就只能以 x 新开一条长度为 1 的链。决策顺序被值域唯一确定,可以贪心。

三条规则,按执行顺序编号(前两条用交换论证证明不会让答案变差):
  一、实力值不连续时,前一段的链全部冻结,必须当场结算
  二、有多条链可接时,接最短的 —— 短链最可能成为答案,优先给它增长机会
  三、链比人多的部分接不上,只能冻结;冻结的应当是其中最长的那些

实现上用 defaultdict(list) 给每个「结尾实力值」配一个小根堆存链长:
堆顶总是以该值结尾的最短链,天然满足规则二(先取最短)
和规则三(剩下的都更长,冻结它们)。
规则一在逐个处理时自然消解:值 x-1 的堆为空即说明断档,不必显式判断。
"""

from collections import defaultdict
import heapq
import sys


def main() -> None:
    data = list(map(int, sys.stdin.buffer.read().split()))
    n = data[0]
    abilities = data[1:1 + n]
    abilities.sort()

    # chains[v] = 以实力值 v 结尾的所有链长,小根堆维护,堆顶是最短的那条
    chains = defaultdict(list)

    # 链长的最小值就是答案,先取最坏上界 n
    answer = n

    for x in abilities:
        if chains[x - 1]:
            # 规则一:能接就接最短的(堆顶),长度加 1 后挂到 x 这端
            length = heapq.heappop(chains[x - 1]) + 1
        else:
            # 接不上任何旧链,只能以 x 新开一条长度为 1 的链
            length = 1
        heapq.heappush(chains[x], length)

    # 所有值的队员都安排完了,堆里剩下的每条链长度都已定格,
    # 逐个取最小值即为答案。
    # 注意:同一实力值不会出现在两个不同的 x 上,所以每个链只被统计一次。
    for heap in chains.values():
        for length in heap:
            if length < answer:
                answer = length

    print(answer)


if __name__ == "__main__":
    main()

复杂度

注意一条链会被反复取出、放入,所以不能按「每条链一次」来数堆操作;正确做法是分别数 push 与 pop 的总次数。

**push 次数恒为 nn。**每一轮处理一个值块时,push 的次数恰好等于该值块的成员数:延长取的 extend 次加上新开的 count - extend 次正好是 count,而各值块互不重叠、并起来就是全部 nn 个元素。

**pop 次数也恒为 nn。**每个被 push 的元素只有两种结局——被取出后立刻以长度加 11 重新 push,或被冻结不再参与;收尾的 while (!cur.empty()) 再弹出所有遗留元素,于是每个元素恰好被 pop 一次。

所以堆操作总次数是 2n2n,每次 O(log⁡n)O(\log n),加上排序得到总时间 O(nlog⁡n)O(n\log n),空间 O(n)O(n)。

实测 n=105n=10^5:正解 C++ 约 12∼1812\sim 18 ms,Python 约 40∼17040\sim 170 ms(最慢的一组是值域拉满 [−109,109][-10^9,10^9],几乎每个值都不相邻);main3.cpp 在互不相同数据上约 10∼1810\sim 18 ms。

总结

五层推进,每层只做一件事:

层 关键动作
暴力 把「合法划分」写成可检验的判据
一 看穿「组 = 连续链」,决策顺序被值域确定
二 互不相同时争夺消失,选择规则可以删掉
三 无争夺时链与链无交互,最优必是「整段吃满」
正解 有争夺时按结尾值分桶,用堆在多条链间做取舍

真正有教学价值的是两次跳跃:

  1. 枚举 → 构造:观察「值 xx 只能接 x−1x-1 的链」,全局划分塌缩成按值顺序的局部决策;
  2. 逐元素 → 按值聚合:可接的链只取决于结尾值,所以分桶;桶内多条链的取舍交给堆。

第三层不是新的跳跃,而是第二层在「无争夺」下的自然退化——它的价值是让人看清堆为什么必要:每个值只有一个元素时,堆退化成栈,扫一遍就够;一旦有重复,堆才真正开始工作。

两条易错点,都实测过:

  • 把「冻最长」错写成「冻最短」:3000 组随机数据里会有 435 组答案出错;
  • 执行次序颠倒(断档冻结挪到延长之后):输入 [1,2,10][1,2,10] 会从正确的 11 变成 33。

一个边界要说明:这套规则依赖目标是最大化最小值。若目标改成最小化最大值(即不让某组过长),规则就要反向。所以它不是「分组问题」的通用规律,而是这个目标函数专属的。