题单 · 习题解析

洛谷 P2782 友好城市

★★★ 难点是看出「不相交 ⟺ 两岸大小关系一致」⇒ 排序后就是 LIS;而那个「一字之差」的坑又是题面保证的精确 0(这一章五个点到此钉齐)

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

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

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

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

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

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

题目描述

有一条横贯东西的大河,河有笔直的南北两岸,岸上各有位置各不相同N 个城市。 北岸的每个城市有且仅有一个友好城市在南岸,而且不同城市的友好城市不相同。 每对友好城市都向政府申请在河上开辟一条直线航道连接两个城市, 但是由于河上雾太大,政府决定避免任意两条航道交叉,以避免事故。 编程帮助政府做出一些批准和拒绝申请的决定, 使得在保证任意两条航道不相交的情况下,被批准的申请尽量多

输入格式

第一行,一个整数 N,表示城市数。 第二行到第 N+1 行,每行两个整数,分别表示南岸和北岸的一对友好城市的坐标。

输出格式

仅一行,输出一个整数,表示政府所能批准的最多申请数。

数据规模与约定

  • 对于 50% 的数据,1 ≤ N ≤ 50000 ≤ xᵢ ≤ 10000
  • 对于 100% 的数据,1 ≤ N ≤ 2 × 10⁵0 ≤ xᵢ ≤ 10⁶

输入输出样例

输入

7
22 4
2 6
10 3
15 12
9 8
17 17
4 2

输出

4

七对友好城市,最多能批准 4 条互不相交的航道。 ⚠ 这组样例两个错法都放过了 —— 第 ③ ④ 步各说一个原因。

★ 这一页的难点不在算法,在「看出它是 LIS」

题单给这道题写的是:「排序之后就是 LIS。难点在看出「排完序之后这题就是 LIS」, 这一步才是 DP 题的真正门槛。」

⇒ 这一页第 ① 步就把那一步说透;剩下的两步是这一轮那条曲线的收尾: 那个「一字之差」的坑在这道题上又是精确的 0,而且和 P1439 一样 是题面保证的(第 ④ 步,含自检)。

1★★★ 关键一步:两条航道什么时候不相交

把「不相交」翻译成一句大小关系

一条航道就是一对坐标 (a, b)a 是南岸、b 是北岸)。两条航道 (a₁, b₁)(a₂, b₂) 画在河上:

    南岸  ---a1--------a2---
             \         /            <- 交叉:a1 < a2 而 b1 > b2
              \       /
    北岸  -----b2----b1----

    南岸  ---a1--------a2---
             |         |            <- 不交叉:a1 < a2 而 b1 < b2
             |         |
    北岸  ---b1--------b2---

不相交 ⟺ a 的大小关系和 b 的一致。

于是:把所有城市对按南岸坐标 a 排序a 那一维就自动递增了 —— 剩下的条件只有「b 递增」。 ⇒ 答案 = 排序后 b 序列的最长上升子序列长度。

p2782.cpp★ 这一版就能 AC
// P2782 友好城市 —— ★ 这一版就能 AC
//
// 题意:大河南北两岸各有 N 个位置互不相同的城市,每对友好城市想连一条直线航道。
// 要求任意两条航道**不相交**,问最多能批准几条。
//
// ★★★ 关键一步:**看出这题是 LIS** —— 题单里说「这一步才是 DP 题的真正门槛」。
// 两条航道 `(a₁, b₁)` 和 `(a₂, b₂)`(a 是南岸坐标、b 是北岸坐标)不相交
// ⟺ **a 和 b 的大小关系一致**(`a₁ < a₂` 且 `b₁ < b₂`,或者反过来)。
// ⇒ 把所有城市对**按南岸坐标 a 排序**,问题就变成「在 b 这个序列里挑最长的上升子序列」。
//
// ⚠ 而这一步之所以成立,靠的是题面那句「**位置各不相同**」:
// a 互不相同 ⇒ 排序之后顺序唯一;b 互不相同 ⇒ 严格上升和不下降是同一件事
// (页面第 ④ 步:那个「一字之差」的坑在这道题上又是**精确的 0**,和
// [P1439](/sol/p1439/) 一样是**题面保证**的)。
//
// 复杂度 `O(N log N)`(排序 + LIS)。题面 `N ≤ 2 × 10⁵`,`O(N²)` 过不去。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<pair<int, int>> c(n); // (南岸, 北岸)
for (auto& p : c) cin >> p.first >> p.second;
sort(c.begin(), c.end()); // ★ 按南岸坐标排
vector<int> tails;
for (auto& [a, b] : c) {
auto it = lower_bound(tails.begin(), tails.end(), b);
if (it == tails.end()) tails.push_back(b);
else *it = b;
}
cout << tails.size() << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

