题单 · 习题解析

洛谷 P1091 [NOIP 2004 提高组] 合唱队形

★★ 跑两遍 LIS,而那个 -1 忘了就恒好少 1(300/300 逐组相等);★★★ 题面比值 1.0 ⇒ 「严格/非严格」那条曲线在这道题上原样重现

原题:洛谷 P1091出自 第 22 章 线性 DP:最长上升子序列 的题单题面本地存档:2026-08-29
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1091,日期见页头。两边不一致时信原站。

题目描述

n 位同学站成一排,音乐老师要请其中的 n-k 位同学出列,使得剩下的 k 位同学排成合唱队形。

合唱队形是指这样的一种队形:设 k 位同学从左到右依次编号为 1, 2, …, k, 他们的身高分别为 t₁, t₂, …, t_k,则他们的身高满足

    t₁ < ⋯ < tᵢ > tᵢ₊₁ > ⋯ > t_k        (1 ≤ i ≤ k)

你的任务是,已知所有 n 位同学的身高,计算最少需要几位同学出列, 可以使得剩下的同学排成合唱队形。

输入格式

共二行。第一行是一个整数 n (2 ≤ n ≤ 100)。 第二行有 n 个整数,第 i 个整数 tᵢ (130 ≤ tᵢ ≤ 230) 是第 i 位同学的身高(厘米)。

输出格式

一个整数,最少需要几位同学出列。

数据规模与约定

对于 50% 的数据,n ≤ 20;对于全部的数据,n ≤ 100

输入输出样例

输入

8
186 186 150 200 160 130 197 220

输出

4

八个人里请走 4 位(186 186 150 200 160 130 197 220 ⇒ 留下 186 200 160 130 之类)。 ★ 注意这组样例里有一对相同的身高(两个 186)—— 第 ④ 步会说这一点让它挡住了什么。

★ 这一页在那条曲线上的位置

同一轮的三页量的是同一件事 ——「严格 / 非严格」那个坑的抓获率 由 n / 值域 的比值决定。这道题的比值是题面直接给的:

    n ≤ 100,身高 130 ~ 230(101 种)   ⇒   n / 值域 ≈ 1.0

⇒ 按那条曲线,这里的抓获率应该在八成上下 —— 第 ④ 步在另一道题上把整条曲线又跑了一遍, 形状完全一样。

1★ 关键一步:跑两遍 LIS

枚举「最高的那个人」

f[i] = 以第 i 个人结尾的最长严格上升子序列长度(从左往右跑一遍); g[i] = 以第 i 个人开头的最长严格下降子序列长度(从右往左跑一遍)。

枚举最高的那位是第 i 个 ⇒ 队形最长 = f[i] + g[i] − 1,答案 = n − max(…)

⚠⚠ 那个 −1 是这道题最容易漏的一笔:第 i 个人在 f[i]g[i]各被数了一次