O(N log N)。⚠ 顶格 N = 2 × 10⁵O(N²) 要算 4 × 10¹⁰ 次, 而 O(N log N)3.6 × 10⁶ 次 —— 差 11 111 倍

2参照物:枚举所有子集,逐对验「不相交」

p2782Brute.cpp参照物:2ᴺ 枚举子集
// P2782 的参照物:**枚举所有子集**,逐个检查「两两不相交」
//
// ★ 它把题面照抄一遍 —— 一点「排序 + LIS」的巧劲都不用,
// 而这一页要验的恰恰是「看出它是 LIS」这一步对不对。
// ⚠ 只跑得动 `N ≤ 18` 左右。
#include <bits/stdc++.h>
using namespace std;
int main() {
int n;
if (!(cin >> n)) return 0;
vector<pair<int, int>> c(n);
for (auto& p : c) cin >> p.first >> p.second;
int best = 0;
for (int mask = 0; mask < (1 << n); mask++) {
vector<pair<int, int>> v;
for (int i = 0; i < n; i++) if (mask >> i & 1) v.push_back(c[i]);
bool ok = true;
for (size_t i = 0; i < v.size() && ok; i++)
for (size_t j = i + 1; j < v.size() && ok; j++) {
// 两条航道相交 ⟺ a 的大小关系和 b 的相反
bool cross = (v[i].first < v[j].first) != (v[i].second < v[j].second);
if (cross) ok = false;
}
if (ok) best = max(best, (int)v.size());
}
cout << best << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

它把上面那句「不相交 ⟺ 大小关系一致」直接拿来两两验,一点排序和 LIS 都不用—— 而这一页要验的恰恰是「看出它是 LIS」这一步对不对。

300 组(照题面,N ≤ 12
排序 + LIS vs 2ᴺ 枚举子集 不一致 0 组

3★ 忘了排序会怎样

p2782NoSort.cpp错法一:忘了排序
// P2782 错法一:**忘了排序**,直接对输入顺序里的北岸坐标求 LIS
//
// 「按南岸坐标排序」这一步不是优化,是**把二维问题降成一维**的那一步:
// 排完序之后,「南岸也递增」这个条件才自动满足,剩下的才只是「北岸递增」。
//
// ★ 不排序的话,输入顺序完全是随机的 —— 它算出来的东西和题目问的没有关系。
// ⚠ 而它在**输入本来就按南岸有序**的数据上会蒙对 —— 页面第 ③ 步量了这件事。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<pair<int, int>> c(n);
for (auto& p : c) cin >> p.first >> p.second;
// ← 少了 sort
vector<int> tails;
for (auto& [a, b] : c) {
auto it = lower_bound(tails.begin(), tails.end(), b);
if (it == tails.end()) tails.push_back(b);
else *it = b;
}
cout << tails.size() << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

「按南岸坐标排序」不是优化,是把二维问题降成一维的那一步 —— 排完序之后「南岸也递增」才自动成立。

照题面随机 300 轮,答案不同 167 / 300
官方样例 也输出 4放过

★ 样例放过它,是因为那七行数据碰巧让两种顺序算出同一个数 —— 又一次「样例只有一组」。

4★★★ 那个「一字之差」的坑:这一轮那条曲线的收尾

p2782Upper.cpp把 lower_bound 换成 upper_bound
// P2782 「错法」:把 `lower_bound` 写成 `upper_bound`
//
// ★★★ 而它在这道题上**又是精确的 0** —— 和 [P1439](/sol/p1439/) 同一个原因:
// 题面写着两岸城市的「位置**各不相同**」⇒ 排序后的北岸坐标序列**没有重复元素**,
// 而[第 22 章第 ⑩ 步](/ch/22-lis/)那个判据说得很清楚:
// **没有重复元素时,严格上升和不下降是同一件事。**
//
// ⇒ 这是这一轮那条曲线上的**第五个点**,也是第二个「**题面保证**的 0」。
// ⚠ 而这个 0 同样配了自检(页面第 ④ 步):放开「位置各不相同」,它当场就分得出来。
#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
if (!(cin >> n)) return 0;
vector<pair<int, int>> c(n);
for (auto& p : c) cin >> p.first >> p.second;
sort(c.begin(), c.end());
vector<int> tails;
for (auto& [a, b] : c) {
auto it = upper_bound(tails.begin(), tails.end(), b); // ← 一字之差
if (it == tails.end()) tails.push_back(b);
else *it = b;
}
cout << tails.size() << "\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
300 组(照题面)
upper_bound 版 vs lower_bound 不同 0 组
★ 又是题面保证的 0

题面写着两岸城市「位置各不相同」⇒ 排序后的北岸坐标序列没有重复元素, 而第 22 章第 ⑩ 步那个判据说:没有重复元素时,严格上升和不下降是同一件事。

⇒ 加多少轮、换什么种子都不会变。这是这一轮第二个「题面保证的 0」 (第一个是 P1439)。

⚠⚠ 而「精确的 0」照例配一道自检 —— 又是同一个动作

把题面那句「位置各不相同」放开(让北岸坐标出现重复),同一批代码再跑 300 轮:

upper_boundlower_bound 不同的轮数 288 / 300

⇒ 那个 0 不是空壳,是题面保证出来的。 ★ 和 P1439 完全一样的做法:「造一档违反题面的数据」这一个动作, 既给精确的 0 做了自检,也称出了那句约束的重量。

5★ 这一轮那条曲线,到这儿钉齐了

同一个坑(lower_bound 还是 upper_bound),这一章五道题量出五个点:

n / 值域 那个坑被抓
P1439 题面保证无重复 精确的 0
这道题 题面保证无重复 精确的 0
B3637 0.005 2 / 300
P1091 1.0 215 / 300
P1020 2.0 几乎必抓
★★ 一个坑,五道题,三种「抓不到」
  • P1439 / P2782:抓不到是题面保证的 —— 加轮数、换种子都没用, 而且这不是坏事:它意味着你可以放心用任意一种写法;
  • B3637:抓不到是比值太小 —— 换个生成器(压值域)当场就能抓到;
  • P1091 / P1020:题面的比值本身就大 —— 躲不掉,必须写对

⇒ ★★★ 「这个 bug 对拍抓不抓得到」从来不是一句话能回答的, 要连着「哪道题、什么数据、什么比值」一起说 —— 而这一章正好给了同一个 bug 的五个不同答案。

6度量程序和生成器

p2782Count.cpp度量程序
// P2782 的度量程序 —— 这一页所有数字都出自这一份。
//
// `./p2782Count` 人看的版本
// `./p2782Count csv` 一行一项,给 scripts/check-viz.mjs 写断言用
//
// 四段:
// ① ★ 「按南岸排序 + 北岸 LIS」≡ `2ᴺ` 枚举子集逐对验「不相交」;
// ② ★ 忘了排序会怎样(照题面随机的抓获率);
// ③ ★★★ 那个「一字之差」的坑在这道题上是**精确的 0** —— 题面「位置各不相同」保证的;
// ⚠ 而这个 0 **配了自检**:放开那句约束,它当场就分得出来;
// ④ ★ `O(N²)` 在题面顶格上是什么概念。
#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");
}
typedef vector<pair<int, int>> Cities;
/** 正解:按南岸排序 + 北岸 LIS。loose 用 upper_bound;noSort 跳过排序 */
static int solve(Cities c, bool loose = false, bool noSort = false) {
if (!noSort) sort(c.begin(), c.end());
vector<int> t;
for (auto& [a, b] : c) {
auto it = loose ? upper_bound(t.begin(), t.end(), b) : lower_bound(t.begin(), t.end(), b);
if (it == t.end()) t.push_back(b);
else *it = b;
}
return (int)t.size();
}
/** 参照物:枚举所有子集,逐对验「不相交」 */
static int brute(const Cities& c) {
int n = c.size(), best = 0;
for (int mask = 0; mask < (1 << n); mask++) {
vector<pair<int, int>> v;
for (int i = 0; i < n; i++) if (mask >> i & 1) v.push_back(c[i]);
bool ok = true;
for (size_t i = 0; i < v.size() && ok; i++)
for (size_t j = i + 1; j < v.size() && ok; j++)
if ((v[i].first < v[j].first) != (v[i].second < v[j].second)) ok = false;
if (ok) best = max(best, (int)v.size());
}
return best;
}
static Cities genPerm(mt19937& rng, int n) {
vector<int> a(n), b(n);
iota(a.begin(), a.end(), 0);
iota(b.begin(), b.end(), 0);
shuffle(a.begin(), a.end(), rng);
shuffle(b.begin(), b.end(), rng);
Cities c(n);
for (int i = 0; i < n; i++) c[i] = {a[i], b[i]};
return c;
}
static Cities genDup(mt19937& rng, int n) {
vector<int> a(n);
iota(a.begin(), a.end(), 0);
shuffle(a.begin(), a.end(), rng);
Cities c(n);
for (int i = 0; i < n; i++) c[i] = {a[i], (int)(rng() % (unsigned)max(1, n / 2))};
return c;
}
int main(int argc, char** argv) {
CSV = argc > 1 && string(argv[1]) == "csv";
/* ①②③ 照题面随机 */
{
mt19937 rng(20260829u);
int groups = 0, bad = 0, noSort = 0, upper = 0;
for (int rep = 0; rep < 300; rep++, groups++) {
Cities c = genPerm(rng, (int)(rng() % 12) + 1);
int ok = solve(c);
if (brute(c) != ok) bad++;
if (solve(c, false, true) != ok) noSort++;
if (solve(c, true) != ok) upper++;
}
if (!CSV) printf("① %d 组(照题面:两岸坐标各自互不相同,N ≤ 12):"
"排序 + LIS vs 2^N 枚举子集不一致 %d 组\n"
"② 忘了排序:不同 %d 组\n"
"③ 那个「一字之差」的坑(upper_bound):不同 %d 组 —— **精确的 0**\n",
groups, bad, noSort, upper);
row("perm", {groups, bad, noSort, upper});
}
/* ③ 自检:放开「位置各不相同」,upper_bound 当场就分得出来 */
{
mt19937 rng(2782u);
int rounds = 300, upper = 0;
for (int r = 0; r < rounds; r++) {
Cities c = genDup(rng, (int)(rng() % 12) + 4); // ← 违反题面:北岸会重复
if (solve(c, true) != solve(c)) upper++;
}
if (!CSV) printf("③ ⚠ 自检(放开「位置各不相同」,%d 轮):"
"upper_bound 和 lower_bound 不同 %d 轮 ⇒ 上面那个 0 不是空壳\n",
rounds, upper);
row("selfcheck", {rounds, upper});
}
/* ④ O(N²) 在顶格上是什么概念 */
{
ll n = 200000;
if (!CSV) printf("④ 顶格 N = 2×10^5:O(N²) 要算 %lld 次(4×10^10 量级),"
"而 O(N log N) 约 %lld 次 —— 差 %lld 倍\n",
n * n, (ll)(n * 18), (ll)(n / 18));
row("scale", {n * n, (ll)(n * 18), (ll)(n / 18)});
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
p2782Gen.cpp数据生成器
// P2782 对拍生成器:`./p2782Gen <seed> [N 上限] [是否允许坐标重复]`
// 默认 `N ≤ 12`、**严格照题面:两岸坐标各自互不相同**。
//
// ★ 第三个参数(默认 0)是给页面第 ④ 步用的**违反题面**的一档 ——
// 题面写着两岸城市「位置各不相同」,而这一档故意让北岸坐标出现重复。
// ⇒ 它同时干两件事:给「精确的 0」做自检,顺便称一称那句约束的重量
// (和同一轮 [P1439](/sol/p1439/) 是同一个动作)。
//
// ⚠ 默认 N 压在 12,因为参照物是 `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]) : 12;
int dup = argc > 3 ? atoi(argv[3]) : 0;
nHi = max(1, min(200000, nHi));
mt19937 rng(seed * 2654435761u + 2782u);
int n = (int)(rng() % (unsigned)nHi) + 1;
printf("%d\n", n);
if (!dup) {
vector<int> a(n), b(n);
iota(a.begin(), a.end(), 0);
iota(b.begin(), b.end(), 0);
shuffle(a.begin(), a.end(), rng);
shuffle(b.begin(), b.end(), rng);
for (int i = 0; i < n; i++) printf("%d %d\n", a[i], b[i]);
} else {
vector<int> a(n);
iota(a.begin(), a.end(), 0);
shuffle(a.begin(), a.end(), rng);
for (int i = 0; i < n; i++)
printf("%d %u\n", a[i], (unsigned)(rng() % (unsigned)max(1, n / 2))); // ← 北岸会重复
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

★ 生成器的第三个参数就是那个「违反题面」的开关(默认 0 = 严格照题面,两岸坐标各自互不相同)。

7一页纸

★★★ 关键的一步 不相交 ⟺ 两岸的大小关系一致 ⇒ 按南岸排序后求北岸的 LIS
哪一版能 AC p2782.cppO(N log N)(顶格 O(N²) 要 4 × 10¹⁰ 次,差 11 111 倍
错法一 忘了排序 —— 那一步不是优化,是把二维降成一维(167 / 300 被抓,样例放过)
★★★ 错法二 upper_bound —— 精确的 0,题面「位置各不相同」保证的(第二个这样的 0)
⚠⚠ 自检 放开那句约束,upperlower 当场不同 288 / 300 ⇒ 那个 0 不是空壳
★★ 这一章的收尾 同一个坑五个点:两个题面保证的 0 / 比值 0.005 抓 2 次 / 1.0 抓 215 次 / 2.0 必抓
「对拍抓不抓得到」要连着「哪道题、什么比值」一起说
参照物 2ᴺ 枚举子集逐对验「不相交」—— 300 组不一致 0 组