p1091.cpp★ 这一版就能 AC
// P1091 [NOIP 2004 提高组] 合唱队形 —— ★ 这一版就能 AC
//
// 题意:n 个人站一排,请走 n-k 位,剩下的 k 位要满足 `t₁ < … < tᵢ > … > t_k`
// (**严格**上升再**严格**下降,`1 ≤ i ≤ k`)。求最少走几位。
//
// ★ 关键一步:**跑两遍 LIS**。
// `f[i]` = 以第 i 个人**结尾**的最长严格上升子序列长度(从左往右);
// `g[i]` = 以第 i 个人**开头**的最长严格下降子序列长度(从右往左跑一遍 LIS)。
// 枚举「最高的那个人」是第 i 个 ⇒ 队形最长 = `f[i] + g[i] - 1`。
// 答案 = `n − max(f[i] + g[i] - 1)`。
//
// ⚠⚠ **那个 `-1` 是这道题最容易漏的一笔**:第 i 个人在 `f[i]` 和 `g[i]` 里**各被数了一次**
// (页面第 ③ 步量了漏掉它会怎样)。
//
// ★ 题面写的是 `1 ≤ i ≤ k` ⇒ **单调上升或单调下降本身也是合唱队形**
// (最高的那个人可以站在最左边或最右边)—— 上面这个式子天然覆盖了这两种情况。
//
// ★★ 这道题的身高范围是 `130 ≤ tᵢ ≤ 230`(只有 101 种),而 `n ≤ 100`
// ⇒ **`n / 值域 ≈ 1.0`** —— 按同一轮 [P1020](/sol/p1020/) 那条曲线,
// 「严格 / 非严格」那个坑在这道题上**几乎必然现形**(页面第 ④ 步实测)。
//
// 复杂度 `O(n²)`,`n ≤ 100` 绰绰有余。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<int> t(n);
for (int& x : t) cin >> x;
vector<int> f(n, 1), g(n, 1);
for (int i = 0; i < n; i++)
for (int j = 0; j < i; j++)
if (t[j] < t[i]) f[i] = max(f[i], f[j] + 1); // ★ 严格上升
for (int i = n - 1; i >= 0; i--)
for (int j = n - 1; j > i; j--)
if (t[j] < t[i]) g[i] = max(g[i], g[j] + 1); // ★ 严格下降
int best = 0;
for (int i = 0; i < n; i++) best = max(best, f[i] + g[i] - 1); // ★ 减 1
cout << n - best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

2参照物:枚举所有子集,照题面那条不等式链去验

p1091Brute.cpp参照物:2ⁿ 枚举子集
// P1091 的参照物:**枚举所有子集**,逐个检查是不是合唱队形
//
// ★ 它把题面那个不等式链 `t₁ < … < tᵢ > … > t_k` 照抄一遍去验,
// 不需要「跑两遍 LIS」,也不用想那个 `-1`。
// ⚠ 只跑得动 `n ≤ 20` 左右。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<int> t(n);
for (int& x : t) cin >> x;
int best = 0;
for (int mask = 1; mask < (1 << n); mask++) {
vector<int> v;
for (int i = 0; i < n; i++) if (mask >> i & 1) v.push_back(t[i]);
int k = v.size();
// 找峰:先严格升,再严格降;峰可以在两端
int p = 0;
while (p + 1 < k && v[p] < v[p + 1]) p++;
int q = p;
while (q + 1 < k && v[q] > v[q + 1]) q++;
if (q == k - 1) best = max(best, k); // 整条链都合法
}
cout << n - best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它不需要「跑两遍 LIS」,也不用想那个 −1 —— 直接照抄题面那条链去检查。

300 组(n ≤ 14
两遍 LIS vs 2ⁿ 枚举子集 不一致 0 组

3★★ 忘了那个 −1:说清楚它「算了什么」

p1091NoMinus.cpp错法一:忘了 -1
// P1091 错法一:忘了那个 `-1`
//
// 枚举「最高的那个人」是第 i 个时,他在 `f[i]`(左边那段)和 `g[i]`(右边那段)里
// **各被数了一次** —— 不减掉就把他数了两遍。
//
// ★★ 而它错得非常规整:**队形长度恒好 1,于是答案恒少 1**
// (页面第 ③ 步实测逐组相等)—— 除非 `n - best` 已经被压到 0 以下那种边界。
// ⇒ 「说清楚一个 bug 算了什么」比说它「错了」有用得多。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<int> t(n);
for (int& x : t) cin >> x;
vector<int> f(n, 1), g(n, 1);
for (int i = 0; i < n; i++)
for (int j = 0; j < i; j++) if (t[j] < t[i]) f[i] = max(f[i], f[j] + 1);
for (int i = n - 1; i >= 0; i--)
for (int j = n - 1; j > i; j--) if (t[j] < t[i]) g[i] = max(g[i], g[j] + 1);
int best = 0;
for (int i = 0; i < n; i++) best = max(best, f[i] + g[i]); // ← 少了 -1
cout << n - best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 组
它的答案 恒好比正解少 1 300 / 300 逐组相等

⇒ 它不是「有时候错」,是每一组都恰好少 1 —— 因为最高的那个人被数了两遍, 队形长度恒好多 1,答案(n − 队形长度)就恒好少 1。

★ 「先说清楚 bug 算的是什么,再说它错在哪」—— 本书这一轮已经用了四次(P1332 / P1048 / P5019 / 这里)。说清楚之后,它的所有表现都是白送的推论。

4★★★ 同一条曲线,在另一道题上再跑一遍

p1091Loose.cpp错法二:严格写成非严格
// P1091 错法二:把「严格」写成了「非严格」(`<=` 而不是 `<`)
//
// 题面写的是 `t₁ < … < tᵢ > … > t_k` —— **两边都是严格的**,
// 相邻两个人身高一样是**不允许**的。
//
// ★★ 而这道题的数据让它躲不掉:身高只有 `130 ≤ tᵢ ≤ 230`(**101 种**),而 `n ≤ 100`
// ⇒ `n / 值域 ≈ 1.0`。按同一轮 [P1020](/sol/p1020/) 那条曲线,
// 比值到 1.0 时这个坑的抓获率已经在 **87%** 上下了(页面第 ④ 步实测)。
// ⇒ 和 [P1439](/sol/p1439/)(题面保证没有重复 ⇒ 精确的 0)正好是两个极端。
//
// ★ 官方样例里有一对相同的身高(两个 186)—— 第 ④ 步会说它挡没挡住。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<int> t(n);
for (int& x : t) cin >> x;
vector<int> f(n, 1), g(n, 1);
for (int i = 0; i < n; i++)
for (int j = 0; j < i; j++) if (t[j] <= t[i]) f[i] = max(f[i], f[j] + 1); // ← <=
for (int i = n - 1; i >= 0; i--)
for (int j = n - 1; j > i; j--) if (t[j] <= t[i]) g[i] = max(g[i], g[j] + 1);
int best = 0;
for (int i = 0; i < n; i++) best = max(best, f[i] + g[i] - 1);
cout << n - best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

题面那条链 t₁ < ⋯ < tᵢ > ⋯ > t_k 两边都是严格的。 把 < 写成 <=,抓获率沿比值走一遍(n 固定 14,只拧身高的种类数):

n / 值域 0.007 0.070 0.350 1.000(题面) 3.500
有相同身高的轮数 13 120 283 300 300
非严格那版被抓 2 25 106 215 300
★ 和 P1020 那条曲线形状完全一样 —— 而且第一层又一次先饱和

P1020 是在「导弹高度」上拧的,这一页是在「同学身高」上拧的, 两条曲线的形状一模一样:

  • 抓获率随比值单调上升(2 → 25 → 106 → 215 → 300);
  • 而「有没有相同的数」这一层在比值 1.0 就已经饱和到 300,之后不再提供任何信息

⇒ 这一轮四道题把这条曲线钉在了五个点上:

比值 那个坑被抓
P1439 题面保证无重复 精确的 0
B3637 0.005 2 / 300
这道题 1.0 215 / 300
P1020 2.0 几乎必抓
(压到极限) 3.5 300 / 300
✓ 而这道题的官方样例挡住了它 —— 原因也在这条曲线上

样例 186 186 150 200 160 130 197 220 里有一对相同的身高(两个 186), 而且它们恰好卡在最优队形的路上 ⇒ 非严格那版输出 3,答案是 4挡住。

⚠ 对比 P1020 的样例:8 个高度没有一个重复 ⇒ 同一类 bug 被放过。 ⇒ 同一个坑,两道题的样例一个挡住一个放过 —— 差别只在样例数据里有没有重复。

5★ 顺带:题面那句 1 ≤ i ≤ k 是有内容的

题面写的是 t₁ < ⋯ < tᵢ > ⋯ > t_k,并且 1 ≤ i ≤ k —— 也就是说「最高的那位」可以站在最左边(整个队形单调下降)或最右边(单调上升)。

★ 上面那个式子 f[i] + g[i] − 1 天然覆盖了这两种情况(那时 f[i]g[i] 等于 1)。 实测 300 轮里,最优队形是纯下降的有 13 轮、纯上升的有 1 轮 —— ⇒ 这句话不是废话,它真的会被用到。

6度量程序和生成器

p1091Count.cpp度量程序
// P1091 的度量程序 —— 这一页所有数字都出自这一份。
//
// `./p1091Count` 人看的版本
// `./p1091Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用
//
// 四段:
// ① ★ 「跑两遍 LIS」≡ `2ⁿ` 枚举子集逐个验队形;
// ② ★★ 忘了那个 `-1` 那版**算了什么**:答案恒少 1(逐组相等);
// ③ ★★★ 「严格写成非严格」的抓获率 —— 沿 `n / 身高种类数` 这个比值走一遍,
// 和同一轮 [P1020](/sol/p1020/) 那条曲线是同一条;
// ④ ★ 题面 `1 ≤ i ≤ k` ⇒ **单调上升或单调下降本身也算队形**:数一数这种局面有多少。
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
static bool CSV = false;
static void row(const char* key, const vector<ll>& v) {
if (!CSV) return;
printf("%s", key);
for (ll x : v) printf(",%lld", x);
printf("\n");
}
/** 正解:两遍 LIS。loose=true 时把严格换成非严格;minus=false 时忘了 -1 */
static int solve(const vector<int>& t, bool loose = false, bool minus = true, int* peakSide = nullptr) {
int n = t.size();
vector<int> f(n, 1), g(n, 1);
for (int i = 0; i < n; i++)
for (int j = 0; j < i; j++)
if (loose ? t[j] <= t[i] : t[j] < t[i]) f[i] = max(f[i], f[j] + 1);
for (int i = n - 1; i >= 0; i--)
for (int j = n - 1; j > i; j--)
if (loose ? t[j] <= t[i] : t[j] < t[i]) g[i] = max(g[i], g[j] + 1);
int best = 0, at = 0;
for (int i = 0; i < n; i++) {
int cur = f[i] + g[i] - (minus ? 1 : 0);
if (cur > best) { best = cur; at = i; }
}
if (peakSide) *peakSide = (f[at] == 1 ? -1 : (g[at] == 1 ? 1 : 0)); // -1 峰在最左,1 峰在最右
return n - best;
}
/** 参照物:枚举所有子集,照题面那条不等式链验 */
static int brute(const vector<int>& t) {
int n = t.size(), best = 0;
for (int mask = 1; mask < (1 << n); mask++) {
vector<int> v;
for (int i = 0; i < n; i++) if (mask >> i & 1) v.push_back(t[i]);
int k = v.size(), p = 0;
while (p + 1 < k && v[p] < v[p + 1]) p++;
int q = p;
while (q + 1 < k && v[q] > v[q + 1]) q++;
if (q == k - 1) best = max(best, k);
}
return n - best;
}
static vector<int> gen(mt19937& rng, int n, int kinds) {
vector<int> t(n);
for (int i = 0; i < n; i++) t[i] = 130 + (int)(rng() % (unsigned)kinds);
return t;
}
int main(int argc, char** argv) {
CSV = argc > 1 && string(argv[1]) == "csv";
/* ① 两遍 LIS ≡ 2^n 暴力,② 忘了 -1 那版恒少 1 */
{
mt19937 rng(20260829u);
int groups = 0, bad = 0, offByOne = 0;
for (int rep = 0; rep < 300; rep++, groups++) {
vector<int> t = gen(rng, (int)(rng() % 13) + 2, 14);
int ok = solve(t);
if (brute(t) != ok) bad++;
if (solve(t, false, false) == ok - 1) offByOne++; // 恒少 1
}
if (!CSV) printf("① %d 组(n ≤ 14):两遍 LIS vs 2^n 枚举子集不一致 %d 组\n"
"② 忘了 -1 那版:答案**恒好少 1** 的轮数 %d / %d(逐组相等)\n",
groups, bad, offByOne, groups);
row("ref", {groups, bad, offByOne});
}
/* ③ ★★★ 「严格写成非严格」的抓获率,沿比值走 */
{
const int KINDS[] = {2000, 200, 40, 14, 4}; // n 固定 14 ⇒ 比值 0.007 / 0.07 / 0.35 / 1.0 / 3.5
vector<ll> out;
for (int k : KINDS) {
mt19937 rng(k * 7919u + 91u);
int dup = 0, caught = 0;
for (int r = 0; r < 300; r++) {
vector<int> t = gen(rng, 14, k);
vector<int> s = t;
sort(s.begin(), s.end());
if (adjacent_find(s.begin(), s.end()) != s.end()) dup++;
if (solve(t, true) != solve(t)) caught++;
}
out.push_back(caught); out.push_back(dup);
if (!CSV) printf("③ n = 14,身高 %4d 种(比值 %.3f):非严格那版被抓 %d / 300,"
"有相同身高的 %d / 300\n", k, 14.0 / k, caught, dup);
}
row("loose", out);
}
/* ④ 单调上升 / 单调下降本身也算队形 */
{
mt19937 rng(4444u);
int rounds = 300, left = 0, right = 0;
for (int r = 0; r < rounds; r++) {
vector<int> t = gen(rng, 14, 14);
int side = 0;
solve(t, false, true, &side);
if (side < 0) left++;
else if (side > 0) right++;
}
if (!CSV) printf("④ %d 轮:最优队形里「最高的人站在最左边」(纯下降)%d 轮,"
"「站在最右边」(纯上升)%d 轮 ⇒ 题面 1 ≤ i ≤ k 允许这两种\n",
rounds, left, right);
row("mono", {rounds, left, right});
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p1091Gen.cpp数据生成器
// P1091 对拍生成器:`./p1091Gen <seed> [n 上限] [身高种类数]`
// 默认 `n ≤ 14`、身高从 `130..230` 里取(101 种)—— **照题面的比值**。
//
// ★ 题面是 `n ≤ 100`、`130 ≤ tᵢ ≤ 230`(101 种)⇒ `n / 值域 ≈ 1.0`;
// 默认档 `n ≤ 14` 时把种类数一起缩到 14,比值仍然是 1.0 ——
// ⇒ 又一次「[照抄题面的比值,不是绝对规模](/sol/p1020/)」(n 小了才能用 `2ⁿ` 暴力当参照物)。
#include <bits/stdc++.h>
using namespace std;
int main(int argc, char** argv) {
unsigned seed = argc > 1 ? (unsigned)atoi(argv[1]) : 1;
int nHi = argc > 2 ? atoi(argv[2]) : 14;
int kinds = argc > 3 ? atoi(argv[3]) : 14;
nHi = max(2, min(100, nHi));
kinds = max(1, min(101, kinds));
mt19937 rng(seed * 2654435761u + 1091u);
int n = (int)(rng() % (unsigned)(nHi - 1)) + 2;
printf("%d\n", n);
for (int i = 0; i < n; i++)
printf("%u%c", 130 + (unsigned)(rng() % (unsigned)kinds), i + 1 == n ? '\n' : ' ');
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

★ 生成器默认 n ≤ 14、身高 14 种 —— 比值仍然是 1.0,和题面一致 (又一次「照抄题面的比值,不是绝对规模」:n 小了才能用 2ⁿ 暴力当参照物)。

7一页纸

关键的一步 跑两遍 LIS(正着一遍、倒着一遍),枚举最高的那位;f[i] + g[i] − 1
哪一版能 AC p1091.cppO(n²)n ≤ 100
⚠⚠ 最容易漏的一笔 那个 −1 —— 忘了它,答案恒好少 1(300 / 300 逐组相等)
★★★ 这一页的位置 「严格 / 非严格」那条曲线的第四个点:题面比值 1.0 ⇒ 被抓 215 / 300
(曲线:0.007 → 3.5 时 2 → 300,而「有相同的数」在比值 1.0 就饱和了)
★ 样例 挡住了非严格那版(样例里有两个 186)—— 而 P1020 的样例放过了同一类 bug
★ 题面的边界 1 ≤ i ≤ k纯上升 / 纯下降也算队形(实测 300 轮里出现 14 次)
参照物 2ⁿ 枚举子集,照题面那条不等式链验 —— 300 组不一致 0 组