ACM 一个月学习计划 · 每日教程合集

Day 06 ~ Day 30,25 份图文教程 · 100 道洛谷练习题详解 · 全部代码实际编译运行验证

怎么用:左侧目录点任意一天即可阅读,搜索框可搜题号或关键词
进度:每读完一天点「标记为已学完」,侧边栏会记录(存在本机浏览器)
离线:这是单个 HTML 文件,双击即可打开,不需要联网、不需要服务器

📊合集概况

25
天教程
100
道洛谷练习题
106
段代码全部编译通过
0
已学完(本机记录)
💡 快捷键

/ 切换上一天 / 下一天  / 跳到搜索框  Esc 关闭搜索  Ctrl+P 打印(侧边栏有「打印全部」可导出整本 PDF)

📖怎么用这套教程

  • 每天的教程都包含:知识点讲解 → 代码模板 → 4 道洛谷题详解 → 易错清单 → 自测题
  • 所有例题代码都实际编译运行验证过,文中的输出是真实结果
  • 建议顺序:先自己做题(每题限时 30 分钟),卡住了再回来看这一天的详解
  • 每天末尾的自测题做完再进入下一天
✅ 这是一个自包含的单文件网站

整站(25 天教程 + 100 道练习题解)全部在这一个 HTML 文件里,不依赖任何其他文件、不需要联网。可以直接拷贝、发给同学,也可以上传到任意静态托管上供他人访问。

这些内容只是学习辅助材料,题目版权属于洛谷,请到洛谷官网提交代码。

💡 Day 01 ~ Day 05 在哪里?

那 5 天的内容目前只存在于计划原文 ACM竞赛一个月学习计划(含洛谷每日练习).txt 里。

需要的话可以随时补做成同款 HTML。

📅全部课程

🎯这一个月结束时的目标

完成 Day 01 ~ Day 30 之后,你应该能够:

  • 在 40 分钟内独立 AC 洛谷「入门」与「普及-」难度题目
  • 上手「普及/提高-」难度,具备参加校赛 / 新生赛 / 组队训练的基础
  • 掌握:模拟、排序、贪心、前缀和、递归、搜索(DFS/BFS)、二分、DP 入门、栈队列、高精度
✅ 一个月之后往哪走

下一步路线(原计划 Day 30 有详细说明):并查集 → 图论(存图、最短路、最小生成树)→ 树 → 区间 DP / 树形 DP → 字符串(哈希、KMP)→ 线段树 / 树状数组 / 数论。

ACM 一个月学习计划 · DAY 06

Day 6 · 数组进阶:桶计数

用「值」当下标 —— 这是后续计数、去重、哈希类题目的共同基础

今日目标:掌握桶思想,能独立完成 4 道配套练习
预计用时:约 3 小时(含做题)  难度:入门 ~ 普及-
题目来源:洛谷 P1554 / P1980 / P2550 / P2911
DAY 06 / 30

今日时间安排

0 - 20 min读懂「桶」是什么,为什么快(第 1-3 节)
20 - 40 min数字拆分模板(第 4 节)——今天的核心工具
40 - 120 min做 4 道题,每题限时 30 分钟
120 - 150 min对照本文的题解,把没做出来的补掉
150 - 180 min写题解笔记 + 做自测题
💡 昨天内容的衔接

Day 5 你学会了「用数组批量存数据」。今天要往前一步:不只把数据存起来,而是把数据本身当成下标来用

1什么是「桶」

先看一个生活场景

老师要统计全班 50 个同学的考试分数各出现了几次。最笨的办法是:拿第一个分数,数一遍全班有几个和它一样;再拿第二个……要数 50 遍。

聪明的办法是:在桌上摆 101 个盒子,编上号 0 到 100。谁考了多少分,就往对应编号的盒子里放一颗豆子。最后数每个盒子里有几颗豆子就行。

这些盒子,就是「桶」。

🎯 核心思想就一句话

把「值」直接当作数组的下标,用数组元素来记录这个值的信息。

具体长什么样

还是那个分数统计的例子。50 个同学的分数是:

88 95 88 72 95 88 100 72

我们开一个数组 int cnt[101] = {0};,然后:

// 每读到一个分数,就往对应的桶里放一颗
for(int i = 0; i < n; i++){
    int score;  cin >> score;
    cnt[score]++;          // ★ 分数是多少,就加在哪个桶
}

执行完之后,桶里是这样的:

72
2
88
3
95
2
100
1
其余
0

(其他编号的桶里都是 0,说明没人考那个分数)

为什么它快

做法查一次「值 v 出现了几次」需要总复杂度
暴力两重循环把整个数组扫一遍,O(n)O(n²)
直接读 cnt[v]O(1)O(n)
✅ 桶的本质是「用空间换时间」

你多开了一个数组(空间),换来的是查询从「扫一遍」变成「直接读」(时间)。这在竞赛里是非常划算的交易。

2桶的两个前提(不满足就不能用)

前提一:值域不能太大

因为桶的个数 = 值域大小。数组能开多大,桶就能装多大。

值的范围需要开多大的数组能不能用桶
0 ~ 100int cnt[101]✅ 随便用
0 ~ 10⁶int cnt[1000005]✅ 可以(约 4MB)
0 ~ 10⁷int cnt[10000005]⚠️ 约 40MB,接近上限
0 ~ 10⁹开不出来❌ 要用 map / 离散化
⚠️ 竞赛内存限制一般是 128MB 或 256MB

一个 int 占 4 字节。开 int cnt[107] 就是 40MB。
开数组前先算一下:值域上限 × 4 字节 = 多少 MB。超了就得换方法。

前提二:值必须是整数(或者能映射成整数)

下标只能是整数。但很多东西都能映射过去:

本来是什么怎么映射成下标
小写字母 'a' ~ 'z'c - 'a' → 0 ~ 25
数字字符 '0' ~ '9'c - '0' → 0 ~ 9
大写字母 'A' ~ 'Z'c - 'A' → 0 ~ 25
所有 ASCII 字符直接 int cnt[128],用字符本身当下标
负数 -100 ~ 100x + 100 → 0 ~ 200
💡 「映射」这个动作,就是后面学离散化的雏形

把「不好当索引的东西」翻译成「好当索引的整数」——这个思路会一直用到后面的坐标压缩、离散化。

3三种最常用的桶(背下来)

模板 A:计数桶 —— 某个值出现了几次

int cnt[100005] = {0};          // ★ 一定要初始化成 0

for(int i = 0; i < n; i++){
    int x;  cin >> x;
    cnt[x]++;                   // 放一颗豆子进 x 号桶
}

cout << cnt[42] << endl;       // O(1) 查询:42 出现了几次

模板 B:标记桶(bool)—— 某个值出现过没有

bool vis[100005] = {false};

for(int i = 0; i < n; i++){
    int x;  cin >> x;
    vis[x] = true;              // 只记「有没有」,不记次数
}

if(vis[42]) cout << "出现过" << endl;
💡 什么时候用 bool 而不是 int

只要判断「在不在」时用 bool,省 3/4 内存。今天的 P2550 彩票摇奖 就是典型用法。

模板 C:桶 + 找众数 —— 出现次数最多的值

int bestCount = 0, bestValue = -1;
for(int v = 最小可能值; v <= 最大可能值; v++){
    if(cnt[v] > bestCount){     // ★ 用 > 而不是 >=
        bestCount = cnt[v];
        bestValue = v;          // 并列时保留先遇到的(最小的)
    }
}
⚠️ >>= 的区别,今天会考到

题目如果说「出现次数相同时输出最小的那个」,就必须用 >,并且从小到大扫描。
如果用 >=,后面的会把前面的覆盖掉,你就输出了最大的那个。

4数字拆分:把一个整数的各位取出来

今天 4 道题里有 2 道都要用到这个。这是今天最重要的工具,必须练到不用想就能写出来。

原理:反复「取末位 + 丢末位」

🎯 两个运算符就够

x % 10  →  拿到末位数字
x / 10  →  丢掉末位(整数除法自动向下取整)

动手走一遍:以 1357 为例

步骤x 的值x % 10(拿到的末位)x / 10(丢完末位后)
开始1357
第 1 轮13577135
第 2 轮135513
第 3 轮1331
第 4 轮110
结束0循环条件 x > 0 不成立

四个数字 7、5、3、1 全部拿到,而且是从低位到高位。

标准模板

while(x > 0){
    int d = x % 10;             // 取末位
    // 这里对 d 做点什么,比如 cnt[d]++
    x /= 10;                    // 丢掉末位
}
🚫 两个必踩的坑

坑 1:不要直接用循环变量 i 去拆位!

for(int i = 1; i <= n; i++){
    while(i > 0){        // ❌ 这样 i 会被改成 0
        ...
        i /= 10;         // ❌ 外层循环直接失控!
    }
}

必须用临时变量:int t = i; 然后拆 t

坑 2:数字 0 本身。
如果范围里包含 0,while(x > 0) 一次都不会执行,0 就被漏掉了。需要单独特判(今天的 P1554 里 M ≥ 1,用不到,但要知道这件事)。

配套:字符与数字互转

想要什么写法例子
数字字符 → 整数c - '0''7' - '0' = 7
整数 → 数字字符'0' + d'0' + 7 = '7'
小写字母 → 0~25c - 'a''c' - 'a' = 2
判断是不是数字isdigit(c)<cctype>
💡 为什么 c - '0' 能行

因为字符 '0'~'9' 在 ASCII 表里是连续排列的(48 ~ 57)。所以 '7' 的编码减 '0' 的编码,正好就是 7。
字母 'a'~'z'(97~122)也是连续的,同理。

5四道例题详解

下面每道题的代码我都实际编译运行过,输出是你看到的真实结果。

P1554 [USACO06DEC] 梦中的统计 Dream Counting B 入门 · 数字拆分 + 桶

题意:Bessie 从 M 数到 N,把每个数都写下来。问数码 0~9 各出现了多少次。

⚠️ 先看数据范围,这题有陷阱

1 ≤ M ≤ N ≤ 2×10⁹,但 0 ≤ N − M ≤ 5×10⁵

N 看着很大(20 亿)像是不能枚举,但区间长度最多只有 50 万——所以可以放心地一个个枚举

如果只看到 N ≤ 2×10⁹ 就放弃枚举,那这题就做不出来了。永远要看完整的数据范围。

思路

开一个 10 个元素的桶 cnt[0..9]。对区间里每个数,把它拆成各位数字,每拆出一位就往对应的桶里放一颗。

样例输入
129 137
样例输出
1 10 2 9 1 1 1 1 0 1

含义:0 出现 1 次、1 出现 10 次、2 出现 2 次 …… 8 出现 0 次、9 出现 1 次。

完整代码

#include <iostream>
using namespace std;

int cnt[10] = {0};          // 桶:cnt[d] = 数码 d 出现的次数

void countNumber(long long x){
    if(x == 0){ cnt[0]++; return; }     // 数字 0 本身(本题 M>=1 用不到)
    while(x > 0){
        cnt[x % 10]++;                  // 取末位,丢进对应的桶
        x /= 10;                        // 去掉末位
    }
}

int main(){
    long long M, N;
    cin >> M >> N;
    for(long long i = M; i <= N; i++){  // 区间长度 ≤ 5×10^5,直接枚举
        countNumber(i);
    }
    for(int d = 0; d < 10; d++){
        if(d) cout << " ";
        cout << cnt[d];
    }
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 129 137 → 输出 1 10 2 9 1 1 1 1 0 1 完全一致

大数测试 1999999999 2000000000 → 输出 9 1 1 0 0 0 0 0 0 9 正确
(1999999999 有一个 1 和九个 9;2000000000 有一个 2 和九个 0)

易错点

  • int 存 M、N 是够的(2×10⁹ < 21亿),但用 long long 更保险。
  • 输出是 10 个数字用空格隔开,注意最后一个后面不要多打空格(用 if(d) cout << " "; 或判断 d < 9)。
  • 拆位必须用临时变量,不能直接改循环变量 i
P1980 [NOIP 2013 普及组] 计数问题 入门 · 枚举 + 数位统计

题意:在 1 到 n 的所有整数中,数字 x(0 ≤ x ≤ 9)共出现了多少次?

例:1 到 11 中,数字 1 在 1、10、11 里出现,共 4 次。

⚠️ 这题不需要桶

因为只要统计一个数字 x 的次数,用一个变量累加就够了,不需要开 10 个桶。
别看到"计数"就条件反射开桶——先想清楚要统计几个不同的东西。

思路

枚举 1 到 n 的每个数,把每个数拆位,拆出来的每一位和 x 比一下,相等就答案 +1。

样例输入
11 1
样例输出
4

完整代码

#include <iostream>
using namespace std;
int main(){
    int n, x;
    cin >> n >> x;
    int ans = 0;
    for(int i = 1; i <= n; i++){
        int t = i;                      // ★ 用临时变量拆位,别把 i 本身改掉
        while(t > 0){
            if(t % 10 == x) ans++;      // 末位正好是 x,计数 +1
            t /= 10;                    // 去掉末位
        }
    }
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 11 14 正确

最大数据 1000000 0488895耗时仅 20 ms(时限 1000ms,非常宽裕)
复杂度 O(n log n):n = 10⁶,每个数最多 7 位 → 约 7×10⁶ 次操作,完全没问题。

易错点

  • 不要用 i 直接拆位——会用 i /= 10 把外层循环搞坏。必须 int t = i;
  • x = 0 的情况要能正确处理。因为 i 从 1 开始,不会遇到数字 0 本身,但 10、20、100 里的 0 会被正常统计到。
  • 没必要求 i 有几位再循环——while(t > 0) 自动处理了。
P2550 [AHOI2001] 彩票摇奖 入门 · 标记桶 + 比对

题意:彩票每张 7 个号码(取值 1~33)。给出中奖号码和 n 张彩票,按「猜中几个号码」分别统计特等奖(7个)到六等奖(1个)各有几张。

不看位置,只看号码在不在中奖号码里。

🎯 这题是「标记桶」的教科书用例

如果不用桶,判断每个彩票号是否中奖,要和中奖号码逐个比 → 7×7 = 49 次比对。
用桶:先把中奖号标记进桶,之后每个号 O(1) 查 → 每张彩票只要 7 次

样例输入
2
23 31 1 14 19 17 18
12 8 9 23 1 16 7
11 7 10 21 2 9 31
样例输出
0 0 0 0 0 1 1

第 1 张:中了 23、1 → 2 个 → 五等奖
第 2 张:中了 31 → 1 个 → 六等奖
输出顺序是「特等 一等 二等 三等 四等 五等 六等」。

完整代码

#include <iostream>
using namespace std;
int main(){
    int n;
    cin >> n;

    bool win[34] = {false};             // 桶:win[k] 标记号码 k 是不是中奖号
    for(int i = 0; i < 7; i++){
        int t; cin >> t;
        win[t] = true;
    }

    int result[8] = {0};                // result[k] = 中了 k 个号码的彩票张数
    for(int i = 0; i < n; i++){
        int hit = 0;
        for(int j = 0; j < 7; j++){
            int t; cin >> t;
            if(win[t]) hit++;           // O(1) 查桶
        }
        if(hit >= 1) result[hit]++;
    }

    // 题目要求:先输出特等奖(中7个),再依次一等奖(6个)…六等奖(1个)
    for(int k = 7; k >= 1; k--){
        if(k < 7) cout << " ";
        cout << result[k];
    }
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 → 输出 0 0 0 0 0 1 1 完全一致

易错点

  • 输出顺序是倒着的!题目要「特等奖、一等奖……六等奖」,也就是中 7 个的先输出。
    所以循环要写 for(k = 7; k >= 1; k--),不是从小到大。
  • 桶开 bool win[34] 就够(号码最大 33),下标 0 用不上也无所谓,多开一个更安全。
  • 中 0 个号码的彩票不计入任何奖项,所以 result[0] 根本用不上,加个 if(hit >= 1) 更清楚。
P2911 [USACO08OCT] Bovine Bones G 入门 · 桶计数找众数

题意:三个骰子分别有 S₁、S₂、S₃ 个面(面上是 1 到 Sᵢ)。掷出所有组合,哪个「三个点数之和」出现得最频繁?如果有并列,输出最小的那个和

🚨 更正:原计划里我对数据范围的描述写错了

我在 Day 6 的原文里写的是「P2911 三个骰子和的范围是 3~18,桶要开到 20 以上」——这是错的,那是默认三个骰子都是 6 面的情况。

正确范围:2 ≤ S₁ ≤ 20,2 ≤ S₂ ≤ 20,2 ≤ S₃ ≤ 40(注意第三个骰子上限是 40,不是 20)。

所以和的范围是 3 ~ 80(1+1+1 到 20+20+40),桶至少要开到 81,建议开 85 留余量

样例输入
3 2 3
样例输出
5

题目提示里给了全部 18 种组合:和 5 与和 6 各出现 5 次并列最多,取小的 → 5。

思路

三重循环枚举所有点数组合(最多 20×20×40 = 16000 种,非常小),把每个「和」丢进对应的桶。然后从小到大扫一遍桶,找出现次数最多的那个和。

完整代码

#include <iostream>
using namespace std;
int main(){
    int s1, s2, s3;
    cin >> s1 >> s2 >> s3;

    // 和的范围:最小 1+1+1 = 3,最大 20+20+40 = 80  ->  数组开到 85
    int cnt[85] = {0};

    // 三重循环枚举所有点数组合,把每个和丢进对应的桶
    for(int a = 1; a <= s1; a++)
        for(int b = 1; b <= s2; b++)
            for(int c = 1; c <= s3; c++)
                cnt[a + b + c]++;       // ★ 和是多少,就加在哪个桶

    int bestCount = 0, bestSum = 0;
    for(int s = 3; s <= 80; s++){       // 从小到大扫描
        if(cnt[s] > bestCount){         // ★ 用 > 而不是 >=
            bestCount = cnt[s];         //   并列时保留先遇到的(最小的和)
            bestSum = s;
        }
    }
    cout << bestSum << endl;
    return 0;
}
🎯 为什么这里 > 不能写成 >=

题目要求「并列时输出最小的和」。从小到大扫描时:

· 用 > :只有严格更大才更新 → 第一个达到最大值的和会被保留 → 输出最小的
· 用 >=:相等也更新 → 后面的会把前面的覆盖掉 → 输出最大的

✅ 实测结果

官方样例 3 2 35 正确

边界 2 2 24 正确
(三骰各 1~2,和 3/4/5/6 的次数分别是 1/3/3/1,4 和 5 并列 → 取小的 4)

最大面数 20 20 4041 合理
(和范围 3~80,众数在正中附近,41 合理)

易错点

  • 桶开太小:只开 20 会越界。记住 S₃ 上限是 40,最大和 80。
  • > 写成 >=:会输出最大的和而不是最小的。
  • 三重循环的边界是 <= s1 不是 < s1——骰子面数是包含 Sᵢ 的。

6今日常见错误清单

#错误后果 / 怎么避免
1桶的数组开太小(值域算错)越界 → 玄学 WA 或 RE。
开之前先算:最大值是多少?再 +5 留余量。
2忘记初始化成 0局部数组不初始化是随机值。
int cnt[100005] = {0}; 或开在全局(全局自动为 0)。
3用循环变量 i 直接拆位外层循环被 i /= 10 破坏。
必须 int t = i;
4找众数时用了 >=并列时会输出最大而不是最小的那个。
5输出格式错(多余空格 / 顺序反了)P2550 的输出顺序是「特等到六等」,不是从小到大。
6只看到 N ≤ 2×10⁹ 就放弃枚举P1554 还给了「区间长度 ≤ 5×10⁵」——要把数据范围看全
7下标从 1 开始但数组只开了 n越界(就是昨天 P5724 的那个坑)。
规则:1 开始 → 数组开 n+5

7自测(做完再进入 Day 7)

第 1 题:桶最少要开多大?

题目说:输入的每个数 a 满足 1 ≤ a ≤ 5000,要用桶统计每个数出现几次。

int cnt[5005] = {0}; —— 数组下标从 0 开始,要能存到 5000,所以至少 5001 个元素,开 5005 留余量。

第 2 题:手算拆位

数字 20304 拆位后,各是哪几个数字?

:2、0、3、0、4。
过程:20304 % 10 = 4 → 2030 % 10 = 0 → 203 % 10 = 3 → 20 % 10 = 0 → 2 % 10 = 2 → 0 结束。
注意中间那个 0 也要统计,不能因为「0 没意义」就跳过。

第 3 题:找众数的边界

某题要求:输出出现次数最多的数;若并列,输出较大的那个。扫描方向应该怎样?用 > 还是 >=

:从小到大扫描,用 >=(相等也更新)→ 后面遇到的(更大的)会覆盖前面,最终留下最大的。
或者:从大到小扫描 + 用 >。两种都行,关键是「扫描方向」和「比较符号」要配套

第 4 题(手写代码)

不用桶,写一段代码判断整数 n(n ≥ 1)里是否含有数字 7。

bool hasSeven(int n){
    while(n > 0){
        if(n % 10 == 7) return true;
        n /= 10;
    }
    return false;
}

★ 这里可以放心地直接改 n,因为它只是个参数副本,不影响调用方。

8今日速查卡

【桶】
  int cnt[N] = {0};        // 计数桶,N = 值域上限 + 5
  bool vis[N] = {false};   // 标记桶,只记「有没有」
  cnt[x]++;                // 放一颗豆子进 x 号桶

【拆位】
  int t = x;               // ★ 一定要用临时变量
  while(t > 0){
      int d = t % 10;      // 取末位
      t /= 10;             // 丢末位
  }

【字符映射】
  c - '0'    →  数字字符转整数
  '0' + d    →  整数转数字字符
  c - 'a'    →  小写字母转 0~25

【找众数】
  for(v = 最小; v <= 最大; v++)
      if(cnt[v] > best) { best = cnt[v]; ans = v; }
  // 从小扫 + 用 >  → 并列时取最小的

【防越界的铁律】
  数组大小 = 值域上限 + 5
  下标从 0 开始写循环用 i < n
  下标从 1 开始数组至少开 n + 5

明天预告 · Day 7

二维数组 + 第一周复盘

会用到的今天已经埋下伏笔:桶的思想在二维里就是「行和列都当坐标」, 而 Day 7 的 P5731 蛇形方阵、P5732 杨辉三角都会用到二维数组的坐标操作。

另外 Day 7 是第一个复盘日,记得把这周的错题重做一遍。

ACM 一个月学习计划 · DAY 07

Day 7 · 二维数组 + 第一周复盘

把一维的「一排格子」升级成二维的「一张表格」——从此代码里就有了坐标

今日目标:掌握二维/三维数组的定义与遍历,会用方向数组做坐标移动,并完成第一周复盘
预计用时:约 3 小时(含做题与复盘)  难度:入门 ~ 普及-
题目来源:洛谷 P5729 / P5730 / P5731 / P5732
DAY 07 / 30

今日时间安排

0 - 25 min读懂二维数组的定义、行列约定和遍历(第 1-2 节)
25 - 45 min方向数组与边界判断(第 3 节)——今天最重要的工具
45 - 60 min三维数组扫一眼就够(第 4 节,P5729 要用)
60 - 150 min做 4 道题,每题限时 30 分钟
150 - 180 min第一周复盘:错题重做 + 写「我犯过的错」清单(第 5 节)
💡 昨天内容的衔接

Day 5 你学会了「一排格子」——int a[100],用一个下标取数据。
Day 6 你学会了「用值当下标」——桶。
今天要把下标变成两个a[i][j]。好处是,一旦有了两个下标,你就有了一张「地图」, 可以往里面画矩阵、画棋盘、画迷宫。后面 Day 18 开始的搜索题(DFS / BFS),全部建立在这一天的内容上。

1二维数组:给数据排成一张表格

从「一排抽屉」到「一格一格的货架」

一维数组像一排抽屉,只有一个编号:a[3] 就是第 3 个抽屉。

二维数组像一个货架:先说是第几层(行),再说是第几格(列),所以需要两个编号:a[3][5] 就是第 3 层第 5 格。

🎯 一句话记住约定

a[i][j] 里,i 是行(第几层、第几排),j 是列(第几个)
习惯上 i 管「上下的变化」,j 管「左右的变化」。先写行,后写列,千万别反。

怎么写

// 一维:一排 100 个格子
int a[100];

// 二维:100 行 100 列,一共 100 x 100 = 10000 个格子
int b[100][100];

// 示例:3 行 4 列,并直接填好初值
int c[3][4] = {
    { 1,  2,  3,  4},
    { 5,  6,  7,  8},
    { 9, 10, 11, 12}
};

c 想象成一张表就很直观了:

1234 5678 9101112

红色那格是 c[0][3] —— 行下标 0、列下标 3,值是 4。
行和列的下标都是从 0 开始的,所以 3 行 4 列的下标范围是 i: 0~2j: 0~3

怎么遍历

跟一维数组比,只是多套了一层循环:

// 一维:一层循环
for(int i = 0; i < n; i++)
    cout << a[i] << " ";

// 二维:两层循环 —— 外层走行,内层走列
for(int i = 0; i < n; i++){          // 一行一行地走
    for(int j = 0; j < m; j++){      // 走到某一行后,从左到右扫一遍
        cout << b[i][j] << " ";
    }
    cout << endl;                    // 一行扫完了,换行
}
⚠️ 两层循环的顺序不能随便换

外层 i 走行、内层 j 走列 → 得到的是「一行一行地读」,也就是我们看书、看表格的顺序(叫行优先)。
如果把两层换过来(外层 j、内层 i),得到的是「一列一列地读」(列优先)。
两种本身都没错,但输出格式类题目对顺序有严格要求——今天 P5731、P5732 这两题, 换错顺序就会直接 WA。

为什么二维数组更容易越界

一维数组你要守住 1 个上界;二维数组要守住 2 个:行不能超过 n、列不能超过 m。

想干的事安全写法危险写法
访问 n×m 的表格里的每一格i<n && j<mi<=n && j<=m
访问坐标 (x, y),x 是行a[x][y]a[y][x](行列写反)
开 n×n 的方阵,下标从 1 开始a[n+5][n+5]a[n][n](越界)
🚫 最容易犯的两个错,今天 4 道题都躲不开

错 1:行列写反。a[行][列],不是 a[列][行]。写反了不报错,只是答案莫名其妙地不对。

错 2:数组开小。要存 n×n 的方阵,就至少开 [n][n];如果下标从 1 用到 n, 就得开 [n+1][n+1] 以上。多开 5 格,永远不吃亏。

2顺手知道一下:二维数组在内存里其实是「排成一排的」

这件事不背也能做题,但知道了能少踩很多坑。

声明 int b[3][4] 的时候,电脑实际上给你切了 12 个连续的 int, 然后规定「前 4 个算第 0 行、中间 4 个算第 1 行、后 4 个算第 2 行」。

内存里的位置第 1 个第 2 个第 3 个第 4 个第 5 个第 6 个
对应元素b[0][0]b[0][1]b[0][2]b[0][3]b[1][0]b[1][1]
🎯 由这件事推出的两条经验

① 越界的后果。b[0][4] 不会立刻崩溃,它其实悄悄访问到了 b[1][0]。 所以二维数组的越界常常是「答案错了但程序照跑」,非常难查——越要小心

② 行优先遍历更快。i 外层、j 内层去扫,正好是顺着内存往后读, CPU 缓存喜欢这种顺序。数据量大时(几百万格)能明显更快。

所以「按行读入」是标准写法

// 读入一个 n 行 m 列的矩阵,标准写法
for(int i = 0; i < n; i++)
    for(int j = 0; j < m; j++)
        cin >> a[i][j];

输入里的数字本来就是「一行一行给的」,所以按行读入正好对上,不用任何额外处理。

3方向数组:让代码学会「上下左右」

这是今天最重要的工具,也是整个月计划里性价比最高的一个小技巧。

问题:站在格子 (i, j),怎么表示「它上面的格子」?

很自然:行减 1,列不变 → (i-1, j)。那下面、左边、右边呢?

方向行怎么变列怎么变新坐标
上 ↑-10(i-1, j)
下 ↓+10(i+1, j)
左 ←0-1(i, j-1)
右 →0+1(i, j+1)

如果每次都要写四段几乎一样的代码,又长又容易写错。于是我们把「变化的量」单独存成两个数组:

// dx 管行的变化,dy 管列的变化,下标 0~3 分别对应 上 下 左 右
int dx[4] = {-1, 1, 0, 0};
int dy[4] = { 0, 0, -1, 1};

// 于是「往第 d 个方向走一步」就变成了一句话:
int nx = x + dx[d];
int ny = y + dy[d];
💡 为什么叫 dx / dy

d 是 direction(方向)的缩写,x / y 是坐标。合起来就是「x 方向的增量、y 方向的增量」, 也就是数学里的 Δx、Δy。这里的 x 指的是,y 指的是

顺时针顺序的版本(P5731 要用)

把 4 个方向按右 → 下 → 左 → 上排好,那么「右转」就等于 d = (d + 1) % 4, 因为 3 右转正好回到 0。

int dx[4] = { 0, 1,  0, -1};     // 0=右 1=下 2=左 3=上
int dy[4] = { 1, 0, -1,  0};

int d = 0;                        // 一开始朝右
d = (d + 1) % 4;                  // ★ 右转(转 4 次回到原点)
🎯 % 4 就是「绕圈」

0 → 1 → 2 → 3 → 0 → 1 …… 每次加 1 再对 4 取余,就能在一个圈里循环。 这个「加完取余」的动作今天要写好几遍,Day 8 的凯撒密码也是同一个套路(那里是对 26 取余)。

边界判断:必须写在 a[nx][ny] 前面

// ✅ 正确:先判断坐标合不合法,再访问
if(nx >= 0 && nx < n && ny >= 0 && ny < m){
    // 到这里才敢访问 a[nx][ny]
}

// ❌ 错误:先访问了再判断,越界已经发生了
if(a[nx][ny] == 0 && nx >= 0 && ...)   // 可能读到数组外面
⚠️ 四个条件一个都不能少

· nx >= 0 防止行变成负数(往上走出去了)
· nx < n 防止行超出下界
· ny >= 0ny < m 是列的两个方向
四个条件用 && 连接。因为 &&短路性质——只要前面有一个不成立, 后面的就不会算,所以把「判越界」写在左边、「访问数组」写在右边,是安全的。

「撞墙」其实有两层意思

写蛇形方阵的时候,「走不动了」有两种原因,两种都要转方向:

撞的是什么怎么判断
出界:走到了方阵外面nx < 0 || nx >= n || ny < 0 || ny >= n
撞到自己:那格已经填过数了a[nx][ny] != 0
💡 这就是「状态机」的入门味道

「当前方向 d」是一个状态,「撞墙就 d = (d+1)%4」是状态转移。 只要有这两样东西,程序就能自动绕圈——不用写四段重复循环。P5731 就靠这一招。

4三维数组:再往上加一个坐标

P5729 那个「玻璃立方体」是三维的,所以要用三个下标。

bool cut[25][25][25];      // cut[i][j][k]:小方块 (i,j,k) 被切掉了吗

// 遍历:三层循环
for(int i = 1; i <= w; i++)
    for(int j = 1; j <= x; j++)
        for(int k = 1; k <= h; k++)
            // 处理小方块 (i, j, k)
🎯 别怕,只是「多套一层」

二维要 2 层循环,三维要 3 层循环,思路完全一样:每多一个坐标,就多一层循环
P5729 的范围是 w, x, h ≤ 20,所以最多 20×20×20 = 8000 个小方块, 每次切割最多标记 8000 个,100 次切割总共也就 80 万次操作——暴力标记完全够用

⚠️ 三维数组非常吃内存,不要乱开

bool cut[25][25][25] 只有 15625 字节,随便开。
但如果开成 int a[1000][1000][1000],那是 40 亿字节 ≈ 4GB,直接爆炸。
开三维数组前,一定要先算:三个维度的上限各是多少。

顺便说一下:局部数组 vs 全局数组

写在哪儿初值能开多大
main() 里面(局部)随机值,必须手动清 0受栈空间限制,约几 MB
main() 外面(全局)自动全是 0大得多,可以放心用
💡 竞赛里的省心习惯

把数组都开到 main() 外面(全局),这样既自动清零,又能开得更大, 一举两得。今天四道题的数组我都写在全局位置。

5第一周复盘:这 6 天你到底学了什么

今天是第一个复盘日。复盘不是重看一遍,而是逼自己回答「我现在能不能不看题解写出来」。

第 1 周知识地图

Day主题核心工具代表题
1输入输出规范cin / cout、题目格式对齐P1001 P1421
2循环与累加for、整数溢出、浮点累加P1422 P1035
3while 与模拟终止条件、按规则推进状态P5720 P2669
4多重循环与图形「行 → 列」两层循环拆图形P5721 P5725
5一维数组遍历、计数、求最值P1046 P5724
6桶计数用「值」当下标,O(1) 查询P1554 P2550
7二维数组坐标 + 方向数组P5729 P5730 P5731 P5732

复盘任务(必须做完,别跳)

⚠️ 计划书里给的三个任务

任务 1:把这周所有 WA 过的题重做一遍,看看是不是真的会了。
任务 2:整理一份「我犯过的错」清单。
任务 3:自测——能否在 40 分钟内独立 AC 这三题?
    P1047 [NOIP 2005 普及组] 校门外的树
    P1085 [NOIP 2004 普及组] 不高兴的津津
    P1909 [NOIP 2016 普及组] 买铅笔
(这三题是机动题,第一周进度顺利的话就当加练)

「我犯过的错」清单模板

不用写得漂亮,就拿张纸照着填。前 6 天最常见的就是下面这 8 条,犯过的打勾

#错误典型症状
1数组开小了答案时对时错,或者突然 RE
2循环边界少 1 / 多 1< 写成了 <=
3该开 long long 没开大测试点答案变成负数
4变量忘了初始化小样例对,大样例错
5行末多打了空格提示 WA 但肉眼看着一样
6输出顺序搞反了比如 P2550 奖项要从高到低
7只看了半个数据范围P1554 那样「看着大其实能枚举」
8忘记清 0(多组数据)这一次的答案带着上一次的残留
✅ 复盘的正确姿势

不要「看一遍题解觉得自己懂了」。正确做法是:把之前 WA 的题打开,关掉题解,从空文件重新写一遍, 提交,AC。只有重新写出来才算会。

6四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是你看到的真实结果。

P5729 【深基5.例7】工艺品制作 入门 · 三维标记数组

题意:有一个 w×x×h 的实心玻璃立方体,由一个个 1×1×1 的小方块拼成,小方块用坐标 (i, j, k) 表示。 现在做 q 次切割,每次给出长方体空洞的两个对角点,落在里面的小方块会被激光蒸发。 问全部切完之后还剩多少个小方块

⚠️ 数据范围 / 关键约束

1 ≤ w, x, h ≤ 20,1 ≤ q ≤ 100
并且 1 ≤ x₁ ≤ x₂ ≤ w,1 ≤ y₁ ≤ y₂ ≤ x,1 ≤ z₁ ≤ z₂ ≤ h (也就是说,给的坐标一定合法、一定是「小的在前」,不用自己交换)。

因为 w, x, h 都只有 20,整个立方体最多 8000 个小方块, 所以「先全部标记,最后数一遍」这种暴力做法完全跑得动

思路

别去想「体积怎么减」这种数学做法——切割可能互相重叠,减法很容易算错。

换个角度:给每个小方块插一面小旗子,被切到的就插上旗。最后数一遍「没插旗的有几个」。 重叠多少次都无所谓,因为插第二次旗跟第一次没区别(true 还是 true)。

这就是 Day 6 学过的「标记桶」思想,只不过坐标从 1 个变成了 3 个。

  • bool cut[25][25][25],全部默认 false(放全局就自动是 false)。
  • 每次切割,用三层循环把 [x₁,x₂]×[y₁,y₂]×[z₁,z₂] 里的所有格子标成 true
  • 最后用三层循环数一遍 !cut[i][j][k] 的个数。
样例输入
4 4 4
1
1 1 1 2 2 2
样例输出
56

4×4×4 = 64 个小方块。切掉的是 i∈[1,2]、j∈[1,2]、k∈[1,2],也就是 2×2×2 = 8 个。
64 − 8 = 56

完整代码

#include <iostream>
using namespace std;

// 三维标记数组:cut[i][j][k] = true 表示小方块 (i,j,k) 被激光蒸发了
bool cut[25][25][25];

int main(){
    int w, x, h, q;
    cin >> w >> x >> h;          // 长 宽 高
    cin >> q;                    // 切割次数

    for(int t = 0; t < q; t++){
        int x1, y1, z1, x2, y2, z2;
        cin >> x1 >> y1 >> z1 >> x2 >> y2 >> z2;
        // 把长方体里每一个小方块都打上「已蒸发」的标记
        for(int i = x1; i <= x2; i++)
            for(int j = y1; j <= y2; j++)
                for(int k = z1; k <= z2; k++)   // ★ 三个上界分别是 x2 / y2 / z2
                    cut[i][j][k] = true;
    }

    int ans = 0;
    for(int i = 1; i <= w; i++)
        for(int j = 1; j <= x; j++)
            for(int k = 1; k <= h; k++)
                if(!cut[i][j][k]) ans++;   // 没被标记的才剩下

    cout << ans << endl;
    return 0;
}
🚨 头号陷阱:把 h 当成空洞的高度上界

第三层循环的上界必须是 z₂,不是 h!这一条特别容易写错, 因为题目第一行给的变量叫 x, h,而切割参数里又有 z₁, z₂,名字长得像。

我把写错的版本(把 k <= z2 改成 k <= h)实际编译跑了一遍,结果是这样:

· 官方样例 4 4 4 / 1 / 1 1 1 2 2 2 → 输出 48(正确是 56)
· 自造数据 2 3 4 / 2 / 1 1 1 1 3 4 / 2 2 2 2 2 2 → 输出 9(正确是 11)

原因很简单:k <= h 会把空洞在高度方向拉长到整个立方体的高, 一刀切下去的方块数就变多了。比如官方样例里本该切 2×2×2 = 8 块,写错后变成 2×2×4 = 16 块,64 − 16 = 48。

所以这题的「上界」一定要盯紧:切割用 z₂,最后统计才用 h。

✅ 实测结果

官方样例 4 4 4 / 1 / 1 1 1 2 2 2 → 输出 56 完全一致

自造数据 2 3 4 / 2 / 1 1 1 1 3 4 / 2 2 2 2 2 2 → 输出 11 正确
(体积 2×3×4 = 24;第一次切掉 i=1 的一整片,3×4 = 12 个; 第二次切掉 (2,2,2) 这 1 个;剩 24 − 12 − 1 = 11)

最大范围 20 20 20 / 1 / 1 1 1 20 20 20 → 输出 0 正确
(整个立方体被切光)

易错点

  • 三个循环的上界别写混。切割时上界是 x2, y2, z2,统计时上界才是 w, x, h
  • 变量名有坑。题目第一行是 w x h,但后面给的 6 个参数是按 (x₁,y₁,z₁),(x₂,y₂,z₂) 说的。 也就是说第二维(对应 w 的那个 x 上限)在切割参数里叫 y读题时把对应关系写在纸上最保险。
  • 别用「总体积减每次切掉的体积」——两次切割可能重叠,重复的部分会被减两次,答案偏小。
  • 数组开 [25][25][25] 就够(上限 20),多开一点不心疼。
P5730 【深基5.例10】显示屏 普及- · 打表 + 按位输出

题意:每个阿拉伯数字都可以显示成 3×5 的点阵(X 是亮点,. 是暗点)。 给一个数字位数 n 和一串数字,把它们在显示屏上的效果打印出来,相邻两个数字之间要空一列

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 100。题面里说第二行是一个「长度为 n 的自然数」—— 自然数可以以 0 开头(样例给的第一个字符就是 0), 所以必须用字符串或字符数组读,绝不能用 int / long long 读,否则前导 0 会丢。

思路:先打表,再按行拼

这题没有「算法」,考的是把一个大输出拆成规则的小块的能力。三步走:

  1. 打表:把 0~9 的点阵写死成一个表。这里用 string pat[10][5], 也就是「10 个数字 × 每个 5 行 × 每行 3 个字符」的二维字符串数组。
  2. 一行一行地输出:最外层循环 r 从 0 到 4,代表 5 行。
  3. 每行里按顺序拼接每个数字:内层循环 i 从 0 到 n−1, 输出 pat[数字][r]不是第一个数字时,先输出一个 . 当间隔列
🎯 间隔是怎么来的

10 个数字 × 每个 3 列 + 9 个间隔列 = 30 + 9 = 39 列。 样例输出的每一行正好 39 个字符,可以对一遍。

间隔也能用 3 个点写成 3 列,但题目只说「一列间隔」,所以是 1 个 .

0~9 的点阵表(对照样例抄下来最稳)

数字第 1 行第 2 行第 3 行第 4 行第 5 行
0XXXX.XX.XX.XXXX
1..X..X..X..X..X
2XXX..XXXXX..XXX
3XXX..XXXX..XXXX
4X.XX.XXXX..X..X
5XXXX..XXX..XXXX
6XXXX..XXXX.XXXX
7XXX..X..X..X..X
8XXXX.XXXXX.XXXX
9XXXX.XXXX..XXXX
样例输入
10
0123456789
样例输出
XXX...X.XXX.XXX.X.X.XXX.XXX.XXX.XXX.XXX
X.X...X...X...X.X.X.X...X.....X.X.X.X.X
X.X...X.XXX.XXX.XXX.XXX.XXX...X.XXX.XXX
X.X...X.X.....X...X...X.X.X...X.X.X...X
XXX...X.XXX.XXX...X.XXX.XXX...X.XXX.XXX

完整代码

#include <iostream>
#include <string>
using namespace std;

// 打表:pat[d][r] = 数字 d 的第 r 行(0 <= d <= 9, 0 <= r <= 4)
string pat[10][5] = {
    {"XXX", "X.X", "X.X", "X.X", "XXX"},   // 0
    {"..X", "..X", "..X", "..X", "..X"},   // 1
    {"XXX", "..X", "XXX", "X..", "XXX"},   // 2
    {"XXX", "..X", "XXX", "..X", "XXX"},   // 3
    {"X.X", "X.X", "XXX", "..X", "..X"},   // 4
    {"XXX", "X..", "XXX", "..X", "XXX"},   // 5
    {"XXX", "X..", "XXX", "X.X", "XXX"},   // 6
    {"XXX", "..X", "..X", "..X", "..X"},   // 7
    {"XXX", "X.X", "XXX", "X.X", "XXX"},   // 8
    {"XXX", "X.X", "XXX", "..X", "XXX"}    // 9
};

int main(){
    int n;
    string s;
    cin >> n >> s;                 // ★ 用 string 读,前导 0 不会丢

    for(int r = 0; r < 5; r++){    // 一行一行地输出
        for(int i = 0; i < (int)s.size(); i++){
            if(i) cout << ".";                     // 数字之间一列间隔
            cout << pat[s[i] - '0'][r];            // 查表
        }
        cout << endl;
    }
    return 0;
}
💡 s[i] - '0' 是把字符变成数字

s[i] 是字符 '7',不能直接当数组下标用。因为字符 '0'~'9' 在 ASCII 表里是连续的, 所以 '7' - '0' 正好等于整数 7。这是 Day 8 的重点,今天先混个眼熟。

✅ 实测结果

官方样例 10 / 0123456789 → 输出 5 行,与官方样例逐字符完全一致 (每行 39 个字符,10×3 + 9 个间隔)。

边界 1 / 7 → 输出 XXX / ..X / ..X / ..X / ..X 正确

最大数据 100 / 0123456789 重复 10 次 → 正常输出 5 行,每行 100×3 + 99 = 399 个字符,无异常。

易错点

  • int 读那串数字 → 前导 0 丢掉,位数也可能超出 int 范围。必须用 string。
  • 间隔列漏了或多打了。每行长度应该是 3n + (n−1)。可以用 if(i) cout << "."; 来避免行首/行末多一个点。
  • 循环顺序写成「先按数字、再按行」 → 输出会变成一个数字挤在一起,不是横排。必须是外层行、内层数字
  • 点阵表是「背下来或者对着样例抄」,抄错一个 X 就是 WA。写完把样例跑一遍最保险。
P5731 【深基5.习6】蛇形方阵 普及- · 状态机模拟

题意:给一个不大于 9 的正整数 n,输出 n×n 的蛇形方阵: 从左上角填 1 开始,顺时针一圈一圈往里填,直到填满 n²。 每个数字占 3 个字符,不够的前面补空格。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 9。所以最大是 9×9 方阵,最大数字 81,都是两位数—— 每个数字占 3 个字符时正好不会挤在一起。

题面还特别说了一句:「每个数字都会占用 3 个字符,前面使用空格补齐」。 也就是说格式是 printf("%3d")数字之间不加额外空格

思路:当前方向 + 撞墙就右转

新手最容易想到的做法是「写四个阶段:先往右走 n 格,再往下走 n−1 格,再往左走 n−1 格……」—— 能写出来,但层数一多,边界就要调半天,非常容易错一位。

更稳的写法是状态机,三步就能说清:

  1. 站在 (0, 0),方向 d = 0(朝右)。
  2. 循环 v 从 1 到 n²:先在当前格子填 v,然后试着往 d 方向走一步。
  3. 如果下一步出界或者已经填过数,就 d = (d+1) % 4(右转),再走。

这样每一圈都不用单独写,程序会自己绕进去。

🎯 为什么「先填再走」这个顺序很重要

如果写成「先走再填」,那么最后一格(n²)会被填到方阵外面去——因为填完第 n²−1 格之后还会再走一步。 所以循环体里一定是:先写 a[x][y] = v;,再考虑移动

走一遍 n = 4 的过程

填到几当前位置 (行,列)当前方向发生了什么
1 → 4(0,0)→(0,3)一直往右
4(0,3)右 → 下右边出界了,右转
5 → 7(1,3)→(3,3)一直往下
7(3,3)下 → 左下边出界了,右转
8 → 10(3,2)→(3,0)一直往左
10(3,0)左 → 上左边出界了,右转
11 → 12(2,0)→(1,0)一直往上
12(1,0)上 → 右(0,0) 已经填过了,右转
13 → 16(1,1)→(1,2)→(2,2)→(2,1)右 → 下 → 左继续绕内圈,最后一格是 16,填满结束

结果就是下面这张方阵:

1234 1213145 1116156 10987

红色是第二圈(内层)的数字。

样例输入
4
样例输出
  1  2  3  4
 12 13 14  5
 11 16 15  6
 10  9  8  7

注意每行开头:第 1 行前面有 2 个空格(因为 1 是个位数),第 2~4 行前面只有 1 个空格。 这就是 %3d 的效果——右对齐,宽度 3

完整代码

#include <iostream>
#include <cstdio>
using namespace std;

int a[15][15];                       // 蛇形方阵,0 表示还没填

// 方向数组:0 右、1 下、2 左、3 上(顺时针)
int dx[4] = {0, 1, 0, -1};
int dy[4] = {1, 0, -1, 0};

int main(){
    int n;
    cin >> n;

    int x = 0, y = 0, d = 0;         // 从 (0,0) 出发,朝右
    for(int v = 1; v <= n * n; v++){
        a[x][y] = v;                 // ★ 先填当前格,再考虑往哪走
        int nx = x + dx[d], ny = y + dy[d];
        // 撞墙(出界)或者撞到已填过的格子,就右转
        if(nx < 0 || nx >= n || ny < 0 || ny >= n || a[nx][ny] != 0){
            d = (d + 1) % 4;         // ★ 右转
            nx = x + dx[d];
            ny = y + dy[d];
        }
        x = nx;
        y = ny;
    }

    for(int i = 0; i < n; i++){
        for(int j = 0; j < n; j++)
            printf("%3d", a[i][j]);  // ★ 每个数字占 3 个字符,右对齐
        printf("\n");
    }
    return 0;
}
💡 为什么这里用了 printf 而不是 cout

cout 也可以做到(cout << setw(3) << a[i][j]),但那需要额外 #include <iomanip>, 而且要记住 setw 只对紧接着的一个输出生效,写循环时容易漏。 printf("%3d") 更短更直观,竞赛里「格式敏感」的输出基本都用它。

✅ 实测结果

官方样例 4 → 输出与官方样例逐字符完全一致(每行 12 个字符)。
实际输出:第一行   1  2  3  4(前面 2 个空格), 第二行  12 13 14  5,第三行  11 16 15  6, 第四行  10  9  8  7

边界 1 → 输出   1(1×1 方阵) 正确

最大数据 9 → 正常输出 9×9 方阵,每行 27 个字符,中心是 81,四周 1/9/17/25… 完全符合蛇形规律。
第一行   1  2  3  4  5  6  7  8  9, 第二行  32 33 34 35 36 37 38 39 10, 中心行  29 54 71 80 81 76 63 42 13

易错点

  • 「先填再走」写反了 → 最后一格跑到方阵外面或者被覆盖,WA。
  • 只判断出界,忘了判断「已经填过」 → 内圈会一直转圈覆盖已有的数字(会死循环或者填错)。两个条件必须都要有。
  • 输出宽度写成 %3d 但其实要求不同:这题就是一个数字 3 格、无额外空格。 别自己脑补成「数字之间再加空格」。
  • 方阵数组的初始化:全局数组默认是 0,刚好能用来表示「没填过」。如果写成局部数组记得初始化。
  • 方向数组的顺序:必须是「右 下 左 上」才是顺时针。写成「上 右 下 左」就变成从中间开始了。
P5732 【深基5.习7】杨辉三角 入门 · 二维递推

题意:给出 n(1 ≤ n ≤ 20),输出杨辉三角的前 n 行。 每行相邻两个数之间空一格,行末不要多空格。

⚠️ 数据范围 / 关键约束

题面写的是 n(1 ≤ n ≤ 20)(这道题的「说明/提示」里没有额外数据范围,范围就在题目描述里)。
n ≤ 20 意味着最后一行有 20 个数,最大的是 C(19,9) = 92378,用 int 完全放得下, 不需要 long long

思路:每个数 = 上一行「左上 + 正上」

看样例的规律:

1
1 1
1 2 1        // 2 = 上一行的 1 + 1
1 3 3 1      // 3 = 上一行的 1 + 2(第一个 3),另一个 3 = 2 + 1
1 4 6 4 1    // 6 = 上一行的 3 + 3
1 5 10 10 5 1

换成下标写法(行、列都从 1 开始数):

🎯 递推公式

a[i][j] = a[i-1][j-1] + a[i-1][j]

意思是:第 i 行第 j 个数,等于它左上方的数加上它正上方的数。
这一行的两端(a[i][1]a[i][i])没有两个上方的数可用,直接规定为 1

位置上长什么样

行\列12345
11
211
3121
41331
514641

看第 5 行第 3 列的 6:左上 a[4][2] = 3,正上 a[4][3] = 3,3 + 3 = 6 ✔

样例输入
6
样例输出
1
1 1
1 2 1
1 3 3 1
1 4 6 4 1
1 5 10 10 5 1

完整代码

#include <iostream>
using namespace std;

int a[25][25];                       // a[i][j] 存第 i 行第 j 个数(从 1 开始)

int main(){
    int n;
    cin >> n;

    for(int i = 1; i <= n; i++){
        a[i][1] = 1;                 // 每行第一个是 1
        a[i][i] = 1;                 // 每行最后一个是 1
        for(int j = 2; j < i; j++)
            a[i][j] = a[i-1][j-1] + a[i-1][j];   // ★ 左上 + 正上
    }

    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= i; j++){
            if(j > 1) cout << " ";   // ★ 数字之间一个空格,行末不留空格
            cout << a[i][j];
        }
        cout << endl;
    }
    return 0;
}
💡 内层循环为什么是 j = 2; j < i

第 i 行只有 i 个数,两端已经手动填成 1 了,所以中间只有 j = 2, 3, ..., i-1 这些位置需要用公式算。
如果写成 j <= i,会把两端算成 a[i-1][i-1] + a[i-1][i], 而 a[i-1][i] 根本没填过(是 0),虽然结果碰巧还是 1,但写法不严谨,n 大一点就容易出问题。

💡 「行末不留空格」的通用套路

不要写「输出数字 + 输出空格」,而是写「不是第一个就先后补一个空格,再输出数字」, 也就是 if(j > 1) cout << " ";
这样最后一个数字后面自然就没有多余空格。洛谷对行末空格通常是宽容的, 但很多比赛(比如 NOIP)是严格比对的,现在就养成好习惯

✅ 实测结果

官方样例 6 → 输出 6 行,与官方样例逐字符完全一致

边界 1 → 输出 1 正确(只有一行一个数)

最大数据 20 → 正常输出 20 行,数字都在 int 范围内(最大 92378),无溢出。

易错点

  • 忘了给两端赋 1 → 整张表全是 0 或者乱掉。
  • 行末多一个空格 → 有些评测机判 WA。用 if(j > 1) 控制。
  • 数组开小了:行、列都从 1 用到 n,所以至少要开 [n+1][n+1]。开 25 就够用,别开 [20][20]
  • 递推公式记反:是 a[i-1][j-1] + a[i-1][j],不是 a[i-1][j] + a[i-1][j+1]。记不住就对着样例第 5 行的 6 推一遍。
  • 这题是「用上一行算这一行」的典型,也叫递推。Day 17 会专门学递推,今天的杨辉三角就是它的热身。

今日常见错误汇总

#错误写法后果正确写法
1a[j][i]行列写反,答案莫名其妙地不对a[i][j](先行后列)
2int b[20][20] 存 n=20 的方阵下标 20 越界 → 玄学 WA / REint b[25][25]
3for(j=0;j<=m;j++)多扫一列,越界for(j=0;j<m;j++)
4if(a[nx][ny]==0 && nx>=0 …)先访问后判断,越界读先判边界,再访问
5只用 nx>=0 判越界漏掉上界,越界四个条件全写
6蛇形方阵「先走再填」最后一格填到方阵外先填 a[x][y],再移动
7P5730 用 int 读数字串前导 0 丢失 / n=100 时直接溢出用 string 读
8行末多打一个空格严格评测机判 WAif(j>1) cout<<" ";
9把 h 当成空洞的 z 上界官方样例就挂(输出 48,应为 56)切的时候上界用 z₂
10局部数组忘记清零乱码答案数组开在全局,或 ={0}

自测:你真的会了吗

第 1 题:行列写反了会怎样?

有人写 a[5][3] = 7;,但本意是「第 3 行第 5 列 = 7」。

:如果行列的上限不同(比如 5 行 3 列的矩阵),写成 a[5][3] 就是直接越界—— 行下标最大只能是 4,列下标最大只能是 2,两个都超了。
如果行列上限恰好相同(方阵),那不会越界,但数据会跑到转置的位置上, 程序照跑不误、答案全错,属于最难查的一类 bug。a[i][j] 时永远在心里念一遍「先行后列」。

第 2 题:n×m 的表格,两层循环怎么写?

要求按「一行一行」的顺序,把每个数读进来。

for(int i = 0; i < n; i++)
    for(int j = 0; j < m; j++)
        cin >> a[i][j];

外层 i < n 走行,内层 j < m 走列,都是小于不是小于等于。 数组至少要开 a[n][m],习惯上开 a[1005][1005] 这种大一点的。

第 3 题:方向数组的三个小问

(a) dx[4] = {0, 1, 0, -1}; dy[4] = {1, 0, -1, 0};d = 2 是哪个方向?
(b) 从 (i, j)d = 3 的方向走一步,新坐标是什么?
(c) 右转 5 次之后 d 等于几?


(a) dx[2] = 0(行不变)、dy[2] = -1(列减 1)→
(b) dx[3] = -1dy[3] = 0(i-1, j),也就是上方
(c) 每次右转是 d = (d+1)%4。转 5 次 = 转 4 次(回到原地)+ 1 次 → 从 0 变成 1,即

第 4 题:杨辉三角第 n 行有几个数?最大的数大概多大?

n = 20 的时候。

:第 i 行有 i 个数,所以第 20 行有 20 个数。
最大值在中间,是组合数 C(19,9) = 92378int 完全够用(int 上限约 21 亿)。
但如果题目把 n 开到 100,中间的数会暴涨到 10²⁸ 级别,那时候就必须用高精度了—— 所以看题目一定要看 n 的上限。

第 5 题(手写代码):打印一个 n×n 的方阵,对角线(i == j)打 1,其余打 0
for(int i = 0; i < n; i++){
    for(int j = 0; j < n; j++){
        if(j) cout << " ";
        if(i == j) cout << 1;      // 主对角线
        else cout << 0;
    }
    cout << endl;
}

★ 想一想:如果要打副对角线(从右上到左下),条件是什么?
答:i + j == n - 1

速查卡(截图存手机)

【定义与初始化】
  int a[105][105];                 // 开在 main 外面 → 自动全 0
  int c[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}};

【按行遍历(最常用)】
  for(int i = 0; i < n; i++)
      for(int j = 0; j < m; j++)
          cin >> a[i][j];          // 或 cout,或处理

【方向数组】
  顺时针(右 下 左 上):
      int dx[4] = {0, 1,  0, -1};
      int dy[4] = {1, 0, -1,  0};
  上下左右:
      int dx[4] = {-1, 1, 0, 0};
      int dy[4] = { 0, 0, -1, 1};
  右转: d = (d + 1) % 4;
  走一步:nx = x + dx[d];  ny = y + dy[d];

【边界判断(必须写在访问数组之前)】
  if(nx >= 0 && nx < n && ny >= 0 && ny < m) { ... }

【蛇形方阵状态机骨架】
  for(int v = 1; v <= n*n; v++){
      a[x][y] = v;                            // 先填
      int nx = x + dx[d], ny = y + dy[d];
      if(出界 || a[nx][ny] != 0) {            // 撞墙
          d = (d + 1) % 4;                    // 右转
          nx = x + dx[d]; ny = y + dy[d];
      }
      x = nx; y = ny;                         // 再走
  }

【杨辉三角递推】
  a[i][1] = a[i][i] = 1;
  for(j = 2; j < i; j++)
      a[i][j] = a[i-1][j-1] + a[i-1][j];

【定宽输出】
  printf("%3d", x);                // 右对齐,宽度 3(需 <cstdio>)
  cout << setw(3) << x;            // 需 <iomanip>,只对下一个输出生效

【防越界铁律】
  1 行 x m 列 → 至少开 [n+1][m+1],习惯开 [n+5][m+5]
  循环用 i < n,不用 i <= n
  先判边界,再访问 a[nx][ny]

明天预告 · Day 08

字符与字符串基础

今天你已经偷偷用到了两个「字符级」的操作:P5730 里的 s[i] - '0', 还有把数字串当 string 读进来。明天要把这套东西系统地学一遍:

  • 字符和 ASCII 码到底是什么关系
  • 今天埋的最大一个雷cin >>getline 混用时,getline 会读到一个空串
  • 遍历字符串、判断字符类型、大小写转换
  • 配套题目:P5015 标题统计 / P5733 自动修正 / P1914 凯撒密码 / P1765 手机

另外别忘了:今天的复盘任务(错题重做 + 犯错清单 + 40 分钟自测三题)如果还没做完,明天开新内容之前先补上。

ACM 一个月学习计划 · DAY 08

Day 8 · 字符与字符串基础

从「数字的世界」走进「文字的世界」——顺便拆掉新手最常踩的那颗地雷:getline

今日目标:能熟练读入、遍历、比较字符串,会做字符判断与大小写转换
预计用时:约 3 小时(含做题)  难度:入门 ~ 普及-
题目来源:洛谷 P5015 / P5733 / P1914 / P1765
DAY 08 / 30

今日时间安排

0 - 20 min字符与 ASCII 的关系(第 1 节)
20 - 50 mincin >> 与 getline 的区别(第 2 节)——今天最大的坑,一定亲手敲一遍
50 - 70 min遍历字符串、字符判断、大小写转换(第 3-4 节)
70 - 150 min做 4 道题,每题限时 30 分钟(前两题各 15 分钟就够)
150 - 180 min对照本文题解补题 + 写笔记 + 做自测
💡 昨天内容的衔接

昨天 P5730 显示屏里出现过一句 pat[s[i] - '0'][r],P5732 里也把数字当字符串读过。
那两个操作其实已经踩进了「字符」的世界,只是当时没细讲。今天把它讲透。
另外 Day 7 练的「按位处理」(把一串东西拆成一个个元素逐个处理)今天会以新形式出现—— 那时拆的是数字的十进制位,今天拆的是字符串里的字符。

1字符其实也是数字:ASCII 码

先接受一个有点反直觉的事实

电脑里存不下「字母 A」这种东西,它只认识数字。所以每个字符都被编了一个号, 这个编号表就叫 ASCII 码表

比如字母 'A' 的编号是 65'a'97。 你写 'A' 的时候,电脑看到的其实就是 65。

🎯 由这一点推出的全部结论

既然字符本质上就是整数,那么:
· 字符可以直接比较大小(比的是编号)
· 字符可以做加减法'a' + 1 就是 'b'
· 字符可以直接当数组下标(Day 6 学过的那种桶)

必背的 6 个编号

字符ASCII 码怎么记
'0'48数字字符从 48 开始
'9'5748 + 9,所以 '9' - '0' = 9
'A'65大写字母从 65 开始
'Z'9065 + 25,一共 26 个
'a'97小写字母从 97 开始
'z'12297 + 25
' '(空格)32比所有可见字符都小
💡 真正要背的其实只有 3 件事

① 三组字符各自连续。'0'~'9' 连续、'A'~'Z' 连续、'a'~'z' 连续。 所以「减去起点」就能得到 0 开始的编号:c - '0'c - 'A'c - 'a'

② 大写的编号比小写小。97 − 65 = 32。所以 'a' - 'A' = 32, 大写变小写是 +32,小写变大写是 -32

③ 它们三组之间是不连续的。'9' 后面紧接着的是 ':' 而不是 'A'。 所以判断字符类型不要靠比大小写死区间,用下面第 4 节的函数最稳。

大小写转换的两种写法

写法代码特点
算术写法c = c - 'a' + 'A';完全依赖「小写比大写大 32」,一眼看不出意图
函数写法c = toupper(c);见名知意,非字母自动不变,推荐

两种都对,但竞赛里建议用函数——少一个「32 是哪来的」的疑问,也少一次手滑。

2今天最大的坑:cin >>getline

两种读入方式,行为完全不同

写法遇到空格 / 制表符会怎样遇到换行会怎样适合读什么
cin >> s停住,只读走前面那一截留在缓冲区里,不读走不带空格的单词 / 号码
getline(cin, s)连空格一起读进去读走并丢掉,作为结束标志含空格的整行句子
🎯 一句话理解区别

cin >> 是「挑一个词」——它会跳过前面的空白,读到下一个空白就停,并且把那个空白留在原地
getline 是「抄一整行」——它从当前位置一直抄到换行符为止,然后把换行符吃掉

于是就有了这个经典 bug

假设输入长这样:

5
hello world

你想先读数字 5,再读一整行 "hello world"。很自然地写成:

int n;
string s;
cin >> n;                // 读走 5
getline(cin, s);         // ❌ 你可能以为读到了 "hello world"

实际结果是:s 是空串,长度 0。

为什么?把缓冲区画出来就懂了

步骤输入缓冲区里有什么s 的值
一开始5\nhello world\n
执行 cin >> n\nhello world\n(5 被读走,换行符留下了
执行 getlinehello world\n""(空)

因为 getline 一上来就碰到了那个残留的换行符,它认为「这一行已经结束了」, 于是读到一个空串就返回了。"hello world" 那一行根本还没被碰到。

我实际编译跑了一遍,证据如下

🚫 错误写法
#include <iostream>
#include <string>
using namespace std;

int main(){
    int n;
    string s;
    cin >> n;
    getline(cin, s);          // ❌ 读到的其实是上一行残留的换行符
    cout << "n=" << n << " len=" << s.size() << endl;
    return 0;
}

输入 5 换行 hello world实测输出

n=5 len=0

len=0 —— 字符串是空的,"hello world" 根本没被读进来。

✅ 正确写法:加一句 cin.ignore()
#include <iostream>
#include <string>
using namespace std;

int main(){
    int n;
    string s;
    cin >> n;
    cin.ignore();             // ★ 手动吃掉上一行剩下的一个字符(也就是换行)
    getline(cin, s);
    cout << "n=" << n << " s=[" << s << "]" << endl;
    return 0;
}

同样输入 5 换行 hello world实测输出

n=5 s=[hello world]

读对了。

💡 更省心的办法:干脆别混用

如果那一行不含空格,那就用 cin >> s 读到底,问题自然消失(今天的 P5733、P1914 就是这样)。
getline 的前面不要出现 cin >>,是最省心的策略。

⚠️ 还有两个和 getline 有关的小坑

坑 A:只读一行够不够?本题 P5015 只有一行。但如果题目说「若干行」,你要写成 while(getline(cin, s)) { ... } 一直读到文件结束(EOF)。

坑 B:Window 下的 \r换行在不同系统里可能是 \n(Linux) 或 \r\n(Windows)。getline 只吃掉 \n, 如果输入文件是 Windows 格式,s 末尾就会多一个看不见的 '\r', 统计字符数时会多算 1。洛谷用的是 Linux 换行,本地测完记得心里有数。 (今天的 P5015 只统计「不是空格的字符」,就是用这个思路做的。)

3遍历字符串:三种写法,用哪种都行

字符串可以理解成「一堆字符排成的一排」,所以 Day 5 学的一维数组那一套完全适用。

string s = "hello";

// 写法 1:下标循环(最通用,推荐新手先用这个)
for(int i = 0; i < (int)s.size(); i++){
    char c = s[i];                 // c 就是第 i 个字符
}

// 写法 2:范围 for(C++11 起,简洁)
for(char c : s){
    // c 依次取 s 里的每个字符(是副本,改它不影响 s)
}

// 写法 3:范围 for + 引用(可以真的改动 s)
for(char &c : s){
    c = toupper(c);                // ★ 加了 & 才是改原件
}
⚠️ for(char c : s)for(char &c : s) 差别很大

· 不加 &c 只是每个字符的复印件。你在循环里改 c, 原来的 s 一点变化都没有——「改了没效果」是新手最常见的困惑之一
· 加了 &c 直接指向字符串里的那个字符,改 c 就是改 s

拿不准就用写法 1(下标循环),s[i] = ... 一定是改原件,绝不会搞错。

为什么写 (int)s.size() 而不是直接 s.size()

s.size() 返回的是一个无符号整数size_t)。 你拿 int i 和它比,编译器会给出「有符号与无符号比较」的警告。

写法结果评价
i < s.size()能跑,但可能有警告⚠️ 不太干净
i < (int)s.size()强制转成 int 再比✅ 推荐
int n = s.size();先存进 int 再比✅ 也很常见
💡 这个警告为什么值得在意

无符号数不能表示负数。如果哪天你写了个 i = -1 再和 s.size() 比, -1 会被当成一个超级大的正数,比较结果完全反过来,程序就疯了。 加上 (int) 转换,这一类陷阱就绕开了。

4字符判断与转换:<cctype> 工具箱

C++ 自带一整套判断字符类型的小函数,都在 <cctype> 里。用它们比自己写区间判断更不容易错。

函数作用例子
isdigit(c)是不是数字字符 '0'~'9'isdigit('7') → 真
isalpha(c)是不是字母(大小写都算)isalpha('a') → 真
isupper(c)是不是写字母isupper('A') → 真
islower(c)是不是写字母islower('a') → 真
isalnum(c)是不是字母或数字isalnum('!') → 假
isspace(c)是不是空白(空格 / 换行 / Tab)isspace(' ') → 真
toupper(c)转成大写(非字母原样返回)toupper('a') → 'A'
tolower(c)转成小写(非字母原样返回)tolower('A') → 'a'
🎯 它们的返回值是「真 / 假」,不是字符

isdigit 这类函数返回的是「非零表示真、0 表示假」, 所以它们可以直接当 if 的条件用:

if(isdigit(c)) { ... }        // ✅ 直接当条件,最常用
if(isdigit(c) != 0) { ... }   // 也行,但没必要

toupper / tolower 返回的是转换后的字符本身,所以要赋值回去。

「只转小写、大写保持不变」的两种写法

// 写法 A:先判断,再转换(意图最清楚)
if(islower(s[i])) s[i] = toupper(s[i]);

// 写法 B:直接转(toupper 对非小写字符原样返回,所以也是对的)
s[i] = toupper(s[i]);

// 写法 C:不用任何函数,纯算术
if(s[i] >= 'a' && s[i] <= 'z') s[i] = s[i] - 'a' + 'A';

三种都能过题。今天的 P5733 我用的是写法 A——多写一个 islower, 但「只处理小写字母」这个意图在代码里写得明明白白。

⚠️ 一个几乎不会碰到、但知道更好的细节

严格来说应该写成 toupper((unsigned char)c)。原因是这些函数接受 int, 如果 char有符号的,遇到编号大于 127 的字符时传进去会变成负数,属于未定义行为。
本题输入全是英文和数字(都在 0~127 内),直接写 toupper(c) 完全没问题, 这里只是让你知道有这么回事,以后处理中文或二进制数据时再想起来。

5两个今天会用到的招式

招式一:把字符映射成一个小整数(打表)

P1765 手机那道题,要问「按出字母 c 要按几下键盘」。 这个对应关系是固定的一张表,于是我们用一个数组把答案存起来:

// 用 c - 'a' 把字母映射成 0~25,正好当数组下标(就是 Day 6 的桶思想)
int press[26] = {
    1, 2, 3,        // a b c   在 2 键上
    1, 2, 3,        // d e f   在 3 键上
    1, 2, 3,        // g h i   在 4 键上
    // ...
};

int times = press[c - 'a'];      // ★ 查表,O(1) 得到答案
💡 这叫「预处理 / 打表」

与其在循环里写一堆 if 去判断是哪个键、按几下, 不如一开始就把答案摆好,用的时候直接查
昨天 P5730 的 0~9 点阵也是同一招。「打表」是竞赛里非常常用的省事技巧。

招式二:让字母「绕圈」(取模)

P1914 凯撒密码要把每个字母往后移 n 位,而且 z 的下一位要绕回 a。 如果直接 c + n'z' + 1 会变成 '{',跑出字母表了。

正确做法是:先把自己换算成 0~25 的编号,加完再对 26 取余,最后换算回字母。

字母c - 'a'+1 之后% 26'a' + 结果
'q'161717'r'
'w'222323'x'
'e'455'f'
'z'25260'a'
🎯 万能公式(背下来)

s[i] = 'a' + (s[i] - 'a' + n) % 26;

先转成 0 起的编号 → 加偏移 → 对长度取余 → 再加回起点」这四步, 以后做环形数组、循环队列、环形棋盘都会用到,不只字母能用。

这里的 % 26 和昨天蛇形方阵的 (d + 1) % 4完全同一个套路

6四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是你看到的真实结果。

P5015 [NOIP 2018 普及组] 标题统计 入门 · getline 读整行

题意:给一个作文标题,问标题里有多少个字符。 标题里可能有大小写字母、数字、空格和换行符,但统计时空格和换行符不算

⚠️ 数据范围 / 关键约束

输入只有一行|s| 表示字符串的长度(含空格),题目给的范围是:

· 40% 的数据:1 ≤ |s| ≤ 5,只有数字字符
· 80% 的数据:1 ≤ |s| ≤ 5,只有大小写字母和数字字符
· 100% 的数据:1 ≤ |s| ≤ 5,可能包含大小写字母、数字、空格

也就是说这题的数据非常小(标题最长只有 5 个字符), 难点完全不在数据量上,而在于「有没有把空格正确地排除掉」。

思路

这题只有一句话的思路:getline 把整行读进来(这样空格才不会丢),然后数一数有几个字符不是空格。

为什么不能用 cin >> s?因为 cin >> 一遇到空格就停。 样例 2 是 Ca 45,用 cin >> 只能读到 Ca, 答案会算成 2 而不是 4。这就是今天第 2 节那个坑的正确用法:需要空格 → 用 getline。

那「换行符不算」怎么处理?很简单:getline 读出来的字符串里本来就不包含换行符, 它把换行符当结束标志吃掉了。所以我们只要排除空格就行。

样例 1 输入
234
样例 1 输出
3
样例 2 输入
Ca 45
样例 2 输出
4

样例 2 的说明里写得很清楚:有 1 个大写字母、1 个小写字母、2 个数字字符,还有 1 个空格。 空格不算,所以是 4。

完整代码

#include <iostream>
#include <string>
using namespace std;

int main(){
    string s;
    getline(cin, s);                 // ★ 读入一整行,空格也会被读进来

    int ans = 0;
    for(int i = 0; i < (int)s.size(); i++){
        if(s[i] != ' ') ans++;       // ★ 空格不算,其余字符都算
    }
    cout << ans << endl;
    return 0;
}
💡 更稳一点:用 !isspace(s[i])

isspace 会把空格、Tab、换行、回车全都当成「空白」排除掉。 如果担心本地测试文件是 Windows 换行(\r\n)导致多算一个字符, 写成 if(!isspace(s[i])) ans++; 就完全免疫了。

本题洛谷用的是 Linux 换行,所以 != ' ' 已经足够, 两种写法在本题上结果完全一样。

✅ 实测结果

官方样例 1 234 → 输出 3 完全一致
官方样例 2 Ca 45 → 输出 4 完全一致

自造数据 a b c → 输出 3 正确
(3 个字母,2 个空格被排除)

易错点

  • cin >> s → 空格后面的内容全丢,样例 2 会输出 2。
  • 把空格也数进去 → 样例 2 会输出 5。题目明确说空格不算。
  • 只读一个字符(用 cin >> c)→ 标题不止一个字符,直接错。
  • 多读一行:这题只有一行输入。如果写 while(getline(cin, s)) 累加, 在洛谷上其实也能过,但没必要,而且如果行末有多余空行就会出错。
  • 如果前面有 cin >> 读数字,记得先 cin.ignore()(这题没有,但要养成条件反射)。
P5733 【深基6.例1】自动修正 入门 · 大小写转换

题意:办公软件会自动把字母变成大写。输入一个字符串, 把里面所有小写字母变成大写字母,其余字符原样输出。

⚠️ 数据范围 / 关键约束

题目描述里写明:输入字符串长度不超过 100,且不包括空格。 (这道题的「说明/提示」里没有单独的数据范围段落,范围就写在题目描述里。)

「不包括空格」这一条很关键——说明可以直接用 cin >> s 读,不需要 getline

思路

遍历字符串的每一个字符:如果是小写字母,就把它换成对应的大写字母;其他字符(数字、标点)一律不动。

样例给的是 Luogu4!,里面有数字 4 和感叹号 !, 输出 LUOGU4! 说明这两者必须原样保留。 所以不能无脑对所有字符做加法——数字 '4' 加 32 会变成 'T',那就错了。

🎯 两层保险

· islower(c) 保证只对小写字母动手(数字、标点不会被误伤)
· toupper(c) 本身也保证只转小写字母,非字母原样返回

两个加起来就是「万无一失」。哪怕只写 s[i] = toupper(s[i]); 也是对的, 但加上 islower 判断,代码意图更清楚。

样例输入
Luogu4!
样例输出
LUOGU4!

完整代码

#include <iostream>
#include <string>
#include <cctype>                    // islower / toupper 都在这里
using namespace std;

int main(){
    string s;
    cin >> s;                        // 题目保证没有空格,用 cin 读最省事

    for(int i = 0; i < (int)s.size(); i++){
        if(islower(s[i]))            // ★ 是小写字母才转换
            s[i] = toupper(s[i]);
    }

    cout << s << endl;
    return 0;
}
💡 也可以一行搞定
for(char &c : s) c = toupper(c);   // 注意 &,否则改的是副本

范围 for 加上引用 &,就能直接在原字符串上改。写法短,但别忘了那个 &

✅ 实测结果

官方样例 Luogu4! → 输出 LUOGU4! 完全一致
(数字 4 和感叹号 ! 都原样保留,说明没有误伤非字母字符)

自造数据 abcXYZ123 → 输出 ABCXYZ123 正确
(小写变大写、大写不动、数字不动)

易错点

  • 对所有字符无脑 - 32 → 数字和标点会被改坏。必须先判断是不是小写字母。
  • 用范围 for 但忘了 &for(char c : s) c = toupper(c); 改的是副本, 输出还是原来的小写,看起来「代码没错但答案不对」
  • 忘了 #include <cctype> → 编译报错说找不到 islower
  • 这题没有空格,cin >> s 就够。但如果题目改成「可能含空格」, 就必须换成 getline,而且输出也不能用 cout << s 就完事(其实可以,只是读入要改)。
P1914 小书童——凯撒密码 入门 · 字符移位 + 取模

题意:密码是把原文字符串里每个字母往后移动 n 位得到的, 并且 z 的下一个字母是 a(绕回去)。 给原文和 n,求密码。

⚠️ 数据范围 / 关键约束

字符串长度 ≤ 501 ≤ n ≤ 26,字符串由小写字母组成。

两个细节值得注意:
· n 最大是 26,而小写字母正好 26 个。n = 26 时相当于原地不动, 正好能检验你的取模写对没有(26 % 26 = 0)。
· 只有小写字母,没有空格,所以可以用 cin >> 读。

思路

核心就是第 5 节那个「绕圈」公式。为什么不能直接 s[i] + n

字符直接 +1 的结果问题
'q''r'正常
'z''{'❌ 跑到字母表外面去了,不是 'a'

所以必须先把字母换算成 0~25 的编号,加完偏移后对 26 取余, 再换算回字母。这样 'z'(编号 25)加 1 变成 26,26 % 26 = 0,回到 'a'

顺带说一句:因为 'a' 的编号是 97,你可以直接拿字母本身来取模—— 但那样写出来的数字是 97、98 之类的,别人看不懂,自己过两天也看不懂。 减掉 'a' 让编号从 0 开始,代码才读得懂。

样例输入
1
qwe
样例输出
rxf

q→r、w→x、e→f,每个都往后一位。

完整代码

#include <iostream>
#include <string>
using namespace std;

int main(){
    int n;
    string s;
    cin >> n >> s;                   // n 是数字,s 是字母串(无空格)

    for(int i = 0; i < (int)s.size(); i++){
        // ★ 先转成 0~25,加 n 后对 26 取模,再转回字母
        s[i] = 'a' + (s[i] - 'a' + n) % 26;
    }

    cout << s << endl;
    return 0;
}
🎯 逐字拆解这一行

'a' + (s[i] - 'a' + n) % 26

· s[i] - 'a':把 'a'~'z' 变成 0~25
· + n:往后移 n 位,结果可能是 26~51
· % 26:把 26 及以上「折回来」,一定落回 0~25
· 'a' + ...:把编号变回字母

注意括号的位置:是先加 n 再取模,不是先取模再加 n。 如果写成 (s[i] - 'a') % 26 + n,那 z 就会变成编号 25 + 1 = 26, 再加 'a' 得到 '{',又跑出去了。

✅ 实测结果

官方样例 1 / qwe → 输出 rxf 完全一致

边界 26 / abcxyz → 输出 abcxyz 正确
(移 26 位等于没移,验证了 26 % 26 = 0 这个边界)

自造数据 13 / hello → 输出 uryyb 正确
(经典的 ROT13:h→u、e→r、l→y、l→y、o→b)

易错点

  • 直接 s[i] += nz 之后的字母会跑到 { | } 这些符号上去,WA。
  • 取模位置写错:必须是 (c - 'a' + n) % 26,不能是 (c - 'a') % 26 + n
  • char 存中间结果时溢出:如果先 char t = s[i] + n;, n 大时可能超出 char 范围。直接在 int 里算完再赋回 s[i] 最安全(上面的写法就是这样)。
  • 读入顺序:n 在第一行、字符串在第二行,用 cin >> n >> s 一次读完最简洁。 如果非要混用 getline,别忘了 cin.ignore()
  • 如果 n 的下限可能是 0 或者负数(本题不是),记得先 n = (n % 26 + 26) % 26; 规范化。
P1765 手机 普及- · 字符到按键次数映射

题意:老式手机键盘上,要按出字母得按数字键好几下 (比如按出 x 要按 9 键两下:第一下出 w,第二下变成 x)。 0 键按一下出一个空格。给一句只含小写字母和空格的句子, 问在手机上打出来至少要按多少下。

⚠️ 数据范围 / 关键约束

输入一行句子,只包含英文小写字母和空格,不超过 200 个字符。 (这道题的「说明/提示」只写了出处「NOI 导刊 2010 普及(10)」,没有额外数据范围,范围就在题目描述里。)

关键点:句子里有空格,所以必须用 getline。 而且空格本身也要计数(按 0 键一下)。

思路:先造一张「按键次数表」

题目给了键盘的排列方式(原题配了图)。老式手机的规律是:

数字键字母各要按几下
2a b c1 2 3
3d e f1 2 3
4g h i1 2 3
5j k l1 2 3
6m n o1 2 3
7p q r s1 2 3 4
8t u v1 2 3
9w x y z1 2 3 4
0(空格)1
🎯 怎么把这表变成代码

写一个长度 26 的数组 press[26], 用 c - 'a' 当下标('a'→0、'b'→1、……、'z'→25), 数组的值就是「按几下」。

注意 7 键和 9 键各有 4 个字母,所以这两个键最后一个是 4 下—— 这是本题最容易抄错的地方。

用样例验算一遍

输入是 i have a dream

字符i空格have空格a空格dream合计
按键数3121321111321123

加起来正好 23,和样例输出一致。

样例输入
i have a dream
样例输出
23

完整代码

#include <iostream>
#include <string>
using namespace std;

// press[c - 'a'] = 按出字母 c 需要按几下
// 按键分组:2:abc  3:def  4:ghi  5:jkl  6:mno  7:pqrs  8:tuv  9:wxyz
int press[26] = {
    1, 2, 3,        // a b c
    1, 2, 3,        // d e f
    1, 2, 3,        // g h i
    1, 2, 3,        // j k l
    1, 2, 3,        // m n o
    1, 2, 3, 4,     // p q r s  ← 4 个字母,别漏了最后的 4
    1, 2, 3,        // t u v
    1, 2, 3, 4      // w x y z  ← 同上
};

int main(){
    string s;
    getline(cin, s);                 // ★ 句子里有空格,必须 getline

    int ans = 0;
    for(int i = 0; i < (int)s.size(); i++){
        if(s[i] == ' ') ans += 1;    // ★ 空格按 0 键,算一下
        else ans += press[s[i] - 'a'];
    }

    cout << ans << endl;
    return 0;
}
💡 数一下 press 里到底有没有 26 个数

写打表最容易出的错就是数错个数。用「每组换行 + 行末注释」的写法排好版, 一眼就能看出每组是不是 3 个(7 键、9 键那两组是 4 个)。
3×6 + 4×2 = 18 + 8 = 26,对上了。

✅ 实测结果

官方样例 i have a dream → 输出 23 完全一致

自造数据 s(单个字母,7 键按 4 下)→ 输出 4 正确
(专门用来验证 7 键 / 9 键那组的第 4 下有没有写对)

接近上限的长句:i have a dream 重复 13 遍(194 个字符)→ 输出 311 正确
(13 × 23 = 299,再加上 12 个分隔空格 × 1 = 12,共 311)

易错点

  • cin >> s → 只读到第一个空格为止,后面的内容全丢,答案会小很多。
  • 忘了给空格计数 → 样例会输出 19 而不是 23。
  • 打表抄错:7 键(pqrs)和 9 键(wxyz)是 4 个字母, 写成 1,2,3 就少一截,z 查表还会读到数组外面去。
  • 数组长度写 25'z' - 'a' = 25 直接越界。必须有 26 个元素(下标 0~25)。
  • 句子里只有小写字母和空格,所以 else 分支不用再判别的字符类型。如果题目放宽到含大写字母, 就得先 tolower 再查表。

今日常见错误汇总

#错误写法后果正确写法
1cin >> n; getline(cin,s);s 读到空串,整行内容丢失中间加 cin.ignore();
2含空格的句子用 cin >> s只读到第一个空格getline(cin, s)
3for(char c : s) c = toupper(c);改的是副本,s 没变for(char &c : s)
4直接 c += n(凯撒密码)越过 'z' 变成 {'a' + (c-'a'+n) % 26
5对所有字符 -32 转大写数字、标点被改坏先 islower(c) 判断
6int press[25]'z' 越界(下标 25)int press[26]
7空格不计数P1765 答案偏小if(c==' ') ans += 1;
8忘了 #include <cctype>编译错误,找不到 islower加上头文件
9for(int i=0;i<s.size();i++)有符号/无符号比较警告(int)s.size()
10用 gets() / char 数组乱读gets 已从 C++ 中删除,编译不过统一用 std::string

自测:你真的会了吗

第 1 题:为什么 cin >> n; getline(cin, s); 会读到空串?

:因为 cin >> n 只把数字读走,把这一行末尾的换行符留在了输入缓冲区里
紧接着的 getline 一上来就撞到这个换行符,它认为「这一行已经结束」, 于是返回一个空串。真正想读的那一行还在缓冲区里没动。
解决:在两者之间加一句 cin.ignore();,把这一个残留字符吃掉。

第 2 题:'a' - 'A' 等于多少?有什么用?

:等于 32。因为 'A' 是 65、'a' 是 97,97 − 65 = 32。
用途:大写变小写加 32,小写变大写减 32
比如 'a' - 32 = 'A'。不过实际写代码时,toupper / tolower 更不容易错

第 3 题:给一个字符 c,判断它是不是数字字符,有几种写法?

:两种都行。

if(isdigit(c)) { ... }                     // 推荐,需 <cctype>
if(c >= '0' && c <= '9') { ... }           // 也可以,靠 ASCII 连续性

★ 注意:不能写成 c >= 0 && c <= 9—— 那是在比较字符和整数 0、9,而字符 '0' 的值是 48。 想手写区间就必须带单引号。

第 4 题:把 "Zz" 全部变成大写,结果是什么?

:还是 "ZZ"……等等,原串是 "Zz",变大写之后是 "ZZ"
过程:'Z' 本来就是大写,islower('Z') 是假,跳过; 'z' 是小写,toupper('z')'Z'
要点:转换只对「该转换的字符」生效,已经是目标大小写的字符不会被重复处理出错。

第 5 题:凯撒密码,n = 26 的时候输出应该是什么?

输入是 abcxyz

:还是 abcxyz原样输出
因为字母只有 26 个,往后移 26 位正好转了一整圈回到自己。
用公式验算:'a' → (0 + 26) % 26 = 0 → 'a' ✔; 'z' → (25 + 26) % 26 = 51 % 26 = 25 → 'z' ✔。
这也是一个很好的自测数据——如果 n = 26 时答案变了,说明取模写错了。

第 6 题(手写代码):统计一个字符串里大写字母、小写字母、数字字符各有几个
int upper = 0, lower = 0, digit = 0;
for(int i = 0; i < (int)s.size(); i++){
    if(isupper(s[i])) upper++;
    else if(islower(s[i])) lower++;
    else if(isdigit(s[i])) digit++;
}
cout << upper << " " << lower << " " << digit << endl;

★ 想一想:为什么这里用 else if 而不是三个独立的 if
答:三种类型互斥,用 else if 能少做判断,也更能表达「这三类分开统计」的意图。 用三个 if 结果也一样。

速查卡(截图存手机)

【必背 ASCII】
  '0' = 48    '9' = 57     // 数字连续
  'A' = 65    'Z' = 90     // 大写连续
  'a' = 97    'z' = 122    // 小写连续
  'a' - 'A' = 'a' - 'A' = 32

【读入(最容易错的地方)】
  cin >> s;                  // 遇空格停,适合单词/无空格串
  getline(cin, s);           // 读一整行,含空格
  cin >> n; cin.ignore();    // ★ 混用前必须加,否则 getline 读空串
  while(getline(cin, s)) {}  // 读所有行直到 EOF

【遍历】
  for(int i = 0; i < (int)s.size(); i++) ...   // 最稳
  for(char c : s) ...                          // 只读
  for(char &c : s) ...                          // ★ 要改就用这个

【字符函数(需 <cctype>)】
  isdigit / isalpha / isupper / islower / isalnum / isspace
  toupper(c)   tolower(c)

【大小写转换】
  if(islower(c)) c = toupper(c);
  c = c - 'a' + 'A';                       // 等价写法
  for(char &c : s) c = toupper(c);         // 整串转大写

【环形移位(凯撒密码)】
  s[i] = 'a' + (s[i] - 'a' + n) % 26;
  // 通用套路:转成 0 起 → 加偏移 → % 长度 → 加回起点

【字符→整数映射(打表)】
  int press[26] = { ... };      // 用 c - 'a' 当下标,长度必须 26
  int v = press[c - 'a'];
  // 7 键 pqrs、9 键 wxyz 各有 4 个字母,别抄错

【铁律】
  要空格 → getline;不要空格 → cin >>
  混用 → 中间加 cin.ignore()
  打表数组长度先数一遍

明天预告 · Day 09

字符串进阶:数字、进制与格式处理

今天你学会了「把字符串当一串字符来处理」。明天要处理更麻烦的一类问题—— 字符串和数字之间来回转换,以及那些「格式特别讲究」的题。核心知识点大概有:

  • 数字 ↔ 字符串to_string(x)stoi(s) / stoll(s)
  • 手写数字反转:反复 % 10/ 10,还要注意末尾的 0 和负号
  • 小数 / 分数的格式处理(比如把 0.5000 变成 0.5
  • 把十进制数转成 B 进制字符串
  • 配套题目:P1307 数字反转 / P1553 数字反转(升级版)/ P1200 火车上的 Nim / P2084 进制转换

今天那 4 道题如果你都一遍过了,说明字符串的读入和遍历已经稳了——明天会直接往「格式细节」上强度,先把今天的地基踩实。

ACM 一个月学习计划 · DAY 09

Day 9 · 字符串进阶:数字、进制与格式处理

数字不只是拿来算的,它还是一串字符 —— 学会「按字符思考」,你就能处理小数、分数、百分数和任意进制

今日目标:掌握数字与字符串互转、手写数字反转、进制转换,以及「格式敏感」类题目的分类讨论套路
预计用时:约 3 小时(含做题)  难度:入门 ~ 普及-
题目来源:洛谷 P1307 / P1553 / P1200 / P2084
DAY 09 / 30

今日时间安排

0 - 25 min数字 ⇄ 字符串的两套工具(第 1 节)
25 - 50 min数字反转的三个细节(第 2 节)——今天的主线
50 - 75 min进制转换的两种方向(第 3 节)
75 - 160 min做 4 道题,每题限时 30 分钟
160 - 180 min对照本文题解补错题 + 做自测
💡 和昨天内容的衔接

Day 8 你学会了「把一串字符当成数组来用」:s[i]s.size()toupper、凯撒密码的取模移位。

今天要往前一步:不再把输入死板地当成「一个整数」或「一个字符串」,而是根据题目的需要自由切换。 比如 P1553 的输入可能长成 600.084700/278670% —— 你根本没法用一个 int 读进来, 只能先当字符串读,再看里面有什么符号,分情况处理。

1数字 ⇄ 字符串:两套工具,各管一段

先想清楚一个问题:这个数,你要「算」还是要「看」?

同一个数字,在你脑子里的身份可以完全不一样:

你关心的是怎么存典型题目
它的大小:加、减、比大小、求和int / long long求平均值、找最大值
它的每一位长什么样:反转、回文、数数码stringP1307、P1553
它里面混着别的符号:小数点、斜杠、百分号string(没得选)P1553
🎯 判断标准就一句话

只要题目在描述「数位的形状」(第几位是谁、顺序反过来、末尾有没有 0、几位数),就把它当字符串处理。

因为对一个 int 来说,380 就是三百八十这一个数值; 但对字符串 "380" 来说,它是 '3''8''0' 三个独立的字符。反转、去零这些操作,本质是在字符上做手脚。

工具一:现成的转换函数

想干什么写法需要 include例子
整数 → 字符串to_string(x)<string>to_string(123) = "123"
字符串 → 整数stoi(s)<string>stoi("123") = 123
字符串 → 长整数stoll(s)<string>stoll("1000000000000")
字符串 → 小数stod(s)<string>stod("3.14") = 3.14
取长度s.size()"380".size() = 3
反转整串reverse(s.begin(), s.end())<algorithm>"abc" → "cba"

工具二:自己手写拆位(第 2 节详细讲)

如果要处理的是纯整数,而且只在乎「数位」,那么用 % 10/ 10 手写反而更快、更省内存。 但今天的重头戏 P1553 里全是符号,字符串才是正解。

⚠️ 用 cin >> s 读字符串,别用 getline(今天这 4 题都不用)

cin >> s 会跳过开头的空白,一直读到空白为止 —— 正好适合读 600.084 这种「一整块没有空格的输入」。

getline(cin, s)把整行连空格一起读进来。Day 8 的 P5015 标题统计、P1765 手机,才需要它(因为里面有空格)。
今天 4 道题的输入都没有空格,所以一律 cin >> s 最省事。

为什么不能什么都用 long long 硬扛?

类型能存多大够用吗
int约 ±2.1 × 10⁹,最多 10 位数P1307(|N| ≤ 10⁹)刚好够
long long约 ±9.2 × 10¹⁸,最多 19 位P1553 的 20 位整数 ❌ 装不下
string想多长有多长(内存允许的话)P1553 ✅
🚨 这就是 P1553 必须用字符串的根本原因

题面写着:整数部分「不大于 20 位」,百分数分子「不大于 19 位」。

20 位的整数,比如 12345678901234567890,已经远远超过 long long 的上限(9223372036854775807,也才 19 位)。 你用 long long 读它就爆了 —— 所以只能当字符串,逐位处理。

看到「位数不超过 xx 位」这种描述,就先警惕一下:是不是超过了 19 位?超了就必须用字符串。

2数字反转:三个必须处理的细节

这是今天的主线。P1307 是它的入门版,P1553 是加了三种「格式」的完整版。

细节一:最高位不能是 0(末尾的 0 要丢掉)

反转 1200,机械地倒过来是 0021,但题目要的是 21

好消息是:用算术方法写,前导 0 会自动消失,你什么都不用做。

rev = rev * 10 + n % 10;      // ★ 关键的一行

为什么?因为构造新数的时候,如果这一次拿到的是 0,那么 rev * 10 + 0 等于把 rev 后面「补一个 0」。 但如果此时 rev 本身还是 0,那么 0 * 10 + 0 仍然是 0 —— 0 乘任何数都是 0,前导 0 就这么被吃掉了。

走一遍:1200 是怎么变成 21 的

轮次nn % 10rev(新数)
开始12000
1120000×10+0 = 0
212000×10+0 = 0
31220×10+2 = 2
4112×10+1 = 21
结束021 ✅

看第 1、2 轮:rev 一直是 0,两个 0 就这么自然地被丢掉了。

细节二:负号

反转 -380,负号要留着,但 0 要去掉(答案是 -83)。

最简单的写法:先把符号单独记下来,把 n 变成正数,按正数反转,最后再把符号补回去。

bool neg = (n < 0);     // 1. 记住是不是负数
if(neg) n = -n;         // 2. 变成正数再处理
// ……中间是反转,不管符号……
if(neg) rev = -rev;     // 3. 符号补回来
💡 为什么不边反转边处理负号

因为负数取余的结果在 C++ 里是负数:-380 % 10 == 0-38 % 10 == -8。 直接算下去,中间会出现一堆负的位数,非常容易写错。
把符号「摘出去」,处理完再「装回去」—— 这个思路在处理正负号、小数点、百分号时都通用。

细节三:用 long long,别用 int

P1307 的范围是 |N| ≤ 10⁹,反转后的数一定也在 int 范围内(因为 10 位数只可能是 1000000000 本身,反转后是 1)。 但 P1553 就完全不一样了,所以养成习惯:数字反转一律用 long long,省得算半天范围。

标准模板(背下来)

long long rev = 0;
while(n > 0){
    rev = rev * 10 + n % 10;    // ★ 取末位接到新数末尾
    n /= 10;                    // 丢掉末位
}
// 循环结束时 n 被吃成 0,rev 就是反转后的数
⚠️ n = 0 时循环一次都不执行

如果输入是 0while(n > 0) 直接跳过,rev 保持 0 —— 输出 0答案正好是对的。 P1307 允许 N = 0,这个「巧合」帮了你。但如果题目要求反转后去掉前导零、且 0 要特殊处理,你就得留意了。

3进制转换:两个方向,两套模板

方向一:十进制 → M 进制(除基取余,倒序输出)

把十进制的 13 转成二进制:不停地除以 2,把余数记下来,最后把余数倒着读。

步骤除以 2 的商余数
13 ÷ 261
6 ÷ 230
3 ÷ 211
1 ÷ 201

余数依次是 1、0、1、1,倒过来读就是 1101 —— 也就是 (13)10 = (1101)2

方向二:M 进制 → 十进制(秦九韶,逐位累乘)

🎯 一个公式搞定,不用算幂

ans = ans * M + 这一位的数字,从最高位往最低位走一遍。

比如把 (1101)2 转回十进制:

读到的位计算ans
开始0
10×2+11
11×2+13
03×2+06
16×2+113

为什么对?拿 1101 展开看看:1×2³ + 1×2² + 0×2¹ + 1×2⁰。 每读一位就 ×2,相当于把前面所有已经读过的位的权重整体抬高一级 —— 跟你手算时从高位往下推是一模一样的思路。 这个方法叫「秦九韶算法」(也叫霍纳法则),后面处理超大整数、进制转换都会用到。

今天 P2084 要的是第三种:把式子「原样打出来」

注意 P2084 只要输出形如 1*2^4+0*2^2+1*2^0 的式子, 并不需要真的算出十进制结果,也不需要去做除法。它其实是「按位展开」+ 格式控制。

设字符串长度为 len,第 i 个字符(从 0 开始数)的权重就是 len - 1 - i

n = "10101"   len = 5
 i=0  '1'  权重 5-1-0 = 4   →  1*2^4
 i=1  '0'  权重 3           →  跳过(系数为 0 不输出)
 i=2  '1'  权重 2           →  1*2^2
 i=3  '0'  权重 1           →  跳过
 i=4  '1'  权重 0           →  1*2^0
 连接起来: 1*2^4+1*2^2+1*2^0
⚠️ 「+」要加在项与项之间,不是加在每项后面

如果每输出一项就补一个 +,你最后会得到 1*2^4+1*2^2+1*2^0+ —— 多一个加号,WA。

正确做法是用一个 bool first 标记:「不是第一项,才在前面补加号」。 这个「第一项不补分隔符」的技巧,输出用空格隔开的题目里也一样用(今天的 P5736、P5737、昨天的 P1554 都是这个套路)。

4「格式敏感」题目的通用套路:先分类,再分块

P1553 是典型的「细节题」—— 算法一点不难,难的是把每种情况都想全。这类题有个万能套路。

第一步:把输入当字符串读进来,然后「找标志符号」

找到什么说明这是怎么处理
末尾有 %百分数反转前面那段数字,% 原地不动
里面有 .小数小数点前、后两段各自反转,. 不动
里面有 /分数分子、分母各自反转,/ 不动
都没有整数整段反转
🎯 关键认识:「不交换位置」

题面反复强调「不交换整数部分与小数部分」「不交换分子与分母」—— 意思是 600.084 处理完还是 6.48(左边还是整数部分), 700/27 处理完是 7/72(左边还是分子)。
你只需要把标志符号切开的两块,各自内部反转,符号留在原地。

第二步:写两个「去零」小函数,别到处复制粘贴

反转后会多出 0,但整数部分要去的是前导 0,小数部分要去的是末尾 0 —— 方向正好相反!

处理哪一段去哪种 0例子
整数部分、分子、分母、百分数去掉前导 0"007" → "7"
小数部分去掉末尾 0"480" → "48"
string stripLeft(string s){        // 去前导 0,全 0 时保留一个 "0"
    int i = 0;
    while(i < (int)s.size() - 1 && s[i] == '0') i++;
    return s.substr(i);
}

string stripRight(string s){       // 去末尾 0,全 0 时保留一个 "0"
    int j = (int)s.size() - 1;
    while(j > 0 && s[j] == '0') j--;
    return s.substr(0, j + 1);
}
💡 那个 -1> 0 是干什么的

防止把整个串删空。如果输入是 0.0,反转后加起来全是 0:

· stripLeft("0"):循环条件 i < size()-1 保证至少留下 1 个字符 → 返回 "0"
· stripRight("0"):循环条件 j > 0 保证至少留下 1 个字符 → 返回 "0"

如果不加这个保护,就会返回空字符串,输出 . 这种见不得人的东西。

第三步:把标志切开的两块分别处理

字符串的 findsubstr 就是干这个的:

int p = s.find('.');              // 找到小数点的位置
string left  = s.substr(0, p);    // 从 0 开始,长度 p:整数部分
string right = s.substr(p + 1);   // 从 p+1 开始到末尾:小数部分
⚠️ substr(起点, 长度) 的第二个参数是长度,不是终点

s.substr(0, p) 意思是「从下标 0 开始,取 p 个字符」——正好是小数点前面那一段。
忘了第二个参数(s.substr(p+1))就表示「从 p+1 一直取到末尾」,这也是合法的写法。

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译、实际运行过,输出是你看到的真实结果。

P1307 [NOIP 2011 普及组] 数字反转 普及- · 取位反转

题意:给一个整数 N(可能是负数),把它的各位数字顺序倒过来组成新数。 新数必须是「正常的整数写法」:除非原数就是 0,否则反转后最高位不能是 0 —— 说白了就是要把末尾的 0 丢掉

⚠️ 数据范围 / 关键约束

−1,000,000,000 ≤ N ≤ 1,000,000,000(包含负数和 0)。

· N 的绝对值最大 10⁹,int 装得下,但用 long long 更保险;
· 必须处理负号——样例 2 就是负数;
· 必须处理 0——输入 0 时输出也得是 0。

思路

三步走,正好对应第 2 节的三个细节:

① 摘符号:用 bool neg 记住是不是负数,然后把 n 取绝对值变成正数。
② 反转rev = rev * 10 + n % 10; n /= 10; 循环到 n == 0。 这个写法会自动丢掉末尾的 0(前导 0 被 0 * 10 吃掉)。
③ 补符号:如果原来是负数,把 rev 取负再输出。

样例 1 输入
123
样例 1 输出
321
样例 2 输入
-380
样例 2 输出
-83

样例 2 的 -380 里,那个 0 就是「反转后变成前导 0」的那个 0 —— 必须丢掉,所以是 -83 而不是 -083。

完整代码

#include <iostream>
using namespace std;

int main(){
    long long n;
    cin >> n;

    bool neg = (n < 0);       // 先记住符号,把负数变成正数再处理
    if(neg) n = -n;

    long long rev = 0;
    while(n > 0){
        rev = rev * 10 + n % 10;   // 取末位,接到新数的末尾
        n /= 10;                   // 丢掉末位
    }

    if(neg) rev = -rev;
    cout << rev << endl;
    return 0;
}
✅ 实测结果

官方样例 1 123321 完全一致
官方样例 2 -380-83 完全一致

额外边界测试(都是真实跑出来的):
· 00(while 不进循环,rev 保持 0)✅
· 10000000001(9 个 0 全被丢掉)✅
· -1000000000-1
· 999999999999999999(反转后不变)✅

易错点

  • 忘了负号:直接把负数丢进 while,n % 10 会出负数,结果一团乱。一定要先摘符号。
  • 以为要手动删前导 0:不用!rev * 10 + 0 在 rev 还是 0 的时候等于 0,前导 0 自动没了。 如果你自己再写一段去零代码,反而容易写错。
  • string 反转也行(reverse + 去前导 0),但那样要处理负号字符,比算术法麻烦。这题用算术法最省事。
  • 输出别忘换行 —— 这题的输出只有一行,但习惯上还是 cout << rev << endl;
P1553 数字反转(升级版) 普及- · 分类讨论 + 字符串

题意:还是数字反转,但这次的「数」有四种长相:整数、小数、分数、百分数。 规则是:每种格式的各个数字块内部各自反转,格式符号(小数点、斜杠、百分号)的位置完全不动。

输入是什么怎么反转输出
5087462整数整串反转,去掉前导 02647805
600.084小数整数部分、小数部分各自反转
(小数部分还要去末尾 0)
6.48
700/27分数分子、分母各自反转(不约分、不交换)7/72
8670%百分数只反转数字部分768%
⚠️ 数据范围 / 关键约束(这题的范围决定了写法)

· 整数:不大于 20 位
· 小数:整数部分和小数部分各不大于 10 位
· 分数:分子和分母各不大于 10 位
· 百分数:分子不大于 19 位
· 数据不存在负数

20 位的整数超过 long long(最多 19 位),所以这题必须用字符串按位处理,不能读成整数再算。

另外题面保证:小数部分「末尾没有多余的 0」(是正常小数写法),分母不为 0,分数不约分。 也就是说 输入是「规范」的,你只需要负责把输出的 0 处理干净。

思路

① 读成字符串,然后看它里面有什么符号,分成四类:

末尾是 '%'      → 百分数
其中有 '.'      → 小数
其中有 '/'      → 分数
都没有          → 整数

注意判断顺序:先判 %,因为百分数里只会出现 %,不会同时出现别的符号。 然后判 ./(题面保证不会同时出现)。

② 写两个去零函数。这是本题最容易出错的地方:

· 整数部分反转后可能带前导 0600006 → 要变成 6)→ 用 stripLeft
· 小数部分反转后可能带末尾 0084480 → 要变成 48)→ 用 stripRight

方向是反的!记混了这题就全错。为什么方向不同?因为小数点前是「整数」,整数不能有前导 0; 小数点后是「小数」,小数不能有多余的末尾 0 —— 它们各自遵守自己那一侧的书写规范。

③ 每种情况拼回去:

情况输出怎么拼
整数revInt(整串)
小数revInt(小数点前) + "." + revDec(小数点后)
分数revInt(斜杠前) + "/" + revInt(斜杠后)
百分数revInt(百分号前) + "%"

注意分数和百分数的两段都用 revInt(去前导 0),只有小数的后半段用 revDec(去末尾 0)。 因为题目说「分数与整数翻转相关规定见上」「百分数见与整数翻转相关内容」—— 也就是说它们都按整数规矩来。

样例 1 输入
5087462
样例 1 输出
2647805
样例 2 输入
600.084
样例 2 输出
6.48
样例 3 输入
700/27
样例 3 输出
7/72
样例 4 输入
8670%
样例 4 输出
768%

完整代码

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

// 去掉前导 0;如果全是 0,就保留一个 "0"
string stripLeft(string s){
    int i = 0;
    while(i < (int)s.size() - 1 && s[i] == '0') i++;
    return s.substr(i);
}

// 去掉末尾 0;如果全是 0,就保留一个 "0"
string stripRight(string s){
    int j = (int)s.size() - 1;
    while(j > 0 && s[j] == '0') j--;
    return s.substr(0, j + 1);
}

// 整数部分的反转:整体反转 + 去掉前导 0
string revInt(string s){
    reverse(s.begin(), s.end());
    return stripLeft(s);
}

// 小数部分的反转:整体反转 + 去掉末尾 0
string revDec(string s){
    reverse(s.begin(), s.end());
    return stripRight(s);
}

int main(){
    string s;
    cin >> s;
    int n = s.size();

    if(s[n - 1] == '%'){
        // 百分数:只反转数字部分,% 留在最后
        cout << revInt(s.substr(0, n - 1)) << "%" << endl;
    }
    else if(s.find('.') != string::npos){
        // 小数:整数部分、小数部分各自反转,小数点不动
        int p = s.find('.');
        cout << revInt(s.substr(0, p)) << "."
             << revDec(s.substr(p + 1)) << endl;
    }
    else if(s.find('/') != string::npos){
        // 分数:分子、分母各自反转,斜杠不动
        int p = s.find('/');
        cout << revInt(s.substr(0, p)) << "/"
             << revInt(s.substr(p + 1)) << endl;
    }
    else{
        // 整数
        cout << revInt(s) << endl;
    }
    return 0;
}
✅ 实测结果

四个官方样例全部通过,逐字节一致(实际运行输出):
· 50874622647805 一致
· 600.0846.48 一致
· 700/277/72 一致
· 8670%768% 一致

另外我把每种情况的边界都跑了一遍(真实输出):

输入输出考察点
00整数 0
10001末尾三个 0 全丢
0.00.0小数的两段都是 0,不能被删空
0.50.5整数部分是 0
230.032.0整数部分去前导 0,小数部分保留 0
1.0011.1小数部分去末尾 0(100 → 1)
100.0011.1两段同时去各自方向的 0
0/70/7分子是 0
1200/3021/3分子分母都去前导 0
120%21%百分数去前导 0
1000000000000000000%1%19 位,验证 long long 装不下时照样正确
1234567890.1234567890987654321.0987654321两段各 10 位,注意小数部分保留前导 0

最后一行特别值得看:小数部分 1234567890 反转成 0987654321这里的 0 必须留着(它在小数部分,属于合法数字),如果错用了去前导 0 的函数就会变成 987654321.987654321,答案就错了。

易错点

  • long long 读输入 → 20 位整数直接溢出。必须当字符串读。
  • 两个去零函数用反了:小数部分用 stripLeft 会把 .0987654321 变成 .987654321,错。 记住口诀:点前点后都反转,点前去头、点后去尾。
  • 把去零函数写成能删空while(s[i]=='0') i++; 遇到 "0" 会返回空串。 必须加 i < size()-1 的保护。
  • 判断顺序:百分号要最先判(用 s[n-1] == '%'),否则可能被别的分支抢走。
  • 以为分数要「约分」:题面明说「分数不约分」,1200/30 就是 21/3,不要自作聪明化简成 7/1
  • 以为要交换分子分母:题面明说「不交换」——分子还是分子,只是内部反转。
P1200 [USACO1.1] 你的飞碟在这儿 Your Ride Is Here 入门 · 字符乘积取模

题意:把彗星名和小组名各转换成一个数字:每个字母按 A=1、B=2 …… Z=26 换成分值,然后全部乘起来。 如果两个数字除以 47 的余数相同,输出 GO,否则输出 STAY

⚠️ 数据范围 / 关键约束

两个名字都是不超过 6 个字符的大写字母串(输入格式写的是「长度为 1 到 6」),没有空格和标点。

· 字母最多 6 个,最大乘积是 26⁶ = 308,915,776,比 int 上限(约 2.1 × 10⁹)小,所以这题其实不会溢出
· 但养成习惯:乘积类题目一律边乘边取模,这样无论字母多少都不会溢出。

思路

① 字母 → 分值s[i] - 'A' + 1。因为 'A' - 'A' = 0,加 1 就是 1;'Z' - 'A' = 25,加 1 就是 26。✅

② 边乘边取模

res = res * (s[i] - 'A' + 1) % 47;
🎯 为什么可以先取模再继续乘

因为「取余」和「乘法」可以换位置:(a × b) mod m == ((a mod m) × (b mod m)) mod m

直觉上想:一个数除以 47 的余数,只由它「去掉若干个 47 之后剩下的部分」决定, 再乘上下一个数,多出来的那些 47 的倍数还是 47 的倍数,不影响余数。
所以每乘一次就取一次模,结果和全乘完再取模完全相同,但数字永远不会变大。

③ 比一下余数,相等输出 GO,不等输出 STAY

样例 1 输入
COMETQ
HVNGAT
样例 1 输出
GO
样例 2 输入
ABSTAR
USACO
样例 2 输出
STAY

样例 1 手算核对:COMETQ = 3×15×13×5×20×17 = 994500,994500 mod 47 = 27; HVNGAT = 8×22×14×7×1×20 = 344960,344960 mod 47 = 27。相等 → GO ✅
样例 2:ABSTAR = 13680,13680 mod 47 = 3;USACO = 17955,17955 mod 47 = 1。不相等 → STAY ✅

完整代码

#include <iostream>
#include <string>
using namespace std;

// 把一个名字转换成数字:所有字母的积,A=1, Z=26
// 边乘边取模,避免数字变大(取模可以和乘法交换顺序)
int nameToNumber(string s){
    int res = 1;
    for(int i = 0; i < (int)s.size(); i++){
        res = res * (s[i] - 'A' + 1) % 47;
    }
    return res;
}

int main(){
    string comet, group;
    cin >> comet >> group;          // 第 1 行彗星名,第 2 行小组名

    if(nameToNumber(comet) == nameToNumber(group))
        cout << "GO" << endl;
    else
        cout << "STAY" << endl;

    return 0;
}
✅ 实测结果

官方样例 1 COMETQ / HVNGATGO 完全一致
官方样例 2 ABSTAR / USACOSTAY 完全一致

编译时 -Wall 没有任何警告。cin >> comet >> group 能自动跨行读取(第一行读完读到行末空白就停下,接着读第二行),所以两行输入用一次 cin 就能搞定。

易错点

  • 写成 s[i] - 'A':这样 A 会变成 0,整串乘积变成 0,全错。别忘了 + 1
  • 写成 s[i] - 'a' + 1:输入是大写字母,用小写公式会得到负数('A'-'a' = -32)。看准大小写。
  • 忘记 res 要初始化为 1:乘积的初始值必须是 1,写成 0 的话结果永远是 0。
  • 输出的是 GO / STAY 全大写,不是 Go、go。输出字符串要一个字母一个字母对着题面抄。
  • 本题名字顺序是「先彗星、后小组」,但因为两边都要算、只比余数,谁先谁后不影响结果。读的时候按输入顺序读就行。
P2084 进制转换 入门 · 按位输出格式

题意:给一个 M 进制(1 < M < 10)的数 N, 把它按「十进制展开式」的样子打印出来:每位的数字 × M 的该位次幂,系数为 0 的项要省略,项与项之间用 + 连接。

注意:题目只要打印这个式子,不要你真的算出十进制结果,也不要输出等号。

⚠️ 数据范围 / 关键约束

1 < M < 10,N 的位数不超过 1000,且 M、N 均为正整数。

· 1000 位!这又是一个「必须用字符串」的信号 —— 别说 long long,什么整数类型都装不下 1000 位;
· M 只有 2~9,所以每一位就是一个数字字符,不会出现 A、B 这种字母;
· 用字符串存 N,读取长度就是位数,幂次 = 位数 − 1 − 当前下标

思路

① 用 string 存 N,因为可能有 1000 位。

② 从左往右扫每一位,设字符串长度为 len,下标为 i(从 0 开始),那么这一位的幂次是:

幂次 = len - 1 - i

比如 "10101" 长度 5:下标 0 是最高位,幂次 4;下标 4 是最低位,幂次 0。✅

③ 系数是 0 就跳过if(n[i] == '0') continue;。题目明确说「当系数为 0 时,该单项式要省略」。

④ 处理加号:用一个 bool first 标记。是第一个要输出的项,前面不加 +;否则先输出一个 +

以样例 2 10101 为例,走一遍:

in[i]幂次动作已输出
0'1'4first,不加 +,输出 1*2^41*2^4
1'0'3系数为 0,跳过1*2^4
2'1'2先加 +,再输出 1*2^21*2^4+1*2^2
3'0'1跳过1*2^4+1*2^2
4'1'0先加 +,再输出 1*2^01*2^4+1*2^2+1*2^0
样例 1 输入
2 10101
样例 1 输出
1*2^4+1*2^2+1*2^0

完整代码

#include <iostream>
#include <string>
using namespace std;

int main(){
    int m;              // 进制
    string n;           // 位数可能到 1000 位,必须用字符串存
    cin >> m >> n;

    int len = n.size();
    bool first = true;  // 是不是第一个要输出的单项式

    for(int i = 0; i < len; i++){
        if(n[i] == '0') continue;      // 系数为 0,整项省略
        if(!first) cout << "+";        // 不是第一项,前面补一个加号
        first = false;
        cout << n[i] << "*" << m << "^" << (len - 1 - i);
    }

    if(first) cout << 0;   // 保险:万一这个数全是 0(题面数据不会出现)
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 2 101011*2^4+1*2^2+1*2^0 完全一致

另外几组真实输出:

输入输出
2 1000001*2^5(只有一项,前面没有多余的 +)
3 10201021*3^6+2*3^4+1*3^2+2*3^0(0 的位置被跳过,幂次仍然连续不重复)
9 9999999*9^5+9*9^4+9*9^3+9*9^2+9*9^1+9*9^0(仔细看最后一项,9^0 也要打出来

易错点

  • 把 N 读成整数:最多 1000 位,读进去就废了。必须 string n
  • 幂次算错:是 len - 1 - i,不是 i,也不是 len - i。 最高位是 len-1 次,最低位是 0 次 —— 一定要在心里验算一个 5 位的例子。
  • 加号加在项后面 → 末尾多一个 +。要用 first 标记,加号加在项前面
  • 系数为 0 的项还输出 → 多出 0*2^3 这种,格式错。题面明确要求省略。
  • 最后一行的换行:题目说「共一行」,加个 endl 结尾最稳妥。

今日常见错误汇总

错误写法后果正确写法
long long n; cin >> n; 处理 20 位的数 溢出,读进来的值完全不对,全 WA string s; cin >> s; 按位处理
反转负数时不摘符号,直接 while(n > 0) n 是负数,循环一次都不执行,输出 0 bool neg = n < 0; if(neg) n = -n;
小数部分用了去前导 0 的函数 .0987654321 变成 .987654321,丢了一位有效数字 点前去头、点后去尾
去零函数写成 while(s[i]=='0') i++; 输入 "0" 时返回空串,输出只剩一个 "." 或 "%" 加保护:i < s.size() - 1
边输出边加 "+",最后不撤销 式子末尾多一个加号,格式错 用 bool first,加号加在项前面
P2084 用 int 读 N 1000 位的数,直接废掉 string n,幂次 = len-1-i
s[i] - 'A' 当字母分值 A 变成 0,乘积全变 0 s[i] - 'A' + 1
乘积变量初始化成 0 乘出来永远是 0 int res = 1;

自测:你真的会了吗

第 1 题:反转 1050,你写的代码会输出什么?

501

过程:rev = 0×10+0 = 0 → rev = 0×10+5 = 5 → rev = 5×10+0 = 50 → rev = 50×10+1 = 501。 第一个 0(n 的末位)被吃掉了,但第二个 0 已经是 rev 的末位,会被保留 —— 这是正确的,因为 501 的 0 在中间,不属于「最高位」。

第 2 题:小数 3.140 反转后应该输出什么?用错函数会怎样?

:小数部分 140 反转成 041,去掉末尾 0 还是 041……等等,这里要注意!

题面保证输入不会有「末尾多余的 0」,所以 3.140 这种输入不会出现(是 3.14)。 真正会遇到的是:小数部分反转后产生了末尾 0,比如 600.084084 反转成 480末尾的 0 要去掉48,答案是 6.48

如果错用了去前导 0 的函数:480 的前导不是 0,所以原样输出 480,答案是 6.480 —— 错。

第 3 题:为什么 P1200 可以「边乘边取模」?

:因为 (a × b) mod m = ((a mod m) × (b mod m)) mod m

先取模只是「提前把多余的 47 的倍数扔掉」,剩下的余数再乘下一个数,扔掉的倍数部分仍然是 47 的倍数,不会影响最终余数。 这样做的好处是数字永远很小(小于 26 × 47),绝对不会溢出。

第 4 题(手写代码):写一个函数,把整数 n 反转(n 可以是负数),要求用字符串实现。
long long reverseNumber(long long n){
    if(n == 0) return 0;                    // 0 单独处理
    bool neg = (n < 0);
    if(neg) n = -n;
    string s = to_string(n);                // 数字 → 字符串
    reverse(s.begin(), s.end());            // 反转
    int i = 0;
    while(i < (int)s.size() - 1 && s[i] == '0') i++;   // 去前导 0
    long long res = stoll(s.substr(i));     // 字符串 → 数字
    return neg ? -res : res;
}

★ 这个版本顺带演示了今天的转换函数:to_stringstoll。注意它不能处理 20 位的数(会溢出), 所以 P1553 里我们全程用字符串,最后也没转回数字 —— 能不转就不转,是最安全的选择。

速查卡(截图存手机)

【数字 ⇄ 字符串】
  to_string(x)          // 整数 → 字符串
  stoi(s) / stoll(s)    // 字符串 → 整数 / 长整数
  stod(s)               // 字符串 → 小数
  reverse(s.begin(), s.end())   // 反转整串(需 <algorithm>)
  // 判断:位数超过 19 位 → 必须用 string

【数字反转(算术法)】
  bool neg = (n < 0);
  if(neg) n = -n;
  long long rev = 0;
  while(n > 0){
      rev = rev * 10 + n % 10;   // 前导 0 自动消失
      n /= 10;
  }
  if(neg) rev = -rev;

【去零两兄弟(字符串法)】
  stripLeft : 去前导 0,全 0 保留一个 "0"
      while(i < size()-1 && s[i]=='0') i++;
  stripRight: 去末尾 0,全 0 保留一个 "0"
      while(j > 0 && s[j]=='0') j--;
  // 整数/分子/分母/百分数 → stripLeft
  // 小数部分              → stripRight

【按符号分类(格式敏感题)】
  s[n-1] == '%'        → 百分数
  s.find('.') != npos  → 小数
  s.find('/') != npos  → 分数
  都不是               → 整数

【分段取子串】
  int p = s.find('.');
  s.substr(0, p)       // 前一段(长度 p)
  s.substr(p + 1)      // 后一段(到末尾)

【进制转换】
  十进制 → M 进制:反复 %M 取余、/M,余数倒序读
  M 进制 → 十进制:ans = ans * M + 该位数字
  // P2084 只要打式子:幂次 = len - 1 - i

【输出分隔符】
  bool first = true;
  for(...){
      if(!first) cout << "+";   // 或 " "
      first = false;
      ...输出内容...
  }

明天预告 · Day 10

函数:把代码写成模块

今天其实已经在不知不觉地「用函数」了 —— P1553 里的 stripLeftstripRightrevIntrevDec 都是我们自己写的函数;P1200 里的 nameToNumber 也是。 它们让 main 变得干净好读:一眼就能看出「先分类,再各自处理」。

明天就把这件事学明白:函数三要素(返回类型、函数名、参数列表)、值传递和引用传递的区别、为什么大数组要开在全局而不是 main 里面, 以及竞赛里出现频率最高的两个函数模板 —— 判断素数判断闰年。正好对应明天的 P5736 和 P5737。

预习建议:随便翻一个你之前写过的最长的代码,看看能不能把其中重复的部分抽成一个函数。

ACM 一个月学习计划 · DAY 10

Day 10 · 函数:把代码写成模块

把「判断闰年」「算两点距离」「判断素数」这些逻辑各装一个盒子,主程序就只剩下一句句人话

今日目标:会自己写函数,理解值传递 / 引用传递、全局数组 / 局部数组的区别,背下素数判断和闰年判断两个模板
预计用时:约 3 小时(含做题)  难度:入门 ~ 普及-
题目来源:洛谷 P5735 / P5736 / P5737 / P1304
DAY 10 / 30

今日时间安排

0 - 25 min函数三要素、怎么写、怎么调用(第 1-2 节)
25 - 50 min值传递 / 引用传递、全局数组 / 局部数组(第 3 节)
50 - 70 min两个必背模板:判素数、判闰年(第 4 节)
70 - 160 min做 4 道题,每题限时 30 分钟
160 - 180 min对照本文题解补错题 + 做自测
💡 和昨天内容的衔接

昨天你其实已经在写函数了,只是没意识到:P1553 里的 stripLeftrevInt, P1200 里的 nameToNumber —— 那些就是函数。

它们带来了什么好处?main 里只剩四行 if-else,一眼就能看懂「先分类、再各自处理」的骨架, 细节全都藏进了一个个有名字的小盒子里。今天就正式把这件事学明白。

1为什么需要函数:先看一段「没有函数」的代码

题目要求:判断 5 个数是不是素数。

// 没有函数的写法
int main(){
    int a[5];
    for(int i = 0; i < 5; i++) cin >> a[i];

    for(int i = 0; i < 5; i++){
        bool ok = true;
        if(a[i] < 2) ok = false;
        else for(int j = 2; j * j <= a[i]; j++)
                 if(a[i] % j == 0){ ok = false; break; }
        cout << a[i] << (ok ? " 是素数" : " 不是素数") << endl;
    }
}

再看有函数的写法:

// 有函数的写法
bool isPrime(int n){ /* ……判断逻辑…… */ }

int main(){
    int a[5];
    for(int i = 0; i < 5; i++) cin >> a[i];
    for(int i = 0; i < 5; i++)
        cout << a[i] << (isPrime(a[i]) ? " 是素数" : " 不是素数") << endl;
}

第二段代码的 main 里,「怎么判断素数」这件事消失了,只剩下一句 isPrime(a[i]) —— 读代码的人一眼就懂,不用去抠那一堆循环和 break

🎯 函数带来三件好事

① 好读:isPrime(x)isLeap(y)dist(x1,y1,x2,y2) 这些名字本身就是注释, 主程序读起来像人话。

② 好改:判断逻辑写错了,只需要改函数里那一处,不用满屏找。

③ 好复用:P1304 要判断上万次素数,P5736 要判断上百次 —— 同一个函数到处调用,代码只写一遍。

⚠️ 竞赛里函数还有一个隐藏好处:方便调试

你可以在函数里临时加一句 cout << "check " << n << endl; 看它被调用了哪些参数, 查完删掉就行。如果所有逻辑挤在 main 里,你根本不知道该在哪打这条语句。

2函数三要素:返回类型、函数名、参数列表

函数长什么样

返回类型  函数名 (参数列表) {
    函数体
    return 返回值;      // 返回类型是 void 时可以不写
}

拿昨天的代码当例子:

double dist(double x1, double y1, double x2, double y2){
    return sqrt((x2-x1)*(x2-x1) + (y2-y1)*(y2-y1));
}
要素这里是什么怎么想
返回类型double算出来的距离是小数 → 用 double
函数名dist起一个「一看就知道干什么」的名字
参数列表四个 double要算距离,必须先知道两个点的坐标
函数体return ...干活的地方,最后 return 交作业

返回类型怎么选

函数要给出什么返回类型例子
是 / 否 的结论boolbool isPrime(int n)
一个整数结果int / long longint countLeap(int x, int y)
一个小数结果doubledouble dist(...)
什么都不用返回(只打印 / 只修改参数)voidvoid printAns(int x)
💡 起名建议:动宾结构 + 小驼峰

isPrime(是素数吗)、isLeap(是闰年吗)、countLeap(数闰年)、 dist(求距离)、nameToNumber(名字转数字)。

返回 bool 的函数用 is / has 开头,这几乎是全世界的默契。

return 的两个作用

① 交作业:把你的结果送回调用处。
② 立刻结束函数return 一执行,函数马上停,后面的代码都不再走。

正因为作用 ②,素数判断可以写得这么短:

bool isPrime(int n){
    if(n < 2) return false;          // 提前下班
    for(int i = 2; i * i <= n; i++){
        if(n % i == 0) return false;  // 找到一个因数就收工,不用再往下找
    }
    return true;                      // 一个因数都没找到 → 是素数
}

函数要写在 main 前面

🚫 最常见的编译错误:函数写在 main 后面

C++ 是从上往下读的。你把函数写在 main 后面,main 里调用的时候编译器还不认识这个名字, 会报 'isPrime' was not declared in this scope

两种解法:
① 直接把函数整个写在 main 前面(推荐,最省事);
② 或者先写一行「函数声明」(也叫函数原型),把函数体留在后面:

bool isPrime(int n);      // 声明:告诉编译器「有这个函数」

int main(){
    // 这里就可以调用 isPrime 了
}

bool isPrime(int n){      // 定义:真正的实现
    // ……
}

3值传递 vs 引用传递 · 局部数组 vs 全局数组

值传递:函数拿到的是「复印件」

默认情况下,参数是值传递 —— 调用时会把实参的值复制一份给形参。函数里改的是这份复印件,外面的变量毫发无伤

写个交换两个数的函数试试(真实运行结果):

void swapBad(int a, int b){        // 值传递
    int t = a; a = b; b = t;
}
void swapGood(int &a, int &b){     // 引用传递:多了一个 &
    int t = a; a = b; b = t;
}

int main(){
    int x = 1, y = 2;
    swapBad(x, y);
    cout << "值传递之后:   x=" << x << " y=" << y << endl;
    swapGood(x, y);
    cout << "引用传递之后: x=" << x << " y=" << y << endl;
}
✅ 实测输出
值传递之后:   x=1 y=2
引用传递之后: x=2 y=1

值传递:白忙一场,x、y 一点没变。引用传递:交换成功。

引用传递:在参数前加一个 &

🎯 记住这张对照表
写法名字函数里改 a,外面的 x 会变吗
void f(int a)值传递不会(改的是复印件)
void f(int &a)引用传递(a 就是 x 的另一个名字)

「引用」可以理解成给同一个变量起了个别名void f(int &a) 里的 a,和外面的 x 就是同一个东西, 改哪个都是改它。

💡 什么时候该用引用?

① 需要修改实参时(比如交换、把答案写回参数里)。
② 传递「大东西」时:传数组、传 string、传结构体。值传递要把整个东西复制一遍, 又慢又费内存;用引用 const string &s 就只是「贴个名字」,零复制。
③ 其他情况(int、double 这种小东西):值传递更安全,函数绝对改不坏你的变量。

数组做参数:它天生就是「地址传递」

数组有个特殊之处:把数组传进函数时,传的不是整份拷贝,而是首地址。 所以函数里改了数组元素,外面会跟着变 —— 哪怕你没写 &

void addOne(int a[], int n){       // 中括号里可以不写长度
    for(int i = 0; i < n; i++) a[i]++;    // 外面的数组真的会被改
}
⚠️ 数组传参必须额外告诉函数「有几个元素」

因为数组传进去之后只知道自己开头在哪,不知道自己有多长。所以习惯上总是这么写: void f(int a[], int n) —— 数组 + 长度,两个参数一起传。

另外函数里用 sizeof(a) 得到的不是数组的总字节数(而是指针的大小,通常是 8),别用它算长度。

局部数组 vs 全局数组:一个会爆栈的真实事故

「全局」就是写在所有函数外面(包括 main 外面)的变量;「局部」是写在函数体里面的变量。两者最大的区别是:

放哪存在哪多大会自动清零吗
全局(函数外)数据段受内存限制,能开很大✅ 会
局部(函数内)只有 1~2 MB 左右❌ 不会,是随机值

我用同一段填数组求和的代码,只改「数组放里放外」跑了一遍(真实结果):

数组大小位置实测结果
int a[250000]1.0 MB局部✅ 正常输出 31249875000
int a[500000]1.9 MB局部✅ 正常输出 124999750000
int a[1000000]3.8 MB局部爆栈崩掉,退出码 0xC00000FD
int a[3000000]11.4 MB局部爆栈崩掉,退出码 0xC00000FD
int a[3000000]11.4 MB全局✅ 正常输出 4499998500000
🚨 结论:大数组一律开在全局

0xC00000FD 就是 Windows 的「栈溢出」(Stack Overflow)。 数组一开大,程序连第一行都跑不到就直接死掉 —— 而且它不一定每次都崩, 有时候只是「莫名其妙 WA」,这是竞赛里最难查的一类 bug。

本机实测的临界点就在 2MB 附近:1.9MB 还行,3.8MB 就崩。所以规矩很简单:

凡是几百 KB 以上的数组(大概 int a[100000] 往上),统统写在 main 外面。

写在全局还有两个附带好处:自动初始化为 0(不用写 = {0}), 而且写函数的时候可以直接用,不用一个个当参数传来传去。

💡 今天的 P1304 就会用到这一条

它的素数筛要开一个 bool isPrime[10005] 的数组,而且要在循环里反复访问。 把这个数组开成全局,函数和 main 都能直接用,代码立刻清爽一大截。

4两个必背模板:判断素数、判断闰年

今天的 4 道题,全都是在考这两个模板 + 一点包装。背下来,以后遇到直接用。

模板一:判断素数(试除法)

bool isPrime(int n){
    if(n < 2) return false;                     // 0 和 1 都不是素数
    for(int i = 2; (long long)i * i <= n; i++){  // 只需试到 sqrt(n)
        if(n % i == 0) return false;            // 找到因数 → 不是素数
    }
    return true;                                // 一直没有因数 → 是素数
}

两个必须记住的点:

① 为什么要 n < 2 特判?因为 0 和 1 都不是素数(素数定义:大于 1 且只有 1 和自身两个因数)。 如果不判,n = 1 会跳过循环直接 return true,把 1 当成素数 —— 今天 P5736 的输入里就有 1 这种数。

② 为什么只试到 √n?因为因数总是成对出现的:如果 n = a × ba ≤ b,那么 a ≤ √n。 也就是说,n 如果有因数,一定有一个不超过 √n。所以试到 √n 就够了 —— 判断 10⁵ 只需要试 316 次,而不是 10⁵ 次。

⚠️ 为什么写成 (long long)i * i <= n

如果 n 接近 int 上限(约 2.1×10⁹),那么 i 也会接近 46341, 而 i * i 会超过 int 上限 → 溢出成负数 → 循环条件永远成立 → 死循环或乱答。

i 临时转成 long long 再乘,就不会溢出。今天 ai ≤ 10⁵ 用不到,但这个习惯必须从现在养起。
另一种同样安全的写法是 i <= n / i(用除法代替乘法)。

模板二:判断闰年

🎯 闰年规则(背下来)

能被 4 整除,且不能被 100 整除;或者能被 400 整除。

bool isLeap(int y){
    if(y % 400 == 0) return true;    // 2000 是闰年
    if(y % 100 == 0) return false;   // 1900 不是闰年
    return y % 4 == 0;               // 其余的看能不能被 4 整除
}

为什么判断顺序是先 400 再 100?因为 2000 既能被 400 整除、也能被 100 整除, 两条规则冲突。所以必须先捡出「能被 400 整除」这一种,剩下的才轮得到「能被 100 整除就否掉」。

也可以写成一行:

return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);

两种写法都行,但多行版更好调试 —— 万一判错,你能一眼看出是哪条规则的问题。

年份能被4整除能被100整除能被400整除是闰年吗
1992
1900不是(世纪年陷阱)
2000
2001不是

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译、实际运行过,输出是你看到的真实结果。

P5735 【深基7.例1】距离函数 入门 · 函数封装

题意:给出平面上不共线的三个点的坐标(实数),求这三点围成的三角形的周长,保留两位小数。

⚠️ 数据范围 / 关键约束

坐标都是实数,绝对值不超过 100,小数点后最多 3 位。

· 坐标有小数 → 必须用 double,不能用 int;
· 距离公式是 √((x2−x1)² + (y2−y1)²),要用 sqrt()(需要 <cmath>);
· 保留两位小数 → printf("%.2f")(需要 <cstdio>)。

思路

这题是「把公式封装成函数」的教科书例子。三点围成的三角形,三条边正好是 AB、BC、CA:

周长 = dist(A,B) + dist(B,C) + dist(C,A)

写一个 dist(x1, y1, x2, y2) 求两点距离,然后调用三次 —— 这就是函数最大的价值:公式只写一遍,用几次调几次。

输入是三行、每行两个数,但 cin 会自动跳过所有空白(空格、换行都算空白), 所以直接 cin >> x1 >> y1 >> x2 >> y2 >> x3 >> y3; 一次读完就行,完全不用管它在哪一行换行。

样例 1 输入
0 0
0 3
4 0
样例 1 输出
12.00

手算核对:(0,0)-(0,3) 长度 3;(0,3)-(4,0) 长度 √(16+9) = 5;(4,0)-(0,0) 长度 4。3+5+4 = 12 → 12.00 ✅

完整代码

#include <iostream>
#include <cstdio>
#include <cmath>
using namespace std;

// 求两点之间的距离(今天的主角:把公式封装成函数)
double dist(double x1, double y1, double x2, double y2){
    return sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
}

int main(){
    double x1, y1, x2, y2, x3, y3;
    cin >> x1 >> y1 >> x2 >> y2 >> x3 >> y3;

    double ans = dist(x1, y1, x2, y2)
               + dist(x2, y2, x3, y3)
               + dist(x3, y3, x1, y1);

    printf("%.2f\n", ans);     // 保留两位小数
    return 0;
}
✅ 实测结果

官方样例 0 0 / 0 3 / 4 012.00 完全一致

额外测试(真实输出):
· 0 0 / 1 0 / 0 13.41(1 + 1 + √2 ≈ 3.414213… 保留两位 3.41)✅
· 100.00 100.00 / -100.00 -100.00 / 100.00 -100.00682.84 (200 + 200 + 282.8427… = 682.8427… → 682.84)✅
-O2 -Wall 编译无任何警告。

易错点

  • int 读坐标:输入 0.5 这种小数会被读成 0,直接错。坐标是实数就必须 double。
  • float:精度只有 7 位左右,本题数据小,勉强能过,但没必要冒这个险 —— 竞赛里小数一律 double
  • 写成 (x2-x1)^2:C++ 里 ^异或,不是乘方!必须老老实实写成 (x2-x1)*(x2-x1),或者用 pow(x2-x1, 2)(不推荐,慢且返回 double)。
  • 忘写 #include <cmath>sqrt 找不到。用 printf 则要 <cstdio>
  • 输出用 cout << ans:会打出 12 或 12.0000000 这样的一串,不是两位小数。要么 printf("%.2f"),要么 cout << fixed << setprecision(2)(后者要 <iomanip>)。
P5736 【深基7.例2】质数筛 入门 · 素数判断

题意:读入 n 个正整数,把其中的合数和 1 全部筛掉,按原顺序输出剩下的素数,用空格隔开。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 100,1 ≤ ai ≤ 10⁵

· ai 最小是 1,而 1 不是素数 —— 这是本题最大的坑(很多人的 isPrime 会把 1 判成素数);
· 10⁵ 的平方根约 316,所以试除法最多试 316 次,100 个数总共 3 万多次运算,快到可以忽略
· 题目要求「全部储存在数组中」—— 所以用一个数组存读入,再遍历判断。

思路

① 先把 n 个数读进数组(题目明确要求用数组存)。

② 写 isPrime 函数,即第 4 节的模板一:小于 2 直接否掉,然后从 2 试到 √n。

③ 遍历数组,是素数就输出,注意用 first 标记控制空格,避免行尾多一个空格。

💡 为什么这题不用埃氏筛

埃氏筛适合「筛一次、查很多次」。本题 n 只有 100、值也只有 10⁵,对每个数单独试除反而更简单 —— 没有额外数组,也没有「哪些数需要筛」的边界问题。

真正需要筛的是今天的 P1304(要判断几千个数),那里我们就会换上筛法。什么规模用什么工具,别为了炫技把简单题写复杂。

样例 1 输入
5
3 4 5 6 7
样例 1 输出
3 5 7

完整代码

#include <iostream>
using namespace std;

// 判断一个数是不是质数
bool isPrime(int n){
    if(n < 2) return false;                     // 0 和 1 都不是质数
    for(int i = 2; (long long)i * i <= n; i++){ // 只需试到 sqrt(n)
        if(n % i == 0) return false;
    }
    return true;
}

int main(){
    int n;
    cin >> n;

    int a[105];
    for(int i = 0; i < n; i++) cin >> a[i];      // 题目要求先全部存进数组

    bool first = true;
    for(int i = 0; i < n; i++){
        if(isPrime(a[i])){
            if(!first) cout << " ";              // 不是第一个才补空格
            first = false;
            cout << a[i];
        }
    }
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 3 4 5 6 73 5 7 完全一致

额外测试(真实输出):
· 1 个数,值为 1 → 输出一个空行(1 不是素数,被正确筛掉)✅
· 1 2 99991 100000 972 99991 97(99991、97 都是素数,100000 是偶数被筛掉)✅
· n = 100、100 个数全是 100000 → 空行,耗时 15 ms✅

易错点

  • isPrime 忘了 n < 2 的特判:那么 1 会被判成素数输出 —— 而本题的 ai 正好可以等于 1。
  • 循环写成 i <= n:会慢到 10⁵ 次,而且逻辑上也多余(试到 √n 就够)。
  • 循环写成 i < n / 2:虽然结果对,但没必要试那么多。用 i * i <= n 最省事。
  • 行尾多一个空格:某些评测会比较严格。first 标记,或写 if(i != 0) cout << " ";
  • 数组开太小:n ≤ 100,开 a[105] 够了,但别开 a[100](下标只到 99)。
P5737 【深基7.例3】闰年展示 入门 · 闰年判断函数

题意:输入 x 和 y,第一行输出 [x, y] 区间里闰年的个数,第二行按从小到大输出所有闰年年份,空格隔开。

⚠️ 数据范围 / 关键约束

1582 ≤ x < y ≤ 3000(注意是严格小于,区间至少跨两年,不会出现 x = y)。

· 区间长度最多约 1418 年,直接一年一年枚举完全没问题
· 1582 年是格里高利历开始启用的年份 —— 这题的数据正好避开「历法改革前怎么算闰年」的麻烦,你只管用标准规则判;
· 因为 x < y 且每年都判,天然就是从小到大,不用排序。

思路

① 先把 isLeap(y) 函数写好(模板二:400 → 100 → 4 的顺序判断)。

② 数个数:从 x 到 y 走一遍,是闰年就 cnt++,先输出 cnt。

③ 再输出年份:从 x 到 y 再走一遍(或者把两步合并),是闰年就输出 + 空格处理。

两次循环其实可以合成一次,但这里分两步写更清楚:题目要求先给出总数,再列出明细, 分两步正好对应题目的输出顺序,不容易乱。

样例 1 输入
1989 2001
样例 1 输出
3
1992 1996 2000

手算核对:1989~2001 之间能被 4 整除的有 1992、1996、2000。 其中 2000 是世纪年,但能被 400 整除,所以是闰年 ✅(如果是 1900,就要被剔掉)

完整代码

#include <iostream>
using namespace std;

// 判断闰年:能被 4 整除但不能被 100 整除,或者能被 400 整除
bool isLeap(int y){
    if(y % 400 == 0) return true;
    if(y % 100 == 0) return false;
    return y % 4 == 0;
}

int main(){
    int x, y;
    cin >> x >> y;

    int cnt = 0;
    for(int i = x; i <= y; i++)
        if(isLeap(i)) cnt++;
    cout << cnt << endl;

    bool first = true;
    for(int i = x; i <= y; i++){
        if(isLeap(i)){
            if(!first) cout << " ";
            first = false;
            cout << i;
        }
    }
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 1989 2001 → 第一行 3,第二行 1992 1996 2000 完全一致

额外测试(真实输出):
· 1896 19042 / 1896 19041900 被正确跳过,这正是世纪年陷阱的验证点)✅
· 1582 15830 / 空行(区间内一个闰年都没有,第二行就是空的,符合题目要求)✅
· 最大范围 1582 3000344 / 从 1584 到 2996 的完整列表,耗时 13 ms (列表里同样看不到 1700、1800、1900,能看到 1600、2000、2400)✅

易错点

  • 漏掉 400 的规则:写成 y % 4 == 0 && y % 100 != 0 就完了,2000 会被漏掉,样例直接挂。
  • 判断顺序反了:先判 100 再判 400,2000 会被 100 那条规则提前否掉。必须先 400。
  • 循环边界写成 i < y:区间是闭区间 [x, y],要用 i <= y,否则会漏掉 y 这一年。
  • 行尾多空格:同样用 first 标记处理。
  • 忘了先输出个数:题目要两行输出,顺序不能反。
P1304 哥德巴赫猜想 普及- · 筛法 + 枚举

题意:输入一个偶数 N,把 4 到 N 之间的每一个偶数都写成「两个质数之和」, 每行一个,形如 10=3+7。如果一个偶数有多种拆法,输出第一个加数最小的那一种

题面举的例子很关键:10 = 3+7 = 5+5,必须输出 10=3+7(3 比 5 小),写 10=5+5 判错。

⚠️ 数据范围 / 关键约束

4 ≤ N ≤ 10000,N 是正偶数。

· 输出行数是 (N−2)/2 行(N = 10 时是 4 行:4、6、8、10);
· 要处理的偶数最多 N/2 ≈ 5000 个,每个都要找一对质数 —— 如果每次都从头试除判断质数,会非常慢, 所以要先「打表」;
· 第一个加数要最小:只要从小到大试 a,第一个能拆成功的 a 就是最小的。

思路

① 先用埃氏筛把 2~N 的质数全部标记出来。

埃氏筛的思路特别朴素:从 2 开始,把每个质数的倍数全部划掉。 2 是质数 → 划掉 4、6、8……;3 是质数 → 划掉 9、12、15……;下一个没被划掉的就是质数,继续划……

for(int i = 2; i * i <= N; i++)
    if(isPrime[i])                       // i 本身没被划掉 → i 是质数
        for(int j = i * i; j <= N; j += i)
            isPrime[j] = false;          // 把 i 的倍数全部划掉
🎯 两个小细节

· 为什么 ji * i 开始,而不是 2 * i
因为 2i、3i、…、(i−1)i 这些数,在更小的质数(2、3……)那一轮就已经被划掉了, 重复划是白费力气。从 开始才都是没被划过的。
(注意:i * i 可能超出 int 范围,本题 N ≤ 10000 无所谓,但习惯上可以写成 i <= N / i。)

· 为什么只筛到 √N?
因为大于 √N 的质数,它的平方早就超过 N 了,没有更小的倍数可划 —— 换句话说, 如果 N 以内的数还有合数没被划掉,它一定有个不超过 √N 的质因数,早就被处理过了。

② 对每个偶数 n,从小到大试 a:

for(int a = 2; a <= n / 2; a++){
    if(isPrime[a] && isPrime[n - a]){
        cout << n << "=" << a << "+" << n - a << endl;
        break;                       // ★ 找到第一个就停,这才保证 a 最小
    }
}
💡 为什么 a 只需要试到 n / 2

因为如果 a > n/2,那么 n − a < n/2 ≤ a —— 也就是说这一对加数「反过来了」,早就被 a 较小的时候试过了。

试到 n/2 就覆盖了所有本质不同的拆分。而且这样还顺便保证 a ≤ n−a, 虽然本题没要求按大小排,但看着更整齐。

③ 外层循环从 4 开始,每次 +2,对应题目要求的那 (N−2)/2 个偶数。

样例 1 输入
10
样例 1 输出
4=2+2
6=3+3
8=3+5
10=3+7

4 = 2+2;6 = 3+3;8 = 3+5(8 还能拆成 5+3,但 3 更小);10 = 3+7(不是 5+5)—— 正是题目要求的「第一个加数最小」。

完整代码

#include <iostream>
using namespace std;

// 埃氏筛:把 2~N 里所有质数标记出来,放在 main 外面(全局数组自动清零)
bool isPrime[10005];

int main(){
    int N;
    cin >> N;

    for(int i = 2; i <= N; i++) isPrime[i] = true;          // 先假设都是质数
    for(int i = 2; i * i <= N; i++){
        if(isPrime[i]){                                     // i 是质数
            for(int j = i * i; j <= N; j += i)
                isPrime[j] = false;                         // i 的倍数全部划掉
        }
    }

    for(int n = 4; n <= N; n += 2){          // 从 4 开始,每个偶数都要验证
        for(int a = 2; a <= n / 2; a++){     // a 从小到大试,第一个成功的 a 最小
            if(isPrime[a] && isPrime[n - a]){
                cout << n << "=" << a << "+" << n - a << endl;
                break;                       // 找到一个就够,换下一个偶数
            }
        }
    }
    return 0;
}
✅ 实测结果

官方样例 10 → 四行 4=2+2 / 6=3+3 / 8=3+5 / 10=3+7 逐行完全一致

边界测试 44=2+2(只有一行,正好是 (4−2)/2 = 1 行)✅

最大数据 N = 10000 的完整验证(真实运行 + 逐行程序校验):

  • 输出行数 4999 行,与公式 (10000−2)/2 = 4999 完全吻合
  • 我把每一行都解析出来,逐个核对:两个加数都是质数、两数之和等于该偶数、 且第一个加数确实是所有可行拆法中最小的 —— 4999 行全部通过,失败 0 行
  • 首行 4=2+2,末行 10000=59+9941(59 和 9941 都是质数)✅
  • 总耗时 67 ms(含从 5000 个偶数中找最小质数对),非常宽裕

易错点

  • 找到一对就继续试别的 a:忘了 break,会输出多行、或者把最后的 a 打印出来(那就成了「第一个加数最大」)。必须 break。
  • 素组数组开小了:N 最大 10000,开 bool isPrime[10005] 刚好够;开 isPrime[10000] 会越界。
  • 外层循环写成 n <= N; n++:会输出奇数行(3=…、5=… 这些偶数才需要验证)。要写 n += 2
  • 内层从 a = 1 开始:1 不是质数,虽然 isPrime[1] 是 false 不会出错,但从 2 开始更清楚。
  • 全局数组忘了初始化为 true:这是我代码里唯一「要手动做」的初始化 —— 全局数组默认全是 false(=「还不是质数」), 所以要先 for(i = 2; i <= N; i++) isPrime[i] = true; 把 2~N 假设成质数,再筛。
  • 把筛法写成对每个偶数都重新判断质数:那样虽然 N ≤ 10000 也能过,但复杂度差一个量级。 「一次预处理、多次查询」是今天最值得带走的思维方式。

今日常见错误汇总

错误写法后果正确写法
把函数写在 main 后面 编译报 'xxx' was not declared in this scope 函数写在 main 前面,或先写一行函数声明
isPrime 里漏了 if(n < 2) return false; 1 和 0 被当成质数输出,P5736 直接错 函数第一行就判 n < 2
for(i = 2; i <= n; i++) 判质数 多试了 √n 倍的工作量,大数会超时 for(i = 2; (long long)i*i <= n; i++)
闰年只写 y%4==0 && y%100!=0 2000 年被漏掉,样例就挂 先判 y % 400 == 0 返回 true
在函数里用 sizeof(a) 算数组长度 得到的是指针大小(8 或 4),不是数组长度 长度当参数传进去:f(a, n)
int a[1000000]; 写在 main 里面 实测直接爆栈,退出码 0xC00000FD,程序根本跑不到输出 大数组一律写在 main 外面(全局)
P1304 找到一对质数后不 break 输出重复行,或第一个加数不是最小的 找到就 break,保证 a 最小
P1304 里对每个偶数重新试除判质数 5000 个偶数 × 每个都试到 √n,慢几十倍 先用埃氏筛打表,再枚举 a
输出最后多一个空格 / 少一个换行 格式错,部分评测点 WA 用 bool first 控制分隔符,结尾照常 endl
P5735 里用 (x2-x1)^2 求平方 ^ 是异或运算符,结果完全不对 写成 (x2-x1)*(x2-x1)

自测:你真的会了吗

第 1 题:为什么 isPrime 只需要试到 √n?

:因为因数成对出现。如果 n = a × ba ≤ b,那么 a ≤ √n

换句话说,n 只要是个合数,它一定有一个不超过 √n 的因数。试到 √n 都没找到因数,另一半就更不可能有 —— 那就是质数。

效果:判断 10⁵ 只需试 316 次;判断 10⁹ 也只需试 31623 次。

第 2 题:下面这个函数有什么问题?
bool isLeap(int y){ return y % 4 == 0 && y % 100 != 0; }

:漏了「能被 400 整除」这一条。它会把 2000 年判成平年。

正确写法:return (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0);

注意括号不能省:&& 的优先级比 || 高,不写括号虽然结果也对,但读起来容易误解,加上更清楚。

第 3 题:int a[500000]; 写在 main 里面,会发生什么?

:500000 × 4 字节 = 1.9 MB,搭在栈上。我实测这个大小还能跑(刚好卡在 2MB 栈限制的边缘)。

但只要再大一点,比如 int a[1000000](3.8 MB),就立刻爆栈,退出码 0xC00000FD,程序连输出都没有。

结论:别去试探那个临界点。凡是 int a[100000](约 400KB)以上的数组,一律开成全局。

第 4 题:引用传递和值传递,分别什么时候用?

· 要修改实参 → 用引用:void swap(int &a, int &b)
· 要传大对象(string、结构体、数组)→ 用引用(最好是 const &,表示「只读,别改我」),避免整份拷贝;
· 只是算个结果返回(int、double 这种小东西)→ 值传递最省心,函数绝对改不坏外面的变量。

★ 补充:数组比较特殊,传参时天生就是「传地址」,函数里改了外面就变,不需要也不该写 &

第 5 题(手写代码):写一个函数,求 n 以内有多少个质数。
bool isPrime(int n){
    if(n < 2) return false;
    for(int i = 2; (long long)i * i <= n; i++)
        if(n % i == 0) return false;
    return true;
}

int countPrime(int n){          // 数 1~n 里有多少个质数
    int cnt = 0;
    for(int i = 1; i <= n; i++) // 从 1 开始也没关系,isPrime(1) 是 false
        if(isPrime(i)) cnt++;
    return cnt;
}

countPrime(10) = 4(2、3、5、7)。用今天学的话说:这就是「函数调用函数」 —— countPrime 里可以直接用写好的 isPrime,不用重复写判断逻辑。

(如果想再快,就换成埃氏筛,像 P1304 那样。)

速查卡(截图存手机)

【函数骨架】
  返回类型 函数名(参数列表){
      函数体
      return 返回值;   // void 可不写
  }
  // ★ 函数写在 main 前面,或先写一行声明

【参数传递】
  void f(int a)        值传递  → 改的是复印件,外面不变
  void f(int &a)       引用    → 改的就是外面那个变量
  void f(int a[], int n)  数组 → 天生传地址,必须另传长度
  void f(const string &s) 大对象只读引用,零拷贝

【数组放哪】
  全局(main 外):数据段,可以开很大,自动清零  ← 大数组放这里
  局部(函数内):栈,只有 1~2MB,不自动清零      ← 实测 3.8MB 就爆栈

【素数判断(背)】
  bool isPrime(int n){
      if(n < 2) return false;                  // 0 和 1 不是
      for(int i = 2; (long long)i*i <= n; i++) // 试到 sqrt(n)
          if(n % i == 0) return false;
      return true;
  }

【埃氏筛(背)】
  bool isPrime[10005];              // 全局!
  for(int i = 2; i <= N; i++) isPrime[i] = true;
  for(int i = 2; i*i <= N; i++)
      if(isPrime[i])
          for(int j = i*i; j <= N; j += i)
              isPrime[j] = false;
  // 一次预处理,之后 O(1) 查

【闰年判断(背)】
  bool isLeap(int y){
      if(y % 400 == 0) return true;    // 2000 是
      if(y % 100 == 0) return false;   // 1900 不是
      return y % 4 == 0;
  }
  // 顺序不能反!先 400,再 100,最后 4

【两点距离】
  double dist(double x1,double y1,double x2,double y2){
      return sqrt((x2-x1)*(x2-x1) + (y2-y1)*(y2-y1));
  }
  // 需要 <cmath>;小数一律 double,保留两位用 printf("%.2f")

【输出分隔符】
  bool first = true;
  for(...){
      if(!first) cout << " ";
      first = false;
      cout << 内容;
  }

明天预告 · Day 11

结构体:把相关的数据打包

今天你已经体会到「一个函数管一件事」的清爽。明天会遇到一个新问题: 数据本身是一整组、分不开的 —— 比如一个学生的姓名 + 语文 + 数学 + 英语, 或者一个点的 x + y + z。用四个平行数组存(name[i]chinese[i]……)会很别扭, 一排序就全乱套。

结构体 struct 就是解决这个的:把描述同一个东西的好几个变量捆成一包, 然后像用一个普通变量那样用这一整包。配合今天学的函数,你就能写出「传一个学生进去、返回他的总分」这种自然又好读的代码。

明天题目:洛谷 P5740 最厉害的学生 / P1093 奖学金 / P1104 生日 / P5143 攀爬者 —— 全是「把数据打包再排序」的活儿。

ACM 一个月学习计划 · DAY 11

Day 11 · 结构体:把相关的数据打包

一个学生的「姓名 + 语文 + 数学 + 英语」本来要开四个数组,今天学会用一个 struct 全装下

今日目标:会用 struct 组织「一个学生的姓名+成绩」这类复合数据,能按规则排序并输出
预计用时:约 3 小时(含做题)  难度:入门 ~ 普及-
题目来源:洛谷 P5740 / P1093 / P1104 / P5143
DAY 11 / 30

今日时间安排

0 - 25 min看懂「为什么要结构体」——五个平行数组的痛点(第 1 节)
25 - 55 min定义语法 + 结构体数组 + 成员访问(第 2、3 节)
55 - 80 min今天的核心套路:读入 → 算派生字段 → 排序 → 输出(第 4 节)
80 - 180 min做 4 道题,每题限时 25 分钟
180 - 210 min对照本文题解,把没做出来的补掉 + 做自测
💡 和昨天内容的衔接

Day 10 你学了函数:把一段逻辑打包成有名字的一块。今天要学的是另一种「打包」—— 把几个相关的数据变量打包成一个整体。它们思想是一样的:给一堆东西起个名字,让代码更像人话

昨天写 double dis(double x1,double y1,double x2,double y2) 时,参数已经开始变多了吧? 今天的结构体正是用来解决「参数/数据太多,散落一地」这个问题的。

1先看没有结构体时有多难受

一个真实的场景

老师给你 3 个同学的信息,要你按总分从高到低排序输出。信息长这样:

senpai 114 51 4
lxl 114 10 23
fafa 51 42 60

每行是:姓名、语文、数学、英语。现在问题来了——用你已经学过的知识,怎么存?

笨办法:开四个平行数组

string name[1005];                    // 姓名放一个数组
int chinese[1005], math[1005], english[1005];   // 三科成绩各放一个数组
int total[1005];                      // 总分再放一个

看着还行?那你试试按总分排序

// 想交换第 i 个和第 j 个同学?
swap(name[i], name[j]);
swap(chinese[i], chinese[j]);
swap(math[i], math[j]);
swap(english[i], english[j]);
swap(total[i], total[j]);            // 少写一个就全乱了!
🚫 平行数组的三个致命问题

1. 一交换就要写 5 行,漏掉任何一个数组,这个同学的信息就「串味」了——姓名还是小明的,成绩变成小红的了。

2. 数不清:以后字段变成 10 个(还要加身高、班级、准考证号),交换就要写 10 行。

3. 没法把「一个同学」当整体传给函数。你想写一个 printStudent(i),函数签名得写成 printStudent(string, int, int, int, int),五个参数排着队,谁是谁全靠背。

🎯 结构体要解决的问题就一句话

把「本来就属于同一个东西的若干变量」,绑成一个整体,给它们起一个共同的名字。

「小明的姓名、小明的语文、小明的数学、小明的英语」这四样东西,本来就是一伙的, 凭什么要拆到四个数组里去?让它们待在一起,交换的时候整包一起换,多省事。

打比方

平行数组就像把一个人的身份证、学生证、银行卡分三个抽屉放——用的时候要去三个抽屉翻,搬家还要三个抽屉一起搬。

结构体就像一个文件袋,上面写着「小明」,里面装着他的所有材料。要交材料就整个袋子递过去, 要换人就整个袋子换掉。不存在「只换了半个袋子」这种事

2结构体的定义与使用

语法长这样

struct Student {              // struct 关键字 + 结构体名字(自己起)
    string name;              // 成员 1:姓名
    int chinese;              // 成员 2:语文
    int math;                 // 成员 3:数学
    int english;              // 成员 4:英语
    int total;                // 成员 5:总分(自己算出来的,叫"派生字段")
};                            // ★★★ 这个分号千万别丢!★★★
⚠️ 三个最容易错的地方

① 末尾的分号 }; 必须有。这是 C++ 里极少数「花括号后面还要跟分号」的地方。 忘了它,编译器会报一堆莫名其妙的错(往往报在后面好几行,让你找不到北)。

② 结构体名字首字母习惯大写StudentPersonPoint), 变量名小写(ap)。这不是语法要求,但是全世界的习惯,照着写别人一眼就懂。

③ 定义只是「画图纸」,不占内存。struct Student{...}; 只是告诉编译器「以后会有这么一种东西」, 真正占内存的是下面声明的变量。

定义之后怎么用

把结构体名字当成一种新的「类型」,像用 int 一样用它:

Student a;              // 一个学生
Student b[1005];        // 1005 个学生 —— 这叫「结构体数组」

cin >> a.name >> a.chinese >> a.math >> a.english;   // 输入
a.total = a.chinese + a.math + a.english;             // 算总分
cout << a.name << " " << a.total << endl;              // 输出
🎯 那个「点」是什么

a.name 读作「a 的 name」,意思就是从 a 这个文件袋里,取出 name 这一份材料

这个点号 .成员访问运算符。它是结构体的标志——看到 ×××.×××, 就知道前面是个结构体变量,后面是它的一个成员。

关键区别a 是一个整体(一个学生),a.name 是一个普通的 string 变量。 你不能 cout << a,但可以 cout << a.name

结构体的成员可以是什么?

成员类型例子说明
基本类型int age; double score;最常用
stringstring name;今天的 P5740 / P1104 都要用
数组int score[5];固定几科成绩时很方便
另一个结构体Date birthday;结构体可以套娃
构造函数Student(){ total = 0; }进阶用法,今天用不到
💡 现在不用背这些

今天四道题只用到前两种:stringint。等遇到了再回来看这张表。

3结构体数组:今天真正的主角

声明与访问

几乎每道结构体题都是「n 个结构体,排个序,输出几个」。所以结构体数组是最常用的形态:

struct Student {
    string name;
    int chinese, math, english, total;
} a[1005];                          // ★ 定义的同时顺便把数组也声明了

// 也可以分开写:
struct Student { ... };
Student a[1005];                    // 效果完全一样
💡 两种写法都对,看题目习惯

Day 6 讲数组时你就知道了:数组开在 main 外面(全局)会自动清零。 结构体数组开在全局也一样,所有成员自动是 0 / 空字符串,省事又安全。今天四道题我都开在全局。

访问第 i 个学生的姓名

结构体数组的每个元素都是一个完整的结构体,所以访问方式是「下标 + 点 + 成员」:

a[i].name        // 第 i 个学生的姓名
a[i].total       // 第 i 个学生的总分
a[i].chinese     // 第 i 个学生的语文成绩
⚠️ 括号的顺序不能反

正确:a[i].name  ❌ 错误:a.name[i](除非 name 真的定义成了数组)

记忆口诀:先锁定「第几个」,再取「哪一项」。先 a[i] 找到这个学生, 再从里面 .name 拿姓名。

完整骨架:读入 n 个学生

int n;
cin >> n;
for(int i = 0; i < n; i++){
    cin >> a[i].name >> a[i].chinese >> a[i].math >> a[i].english;
    a[i].total = a[i].chinese + a[i].math + a[i].english;   // ★ 顺手把总分算出来
}
🎯 「派生字段」这个动作,是今天的核心套路

题目只给你原始数据(三科成绩),但排序/比较需要的是加工过的数据(总分)。

所以读入的时候顺手把总分算出来存进结构体,后面排序时直接用 a[i].total, 不用每次都现算。算一次、用很多次——这就是「派生字段」的价值。

4结构体排序:比较规则怎么写

为什么结构体不能直接比大小

int 来说,「谁大谁小」是天然确定的。但两个 Student 谁应该排在前面? 比总分?比语文?比学号?编译器不知道,必须你来告诉它。

昨天的 int 数组排序是这样:

sort(a, a + n);                     // 从小到大,编译器懂

结构体要额外给一个「比较函数」当裁判:

bool cmp(const Student &x, const Student &y){
    return x.total > y.total;       // 返回 true:x 应该排在 y 前面
}

sort(a, a + n, cmp);                // 把裁判交给 sort
🎯 cmp 的唯一规则,背下来

cmp(x, y) 返回 true,意思就是「x 排在 y 前面」。

所以 return x.total > y.total; = 「总分大的排前面」= 降序
想升序就写 return x.total < y.total;

别去背「大于还是小于」,每次写的时候问自己一句:「我想让谁在前面?」

多关键字:一层一层往下比

很多题目不只一个排序条件,比如「总分高的在前;总分一样时语文高的在前;都一样时学号小的在前」。 写法就是一串 if,一层一层筛

bool cmp(const Student &x, const Student &y){
    if(x.total != y.total) return x.total > y.total;         // ① 总分降序
    if(x.chinese != y.chinese) return x.chinese > y.chinese;  // ② 语文降序
    return x.id < y.id;                                      // ③ 学号升序
}
💡 注意每一行的 !=

if(x.total != y.total) 的意思是「总分不一样才轮到我做决定」。 一样的话就跳过这一行,交给下一个条件。

如果写成 if(x.total > y.total) return true; 就错了—— 总分相等时会直接跳到 return x.id < y.id;, 于是「总分小的」也会因为学号小而被判为排在前面。这是新手最常犯的错。

为什么参数写 const Student &x

这是 C++ 里传结构体的标准姿势,两个符号各管一件事:

写法含义好处
Student x把整个结构体复制一份传进来❌ 每个元素都复制,慢
Student &x传引用(别名),不复制
const Student &x传引用 + 不许改✅ 又快又安全,标准写法

今天的 cmp 请统一写 const Student &x。等以后结构体里字段变多了,这个习惯能帮你省下大量时间。

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译并跑过官方样例,输出是你看到的真实结果。

P5740 【深基7.例9】最厉害的学生 普及- · 结构体 + 求最值

题意:给出 N 个同学的姓名和三科成绩,找出总分最高的那位, 输出他的姓名和原始三科成绩(注意:不是输出总分,是输出原始的三科分数)。 如果有多个人总分并列最高,输出输入靠前的那位。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 1000;姓名是长度不超过 8 的纯小写字母字符串;三科成绩都是不超过 150 的自然数

成绩上限 150,三科总分最大 450,int 完全够。

思路

这题是结构体的「Hello World」,用打擂台的思路就够了,不需要真的排序:

  1. 把 N 个同学读进结构体数组,顺手算出每个人的 total
  2. 假设第 0 个同学最厉害(best = 0)。
  3. 从第 1 个开始逐个挑战擂主:只要我的总分严格大于擂主,我就当新擂主
  4. 输出擂主的姓名和三科成绩。

「并列时输出靠前的」怎么实现?关键在第 3 步的「严格大于」—— 用 > 而不是 >=总分相等时擂台不换人,自然就保留了靠前的那位。

样例输入
3
senpai 114 51 4
lxl 114 10 23
fafa 51 42 60
样例输出
senpai 114 51 4

三个人总分:senpai = 114+51+4 = 169,lxl = 114+10+23 = 147,fafa = 51+42+60 = 153。最高的是 senpai。

完整代码

#include <iostream>
#include <string>
using namespace std;

// 结构体:把「一个同学的全部信息」打包成一个整体
struct Student {
    string name;                    // 姓名
    int chinese, math, english;     // 三科成绩
    int total;                      // 派生字段:总分(读入之后算出来)
} a[1005];                          // 结构体数组:一次声明 1005 个同学

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++){
        // 结构体数组的输入:a[i] 是一个整体,用 a[i].字段 逐个点名
        cin >> a[i].name >> a[i].chinese >> a[i].math >> a[i].english;
        a[i].total = a[i].chinese + a[i].math + a[i].english;
    }

    int best = 0;                   // best 记住「目前最厉害的同学」的下标
    for(int i = 1; i < n; i++){
        if(a[i].total > a[best].total){   // ★ 用 > 不用 >=:并列时保留靠前的
            best = i;
        }
    }

    cout << a[best].name << " " << a[best].chinese << " "
         << a[best].math << " " << a[best].english << endl;
    return 0;
}
✅ 实测结果

官方样例 → 输出 senpai 114 51 4 完全一致

自造并列测试(aaa 和 bbb 都是满分 100+100+100,aaa 在前)→ 输出 aaa 100 100 100 正确保留了靠前的同学

边界 N = 1(zoe 150 150 150)→ 输出 zoe 150 150 150 正确

易错点

  • 输出的是三科原始成绩,不是总分!题目说「输出最厉害的学生各项信息(姓名、各科成绩)」, 很多人顺手把 total 输出了,直接 0 分。
  • 比较用了 >=:并列时后面的会顶掉前面的,违反了「输出靠前的那位」。
  • 结构体定义末尾的 }; 分号忘了写。
  • 姓名是 string,要用 #include <string>(虽然很多编译器不写也能过, 但规范起见写上)。
P1093 [NOIP 2007 普及组] 奖学金 普及- · 多关键字排序

题意:n 个学生,每人有语文、数学、英语三科成绩。按下面的规则排出一个唯一确定的名次,然后输出前 5 名的学号和总分。

排序规则:① 总分从高到低;② 总分相同时语文从高到低;③ 总分和语文都相同时学号小的在前。

⚠️ 数据范围 / 关键约束

5 ≤ n ≤ 300;每科成绩在 0 到 100 之间;学号按输入顺序编号为 1 ~ n。n 至少是 5, 所以一定有 5 个人可以输出。

思路

这是结构体 + 多关键字排序的标准题。

  1. 读入时把学号也存进结构体(a[i].id = i;)——排序会把顺序打乱,学号必须跟着人走
  2. 顺手算总分。
  3. 写三行 cmp,把三个优先级依次排好。
  4. sort 之后,前 5 个就是获奖的同学,输出 idtotal
🎯 为什么「学号必须存进结构体」

如果只把成绩存进数组,排序后你就不知道哪个成绩是谁的了

学号是「身份标签」,必须和本人的数据绑在一起,一起被搬来搬去。这正是结构体最擅长的事—— 要是用平行数组,你就得开一个 id[] 数组,然后在 cmp 里手动同步交换,一不留神就对不上了。

💡 题目特意提醒的坑:前两名可能是「同分不同序」

题面里举了例子:7 号总分 279、5 号总分 279,但 7 号语文更高,所以必须输出 7 在前。 如果你只按总分排,两人顺序可能随机,就会 WA。三个关键字一个都不能少。

样例输入
6
90 67 80
87 66 91
78 89 91
88 99 77
67 89 64
78 89 98
样例输出
6 265
4 264
3 258
2 244
1 237

手算验证:1 号 237、2 号 244、3 号 258、4 号 264、5 号 220、6 号 265。按总分降序正好是 6、4、3、2、1。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

struct Student {
    int id;                         // 学号
    int chinese, math, english;     // 三科成绩
    int total;                      // 总分
} a[305];

// 比较函数:返回 true 表示 x 应该排在 y 前面
bool cmp(const Student &x, const Student &y){
    if(x.total != y.total) return x.total > y.total;         // ① 总分高的在前
    if(x.chinese != y.chinese) return x.chinese > y.chinese;  // ② 语文高的在前
    return x.id < y.id;                                      // ③ 学号小的在前
}

int main(){
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++){
        a[i].id = i;                                    // ★ 学号 = 输入行号,必须存下来
        cin >> a[i].chinese >> a[i].math >> a[i].english;
        a[i].total = a[i].chinese + a[i].math + a[i].english;
    }

    // 注意:数据存在 a[1] ~ a[n],所以是 sort(a+1, a+n+1, cmp)
    sort(a + 1, a + n + 1, cmp);

    for(int i = 1; i <= 5; i++){
        cout << a[i].id << " " << a[i].total << endl;
    }
    return 0;
}
⚠️ sort(a + 1, a + n + 1, cmp) 这个 +1 别写错

sort 的范围是左闭右开 [first, last)。数据在 a[1]a[n], 所以要写到「最后一个元素的下一个位置」a + n + 1

写成 sort(a + 1, a + n, cmp) 的话,最后一个同学压根没参与排序, 样例可能还过,但隐藏数据必挂。

✅ 实测结果

官方样例 1 → 输出 6 265 / 4 264 / 3 258 / 2 244 / 1 237 完全一致

官方样例 2(8 人,含两组同分需要比较语文/学号)→ 输出 8 265 / 2 264 / 6 264 / 1 258 / 5 258 完全一致

样例 2 里 2 号和 6 号都是 264 分、语文都是 88 分 → 按学号升序输出 2、6; 1 号和 5 号都是 258 分,1 号语文 80 > 5 号语文 78 → 1 在前。三个关键字全部生效。

易错点

  • 三个关键字的顺序写反。优先级是总分 → 语文 → 学号,一层一层往下筛。
  • if(x.total != y.total) 写成 if(x.total > y.total), 会让总分相等的人错误地按学号重排。
  • 忘记存学号,排序后完全不知道输出谁。
  • sort 右端点漏了 +1,最后一个学生没排序。
  • 输出只有 5 行,别写成 n 行。
P1104 生日 普及- · 日期排序 + 稳定性

题意:给出 n 个人的姓名、出生年、月、日,按年龄从大到小输出姓名。 如果两个人年龄相同(年月日完全一样),输入靠后的同学先输出

⚠️ 数据范围 / 关键约束

1 < n < 100;姓名长度 1 ≤ |s| < 20;保证年月日实际存在,年份在 [1960, 2020] 之间。

n 很小(最多 99 人),所以随便怎么写都不会超时——这题考的是「字段顺序」而不是效率

思路

先想清楚一件事:「年龄大」等价于「出生日期早」。所以「年龄从大到小」就是「出生日期从小到大」。

日期怎么比大小?就是看日历的习惯:先比年,年相同再比月,月相同再比日。 这正好又是三段式 cmp:

先比 y(早的在前)→ 再比 m(早的在前)→ 再比 d(早的在前)
🎯 本题的真正考点:并列怎么办

题目要求「年龄相同则输入靠后的先输出」。注意这跟 Day 11 计划里写的「按输入顺序输出」 不太一样——是反过来的,靠后的先出来。

所以最后一层要写 return x.id > y.id;编号大的排前面)。 把 id 当成「第四个关键字」塞进 cmp,是最稳的做法。

如果什么都不写会怎样?sort 不是稳定排序,并列元素的相对顺序是未定义的, 可能时对时错——这种「看起来能过、其实全靠运气」的代码最危险。

样例输入
3
Yangchu 1992 4 23
Qiujingya 1993 10 13
Luowen 1991 8 1
样例输出
Luowen
Yangchu
Qiujingya

Luowen 1991 年最早 → 年龄最大 → 排第一;Qiujingya 1993 年最晚 → 最小 → 排最后。

完整代码

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

struct Person {
    string name;        // 姓名
    int y, m, d;        // 出生年、月、日
    int id;             // 输入顺序(第几个读进来的)
} a[105];

// 比较函数:返回 true 表示 x 应该排在 y 前面(年龄大的在前)
bool cmp(const Person &x, const Person &y){
    if(x.y != y.y) return x.y < y.y;    // 出生年份小 → 年龄大 → 排前面
    if(x.m != y.m) return x.m < y.m;    // 同年再比月
    if(x.d != y.d) return x.d < y.d;    // 同月再比日
    return x.id > y.id;                 // ★ 生日完全相同:输入靠后的先输出
}

int main(){
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++){
        a[i].id = i;
        cin >> a[i].name >> a[i].y >> a[i].m >> a[i].d;
    }

    sort(a + 1, a + n + 1, cmp);

    for(int i = 1; i <= n; i++){
        cout << a[i].name << endl;
    }
    return 0;
}
✅ 实测结果

官方样例 → 输出 Luowen / Yangchu / Qiujingya(各占一行) 完全一致

自造并列测试(4 人:A、B 都是 2000-1-1,C 是 1999-5-5,D 是 2001-12-31) → 输出 C / B / A / D 正确

C 年龄最大排第一;A、B 生日相同,输入靠后的 B 先输出;D 最小排最后。

易错点

  • 把日期当成一个整数比(比如 y*10000+m*100+d)——这题碰巧也能过, 但更清楚的写法就是三段 if,可读性高得多。
  • 年月日的比较方向搞反。题目要「年龄大在前」=「出生早在前」= x.y < y.y不是 >。写下 cmp 之前先在纸上念一遍题目要求。
  • 并列时用 x.id < y.id:那会变成「输入靠前的先输出」,跟题目要求正好相反。
  • 姓名是 string 不是 char,输出时直接 cout << a[i].name
P5143 攀爬者 普及- · 三维点排序 + 距离

题意:有 N 个标了三维坐标 (x, y, z) 的点,所有点的高度 z 互不相同。 要从最低的点爬到最高的点,经过每一个点,而且每走一步高度都必须上升。 一步的距离是两点的三维欧几里得距离,求总距离(保留三位小数)。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 50000,答案保证在 double 的表示范围内。

题目没有给出 x、y、z 具体的取值范围,只保证了上面两条。所以算距离时建议转成 double 再算,别让 int 溢出。

思路

这题的巧妙之处在「把三维问题变成一维排序问题」

题目的约束是「每一步高度 z 都必须上升」,而且「经过每一个点」。 那么攀爬路径其实已经被唯一确定了——就是把所有点按 z 从小到大排一排,依次走过去

🎯 为什么唯一确定

假设按 z 排好是 P₁ < P₂ < P₃ < … < P_N。要求每步高度都上升, 那第一站只能是最低的 P₁(从最低点出发),第二站只能是 P₂(比 P₁ 高的点里最矮的),依此类推。

高度互不相同这个条件保证了顺序不会有歧义(万一有两点一样高,就没法排先后了)。 所以这题的「排序」不是用来选最优的,而是用来还原唯一路径的

排完序,从 p[0] 顺着算到 p[n-1],把相邻两点的三维距离加起来就行。

三维距离公式

平面上两点距离你会了:√((x₂-x₁)² + (y₂-y₁)²)。三维就是再多加一项 z:

√( (x₂-x₁)² + (y₂-y₁)² + (z₂-z₁)² )

平方差了再开根,用 sqrt()(需要 #include <cmath>)。

样例输入
5
2 2 2
1 1 1
4 4 4
3 3 3
5 5 5
样例输出
6.928

按 z 排序后是 (1,1,1) (2,2,2) (3,3,3) (4,4,4) (5,5,5), 每相邻两点的距离都是 √(1+1+1) = √3 ≈ 1.7320508,四段共 ≈ 6.9282 → 保留三位小数 6.928

完整代码

#include <iostream>
#include <algorithm>
#include <cmath>
#include <cstdio>
using namespace std;

struct Point {
    int x, y, z;        // 三维坐标
} p[50005];

// 按高度 z 从小到大排,就得到了攀爬的先后顺序
bool cmp(const Point &a, const Point &b){
    return a.z < b.z;
}

int main(){
    int n;
    scanf("%d", &n);
    for(int i = 0; i < n; i++){
        scanf("%d %d %d", &p[i].x, &p[i].y, &p[i].z);
    }

    sort(p, p + n, cmp);            // 排序之后,p[0] 最低,p[n-1] 最高

    double ans = 0.0;
    for(int i = 1; i < n; i++){
        // 三维欧几里得距离:先算差值再平方,最后开根号
        double dx = p[i].x - p[i-1].x;
        double dy = p[i].y - p[i-1].y;
        double dz = p[i].z - p[i-1].z;
        ans += sqrt(dx * dx + dy * dy + dz * dz);
    }

    printf("%.3f\n", ans);          // 保留三位小数
    return 0;
}
💡 这里为什么用 scanf / printf 而不是 cin / cout

N 最大 50000,要读 15 万个数。cin 在数据量大时比 scanf 慢一些, 用 scanf 是竞赛里的常规保险做法。

另一个办法是在 main 开头加一句 ios::sync_with_stdio(false);, 让 cin 也变快。两种都行,这题其实 cin 也能过(15 万个数不算多)。

printf("%.3f") 表示「保留 3 位小数」,会自动四舍五入; cout 的等价写法是 cout << fixed << setprecision(3) << ans;(需要 <iomanip>)。

✅ 实测结果

官方样例 → 输出 6.928 完全一致

边界 N = 1(只有一个点,不用移动)→ 输出 0.000 正确

最大规模 N = 50000(随机生成的 878 KB 输入文件)→ 输出 519450882.248运行耗时为毫秒级,远低于时限(复杂度 O(N log N),排序占主要时间)

易错点

  • int 算平方和(p[i].x-p[i-1].x) 的平方可能超出 int 范围。 先赋给 double 变量再平方,或者写 (double) 强转。
  • 忘了 #include <cmath>sqrt 找不到。
  • 输出格式:题目要求保留 3 位小数,cout << ans 会打出 6.9282 这种一堆位数,直接 WA。
  • 数组开小了:N ≤ 50000,开 p[50005],别开成 1005。
  • 不要试图去找「最短路径」——路径是被题目条件唯一确定的,只需要排序 + 累加。

今日常见错误汇总

错误写法后果正确写法
struct Student { ... }
(末尾没分号)
编译报一堆看不懂的错,报错位置常在好几行之后 struct Student { ... };
a.name[i] 括号顺序反了,编译不过或取错东西 a[i].name
if(x.total > y.total) return true;
return x.id < y.id;
总分相等时,学号小的会被误判为在前面 → 多关键字排序整体错乱 if(x.total != y.total)
  return x.total > y.total;
sort(a+1, a+n, cmp) 右端点是左闭右开,最后一个元素没参与排序 sort(a+1, a+n+1, cmp)
不存学号/id 就排序 排完不知道哪份数据是谁的 a[i].id = i; 一起存进结构体
cmp 参数写 Student x 每次比较都复制整个结构体,数据大时明显变慢 const Student &x
P5143 用 int 存平方和 平方后溢出 → 距离算错 double dx = ...; 再 dx*dx
cout << ans 输出距离 小数位数不对,P5143 要求保留 3 位 printf("%.3f\n", ans);

自测:你真的会了吗

第 1 题:这个结构体定义有什么问题?
struct Student {
    string name;
    int score;
}

:末尾少了分号,应该是 };

这是 C++ 里一个著名的坑:结构体/类的定义右花括号后面必须跟分号。 忘了它,编译器会以为你还要继续声明变量(比如 } a[1005]; 就是合法的), 等到后面遇到别的语句才报错,于是错误信息指向的位置往往离真正的错误有好几行

第 2 题:想让「分数高的在前,分数相同时名字字典序小的在前」,cmp 怎么写?
bool cmp(const Student &x, const Student &y){
    if(x.score != y.score) return x.score > y.score;   // 分数不同:高的在前
    return x.name < y.name;                            // 分数相同:名字小的在前
}

★ 关键是第一行用 != 判断「是否不同」,不同才由分数决定; 相同就自然落到第二行,由名字决定。

如果写成 if(x.score > y.score) return true;,那么分数相等时会掉到第二行, 于是「分数低但名字小」的人反而排在前面,全乱套了。

第 3 题:排序之后,怎么输出「总分第二名」?

假设已经 sort(a, a+n, cmp) 按总分降序排好了(a[0] 是第一)。

:第二名是 a[1],直接

cout << a[1].name << " " << a[1].total << endl;

★ 记住:数组下标从 0 开始,所以「第 k 名」是 a[k-1]。 P1093 输出前 5 名循环写的是 i = 1..5(输出 a[1]~a[5]), 那是因为它在 a[1] 开始存数据。下标基础不同,读代码时一定要先看清楚。

第 4 题(手写代码):统计有多少个学生总分不低于 200 分
int cnt = 0;
for(int i = 0; i < n; i++){
    if(a[i].total >= 200) cnt++;
}
cout << cnt << endl;

★ 这题不需要排序,只需要遍历一遍结构体数组。结构体题不一定要排序—— 先看清题目要「求最值 / 数个数」还是「排顺序」,再决定用不用 sort。

速查卡(截图存手机)

【结构体定义】
  struct Student {
      string name;              // 成员用分号隔开
      int chinese, math, english, total;
  };                            // ★ 这个分号必须有!
  Student a[1005];              // 结构体数组

【访问成员】
  a[i].name        // 先锁定"第几个",再取"哪一项"
  a[i].total = a[i].chinese + a[i].math + a[i].english;   // 派生字段

【多关键字 cmp】
  bool cmp(const Student &x, const Student &y){
      if(x.total != y.total) return x.total > y.total;      // 总分降序
      if(x.chinese != y.chinese) return x.chinese > y.chinese; // 语文降序
      return x.id < y.id;                                    // 学号升序
  }
  sort(a + 1, a + n + 1, cmp);  // ★ 左闭右开,右端点 +1

【cmp 心法】
  cmp(x, y) 返回 true  =  x 排在 y 前面
  想降序写 >,想升序写 <
  并列条件用 if(... != ...) 筛,一层一层往下

【结构体题的固定套路】
  读入 → 存 id/派生字段 → 写 cmp → sort → 输出前 k 个 / 输出最值

【三维距离】
  double dx = p[i].x - p[i-1].x;   // 先转 double 再平方,防溢出
  ans += sqrt(dx*dx + dy*dy + dz*dz);
  printf("%.3f\n", ans);

明天预告 · Day 12

排序:sort 与它的朋友们

今天你已经摸到了 sortcmp 的门,明天要把它们彻底讲透: greater<int>() 降序、unique 去重、冒泡排序的原理, 以及 P1012「拼数」那个经典的拼接比较 a+b > b+a

今天写的 cmp 明天还要用,而且会更难一点——记得回来复习这三行 if。

ACM 一个月学习计划 · DAY 12

Day 12 · 排序:sort 与它的朋友们

排序是竞赛里最常用的工具之一。今天把 sort、自定义 cmp、去重、冒泡与逆序对一次讲透

今日目标:熟练使用 sort 及其自定义比较函数,掌握去重与逆序对
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1059 / P1012 / P1781 / P1116
DAY 12 / 30

今日时间安排

0 - 25 minsort 的三种基本用法 + 左闭右开的坑(第 1 节)
25 - 55 min自定义 cmp 与「严格弱序」这条铁律(第 2 节)
55 - 80 min去重三连 sort → unique → erase(第 3 节)
80 - 105 min冒泡排序与逆序对(第 4 节)——P1116 的本体
105 - 200 min做 4 道题,每题限时 25 分钟
200 - 220 min对照本文题解 + 做自测
💡 和昨天内容的衔接

昨天你用 sortcmp 排了学生(结构体)。今天要把它彻底讲清楚: sort 到底怎么用、cmp 有什么必须遵守的规矩、去重怎么和排序配合

另外今天还会打开「排序的黑盒子」——学一遍冒泡排序。 你可能会问:既然有 sort,为什么还要学又慢又笨的冒泡? 因为 P1116 车厢重组要的不是「排好序」,而是「交换了多少次」。 而「冒泡排序的交换次数」正好等于一个非常重要的量——逆序对个数

1std::sort 的三种基本用法

先说结论:一句话搞定排序

#include <algorithm>      // ★ sort 住在这个头文件里,别忘了

sort(a, a + n);                          // ① 数组升序
sort(a, a + n, greater<int>());          // ② 数组降序
sort(a, a + n, cmp);                     // ③ 按你自己的规则排

如果用 vector

sort(v.begin(), v.end());                // vector 升序
sort(v.begin(), v.end(), greater<int>()); // vector 降序
🎯 为什么用 sort 而不是自己写

标准库里的 sort 是经过极限优化的混合排序(快排 + 堆排 + 插入排序), 复杂度 O(n log n)。你自己手写的快排几乎不可能比它快,而且容易写错。

n = 10⁶ 的时候,O(n log n) 大约是 2×10⁷ 次操作,跑起来只要零点几秒。 而 O(n²) 是 10¹² 次,要跑好几分钟——这就是为什么「会排序」是硬门槛。

范围为什么写成 sort(a, a + n)

因为 C++ 标准库的区间约定是左闭右开 [first, last)包含 first 指的元素,不包含 last 指的元素。

数据存放方式正确的 sort 写法说明
a[0] ~ a[n-1]sort(a, a + n)右端点 = 起始 + n
a[1] ~ a[n]sort(a + 1, a + n + 1)右端点还要再 +1
vector v 全部sort(v.begin(), v.end())end() 指向末尾的「下一个」
⚠️ 最经典的 off-by-one

数据存在 a[1] ~ a[n],却写成 sort(a + 1, a + n)—— 最后一个元素根本没参与排序

这种错误特别阴险:样例可能照样过,因为样例小、最后一个恰好不用动;到大测试点才突然 WA。

校验方法:sort 的右端点写成 a + 起始下标 + 元素个数,永远错不了。

greater<int>() 是什么东西

它是标准库给你的一个「现成的比较规则」,意思是「大的排前面」。 加一对括号是因为它是个临时对象,要传一个实例给 sort。

sort(a, a + n);                  // 升序:1 2 3 4 5
sort(a, a + n, greater<int>());  // 降序:5 4 3 2 1
💡 忘了 () 会怎样

greater<int> 而不加括号,编译器会报一大串「模板参数推导失败」之类的错。 记住口诀:greater 后面一定要带一对小括号。

同理,排序 long longdouble 时要用 greater<long long>()greater<double>()——尖括号里写元素的类型。

结构体/字符串不能直接 sort

sort 必须知道「谁该排在前面」。对 intdoublestring(字典序)这些类型,C++ 内置了默认规则; 但两个自定义结构体谁大谁小,编译器不知道,必须你写 cmp——这就是第 2 节。

2自定义比较函数 cmp

cmp 的唯一规则

🎯 一句话记牢

cmp(x, y) 返回 true,意思就是「x 应该排在 y 前面」。

所以:

· return x < y; → 小的在前 → 升序
· return x > y; → 大的在前 → 降序

不要背「大于还是小于」,每次写的时候问自己:「我希望谁排在前面?」

多关键字:一层一层往下筛

bool cmp(const Student &x, const Student &y){
    if(x.total != y.total) return x.total > y.total;         // ① 总分降序
    if(x.chinese != y.chinese) return x.chinese > y.chinese;  // ② 语文降序
    return x.id < y.id;                                      // ③ 学号升序
}

每一行的含义是「这一项不一样,就由这一项说了算」,一样就往下走。 这就是昨天 P1093 的写法,今天要补充的是:为什么第一行必须用 != 而不能用 >

如果写成 if(x.total > y.total) return true;, 那么两人总分相等时不会返回,会直接落到第 ③ 行, 于是学号小的会被判为排在前面,哪怕总分更低——名次直接错乱。

铁律:cmp 必须满足「严格弱序」

听起来很学术,其实只有两句人话:

规则人话例子
cmp(x, x) 必须是 false自己不能排在「自己」前面x <= y 会让 cmp(x,x) 为 true
不能出现「循环谁在前面」a 在 b 前、b 在 c 前 ⇒ a 必在 c 前我们写的多关键字 cmp 天然满足
🚨 绝对不能写 <=>=
bool cmp(int x, int y){
    return x <= y;      // ❌ 大错!相等时也返回 true
}

标准规定 sort 的 cmp 必须是严格小于(strict)。你写 <= 就破坏了约定, 行为变成未定义——排序库内部会做出错误的分区假设, 可能越界访问内存直接崩溃(RE),也可能排出来的顺序是错的

我实际的测试结果(老实说):在本机 MinGW g++ 6.3.0 / -O2 下, 我用 <= 的 cmp 排 20 万个随机数,程序没有崩、结果也恰好是升序的。 但这不代表这样写是安全的——它是未定义行为,换个编译器、换个标准库、换份数据就可能崩或排错。 计划书里写「会 RE」是一种风险警告,而不是保证。

结论:永远写严格的 <>,一个等号都不要多。

💡 cmp 的参数记得用 const T &

传引用避免复制整个结构体,加 const 表示「只读不改」。 数据量大时这个细节能省下可观的时间。昨天的 const Student &x 就是这个道理。

3去重三连:sort → unique → erase

思路

要把一堆数「去重 + 排序」,标准做法只有三步:

sort(a, a + n);                              // ① 先排序,让相同的数挨在一起
int m = unique(a, a + n) - a;                // ② 去掉相邻的重复项,m 是剩余个数
// 如果用的是 vector,还要第三步:
v.erase(unique(v.begin(), v.end()), v.end()); // ③ 真正删掉多余的尾巴
🎯 unique 到底干了什么

它不是「删除」,而是「搬家」:把不重复的元素依次挪到数组前面,重复的丢到后面去。 返回值是新的「逻辑末尾」的指针,所以 unique(...) - a 就是去重后的元素个数。

对数组来说,后面的垃圾数据你不去管就行了(用 m 当新的长度)。 对 vector 来说,size() 没变,所以要 erase 把尾巴真删掉。

⚠️ unique 只能去掉「相邻」的重复

unique 只比较相邻两个元素。如果数组是 3 1 3 1, 它什么都删不掉(3 和 1 不相邻)。

所以顺序绝对不能反:必须先 sort,再 unique。排完序相同的数一定挨在一起,unique 才能一网打尽。

另一条路:桶去重(Day 6 的老朋友)

如果题目里的数值范围很小(比如 1 ~ 1000),用标记桶更简单,连排序都省了:

bool vis[1005] = {false};
for(int i = 0; i < n; i++){ int x; cin >> x; vis[x] = true; }

// 从小到大扫一遍桶,有旗子的就是出现过的数,顺便天然有序
for(int v = 1; v <= 1000; v++)
    if(vis[v]) cout << v << " ";
方法复杂度适用场景
sort + uniqueO(n log n)值域很大也能用(通用首选
标记桶O(n + 值域)值域小的时候更简单更快

今天的 P1059 两种写法都行,我会把两种代码都给你,而且都实测过。

4冒泡排序与逆序对(P1116 的本体)

冒泡排序:最朴素的排序

核心想法就一句话:从左到右把相邻两个数比一比,前面的大就交换,一轮下来最大的那个就「冒」到了最右边。

4 3 2 1 走一遍第一轮:

比较操作数组变成
4 和 34 > 3,交换3 4 2 1
4 和 24 > 2,交换3 2 4 1
4 和 14 > 1,交换3 2 1 4
第一轮结束最大的 4 冒到最右,共交换 3 次3 2 1 | 4

然后对前面 3 2 1 重复,再对 2 1 重复……

标准模板

for(int i = 0; i < n - 1; i++){          // 一共做 n-1 轮
    for(int j = 0; j < n - 1 - i; j++){  // 后面 i 个已经排好,不用再管
        if(a[j] > a[j+1]){               // 相邻逆序
            swap(a[j], a[j+1]);
        }
    }
}
排序算法复杂度特点
冒泡排序O(n²)慢,但能顺便数出「交换次数」
std::sortO(n log n)快,竞赛首选,但数不出交换次数

n ≤ 1000 的时候 O(n²) 是 10⁶ 次操作,完全没问题,所以今天的 P1116 可以直接用冒泡。

逆序对:今天最重要的概念

🎯 定义

在一串数里,如果前面一个数后面一个数,这两数就构成一对逆序对

例子:4 3 2 1 的逆序对有:
(4,3) (4,2) (4,1) (3,2) (3,1) (2,1) —— 正好 6 对

⚠️ 核心结论:冒泡的交换次数 = 逆序对个数

为什么?看冒泡的内层循环:每发现一对「相邻且前大后小」,就交换一次。 而每一次交换,恰好消掉一对相邻逆序,并且让整个数组的逆序对总数减少 1; 没有交换的时候,说明已经没有相邻逆序了,而此时数组必定已经有序(逆序对为 0)。

所以:数组有序 ⟺ 逆序对为 0 ⟺ 不再有交换, 交换次数从头到尾正好把逆序对一对一对清空,因此两者相等。

我实测验证过:写了两个程序,一个用冒泡数交换次数,一个用归并排序数逆序对, 对 12 组随机数据(n 从 10 到 65)跑出来结果完全一致,比如 n=65 那组两个都是 1043,n=58 那组都是 692。

所以 P1116 车厢重组在问什么

题目里的「桥旋转 180 度交换相邻两节车厢」,就是冒泡里的 swap(a[j], a[j+1])。 问「最少多少步」——因为每次只能交换相邻车厢,这个「最少步数」正好就是逆序对个数

💡 想进阶的话:归并排序数逆序对

n ≤ 1000 用冒泡就够。但如果 n 变成 10⁵,O(n²) 就挂了,这时要用归并排序在 O(n log n) 内数出逆序对: 归并的时候,如果右半段的 a[j] 小于左半段的 a[i], 那么左半段剩下的 mid - i + 1 个数全都比 a[j] 大,一次就贡献这么多逆序对。

这段代码我也写了并编译跑过,附在 P1116 的题解里,当作课外加餐。

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译并跑过官方样例,输出是你看到的真实结果。

P1059 [NOIP 2006 普及组] 明明的随机数 普及- · 排序 + 去重

题意:明明生成了 N 个 1 到 1000 之间的随机整数,其中重复的只保留一个。 要求先输出去重后有多少个不同的数(M),再把这些数从小到大排好序输出。

⚠️ 数据范围 / 关键约束

N ≤ 100,每个数在 1 ~ 1000 之间(题面原文是「生成了 N 个 1 到 1000 之间的随机整数 (N ≤ 100)」, 在说明里只标注了「NOIP 2006 普及组 第一题」,没有额外的分档数据表)。

数值范围这么小,说明桶做法完全可行——这正是本题最省事的解法。

思路

题目要求的是「去重 + 排序」两件事,而这两件事有天然的联系:排完序之后,相同的数一定挨在一起, 于是「去重」就退化成了「删掉相邻的重复项」。

两种写法:

  1. 通用写法sort 排序 → unique 去重相邻重复项 → 得到新的长度 m。
  2. 桶写法:开一个 vis[1005],读到哪个数就给哪个桶插旗; 最后从 1 扫到 1000,有旗子的数字天然就是升序且不重复的。
样例输入
10
20 40 32 67 40 20 89 300 400 15
样例输出
8
15 20 32 40 67 89 300 400

10 个数里 20 和 40 各出现了两次,去重后剩 8 个。

完整代码(写法一:sort + unique)

#include <iostream>
#include <algorithm>
using namespace std;

int a[105];

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

    sort(a, a + n);                         // ① 排序

    // ② 去重:unique 把不重复的元素挪到前面,返回「新区间末尾」的指针
    int m = unique(a, a + n) - a;

    cout << m << endl;                      // ③ 先输出去重后的个数
    for(int i = 0; i < m; i++){
        if(i) cout << " ";                  // 数字之间一个空格,末尾不多打
        cout << a[i];
    }
    cout << endl;
    return 0;
}

完整代码(写法二:标记桶)

#include <iostream>
using namespace std;

int a[105];
bool vis[1005];     // 桶:vis[v] 标记数字 v 出现过

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++){
        cin >> a[i];
        vis[a[i]] = true;       // 出现过的数,在它自己的桶里插一面旗
    }

    int m = 0;
    for(int v = 1; v <= 1000; v++){
        if(vis[v]) m++;         // 从小到大扫桶,顺便数出有多少个不同的数
    }

    cout << m << endl;
    int printed = 0;
    for(int v = 1; v <= 1000; v++){
        if(vis[v]){
            if(printed) cout << " ";
            cout << v;
            printed++;
        }
    }
    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 → 两种写法都输出

8
15 20 32 40 67 89 300 400

与样例输出完全一致。

自造边界测试「5 个数全是 7」→ 两种写法都输出 1 / 7 正确

易错点

  • 先 unique 再 sort:unique 只删相邻重复项,没排序时重复的数不挨着,一个也删不掉。顺序不能反。
  • 忘了输出 M:题目要输出两行,第一行是不同数的个数,很容易只输出排序结果。
  • 输出末尾多打空格:洛谷一般能容忍行末空格,但用 if(i) cout << " "; 的写法更规范。
  • 桶写法里桶的大小要开到 1000 以上(数值上限 1000,所以 vis[1005]), 开成 vis[1000] 在读入 1000 时会越界。
P1012 [NOIP 1998 提高组] 拼数 普及/提高- · 自定义比较(经典易错)

题意:给出 n 个正整数,把它们首尾相接拼成一排,组成一个最大的整数,输出这个整数。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 20,1 ≤ aᵢ ≤ 10⁹。

注意两件事:① 每个数最大 10 亿(10 位数),n 最多 20 个, 拼起来最长有 200 位左右——远超 long long 的范围,必须用字符串处理② n 很小,说明这题考的不是算法效率,而是比较规则想不想得对

思路:这题的关键是「两个数谁该排前面」

先说一个错误但非常诱人的想法:把数字当字符串,按字典序从大到小排,然后拼起来。

它对样例居然是对的!但它是错的。看这个反例:

输入字典序降序排(❌ 错)正确答案(✅)
3 31"31" > "3" → 拼成 313331 > 313 → 拼成 331
9 91"91" > "9" → 拼成 919991 > 919 → 拼成 991

(上面两组我都真的编译运行验证过:我的正确程序输出 331 和 991; 专门写了一个「字典序降序」的错误版本,输出的正是 313 和 919。)

🎯 正确的比较规则:拼接比较

不要单独看 a 和 b,而要看「a 拼 b」和「b 拼 a」哪个更大

如果 a + b > b + a,那么 a 应该排在 b 前面

回到 {3, 31}"3"+"31" = "331""31"+"3" = "313", 因为 "331" > "313",所以 3 排在 31 前面,拼出来是 331。✅

再看 {9, 91}"9"+"91" = "991""91"+"9" = "919", 991 > 919,所以 9 排在 91 前面,得到 991。✅

为什么这样比是对的

三个理由,说服你自己:

  1. a+b 和 b+a 的长度一定相同(都是「a 的长度 + b 的长度」)。 长度相同的字符串比大小,就是纯粹的数值比大小,不用考虑位数问题。这一点非常关键—— 如果长度不同,「位数多的更大」和「字典序更大」就不一定一致了。
  2. 它回答的正是题目要问的问题。题目要总长度最大,而在「a 在前」和「b 在前」之间二选一时, 局部最优就是选拼接串更大的那种排法。当这个「两两比较」规则被 sort 全局应用之后,得到的整体拼接串就是最大的。
  3. 它满足严格弱序:当 a+b == b+a 时(比如 a=b,或者 {"12","1212"} 这种), cmp 两边都返回 false,视为「等价」。所以它可以安全地交给 sort
💡 数据保证 aᵢ ≥ 1,所以不用管前导零

题面给了 1 ≤ aᵢ,也就是说输入里不会出现 0。 如果真的有 0(比如 0 1),拼接结果可能是 "01" 这种不合法的整数形式, 那时才需要特判。这题不用操心。

样例输入 1
3
13 312 343
样例输出 1
34331213
样例输入 2
4
7 13 4 246
样例输出 2
7424613

样例 1:343 > 312 > 13(两两拼接比较后),拼成 34331213。
样例 2:顺序是 7、4、246、13 → 拼成 7424613。

完整代码

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

string s[25];

// ★ 核心比较规则:如果 a 接在 b 前面比 b 接在 a 前面更大,a 就排在前面
bool cmp(const string &a, const string &b){
    return a + b > b + a;
}

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> s[i];

    sort(s, s + n, cmp);

    for(int i = 0; i < n; i++) cout << s[i];
    cout << endl;
    return 0;
}

读入直接读成 string,不做任何转换——因为最后要的就是拼接出来的字符串, 中间用不用 int 根本没意义,而且 int 也存不下 10 位数拼起来的结果。

✅ 实测结果

官方样例 1 3 / 13 312 343 → 输出 34331213 完全一致

官方样例 2 4 / 7 13 4 246 → 输出 7424613 完全一致

关键反例 2 / 3 31 → 输出 331 正确(朴素字典序降序会输出 313,错)

关键反例 2 / 9 91 → 输出 991 正确(朴素字典序降序会输出 919,错)

⚠️ 一个非常重要的实测发现:我专门写了一个「按字典序降序」的错误版本, 拿它去跑两个官方样例,竟然都输出正确(34331213、7424613 全对); 只有在我自造的反例 {3,31}{9,91} 上才露出马脚(输出 313、919)。

结论:样例全过 ≠ 代码正确。P1012 是「样例骗人」的典型案例,比较规则必须从原理上想清楚。

易错点

  • int / long long 去拼:20 个数各 10 位,拼起来最多 200 位, 什么整数类型都存不下,必须用 string
  • cmp 写成 a > b(字典序降序):样例能过,隐藏数据必挂,见上面的反例。
  • cmp 写成 a + b >= b + a:多了个等号,破坏严格弱序,属于未定义行为, 可能崩也可能排错。必须用严格的 >
  • cmp 里反复做字符串拼接在数据量大时会有开销,但本题 n ≤ 20,完全无所谓。
  • 输出是一个整数(没有空格、没有换行分组),别在中间加空格——加了就变成另一个东西了。
P1781 宇宙总统 普及- · 大数比较(长度 + 字典序)

题意:n 个候选人,每人有一个票数,票数可能非常大(最多 100 位)。 输出票数最多的那个人的号数(第几行输入的),再输出他的票数。保证没有并列。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 20;票数可能大到 100 位数字;保证不存在票数相同的候选人。

100 位是什么概念?long long 最多存 19 位。所以只能把票数当字符串读进来

思路:大数比大小,两步走

🎯 大数比较口诀:先比位数,位数相同再比字典序

第一步:谁长谁大。一个数的位数多,它一定更大 (因为两个数都没有前导零:5 位数最小是 10000,4 位数最大是 9999,10000 > 9999 恒成立)。

第二步:一样长就按字典序比。位数相同时,字符串的字典序比较等价于数值比较—— 因为逐位比较就是「从最高位开始比」,第一位谁大谁就大。而 string> 正是逐字符比较 ASCII 码,数字字符 '0'~'9' 的 ASCII 也是递增的,所以完全对得上。

实现上用「打擂台」比排序更简单,毕竟 n 只有 20:

  1. 读第一个候选人,把他当擂主(记住他的字符串和号数)。
  2. 后面每个人来挑战:先比长度,长度相同再比字典序,赢了就换擂主。
  3. 最后输出擂主的号数和票数。
💡 号数别丢

又是昨天那个道理:要输出「第几个人」,就必须在扫描时把号数一直带在身边。 这里用一个 bestId 变量跟着擂主字符串一起更新就行了。

样例输入
5
98765
12365
87954
1022356
985678
样例输出
4
1022356

5 个票数里,1022356 是唯一一个 7 位数,其他都是 5 位或 6 位数,所以位数最多的一定最大 → 第 4 个人当选。这题样例本身就体现了「先比长度」的重要性:985678(6 位)比 98765(5 位)大, 但比 1022356(7 位)小。

完整代码

#include <iostream>
#include <string>
using namespace std;

int main(){
    int n;
    cin >> n;

    string best = "";       // 目前票数最多的那个数
    int bestId = 0;         // 它的号数

    for(int i = 1; i <= n; i++){
        string s;
        cin >> s;

        bool better = false;
        if(bestId == 0){
            better = true;                                  // 第一个候选人,直接当擂主
        }else if(s.size() != best.size()){
            better = (s.size() > best.size());              // ① 位数多的数一定更大
        }else{
            better = (s > best);                            // ② 位数相同,用字典序比
        }

        if(better){
            best = s;
            bestId = i;
        }
    }

    cout << bestId << endl;
    cout << best << endl;
    return 0;
}
✅ 实测结果

官方样例 → 输出 4 / 1022356 完全一致

自造大数据测试(3 个候选人:一个 99 位数 999…9、一个 100 位数 999…98、一个 12345) → 输出 2 / 那个 100 位数 正确

这个测试说明:100 位数字用 string 存完全没问题, 位数多的那个(100 位)确实更大,程序选出了第 2 个候选人。

「先比长度」这一步到底有多必要?我专门写了个只比字典序的错误版本做了对照:

  • 错误版本跑官方样例 → 输出 1 / 98765 是错的(正确答案是 4 / 1022356)。 因为 '9' 的 ASCII 码比 '1' 大,字典序认为 98765 > 1022356。
  • 换一组 2 / 9 / 10:正确版输出 2 / 10; 错误版输出 1 / 9。字典序认为 "9" > "10",但数值上 9 < 10。

结论:大数比大小,「先比长度」绝对不能省。(这题比 P1012 友好——错误做法连样例都过不了, 而不是像 P1012 那样样例全过、隐藏数据才挂。)

易错点

  • long long 读票数:100 位数字直接溢出,读进来就是垃圾值。必须用 string
  • 只按字典序比、忘了先比长度"9" 会被判为大于 "10",答案就错了。
  • 把号数搞成 0-based:输出要求的是「第一个候选人到第 n 个候选人」,第一行是 1 不是 0。
  • 并列怎么办:题面保证不存在票数相同的候选人,所以不用处理。 如果真遇到,用严格大于(better 只在严格更大时为 true)就会保留靠前的那位。
P1116 车厢重组 普及- · 逆序对 / 冒泡交换次数

题意:一条桥最多容纳两节车厢,转 180 度就能交换相邻两节车厢。 要用这个操作把车厢按编号从小到大排好,问最少要转多少次。

⚠️ 数据范围 / 关键约束

车厢总数 N ≤ 1000,N 个数互不相同

还有一个藏在输入格式里的坑:题面特意注明 「实际上数据中并不都在同一行,有可能分行输入」。

所以如果你用 getline 按行读、或者假设「第二行正好有 N 个数」,就会翻车。 cin >> a[i] 逐个读则完全免疫——因为 cin 会 自动跳过空格和换行,根本不在乎数据是怎么分行的。这也是我一直推荐用 cin >> 读数字的原因之一。

思路:把「旋转」翻译成「相邻交换」

题目绕了一大圈,本质就是一句:你有一个操作,可以交换数组里相邻的两个元素,问最少几次能让数组升序。

而「最少次数」这个问题的答案非常干净:

🎯 结论:最少步数 = 逆序对个数

下界:每次相邻交换最多只能消掉 1 个逆序对,所以步数 ≥ 逆序对个数。

达成:冒泡排序每次发现相邻逆序就交换,恰好每次都消掉 1 个,最终把逆序对清零,步数 = 逆序对个数。

两边一夹:最少步数 = 逆序对个数 = 冒泡排序的交换次数。

所以这题的代码骨架就是「冒泡排序 + 一个计数器」。N ≤ 1000 时 O(N²) 是 10⁶ 次操作,轻松通过。

样例输入
4
4 3 2 1
样例输出
6

4 3 2 1 的逆序对:(4,3)(4,2)(4,1)(3,2)(3,1)(2,1),共 6 对 → 需要 6 次旋转。也可以手数:第一轮 3 次交换、第二轮 2 次、第三轮 1 次,3+2+1 = 6。

完整代码(冒泡,最简单)

#include <iostream>
#include <algorithm>
using namespace std;

int a[1005];

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> a[i];   // cin 自动跳过换行,分行输入也不怕

    int cnt = 0;
    // 冒泡排序:每一轮把当前最大的车厢「冒」到最右边
    for(int i = 0; i < n - 1; i++){
        for(int j = 0; j < n - 1 - i; j++){
            if(a[j] > a[j+1]){          // 相邻两节逆序,必须旋转一次
                swap(a[j], a[j+1]);
                cnt++;                  // 每交换一次记一步
            }
        }
    }

    cout << cnt << endl;
    return 0;
}

进阶版:归并排序数逆序对(O(N log N))

如果 N 变成 10⁵,冒泡的 O(N²) 就超时了。这时用归并排序,在合并两段的时候顺手把逆序对统计出来:

#include <cstdio>
#include <iostream>
using namespace std;

int a[1005], tmp[1005];
long long cnt = 0;          // 逆序对个数

// 归并排序:顺便统计跨越左右两半的逆序对
void mergeSort(int l, int r){
    if(l >= r) return;
    int mid = (l + r) / 2;
    mergeSort(l, mid);
    mergeSort(mid + 1, r);

    int i = l, j = mid + 1, k = l;
    while(i <= mid && j <= r){
        if(a[i] <= a[j]){
            tmp[k++] = a[i++];
        }else{
            tmp[k++] = a[j++];
            cnt += (mid - i + 1);   // 左半段剩下的 i..mid 全都比 a[j] 大
        }
    }
    while(i <= mid) tmp[k++] = a[i++];
    while(j <= r)   tmp[k++] = a[j++];
    for(int t = l; t <= r; t++) a[t] = tmp[t];
}

int main(){
    int n;
    scanf("%d", &n);
    for(int i = 0; i < n; i++) scanf("%d", &a[i]);
    mergeSort(0, n - 1);
    cout << cnt << endl;
    return 0;
}
💡 这版代码的 key 就一行

合并时如果右半段的 a[j] 比左半段的 a[i] 小, 说明左半段从 imidmid - i + 1 个数全都不小于 a[j], 而且它们在原数组里都排在 a[j] 前面——一次就贡献了这么多逆序对

今天的题用不上,但它是后面「CDQ 分治」「树状数组求逆序对」的思想起点,值得先混个脸熟。

✅ 实测结果

官方样例 4 / 4 3 2 1:冒泡版输出 6,归并版也输出 6 与样例完全一致

边界测试:已有序输入 3 / 1 2 3 → 输出 0(不用旋转) 正确
N = 1(1 / 1)→ 输出 0 正确

最大规模 N = 1000 完全降序(逆序对最多的情况)→ 输出 499500, 与理论值 1000×999÷2 = 499500 完全吻合,运行毫秒级完成

交叉验证:用 12 组随机数据(n 从 10 到 65)分别跑冒泡版和归并版, 12 组结果全部相同(例如 n = 65 那组两版都是 1043,n = 58 那组都是 692,n = 12 那组都是 20), 实测支持了「冒泡交换次数 = 逆序对个数」这条结论。

易错点

  • getline 读数据:题面明说数据可能分行,分行读会把一行当成一整份,直接错。 用 cin >>scanf 逐个读数字最稳。
  • 把 1005 写成 1000:N 最大 1000,数组至少要 a[1001],开 a[1005] 更保险。
  • 以为要「排序后输出」:题目只要输出次数,不要输出排好序的车厢序列。
  • 内层循环边界写成 j < n - 1 而不是 j < n - 1 - i: 多跑一些不影响正确性(只是慢一点),但内层访问到 a[n] 就越界了,必须带 - i
  • 以为「最少」是随便走走看运气:这题答案是确定的(逆序对数),不需要搜索。

今日常见错误汇总

错误写法后果正确写法
sort(a+1, a+n, cmp) 左闭右开,最后一个元素没参与排序,样例可能还过 sort(a+1, a+n+1, cmp)
sort(a, a+n, greater<int>) 少了括号,编译报模板推导失败 greater<int>()
bool cmp(int x,int y){return x<=y;} 破坏严格弱序 → 未定义行为,可能越界崩溃或排错 return x < y;
if(x.total > y.total) return true;
return x.id < y.id;
第一关键字相等时,会被后面的条件错误接管 if(x.total != y.total)
  return x.total > y.total;
去掉重时先 unique 再 sort unique 只删相邻重复,没排序时一个都删不掉 先 sort,再 unique
vector 只用 unique 不 erase size() 没变,尾巴上还留着垃圾数据 v.erase(unique(v.begin(),v.end()), v.end())
P1012 用 a > b 当 cmp 两个样例全过但隐藏数据必挂({3,31} 会输出 313) return a + b > b + a;
P1781 用 long long 读票数 100 位数字溢出,读到垃圾值 string s; cin >> s; 先比长度再比字典序
P1116 用 getline 读车厢 数据可能跨行,按行读全乱 cin >> a[i],自动跳过空白符
变量 int 存逆序对数 n 大时 n²/2 可能超 int(n=1000 时 50 万没超,但习惯要养好) long long cnt;

自测:你真的会了吗

第 1 题:数据存在 a[0] ~ a[n-1],要降序排,sort 怎么写?

sort(a, a + n, greater<int>());

三个要点:① 需要 #include <algorithm>; ② 右端点是 a + n(左闭右开,不含 a[n]); ③ greater<int> 后面的括号不能丢。

第 2 题:下面这个 cmp 有什么问题?
bool cmp(int x, int y){
    return x <= y;
}

:用了 <=,不满足严格弱序cmp(x,x) 返回了 true,等于说「自己排在自己前面」)。

sort 要求的是严格小于。写 <= 属于未定义行为:可能让排序库越界读写直接崩溃, 也可能悄悄排出错误的顺序。正确写法是 return x < y;

★ 补充一句真话:我在本机 MinGW g++ 6.3.0 / -O2 上实测过这个错误 cmp, 排 20 万随机数时并没有崩、结果也恰好是对的。 但这不代表安全——未定义行为的意思是「标准不保证任何结果」,换个环境就可能出事。 别拿编译器的宽容当运气。

第 3 题:3 31 3 三个数拼起来最大是多少?

a + b > b + a 的规则两两比一遍:

· 3 和 31:"331" > "313" → 3 在前
· 3 和 3:"33" == "33" → 等价,谁先都一样
· 31 和 3:"313" < "331" → 3 在前

:排序结果是 3 3 31,拼起来是 3313

验证一下确实是最大:3313(3-3-31)、3313(3-31-3 也是 3313)…… 而 3133 更小。✅

第 4 题:数组 5 4 3 2 1 的逆序对有几个?冒泡要交换几次?

:逆序对 = 任取两个数都是「前大后小」= C(5,2) = 10 个。 冒泡交换次数也是 10 次(两者永远相等)。

手数验证:4 + 3 + 2 + 1 + 0 = 10。✅
一般结论:完全降序的 n 个数,逆序对是 n(n-1)/2 个—— 这就是为什么 1000 个降序车厢的答案是 1000×999÷2 = 499500。

第 5 题:为什么去重前一定要先排序?

:因为 unique 只比较相邻的两个元素,把相邻的重复项合并掉。 如果数组是 2 5 2,两个 2 不相邻,unique 一个都删不掉。

排完序之后(2 2 5),相同的数一定挨在一起,unique 才能一次清干净。

★ 一句话:sort 负责把重复的凑到一起,unique 负责把它们合并。分工明确,顺序不能换。

速查卡(截图存手机)

【sort 三件套】  #include <algorithm>
  sort(a, a + n);                          // 升序(左闭右开!)
  sort(a, a + n, greater<int>());          // 降序,括号别丢
  sort(a, a + n, cmp);                     // 自定义规则
  sort(v.begin(), v.end());                // vector
  // 数据在 a[1]~a[n] → sort(a+1, a+n+1, cmp)

【cmp 的铁律】
  cmp(x, y) 返回 true  =  x 排在 y 前面
  只能写严格 < 或 >,绝不能写 <= / >=
  多关键字:if(x.k1 != y.k1) return x.k1 > y.k1;
            if(x.k2 != y.k2) return x.k2 > y.k2;
            return x.k3 < y.k3;
  参数用 const T &x

【去重三连】
  sort(a, a + n);
  int m = unique(a, a + n) - a;            // 数组:m 就是新长度
  v.erase(unique(v.begin(), v.end()), v.end());   // vector 要 erase

【桶去重(值域小时更好用)】
  bool vis[1005] = {false};
  vis[x] = true;
  for(int v = 1; v <= 1000; v++) if(vis[v]) cout << v << " ";

【冒泡排序 + 数交换次数】
  int cnt = 0;
  for(int i = 0; i < n - 1; i++)
      for(int j = 0; j < n - 1 - i; j++)
          if(a[j] > a[j+1]){ swap(a[j], a[j+1]); cnt++; }
  // cnt = 逆序对个数 = 最少相邻交换次数

【大数比较(字符串)】
  先比长度:s.size() != t.size() → 长的更大
  再比字典序:s > t

【拼接最大数(P1012)】
  bool cmp(const string &a, const string &b){
      return a + b > b + a;     // ★ 不是 a > b!
  }

【输入防坑】
  cin >> x  自动跳过空格和换行 → 数据分行也无所谓
  不要用 getline 读数字序列

明天预告 · Day 13

贪心入门:局部最优通向全局最优

今天学的排序,明天马上要派上大用场——「排序 + 贪心」是竞赛里最常见的组合拳: P1223 排队接水(按接水时间从小到大排)、P1094 纪念品分组(排序 + 双指针)、 P4995 跳跳、P3817 小 A 的糖果,全都建立在你今天掌握的排序能力之上。

另外,今天没搞懂的 P1012 拼接比较,明天还会以别的形式出现,记得复习一下 cmp 的心法。

ACM 一个月学习计划 · DAY 13

Day 13 · 贪心入门:局部最优通向全局最优

每一步都做当前看起来最好的选择 —— 但你必须能说清「为什么这样贪不会错」

今日目标:理解贪心思想,掌握「排序 + 贪心」这个最常见的组合,学会用交换论证和反例检验自己的贪心
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1223 / P1094 / P4995 / P3817
DAY 13 / 30

今日时间安排

0 - 25 min搞懂贪心是什么、它和暴力/DP 的区别(第 1 节)
25 - 55 min重点:怎么证明贪心是对的 + 怎么用反例把自己推翻(第 2 节)
55 - 140 min做 4 道题,每题限时 30 分钟
140 - 170 min对照本文题解,把没做出来的补掉,尤其是 P3817
170 - 180 min写题解笔记 + 做自测题
💡 和昨天内容的衔接

Day 12 你学了排序。当时可能觉得「排序不就是排个序吗」,今天你就会明白: 排序是贪心最亲密的搭档。今天四道题里有三道的第一步都是 sort—— 因为「按什么关键字排序」往往就是贪心策略本身。

1什么是贪心

先看一个每天都在发生的场景

你去超市买东西,一共 66 元。收银员找钱的时候会怎么找?

他不会掏出一把 1 元的硬币数 66 个,而是:先看有没有 50 元,有就拿一张;再看 10 元,拿一张;再看 5 元,拿一张;最后 1 元,拿一张。一共 4 张。

他每一步都在做一件事:在「不超过剩余金额」的前提下,拿面额最大的那张。

这就是贪心。他不考虑「万一等下需要零钱怎么办」,也不回头把已经拿出来的 50 元换成两张 20 元—— 每一步都选当前看起来最好的,选完就不再改。

🎯 贪心的核心就一句话

把问题拆成一连串的选择,每一步都选当前最优的那个,并且相信这样走下去整体就是最优的。

贪心和暴力、DP 有什么不一样

做法怎么走典型复杂度能不能保证正确
暴力 / 搜索把所有可能都试一遍,最后挑最好的指数级,慢✅ 一定对
动态规划(DP)每个状态都算出最优值,一步步推到全局多项式,较快✅ 一定对
贪心只顺着一条路走,选了就不回头通常 O(n log n)⚠️ 不一定对!
⚠️ 贪心最大的特点,也是最大的坑

贪心,但不是所有题都能贪。同样一道题,换一个贪心策略,答案就可能错。

所以学贪心,一半的功夫在「想出策略」,另一半在「证明这个策略没错,或者找出一个反例把它推翻」。 第二半才是真正区分水平的地方。

贪心的「不回头」体现在哪里

拿今天第一题 P1223 打比方。有 3 个人接水,时间分别是 3、1、2 分钟。

如果你决定让时间最长的人先接(3 分钟那个人),那么后面 2 个人都得干等 3 分钟。 而如果让 1 分钟的人先接,后两个人只需要等 1 分钟。

贪心会直接算出一个规则:「谁快谁先上」,然后按这个规则一次性排好,排完就结束,绝不反悔

💡 一句话记住贪心和 DP 的分界

贪心只需要「一条路」,DP 需要「一张表」。
如果一个选择会影响后面所有选择(做了就得负责),那通常不能贪,要考虑 DP; 如果每个选择互不干扰、只影响自己那一小段,就可以试试贪心。

2重点:凭什么说「贪心是对的」

这一节是今天最重要的内容。不会证明,你就是在赌运气;会证明,你才敢把代码提交上去。

方法一:交换论证(最常用的那一招)

交换论证的套路固定得像个模板,一共三步:

  1. 假设:假设存在一个最优解 OPT,它和贪心解在某个位置不一样。
  2. 交换:在 OPT 里,把那个不一样的选择换成贪心选的那个。
  3. 验证:证明换完之后,答案没有变差(还是一样好,甚至更好)。

第 3 步成立,就说明「贪心的选择至少不会拖后腿」,一直换下去,贪心解就是一个最优解。 这就是「为什么贪心是对的」的标准答案。

现在就动手证一遍:P1223 为什么「接水快的先上」

假设排好的队伍里,有相邻的两个人:前面那位接水要 a 分钟,后面那位要 b 分钟,而且 a > b(也就是「慢的排在了快的前面」)。

这两个人后面还有 k 个人。我们只看这 k 个人被影响的等待时间:

方案前面 k 个人的等待时间之和
保持原样(a 在前,b 在后)k × a + k × b
把两人交换(b 在前,a 在后)k × b + k × a

咦,看起来一样?别急 —— 关键在于这两个人自己也要等待。后面那位(原本是 b)要等前面那位走完,所以:

方案这两个人自己的等待时间之和
原样(a 在前)前面那位等 0 分钟,后面那位等 a 分钟 → a
交换(b 在前)前面那位等 0 分钟,后面那位等 b 分钟 → b

因为 a > b,交换之后这两个人的等待时间从 a 变成了 b变小了;而后面 k 个人的等待时间完全没变(他们等的始终是 a+b 这么多时间)。

✅ 结论

只要队伍里出现「慢的排在快的前面」,把它俩换一下,总等待时间一定减少(或至少不变)。
所以最优队伍里不可能有「慢在前、快在后」的相邻对 → 最优方案就是按接水时间从小到大排

这个证明的思路叫「相邻交换法」,是交换论证里最常用的一种。以后遇到「排队类贪心」,基本都用它。

方法二:反例思维 —— 先用小数据把自己打一顿

想出一个贪心策略之后,不要立刻写代码。先花两分钟问自己一句:

🎯 灵魂拷问

「我能举出一个反例吗?」

举不出来 → 大概率是对的,可以写代码了。
举出来了 → 策略错了,赶紧换,省下 30 分钟。

看两个真实的反例(第二个我今天真的踩了)

反例 A:P1094 纪念品分组,策略「每次把最贵的和最贵的凑一对」。

听起来合理(贵的跟贵的放一起,省下便宜的?)。造个数据:w = 100,价格是 60 60 40 40

按「贵配贵」:60+60 = 120 > 100,配不上,于是两个 60 各占一组、两个 40 凑一组 → 3 组
正确做法「贵配便宜」:60+40 = 100 ≤ 100,两组搞定 → 2 组。策略错了 ❌

反例 B:P3817 小 A 的糖果,策略「一旦相邻两盒之和超过 x,就无脑削右边那盒」。

这个策略在大部分数据上看起来都对(计划原文也是这么写的),但我今天实际写出来对拍之后, 发现它有一个真实的漏洞。数据:n = 3, x = 2, a = [4, 1, 3]

步骤「无脑削右边」会怎么算
看第 1、2 盒4 + 1 = 5 > 2,超出 3,削右边那盒 3 颗
问题来了第 2 盒一共只有 1 颗!削 3 颗就变成 −2 了 —— 糖果盒不可能有负数
算出的答案3
正确答案4(第 1 盒吃掉 2 颗变成 2,第 2 盒吃掉 1 颗变成 0,然后第 3 盒吃掉 1 颗变成 2)
🚫 我实测跑出来的数字

我把这个「无脑削右边」的版本编译运行,输入 3 2 / 4 1 3,程序输出 3
而用暴力枚举所有吃法(每盒吃 0~aᵢ 颗)跑同一组数据,得到的真正最小值是 4

3 < 4,贪心版本报出了一个根本做不到的更小答案 —— 这就是错误。

原因是漏了一种情况:右边那盒不够吃的时候,剩下的必须从左边那盒吃。本文 P3817 的题解里给了修正后的代码。

💡 所以「对拍」是竞赛里最值钱的技能

写完贪心,再花十分钟写一个只求「答案对」的暴力(枚举 / 状压 / DFS), 随机造几百组小数据让两个程序互相对答案。只要有一组不一样,你就抓到了一个反例。

今天本文的四道题我全都这么做了,具体结果写在每道题的「实测结果」里。

3什么时候该想到贪心

三个信号

#信号例子
1题目问的是「最多 / 最少 / 最大 / 最小」的个数或总量P1803 最多参加几场比赛、P1094 最少分几组
2数据范围很大(10⁵ ~ 10⁶),DP 的二维表开不下P1803 的 n ≤ 10⁶,O(n²) 绝对过不了
3存在一个明显的「好坏」标准,且这个标准不随情况变P1223 接水时间越短越好、P2240 性价比越高越好
⚠️ 反过来,出现这些情况就要小心了

· 某个选择会改变后面所有选项的价值 → 大概率要 DP(比如 0/1 背包)。
· 题目里出现「物品不可分割」、「只能选或不选」→ 不能按性价比贪(Day 26 会讲 0/1 背包)。
· 你试了三组数据贪心答案都不一样 → 换个策略或者干脆想 DP。

排序是贪心的好朋友

今天四道题,第一行代码几乎都是 sort。原因很简单:

🎯 「按什么排序」= 「贪心策略是什么」

贪心要「每一步都选当前最好的」。但如果数据是乱序的,「最好的」藏在中间,你每步都得重新找一遍。 排好序之后,「最好的」永远在队头或队尾,看一眼就知道该拿谁 —— 一步到位。

题目按什么排序为什么是这个关键字
P1223 排队接水接水时间 升序快的先走,后面所有人少等
P1094 纪念品分组价格 升序双指针要从两端取最轻/最重
P4995 跳跳!高度 升序要交替取最高的和最矮的
P3817 小 A 的糖果不排序位置是固定的,只能从左往右扫
💡 一个实用的思考顺序

拿到一道「最值」题,先问自己:「如果让我手动排一下,我会按哪个关键字排?」
这个关键字往往就是答案。今天四道题里,P1094 是按价格排、P4995 是按高度排、P1223 是按时间排。

4双指针:排序之后的标配动作

排完序之后,经常会遇到这种需求:「我要同时盯着最小的一批和最大的一批。」 这时候就用两个指针,一个从左边出发,一个从右边出发,向中间收缩。

sort(a, a + n);          // 先排序

int l = 0, r = n - 1;    // l 指最左(最小),r 指最右(最大)
while(l <= r){           // ★ 注意是 <= ,l 和 r 相等时中间还剩一个元素
    // 根据 a[l] 和 a[r] 的关系决定怎么动
    if(条件成立){
        l++;  r--;       // 两端都用掉了,一起往中间收
    } else {
        r--;             // 只处理右边那个
    }
}
⚠️ 双指针三个必踩的坑

坑 1:循环条件写 l < r这样当 l == r(中间还剩最后一个元素)时循环直接结束, 那个元素就被漏掉了。要写 l <= r,并在里面单独处理 l == r 的情况。

坑 2:忘了先排序。双指针依赖「左边一定小、右边一定大」,不排序就是瞎走。

坑 3:动了 l 却没动 r每次循环必须保证区间真的在变小,否则死循环。

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译过(零警告), 跑完了官方样例,还额外写了暴力程序随机对拍。所有「实测结果」都是真实输出。

P1223 排队接水 普及- · 排序 + 相邻交换

题意:n 个人排队接水,第 i 个人要接 Tᵢ 分钟。一个人的等待时间不算自己接水的时间。 要你排出一种顺序,让所有人的平均等待时间最小,并输出这个顺序和平均等待时间(保留两位小数)。 如果两个人的接水时间相同,编号更小的排前面

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 1000,1 ≤ tᵢ ≤ 10⁶,不保证 tᵢ 不重复。

算一下答案有多大:最坏情况是 n = 1000 人、每人都要 10⁶ 分钟。 总等待时间 ≈ 10⁶ × (1 + 2 + … + 999) ≈ 10⁶ × 5×10⁵ = 5×10¹¹。 而 int 最大只能到约 2.1×10⁹ —— 早就爆了

所以这题的累加变量必须是 long long,而且乘法要先转 long long

思路

贪心策略:接水时间短的先上。

为什么?一个人接水的时候,他后面所有人都在等他。所以一个人接水时间越长, 他被「算进别人的等待时间」里的次数就越多。

换句话说是这样:排在第 i 位的人(从 1 数),他的接水时间会被后面 n − i 个人各等一次。 所以他对总等待时间的贡献是 Tᵢ × (n − i)

乘数 (n − i) 在队伍前面很大、后面很小。要让总和最小, 就应该让大的 Tᵢ 配上小的乘数 —— 也就是让接水慢的人排到后面去。

🎯 换个说法:这就是「排序不等式」

两个序列,一个升序一个降序,反着配的时候乘积和最小,顺着配的时候最大。 这里 Tᵢ 升序、乘数 (n−i) 降序,正好是「反着配」的最小情况。

本文第 2 节里我用相邻交换法完整证明了它,可以回去看一遍。

样例输入
10
56 12 1 99 1000 234 33 55 99 812
样例输出
3 2 7 8 1 4 9 6 10 5
291.90

第一行输出的是人的编号(不是时间)。按时间升序排:1 (3号) → 12 (2号) → 33 (7号) → 55 (8号) → 56 (1号) → 99 (4号) → 99 (9号) → 234 (6号) → 812 (10号) → 1000 (5号)。
注意两个 99:4 号在 9 号前面,因为 4 号编号更小 —— 这就是「时间相同按编号」的体现。

完整代码

#include <iostream>
#include <algorithm>
#include <iomanip>
using namespace std;

struct Person {
    int id;   // 原来的编号(1 开始)
    int t;    // 接水时间
};

Person a[1005];

// 排序规则:接水时间小的排前面;时间相同时编号小的排前面
bool cmp(const Person &p, const Person &q){
    if(p.t != q.t) return p.t < q.t;
    return p.id < q.id;                 // ★ 时间相同按编号升序,不能漏
}

int main(){
    int n;
    cin >> n;
    for(int i = 1; i <= n; i++){
        a[i].id = i;
        cin >> a[i].t;
    }
    sort(a + 1, a + n + 1, cmp);        // 下标从 1 开始,所以是 a+1 到 a+n+1

    long long total = 0;                // ★ 总等待时间最大约 5×10^11,必须 long long
    for(int i = 1; i <= n; i++){
        // 排在第 i 位的人接水时,后面 n-i 个人都在等他
        total += 1LL * a[i].t * (n - i); // ★ 1LL 先转 long long,再乘,防止中途溢出
    }

    for(int i = 1; i <= n; i++){
        if(i > 1) cout << " ";
        cout << a[i].id;
    }
    cout << "\n";
    // ★ 先转 double 再除,否则会做整数除法,小数部分直接没了
    cout << fixed << setprecision(2) << (double)total / n << "\n";
    return 0;
}
✅ 实测结果

官方样例 10 / 56 12 1 99 1000 234 33 55 99 812 → 输出
3 2 7 8 1 4 9 6 10 5291.90 与样例完全一致

边界 n = 1:输入 1 / 7 → 输出 10.00 正确 (只有一个人,没人需要等)

极限数据:n = 1000、所有人 t = 10⁶ → 输出顺序 1 2 3 … 1000, 平均等待时间 499500000.00 正确 (总等待时间 4.995×10¹¹,已经远超 int 上限 2147483647 —— long long 扛住了,实测耗时 28 ms)

暴力对拍:另写一个程序枚举所有排队顺序(n ≤ 7)求最小总等待时间, 随机造 300 组数据对比 → 300/300 全部一致

易错点

  • 不用 long long:总等待时间能到 5×10¹¹,int 存不下,会输出负数或者莫名其妙的数。
  • 乘法写成 a[i].t * (n - i):两边都是 int,会先按 int 算完再赋给 long long,中间就已经溢出了。
    必须写 1LL * a[i].t * (n - i),让编译器一开始就按 64 位算。
  • 平均值写成 total / n:这是整数除法,2919 / 10 会得到 291 而不是 291.9
    要写 (double)total / n,并且加上 fixed << setprecision(2) 才会输出两位小数。
  • 输出的是编号不是时间:第一行要输出 3 2 7 8 …(人的编号),把接水时间输出出去就废了。
  • 时间相同时忘了按编号升序:样例里两个 99 就是专门用来考这个的,顺序错了会 WA。
  • 数组下标从 1 开始却只开了 1000:题目 n 最大 1000,需要用到 a[1000],所以至少开 1001,本文开 1005 留余量。
P1094 [NOIP 2007 普及组] 纪念品分组 普及- · 排序 + 双指针

题意:有一堆纪念品,每件有个价格。要把它们分组发下去,规则是: 每组最多 2 件,而且每组两件的价格之和不能超过 w。问最少要分多少组。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 3×10⁴,80 ≤ w ≤ 200,5 ≤ Pᵢ ≤ w

注意最后一条:每件纪念品的价格都不超过 w。 这句话很重要 —— 它保证了任何一件纪念品单独放进一组都是合法的, 所以我们永远不用担心「某件太贵了没地方放」。

另外 w 最大只有 200,价格也在 200 以内,都是很小的整数(这一点后面可以拿来优化), 但 n 有 3 万,O(n²) 的两两配对是 9×10⁸ 次,会超时。必须做到 O(n log n)。

思路

贪心策略:先排序,然后每次拿「现在最重的」去试试能不能和「现在最轻的」凑一组。

能凑上就两个一起走;凑不上就说明最重的那件跟谁配都超标,只能自己占一组。

为什么这样贪是对的

用交换论证,重点想清楚一件事:最重的那件纪念品怎么办最划算?

设现在最重的是 r,最轻的是 l。假设 P_l + P_r ≤ w(它俩能配)。

最优解里 r 的情况能不能改造得一样好
r 和 l 一组就是贪心的做法,不用动 ✅
r 自己一组,l 也自己一组把 l 挪过来和 r 配,组数 −1,比「最优解」还少 → 矛盾,说明最优解不会这样
r 自己一组,l 和别人 x 一组因为 P_l ≤ P_x,所以 P_l + P_r ≤ P_x + P_r ≤ w
改成「r 配 l、x 自己一组」,组数不变,仍然是最优 ✅
r 和 x 一组(x 不是 l)把 x 换成 l:P_l + P_r ≤ P_x + P_r ≤ w,而且 l 原本的位置空出来了,
x 顶替 l 原来的位置也不会更差 ✅
✅ 结论

四种情况都能改造成「l 和 r 配成一组」且组数不增加。 所以一定存在一个最优解是「最重的配最轻的」。贪心选它,不会错。

一句话直觉:最重的那件最难安排,所以要把「最没用的」那件便宜货拿来陪它 —— 反正便宜货跟谁配都行。

样例输入
100
9
90
20
20
30
50
60
70
80
90
样例输出
6

排序后:20 20 30 50 60 70 80 90 90。配对过程:
90 配 20 → 110 超标,90 单独一组;
90 配 20 → 同样超标,90 单独一组;
80 配 20 → 100 ✅ 一组;70 配 20 → 90 ✅ 一组;60 配 30 → 90 ✅ 一组;50 剩自己 → 一组。
一共 6 组。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int p[30005];

int main(){
    int w, n;
    cin >> w;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> p[i];

    sort(p, p + n);              // 从小到大排序,双指针才好用

    int l = 0, r = n - 1, ans = 0;
    while(l <= r){               // ★ 用 <= 而不是 < ,否则中间剩一个会被漏掉
        if(l == r){
            ans++;               // ★ 只剩一件,自己一组
            break;
        }
        if(p[l] + p[r] <= w){
            // 最轻的能配上最重的,就让它们一组(这样最重的不会浪费)
            l++;
            r--;
            ans++;
        } else {
            // 最重的配谁都超重(连最轻的都配不上),只能自己一组
            r--;
            ans++;
        }
    }
    cout << ans << "\n";
    return 0;
}
🎯 为什么 l == r 必须单独判

如果偷懒不判,让代码直接跑 p[l] + p[r] <= w,那就变成 「拿一件纪念品和它自己配」,算出来是 2 × P。这显然不是题目的意思。

虽然这道题里两种写法碰巧结果一样(不管走哪个分支都只产生 1 组), 但「自己和自己配对」是逻辑错误,换个题目就会出事。养成习惯,老老实实判。

✅ 实测结果

官方样例 → 输出 6 与样例完全一致

边界 n = 1:输入 100 / 1 / 50 → 输出 1 正确

顺手能全配上:输入 100 / 4 / 20 30 40 60 → 输出 2 正确 (20+60 和 30+40 各一组)

最大数据:n = 3×10⁴、w = 200、价格随机取 5~200 → 输出 15360实测耗时 40 ms  (O(n log n),非常宽裕)

暴力对拍:另写一个状压 DP 程序(枚举 2ⁿ 种分组方式求最小分组数), 随机造 300 组数据(n ≤ 8)对比 → 300/300 全部一致

易错点

  • 循环写成 while(l < r):中间剩下的单独一件会被漏掉,答案偏小。
  • 忘记 l == r 的特判:会变成「自己和自己配对」。
  • 贪心策略选错:比如「贵的配贵的」(第 2 节的反例 A 就是它),或者「轻的配轻的」——都会错。
  • 输入格式:w 在第一行、n 在第二行,然后 n 行每行一个价格。不要连续读成「一行两个数」,虽然用 cin >> 读其实都能过,但要看清题面。
  • 以为一件纪念品可能超过 w:题目保证了 Pᵢ ≤ w,所以不用写「放不下就输出无解」那种逻辑。
P4995 跳跳! 普及- · 排序 + 从两端交替取

题意:你和一堆石头,第 i 块高 hᵢ,地面高度 h₀ = 0。 从高度 x 跳到高度 y 消耗体力 (x − y)²。你必须把每块石头都跳一遍,最后停在哪块都行 (但不能跳回地面)。问:最多能消耗多少体力?

⚠️ 数据范围 / 关键约束

0 < hᵢ ≤ 10⁴,且保证 hᵢ 互不相同。n ≤ 300

算一下答案有多大:单次跳跃最多 (10⁴)² = 10⁸; 一共最多跳 n = 300 次,所以答案最大约 300 × 10⁸ = 3×10¹⁰

这又超过了 int 上限(2.1×10⁹),必须用 long long。 我实测 n = 300 的数据,答案跑出来是 9965394032 —— 已经是 99 亿,int 百分百爆。

思路

这题要的是最大体力,而体力是「高度差的平方」。平方这个东西有个特点:

🎯 关键观察

平方函数越往右涨得越快。差 1 消耗 1,差 2 消耗 4,差 10 消耗 100。
所以想耗体力,就要专挑差距大的跳:从最高的跳到最矮的,从最矮的跳到最高的。

贪心策略:

  1. 把高度从小到大排好序。
  2. 从地面(高度 0)出发,先跳到最高的那块石头。
  3. 然后跳到最矮的那块。
  4. 再跳到第二高的,再跳到第二矮的……像拉锯齿一样在两端之间来回横跳。

为什么每步都跳到两端之一?因为排好序之后,离当前点最远的那个高度,一定在两端。 既然越远越耗体力,那一端必然是候选。

为什么是「交替」而不是「一直跳最高」?因为你不能跳回地面,也不能重复跳同一块石头。 跳到最高处之后再想跳远,就只能去最矮处;到了最矮处再想跳远,就只能回高处 —— 自然就形成了交替。

💡 这个贪心的严格证明比较绕,我用了另一种办法确认

P4995 的严格证明要用到「排序不等式 + 每次交换都会让平方和变大」这类推导,对新手不太友好。 所以我的做法是:写一个暴力程序,把 n ≤ 7 的所有访问顺序全枚举一遍,取真正的最小/最大值,然后对拍。

随机造了 300 组数据,贪心和暴力300/300 完全一致。 这说明这个策略在小数据上确实无敌 —— 竞赛里这是非常有说服力的证据。

样例 1 输入
2
2 1
样例 1 输出
5
样例 2 输入
3
6 3 5
样例 2 输出
49

样例 1:地面 → 2(耗 2²=4)→ 1(耗 (2−1)²=1),共 5。
样例 2:排序后是 3 5 6。地面 → 6(耗 36)→ 3(耗 (6−3)²=9)→ 5(耗 (5−3)²=4),共 49。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int h[305];

int main(){
    int n;
    cin >> n;
    for(int i = 0; i < n; i++) cin >> h[i];
    sort(h, h + n);          // 从小到大排好,方便从两端交替取

    long long ans = 0;       // ★ 答案能到 3×10^10,必须 long long
    int l = 0, r = n - 1;
    long long cur = 0;       // 当前位置的高度,一开始站在地上 h0 = 0
    bool takeRight = true;   // 先跳最高的

    while(l <= r){
        long long nxt;
        if(takeRight){
            nxt = h[r];      // 拿当前最高的
            r--;
        } else {
            nxt = h[l];      // 拿当前最矮的
            l++;
        }
        ans += (nxt - cur) * (nxt - cur);   // 消耗 (差值)²
        cur = nxt;
        takeRight = !takeRight;             // ★ 下一次换成另一端
    }
    cout << ans << "\n";
    return 0;
}
✅ 实测结果

官方样例 1 2 / 2 15 一致
官方样例 2 3 / 6 3 549 一致

边界 n = 1:输入 1 / 749 正确 (地面 → 7,耗 7² = 49)

最大数据:n = 300、高度从 1~10⁴ 里随机取 300 个互不相同的值 → 输出 9965394032实测耗时 25 ms
这个数接近 100 亿,int 上限只有 21 亿 —— 用 int 必然出错,坐实了必须 long long

暴力对拍:另写一个程序用 next_permutation 枚举全部访问顺序(n ≤ 7)取最大值, 随机造 300 组数据(高度互不相同)对比 → 300/300 全部一致

易错点

  • int 存答案:最大能到 3×10¹⁰,必爆。这是本题最容易丢分的地方。
  • 算平方时没转 long longh[r] - h[l]int,两个 int 相乘还是 int
    本文代码里 nxtcur 都声明成了 long long,所以自动就按 64 位算了。
  • 跳跃方向搞反:题目要的是最大体力,不是最小。别习惯性地求最小。
  • 忘了从地面出发:第一跳是从高度 0 起跳的,不是从某块石头起跳。cur 的初值必须是 0。
  • 最后停在原地不动:题目说得很清楚,必须每块石头都跳到,不能跳过某些石头。所以循环必须在 l <= r 里把每一个都取走。
  • 不能跳回地面:所以最后一步一定是停在某块石头上,不能加一步「跳回地面」。
P3817 小 A 的糖果 普及/提高- · 线性贪心 + 反例意识

题意:n 个糖果盒排成一排,第 i 盒有 aᵢ 颗糖。每次可以从某一盒里吃掉一颗。 目标是让任意两个相邻盒子的糖果数之和都不超过 x。问最少要吃几颗。

⚠️ 数据范围 / 关键约束

2 ≤ n ≤ 10⁵0 ≤ aᵢ, x ≤ 10⁹

注意 aᵢ 和 x 都可能是 0,而且 aᵢ 的上限是 10⁹ —— 差一点又超过 int 的 2.1×10⁹ 了(两个 10⁹ 相加就是 2×10⁹)。

算一下答案有多大:每盒最多吃 10⁹ 颗,一共 10⁵ 盒,最坏情况答案能到 10¹⁴int 完全不够用,必须 long long

思路

从左往右一盒一盒地看。看第 i−1 盒和第 i 盒的时候,如果它俩的和超了 x, 那多出来的部分必须被吃掉,没得商量(因为只有这两盒能改,前面已经处理完了,不能反悔)。

问题只在于:吃哪一盒?

🎯 优先吃右边的(但要注意补锅)

优先吃右边的第 i 盒 —— 因为第 i−1 盒的「搭档关系」已经用完了, 而第 i 盒待会儿还要和第 i+1 盒配对,先削它同样有效,不浪费。

但如果第 i 盒不够吃呢?那就把第 i 盒吃空,剩下的缺口从第 i−1 盒补。 这是计划里没提到、但必须处理的情况(见下面的反例)。

⚠️ 反例:无脑「只削右边」是错的

计划原文的提示是「一旦相邻两堆之和超过 x 就削掉右边的」。如果真的一行照抄,会挂在这组数据上:

反例输入
3 2
4 1 3
正确答案
4

无脑削右边:4 + 1 = 5 > 2,超出 3 颗,从第 2 盒削 3 颗 —— 可第 2 盒只有 1 颗,削完变成 −2,程序算出 3。
正确方案:第 1 盒吃 2 颗(4→2),第 2 盒吃 1 颗(1→0),第 3 盒吃 1 颗(3→2),共 4 颗。

我实际把两种版本都编译运行过:错误的版本在这组数据上输出 3, 暴力枚举版本输出 4。所以这个补丁是必需的,不是可写可不写。

为什么这样贪是对的

每一步我们处理的都是「当前这一对 (i−1, i)」。此时第 i−2 盒及更左边都已经合法了, 后面的盒子还没被这对影响。所以:

  • 超出 x 的那部分无论如何都要吃掉(不在这两盒吃,就没别的地方可吃了)。
  • 在这两盒里,吃第 i 盒(右边)永远不比吃第 i−1 盒差:因为减少第 i 盒只会让它更小, 待会儿处理 (i, i+1) 时更容易达标;而减少第 i−1 盒对未来的配对毫无帮助。
  • 所以「能吃右边就吃右边」是安全的;只有右边不够时,才不得不往左边借。

注意吃左边也不会破坏已经处理好的部分:第 i−1 盒变小, a[i−2] + a[i−1] 只会更小,之前那个约束依然满足。

样例输入 1
3 3
2 2 2
样例输出 1
1
样例输入 2
6 1
1 6 1 2 0 4
样例输出 2
11
样例输入 3
5 9
3 1 4 1 5
样例输出 3
0

样例 1:2+2 = 4 > 3,吃掉 1 颗 → 1。
样例 2:第 2 盒吃 6 颗、第 4 盒吃 2 颗、第 6 盒吃 3 颗,共 11。
样例 3:所有相邻和分别是 4、5、5、6,都不超过 9,一颗都不用吃 → 0。

完整代码

#include <iostream>
using namespace std;

long long a[100005];            // ★ aᵢ 最大 10^9,且相邻和会用到两个数相加

int main(){
    ios::sync_with_stdio(false);    // n 最大 10^5,加快读入
    cin.tie(0);

    int n;
    long long x;
    cin >> n >> x;
    for(int i = 0; i < n; i++) cin >> a[i];

    long long eaten = 0;            // ★ 答案最大 10^14,必须 long long
    for(int i = 1; i < n; i++){
        long long sum = a[i - 1] + a[i];
        if(sum > x){
            // 至少要吃掉这么多颗,才能让 (i-1, i) 这一对和不超标
            long long need = sum - x;
            if(a[i] >= need){
                // 右盒够吃:全从右盒吃(右盒以后还能再削,最划算)
                a[i] -= need;
            } else {
                // ★ 右盒不够吃!先把右盒吃空,剩下不够的从上一盒补
                long long rest = need - a[i];
                a[i] = 0;
                a[i - 1] -= rest;   // rest ≤ a[i-1],绝不会吃成负数
            }
            eaten += need;
        }
    }
    cout << eaten << "\n";
    return 0;
}
✅ 实测结果

官方样例 1 3 3 / 2 2 21 一致
官方样例 2 6 1 / 1 6 1 2 0 411 一致
官方样例 3 5 9 / 3 1 4 1 50 一致

反例验证:输入 3 2 / 4 1 3 → 修正后的代码输出 4
(我另外写了一个不带补丁的版本,同一组数据输出 3 —— 确实是错的,暴力枚举确认真实答案是 4)

极限数据:n = 10⁵、所有 aᵢ = 10⁹、x = 0(每一对和都必须是 0,所以所有盒子都得清零)→ 输出 100000000000000(= 10¹⁴) 正确,实测耗时 75 ms

暴力对拍:另写一个程序枚举每盒吃 0~aᵢ 颗的所有组合(n ≤ 6,aᵢ ≤ 4)求最小值, 随机造 300 组数据对比 → 300/300 全部一致(第一版没过 —— 就是上面那个反例,改完补丁之后才 300/300 全过)

易错点

  • 只削右边忘了补锅:右盒不够吃时会吃成负数,答案偏小。这是本题最大的坑。
  • int:aᵢ 能到 10⁹,两个相加就 2×10⁹ 已经爆 int;答案能到 10¹⁴,更是必须 long long
  • 从右往左扫:那样会破坏「左边已经处理好、永不回头」的不变量,方向必须是从左到右
  • 忘了 x 可以是 0:x = 0 时每一对相邻和都必须 ≤ 0,因为糖果数非负,所以最终所有盒子都得是 0。
  • 以为要排序:这题不能排序!盒子是按位置排的,「相邻」指的是原来的位置关系,排完序就全乱了。

今日常见错误汇总

#错误写法后果正确写法
1int 存答案 P1223 会到 5×10¹¹、P4995 会到 3×10¹⁰、P3817 会到 10¹⁴ —— 全部超出 int 的 2.1×10⁹,输出变成乱码或负数 long long
2a[i].t * (n - i)(两个 int 相乘) 先按 int 算完再赋值,中间就已经溢出,赋给 long long 也救不回来 1LL * a[i].t * (n - i)
3total / n 求平均 整数除法,2919/10 得到 291,小数被砍掉 (double)total / n
4while(l < r) 的双指针 中间最后剩下一个元素时循环直接结束,它被漏掉 while(l <= r) 并特判 l == r
5P3817 只削右边不补锅 右盒不够吃时被吃成负数,答案偏小(反例 3 2 / 4 1 3:错答 3,正解 4) 右盒吃空后,剩余缺口从 a[i-1]
6P3817 去排序 「相邻」指的是原来的位置,排序后位置关系全乱 按原顺序从左到右扫
7P1223 时间相同时不按编号 样例里就有两个 99(4 号和 9 号),顺序反了直接 WA cmp 里第二关键字写 p.id < q.id
8贪心策略不验证就提交 比如 P1094 用「贵配贵」(反例 w=100, 60 60 40 40:错答 3,正解 2) 先交换论证,再写暴力对拍
9数组开小 P1223 下标从 1 开始,n = 1000 需要 a[1000],只开 1000 就越界 n + 5,本文开 1005
10大 n 不用快读 P3817 有 10⁵ 个数,P1803(明天)有 10⁶ 个,不关同步可能超时 ios::sync_with_stdio(false); cin.tie(0);

自测:你真的会了吗

第 1 题:算一下答案会不会爆 int

某题:n ≤ 300,每个数 ≤ 10⁴,答案是把这些数的平方加起来。用 int 够吗?

:单个平方最大 (10⁴)² = 10⁸,300 个加起来就是 3×10¹⁰int 上限约 2.1×10⁹ —— 不够,必须 long long

口诀:拿到题先估一下「最大值 × 个数 大概是多少位数」。超过 9 位数(10⁹)就别用 int 了。

第 2 题:交换论证的三步是哪三步?


假设存在一个最优解,它和贪心解在某处不同;
② 在那个最优解里,把不同的选择换成贪心的选择
③ 证明换完之后答案没有变差

三步都成立,就说明贪心的选择是安全的。对「排队类」问题,第 ③ 步通常用相邻交换法: 只比较相邻两个元素交换前后的差别,最容易算。

第 3 题:P1094 如果换成「每次把最贵的和最贵的凑一对」,反例是什么?

w = 100,价格 60 60 40 40

贵配贵:60+60 = 120 > 100 配不上,于是两个 60 各占一组、两个 40 凑一组 → 3 组
贵配便宜:60+40 = 100 ≤ 100,两组搞定 → 2 组

所以「贵配贵」是错的。正确策略是「最重的尽力去配最轻的」—— 因为最便宜的纪念品跟谁配都不吃亏,拿它去陪最重的那件最划算。

第 4 题:P3817 为什么不能「只削右边」?

:因为右边那盒的糖果可能不够削。数据 3 2 / 4 1 3
第 1、2 盒之和是 5,超出 2 的部分是 3,但第 2 盒只有 1 颗 —— 削 3 颗就变成 −2 了。

正确做法:第 2 盒吃空(吃 1 颗),剩下 2 颗的缺口从第 1 盒吃(4 → 2)。 然后第 2、3 盒是 0 + 3 = 3 > 2,再吃第 3 盒 1 颗。总共 4 颗。

教训:贪心策略要考虑到「资源不够用」这种边界情况。

第 5 题(动手写):P1223 里为什么必须写 1LL *

因为 a[i].tint(n - i) 也是 int。 C++ 会先按 int 算完这个乘法,再把结果转成 long long 加到 total 上。

10⁶ × 999 ≈ 10⁹,虽然没超 int,但换个数据(比如 n = 10⁶、t = 10⁶)就立刻爆了。 加上 1LL *,编译器从一开始就按 64 位算,永远安全。

// ❌ 危险:先按 int 乘,可能中途溢出
total += a[i].t * (n - i);

// ✅ 安全:1LL 让整个表达式变成 long long
total += 1LL * a[i].t * (n - i);

速查卡(截图存手机)

【贪心四步走】
  1. 想到一个策略 → 2. 交换论证 / 直觉解释
  3. 造反例试图推翻 → 4. 举不出反例才写代码

【交换论证模板】
  假设最优解在某处和贪心不同
  → 把那个选择换成贪心的选择
  → 证明答案没有变差
  排队类问题用「相邻交换法」最灵

【排序 + 贪心 关键字对照】
  P1223 接水   → 接水时间升序(快的先上)
  P1094 分组   → 价格升序 + 双指针(重配轻)
  P4995 跳跳   → 高度升序 + 两端交替
  P3817 糖果   → 不排序,从左到右线性扫
  P1803 线段   → 右端点升序(明天)
  P2240 背包   → 性价比降序(明天)
  P1090 果子   → 优先队列小根堆(明天)
  P1208 牛奶   → 单价升序(明天)

【双指针】
  sort(a, a + n);
  int l = 0, r = n - 1;
  while(l <= r){                  // ★ 不是 l < r
      if(l == r){ ...; break; }   // ★ 中间剩一个要特判
      if(条件) { l++; r--; }      // 两端都用掉
      else     { r--; }           // 只用右边
  }

【防溢出的三条铁律】
  1. 先估答案量级:超过 10^9 就用 long long
  2. 乘法前加 1LL:1LL * a * b
  3. 求平均先转 double:(double)total / n

【线性贪心(P3817 型)】
  for i = 1 .. n-1:
      need = a[i-1] + a[i] - x
      if need > 0:
          if a[i] >= need:  a[i] -= need
          else:             rest = need - a[i]
                            a[i] = 0
                            a[i-1] -= rest    // ★ 补锅
          ans += need

明天预告 · Day 14

贪心进阶:区间、背包、堆

今天你已经把「排序 + 贪心」这个基本套路练熟了。明天会碰到三种全新的贪心场景:

  • 区间贪心(P1803 线段覆盖)—— 按右端点排序,不是左端点。这是区间问题的第一定理。
  • 分数背包(P2240)—— 按性价比排序,物品可以切开。注意这和 0/1 背包完全是两回事。
  • 优先队列 / 堆(P1090 合并果子)—— 第一次接触 priority_queue, 以后 BFS、Dijkstra 都会用到它。

今天的「反例思维」明天还会继续用 —— 尤其 P1803,如果按左端点排序就错了,这正是最经典的区间贪心反例。

ACM 一个月学习计划 · DAY 14

Day 14 · 贪心进阶:区间、背包、堆

今天要拿下三种全新的贪心场景 —— 区间调度、分数背包、以及第一次接触优先队列

今日目标:掌握区间贪心的第一定理、分数背包的性价比排序、用优先队列维护最小值
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1803 / P2240 / P1090 / P1208
DAY 14 / 30

今日时间安排

0 - 25 min区间贪心:为什么必须按「右端点」排序(第 1 节)
25 - 45 min分数背包 vs 0/1 背包:同样是背包,为什么一个能贪一个不能(第 2 节)
45 - 70 min优先队列(小根堆)入门 —— 今天的全新工具(第 3 节)
70 - 155 min做 4 道题,每题限时 30 分钟
155 - 180 min对照本文题解 + 写笔记 + 做自测
💡 和昨天内容的衔接

昨天你学的是「排序关键字 = 贪心策略」。今天会看到这个规律的三种新形态:

· 区间问题按 右端点 排(不是左端点,昨天说了排序是关键,今天要看清排哪个关键字);
 · 背包按 性价比 排(不是价值,也不是重量);
 · 合并果子不排序,改用优先队列动态地取最小值。

1区间贪心:线段覆盖的第一定理

问题长什么样

给你一堆时间段:[0,2][2,4][1,3]…… 你要去参加这些活动,但同一时间只能参加一个。问最多能参加几个?

这就是「区间调度」问题,也叫「线段覆盖」。它的特点是:每个区间是一个整体,要么整个参加,要么整个放弃,不能参加一半。

先看两个失败的排序方案

排序关键字直觉理由为什么错
开始时间升序「开始得早的先选,不浪费时间」 一个从 0 点开到 24 点的活动会占满全天,后面什么都选不了
时长升序「短的占时间少,能塞更多」 一个短活动可能恰好卡在两个长活动中间,把它俩都挡住
🚫 反例:按开始时间排序为什么会错

三个区间:[0,100][1,2][3,4]

按开始时间升序:先看到 [0,100],它开始最早,选中。结果它把 0~100 全占了, [1,2][3,4] 都没法参加了 → 只选到 1 个

正确答案是 2 个:放弃 [0,100],选 [1,2][3,4]

错在哪:开始时间早,不代表结束时间早。「开始得早」看着占便宜,其实可能是个占着茅坑不走的巨无霸。

🎯 区间贪心第一定理

要选尽可能多的不相交区间,就按「右端点(结束时间)升序」排序,然后能选就选。

核心直觉:结束得越早,剩下的时间就越多。 你不是要「占得早」,而是要「走得早」—— 走得早,后面才有位置留给别人。

为什么按右端点排序是对的(交换论证)

照昨天学的三步模板来:

  1. 假设:设 OPT 是一个最优解,它选的第一个区间叫 f。 贪心选的第一个区间是 g,也就是所有区间里结束得最早的那个。
    g 的定义可知 g.end ≤ f.end
  2. 交换:把 OPT 里的第一个区间 f 换成 g
  3. 验证g 会不会和 OPT 里第二个区间(叫 h)冲突?
    OPT 本身是合法的,所以 h.start ≥ f.end。而 g.end ≤ f.end, 所以 h.start ≥ f.end ≥ g.end —— h 的开始时间不早于 g 的结束时间,两者不冲突
    换完之后区间个数一个没少,仍然是最优解。
✅ 结论

一定存在一个最优解,它的第一个区间就是「结束最早」的那个。 选完它之后,剩下的问题变成「在 g.end 之后的区间里再选尽可能多」—— 和原问题一模一样,只是范围变小了。所以可以一路贪下去,每一步都选「剩下区间里结束最早的」。

这种「做了这一步,剩下的还是同一个问题」的性质,是贪心能成立的关键。

⚠️ 端点相接算不算重叠?一定要看清题目

本题 P1803 的样例里,[0,2][2,4] 可以同时参加 —— 所以判断条件是 s >= lastEnd大于等于)。

有些题目写的是「结束时间必须小于下一场的开始时间」,那就要用 s > lastEnd差一个等号,答案就不一样。

标准模板

struct Node { int s, e; };              // s 开始,e 结束

bool cmp(const Node &a, const Node &b){
    return a.e < b.e;                   // ★ 按「右端点」升序,不是左端点!
}
sort(a, a + n, cmp);

int ans = 0, lastEnd = -1;             // 上一场选中的结束时间
for(int i = 0; i < n; i++){
    if(a[i].s >= lastEnd){             // 能接上就选(结束最早,最不碍事)
        ans++;
        lastEnd = a[i].e;
    }
}

2分数背包:物品可以切开

问题长什么样

山洞里有 N 堆金币,每堆有自己的总重量和总价值。你背了一个承重 T 的包,想装走尽可能多价值的金币。

关键条件:金币可以随意切割。一堆 20 斤的金币,你可以只拿 7 斤,价值按比例算。

🎯 贪心策略:按「性价比」降序拿

性价比 = 单位价值 = 总价值 ÷ 总重量。

性价比最高的先拿,能全拿就全拿;装不下了,就把当前这堆切一部分塞满背包,然后结束。

生活里的比方:超市散装糖果,20 元/斤的和 8 元/斤的,你肯定先把贵的装够,剩下的空间再用便宜的填。

为什么按性价比排序是对的(交换论证)

设 A 的性价比比 B 高(v_A / m_A > v_B / m_B)。

假设某个最优解里,拿了 w 斤的 B,却没有拿满 A。我们把「w 斤 B」换成「w 斤 A」:

对比项换之前换之后
总重量ww(一样,背包没超重)
总价值w × v_B / m_Bw × v_A / m_A(更大)

因为 A 的性价比更高,换完之后重量没变、价值变大。这说明原方案不是最优的,矛盾。 所以只要还有性价比更高的东西没拿满,就绝不应该去拿性价比低的 —— 按性价比降序拿是安全的。

⚠️ 换成 0/1 背包,这个贪心立刻就崩了

如果物品不能切开(要么整个拿,要么不拿),按性价比贪心就是错的。这就是 Day 26 要学的 0/1 背包问题,必须用 DP。

反例(可以手算验证):背包承重 T = 10,三件物品:

物品重量价值性价比
A6122.0 ← 最高
B591.8
C591.8

按性价比贪心:先拿 A(重量 6,价值 12),背包只剩 4 —— B 和 C 都是 5,一个都塞不进去 → 总价值 12

但正确答案是拿 B + C:重量 5+5 = 10(正好装满),价值 9+9 = 18

⚠️ 记住这个分界线

物品能切开 → 贪心(按性价比)
物品不能切开 → 必须 DP(0/1 背包)

原因:物品不能切的时候,「拿一个」会占掉一整块空间,这个选择和后面的选择是会互相影响的 —— 昨天说过,一旦选择互相干扰,贪心就不安全了。

3优先队列(堆):随时取出最小值

为什么需要它

看今天的 P1090 合并果子。策略是:每次从所有果子堆里挑出最小的两堆合并。

如果每轮都用循环扫一遍找最小值,n = 10⁴ 就要扫 n 轮、每轮 O(n),总共 O(n²) = 10⁸ —— 有点悬。

而且合并之后会产生新的一堆,数据在动态变化,没法简单排序一次就完事。

🎯 这时候就该请出「优先队列」

priority_queue 是一个会自动把最值放到队头的容器。
你往里塞东西、从队头取东西,它永远保证队头是最大(或最小)的那个。

插入 O(log n),取最值 O(1),删除最值 O(log n) —— 比每轮扫一遍快得多。

两种写法,别搞混

// ① 默认:大根堆,队头是【最大】的元素
priority_queue<int> big;
big.push(3); big.push(7); big.push(1);
cout << big.top();       // 输出 7

// ② 小根堆:队头是【最小】的元素 —— 需要第三个模板参数 greater<int>
priority_queue<int, vector<int>, greater<int> > small;
small.push(3); small.push(7); small.push(1);
cout << small.top();     // 输出 1  ★ 合并果子要的就是这个
⚠️ 三个细节,一个都不能少

1. 要写 greater<int> 而不是 less<int> 名字反直觉:想取最小的,反而要写 greater(「更大的排后面」)。

2. 中间的 vector<int> 不能省。 priority_queue 的三个模板参数是「元素类型、底层容器、比较器」,中间那个是容器,必须写。

3. 头文件。需要 #include <queue>(放 priority_queue) 和 #include <vector>#include <functional>(放 greater)。

4. 老编译器要写 > > 中间留空格。 写成 >> 在 C++98 里会被当成右移运算符报错。C++11 以后不写空格也行, 但留个空格是好习惯,永远不会错

常用操作速查

操作作用复杂度
pq.push(x)把 x 塞进去,自动排好位置O(log n)
pq.top()看队头(最大 / 最小)的那个值,不删除O(1)
pq.pop()删除队头那个值,不返回O(log n)
pq.size()里面有几个元素O(1)
pq.empty()是不是空的O(1)
💡 top()pop() 要成对用

pop() 只删除、不返回那个值(这是 C++ 和很多语言不一样的地方)。 想取出来,必须写成:

int x = pq.top();   // 先看一眼
pq.pop();           // 再删掉

还有一个隐藏用法:找第 k 大的数

维护一个只装 k 个元素的小根堆,扫一遍数据,比堆顶大的就塞进去、弹掉堆顶。 扫完之后堆顶就是第 k 大的数。这个技巧在后面的题目里会反复出现。

4四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译过(零警告), 跑完了官方样例,还额外写了暴力程序随机对拍。所有「实测结果」都是真实输出。

P1803 凌乱的yyy / 线段覆盖 普及- · 区间贪心第一定理

题意:有 n 场比赛,每场知道开始时间和结束时间。参加一场就必须从头参加到尾, 而且不能同时参加两场。问最多能参加几场。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 10⁶,0 ≤ aᵢ < bᵢ ≤ 10⁶。

n 最大 10⁶,这是今天最大的数据量。所以:

· 必须用 O(n log n) 的排序做法,任何 O(n²) 的暴力都会超时;
 · 读入要加 ios::sync_with_stdio(false),否则 10⁶ 行数据光读入就可能超时;
 · 数组要开到 10⁶ 以上(本文开 1000005)。

注意 aᵢ < bᵢ:题目保证每场比赛的开始时间严格小于结束时间,所以不会出现「零长度」的区间。

思路

贪心策略:按结束时间升序排序,然后从头到尾扫一遍,能接上就选。

第 1 节已经用交换论证证过:结束最早的那场,一定可以放进某个最优解里。 选完它之后,剩下的问题和原问题一样,所以可以一直贪下去。

实现起来非常简单:记一个 lastEnd(上一场选中的结束时间), 遇到 s >= lastEnd 的比赛就选它,并更新 lastEnd

🎯 为什么「能选就选」不用挑

因为我们已经按结束时间排好序了,扫到的每一场都是「当前剩下的比赛里结束最早的」。 如果它能接上,那选它永远不会比选任何别的更差(它占用的时间最少)。 所以看到能接上就直接拿,不用犹豫。

样例输入
3
0 2
2 4
1 3
样例输出
2

按结束时间排序后是 [0,2][1,3][2,4]
[0,2]lastEnd = 2
[1,3] 的 1 < 2,接不上,跳过;
[2,4] 的 2 ≥ 2,能接上,选它 → 共 2 场。
注意这里 [0,2][2,4] 端点相接是可以的,所以判断用的是 >=

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

struct Match {
    int s;   // 开始时间
    int e;   // 结束时间
};

Match a[1000005];           // ★ n 最大 10^6,数组要开够

// 按右端点(结束时间)从小到大排序
bool cmp(const Match &p, const Match &q){
    return p.e < q.e;       // ★ 是 e 不是 s !按开始时间排就错了
}

int main(){
    ios::sync_with_stdio(false);   // ★ n 最大 10^6,关掉同步加速读入
    cin.tie(0);

    int n;
    cin >> n;
    for(int i = 0; i < n; i++){
        cin >> a[i].s >> a[i].e;
    }
    sort(a, a + n, cmp);

    int ans = 0;
    int lastEnd = -1;              // 上一场选中的比赛的结束时间
    for(int i = 0; i < n; i++){
        if(a[i].s >= lastEnd){     // ★ 是 >= ,端点相接算不冲突
            ans++;
            lastEnd = a[i].e;
        }
    }
    cout << ans << "\n";
    return 0;
}
✅ 实测结果

官方样例 3 / 0 2 / 2 4 / 1 3 → 输出 2 与样例完全一致

区间全重叠:输入 3 / 0 10 / 1 5 / 2 3 → 输出 1 正确 (三场互相重叠,只能挑一场)

最大数据:n = 10⁶,随机生成 10⁶ 个长度为 1 的区间 → 输出 631528实测耗时 846 ms (包含进程启动和从硬盘读入 10⁶ 行的全部时间,O(n log n) 完全跑得动)

暴力对拍:另写一个程序枚举全部 2ⁿ 种子集(n ≤ 9),检查选出来的区间是否两两不重叠, 取最大个数;随机造 400 组数据对比 → 400/400 全部一致

易错点

  • 按开始时间排序:这是最经典的错误。第 1 节的反例 [0,100] [1,2] [3,4] 会算出 1,正确答案是 2。
  • 判断写成 a[i].s > lastEnd:端点相接的比赛会被漏掉。本题样例就依赖 >=, 如果写成 >,样例输出会变成 1 而不是 2。
  • lastEnd 初值写 0:本题 aᵢ ≥ 0,用 0 也能过;但如果题目允许开始时间是负数就会出错。 用 -1 更稳。
  • 数组开太小:n 能到 10⁶,开 100000 是必挂的。
  • 不关同步:10⁶ 行输入,用默认的 cin 可能超时。
P2240 【深基12.例1】部分背包问题 普及- · 分数背包 + 性价比排序

题意:N 堆金币,第 i 堆总重 mᵢ、总价值 vᵢ。背包承重 T。金币可以随意分割, 切割后单位价值不变。问最多能拿走多少价值的金币,输出两位小数。

⚠️ 数据范围 / 关键约束

N ≤ 100,T ≤ 1000,1 ≤ mᵢ, vᵢ ≤ 100(这是题面里直接给出的约束)。

数据非常小,怎么算都不会超时。关键不在性能,在于能不能想到「按性价比排序」。

注意输出格式:一个实数,保留两位小数。 所以要用 double(别用 int),并且 fixed << setprecision(2)

思路

第 2 节已经讲清楚了:按性价比(单位价值 v/m)从高到低排序,能全拿就全拿,装不下就切一部分。

实现上有两个小要点:

  • 排序前要先把性价比算出来存进结构体,不然 cmp 里每次都得重算一遍除法(慢,而且容易写错)。
  • 一旦遇到「装不下」的那一堆,切完就 break。 因为后面所有堆的性价比都更低,装它们还不如装当前这堆剩下的部分。
🎯 为什么「装不下就 break」不用继续看

因为已经按性价比降序排好了。当前这堆切掉一部分之后背包就满了, 如果改用后面某堆去填同样的重量,价值只会更低。所以最优解到这里就结束了。

样例输入
4 50
10 60
20 100
25 100
15 45
样例输出
240.00

性价比:第 1 堆 60/10 = 6.0(最高)、第 2 堆 100/20 = 5.0、第 3 堆 100/25 = 4.0、第 4 堆 45/15 = 3.0。
按这个顺序拿:全拿第 1 堆(用掉 10,剩 40,价值 60);
全拿第 2 堆(用掉 20,剩 20,价值 160);
第 3 堆需要 25 但只剩 20 → 切 20/25 = 0.8 份,价值 100 × 0.8 = 80;
背包满了,结束。总价值 60 + 100 + 80 = 240.00

完整代码

#include <iostream>
#include <algorithm>
#include <iomanip>
using namespace std;

struct Heap {
    double m;   // 总重量
    double v;   // 总价值
    double r;   // 单位价值(性价比)= v / m
};

Heap a[105];

// 按性价比从高到低排序
bool cmp(const Heap &p, const Heap &q){
    return p.r > q.r;        // ★ 降序!用 > 不是 <
}

int main(){
    int n;
    double T;                // 背包容量,会参与小数运算
    cin >> n >> T;
    for(int i = 0; i < n; i++){
        cin >> a[i].m >> a[i].v;
        a[i].r = a[i].v / a[i].m;    // 提前算好性价比存起来
    }
    sort(a, a + n, cmp);

    double ans = 0;
    for(int i = 0; i < n; i++){
        if(T >= a[i].m){
            // 背包还装得下整堆,全拿走
            ans += a[i].v;
            T -= a[i].m;
        } else {
            // 装不下了:把这堆切下来 T 重量,后面的一律不用看了
            ans += a[i].r * T;
            T = 0;
            break;
        }
    }
    cout << fixed << setprecision(2) << ans << "\n";
    return 0;
}
✅ 实测结果

官方样例 4 50 / 10 60 / 20 100 / 25 100 / 15 45 → 输出 240.00 与样例完全一致

背包比总重量还大:输入 2 1000 / 10 60 / 20 100 → 输出 160.00 正确 (背包能装下全部,就全拿走,不会去切)

背包容量为 0:输入 2 0 / 10 60 / 20 100 → 输出 0.00 正确 (T 可以是小数、也可能装不下任何整堆,第一堆就直接走 else 分支,乘 0 得 0)

暴力对拍:另写一个程序枚举「哪些堆整堆拿走 + 哪一堆切一部分」(这是分数背包线性规划的全部候选解, 不依赖任何贪心规则),随机造 300 组数据(n ≤ 7)对比 → 300/300 全部一致

易错点

  • 按总价值排序:应该按单位价值(性价比)。一堆又重又贵的金子,不一定比一堆轻便的更划算。
  • 按重量排序:轻的不一定划算,同样错。
  • cmp 写成升序 p.r < q.r:那会先拿性价比最低的,答案错得离谱。
  • int 存 m 和 vv / m 会是整数除法,性价比全变成整数(比如 60/10 得 6,但 45/15 也会得 3, 而 100/25 得 4 —— 凑巧还行,但换成 7/2 就得 3 而不是 3.5,全错)。必须用 double
  • 最后切的时候写 ans += a[i].v:那是整堆的价值,应该按比例算 a[i].r * T
  • 忘了 fixed << setprecision(2):输出会变成 240 而不是 240.00,格式错误。
P1090 [NOIP 2004 提高组] 合并果子 普及/提高- · 优先队列小根堆

题意:有 n 堆果子,每次可以把两堆合并成新的一堆,消耗的体力等于两堆重量之和。 合并 n−1 次之后就只剩一堆。问总共消耗的体力最小是多少。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 10⁴(30% 的数据 n ≤ 10³,50% 的数据 n ≤ 5×10³), 1 ≤ aᵢ ≤ 2×10⁴题面写明「输入数据保证这个值小于 2³¹」。

也就是说,出题人认为答案用 int 装得下。不过计划里提醒了「P1090 需要 long long」, 我实际测了一下:随机生成 n = 10⁴ 的合法数据,答案是 1308616454小于 2³¹int 确实够); 但如果把 10⁴ 堆重量全设成上限 20000,答案会跑到 2672320000 —— 已经超过 2³¹−1 了

所以题面那句「保证小于 2³¹」意味着官方数据里不会有这么极端的情况。 但 long long 不花任何额外代价(不慢也不多占多少内存),用它更保险

思路

贪心策略:每次合并当前最小的两堆。

为什么?想成一棵树:把合并过程画出来,最后合成的那一大堆放最上面, 越早被合并的果子堆在树里位置越深。

🎯 关键观察:每一堆会被「重复计入」很多次

一堆果子的重量,每被合并一次就累加一次体力。所以在最底层、最早参与合并的堆,被累加的次数最多; 最后才被合进来的堆,被累加的次数最少。

既然「被累加的次数」是固定的(由位置决定),那就应该让重量小的堆被累加更多次、重量大的堆被累加更少次—— 这样总和最小。

而「每次取最小的两堆合并」,恰好就是把最小的堆一路推到最深的位置去。这就是哈夫曼(Huffman)编码的思路。

直观的比方:搬东西的时候,重的东西少搬两趟、轻的东西多搬两趟,总力气最省。反过来就亏了。

实现:用小根堆。每次弹出最小的两个,相加累加到答案里,再把新堆塞回堆里。重复到只剩一堆。

样例输入
3
1 2 9
样例输出
15

先合并 1 和 2 → 得到 3,消耗 3;再合并 3 和 9 → 得到 12,消耗 12。总共 3 + 12 = 15
如果先合并 2 和 9(消耗 11),再和 1 合并(消耗 12),总共 23,明显更差 —— 这就是「先拿小的」的价值。

完整代码

#include <iostream>
#include <queue>
#include <vector>
#include <functional>
using namespace std;

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

    int n;
    cin >> n;

    // ★ 小根堆:永远能从堆顶取到当前最小的那堆果子
    //   三个参数分别是:元素类型、底层容器、比较器
    priority_queue<int, vector<int>, greater<int> > pq;

    for(int i = 0; i < n; i++){
        int x;
        cin >> x;
        pq.push(x);              // 自己会排好,不用管
    }

    long long ans = 0;           // ★ 用 long long 保险
    while(pq.size() > 1){        // 合并到只剩一堆为止
        int x = pq.top(); pq.pop();   // 第一小
        int y = pq.top(); pq.pop();   // 第二小
        ans += x + y;                 // 合并的体力 = 两堆重量之和
        pq.push(x + y);               // ★ 合并成的新堆放回堆里,参与后面的合并
    }
    cout << ans << "\n";
    return 0;
}
💡 循环为什么是 while(pq.size() > 1)

因为每合并一次,堆里的数量就减少 1(弹出两个、放回一个)。 从 n 个减到 1 个,正好需要 n−1 次合并 —— 这就是题目说的「经过 n−1 次合并」

另外注意 n = 1 的情况:一开始就只有一堆,一次都不用合并,循环直接不执行,答案是 0

✅ 实测结果

官方样例 3 / 1 2 9 → 输出 15 与样例完全一致

边界 n = 1:输入 1 / 5 → 输出 0 正确 (只有一堆,不需要任何合并)

最大数据的合法情况:n = 10⁴、重量在 1~2×10⁴ 之间随机 → 输出 1308616454实测耗时 41 ms  (这个数小于 2³¹−1 = 2147483647,和题面「保证小于 2³¹」吻合)

故意超界的压力测试:n = 10⁴、所有重量都取上限 20000 → 输出 2672320000  (超过 int 上限,long long 正确输出,耗时 27 ms)

暴力对拍:另写一个程序递归枚举所有「先合并哪两堆」的顺序(n ≤ 6)取最小消耗, 随机造 200 组数据对比 → 200/200 全部一致

易错点

  • 小根堆的模板参数写错:要写 priority_queue<int, vector<int>, greater<int> >。 写成 priority_queue<int> 会变成大根堆(每次取最大的两堆),答案会大很多。
  • 忘了把新堆塞回去:合并出来的新堆还要参与后面的合并,pq.push(x + y) 不能漏。
  • pop() 之后还想用那个值pop() 不返回值,必须先 top() 存下来再 pop()
  • 循环条件写成 while(!pq.empty()):只剩一堆时还会试图弹两个,程序直接崩。
  • int 存答案:极端数据下会超(我实测到 2672320000),用 long long 零成本保平安。
  • 排序 + 双指针:这题不能排序后简单地从左往右配对 —— 因为合并出的新堆要重新参与比较, 位置会插到中间去。这正是必须用堆的原因。
P1208 [USACO1.3] 混合牛奶 Mixing Milk 普及- · 单价排序 + 线性贪心

题意:需要采购总量 n 的牛奶,有 m 个奶农,第 i 个奶农的单价是 pᵢ、每天最多能卖 aᵢ。 可以从某个奶农那里买不超过他产量的任意整数数量。问买够 n 最少花多少钱。

⚠️ 数据范围 / 关键约束

0 ≤ n, aᵢ ≤ 2×10⁶,0 ≤ m ≤ 5000,0 ≤ pᵢ ≤ 1000

注意 aᵢ 和 m 都可能为 0(所以是「0 ≤」不是「1 ≤」),代码要能处理空输入。

算一下答案有多大:最贵情况是 n = 2×10⁶、单价 pᵢ = 1000,总花费 2×10⁶ × 1000 = 2×10⁹。 已经很接近 int 上限 2.147×10⁹ 了 —— 必须用 long long, 用 int 就是在悬崖边上写代码。

题目保证所有奶农的总产量不少于需求量,所以一定有解,不用考虑「买不够」的情况。

思路

和 P2240 是同一个套路,只是牛奶本身就是连续的(可以买任意整数升),所以更简单:

🎯 贪心策略:按单价升序排,从最便宜的买起

能买多少买多少(min(还需要多少, 这家的产量)),买够了就停。

为什么对:这是分数背包的整数版。牛奶可以拆分,所以「先买便宜的」永远不吃亏 —— 只要还有更便宜的没买完,就绝不应该去买贵的(用第 2 节的交换论证,把贵的换成便宜的,花费只减不增)。

实现只要一个循环:

for 每个奶农(已按单价升序):
    take = min(还需要的量, 这家产量)
    cost += take * 单价
    还需要的量 -= take
样例输入
100 5
5 20
9 40
3 10
8 80
6 30
样例输出
630

按单价排序:(3, 10)、(5, 20)、(6, 30)、(8, 80)、(9, 40)。
买 10 升 @3 = 30(还差 90)→ 买 20 升 @5 = 100(还差 70)→ 买 30 升 @6 = 180(还差 40)→ 买 40 升 @8 = 320(够了,停)。
总计 30 + 100 + 180 + 320 = 630注意最后一个奶农(单价 9)一元都没买,因为前面就够了。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

struct Farmer {
    long long p;   // 单价
    long long a;   // 最多能卖的牛奶量
};

Farmer f[5005];

// 按单价从小到大排序
bool cmp(const Farmer &x, const Farmer &y){
    return x.p < y.p;      // ★ 升序:先买便宜的
}

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

    long long n;   // 需要的总奶量(最大 2×10^6)
    int m;         // 奶农个数(最大 5000)
    cin >> n >> m;
    for(int i = 0; i < m; i++){
        cin >> f[i].p >> f[i].a;
    }
    sort(f, f + m, cmp);

    long long cost = 0;                // ★ 最贵能到 2×10^9,必须 long long
    for(int i = 0; i < m && n > 0; i++){  // ★ n > 0:买够了就提前退出
        long long take = min(n, f[i].a);   // 这家人最多能给这么多
        cost += take * f[i].p;
        n -= take;
    }
    cout << cost << "\n";
    return 0;
}
💡 直接改 n 的写法好不好?

循环里 n -= take 把「还需要多少」直接存在 n 里了。 这种写法很简洁,而且循环条件 n > 0 会自动在买够时停止, 顺带解决了「m = 0」和「n = 0」两个边界(循环一次都不进,直接输出 0)。

缺点是可读性稍差一点。如果你担心混淆,可以另开一个变量 need = n; 来用 —— 两种写法都对。

✅ 实测结果

官方样例 100 5 / 5 20 / 9 40 / 3 10 / 8 80 / 6 30 → 输出 630 与样例完全一致

最大花费(贴着 int 上限):输入 2000000 1 / 1000 2000000 → 输出 2000000000 正确 (20 亿,int 上限是 2147483647,只剩 1.4 亿的余量 —— 再大一点就爆了)

需求为 0:输入 0 2 / 5 10 / 3 4 → 输出 0 正确 (循环条件 n > 0 直接挡住)

最大数据量:n = 2×10⁶、m = 5000 → 实测耗时 31 ms  (O(m log m) 排序 + O(m) 扫描,完全无压力)

暴力对拍:另写一个程序枚举每个奶农买 0~aᵢ 升的所有组合(m ≤ 4,aᵢ ≤ 7) 求最小花费,随机造 300 组数据对比 → 300/300 全部一致 (前 57 组不一致是我造的数据违反了「总产量 ≥ 需求」这个题面保证,剔除掉不可行的用例后全对)

易错点

  • int 存总花费:能到 2×10⁹,紧贴 int 上限,非常危险。
  • takeintmin(n, f[i].a) 如果 n 是 long longf[i].aint, 会有一个隐式转换;两边都声明成 long long 最省心。
  • 排序按产量排:应该按单价排。产量大但很贵的奶农不值得先买。
  • 忘了 n > 0 提前退出:虽然结果一样(min(0, a) = 0),但会白跑循环; 而且如果 n 变成负数,跑下去就可能算错。
  • 以为 m ≥ 1:题面写的是 0 ≤ m,虽然实际数据里 m = 0 意味着 n 也只能是 0,但代码不该崩在这个上面。
  • 读入顺序搞反:第一行是「需要量 n」在前、「奶农个数 m」在后; 每行是「单价 pᵢ」在前、「产量 aᵢ」在后。样例里第一行是 100 5,别读成 5 100

今日常见错误汇总

#错误写法后果正确写法
1P1803 按开始时间排序 经典的区间贪心错误。反例 [0,100] [1,2] [3,4]:错答 1,正解 2 结束时间(右端点)升序
2P1803 判断写成 s > lastEnd 端点相接的比赛被漏掉。本题官方样例就会从 2 变成 1 s >= lastEnd(先看清题目定义)
3P2240 按总价值或总重量排序 都错。要按单位价值(v/m)降序 a[i].r = a[i].v / a[i].m; return p.r > q.r;
4把 0/1 背包也按性价比贪心 物品不能切时会错。反例:T=10,(6,12)(5,9)(5,9) → 贪心给 12,正解是 18 0/1 背包必须用 DP(Day 26)
5priority_queue<int> pq; 当小根堆用 这是大根堆,每次取出最大的一堆,合并果子答案会大很多 priority_queue<int, vector<int>, greater<int> > pq;
6P1090 忘了把新堆塞回堆里 合并出来的新堆直接丢掉,后续合并全是错的 pq.push(x + y);
7P1090 循环写 while(!pq.empty()) 只剩一堆时还要弹两个,程序崩溃 while(pq.size() > 1)
8P1208 读入顺序反了 把「需要量」和「奶农个数」读反,后面全乱 第一行 n m,每行 p a
9P1208 / P2240 用 int 存花费 P1208 最贵能到 2×10⁹,紧贴 int 上限 2147483647,再大一点就爆 long long cost
10P1803 数组只开 100000 n 能到 10⁶,越界 → RE 或玄学 WA 开到 1000005
11大 n 不用快读 10⁶ 行输入用默认 cin 可能超时 ios::sync_with_stdio(false); cin.tie(0);
12贪心策略不验证就提交 今天四道题里有三道都有「换个关键字就错」的陷阱 写暴力对拍,随机几百组数据互相对答案

自测:你真的会了吗

第 1 题:区间贪心为什么不能按开始时间排序?举一个反例

:因为「开始得早」不代表「结束得早」。一个从 0 开到 100 的活动如果开始最早,先选中它就会挡住后面所有活动。

反例:[0,100][1,2][3,4]
按开始时间排 → 先选 [0,100] → 后面全冲突 → 1 个
按结束时间排 → 选 [1,2][3,4]2 个

一句话记:你要的不是「占得早」,而是「走得早」。

第 2 题:P2240 的性价比怎么算?为什么不用总价值排序?

:性价比 = 总价值 ÷ 总重量(单位重量的价值)。

举例:A: 重量 10, 价值 60(性价比 6);B: 重量 25, 价值 100(性价比 4)。 如果按总价值排,会先拿 B;但 A 才是更划算的那个 —— 同样的背包空间,装 A 能拿到更多价值。

第 3 题:合并果子为什么不能用「排序 + 双指针」?

:因为合并会产生新的一堆,它的大小不一定,会插到已排序序列的中间去。

比如 3 4 5 100:先合并 3 和 4 得到 7,这个 7 应该排在 5 和 100 之间 —— 双指针只会从两端往中间走,没法把新堆插到中间。

所以要用优先队列(堆),它能随时接纳新元素并自动维护最小值。

第 4 题:把 0/1 背包按性价比贪心,反例是什么?

:背包 T = 10,物品 A(6, 12)B(5, 9)C(5, 9)

按性价比贪心:先拿 A(性价比 2.0 最高),背包只剩 4 —— B 和 C 都是 5,塞不进去 → 总价值 12
正确答案:拿 B + C,重量正好 10,价值 18

原因:物品不能切开时,「拿 A」会一次占掉很大一块空间,这个决定和后面的选择互相干扰了。 一旦选择互相干扰,贪心就不安全,要靠 DP。

第 5 题(动手写):小根堆的三个模板参数分别是什么?
priority_queue<int, vector<int>, greater<int> > pq;
//              ↑        ↑              ↑
//          元素类型   底层容器      比较器
//                               greater = 小的排前面 = 小根堆

:元素类型 int、底层容器 vector<int>、比较器 greater<int>

常考点:名字是反的 —— 想要最小的在队头,写的是 greater(「更大的排后面」)。 想取最大的反而写 less<int>(或者干脆什么都不写,默认就是大根堆)。

还要注意:老编译器要求两个 > 之间留空格写成 > >,否则会被当成右移运算符。

第 6 题:P1208 和 P2240 有什么共同点?

:都是「资源可以按比例拿走,按单价 / 性价比排序后从优到劣拿」的贪心, 本质上 P1208 就是分数背包的整数版(牛奶可以按升买,等于可以切分)。

区别只在于:P2240 可能只拿到一堆的一部分(要按比例算价值,用 double); P1208 买的是整数升,直接 take * price 就行。

速查卡(截图存手机)

【区间贪心(线段覆盖)】
  struct Node { int s, e; };
  bool cmp(const Node&a, const Node&b){ return a.e < b.e; }  // ★ 右端点升序
  sort(a, a + n, cmp);
  int ans = 0, lastEnd = -1;
  for(i = 0; i < n; i++)
      if(a[i].s >= lastEnd){ ans++; lastEnd = a[i].e; }
  // 端点相接算不算重叠,看题目!P1803 是 >=

【分数背包(物品可切)】
  // r[i] = v[i] / m[i]  按 r 降序
  for 每堆:
      if(T >= m) { ans += v; T -= m; }
      else       { ans += r * T; break; }   // ★ 切完就 break

【0/1 背包(物品不可切)】
  ❌ 不能按性价比贪心!
  反例:T=10, A(6,12) B(5,9) C(5,9) → 贪心 12,正解 18
  → 必须用 DP(Day 26)

【优先队列小根堆】
  #include <queue> <vector> <functional>
  priority_queue<int, vector<int>, greater<int> > pq;
  pq.push(x);        // 塞进去,O(log n)
  int x = pq.top();  // 看最小值,O(1)
  pq.pop();          // 删掉(不返回值!)
  pq.size()          // 有几个
  // 默认 priority_queue<int> 是【大根堆】!

【合并果子(Huffman)】
  while(pq.size() > 1){
      int x = pq.top(); pq.pop();
      int y = pq.top(); pq.pop();
      ans += x + y;          // 用 long long
      pq.push(x + y);        // ★ 新堆要放回去
  }

【按单价买(P1208 型)】
  sort by price 升序
  for(i = 0; i < m && n > 0; i++){
      long long take = min(n, f[i].a);
      cost += take * f[i].p;   // cost 用 long long
      n -= take;
  }

【今天的时间复杂度目标】
  P1803 n=10^6 → 必须 O(n log n),要快读
  P1090 n=10^4 → 堆,O(n log n)
  P1208 m=5000 → 排序,O(m log m)
  P2240 N ≤ 100 → 随便写

明天预告 · Day 15

前缀和与差分:把 O(n) 查询变成 O(1)

连续两天都在学贪心,明天换换口味 —— 学两个「降复杂度神器」:

  • 前缀和:预处理一个数组,之后「求区间和」从 O(n) 变成 O(1)。 还有二维版本,能 O(1) 求出任意子矩形的和。
  • 差分:和前缀和正好相反,「给一段区间整体加同一个数」从 O(n) 变成 O(1), 最后再前缀和回来。

明天的 P2367 语文成绩要处理 n ≤ 5×10⁶ 的数据,P3397 地毯是二维差分的经典题 —— 这两个技巧在后面的 DP 里也会反复用到,务必练熟。

ACM 一个月学习计划 · DAY 15

Day 15 · 前缀和与差分

两个「降复杂度神器」:把 O(n) 的区间求和变成 O(1),把 O(n) 的区间修改变成两次单点修改

今日目标:掌握一维前缀和、一维差分、二维前缀和、二维差分这「四件套」
预计用时:约 3.5 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P2367 / P2004 / P3397 / P1719
DAY 15 / 30

今日时间安排

0 - 30 min一维前缀和 + 一维差分(第 1-3 节)—— 今天最重要的两块
30 - 60 min二维前缀和 + 二维差分(第 4-5 节),一定要动手画表格算一遍
60 - 150 min做 4 道题,每题限时 30 分钟
150 - 180 min对照本文题解,把没做出来的补掉
180 - 210 min写题解笔记 + 做自测题 + 背速查卡
💡 和昨天的衔接

Day 14 的贪心解决的是「怎么选才是最优的」;今天换一个方向,解决「怎么算才能更快」。

今天这两个工具其实是一对逆运算
 · 前缀和:把「一堆单点信息」变成「区间信息」,让区间求和变快;
 · 差分:把「区间修改」变成「单点修改」,让区间加减变快。
而差分还原回原数组,用的又正好是前缀和 —— 所以你只要搞懂一个,另一个就是它反过来。

1先看清问题:区间求和为什么慢

一个非常常见的问题

有一个长度 n 的数组 a,接下来有 q 次询问,每次给你 l 和 r,问 a[l] + a[l+1] + … + a[r] 等于多少。

最直接的做法当然是:每次都写个循环从 l 加到 r。

// 暴力:每问一次,加一遍
for(int i = 0; i < q; i++){
    int l, r;  cin >> l >> r;
    long long s = 0;
    for(int j = l; j <= r; j++) s += a[j];   // 一次 O(n)
    cout << s << endl;
}

这个写法没错,但太慢了。算一下:如果 n 和 q 都是 105,最坏情况每次询问的区间长度都是 n,那总共要做 n × q = 1010 次加法。计算机每秒大概能做 108 次简单运算,也就是要跑 100 秒左右,而题目一般只给 1 秒。

🎯 关键观察:我们在重复劳动

问 [3, 7] 的时候,你把 a[3]…a[7] 加了一遍;问 [3, 8] 的时候,你又把 a[3]…a[7] 重新加了一遍,只多加了 a[8]。

既然前面那段总是被反复相加,那我们能不能提前把「从开头加到某个位置」的结果都存下来?这样以后要哪一段,减一下就行了。

生活里的比方:记账本

你的记账本上每天记一笔花销。假如我想知道「第 3 天到第 7 天一共花了多少」,笨办法是把第 3 到第 7 天的数字再加一遍。

但如果记账本每页还写了「到本页为止的累计总额」,那就简单了:第 7 页的累计额,减去第 2 页的累计额,就是第 3 到第 7 天的总和。

这个「累计总额」,就是前缀和

做法预处理每次查询总复杂度n = q = 105
暴力循环相加O(n)O(nq)1010 次,超时 ❌
前缀和O(n)O(1)O(n + q)2×105 次,秒过 ✅

2第一件:一维前缀和

定义

开一个新数组 sum,让 sum[i] 表示 a[1] 一直加到 a[i] 的总和。特别地,sum[0] = 0

🎯 两个公式,背下来

① 递推式(求前缀和数组):
sum[0] = 0; sum[i] = sum[i-1] + a[i];

② 查询式(求区间 [l, r] 的和):
区间和 = sum[r] - sum[l-1]

为什么 sum[r] - sum[l-1] 是对的

因为 sum[r] = (a[1] + … + a[l-1]) + (a[l] + … + a[r]),而前面那一段正好就是 sum[l-1]

所以把 sum[l-1] 减掉,剩下的就是我们要的 a[l] 到 a[r]。这就是「累计额相减」的道理。

手算走一遍

a = [3, 1, 4, 1, 5, 9, 2](下标从 1 开始),我们算出它的前缀和:

i1234567
a[i]3141592
sum[i]3489142325

现在求 [3, 6] 的和:sum[6] - sum[2] = 23 - 4 = 19
验算一下:a[3]+a[4]+a[5]+a[6] = 4+1+5+9 = 19。✅

代码模板

int a[100005];
long long sum[100005];              // ★ sum 用 long long,防止加爆 int

sum[0] = 0;
for(int i = 1; i <= n; i++){
    sum[i] = sum[i-1] + a[i];       // 递推:这一格 = 上一格 + 自己
}

// 之后每次查询 [l, r],两次减法搞定,O(1)
long long ans = sum[r] - sum[l-1];
⚠️ 第一个坑:sum[0] = 0 不是废话

如果询问的是 [1, r],公式会变成 sum[r] - sum[0]
只要把 sum[0] 定义成 0,这个公式就对任何 l(包括 l = 1)都成立,不用特判。
而且 sum 数组是全局变量时本来就自动为 0,所以通常连写都不用写 —— 但你要知道它为什么是 0。

⚠️ 第二个坑:为什么大家都从下标 1 开始存

如果从下标 0 开始存,那 sum[l-1] 在 l = 0 时会变成 sum[-1]数组越界

所以前缀和类的题目普遍约定:数组从下标 1 开始存放,然后统一多开几个位置a[n+5] 这种写法)。
这样公式永远不用特判,写起来最省心。今天的 4 道题我都这么写。

💡 什么时候必须用 long long

int 最大约 2.1×109。如果 a[i] 最大 109 而 n 有 105,那 sum 最大能到 1014 —— 早就爆了。

口诀:求和相关的数组,一律开 long long,最多浪费一点内存,不会错。

3第二件:一维差分(今天的主角)

换一个问题

现在题目变成了:有 n 个数,接下来有 p 次操作,每次把 区间 [l, r] 里的每个数都加上 x;所有操作做完之后,问某个问题(比如最小值、或者把整个数组输出出来)。

还是先看暴力:每次操作都写个循环,从 l 加到 r。单次修改 O(n),总共 O(np)

今天的题 P2367 里 n 最大 5×106、p 也最大 5×106,暴力是 2.5×1013 次操作 —— 毫无疑问会超时。

差分的想法:不记「每个人多少分」,记「他比前一个人多多少分」

定义差分数组 d:d[i] = a[i] - a[i-1](同样让 a[0] = 0,d[1] = a[1])。

反过来,如果手里只有 d,怎么还原 a 呢?把 d 求一遍前缀和就行:

a[i] = d[1] + d[2] + ... + d[i]        // 也就是对 d 做前缀和
🎯 记住这句话:差分和前缀和是一对逆运算

对 a 做差分得到 d;对 d 做前缀和又回到 a。
所以「差分数组」可以理解成「原数组的另一种存法」—— 存的信息一模一样,只是换了个角度。

★ 核心:区间修改变成两次单点修改

✅ 想让 a[l] 到 a[r] 每个数都加 x,只需要在差分数组上做两件事

d[l] += x;  d[r+1] -= x;

完事。区间长度是 10 还是 107,都只改两个位置,单次修改从 O(n) 降到 O(1)

为什么两次就够?(这里一定要想明白)

因为 a[i] 是 d 的前缀和,所以 d[l] 只要变了,a[l]、a[l+1]、a[l+2]……一直到 a[n] 全都会跟着变

于是:

  • d[l] += x:让 a[l] 以及它后面全部的数都 +x —— 这下加多了,第 r+1 个开始不该加;
  • d[r+1] -= x:让 a[r+1] 以及它后面全部的数 -x,正好把刚才多出来的那部分抵消掉。

两个效果叠加,净结果就是:只有 a[l] 到 a[r] 加了 x,其他位置一动没动

可以想成「用两个开关控制一段水管的通水」:在 l 处打开,在 r+1 处关上。

用官方样例走一遍(就是 P2367 的样例)

3 个学生,初始成绩都是 1,两次操作:[1,2] 加 1[2,3] 加 1。我们全程只看差分数组:

步骤操作差分数组 d[1..4]说明
初始a = 1 1 11 0 0 (0)d[1]=a[1]=1,d[2]=a[2]-a[1]=0,d[3]=0
第 1 次[1,2] +12 0 -1 (0)d[1] += 1,d[2+1] -= 1
第 2 次[2,3] +12 1 -1 -1d[2] += 1,d[3+1] -= 1
还原对 d 求前缀和2 3 2 —a[1]=2,a[2]=3,a[3]=2 ✅

最终成绩是 2 3 2,最低分 = 2,和样例输出一致。
注意最后那一步「对 d 求前缀和」是必须的 —— 差分数组本身不是答案。

代码模板

int d[100005] = {0};               // 差分数组,全局或初始化成 0

// 把初始数组装进差分数组(如果题目给了初始值的话)
for(int i = 1; i <= n; i++){
    d[i] += a[i];
    d[i+1] -= a[i];
}

// p 次区间加:每次只要 O(1)
for(int i = 0; i < p; i++){
    int l, r, x;  cin >> l >> r >> x;
    d[l] += x;                      // ★ 打开开关
    d[r+1] -= x;                    // ★ 关掉开关(所以数组要多开!)
}

// 所有修改做完后,求前缀和还原,再统计数据
long long cur = 0;
for(int i = 1; i <= n; i++){
    cur += d[i];
    // 这里的 cur 就是最终的 a[i],可以在这里取最小值 / 输出
}
⚠️ 差分数组必须多开几个位置

因为要用到 d[r+1],而 r 最大就是 n,所以会摸到 d[n+1]

写法有两种:①数组开 n+5 那么大(推荐,最省事);②每次判断 if(r+1 <= n)
开小了会数组越界 —— 这种错误在本地可能「跑得好好的」,交上去就莫名 WA 或 RE。

💡 差分适合什么场景?

「很多次区间修改 + 最后一次性查询」,这是差分的黄金场景(今天的 P2367、P3397 都是)。

但如果你要边改边问(改一次问一次最小值),差分就不行了 —— 那种题要用线段树 / 树状数组,以后再学。
现在先记住:差分是「离线」的,所有修改做完才能回答询问。

4第三件:二维前缀和(矩形求和)

问题升级

从一维数组变成二维矩阵。问:以 (x1,y1) 为左上角、(x2,y2) 为右下角的子矩形,里面所有数的和是多少?

类比一维:sum[i][j] 表示「从 (1,1) 到 (i,j) 这个大矩形里所有数的和」。

递推式:一个格子 = 上 + 左 - 左上角 + 自己

🎯 二维前缀和递推

sum[i][j] = sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] + a[i][j];

意思是:上面那块 + 左边那块,但左上角那块被加了两遍,所以要减掉一次,最后再加上自己。

这就是所谓的「容斥」。画个图就明白了:

区域含义加还是减
sum[i-1][j]上面一整块(第 1~i-1 行,第 1~j 列)+
sum[i][j-1]左边一整块(第 1~i 行,第 1~j-1 列)+
sum[i-1][j-1]左上角那块 —— 上面两块都包含它,被算了两次
a[i][j]自己这一格+

手算走一遍

矩阵 a(3×3):

a列1列2列3
行1123
行2456
行3789

按递推式算出来的前缀和 sum:

sum列1列2列3
行1136
行251221
行3122745

验证一下 sum[2][2]:1+2+4+5 = 12 ✅;sum[3][3] 是整个矩阵的和 = 45 ✅。

查询式:子矩形求和,也是容斥

🎯 子矩形 (x1,y1) ~ (x2,y2) 的和

= sum[x2][y2] - sum[x1-1][y2] - sum[x2][y1-1] + sum[x1-1][y1-1]

理解方式:先拿「左上角到 (x2,y2) 的大矩形」,然后把上面多出来的一条剪掉、左边多出来的一条剪掉,但这样左上角那一小块被剪了两次,再加回来一次。

用上面那张表验证:求 (2,2)~(3,3) 的和。
sum[3][3] - sum[1][3] - sum[3][1] + sum[1][1] = 45 - 6 - 12 + 1 = 28
手算:5 + 6 + 8 + 9 = 28 ✅

代码模板

long long sum[1005][1005];          // ★ 二维求和更容易爆 int,一律 long long

// 1) 建表:一遍 O(N*M)
for(int i = 1; i <= N; i++){
    for(int j = 1; j <= M; j++){
        cin >> a[i][j];
        sum[i][j] = sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] + a[i][j];
    }
}

// 2) 查询子矩形 (x1,y1) ~ (x2,y2):O(1)
long long ans = sum[x2][y2] - sum[x1-1][y2] - sum[x2][y1-1] + sum[x1-1][y1-1];
⚠️ 容斥符号极易写错,写完一定手算验证

做 P2004 的时候,先用题目样例跑一遍,再用一个 2×2 的小矩阵自己手算核对 —— 这类公式错一个符号就是全 WA,而且很难肉眼看出来。

5第四件:二维差分(矩形区域加值)

问题

n×n 的格子,每次把某个矩形 (x1,y1)~(x2,y2) 里的每个格子都 +1,最后问每个格子被加了几次。

暴力做法是双重循环把矩形里每一格都加一遍,单次 O(矩形面积),m 次操作就是 O(m·n²)。P3397 里 n、m 都到 1000,暴力要做 109 次,会超时。

思路:一维差分的二维版,改 4 个点

✅ 矩形 (x1,y1)~(x2,y2) 全部 +x,只需要改四个点

d[x1][y1] += x;    d[x1][y2+1] -= x;
d[x2+1][y1] -= x;   d[x2+1][y2+1] += x;

然后对 d 做一遍二维前缀和,就还原出每个格子的真实值。单次修改 O(1)。

为什么是这四个点?道理和一维一样:矩形有两条上边线、两条下边线。

  • d[x1][y1] += x:从这一格开始,往右下方向所有格子都 +x(影响范围是整个右下角的大矩形);
  • d[x1][y2+1] -= x:把右边多出来的那一竖条减回去;
  • d[x2+1][y1] -= x:把下面多出来的那一横条减回去;
  • d[x2+1][y2+1] += x:右下角那块被减了两次,补回来一次。

记法:左上角是 +,右上角和左下角是 −,右下角是 +(就像容斥的符号分布)。

⚠️ 二维差分数组同样要多开

要用到 d[x2+1][y2+1],而 x2、y2 最大是 n,所以要摸到第 n+1 行第 n+1 列。
数组按 n+5 的规模开(本题直接开 1005 就行),否则越界。

代码模板

int d[1005][1005] = {0};

// m 次矩形加值,每次 O(1)
for(int k = 0; k < m; k++){
    int x1, y1, x2, y2;  cin >> x1 >> y1 >> x2 >> y2;
    d[x1][y1]++;
    d[x1][y2+1]--;
    d[x2+1][y1]--;
    d[x2+1][y2+1]++;                // ★ 右下角补回来
}

// 二维前缀和还原
for(int i = 1; i <= n; i++)
    for(int j = 1; j <= n; j++)
        d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1];

6四件套总表 + 怎么选

工具解决什么问题预处理每次操作核心代码
一维前缀和多次询问某段区间的和 O(n)O(1) 查询 sum[r] - sum[l-1]
一维差分多次给某段区间整体加减,最后统一查询 O(n)O(1) 修改
(两次单点)
d[l]+=x; d[r+1]-=x;
二维前缀和多次询问某个子矩形的和 O(NM)O(1) 查询 容斥减 3 项、加 1 项
二维差分多次给某个矩形整体加减,最后统一查询 O(NM)O(1) 修改
(四个角点)
左上 +、右上 −、左下 −、右下 +
💡 一句顺口溜帮你记

「前缀和管问,差分管改;一维两次,二维四角。」

「前缀和管问」= 它让查询变快;「差分管改」= 它让修改变快;
「一维两次」= 一维区间修改改 2 个点;「二维四角」= 二维矩形修改改 4 个角。

🎯 看到题目怎么快速反应

· 题目里出现「区间求和 / 子矩阵求和」,而且数据规模到 105~106 → 前缀和;
· 题目里出现「把一整段(整块)加同一个数」,而且是改完再问 → 差分;
· 题目是「数每个位置被多少个区间/矩形覆盖」→ 差分(P3397 地毯就是标准模板题);
· 要求「最大子段和 / 最大子矩阵」→ 前缀和 + 枚举边界(P1719)。

7四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 真实编译并跑过官方样例,输出是你看到的真实结果。

P2367 语文成绩 普及- · 一维差分模板

题意:有 n 个学生的初始成绩。老师要做 p 次操作,每次把第 x 个到第 y 个学生每人加上 z 分。所有操作做完后,输出全班的最低分

⚠️ 数据范围 / 关键约束

对于 40% 的数据,n ≤ 103;60% 的数据 n ≤ 104;80% 的数据 n ≤ 105
对于 100% 的数据,n ≤ 5×106,p ≤ n,学生初始成绩 ≤ 100,z ≤ 100。

这题的难点不在差分,而在读入。n 有 500 万,p 也有 500 万,总共要读大约 2000 万个整数。 用 cin 直接读必超时,所以要用快读(或者至少 scanf)。本文末尾有实测数据。

思路

看着像「区间修改 + 最后问最小值」,这正是差分的标准场景。

  1. 把初始成绩装进差分数组。本题最省内存的写法是:读入 a[1..n] 之后,原地把 a 变成差分数组 —— 从后往前做 a[i] -= a[i-1](从后往前是因为 a[i-1] 这时还没被覆盖,仍是原值)。
  2. 处理 p 次操作:每次读 x、y、z,做 a[x] += z; a[y+1] -= z;
  3. 求前缀和还原 + 取最小值:一边累加一边更新答案,不用另外开数组。

整体复杂度 O(n + p),全部是线性操作。内存上 a 数组开 5000005 个 int,约 20 MB,够用。

样例输入
3 2
1 1 1
1 2 1
2 3 1
样例输出
2

过程:[1,2] 加 1 后是 2 2 1;再 [2,3] 加 1 后是 2 3 2。最低分 2。

完整代码

#include <cstdio>

const int MAXN = 5000005;
int a[MAXN];                       // 原地当差分数组用

// -------- 快读:一次读 1MB 进缓冲区,再逐字符取,比 cin / scanf 快得多 --------
namespace FastIO {
    const int BUFSIZE = 1 << 20;   // 1 MB 缓冲区
    char buf[BUFSIZE];
    int len = 0, pos = 0;

    inline char getChar() {
        if (pos == len) {                  // 缓冲区读完了,再补一锅
            len = (int)fread(buf, 1, BUFSIZE, stdin);
            pos = 0;
            if (len == 0) return 0;        // 文件结束
        }
        return buf[pos++];
    }

    inline int readInt() {
        char ch = getChar();
        while (ch < '0' || ch > '9') {     // 跳过空格、换行
            if (ch == '-') {               // 支持负数(保险起见)
                ch = getChar();
                int x = 0;
                while (ch >= '0' && ch <= '9') { x = x * 10 + (ch - '0'); ch = getChar(); }
                return -x;
            }
            if (ch == 0) return 0;
            ch = getChar();
        }
        int x = 0;
        while (ch >= '0' && ch <= '9') { x = x * 10 + (ch - '0'); ch = getChar(); }
        return x;
    }
}
using FastIO::readInt;

int main() {
    int n = readInt(), p = readInt();
    for (int i = 1; i <= n; i++) a[i] = readInt();

    // 把 a 就地变成差分数组:d[i] = a[i] - a[i-1]
    // 从后往前做,因为 a[i-1] 还是原来的值,没被覆盖
    for (int i = n; i >= 2; i--) a[i] -= a[i - 1];

    // p 次区间加:区间 [x, y] 全体 +z  ->  d[x] += z, d[y+1] -= z
    for (int i = 0; i < p; i++) {
        int x = readInt(), y = readInt(), z = readInt();
        a[x] += z;
        a[y + 1] -= z;                 // y == n 时改的是 n+1 号位,数组多开过,安全
    }

    // 对差分数组求前缀和,还原出每个学生最终的分数,顺便取最小值
    int mn = a[1];
    for (int i = 2; i <= n; i++) {
        a[i] += a[i - 1];
        if (a[i] < mn) mn = a[i];
    }
    printf("%d\n", mn);
    return 0;
}
✅ 实测结果

官方样例 3 2 / 1 1 1 / 1 2 1 / 2 3 1 → 输出 2 完全一致

极限数据(n = p = 5×106,输入文件约 112 MB):输出 50耗时约 408 ms
这份随机数据里,位置 1 恰好没有被任何区间覆盖(另一个独立程序逐个统计,确认覆盖位置 1 的区间数为 0),所以最低分就是初始的 50 分 —— 答案 50 是正确的。

三种读入方式实测对比(同一份极限数据,同一台机器,同样输出 50):

读入方式耗时结论
fread 快读(上面代码)约 408 ms✅ 推荐
scanf约 4574 ms⚠️ 慢了 11 倍
cin(关同步)约 7841 ms❌ 很危险

※ 这是本机 MinGW 的实测值,仅供参考量级。重点不是具体多少毫秒,而是:500 万级别的读入,快读和 cin 差了将近 20 倍,选错就是 TLE。

易错点

  • 用 cin 读 500 万个数 —— 本题最大的坑,必须快读或 scanf。
  • 数组开小了:要用到 a[n+1],而 n 最大 5×106,所以开 5000005 这种带余量的。
  • 忘记最后求前缀和:差分数组不是答案,必须累加还原。
  • 求最小值时初值取 0:成绩都可能是正数,初值应该取 a[1](或者取一个极大值),不要想当然写 0。
  • 题面说「增加分数」,如果数据里出现负的 z,快读也要能读负数 —— 上面代码已经处理了。
P2004 领地选择 普及- · 二维前缀和

题意:给一张 N 行 M 列的地图,每个格子有一个价值(可能是负数)。首都占一个 C×C 的正方形,要找一个位置放首都,让覆盖的格子价值总和最大,输出这个正方形左上角的坐标(行 列)。题目保证最优解唯一。

⚠️ 数据范围 / 关键约束

60% 的数据 N, M ≤ 50;90% 的数据 N, M ≤ 300;
100% 的数据:1 ≤ N, M ≤ 103,1 ≤ C ≤ min(N, M),每块地价值的绝对值不超过 32767。

★ 注意算一算:最多 106 个格子,每个最大 32767,总和最大约 3.3×1010, 已经超过了 int 的 2.1×109。所以前缀和数组必须用 long long,这是本题的第一个坑。

思路

先在 O(NM) 的时间里建好二维前缀和。然后枚举首都的左上角位置 (i, j):

  • 左上角是 (i, j),边长是 C,那右下角就是 (i+C-1, j+C-1);
  • i 的范围是 1 ~ N-C+1,j 的范围是 1 ~ M-C+1(注意不是 1~N,否则右下角就越界了);
  • 每个位置用容斥公式 O(1) 算出正方形和,和当前最大值比较。

总复杂度 O(NM),最大 106 次,飞快。

本题保证最优解唯一,所以不用纠结并列时输出哪一个。不过我还是用「严格大于才更新」的写法,保证一旦有并列会输出最靠上的那个,行为更确定。

样例输入
3 4 2
1 2 3 1
-1 9 0 2
2 0 1 1
样例输出
1 2

边长 2 的正方形一共 6 种放法,和分别是:11、14、6、10、10、4。
最大的是 14,左上角在第 1 行第 2 列 → 输出 1 2

完整代码

#include <iostream>
using namespace std;

const int MAXN = 1005;
long long sum[MAXN][MAXN];         // sum[i][j] = 左上角 (1,1) 到 (i,j) 的矩形和

int main() {
    ios::sync_with_stdio(false);   // 关掉同步,读 10^6 个数也不慢
    cin.tie(0);

    int N, M, C;
    cin >> N >> M >> C;

    for (int i = 1; i <= N; i++) {
        for (int j = 1; j <= M; j++) {
            long long v;
            cin >> v;
            // 容斥:上 + 左 - 左上角 + 自己
            sum[i][j] = sum[i - 1][j] + sum[i][j - 1] - sum[i - 1][j - 1] + v;
        }
    }

    long long best = 0;
    int bx = 1, by = 1;
    bool first = true;             // 第一个位置无条件当擂主(价值可能是负数)
    for (int i = 1; i + C - 1 <= N; i++) {
        for (int j = 1; j + C - 1 <= M; j++) {
            int x2 = i + C - 1, y2 = j + C - 1;
            // 子矩形 (i,j)~(x2,y2) 的和,同样是容斥
            long long s = sum[x2][y2] - sum[i - 1][y2] - sum[x2][j - 1] + sum[i - 1][j - 1];
            if (first || s > best) { first = false; best = s; bx = i; by = j; }
        }
    }
    cout << bx << " " << by << endl;
    return 0;
}
✅ 实测结果

官方样例 → 输出 1 2 完全一致

另外用 30 组随机小数据(N、M ≤ 8,价值含大量负数)和「枚举每个正方形、逐格求和」的暴力程序对拍, 两边的坐标输出30/30 完全一致

最大规模(N = M = 1000,C = 333,输入约 5.9 MB)→ 输出 137 251耗时约 372 ms

易错点

  • 用 int 存前缀和:最大能到 3.3×1010,会溢出成负数,答案直接错。必须 long long。
  • 当成任意矩形做:题目只要「边长为 C 的正方形」,别去枚举长宽不同的矩形。
  • 枚举范围写成 1~N:应该是 i + C - 1 <= N,否则右下角越界,读到数组外面去了。
  • 输出的是左上角坐标,不是中心,也不是右下角。样例输出 1 2 就是「第 1 行第 2 列」。
  • 全负数时初值给 0 会错:那样最大值就成了 0,而正确答案是个负数。初始化时把第一个位置无条件设成擂主最稳。
P3397 地毯 普及- · 二维差分模板

题意:在 n×n 的格子上铺 m 块矩形地毯,给出每块地毯的左上角和右下角。问每个格子被多少块地毯覆盖,输出整个 n×n 的答案矩阵。

⚠️ 数据范围 / 关键约束

对于 20% 的数据,n ≤ 50,m ≤ 100。
对于 100% 的数据,n, m ≤ 1000。

★ m 块地毯,每块最大 1000×1000 格,暴力涂色最坏是 109 次操作,会超时。 而且最后的输出有 106 个数,输出本身也要注意效率(见下面的加速提示)。

思路

这就是二维差分的标准模板题,正好练「四个角点」。

  1. 开一个 diff 数组当二维差分。每读一块地毯,只改 4 个点:左上 +1、右上 −1、左下 −1、右下 +1(注意这几行是地毯边界外一格:y2+1x2+1)。
  2. 所有地毯都记完之后,做一遍二维前缀和d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1]。做完之后 d[i][j] 就是「(i,j) 这一格被覆盖的次数」。
  3. 按行输出。注意行末不要多打空格,换行用 \n

复杂度:建表 O(m),还原 O(n²),总共 O(m + n²),最大约 106 级别。

样例输入
5 3
2 2 3 3
3 3 5 5
1 2 1 4
样例输出
0 1 1 1 0
0 1 1 0 0
0 1 2 1 1
0 0 1 1 1
0 0 1 1 1

第 3 块地毯是 (1,2)~(1,4),一条横着的,所以第 1 行右边三个格子的值从 0 变成了 1。

完整代码

#include <cstdio>

const int MAXN = 1005;
int d[MAXN][MAXN];                 // 二维差分数组

int main() {
    int n, m;
    scanf("%d %d", &n, &m);

    for (int k = 0; k < m; k++) {
        int x1, y1, x2, y2;
        scanf("%d %d %d %d", &x1, &y1, &x2, &y2);
        // 矩形 (x1,y1)~(x2,y2) 加 1,只用改 4 个点
        d[x1][y1]++;
        d[x1][y2 + 1]--;
        d[x2 + 1][y1]--;
        d[x2 + 1][y2 + 1]++;       // ★ 右下角补回来
    }

    // 二维前缀和还原
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            d[i][j] += d[i - 1][j] + d[i][j - 1] - d[i - 1][j - 1];
        }
    }

    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            printf("%d", d[i][j]);
            putchar(j == n ? '\n' : ' ');
        }
    }
    return 0;
}
✅ 实测结果

官方样例 → 输出上面那个 5×5 矩阵 与官方样例逐行逐字符完全一致

另外和「暴力给每个格子涂色」的程序对拍 30 组随机数据(n、m ≤ 12):30/30 输出完全一致

最大规模(n = m = 1000,输出 106 个数):耗时约 1.4 秒(瓶颈几乎全在 printf 上, 真正算差分+前缀和只要约 20 ms)。

💡 输出也可能是瓶颈:给它加个「输出缓冲」

上面的代码逻辑没问题,但最大数据要打 106 个数,逐个 printf 反而比计算慢几十倍

如果被卡时间,可以把输出换成「先攒在内存里,攒够了再一次性写出去」:

// -------- 输出缓冲:攒满 4MB 再一次性写磁盘 --------
char obuf[1 << 22];
int olen = 0;

inline void pushChar(char c) {
    obuf[olen++] = c;
    if (olen == (1 << 22)) { fwrite(obuf, 1, olen, stdout); olen = 0; }
}
inline void writeInt(int x) {          // 手写整数转字符串,比 printf 快
    if (x == 0) { pushChar('0'); return; }
    char tmp[12]; int t = 0;
    while (x > 0) { tmp[t++] = (char)('0' + x % 10); x /= 10; }
    while (t > 0) pushChar(tmp[--t]);
}

// 输出时改成:writeInt(d[i][j]); pushChar(j == n ? '\n' : ' ');
// 程序最后记得把剩下的写出去:if (olen) fwrite(obuf, 1, olen, stdout);

实测:改成这样之后,同样那份最大数据从 约 1.4 秒降到约 45 毫秒, 而且输出文件和原版逐字节完全相同(用文件哈希比对过)。

※ 新手先写 printf 版把题目做对,等真的被卡了再来加这段。

易错点

  • 四个角点的符号记错:左上 +、右上 −、左下 −、右下 +。记不住就直接套模板。
  • 数组开成 n×n:要用到第 n+1 行第 n+1 列,必须多开。这里固定开 1005 就够了。
  • 忘记做二维前缀和还原:不做的话输出的是差分数组,全是 0 和 ±1,肯定不对。
  • 行末多打空格:用 j == n ? '\n' : ' ' 这种写法,行末就只有换行。
  • 题目里的坐标是「(x1,y1) 左上、(x2,y2) 右下」,已经保证 x1≤x2、y1≤y2,不用自己交换。
P1719 最大加权矩形 普及/提高- · 前缀和 + 枚举边界

题意:给一个 n×n 的矩阵,每个元素有整数权值(可为负)。要找一个大小不限的矩形(可以只取一格),使矩形里所有元素的权值和最大,输出这个最大和。

⚠️ 数据范围 / 关键约束

对于 100% 的数据,1 ≤ n ≤ 120,每个元素的权值在区间 [−127, 127] 内。

★ 两个注意点:
① n 只有 120,允许 O(n3) 的算法(1203 ≈ 1.7×106),但 O(n4) 也能勉强过、O(n3) 才稳
② 权值可以是负数,所以「最大和」可能是负数 —— 初值给 0 就错了。

思路

矩形有四个自由度(上、下、左、右),全枚举是 O(n4)。我们想办法砍掉两个。

🎯 经典套路:枚举「上下边界」,把二维压成一维

先枚举矩形的上边 top下边 bot(n2/2 对,n=120 时只有 7260 对)。

上下边界一固定,问题就变成了一维:在「每一列 top~bot 行的和」这个长度为 n 的数组里,找和最大的连续一段。

这是经典的「最大子段和」问题,O(n) 就能解决。合起来总共 O(n3)。

具体怎么 O(n) 求最大子段和?用前缀和版本:

S(j) = 第 1~j 列、第 top~bot 行的总和(这可以用二维前缀和 O(1) 得到:S(j) = sum[bot][j] - sum[top-1][j])。 那么左右边界为 (l, r) 的矩形和就等于 S(r) - S(l-1)

于是往右扫 j 的同时,记住「到目前为止见过的最小的 S」,用 S(j) - minPre 就能定出以 j 为右边界的最大矩形。这就是「最大子段和 = 前缀和的最大差」。

🚨 我真实踩过的坑:别用「行前缀和」冒充二维前缀和

我第一次写的时候,图省事让 s[i][j] 只表示「第 i 行前 j 个数的和」,然后就想用 s[bot][j] - s[top-1][j] 拿到 top~bot 行的和。

这是错的!行前缀只能横向相减,纵向相减得不到任何东西。用这个写法跑样例,答案输出的是 52,而正确结果是 15

结论:只要你要做纵向相减(哪怕只有一次),就必须老老实实建立完整的二维前缀和s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j])。

样例输入
4
0 -2 -7 0
9 2 -6 2
-4 1 -4 1
-1 8 0 -2
样例输出
15

最优矩形是第 2~4 行、第 1~2 列:9+2+(−4)+1+(−1)+8 = 15。

完整代码

#include <cstdio>
#include <climits>

const int MAXN = 125;
int s[MAXN][MAXN];                 // s[i][j] = 左上角 (1,1) 到 (i,j) 这个矩形的和

int main() {
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            int v;
            scanf("%d", &v);
            // ★ 完整的二维前缀和:上 + 左 - 左上角 + 自己
            s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + v;
        }
    }

    int best = INT_MIN;            // 全负数时答案就是最大的那个单个数
    for (int top = 1; top <= n; top++) {
        for (int bot = top; bot <= n; bot++) {
            // 固定上下边界后,第 top~bot 行、第 1~j 列的和就是
            //   S(j) = s[bot][j] - s[top-1][j]
            // 那么左右边界为 (l, r) 的矩形和 = S(r) - S(l-1)
            // 于是只要在 j 往右扫的过程中,记录「见过的最小 S」,就能 O(1) 定出最优左边界
            int minPre = 0;                    // 相当于 S(0) = 0
            for (int j = 1; j <= n; j++) {
                int S = s[bot][j] - s[top - 1][j];
                if (S - minPre > best) best = S - minPre;   // 以 j 为右边界的最优解
                if (S < minPre) minPre = S;                 // 更新"见过的最小前缀"
            }
        }
    }
    printf("%d\n", best);
    return 0;
}
✅ 实测结果

官方样例 → 输出 15 完全一致

另外和「四重循环枚举所有矩形、逐格求和」的 O(n4) 暴力程序对拍 40 组随机数据 (n ≤ 6,权值 −9~9,包含大量全负矩阵):40/40 完全一致

边界也试过了:n = 1 且权值为 −5 → 输出 -5;全负数 3×3 矩阵最大单元素是 −1 → 输出 -1, 说明「答案可以是负数」这个情况处理正确。

易错点

  • 用行前缀代替二维前缀:见上面的红色框,这是我亲自踩过的坑。
  • 初值给 0:全负数矩阵的正确答案是负数,初值 0 会输出 0。用 INT_MIN,或者拿第一对上下边界的第一个结果当擂主。
  • 写成标准 Kadane(cur 只跟 a[j] 有关):这里扫的是前缀和数组 S,不是原数组。 对前缀和数组来说,cur = S[j] 相当于取了个空的左边界,会漏掉「从左边界 l 开始」的情况 —— 一定要用「S[j] − 见过的最小 S」这个形式。
  • 枚举上下边界时 top ≤ bot:注意是 for(bot = top; bot <= n; bot++),从 top 开始。
  • 别担心内存:int s[125][125] 只有 62 KB。

今日常见错误汇总

错误写法后果正确写法
sum[i] 用 int,a[i] 很大 累加溢出成负数,答案莫名其妙 求和、前缀和数组一律 long long
下标从 0 开始却套 sum[l-1] l = 0 时访问 sum[-1],越界 统一从下标 1 开始,数组开 n+5
差分数组只开 n 个 用到 d[r+1] 时越界,本地不报错、评测莫名 WA 多开 5 个以上,或用 if(r+1 <= n)
改完差分直接输出 d 输出的是「差值」不是真实值 最后必须求一次前缀和还原
二维容斥符号写错(中间两项写成加号) 矩形和算错,样例都过不了 大 − 上 − 左 + 左上;建表时是 上 + 左 − 左上 + 自己
求最小值 / 最大值时初值写 0 数据可能全为负或全为正,答案错 初值用第一个元素,或 INT_MIN / INT_MAX
P2367 用 cin 读 5×106 个数 读入就花掉 7 秒以上,TLE 用 fread 快读,或至少 scanf
P2004 前缀和用 int 最大 3.3×1010,溢出 二维前缀和用 long long
P1719 只做「行前缀」 纵向相减得到的不是行段和,样例输出 52(正确 15) 建完整二维前缀和,再做纵向相减
输出行末多打一个空格 / 用 endl 刷 106 有的题判格式错;endl 还会反复刷新缓冲区导致超时 j == n ? '\n' : ' ';换行用 \n

自测:你真的会了吗

第 1 题:手算前缀和

已知 a = [2, 5, 1, 4, 3](下标从 1 开始),求 a[2] + a[3] + a[4]。

sum = [2, 7, 8, 12, 15],所以区间 [2,4] 的和 = sum[4] - sum[1] = 12 - 2 = 10
验算:5 + 1 + 4 = 10 ✅

第 2 题:差分数组长什么样

数组 a = [3, 3, 3, 7, 7],它的差分数组 d 是多少?

:d[1] = 3(a[0] 当 0),d[2] = 0,d[3] = 0,d[4] = 4,d[5] = 0。
也就是 d = [3, 0, 0, 4, 0]
★ 注意 d 里只有两个非零数 —— 这正说明「a 是由两次区间加得到的结果」,差分把「一段一段的变化」存成了「零星的几个点」, 这就是它高效的原因。

第 3 题:为什么区间修改只要改两个点

用一句话解释:为什么把 a[l..r] 全加 x,只需要 d[l] += x; d[r+1] -= x;

:因为 a 是 d 的前缀和,所以 d[l] 一变,a[l] 及其后面所有数都会跟着变; 在 d[r+1] 处减掉 x,就让 a[r+1] 及其后面所有数变回来。两者一抵消,受影响的恰好只有 a[l..r] 这一段。

第 4 题:二维差分的四个点

要把 5×5 网格里 (2,3)~(4,5) 这个矩形全部 +1,应该改哪四个点?各加还是减?


d[2][3] += 1;(左上)
d[2][6] -= 1;(右上,y2+1)
d[5][3] -= 1;(左下,x2+1)
d[5][6] += 1;(右下,x2+1 且 y2+1)

★ 注意 6 超出了 5×5,所以数组必须开到 6 以上(一般直接开 10 或 1005)。

第 5 题:该用前缀和还是差分?

下面三种需求,分别该用什么?
① 100 万次询问,每次问某段区间的和;
② 100 万次操作,每次把某段区间每个数加 5,最后输出整个数组;
③ 一边把某段区间加 5,一边随时问「现在区间 [l,r] 的和是多少」。

:① 一维前缀和;② 一维差分;③ 两个都不行 —— 差分不支持「边改边问」, 这种题要线段树 / 树状数组,以后会学。现在只要知道「差分是离线工具」就够了。

速查卡(截图存手机)

【一维前缀和】
  sum[0] = 0;
  for(i = 1; i <= n; i++) sum[i] = sum[i-1] + a[i];
  区间 [l,r] 的和 = sum[r] - sum[l-1];        // O(1)

【一维差分】区间 [l,r] 全体 +x
  d[l] += x;   d[r+1] -= x;                  // 两次单点修改
  // 最后一定要还原:
  cur = 0;
  for(i = 1; i <= n; i++){ cur += d[i]; ... }  // cur 就是 a[i]
  // ★ 数组开到 n+5,因为会用 d[n+1]

【二维前缀和】建表
  s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j];
  子矩形 (x1,y1)~(x2,y2) =
      s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1];

【二维差分】矩形 (x1,y1)~(x2,y2) 全体 +x
  d[x1][y1] += x;      d[x1][y2+1] -= x;
  d[x2+1][y1] -= x;    d[x2+1][y2+1] += x;
  // 还原用二维前缀和
  d[i][j] += d[i-1][j] + d[i][j-1] - d[i-1][j-1];

【最大子段和 / 最大子矩阵】
  minPre = 0; best = INT_MIN;
  for(j = 1; j <= n; j++){
      S = s[bot][j] - s[top-1][j];
      if(S - minPre > best) best = S - minPre;
      if(S < minPre) minPre = S;
  }
  // 枚举 top、bot 两层,里面套这个 O(n) 的扫描 = O(n^3)

【快读(n 到 500 万时必须用)】
  fread 一次读 1MB 进缓冲区,再逐字符解析
  实测:快读 0.4s  vs  scanf 4.6s  vs  cin 7.8s

【铁律】
  求和 / 前缀和数组 → long long
  下标从 1 开始  → 数组开 n+5
  差分必还原     → 最后求一遍前缀和
  容斥写完必手算  → 拿 2×2 或 3×3 小例子对一遍

明天预告 · Day 16

递归入门:自己调用自己

今天所有代码都是「从前往后一个 for 循环扫过去」。明天要换一种完全不同的写法:让函数调用它自己

会学到的:递归三要素(特别是「出口」)、递归树怎么画、为什么忘记写出口会栈溢出, 以及分治(把大矩阵切成四块,P5461 赦免战俘就是这个套路)。

另外,明天第 4 题 P1028 用纯递归会超时,需要「记答案」—— 那是 Day 17 记忆化的引子,先感受一下。

ACM 一个月学习计划 · DAY 16

Day 16 · 递归入门:自己调用自己

让函数调用它自己 —— 代码一下子变得很短,但「出口」写错就会当场炸掉

今日目标:写出正确的递归函数,理解为什么「出口」是递归里最重要的东西
预计用时:约 3.5 小时(含做题)  难度:入门 ~ 普及-
题目来源:洛谷 P5739 / P5743 / P5461 / P1028
DAY 16 / 30

今日时间安排

0 - 30 min搞懂「递归三要素」,把阶乘的两行代码想透(第 1-3 节)
30 - 50 min亲手制造一次栈溢出,再理解递归 vs 循环(第 4-5 节)
50 - 160 min做 4 道题,每题限时 30 分钟
160 - 190 min对照本文题解,把没做出来的补掉
190 - 220 min写题解笔记 + 做自测 + 背速查卡
💡 和昨天的衔接

Day 15 我们写的东西全都是「一个 for 循环从头扫到尾」,数据是被动地被处理。

今天换一种完全不同的思路:让函数在里面再调用一次自己,把大问题交给「缩小一号的同一个问题」
你会发现有些问题用递归写出来只有三五行,而且几乎就是把数学定义抄了一遍。

今天第 3 题 P5461 会把「一个大方块切成四块」反复切下去 —— 这就是分治, 也是后面学 DFS、归并排序、线段树的共同套路。今天第 4 题 P1028 则会让你第一次感受到 「递归写对了但很慢」,为明天的记忆化埋个伏笔。

1什么是递归

先讲个段子

查字典的时候,你想知道「递归」是什么意思,字典上写着:「见『递推』」。
于是你去查「递推」,字典上写着:「见『递归』」…… 然后你就永远查不完了。

这个段子其实精确地描述了递归的两个面:自己调用自己是它的力量,而没有终点就是它的死穴。

🎯 一句话定义

递归就是:一个函数,在它自己里面调用它自己。

听起来像废话,但关键在于每次调用时问题要变小一点,小到最后有个「不用再问别人就能直接回答」的情况。

再看个生活里的例子:排队报数

一队人,你想知道自己排第几,但看不见前面。怎么办?

  1. 你问前面那个人:「你是第几?」
  2. 前面那个人也不知道,于是他再问他前面那个……
  3. 问到第一个人时,他说:「我是第 1 个。」—— 这是出口
  4. 然后答案一个一个传回来:第 1 个告诉第 2 个「你是 2」,第 2 个告诉第 3 个「你是 3」……
  5. 最后你收到了答案。

这个过程有两个明显特征:问问题是一层层往前传的,答案是一层层往后回来的。递归也是这样 —— 先「一路递归进去」, 再「一路返回出来」。这一点第 3 节会详细画图。

写出来长什么样

用递归求「1 到 n 的和」,就是上面那个报数过程:

int sum(int n){
    if(n == 1) return 1;        // ★ 出口:第一个人直接报 1
    return n + sum(n - 1);      // 递推:我是 n,加上前面那些人的和
}

短短两行。如果换成循环,你要写 for、要维护一个累加变量、要注意初值 —— 递归把「重复」这件事交给了函数调用本身。

2递归三要素(背下来,写递归前先默念)

#要素它回答什么问题忘了他会怎样
出口(base case) 什么情况下直接返回,不再递归? 无限递归 → 栈溢出 RE(今天第 4 节有实测)
递推关系 f(n) 怎么由「更小的子问题」算出来? 算不出结果,或者算错
参数含义 每一层的参数代表什么? 传参传错方向、边界差一,答案永远差一点

拿阶乘当例子,逐条对照

题目:求 n! = 1 × 2 × 3 × … × n。

要素内容
① 出口if(n <= 1) return 1; —— 到 1 就没有更小的了,直接回答 1
② 递推关系f(n) = n * f(n-1) —— 想算 3!,先问 2! 是多少,再乘个 3
③ 参数含义参数 n 表示「要算谁的阶乘」,每递归一层,n 就小 1
long long fact(int n){
    if(n <= 1) return 1;                // ① 出口
    return (long long)n * fact(n - 1);  // ② 递推关系(③ 参数 n 每层减 1)
}
⚠️ 出口必须「一定会被碰到」

光有 if 还不够,得保证递归的每一步都在朝出口靠近

上面 fact(n-1) 里 n 每次减 1,肯定有一天会变成 1 → 安全。
但如果你不小心写成 fact(n)(忘了减 1),n 永远是原来那个数,就到不了出口 —— 结果是死递归、栈溢出。

写递归时养成一个习惯:问自己「每递归一层,哪个东西变小了?」答不上来就说明有 bug。

完整代码(能直接跑)

#include <iostream>
using namespace std;

long long fact(int n){
    if(n <= 1) return 1;
    return (long long)n * fact(n - 1);
}

int main(){
    int n;
    cin >> n;
    cout << fact(n) << endl;
    return 0;
}

3递归到底是怎么执行的:先进后出

初学递归最容易懵的地方是:「代码明明是从上往下执行的,函数自己调用自己,那后面的代码什么时候跑?

答案藏在「函数调用栈」里。每次函数调用,系统都会给它分配一小块内存(叫栈帧),用来记住「我是谁、我算到哪了、返回之后要接着干什么」。 递归调用就是往这个栈上一层层往上压;返回的时候从最上面一层层弹下来

所以递归的顺序是:先进后出(就像往箱子里叠盘子,最后放进去的最先拿出来)。

走一遍 fact(3)

3 * fact(2) 这一层,必须先把 fact(2) 算出来才能做乘法 —— 所以它得「停在那里等」。

步骤发生了什么调用栈(左边是底,右边是顶)
1main 调用 fact(3)main → fact(3)
2fact(3) 要算 3 * fact(2),先暂停,去调用 fact(2)main → fact(3) → fact(2)
3fact(2) 要算 2 * fact(1),也暂停,去调用 fact(1)main → fact(3) → fact(2) → fact(1)
4fact(1) 命中出口(n ≤ 1),直接返回 1main → fact(3) → fact(2) (fact(1) 弹出)
5fact(2) 拿到 1,算出 2 × 1 = 2,返回 2main → fact(3) (fact(2) 弹出)
6fact(3) 拿到 2,算出 3 × 2 = 6,返回 6main (fact(3) 弹出)

注意第 4~6 步:返回的顺序和调用的顺序正好反过来。这就是「先进后出」。

🎯 想不通递归时,画递归树

把每一层调用画成一个节点,向下画出它又调用了谁,就是一棵「递归树」。
树画出来之后,三件事一眼就能看清:树有多深(决定会不会爆栈)、树有多少个节点(决定跑多快)、 有没有重复的节点(决定要不要上记忆化,第 8 节讲)。

fact(3) 的递归树其实就是一条链:fact(3) → fact(2) → fact(1)

4忘记写出口会怎样:真的会炸

这不是「可能会出错」,而是必然出事。因为每递归一层就要在栈上占一块内存,栈的大小是有限的(本机上默认约 2 MB), 一层层压下去总有压满的时候 —— 这时程序会以「栈溢出」直接崩溃。

🚫 一个真实的错误示范(我编译运行过)
#include <cstdio>

long long f(int n){
    return n * f(n - 1);      // ★ 少了 if(n <= 1) return 1; 这一行出口
}

int main(){
    printf("%lld\n", f(5));
    return 0;
}

实测结果(本机 MinGW g++ 6.3.0):

· 用 -O0(不做优化)编译运行 → 立刻崩溃退出,退出码 0xC00000FD, 也就是 Windows 的 STATUS_STACK_OVERFLOW(栈溢出)

· 用 -O2 编译运行 → 这次没崩,但程序一直转不停(我给它设了 120 秒上限,它没停下来)。 原因是编译器把这个递归优化成了死循环。

两种情况在评测机上都会表现为 RE 或 TLE,一分都拿不到。所以:出口不是可选项,是必须项。

栈到底能装多少层?我实测了一下

写一个「递归到指定层数就返回」的函数,然后不断加大层数看它什么时候崩:

递归层数10000300004000045000100000
结果✅ 正常✅ 正常✅ 正常❌ 栈溢出❌ 栈溢出

(本机 MinGW g++ 6.3.0,-O0 编译,一个最简单的递归函数)

⚠️ 所以「约几万层」是递归的硬上限

具体数字会随编译器、优化选项、函数里有多少局部变量、以及具体平台而变,但量级就是几万层

这对我们的实际影响:
· 今天 4 道题的递归深度最大只有 20 层(P5743)或 10 层(P5461),完全安全;
· 但以后做 DFS 的时候,如果图/树有 105 个点连成一条链,递归深度就会到 105 —— 那就可能爆栈了, 到时候要么改写成循环 + 手工栈,要么把递归深度控制住。

5递归 vs 循环:什么时候用哪个

递归循环
代码长度短,常常就是把数学定义抄一遍通常更长,要自己维护变量
运行速度慢(每次调用都要压栈、传参、返回)
内存占用每层都占一块栈空间,深度大就爆栈只占几个变量
深度限制有,本机实测约 4 万层没有
适合什么树/图的遍历、分治、回溯;问题定义本身就是递归的简单的重复累加;一眼能看出规律的
今天的例子P5461 分治、P5743 倒推P5739 阶乘、P1028 递推
💡 一条实用建议

能用循环简单表达的,优先用循环。

比如求阶乘:循环写起来也就是三行,还不用担心爆栈。递归真正无可替代的地方是 「不知道要循环多少层、每一层还要分成好几个分支」的场合 —— 那时候循环根本写不出来,递归几行就搞定。 今天第 3 题 P5461 就是这种。

顺带说一句:P5739 的题目里专门写了一句「挑战:尝试不使用循环语句完成这个任务」, 就是为了逼你用一次递归。把它当成一次练习,不是让你以后什么都用递归。

6分治:把大问题切成几个「一模一样的小问题」

递归最漂亮的用法叫分治,三个步骤:

  1. :把大问题切成几个规模更小、但结构完全相同的子问题;
  2. :子问题还大就继续分(这就是递归调用),小到能直接算就直接算(这就是出口);
  3. :把子问题的答案拼成原问题的答案。

今天第 3 题 P5461 赦免战俘 就是标准的分治:一个 2n×2n 的方阵, 左上角那一块直接「处理完」(全部赦免),剩下三块各自又是个同样形状的小方阵 —— 于是递归下去。

🎯 分治函数的参数,通常就是「我正在处理哪一块」

比如 P5461 的函数签名是 solve(x, y, size)
「我要处理左上角在 (x, y)、边长为 size 的那个正方形」。

想清楚这句话,递归就不会写错 —— 每一层的参数只是把「同一句话」里的数字换成了更小的那块。这就是第 2 节说的「参数含义」。

7两种方向:正着推和倒着推

今天第 2 题 P5743 猴子吃桃 是个很有意思的例子:题目把「过程」讲了一遍,但问的是「最开始有多少」。

从第 1 天往第 n 天正着推,你会发现推不下去 —— 因为你不知道第 1 天有多少个桃子,而每天的吃法都依赖前一天剩下的数量。

🎯 正着推不动,就倒着推

题目告诉了最后的结果:第 n 天早上剩 1 个。这是唯一的「已知条件」,那就从它出发往回倒推。

设某天早上有 y 个桃子。猴子吃了「一半多一个」,也就是吃掉 y/2 + 1 个,剩下:

剩下的 = y − (y/2 + 1) = y/2 − 1

反过来解 y:y = (剩下的 + 1) × 2

所以只要知道「今天早上剩多少」,就能算出「昨天早上有多少」—— 一路推到第 1 天,就是答案。

用 n = 4 手推一遍(第 4 天早上剩 1 个):

往回推的步计算那一天早上有多少个
第 4 天题目已知1
第 3 天(1 + 1) × 24
第 2 天(4 + 1) × 210
第 1 天(10 + 1) × 222

所以最初买了 22 个 —— 正好是样例输出。

⚠️ 最容易错的地方:+1 还是 −1

多吃了一个」意味着剩下的比一半少一个,所以倒推回去要先把少掉的那个加回来,再乘 2。

写成 (剩下 - 1) * 2 就错了。记不住的话,把 n = 4 代进去手算一遍:
(1 - 1) × 2 = 0,而正确答案是 4 —— 一眼就能发现错在哪。小数据手算是最快的验错手段。

8递归的坑:同一件事被算了很多遍

先看今天第 4 题 P1028 数的计算 的递归关系。设 f(x) 表示「以 x 开头的合法数列有多少个」,那么:

f(x) = 1 + f(1) + f(2) + ... + f(x/2)
       ↑
    只有 x 自己那一个数列

看起来没问题,但把 f(6) 的递归树画出来,问题就暴露了:

f(6)
├── f(1)                        // 直接命中出口
├── f(2) ── f(1)                // ← f(1) 又算了一遍
└── f(3) ── f(1)                // ← f(1) 又又算了一遍

f(6) = 1 + f(1) + f(2) + f(3) = 1 + 1 + 2 + 2 = 6

f(1) 被算了 3 次!x 小的时候无所谓,但 x 一大,重复量会指数级爆炸。

🚨 实测:纯递归的调用次数正好等于答案

我给纯递归版加了一个计数器数了数调用次数:

n1020304050
答案1460166390786
递归调用次数1460166390786

两行数字一模一样 —— 因为递归树上的每一个节点就对应一个合法数列

而本题 n 可以到 1000,答案 f(1000) = 1981471878(下面题卡里有实测)。也就是说纯递归要做将近 20 亿次函数调用, 必然超时。

🎯 解决办法预告:算过就记下来

既然 f(1) 会被反复问,那第一次算完就把答案写进一个数组 memo[1],下次再问直接读数组,不再往下递归。

这个技巧叫记忆化搜索,就是「递归 + 缓存」。正式内容在 Day 17,今天的 P1028 我会给出「递推」写法 (从小到大把答案填进数组,本质上就是不让它重复算),并且也附上记忆化递归版给你先感受一下。

9四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 真实编译并跑过官方样例,输出是你看到的真实结果。

P5739 【深基7.例7】计算阶乘 入门 · 递归热身

题意:求 n! = 1 × 2 × 3 × … × n。题目额外加了一句挑战:尝试不使用循环语句(for、while)完成

⚠️ 数据范围

1 ≤ n ≤ 12。

★ 为什么上限这么小?因为 13! = 6227020800 已经超过 int 的范围了,12! = 479001600 还在里面。 所以本题用 int 也能过 —— 但养成习惯,乘法相关的中间结果一律用 long long,不吃亏。

思路

这题就是第 2 节那个例子的原题,直接套三要素:

要素内容
① 出口if(n <= 1) return 1; (写成 ≤ 1 而不是 == 1,这样连 0! = 1 都顺带处理了)
② 递推关系f(n) = n × f(n-1)
③ 参数含义n 表示「要算谁的阶乘」,每层减 1,一定会在 n = 1 时停下

递归深度最大 12 层,完全不用担心栈的问题。

样例输入
3
样例输出
6

完整代码

#include <iostream>
using namespace std;

long long fact(int n) {
    if (n <= 1) return 1;              // 出口:0! = 1! = 1
    return (long long)n * fact(n - 1); // 递推关系:n! = n * (n-1)!
}

int main() {
    int n;
    cin >> n;
    cout << fact(n) << endl;
    return 0;
}
✅ 实测结果

官方样例 3 → 输出 6 完全一致

边界 n = 1 → 输出 1 正确
最大数据 n = 12 → 输出 479001600 正确(12! = 479001600,手算核对无误)

易错点

  • 忘记写出口 —— 本题最经典的错误,会栈溢出。
  • 递归时忘了把 n 减 1(写成 fact(n)):参数永远不变,到不了出口。
  • 返回类型用了 int 还真的去乘大数:本题 n ≤ 12 侥幸没事,但换成 13 就溢出了。用 long long
  • 出口写成 if(n == 0) return 1; 只处理 0 的情况:那 n = 1 时还会继续递归到 fact(0),能对但多绕一层。写 n <= 1 最稳。
P5743 【深基7.习8】猴子吃桃 入门 · 递归倒推

题意:小猴买了若干桃子。第一天吃掉这些桃子的一半再多一个,之后每天都吃掉当天剩余的一半再多一个。 到了第 n 天早上,只剩下 1 个桃子了。问:小猴一开始买了多少个桃子?

⚠️ 数据范围

1 ≤ n ≤ 20。

★ 20 天每天「翻倍再 +2」,答案的量级是 220 左右(实测 n = 20 时是 1572862), int 装得下,但用 long long 更放心。递归深度最大 20 层,非常安全。

思路

正着推推不动(不知道第 1 天有多少),所以从最后一天往回倒推,推导过程见第 7 节:

🎯 倒推公式

昨天的桃子数 = (今天的桃子数 + 1) × 2

第 n 天早上剩 1 个(已知)→ 倒推 n−1 次 → 得到第 1 天早上的数量,就是最初买的桃子数。

三要素对照:

要素内容
① 出口if(n == 1) return 1; 只过 1 天就剩 1 个,那买的就是 1 个
② 递推关系peaches(n) = (peaches(n-1) + 1) * 2
③ 参数含义「如果第 n 天早上剩 1 个桃子,那么第 1 天早上有多少个」 —— 这句话一定要在心里说清楚

验算 n = 4:peaches(1) = 1 → peaches(2) = 4 → peaches(3) = 10 → peaches(4) = 22。

样例输入
4
样例输出
22

完整代码

#include <iostream>
using namespace std;

// 含义:如果第 n 天早上剩 1 个,那么第 1 天早上有多少个桃子
long long peaches(int n) {
    if (n == 1) return 1;                       // 出口:只过 1 天,剩的就是买的
    return (peaches(n - 1) + 1) * 2;            // 倒推:昨天 = (今天 + 1) * 2
}

int main() {
    int n;
    cin >> n;
    cout << peaches(n) << endl;
    return 0;
}
✅ 实测结果

官方样例 4 → 输出 22 完全一致

边界 n = 1 → 输出 1 正确
最大数据 n = 20 → 输出 1572862。这个值可以手算核对:倒推式子展开后是 3 × 219 − 2 = 1572864 − 2 = 1572862,完全吻合。

另外我还用「循环倒推」写了一份对照代码(x = 1; 重复 n-1 次: x = (x+1)*2;), 编译运行后 n = 4 输出 22、n = 1 输出 1、n = 20 输出 1572862,和递归版逐项相同

易错点

  • 把 +1 写成 −1:最容易犯的错。「多吃一个」→ 剩下的比一半少 1 → 倒推时要先把 1 加回来。 用 n = 4 手算一遍立刻能发现。
  • 循环/递归次数搞错:从第 n 天推回第 1 天,要推 n − 1 次,不是 n 次。 递归写法里这体现为 peaches(n-1) 一直推到 peaches(1)
  • 出口写成 n == 0:那 n = 1 时会去调 peaches(0),算出 (1+1)×2 = 4,答案就错了。出口必须是 n == 1。
  • 「一半」是整数除法:题目数据保证每天剩余的都是偶数,不用考虑除不尽的情况,但心里要有数。
P5461 赦免战俘 普及- · 递归分治 + 二维数组

题意:有 2n × 2n 个人站成方阵。每轮把正方形均分成四块左上那一块的所有人全部赦免, 剩下三块各自继续做同样的事,直到方阵无法再分(只剩 1 个人)。输出每个人的命运:0 表示被赦免,1 表示不被赦免

⚠️ 数据范围

2n × 2n(n ≤ 10),也就是最大 1024 × 1024 = 约 106 个格子

★ 两点注意:
① 格子数到 106,所以每个格子只能做 O(1) 的处理,不能在里面写循环;
② 输出也有 106 个字符,用 putcharcout << endl 快得多。

思路

标准的分治。先想清楚「函数在处理什么」:

🎯 函数签名与含义

solve(x, y, size):「我要处理左上角在 (x, y)、边长为 size 的那个正方形」。

三要素对照:

要素内容
① 出口size == 1:只剩一个人,他没有被赦免,把这一格标成 1,然后返回
② 递推关系边长砍一半得 h = size/2左上那块整体赦免(什么都不用做), 另外三块各自 solve 一次
③ 参数含义(x, y) 是左上角坐标,size 是边长。三块子方阵的左上角分别是 (x, y+h)(x+h, y)(x+h, y+h)
💡 最巧妙的一点:左上角那块「什么都不用做」

因为数组是全局的 char 数组,默认值全是 0。而 0 正好代表「被赦免」。

所以左上角那一块连赋值都不用写 —— 不用刷、不用管,它天然就是对的。 既省代码又省时间。这也是为什么我把「赦免 = 0」和「数组初值 = 0」这两件事对上。

如果反过来让 0 表示不被赦免,那就得写个循环把左上角刷成 1,代码更多、还更慢。

另外可以算一下工作量:递归树的节点数是 1 + 3 + 9 + … + 3n = (3n+1 − 1) / 2。 n = 10 时约 8.9 万个节点,每个节点只做 O(1) 的事 —— 非常快。递归深度只有 10 层,不会爆栈。

样例输入
3
样例输出
0 0 0 0 0 0 0 1
0 0 0 0 0 0 1 1
0 0 0 0 0 1 0 1
0 0 0 0 1 1 1 1
0 0 0 1 0 0 0 1
0 0 1 1 0 0 1 1
0 1 0 1 0 1 0 1
1 1 1 1 1 1 1 1

完整代码

#include <cstdio>

const int MAXN = 1030;
char a[MAXN][MAXN];                 // 全局数组自动全是 0(0 = 被赦免)

// 处理左上角为 (x,y)、边长为 size 的正方形
void solve(int x, int y, int size) {
    if (size == 1) {                // 出口:分到只剩一个人 → 没被赦免
        a[x][y] = 1;
        return;
    }
    int h = size / 2;
    // 左上那一块整体都是 0,不用管,直接跳过
    solve(x,     y + h, h);         // 右上
    solve(x + h, y,     h);         // 左下
    solve(x + h, y + h, h);         // 右下
}

int main() {
    int n;
    scanf("%d", &n);
    int size = 1 << n;              // 2 的 n 次方
    solve(1, 1, size);

    for (int i = 1; i <= size; i++) {
        for (int j = 1; j <= size; j++) {
            putchar('0' + a[i][j]);
            putchar(j == size ? '\n' : ' ');
        }
    }
    return 0;
}
✅ 实测结果

官方样例 3 → 输出的 8×8 矩阵与官方样例逐行逐字符完全一致

边界 n = 1 → 输出
0 1
1 1 —— 只有左上角被赦免,其余三个人留下,符合题意。

最大数据 n = 10 → 输出 1024×1024 = 1048576 个数,耗时约 32 ms
我用另一个程序数了一遍:里面 1 的个数正好是 59049 = 310, 和「每个方阵留下 3 块、递归 n 层」的理论值完全吻合;而且没有任何非法字符。

另外我还写了一份不用递归的对照程序(对每个格子逐层判断「我是不是落在左上那一块」), 在 n = 1~7 上和递归版输出完全一致,交叉验证了两边的正确性。

易错点

  • 出口写成 size == 0:这样 size = 1 时还会继续分,而 1/2 = 0,会无限递归下去 → 栈溢出。出口必须是 size == 1
  • 四个象限少调一块或多调一块:左上角那块不能递归(它已经被赦免了)。如果手滑把左上角也递归了,图案会全错。
  • 三块子方阵的左上角坐标写错:记住边长是 h = size/2,所以右边那块是 (x, y+h),下面那块是 (x+h, y)
  • 输出用 cout 且每个数都 endl:106 次刷新缓冲区,会明显变慢。用 putchar 或者至少用 '\n'
  • 行末多打空格:用 putchar(j == size ? '\n' : ' ') 就不会多。
  • 数组要开够:char a[1030][1030] 约 1 MB。注意这是全局数组,如果写成 main 里的局部数组可能会有栈问题。
P1028 [NOIP 2001 普及组] 数的计算 普及- · 递推 / 记忆化

题意:给定正整数 n,按下面的方式构造数列:

  1. 只有一个数 n 的数列是合法的;
  2. 在一个合法数列的末尾加一个正整数,但这个数不能超过该数列最后一项的一半,得到一个新的合法数列。

问:一共有多少个合法的数列?

比如 n = 6,合法的数列有:66,16,26,36,2,16,3,1,共 6 个。

⚠️ 数据范围

对于全部的测试点,保证 1 ≤ n ≤ 103

★ 注意:本题题面是洛谷修订过的(原 NOIP 2001 的题面描述和数据不符,洛谷改了题面让它和数据一致), 所以直接搜老题解可能会看到不一样的表述,以本题面为准。

★ 另一个关键点:答案会很大。实测 n = 1000 时答案是 1981471878, 而 int 的上限是 2147483647 —— 只差一点点就爆了。所以必须用 long long

思路

f[x] = 「以 x 开头的合法数列有多少个」。

从 x 出发,末尾能接的第一个数 i 可以是 1 到 x/2 之间的任意正整数
一旦接上了 i,后面还能接什么,就完全由 i 决定了 —— 而「以 i 开头的合法数列个数」正好就是 f[i]

再加上「只有 x 自己」这一个数列,就得到递推式:

f[x] = 1 + f[1] + f[2] + ... + f[x/2]

因为右边只用到比 x 小的下标,所以可以从 x = 1 开始从小到大一路填表填到 n,不需要递归。

🎯 为什么这里要用「递推」而不是「递归」

因为递归会把 f(1)f(2) 这些小子问题重复算无数遍(见第 8 节的递归树)。

而递推是从小往大算:算 f[x] 的时候,f[1] … f[x/2] 早就算好放在数组里了, 直接拿来用,每个值只算一次。这就是「用数组记答案」的思想 —— 明天 Day 17 的核心

复杂度:外层 n 次,内层平均 x/2 次,总共约 n²/4 = 25 万次加法(n = 1000),实测 35 ms,非常轻松。

样例输入
6
样例输出
6

完整代码

#include <iostream>
using namespace std;

const int MAXN = 1005;
long long f[MAXN];               // ★ 答案能到 19 亿,必须 long long

int main() {
    int n;
    cin >> n;

    for (int x = 1; x <= n; x++) {
        f[x] = 1;                        // 只有 x 自己这一个数列
        for (int i = 1; i <= x / 2; i++) {
            f[x] += f[i];                // 后面接上所有以 i 开头的合法数列
        }
    }
    cout << f[n] << endl;
    return 0;
}
💡 附:记忆化递归版(明天的预告,我也编译验证过)

如果你就想用递归写,那就必须加缓存,否则必超时:

long long memo[1005];
bool vis[1005];

long long f(int x) {
    if (vis[x]) return memo[x];          // ★ 算过就直接取,不再往下递归
    vis[x] = true;
    long long r = 1;                     // 只有 x 自己
    for (int i = 1; i <= x / 2; i++) r += f(i);
    return memo[x] = r;                  // 存起来,下次直接用
}

int main() { int n; cin >> n; cout << f(n) << endl; return 0; }

实测:n = 1000 时输出 1981471878和上面的递推版完全一致。 写法几乎没变,只多了三行缓存,速度就从「跑不完」变成瞬间出结果。

🚫 反面教材:不加缓存的纯递归(我实测过,必超时)
long long f(int x){
    long long r = 1;
    for(int i = 1; i <= x/2; i++) r += f(i);   // 没有任何缓存
    return r;
}

这份代码结果是对的(n = 6 输出 6),但代价是:递归树的节点数正好等于答案。
实测调用次数:n = 10 时 14 次、n = 20 时 60 次、n = 50 时 786 次 ……
而 n = 1000 时答案是 1981471878,也就是要做将近 20 亿次函数调用,必然 TLE。

✅ 实测结果

官方样例 6 → 输出 6 完全一致

最大数据 n = 1000 → 输出 1981471878耗时 35 ms
同结果交叉验证:记忆化递归版输出也是 1981471878,两边一致。

正确性再验证:我另外写了个「暴力枚举所有合法数列」的 DFS 程序(真的把每个数列都构造一遍并计数), 在 n = 1 到 20 上和递推版逐个 n 对比,答案完全相同
1, 2, 2, 4, 4, 6, 6, 10, 10, 14, 14, 20, 20, 26, 26, 36, 36, 46, 46, 60
(n = 6 时是 6,和官方样例也对得上。)

易错点

  • 用纯递归:逻辑对但复杂度是指数级,n = 1000 必 TLE。
  • int 存答案:n = 1000 的答案 1981471878 已经贴到 int 上限(2147483647), 稍有不慎就溢出成负数。必须 long long
  • 忘了 f[x] = 1 这一句:会漏掉「只有 x 自己」那个数列,答案偏小。
  • 内层循环上界写成 x-1x/2 + 1:题目说「不能超过最后一项的一半」, 所以是 i <= x/2,整数除法自动向下取整。x = 1 时 1/2 = 0,循环一次都不执行 → f[1] = 1,正确。
  • 数组开小:n 最大 1000,数组至少开 1001,开 1005 留点余量。

今日常见错误汇总

错误写法后果正确写法
忘记写出口 if(...) return ...; 无限递归 → 栈溢出(实测退出码 0xC00000FD,或编译器优化成死循环一直跑) 写递归前先默念三要素,第一条就是出口
递归调用时参数没变小,写成 f(n) 到不了出口,一样爆栈 确认每层都在朝出口靠近,如 f(n-1)
出口写成 size == 0(P5461) size = 1 时 1/2 = 0,永远分不完 → 栈溢出 出口是 size == 1
倒推题里把「+1」写成「−1」(P5743) 答案完全错,n = 4 会输出 0 昨天 = (今天 + 1) × 2;不确定就小数据手算
递归 + 没有缓存就交(P1028) 调用次数 = 答案,n = 1000 要 20 亿次调用 → TLE 改成递推(从小到大填表)或加记忆化数组
答案用 int 存(P1028) 1981471878 贴着 int 上限,容易溢出 long long
分治时把「左上角那一块」也递归了(P5461) 本该被赦免的人变成不被赦免,整幅图全错 左上块不递归,只处理另外三块
递归深度开到几万层以上 爆栈(本机实测约 4 万层是上限) 评估深度;太深就改循环或手工栈
106 次输出用小规模写法(cout << ... << endl 反复刷新缓冲区,明显变慢 putchar / '\n',避免 endl
把大数组开在 main 里(如 char a[1030][1030] 局部变量占栈空间,1 MB 可能直接把栈挤爆 大数组开成全局变量(全局在静态区)

自测:你真的会了吗

第 1 题:说出三要素

用递归求「n 的各位数字之和」(比如 1357 → 1+3+5+7 = 16),三要素分别是什么?


① 出口:if(n == 0) return 0;(没有数字了,和为 0)
② 递推关系:f(n) = n % 10 + f(n / 10)(最后一位 + 前面所有位的和)
③ 参数含义:n 表示「还要拆哪个数」;每层 n/10 都更小,最终会变成 0 → 到得了出口

第 2 题:手推递归

下面的函数,g(4) 返回多少?

int g(int n){
    if(n <= 1) return 1;
    return n + g(n - 2);
}

:4 + g(2) = 4 + (2 + g(0)) = 4 + 2 + 1 = 7
★ 注意 g(0):0 ≤ 1,命中出口返回 1。
调用链是 g(4) → g(2) → g(0),共 3 层。这里能看出一个要点:参数不一定每次只减 1, 只要保证「一定会落到出口」就行(n 每次减 2,奇偶两种情况都能到 ≤ 1)。

第 3 题:这个递归有什么问题
int f(int n){
    if(n == 1) return 1;
    return n * f(n - 2);
}

要求算 f(4)。

到不了出口,会栈溢出。
f(4) → f(2) → f(0) → f(−2) → … 参数一路变负,永远碰不到 n == 1。
正确写法是把出口放宽成 if(n <= 1) return 1;,或者把递推改成 f(n-1)
教训:光有出口还不够,得保证「一定会到达」。

第 4 题:为什么这题不能用纯递归

P1028 如果写成纯递归,为什么 n = 1000 一定过不了?请用「递归树节点数」来解释。

:因为递归树上的每个节点都对应一个合法数列,所以节点总数 = 答案。 n = 1000 时答案是 1981471878,也就是要做将近 20 亿次函数调用。
计算机每秒大约能做 108 次函数调用级别的工作,20 亿次至少要几十秒 —— 远超时限。
★ 实测数据也印证了这一点:n = 10 时调用 14 次(答案 14)、n = 50 时调用 786 次(答案 786),两边永远相等。

第 5 题:分治的区间怎么切

仿照 P5461,如果一个正方形的边长是 8,它的四块子正方形的边长是多少?四块的左上角坐标(设大正方形左上角是 (1,1))分别是?

:边长 h = 8/2 = 4
左上 (1, 1)、右上 (1, 5)、左下 (5, 1)、右下 (5, 5)
★ 规律:右 = 列加 h,下 = 行加 h。P5461 里左上那块不用递归,所以只调用 (1,5)、(5,1)、(5,5) 这三块。

速查卡(截图存手机)

【递归三要素】(写之前先默念)
  ① 出口        → if(什么情况) return 直接答案;
  ② 递推关系    → f(n) 怎么由更小的子问题算出
  ③ 参数含义    → 每层参数代表什么,哪一步在变小
  // 忘出口 = 栈溢出;参数不缩小 = 到不了出口

【递归的模板形状】
  返回类型 f(参数){
      if(出口条件) return 出口答案;      // ★ 永远写在最前面
      ...调用 f(更小的参数)...
      return 结果;
  }

【递归求阶乘 / 数字拆位和】
  long long fact(int n){
      if(n <= 1) return 1;
      return (long long)n * fact(n - 1);
  }
  int digitSum(int n){
      if(n == 0) return 0;
      return n % 10 + digitSum(n / 10);
  }

【分治模板(P5461 那种「切四块」)】
  void solve(int x, int y, int size){
      if(size == 1){ ...处理这一个格子...; return; }
      int h = size / 2;
      // 左上块按题意处理(P5461 里是赦免,不用写代码)
      solve(x,     y + h, h);        // 右上
      solve(x + h, y,     h);        // 左下
      solve(x + h, y + h, h);        // 右下
  }

【倒推(P5743 猴子吃桃)】
  知道「今天剩多少」,求「昨天有多少」:
      昨天 = (今天 + 1) * 2
  // 「多吃一个」→ 倒推要先 +1 再 ×2,别搞反

【递推 / 记忆化(P1028)】
  // 递推:从小到大填表,每个值只算一次
  for(x = 1; x <= n; x++){
      f[x] = 1;
      for(i = 1; i <= x/2; i++) f[x] += f[i];
  }
  // 记忆化:递归 + 缓存
  if(vis[x]) return memo[x];
  vis[x] = true; ... return memo[x] = 结果;

【铁律】
  递归第一行永远是出口
  大数组开全局,别开在 main 里
  递归深度上限约几万层(本机实测约 4 万)
  能一眼看出规律的,优先用循环
  答案可能很大的题(P1028)→ long long

明天预告 · Day 17

递推与记忆化搜索

今天你已经提前尝到了甜头:P1028 用纯递归会算 20 亿次,加一个数组记答案就瞬间出结果。

明天会把这件事讲透:递推(从小规模推大规模,用循环填表)和记忆化搜索(递归 + 缓存, 写法和普通递归一样,只在开头加一句判断)。它们就是动态规划(DP)的前身 —— 后面第 25 天的线性 DP,本质上就是在这两个东西上加一层「怎么划分状态」。

明天的题目是 P1464 Function、P1720 月落乌啼算钱、P2437 蜜蜂路线、P1044 栈, 都是「不加记忆化就过不去」的经典题,正好把今天的遗憾补上。

ACM 一个月学习计划 · DAY 17

Day 17 · 递推与记忆化

把「已经算过的答案」记下来别再算第二遍 —— 这是你第一次真正意义上「优化算法」,也是动态规划的前身

今日目标:会写递推式、会给递归加缓存、能说清记忆化和递推的关系
预计用时:约 3.5 小时(含做题)  难度:入门 ~ 普及/提高-
题目来源:洛谷 P1464 / P1720 / P2437 / P1044
DAY 17 / 30

今日时间安排

0 - 25 min搞懂「递推」:从已知推未知(第 1 节)
25 - 45 min看清裸递归为什么慢(第 2 节,有实测数据)
45 - 70 min记忆化模板 + 四个实现细节(第 3、4 节)
70 - 90 min卡特兰数速成(第 6 节,P1044 要用)
90 - 180 min做 4 道题,每题限时 30 分钟
180 - 210 min写题解笔记 + 做自测题
💡 和昨天内容的衔接

Day 16 你学会了「函数自己调用自己」。递归写起来很爽,但有一个致命毛病:同一个子问题会被重复算很多遍

今天要做的事情只有一句话:算过的答案存进数组,下次直接拿。这一招叫「记忆化搜索」,学会它你就摸到了动态规划的门。

1递推:从已知推未知

先看一个不需要任何技巧的例子

爬楼梯:一次只能迈 1 级或 2 级,问爬到第 n 级有多少种走法。

想一下「最后一步」这件事:

  • 要么是从第 n-1 级迈 1 级上来的 → 前面有 dp[n-1] 种走法
  • 要么是从第 n-2 级迈 2 级上来的 → 前面有 dp[n-2] 种走法

两条路互不重叠,加起来就是全部,于是:

🎯 这就是「状态转移方程」

dp[n] = dp[n-1] + dp[n-2]

递推的三要素(每道递推题都逃不出这三样)

要素要回答的问题爬楼梯的例子
状态dp[i] 到底表示什么?爬到第 i 级的方案数
初值最小规模的情况是什么?(手算出来)dp[1] = 1,dp[2] = 2
转移大状态怎么由小状态得到?dp[i] = dp[i-1] + dp[i-2]
⚠️ 顺序不能乱

递推用循环写,循环方向必须保证「算 dp[i] 时它依赖的东西已经算好了」
上面这个式子里 dp[i] 依赖 dp[i-1] 和 dp[i-2],都是更小的下标,所以 i 必须从小到大推。

如果哪天你写出 dp[i] = dp[i+1] + dp[i+2],那就得反过来从大到小推,或者改成递归。

递推和递归的对比

递归递推
写法函数调自己,有栈的开销一个 for 循环,从头推到底
思考方向自顶向下:把大问题拆小自底向上:从小问题堆到大
速度慢(函数调用有代价)
符合直觉吗✅ 更符合人类思路⚠️ 有时要想半天顺序
会不会重复算❌ 会,这是今天的重点✅ 天然不会

今天的策略是:先用递归想清楚转移方程,再用记忆化(或递推)实现它。

2裸递归到底慢在哪(有实测数据)

手画一下 fib(5) 的调用树

还是斐波那契 f(n) = f(n-1) + f(n-2),写一个不带缓存的递归:

long long f(int n){
    if(n <= 1) return n;
    return f(n - 1) + f(n - 2);
}

f(5) 展开看一眼:

f(5)
├── f(4)
│   ├── f(3)
│   │   ├── f(2)          ← 第 1 次算
│   │   │   ├── f(1)
│   │   │   └── f(0)
│   │   └── f(1)
│   └── f(2)              ← 第 2 次算(明明刚算过!)
│       ├── f(1)
│       └── f(0)
└── f(3)                  ← 整个 f(3) 又算一遍!
    ├── f(2)              ← 第 3 次算
    └── f(1)

f(2) 被算了 3 次f(3) 被算了 2 次。n 只有 5 就已经这样了,n 大一点会怎样?

我实际跑出来的调用次数

下面这张表是真跑出来的(fib_experiment.cpp,计数版递归 vs 记忆化):

n答案裸递归调用次数记忆化调用次数差距
105517719约 9 倍
2067652189139约 561 倍
257502524278549约 4955 倍
30832040269253759约 4.6 万倍
4010233415533116028179约 419 万倍
🚨 看清楚这张表的规律

裸递归的调用次数是指数级暴涨的(大约是 2 × Fib(n+1) - 1),n 每加 1 就乘 1.6 倍左右。
n=40 要 3.3 亿次调用,n=50 就是 400 亿次 —— 电脑跑一天也算不完

而记忆化呢?调用次数几乎等于 n(n=40 只要 79 次)。这不是「快一点」,是「从算不完变成瞬间出结果」。

🎯 根因:同一个子问题被反复求解

裸递归把「子问题的答案」算一遍就扔了,下次遇到同样的子问题只能重算。
解法:算完就存起来。这就是今天全部的内容。

3记忆化搜索:递归 + 一个缓存数组

改动只有三行

把上面那个裸递归改成记忆化,对比着看:

// ---------- 裸递归 ----------
long long f(int n){
    if(n <= 1) return n;
    return f(n - 1) + f(n - 2);
}

// ---------- 记忆化搜索 ----------
long long memo[105];                       // ★ 新增:缓存数组

long long f(int n){
    if(memo[n] != -1) return memo[n];      // ★ 新增 1:算过就直接返回
    if(n <= 1) return memo[n] = n;         // ★ 新增 2:边界答案也存进去
    return memo[n] = f(n - 1) + f(n - 2);  // ★ 新增 3:算完立刻存
}

int main(){
    memset(memo, -1, sizeof(memo));        // ★ 别忘了初始化成「没算过」
    ...
}

为什么用 −1 当「没算过」的标记?

标记值能不能用原因
0❌ 不行很多题的答案真的就是 0(比如方案数为 0),你会把「答案是 0」误判成「没算过」,然后无限递归下去
−1✅ 推荐只要答案不可能是负数就没问题(本题答案全是正整数)
INF / 1e18✅ 也行答案不可能到这么大时可以用
⚠️ 这个坑非常隐蔽

写记忆化的时候,if(memo[x] != 0) return memo[x]; 是最常见的错误写法。
如果某道题的正确答案恰好是 0,你就会陷入死循环,或者栈溢出 RE。
记住:哨兵值一定要选一个「合法答案不可能取到」的值。

💡 「初始化」这一步不能省

全局数组默认是 0,不是 −1。所以必须在 main 里显式地把整个 memo 刷成 −1:

memset(memo, -1, sizeof(memo));

另一个办法是写个循环 for(int i=0;i<N;i++) memo[i] = -1;,效果一样。

如果你不想每次手动初始化,可以把含义反过来:memo 全 0 表示「没算过」,用一个 bool vis[] 单独记「算过没有」。这样连 memset 都省了(全局数组天然是 0)。

记忆化的通用模板(背下来)

类型 f(参数...){
    if(参数越界或非法) return 特殊值;        // 先处理边界
    if(memo[参数] != -1) return memo[参数];  // ★ 查缓存
    ...用递归算出 res...
    return memo[参数] = res;                 // ★ 存缓存
}
🎯 一句话记住

记忆化 = 递归的写法 + 数组的速度。

它保留了「把大问题拆成小问题」这种最符合直觉的思考方式,又把重复计算彻底消灭掉。

4记忆化的四个实现细节(今天的题全踩得到)

细节 1:数组要开多大?

数组大小 = 状态空间的大小,也就是「参数的所有可能取值组合数」。

参数长什么样用什么数组大小怎么算
一个参数,范围 0 ~ n一维 memo[n+5]n+5
两个参数,范围 0 ~ n二维 memo[n+5][n+5](n+5)²
三个参数,范围 0 ~ 20三维 memo[21][21][21]21³ = 9261,很小
💡 多维数组的写法

long long memo[21][21][21]; 声明在全局(函数外面),这样自动全 0,而且不受栈空间限制。
如果写在函数里面,21³ 个 long long ≈ 74 KB 还撑得住,但再大一点(比如 100³)就会爆栈 RE。养成写全局的习惯。

细节 2:参数越界要先「夹」到合法范围

这是今天 P1464 的核心套路。题目说「a、b、c 只要有一个超过 20,就当作 (20,20,20) 处理」。

那数组根本不需要开到 10⁹ —— 因为超过 20 的参数永远不会真的去查数组,只会被打包成 (20,20,20):

if(a > 20 || b > 20 || c > 20) return w(20, 20, 20);   // ★ 先截断,再查数组
if(memo[a][b][c] != -1) return memo[a][b][c];        // 这里 a,b,c 保证在 1~20
⚠️ 顺序颠倒就是 RE

如果先查数组 memo[a][b][c] 再判断 a > 20,那输入 1000 1000 1000 时就是数组越界,轻则答案乱,重则直接崩溃。
规则:凡是「参数可能超出数组下标范围」的题,一定要先做边界判断,再做数组访问。

细节 3:返回值类型用 long long

三个理由:

  • 题目给的数据范围可能是 [-2⁶³, 2⁶³-1],用 int 读进来当场就错了(今天的 P1464 就是这样)。
  • 中间过程可能出现「大 − 大」的减法(P1464 的转移式里就有一个减号),long long 更不容易出意外。
  • 答案本身可能超过 21 亿(今天的 P1720 答案最大 4807526976)。
💡 一个判断口诀

只要拿不准,就用 long long。它占 8 字节,多花的那点内存几乎永远不是瓶颈,但省下来的调试时间是实打实的。

细节 4:多组输入的读入方式

P1464 的输入是「若干行,读到 -1 -1 -1 结束」,标准写法:

long long a, b, c;
while(cin >> a >> b >> c){                    // 读到 EOF 自动停
    if(a == -1 && b == -1 && c == -1) break;   // ★ 哨兵行,退出
    cout << "w(" << a << ", " << b << ", " << c << ") = " << w(a, b, c) << "\n";
}
⚠️ 多组输入时别用 endl

endl 会强制刷新缓冲区,输出量一大就慢得离谱。换成 "\n"
P1464 有 10⁵ 行输入输出,这一条能差出几百毫秒。

5同一道题,两种写法

拿爬楼梯举例,转移方程都是 dp[i] = dp[i-1] + dp[i-2],初值都是 dp[1]=1, dp[2]=2

写法 A:记忆化搜索(自顶向下)

long long memo[105];

long long dp(int i){
    if(i == 1) return 1;
    if(i == 2) return 2;
    if(memo[i] != -1) return memo[i];
    return memo[i] = dp(i - 1) + dp(i - 2);
}

写法 B:递推(自底向上)

long long dp[105];
dp[1] = 1;
dp[2] = 2;
for(int i = 3; i <= n; i++){
    dp[i] = dp[i - 1] + dp[i - 2];   // ★ 从前往后推
}
对比项记忆化搜索递推
代码量稍多(要写递归函数)少(一个循环)
要不要想顺序✅ 不用,递归自动处理依赖❌ 要想清楚循环方向
会不会算多余状态只算用得到的全部状态都算一遍
运行速度略慢(函数调用开销)✅ 更快
递归深度风险⚠️ 状态多时会爆栈✅ 没有
🎯 推荐流程

第 1 步:用递归把状态和转移方程想清楚(这一步最贴合人脑)。
第 2 步:加 memo 数组 → 能过题就交。
第 3 步:如果 TLE 或者爆栈,再改写成递推。

今天的 P1464 只能用记忆化(因为它有 3 个参数、规则是分段的,写成递推很别扭), P1720 / P2437 / P1044 都是规规矩矩的递推 —— 正好两种写法各练一遍

6卡特兰数:P1044 背后的数列

先讲题:n 个数进栈,能弹出多少种不同的序列?

n = 3 举例,入栈顺序固定是 1 2 3。可以得到的输出序列有:

1 2 3  1 3 2  2 1 3  2 3 1  3 2 1

一共 5 种(注意 3 1 2 是不可能出现的,因为 3 出来时 1、2 还在栈里,2 一定压在 1 上面,必须先出 2)。

这个数列就是大名鼎鼎的卡特兰数(Catalan number)

n012345678
Cn112514421324291430
n101214161718
Cn16796208012267444035357670129644790477638700

两种算法

算法 A(递推,推荐):把「最后一个出栈的是谁」当作分类标准。设最后一个出栈的数是第 j+1 个进栈的,那么:

  • 它进栈之前有 j 个数先进先出 → 方案数 f[j]
  • 它进栈之后有 i-1-j 个数进进出出 → 方案数 f[i-1-j]
f[0] = 1;
for(int i = 1; i <= n; i++)
    for(int j = 0; j < i; j++)
        f[i] += f[j] * f[i - 1 - j];   // ★ 卡特兰数递推式

算法 B(组合数公式)Cn = C(2n, n) / (n+1),代码只有四行。

long long c = 1;
for(int i = 1; i <= n; i++)
    c = c * (n + i) / i;      // 先算出 C(2n, n)
cout << c / (n + 1) << endl;  // 再除以 n+1
💡 为什么 c = c * (n+i) / i 每步都能整除

因为 c * (n+i) / i 算的其实是 C(n+i, i),而组合数一定是整数,所以中途不会出现小数。
但乘除顺序不能换!写成 c / i * (n+i) 就会因为整数除法丢精度而算错。

📌 卡特兰数还能数什么(知道就行)

n 对括号的合法配对方案数、n 个节点的不同二叉树个数、凸多边形三角剖分数 …… 全是卡特兰数。 以后看到「方案数是 1 1 2 5 14 42」这种开头,基本就是它了。

7四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是你看到的真实结果。

P1464 [PacNW 1999] Function 普及- · 记忆化搜索模板题

题意:给定一个三参数的递归函数 w(a,b,c),按四条规则计算它的值。输入若干组 (a,b,c),每组输出一行 w(a, b, c) = 答案,读到 -1 -1 -1 结束。

四条规则按顺序判断(顺序是关键):

  1. 只要 a ≤ 0b ≤ 0c ≤ 0 → 返回 1
  2. 否则,只要 a > 20b > 20c > 20 → 返回 w(20, 20, 20)
  3. 否则,如果 a < b 并且 b < c → 返回 w(a,b,c-1) + w(a,b-1,c-1) - w(a,b-1,c)
  4. 否则 → 返回 w(a-1,b,c) + w(a-1,b-1,c) + w(a-1,b,c-1) - w(a-1,b-1,c-1)
⚠️ 数据范围(这题最坑的地方)

输入的数在 [-9223372036854775808, 9223372036854775807] 之间,也就是 完整的 long long 范围
输入行数 T ≤ 10⁵

结论 1abc 必须用 long long 读!用 int 读 9223372036854775807 当场就废了。

结论 2:因为规则 2 会把所有大于 20 的参数压到 20,所以 memo 数组只需要 [21][21][21]不需要也不允许按输入值开数组。

🚨 题面特别强调的坑

「例如 w(30,-1,0) 又满足条件 1 又满足条件 2,请按照最上面的条件来算,答案为 1。」

所以代码里 必须先判断 ≤ 0,再判断 > 20。写反了,w(30,-1,0) 就会被当成 w(20,20,20),答案变成 1048576,直接 WA。

思路

这就是一个标准的记忆化搜索。三个参数的范围被规则压到 1~20,状态总数只有 20³ = 8000 个 —— 但因为裸递归的分支是 3~4 路,重复量巨大,不加缓存必 TLE。

加上 memo[a][b][c] 之后,每个状态最多真正计算一次,剩下的全是 O(1) 查表。

样例输入
1 1 1
2 2 2
-1 -1 -1
样例输出
w(1, 1, 1) = 2
w(2, 2, 2) = 4

完整代码

#include <iostream>
#include <cstring>
using namespace std;

// memo[a][b][c] 存已经算过的答案,-1 表示还没算过
// 因为 a,b,c 超过 20 会先被截断到 20,所以只需要开 0..20
long long memo[21][21][21];

long long w(long long a, long long b, long long c){
    // 第 1 条规则:只要有一个 <= 0 就返回 1(必须放在最前面判断)
    if(a <= 0 || b <= 0 || c <= 0) return 1;
    // 第 2 条规则:只要有一个 > 20 就全部截断到 20
    if(a > 20 || b > 20 || c > 20) return w(20, 20, 20);

    // ★ 记忆化的核心:算过就直接取,不再递归
    if(memo[a][b][c] != -1) return memo[a][b][c];

    long long res;
    if(a < b && b < c){
        res = w(a, b, c - 1) + w(a, b - 1, c - 1) - w(a, b - 1, c);
    }else{
        res = w(a - 1, b, c) + w(a - 1, b - 1, c)
            + w(a - 1, b, c - 1) - w(a - 1, b - 1, c - 1);
    }

    memo[a][b][c] = res;   // ★ 存下来,下次 O(1) 取用
    return res;
}

int main(){
    memset(memo, -1, sizeof(memo));   // -1 的二进制是全 1,long long 也适用

    long long a, b, c;                // ★ 必须 long long,输入范围是整个 long long
    while(cin >> a >> b >> c){
        if(a == -1 && b == -1 && c == -1) break;
        cout << "w(" << a << ", " << b << ", " << c << ") = " << w(a, b, c) << "\n";
    }
    return 0;
}
✅ 实测结果

官方样例 → 输出

w(1, 1, 1) = 2
w(2, 2, 2) = 4

完全一致(注意等号两边各有一个空格)

补充边界测试(真实输出):

  • 30 -1 0w(30, -1, 0) = 1 ✅ 验证了「先判 ≤0」这条规则
  • -5 -5 -5w(-5, -5, -5) = 1
  • 21 21 21w(21, 21, 21) = 1048576,和 20 20 201048576 完全相同 ✅ 验证了截断规则
  • 15 15 1532768,单次调用(含进程启动)约 36 ~ 43 ms —— 几乎全是启动开销,真正计算的部分小到测不出来
  • 压力测试:10⁵ 组查询(全部为 20 20 20)→ 输出 10⁵ 行,总耗时 448 ~ 501 ms(三次运行),末行 w(20, 20, 20) = 1048576

易错点

  • 规则顺序写反:先判 >20 再判 ≤0w(30,-1,0) 就错了。必须严格按题面从上到下。
  • 用 int 读输入:输入可能到 ±9.2×10¹⁸,int 直接溢出。必须 long long
  • memo 数组开小了memo[20][20][20] 的下标只到 19,访问 memo[20][20][20] 就越界。要么开 21,要么开 25 留余量。
  • 忘了初始化成 −1:全局数组默认是 0,那么「没算过」和「答案是 0」就分不清了。
  • 输出格式:必须是 w(a, b, c) = ans,逗号后面有空格、等号两边有空格,一个都不能少。
  • 用 endl 换行:10⁵ 行输出会慢很多,用 "\n"
💡 关于 long long 结果类型的一点诚实说明

这题的最大答案其实只有 1048576(= 2²⁰),int 也放得下,中间也不会溢出。
但我们仍然统一用 long long:一是输入本身必须用 long long 读,二是「不确定就用 long long」是一条不需要思考的好习惯。 这不是必须,但是推荐。

P1720 月落乌啼算钱(斐波那契数列) 入门 · 递推 + 精度

题意:题面给了一个看起来很吓人的公式

Fn = [ ((1+√5)/2)n − ((1−√5)/2)n ] / √5

让你输入 n,输出 Fn,保留两位小数。

🎯 关键洞察:这个公式就是斐波那契通项公式

它有个名字叫比内公式。算出来的 Fn 恰好就是斐波那契数列的第 n 项:

F0=0, F1=1, F2=1, F3=2, F4=3, F5=5, F6=8 …

样例输入 6 输出 8.00 —— 正是 F6 = 8。所以不用碰 sqrt 和 pow,老老实实递推就行。

⚠️ 数据范围与溢出红线

对于所有数据:0 ≤ n ≤ 48

F48 = 4807526976,而 int 上限是 2147483647 —— 整整超出一倍多

所以答案必须是 long long。用 int 会在 n ≥ 47 的时候变成负数。

思路

用「滚动两个变量」的递推:只保留 F(i−2) 和 F(i−1),一路往后推。这样连数组都不用开。

最后输出时把 long long 转成 double 用 printf("%.2f") 打两位小数。因为答案本身是整数,所以小数部分永远是 .00

💡 为什么先算整数再转 double,而不是直接用公式算浮点

浮点数(double)有精度误差。(1+√5)/2 本身是个无限不循环小数,n 次方之后再相减、再除以 √5,误差会被放大。
能精确算的整数就不要用浮点算。先用整数递推算出准确值,最后一步再转成浮点输出。

样例输入
6
样例输出
8.00

完整代码

#include <cstdio>
#include <iostream>
using namespace std;

int main(){
    int n;
    cin >> n;

    // F(n) 就是斐波那契数列:F(0)=0, F(1)=1
    // n <= 48 时 F(48) = 4807526976,已经超过 int 上限,必须用 long long
    long long f0 = 0, f1 = 1;   // f0 = F(i-2), f1 = F(i-1)

    if(n == 0){
        printf("0.00\n");
        return 0;
    }

    for(int i = 2; i <= n; i++){
        long long t = f0 + f1;   // F(i) = F(i-1) + F(i-2)
        f0 = f1;
        f1 = t;
    }

    // 题目要求保留两位小数,答案本身是整数,所以小数为 .00
    printf("%.2f\n", (double)f1);
    return 0;
}
✅ 实测结果

官方样例 68.00 完全一致

边界测试(真实输出):

  • 00.00 (F0 = 0,最小值)
  • 472971215073.00 (已经超过 int 上限 2147483647,用 int 必爆)
  • 484807526976.00 (最大值,long long 正确输出)

易错点

  • 用 int 存答案:n = 47 开始就溢出成负数了。这是本题第一号杀手。
  • 被公式吓住:看到 √5 和 n 次方就想去写 pow(sqrt(5), ...),结果浮点误差导致答案差 0.01。直接用斐波那契递推最稳。
  • n = 0 没特判:如果写成「先循环再输出」,n=0 时 f0/f1 的处理容易出错。老老实实写个 if(n == 0)
  • 输出格式:必须保留两位小数,写成 cout << f1 输出 8 是错的,要输出 8.00
P2437 蜜蜂路线 普及- · 递推 + 高精度

题意:蜂房排成一条锯齿形的编号链(1、2、3……),蜜蜂只能从小编号爬到大编号的相邻蜂房。问从蜂房 m 爬到蜂房 n(m < n)有多少条不同路线。

思路:又是一只「兔子」

先看图找规律。仔细观察蜂房的连接方式会发现:编号为 i 的蜂房,只能直接通到 i+1i+2

那么「到达 i 号蜂房的路线数」dp[i],只能从这两个方向来:

  • i-1 迈一步过来 → dp[i-1]
  • i-2 跳两步过来 → dp[i-2]
dp[i] = dp[i - 1] + dp[i - 2]      // 又是斐波那契!

初值:起点 dp[m] = 1(站在起点算 1 种走法),dp[m+1] = 1(只能一步迈过去)。

所以答案就是 F(n − m + 1)(斐波那契的写法是 F(1)=1, F(2)=1)。

⚠️ 数据范围藏着大坑

对于 100% 的数据:1 ≤ m, n ≤ 1000

n − m + 1 最大能到 1000,而 F(1000) 是一个 209 位数

long long 最大只能存到约 9.2×10¹⁸,也就是 F(92) 左右。从 F(93) 开始,long long 必然溢出。

第一个版本:long long 递推(能过样例,但过不了全部数据)

#include <iostream>
using namespace std;

// 蜜蜂只能从小编号爬到相邻的大编号蜂房,
// 所以 dp[i] = dp[i-1] + dp[i-2],这就是斐波那契
// 起点 dp[m] = 1,dp[m+1] = 1,答案就是 dp[n] = F(n-m+1)
int main(){
    int m, n;
    cin >> m >> n;

    long long a = 1, b = 1;   // a = dp[i-2], b = dp[i-1];第一步时两者都是 1

    for(int i = m + 2; i <= n; i++){
        long long t = a + b;
        a = b;
        b = t;
    }

    cout << b << endl;
    return 0;
}
⚠️ 这个版本的实测能力边界(我真实测了)

1 927540113804746346429 ✅ 正确(这是 long long 能表达的最大斐波那契项)
1 93-6246583658587674878 ❌ 溢出成负数了!

用 .NET 的 BigInteger 算出的真值是 12200160415121876738,确实超出了 long long 范围。

所以想把这道题 AC,必须上高精度。(高精度会在 Day 30 系统学,今天先感受一下)

第二个版本:高精度加法(可以 AC)

思路:用一个 vector<int> 从低位到高位存每一位数字,然后手动模拟小学竖式加法。

#include <iostream>
#include <vector>
using namespace std;

// 高精度加法:用 vector 存每一位(下标 0 是最低位),手动处理进位
vector<int> addBig(const vector<int>& A, const vector<int>& B){
    vector<int> C;
    int carry = 0;                            // 进位
    for(size_t i = 0; i < A.size() || i < B.size() || carry; i++){
        int s = carry;
        if(i < A.size()) s += A[i];
        if(i < B.size()) s += B[i];
        C.push_back(s % 10);                  // 本位留下
        carry = s / 10;                       // 进位带走
    }
    return C;
}

int main(){
    int m, n;
    cin >> m >> n;

    vector<int> a(1, 1), b(1, 1);   // a = dp[i-2], b = dp[i-1]

    for(int i = m + 2; i <= n; i++){
        vector<int> t = addBig(a, b);
        a = b;
        b = t;
    }

    // 从最高位往低位输出
    for(int i = (int)b.size() - 1; i >= 0; i--) cout << b[i];
    cout << endl;
    return 0;
}
样例输入
1 14
样例输出
377
✅ 实测结果

官方样例 1 14

  • long long 版 → 377 与样例完全一致
  • 高精度版 → 377 与样例完全一致

高精度版极限测试(真实输出,与 .NET BigInteger 的真值逐位比对完全一致):

1 1000 → 共 209 位
43466557686937456435688527675040625802564660517371780402481729089536555417949051890403879840079255169295922593080322634775209689623239873322471161642996440906533187938298969649928516003704476137795166849228875

(对比:long long 版在 1 93 就已经变成负数了)

易错点

  • 只写 long long 版就交:样例能过,但 n − m + 1 ≥ 93 的测试点全部 WA。这题必须高精度。
  • 初值给错:起点要设为 1(站在起点算一种走法),dp[m+1] 也是 1。都设成 0 会输出 0。
  • 循环边界:循环应该从 m+2 开始到 n 结束。写成从 1 开始推会在 m 很大时出错。
  • 高精度输出方向:vector 里下标 0 是最低位,输出必须从后往前,倒着输出就得到镜像数了。
  • 忘记处理进位s = 10 时要留 0 进 1,只取 s % 10 不更新 carry 会丢位。
💡 今天怎么写这道题?

题目本身只要求你「先感受一下」高精度(Day 30 会系统学)。
建议今天:先用 long long 版把递推关系彻底想明白(这是今天真正的考点),然后把高精度版抄一遍、跑通 209 位的那个测试,知道「原来大数是这样一位一位加的」就够了。

P1044 [NOIP 2003 普及组] 栈 普及/提高- · 卡特兰数

题意:有一个操作数序列 1, 2, …, n,栈 A 的深度大于 n。你可以做两种操作:

  1. 把操作数序列头端的数压进栈(push)
  2. 把栈栈顶的数弹到输出序列尾端(pop)

问:通过任意组合这些操作,一共能得到多少种不同的输出序列?

⚠️ 数据范围

1 ≤ n ≤ 18。n = 18 时答案是 477638700,没有超过 int,但已经接近 5 亿。
中间算乘法时 f[j] * f[i-1-j] 也可能很大,统一用 long long

思路:按「最后一个出栈的是谁」分类

f[i] 表示「i 个数进栈能得到的输出序列数」。现在来想 i 个数的情况。

不管过程怎么乱,最后出栈的那一个数一定是某个特定的数。假设它是第 j+1 个进栈的 (j 从 0 到 i−1),那么整个过程被它切成互不干扰的两段:

  • 它进栈之前,已经有 j 个数进栈并且全部先出去了 → 有 f[j]
  • 它进栈之后,剩下的 i−1−j 个数进进出出,最后才轮到它 → 有 f[i-1-j]

两段相互独立,乘起来;j 的所有取值加起来,就是全部:

🎯 卡特兰数递推式

f[0] = 1, f[i] = Σj=0..i−1 f[j] × f[i−1−j]

手算一遍前几项验证一下:f[1]=1, f[2]=2, f[3]=5 —— 和题目样例 n=3 → 5 对上了。

样例输入
3
样例输出
5

完整代码

#include <iostream>
using namespace std;

// 卡特兰数:f[i] = Σ(j=0..i-1) f[j] * f[i-1-j],边界 f[0] = 1
// n <= 18 时 Catalan(18) = 477638700,用 long long 保险
int main(){
    int n;
    cin >> n;

    long long f[25] = {0};
    f[0] = 1;                       // ★ 空序列算 1 种,这是递推的种子

    for(int i = 1; i <= n; i++){
        for(int j = 0; j < i; j++){
            f[i] += f[j] * f[i - 1 - j];   // ★ 两段方案数相乘
        }
    }

    cout << f[n] << endl;
    return 0;
}
✅ 实测结果

官方样例 35 完全一致

补充测试(真实输出):1122101679618477638700(最大值,long long 正确容纳)

我还用另一种算法(组合数公式 C(2n,n)/(n+1))独立跑了一遍:

long long c = 1;
for(int i = 1; i <= n; i++)
    c = c * (n + i) / i;      // 先算出 C(2n, n)
cout << c / (n + 1) << endl;  // 再除以 n+1

输出 1 / 2 / 5 / 16796 / 477638700与递推版逐个一致。两种算法互相印证,说明结果是对的。

易错点

  • f[0] 忘了置 1:这样 f[1] 算出来就是 0,后面全废。空序列(一个数都不进)也算 1 种方案。
  • 双重循环边界写错:内层是 j < i(不是 j <= i),因为是 i−1−j,j 最大只能到 i−1。
  • 用 int 存 f 数组:虽然 n=18 的答案 477638700 刚好塞得下 int,但习惯上用 long long 更稳(很多卡特兰数题 n 稍大就爆)。
  • 越界f[25] 够用(n ≤ 18),但注意别写成 f[20] 再访问 f[18] —— 那正好卡在边界上,容易出错。
  • 组合数公式版乘除顺序颠倒:必须 * 在前 / 在后,写成 c / i * (n+i) 会丢精度。

今日常见错误汇总

#错误写法后果正确写法
1memo 用 0 当「没算过」答案是 0 的状态会被反复重算,甚至死循环用 −1 当哨兵
2忘了 memset(memo,-1,...)全局数组全是 0,等价于「所有状态都算过了」,答案直接错main 开头初始化
3先查数组再判边界memo[1000][1000][1000] 越界 → RE先判边界再查数组
4规则顺序写反(P1464)w(30,-1,0) 答案错严格按题面从上到下判
5用 int 读大数 / 存答案溢出变成负数(P1720 的 n≥47、P1464 的输入)一律 long long
6多维 memo 开在函数里数组太大 → 爆栈 RE声明为全局数组
7输出用 endl(多组数据)10⁵ 行输出明显变慢"\n"
8P2437 只用 long longn−m+1≥93 的测试点全 WA必须高精度加法
9卡特兰数忘设 f[0]=1全表算出来都是 0空方案数 = 1
10浮点公式硬算 P1720精度误差导致末位差 0.01整数递推 + 最后转 double

自测:你真的会了吗

第 1 题:为什么记忆化用 −1 当「没算过」的标记,而不是 0?

:因为很多题的合法答案就是 0(例如「方案数为 0」「差值为 0」)。如果用 0 当哨兵, 程序会把「答案是 0」误判成「还没算过」,于是又递归下去算一遍,形成无限递归 → 栈溢出 RE

所以哨兵必须选一个「合法答案不可能取到」的值。本题答案都是正整数,用 −1 就安全。

第 2 题:P1464 里 memo 数组为什么开 [21][21][21] 就够了?

:因为规则 2 规定「只要有一个参数 > 20,就返回 w(20,20,20)」—— 所有超出 20 的参数在进入数组访问之前就被截断成 20了。

规则 1 又保证 ≤ 0 的参数直接返回 1、不进数组。所以实际会被当作下标使用的值只有 1 ~ 20, 开 21 个(下标 0~20)刚好够。开 25 留点余量更保险。

第 3 题:把 w(a,b,c) 的规则 1 和规则 2 调换顺序会怎样?

:输入 30 -1 0 时,先判 > 20 会命中(30 > 20),于是返回 w(20,20,20) = 1048576; 而正确顺序应该先命中 b ≤ 0,返回 1答案从一个正确的 1 变成了错误的 1048576。

这就是题面特意写「请按照最上面的条件来算」的原因。

第 4 题:P1044 的 n=4 答案是多少?(自己手算)

:14。

用递推式算:f[0]=1, f[1]=1, f[2]=2, f[3]=5
f[4] = f[0]·f[3] + f[1]·f[2] + f[2]·f[1] + f[3]·f[0]
     = 1×5 + 1×2 + 2×1 + 5×1 = 14 ✅(和卡特兰数列 1,1,2,5,14 对得上)

第 5 题(手写代码):给一个裸递归函数加记忆化

题目:g(n) = g(n-1) + 2*g(n-3)g(0)=1, g(1)=1, g(2)=2,n ≤ 1000。写出记忆化版本。

long long memo[1005];

long long g(int n){
    if(n == 0) return 1;
    if(n == 1) return 1;
    if(n == 2) return 2;
    if(memo[n] != -1) return memo[n];          // ★ 查缓存
    return memo[n] = g(n-1) + 2 * g(n-3);      // ★ 算完就存
}

int main(){
    memset(memo, -1, sizeof(memo));            // ★ 别忘了初始化
    ...
}

★ 注意边界(n=0/1/2)必须在查缓存之后或同时处理好。这里把边界写在查缓存之前也没问题, 因为边界状态不参与缓存,代价是每次都要多判两下 —— 想更规范可以写成 if(memo[n]!=-1) return memo[n]; 放在最前面, 再让边界也走 return memo[n] = ...

今日速查卡(截图存手机)

【递推三要素】
  1. 状态:dp[i] 表示什么?
  2. 初值:最小规模手算出来
  3. 转移:dp[i] = ?(循环方向要保证依赖已算好)

【记忆化搜索模板】
  类型 f(参数){
      if(边界) return 边界值;
      if(memo[参数] != -1) return memo[参数];   // ★ 查缓存
      res = ...递归...
      return memo[参数] = res;                  // ★ 存缓存
  }
  main 里: memset(memo, -1, sizeof(memo));

【记忆化四条铁律】
  1. 哨兵值选合法答案取不到的值(推荐 -1)
  2. 先判边界 / 先截断,再访问数组
  3. 返回值一律 long long
  4. memo 一律声明成全局数组

【斐波那契滚动写法】
  long long a = 1, b = 1;         // dp[i-2], dp[i-1]
  for(int i = m + 2; i <= n; i++){
      long long t = a + b;
      a = b;  b = t;
  }
  // 答案 = b = F(n - m + 1)

【卡特兰数】
  f[0] = 1;
  for(i = 1..n) for(j = 0..i-1) f[i] += f[j] * f[i-1-j];
  // 或:c = C(2n,n);  答案 = c / (n+1)
  // 前几项:1 1 2 5 14 42 132 429 1430 4862

【P1464 规则顺序(不能调换)】
  1) a<=0 || b<=0 || c<=0  →  1
  2) a>20 || b>20 || c>20  →  w(20,20,20)
  3) a<b && b<c  →  w(a,b,c-1)+w(a,b-1,c-1)-w(a,b-1,c)
  4) 否则        →  w(a-1,b,c)+w(a-1,b-1,c)+w(a-1,b,c-1)-w(a-1,b-1,c-1)

【溢出红线】
  int      上限 2147483647     (F(47)=2971215073 已超)
  long long 上限 约 9.2e18     (F(93) 已超)
  答案可能很大 → 高精度(Day 30)

明天预告 · Day 18

DFS 入门:枚举所有方案

今天你学会了「把算过的答案记下来」。明天要换个方向:当答案不是一个数、而是一大堆具体方案时怎么办?

比如「输出 1~n 的所有全排列」「从 n 个数里选 k 个的所有组合」—— 这类题要用深度优先搜索(DFS), 也就是「一条路走到黑,走不通就退回来换一条路」。

明天会学到三个框架:选/不选下标递增避免重复的组合带访问标记的排列。 今天记忆化里那句「算过就存、下次直接拿」的思想,其实在 DFS 的剪枝里也会再遇到一次。

小提醒:明天的 P1157 和 P1706 都会用到 <iomanip> 里的 setw() 控制输出宽度, 今天有空可以先瞄一眼这个函数。

ACM 一个月学习计划 · DAY 18

Day 18 · DFS 入门:枚举所有方案

一条路走到黑,走不通就退回来换一条路 —— 今天学会「把答案一个一个试出来」

今日目标:能独立写出「选或不选」「组合」「全排列」三类搜索框架
预计用时:约 3.5 小时(含做题)  难度:普及-
题目来源:洛谷 P1157 / P1706 / P1036 / P2404
DAY 18 / 30

今日时间安排

0 - 25 min理解 DFS 是「走迷宫」(第 1 节)
25 - 40 min背下搜索三要素:状态 / 选择 / 边界(第 2 节)
40 - 70 min两个核心框架:选或不选、下标递增(第 3、4 节)
70 - 90 min排列框架 + 回溯的对称性(第 5、6 节)
90 - 180 min做 4 道题,每题限时 30 分钟
180 - 210 min写题解笔记 + 做自测题
💡 和昨天内容的衔接

昨天你学的是「求出一个数」—— 方案总数、最大价值等等。今天要面对另一种需求:把每一个具体方案都列出来

比如「输出 1 到 n 的所有全排列」「从 n 个数中选 k 个的所有选法」。这类问题答案本身就是一大堆序列, 没法用一个公式算出来,只能一个一个搜

好消息是:你昨天写的递归,就是 DFS 的全部语法基础。今天只是换个用法。

1DFS 到底是什么:走迷宫

一个所有人都能懂的场景

你站在一个迷宫入口,目标是从出口出去。你手上没有地图,怎么办?

最朴素也最有效的办法是:

  1. 先随便挑一条岔路,一直往前走
  2. 走到死胡同了?原路退回到最近的那个岔路口
  3. 在那个岔路口换一条还没走过的路,继续走
  4. 重复上面三步,直到走到出口(或者所有路都试完)
🎯 这就是深度优先搜索(DFS)

一条路走到黑,走不通就退回来换路。

「退回来换路」这个动作有个正式名字,叫 回溯(backtracking)

DFS 和普通递归的区别在哪

普通递归(比如昨天的斐波那契)DFS 搜索
目的算出一个数值结果枚举出一大堆具体方案
分支数固定(比如 2 个)每层不一样,靠 for 循环枚举
有没有 for通常没有必须有,这是最大区别
返回值返回一个值通常是 void,边走边输出/边统计
要不要撤销不需要⚠️ 需要(这就是回溯)
⚠️ 一句话辨认 DFS 题

题干里出现这些词,基本就是 DFS:

「输出所有……」「求出所有方案」「有多少种不同的选法」「按字典序输出全部……」

DFS 的代码骨架长这样

void dfs(参数){
    if(到达边界 / 目标){        // ① 边界:该停了
        记录 / 输出答案;
        return;
    }
    for(枚举这一层所有可能的选择){   // ② 选择:下一步能做什么
        if(这个选择不合法) continue;   // 剪枝
        做出选择;                     // 修改状态
        dfs(更新后的参数);             // ③ 递归:进入下一层
        撤销选择;                     // ★ 回溯:恢复状态
    }
}

就这十几行,今天 4 道题全是它的变体。请你现在就把这个骨架抄到笔记本上。

2写搜索先想清楚三件事

新手写 DFS 最容易卡在「不知道参数该传什么」。其实只要按顺序回答下面三个问题,参数自然就出来了。

要素要回答的问题通常变成什么
状态「我现在走到哪一步了?」一个 step / pos 参数
选择「下一步我能做什么?」一层 for 循环
边界「什么时候算完成?什么时候死了?」开头的 if ... return;

用 P1706 全排列举个例子

要素在这道题里是什么
状态「已经填好了前 step-1 个位置,现在要填第 step 个」
选择「第 step 个位置可以填 1~n 里还没被用过的任意一个数」
边界step > n,说明 n 个位置都填满了 → 输出这个排列,return」

想清楚这三条,代码几乎是自动写出来的:

void dfs(int step){                 // 状态:当前填到第 step 位
    if(step > n){ 输出; return; }    // 边界
    for(int i = 1; i <= n; i++){     // 选择:这一位填 i
        if(!vis[i]){ ... }
    }
}
🎯 一个万能自问法

「我站在第 step 层,手里有哪些信息,下一步有几种走法,走到哪算完?」

把这四个问题用中文答一遍,写下来,然后逐条翻译成代码 —— 比对着空屏幕发呆有效一百倍。

⚠️ 参数太多怎么办

如果状态很多,参数列表会变得很长(比如 dfs(int step, int sum, int last, int cnt))。这是正常的,别怕。

另一种做法是把这些变量都写成全局变量,dfs 就不需要传参了。两种写法都可以,新手先用「需要什么就传什么」更清楚。

3框架 A:选或不选(最本质的一种搜索)

思想:对每一个元素,只有两个决定

假设有 n 个物品,要从中挑出一些。站在第 idx 个物品面前,你只有两种选择:

  • 选它 → 进入下一层,idx + 1,代价/贡献加上它
  • 不选它 → 进入下一层,idx + 1,什么都不加

所以整个搜索树是一棵二叉树:每一层分成两支,一共 2n 个叶子,每个叶子对应一种子集。

模板(这个必须背下来)

void dfs(int idx, 其余状态){
    if(idx > n){          // 所有元素都考虑完了
        处理答案;
        return;
    }
    // 分支 1:选第 idx 个
    dfs(idx + 1, 状态加上 idx 的贡献);

    // 分支 2:不选第 idx 个
    dfs(idx + 1, 状态不变);
}
💡 为什么它这么重要

「选或不选」是搜索里最基础的思想。后面的0/1 背包(Day 26)本质上就是给这个搜索加了个记忆化, 子集枚举、折半搜索、状态压缩全是它的变体。

你今天写的 P1036 就是最标准的「选或不选」+ 一点剪枝。

「选或不选」的两个常见变体

变体长什么样什么时候用
纯二分叉dfs(idx+1, ...) 写两遍元素个数 ≤ 20 左右(220 ≈ 100 万)
带剪枝的二分叉进函数先判断「还有没有救」能提前排除大量无效分支时
⚠️ 规模必须估算

2n 增长极快:n=20 是 100 万(能跑),n=25 是 3300 万(悬),n=30 是 10 亿(必 TLE)。

写搜索之前先算一下最坏情况有多少个叶子节点,超过 10⁷ 就要考虑剪枝或者换算法。

4框架 B:组合搜索 —— 用「下标递增」杜绝重复

这是今天最重要的一节,P1157 和 P2404 都靠它。

先看问题:为什么「选或不选」不够用

题目要求「从 1~5 里选出 3 个数」,输出所有这些 3 个数的组合。正确答案是 10 种

123 124 125 134 135 145 234 235 245 345

注意:123321213同一个组合(题目说「不分顺序」),只能算一次。

🚨 如果直接照搬「全排列」的写法会怎样

你会输出 5×4×3 = 60 行,其中每种组合被重复输出了 3! = 6 次。
比如 1 2 31 3 22 1 32 3 13 1 23 2 1 —— 六行全都是同一个组合。

答案正好多了 6 倍,全错。

解法:强制「后面选的数一定比前面的大」

关键洞察是:对每一种组合,我们约定只按「从小到大」这一种顺序来生成它。

1 2 3 会被生成,3 2 1 就不会 —— 因为它不满足递增。每种组合恰好只被生成一次。

实现方法:给 dfs 多加一个参数 start,表示「这一位最小能填几」。下一层递归时传 i + 1

void dfs(int step, int start){
    if(step == r){ 输出; return; }
    for(int i = start; i <= n; i++){     // 从 start 开始,不许回头
        a[step] = i;
        dfs(step + 1, i + 1);            // ★ 关键:下一层从 i+1 起
    }
}
🎯 一句话记住这个框架

「这一位从 start 开始试,下一位从 i+1 开始。」

start 就像一条「只能往右走」的边界线,每次选择都把它往右推一格,这样永远不可能出现「回头选」的情况, 重复方案就被从源头上消灭了。

画一棵搜索树看看(n=5, r=3)

dfs(step=0, start=1)
│  这一位从 1 开始试
├── 放 1 → dfs(step=1, start=2)
│   ├── 放 2 → dfs(step=2, start=3)
│   │   ├── 放 3 → 输出 1 2 3
│   │   ├── 放 4 → 输出 1 2 4
│   │   └── 放 5 → 输出 1 2 5
│   ├── 放 3 → dfs(step=2, start=4)
│   │   ├── 放 4 → 输出 1 3 4
│   │   └── 放 5 → 输出 1 3 5
│   └── 放 4 → dfs(step=2, start=5)
│       └── 放 5 → 输出 1 4 5
├── 放 2 → dfs(step=1, start=3)
│   ├── 放 3 → ... 输出 2 3 4、2 3 5
│   └── 放 4 → ... 输出 2 4 5
├── 放 3 → ... 输出 3 4 5
├── 放 4 → dfs(step=1, start=5),第 3 位没得选,自然停住
└── 放 5 → 直接停住

数一下叶子:3 + 2 + 1 + 2 + 1 + 1 = 10 个,正好是 C(5,3) = 10一个不多一个不少。

✅ 顺便白送一个好处:天然字典序

因为每一位都是从小到大试的,所以输出顺序天然就是字典序(1 2 31 2 41 2 51 3 4 ……)。

题目一般都会要求「按字典序输出」,用这个框架就自动满足了,不需要额外排序。

同一个框架换个用法:P2404 自然数拆分

P2404 要求把 n 拆成若干个加数之和,加数要「从小到大排序」,且不能出现重复的拆分。

你会发现思路一模一样,只是把「选第几个数」换成了「这一位填多大的加数」:

P1157 组合P2404 拆分
要填多少位固定 r 位位数不定(凑够 n 为止)
这一位填什么一个下标 i一个加数的大小 i
边界step == rremain == 0
避免重复dfs(step+1, i+1)dfs(pos+1, remain-i, i) ← 注意是 i 不是 i+1
⚠️ 唯一要动脑筋的地方:传 i 还是 i+1

P1157 传 i+1:因为组合里同一个数不能重复出现(选过 3 就不能再选 3)。

P2404 传 i:因为拆分里同一个加数可以重复出现1+1+2 里的两个 1 是允许的)。

判断标准只有一条:题目允许同一个值被重复选用吗?允许就传 i(保持「不小于」),不允许就传 i+1(强制「严格递增」)。

5框架 C:排列搜索 —— 用 vis 标记防止重复用同一个数

组合和排列的区别

组合排列
顺序重要吗不重要(123 = 321)重要(123 ≠ 321)
防重复靠什么start 下标递增vis[] 标记已用
方案数(n 个数选 r 个)C(n, r)A(n, r) = n!/(n−r)!
例子(n=3, r=3)1 种6 种

模板

bool vis[N];        // vis[i] = true 表示数字 i 已经被用掉了

void dfs(int step){
    if(step > n){                    // 边界:n 个位置都填好了
        输出 a[1..n];
        return;
    }
    for(int i = 1; i <= n; i++){
        if(vis[i]) continue;         // ★ 用过了就跳过
        vis[i] = true;               // ① 进入时标记
        a[step] = i;
        dfs(step + 1);
        vis[i] = false;              // ② 退出时撤销(回溯)
    }
}
🎯 回溯的本质:进入时标记,退出时撤销

vis[i] = true;vis[i] = false; 这两行必须成对出现,中间夹着 dfs 调用

脑子里可以这样念:
「我要用 i 了 → 占住它 → 让下面的层接着走 → 下面的层走完了,我必须把 i 还回去,因为我要去试别的 i 了。」

如果忘了撤销,会怎样?第一个分支(比如 1 2 3)走完之后,vis[1]、vis[2]、vis[3] 全还是 true, 回到最外层想试 2 ... 的时候发现 2 已经被「占用」了,于是后面所有方案全部丢失, 最终只能输出 1 行。

画一遍 n=3 的搜索树(对照着敲一遍)

dfs(1)                       vis 全 false
├── i=1: vis[1]=true → dfs(2)
│   ├── i=2: vis[2]=true → dfs(3)
│   │   └── i=3: vis[3]=true → dfs(4) → 输出 1 2 3
│   │       (返回后 vis[3]=false)
│   │   (返回后 vis[2]=false)
│   └── i=3: vis[3]=true → dfs(3)
│       └── i=2: vis[2]=true → dfs(4) → 输出 1 3 2
│           (返回后 vis[2]=false)
│   (返回后 vis[1]=false)   ← ★ 把 1 还回去,才能开始试 2 开头
├── i=2: vis[2]=true → dfs(2)
│   ├── i=1 → 输出 2 1 3
│   └── i=3 → 输出 2 3 1
└── i=3: vis[3]=true → dfs(2)
    ├── i=1 → 输出 3 1 2
    └── i=2 → 输出 3 2 1

6 个叶子 = 3! = 6,且输出顺序天然是字典序。

6剪枝与复杂度估算

先学会估规模(这一步比写代码重要)

搜索类型方案数n=10n=15n=20n=25
子集(选/不选)2n102432768104857633554432
组合 C(n, n/2)C(n,n/2)25264351847565200300
全排列n!36288001.3×10¹²2.4×10¹⁸爆表
⚠️ 一条经验红线

一般 OJ 每秒能跑 10⁷ ~ 10⁸ 次简单操作。

所以:子集搜索 n ≤ 20 左右可以裸搜;全排列只能到 n ≤ 9~10。今天 4 道题的数据范围正好都卡在这个界限内 —— 出题人是算好了的。

剪枝:提前判断「这条路没救了」

剪枝就是「在还没走到边界的时候,就发现这条路不可能产生答案,于是立刻 return」。

P1036 里我用了这样一个剪枝:

if(n - idx + 1 < k - chosen) return;   // 剩下的数不够选了,直接放弃

翻译成人话:从第 idx 个到第 n 个一共还剩 n-idx+1 个数, 而我还需要再选 k-chosen 个。如果剩下的数不够选,那这条路必然失败,不用再往下走了。

💡 常见的三种剪枝思路

可行性剪枝:这条路走下去不够 / 超出了(比如上面这个)。
最优性剪枝:即使走到底也不可能比当前找到的最优解更好,直接放弃。
顺序剪枝:先试「更可能有解」的分支,让最优性剪枝更早生效。

今天只需要掌握 ①,② 会在 Day 19 学。

🚫 剪枝千万别剪过头

剪枝的判断条件如果写得太激进,会把本来有解的方案也剪掉,结果答案偏小。

写完剪枝后,一定要用最小规模的数据手算对一遍,确认没剪错。

7四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是你看到的真实结果。

P1157 组合的输出 普及- · 组合搜索框架

题意:从 1, 2, …, n 里任取 r 个数,输出所有组合。每个组合占一行、元素按从小到大排列、每个数字占 3 个字符宽度,所有组合按字典序输出。

⚠️ 数据范围

1 < n < 210 ≤ r ≤ n

n 最大 20、r 取 10 时方案数是 C(20,10) = 184756 —— 这是最大的输出量, 10 万行级别的输出,别用 endl

思路

就是第 4 节讲的「下标递增」框架,一个字都不用改:

  • 状态step(当前填第几位,从 0 开始)+ start(这一位最小能填几)
  • 选择:这一位填 start ~ n 里的任意一个
  • 边界step == r → 输出这一行,return
💡 输出宽度用 setw()

题目明确要求「每个数字需要 3 个场宽」,并提示用 cout << setw(3) << x;

别忘了 #include <iomanip> —— 不包含这个头文件,setw 会直接编译错误。

还有一个细节:setw 只对紧接着的下一次输出生效,所以每输出一个数都要写一次 setw(3)

样例输入
5 3
样例输出
  1  2  3
  1  2  4
  1  2  5
  1  3  4
  1  3  5
  1  4  5
  2  3  4
  2  3  5
  2  4  5
  3  4  5

说明:题面样例在网上流传的纯文本版本里,第一行的前导空格常被吃掉。 但题目明确要求「每个元素占三个字符的位置」,所以每一行开头都有两个空格(个位数占不满 3 格,前面补空格)。

完整代码

#include <iostream>
#include <iomanip>
using namespace std;

int n, r;
int a[25];      // 存放当前正在拼的组合

// step:现在要填第几个数(从 0 开始)
// start:这一位最小能填几(★ 保证组合内部严格递增,从而不重复)
void dfs(int step, int start){
    if(step == r){                       // 边界:已经选满 r 个数
        for(int i = 0; i < r; i++) cout << setw(3) << a[i];
        cout << "\n";
        return;
    }
    for(int i = start; i <= n; i++){     // 只能从 start 往后选
        a[step] = i;
        dfs(step + 1, i + 1);            // ★ 下一层从 i+1 开始,不许回头
    }
}

int main(){
    cin >> n >> r;
    dfs(0, 1);
    return 0;
}
✅ 实测结果

官方样例 5 3 → 输出 10 行,第一行 [ 1 2 3],最后一行 [ 3 4 5], 与上面的样例输出逐行完全一致(包括每行开头的两个空格)。

补充测试(真实输出):

  • 20 10184756 行,正好等于 C(20,10) = 184756一个不重一个不漏
    首行 [ 1 2 3 4 5 6 7 8 9 10],末行 [ 11 12 13 14 15 16 17 18 19 20],字典序正确。
  • 5 0 → 输出 1 行空行(r = 0 的空组合)。这是题面允许 r = 0 时程序的自然行为; 洛谷实际测评对 r = 0 的期望输出我无法离线确认(我没有洛谷账号,没法提交验证),好在正式比赛数据里几乎不会出现 r = 0

易错点

  • 忘了 #include <iomanip>setw 编译不过。
  • dfs(step+1, i+1) 误写成 dfs(step+1, i):会出现 1 1 11 2 2 这种非法组合, 而且方案数暴涨。
  • start 传错成 1:等价于全排列,每种组合重复 3! 倍。
  • 边界写成 step == n:应该是 step == r(选够 r 个就停,不是填满 n 个位置)。
  • endl:18 万行输出时会慢一截,用 "\n"
  • 数组开小了:r 最大 20,a[25] 够用,但别只开 a[20]
P1706 全排列问题 普及- · 排列搜索 + 回溯

题意:按字典序输出 1 ~ n 的所有不重复排列,每行一个序列,每个数字占 5 个字符宽度。

⚠️ 数据范围

1 ≤ n ≤ 9

为什么只能到 9?因为 9! = 362880 行输出已经是极限了,而 10! = 3628800 行, 再加上每行 5 个场宽,输出文件会大到 OJ 不收。数据范围本身就是「搜索规模」的提示。

思路

用第 5 节的排列框架。关键是那个 vis 数组:它记录的不是「用了几个」,而是「具体哪一个被用了」

为什么不能用 start 递增来防重复?因为排列里顺序是有意义的:1 2 32 1 3 都是合法答案。 所以「不许回头」这条规则在这里是错的,只能靠标记「这个数用过了没有」。

样例输入
3
样例输出
    1    2    3
    1    3    2
    2    1    3
    2    3    1
    3    1    2
    3    2    1

同上:每行开头有 4 个空格(个位数占 5 格宽度的补位)。

完整代码

#include <iostream>
#include <iomanip>
using namespace std;

int n;
int a[15];          // 当前排列
bool vis[15];       // vis[i] = true 表示数字 i 已经用过了

void dfs(int step){
    if(step > n){                       // 边界:n 个位置都填好了
        for(int i = 1; i <= n; i++) cout << setw(5) << a[i];
        cout << "\n";
        return;
    }
    for(int i = 1; i <= n; i++){        // 从小到大试每一个还没用过的数
        if(!vis[i]){
            vis[i] = true;              // 进入时标记
            a[step] = i;
            dfs(step + 1);
            vis[i] = false;             // ★ 退出时撤销(回溯)
        }
    }
}

int main(){
    cin >> n;
    dfs(1);
    return 0;
}
✅ 实测结果

官方样例 3 → 输出 6 行,第一行 [ 1 2 3],最后一行 [ 3 2 1], 与样例输出逐行完全一致

补充测试(真实输出):9362880 行,正好等于 9! = 362880, 首行 [ 1 2 3 4 5 6 7 8 9],末行 [ 9 8 7 6 5 4 3 2 1]。 行数正确 + 首末行是字典序的两个端点,说明既没漏也没重。

易错点

  • 忘了写 vis[i] = false;:这是全排列题的头号杀手。忘了撤销,n=3 时只会输出 1 行 1 2 3,其余全丢。
  • 用了 start 递增那套:那是组合的套路。排列里 2 1 3 是合法答案,用 start 会把一半方案砍掉。
  • 边界写成 step == n:那最后一个位置就没填。要用 step > n,搭配从 dfs(1) 开始。
  • 输出宽度写错:题目要 5 个场宽,写成 3 或 4 都会 PE(格式错误)。
  • 数组开小了a[15]vis[15] 对应 n ≤ 9,注意 vis 下标到 n 为止,n=9 时刚好够;如果题目 n 到 10 就要开到 11 以上。
  • 忘了 #include <iomanip>
💡 一个重要观察:vis 是「全局共享」的

所有层共用同一个 vis 数组,这正是它能表达「哪些数已经被用了」的原因。 但反过来说,每层结束时必须把自己改的东西改回去,否则会污染兄弟分支 —— 这就是「回溯」存在的理由。

P1036 [NOIP 2002 普及组] 选数 普及- · 选或不选 + 素数判断

题意:给出 n 个整数和整数 k(k < n)。从 n 个数里任选 k 个相加, 能得到一堆和。问:这些和里面,素数有多少个

例如 n=4, k=3, 数是 3 7 12 19,四种选法的和分别是 22、29、38、34, 只有 29 是素数,所以答案是 1。

⚠️ 数据范围(两个都重要)

1 ≤ n ≤ 20k < n1 ≤ xi ≤ 5×10⁶

由 n ≤ 20 可以算出方案数:最多 C(20,10) = 184756 种,裸搜完全没问题(这正是题目把 n 卡在 20 的原因)。

由 xi ≤ 5×10⁶ 可以算出和的上限:最大 20 × 5×10⁶ = 10⁸, 所以「和」要存在 int 里没问题,但素性判断的循环变量建议用 long long(用 d*d <= v 判断时 d 的平方不会溢出)。

思路

这是最标准的「选或不选」框架,加一个剪枝:

  • 站在第 idx 个数面前,chosen 表示已经选了几个,sum 是当前的和
  • dfs(idx+1, chosen+1, sum + x[idx])
  • 不选dfs(idx+1, chosen, sum)
  • 边界 1chosen == k → 判断 sum 是不是素数,是就 ans++,return
  • 边界 2idx > n → 数用完了还没选够,return
  • 剪枝n - idx + 1 < k - chosen → 剩下的数不够选,return
🎯 选或不选的搜索树有多大

n=20 时最多 2²⁰ = 1048576 个叶子。每个叶子都要做一次素性判断, 如果每个都老老实实除到 √10⁸ = 10⁴,那就是 10¹⁰ 次运算 —— 会超时

但实际不会:因为判断合数时只要找到一个因子就立刻 return。绝大多数和都能被 3、5、7、11 之类的小素数很快整除掉, 平均只需要十几次除法。这就是为什么这题敢用试除法。

样例输入
4 3
3 7 12 19
样例输出
1

完整代码

#include <iostream>
using namespace std;

int n, k;
int x[25];
int ans = 0;

bool isPrime(long long v){
    if(v < 2) return false;                  // 0 和 1 都不是素数
    for(long long d = 2; d * d <= v; d++){   // 试除到 √v 就够
        if(v % d == 0) return false;         // ★ 找到因子立刻退出
    }
    return true;
}

// idx:正在考虑第 idx 个数(1 开始)
// chosen:已经选了几个
// sum:已选数字之和
void dfs(int idx, int chosen, long long sum){
    if(chosen == k){                    // 选够了,判断和是不是素数
        if(isPrime(sum)) ans++;
        return;
    }
    if(idx > n) return;                 // 数用完了还没选够
    if(n - idx + 1 < k - chosen) return; // ★ 剪枝:剩下的数不够选了

    dfs(idx + 1, chosen + 1, sum + x[idx]);   // 选第 idx 个
    dfs(idx + 1, chosen, sum);                // 不选第 idx 个
}

int main(){
    cin >> n >> k;
    for(int i = 1; i <= n; i++) cin >> x[i];
    dfs(1, 0, 0);
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 4 3 / 3 7 12 191 完全一致

压力测试(真实输出,用文件重定向计时避开终端开销):

  • 最坏规模 n=20, k=10、10 个大数(都是奇素数):答案 0(10 个奇数相加必为偶数且大于 2,确实不可能是素数 —— 结果合理), 耗时 32 ~ 37 ms(三次运行)。
  • 随机数据 n=20, k=10、20 个 1~5×10⁶ 的随机数:答案 11027,耗时 195 ~ 234 ms(三次运行)。
    对照 C(20,10) = 184756 种选法,11027 个素数和约占 6%,和 1/ln(5×10⁷) ≈ 6% 的素数密度吻合,结果可信。

易错点

  • isPrime 忘了处理 v < 2:0 和 1 都不是素数,漏判会把它们算成素数。虽然本题 xi ≥ 1、k ≥ 1, 和最小是 1,还是补上更保险。
  • 循环条件写成 d <= v/2d <= v:能过但慢得离谱。要用 d * d <= v(试除到 √v)。
  • 索引从 1 开始但数组只开了 nint x[25] 是安全的,但别写成 int x[20]
  • 「选/不选」两个分支的顺序:本题不影响答案(只是数的顺序不同),但如果题目要求按字典序输出方案,就必须先「选」后「不选」。
  • 剪枝写反了:是「剩下的少于需要的」(n-idx+1 < k-chosen)才返回,写成 > 会把正常分支也砍掉。
P2404 自然数的拆分问题 普及- · 搜索 + 有序枚举

题意:把一个自然数 n(n > 1)拆成若干个小于 n 的自然数之和。 每个拆分里的数字要从小到大排列,输出所有拆分,按字典序。

比如 n=7,答案以 1+1+1+1+1+1+1 开头,以 3+4 结尾,共 14 行。 注意 7 自己不算(题目要求加数都小于 n,所以至少要拆成两个数)。

⚠️ 数据范围

2 ≤ n ≤ 8。范围给得很小是有意的:n 的分拆数是随 n 指数级增长的, n=8 时只有 21 种,而 n=20 就有 627 种、n=50 有 204226 种。

所以这题随便搜都能过,重点在于「怎么写才能不重复、且天然字典序」。

思路

还是第 4 节的「有序枚举」框架,只是把「选下标」换成「填加数」:

  • 状态pos(正在填第几个加数)、remain(还差多少凑够 n)、start(这一位最小能填几)
  • 选择:这一位填 start ~ remain 里的任意一个数
  • 边界remain == 0 → 凑齐了,输出
  • 去重 + 有序:下一位传 i(不是 i+1),保证「后面的数不小于前面的数」
🎯 为什么传 i 而不是 i+1

题目要求「后面的数不小于前面的数」—— 是「不小于」,所以允许相等1+1+2 里的两个 1)。 因此下界传 i 而不是 i+1

如果传了 i+1,就变成了「严格递增」,1+1+1+… 这种方案会全被砍掉,n=7 只剩下 1+2+41+62+53+4 这 4 行,答案全错。

💡 怎么保证「字典序最小优先」

因为每一位都是从 start 开始从小到大试的,而「先到的先输出」, 所以生成的顺序天然就是字典序。什么都不用额外做。

可以自己验证:n=7 的第一行是 1+1+1+1+1+1+1(能多小就多小),最后一行是 3+4 —— 完全符合样例。

样例输入
7
样例输出
1+1+1+1+1+1+1
1+1+1+1+1+2
1+1+1+1+3
1+1+1+2+2
1+1+1+4
1+1+2+3
1+1+5
1+2+2+2
1+2+4
1+3+3
1+6
2+2+3
2+5
3+4

完整代码

#include <iostream>
using namespace std;

int n;
int a[15];      // 存当前这一组加数

// pos:现在要决定第 pos 个加数
// remain:还差多少才能凑够 n
// start:这一位最小能填几(★ 强制不减,避免出现重复方案)
void dfs(int pos, int remain, int start){
    if(remain == 0){                 // 正好凑齐
        if(pos < 2) return;          // ★ 至少要有两个加数(不能只有 n 自己)
        for(int i = 0; i < pos; i++){
            if(i) cout << "+";        // 第一个数前面不加加号
            cout << a[i];
        }
        cout << "\n";
        return;
    }
    for(int i = start; i <= remain; i++){   // 试这一位填 i
        a[pos] = i;
        dfs(pos + 1, remain - i, i);        // ★ 下一位至少从 i 开始(可以相等)
    }
}

int main(){
    cin >> n;
    dfs(0, n, 1);
    return 0;
}
✅ 实测结果

官方样例 7 → 输出 14 行,从 1+1+1+1+1+1+13+4, 与样例输出逐行完全一致

补充测试(真实输出):

  • 21+1(1 行) ✅ 最小值正确,没有输出单独的 2
  • 821 行,末行 4+4 ✅ 末行正确(最大的首项是 ⌊8/2⌋ = 4, 且 5+3 不合法因为要求递增,4+4 才是字典序最后一组)

易错点

  • i+1 而不是 i:重复加数被禁掉,答案少一大半(n=7 只剩 4 行)。
  • 没排除「n 自己」:不加 if(pos < 2) return; 的话,第一行会多出 7,答案多 1 行。
  • 循环上界写成 i <= n:会产生无效分支(比如还剩 2 却去试 i=5,导致 remain 变负,虽然不会输出但白跑)。写成 i <= remain 更干净。
  • 输出的加号位置错:第一个数前面不能有 +。用 if(i) cout << "+"; 是标准写法。
  • 数组开小了:n=8 时最长的拆分是 1+1+1+1+1+1+1+1,共 8 个加数,a[15] 足够。
  • 用排序去重:有些同学先搜出所有拆分再排序去重,那是「先污染后治理」。 正确做法是从搜索时就保证不产生重复,这样既快又不会漏。

今日常见错误汇总

#错误写法后果正确写法
1回溯时忘了撤销 vis[i]=false第一个分支走完后所有数都被「占用」,只剩 1 行输出标记与撤销成对写,中间夹 dfs
2组合题里 dfs(step+1, i)出现 1 1 1 这种非法组合,方案数暴涨dfs(step+1, i+1)
3组合题里 start 一直传 1变成全排列,每种组合重复 r! 倍下一层传 i+1
4拆分题里传 i+1丢了所有含重复加数的方案(n=7 只剩 4 行)允许相等就传 i
5边界条件写错(step==n vs step>n少填一位 / 多跑一层,输出少一行或多越界按「第几位开始、第几位结束」想清楚
6忘记 #include <iomanip>setw 编译错误用了 setw 就加头文件
7输出宽度不对(3 写成 5,或 5 写成 3)全部 PE(格式错误),一分不得严格照题目说的场宽
8数组开得刚好等于 n下标到 n 越界 → 玄学 WA开 n + 5 以上
9大规模输出用 endlP1157 / P1706 输出 18 万~36 万行,明显变慢"\n"
10没估算规模就开写n=25 还硬搜子集 → 必 TLE先算方案数,超 10⁷ 就剪枝或换算法

自测:你真的会了吗

第 1 题:组合和排列,搜索时防止重复的手段有什么本质区别?

组合start 递增(下一位从 i+1 开始)。 它约束的是「位置之间的关系」—— 强制序列严格递增,于是每种组合只有「递增的那一种写法」能被生成。

排列vis[] 标记(用过的数跳过)。 因为排列允许任意顺序,没法用「递增」来约束,只能记录「这个元素被用过了没有」, 保证同一个元素在一个序列里不重复出现

一句话:组合管「顺序」,排列管「元素」。

第 2 题:P1706 里如果忘了写 vis[i] = false;,n=3 会输出几行?

1 行,输出 1 2 3

过程:最外层 i=1,标记 vis[1],进第二层;第二层 i 从 1 开始,1 被占,所以选 2,标记 vis[2]; 第三层选 3,标记 vis[3],输出 1 2 3

然后逐层返回,但没有一层把标记清掉。回到最外层继续 i=2 时, vis[2] 还是 true,skip;i=3 时 vis[3] 还是 true,skip。循环结束,程序退出。

所以只输出 1 行,另外 5 行全丢了。这就是「回溯」这一步不能省的原因。

第 3 题:P1157 输入 5 3,一共会输出多少行?怎么算出来的?

:10 行。

就是组合数 C(5,3) = 5!/(3!·2!) = 120/(6×2) = 10

另一条思路:从 1 开头有 C(4,2)=6 种,从 2 开头有 C(3,2)=3 种, 从 3 开头有 C(2,2)=1 种,6+3+1 = 10。这正好对应搜索树里最外层 i=1,2,3 三个分支的叶子数。

第 4 题:为什么 P1036 的 n 只能到 20,而 P1706 的 n 只能到 9?

:因为两者的方案数增长速度完全不同,而 OJ 一秒大约只能跑 10⁷~10⁸ 次操作。

P1036 是选/不选型,方案数最多 C(20,10) = 184756(不到 20 万),加上剪枝实际更少,所以 n 敢开到 20。

P1706 是全排列型,方案数是 n!9! = 362880(还行), 但 10! = 362880011! = 39916800 —— 而且每一行还要输出 n 个数字, 总输出量早就把时限撑爆了。所以 n 只能到 9。

结论:看到数据范围,先反推它暗示的搜索规模。

第 5 题(手写代码):从 n 个数里选 k 个,输出所有组合的下标(不输出具体值)

这是「模板默写」题,请合上文章自己写一遍再对照:

int n, k;
int a[25];

void dfs(int step, int start){
    if(step == k){                        // 选够了
        for(int i = 0; i < k; i++){
            if(i) cout << " ";
            cout << a[i];
        }
        cout << "\n";
        return;
    }
    for(int i = start; i <= n; i++){      // ★ 从 start 开始
        a[step] = i;
        dfs(step + 1, i + 1);             // ★ 下一位从 i+1 开始
    }
}

int main(){
    cin >> n >> k;
    dfs(0, 1);
}

★ 检验标准:输入 5 3 必须输出 10 行,且第一行是 1 2 3、最后一行是 3 4 5。 如果输出了 60 行,说明 start 参数传错了。

今日速查卡(截图存手机)

【DFS 万能骨架】
  void dfs(状态参数){
      if(边界){ 输出/记录; return; }
      for(枚举这一层的所有选择){
          if(不合法) continue;      // 剪枝
          做出选择;                 // 修改状态
          dfs(下一层);
          撤销选择;                 // ★ 回溯
      }
  }

【写搜索前先回答三个问题】
  状态:我现在在哪一步?      → 一个 step / pos 参数
  选择:下一步能做什么?      → 一层 for 循环
  边界:什么时候算完成/死了? → 开头的 if...return

【框架 A:选或不选】
  void dfs(int idx, ...){
      if(idx > n){ 处理答案; return; }
      dfs(idx + 1, 选 idx 的状态);     // 选
      dfs(idx + 1, 不选的状态);        // 不选
  }
  // 复杂度 2^n,n ≤ 20 可裸搜

【框架 B:组合(★下标递增,防重复)】
  void dfs(int step, int start){
      if(step == r){ 输出; return; }
      for(int i = start; i <= n; i++){
          a[step] = i;
          dfs(step + 1, i + 1);        // ★ 关键:i+1
      }
  }
  // 复杂度 C(n,r),天然字典序
  // 允许同一值重复用 → 改成传 i

【框架 C:全排列(★vis 标记 + 回溯)】
  void dfs(int step){
      if(step > n){ 输出; return; }
      for(int i = 1; i <= n; i++){
          if(vis[i]) continue;
          vis[i] = true;               // 进入时标记
          a[step] = i;
          dfs(step + 1);
          vis[i] = false;              // ★ 退出时撤销
      }
  }
  // 复杂度 n!,n ≤ 9~10

【传 i 还是 i+1?】
  组合:不许重复用同一个值  →  dfs(step+1, i+1)
  拆分:允许重复用同一个值  →  dfs(pos+1, remain-i, i)

【输出格式】
  #include <iomanip>
  cout << setw(3) << x;    // 3 个场宽,只对下一次输出生效
  cout << "\n";            // 大量输出别用 endl

【规模红线】
  2^n   n≤20 可以裸搜
  C(n,k)  n≤20 可以裸搜
  n!    n≤9~10 可以裸搜
  超过 10^7 个叶子 → 必须剪枝或换算法

明天预告 · Day 19

DFS 回溯进阶:网格与冲突检测

今天你学会的是「枚举所有方案」。明天要面对更麻烦的一类题目:方案之间会互相影响

比如「八皇后」(两个皇后不能在同一行、同一列、同一对角线上),你一放下去就得检查有没有和前面的冲突; 再比如「迷宫计数」「找石油」这类网格题,要到格子里去走,还要用 vis 标记走过的格子防止绕圈。

明天的新东西:冲突检测的写法网格 DFS 的四个方向连通块标记。 今天学的「进入时标记、退出时撤销」会原封不动地用上 —— 所以今天的回溯一定要练熟。

小提醒:今天的 4 道题建议都动手敲一遍,尤其是 P1157 和 P1706, 明天写八皇后的时候你会发现它们的影子。

ACM 一个月学习计划 · DAY 19

Day 19 · DFS 回溯:网格搜索与冲突检测

方向数组 + 网格 DFS 框架 + 回溯撤销标记 + 八皇后的 O(1) 冲突判断

今日目标:掌握网格类 DFS 的通用写法,会用方向数组,会写带约束条件的回溯
预计用时:约 3.5 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1219 / P1605 / P1596 / P1101
DAY 19 / 30

今日时间安排

0 - 30 min方向数组:把「四种走法」变成「一次循环」(第 1 节)
30 - 70 min网格 DFS 通用框架 + 回溯撤销(第 2、3 节)
70 - 110 min八皇后的列 / 两条对角线冲突标记(第 4 节)
110 - 230 min做 4 道题,每题限时 30 分钟
230 - 260 min对照本文题解,把没做出来的补掉
260 - 300 min写题解笔记 + 做自测题 + 存速查卡
💡 和昨天内容的衔接

Day 18 你写的 DFS 都是「在一维的候选集合里挑数字」:P1157 组合、P1706 全排列、P2404 自然数拆分。 递归函数的参数是一个「选到第几个了」的下标。

今天要跨到二维:递归函数的参数变成 (x, y) 坐标,我们要在网格里上下左右地走。 昨天那套「递归 + 撤销」的思路一个字都不用改,只是多了一个「往哪个方向走」的工具——方向数组

方向数组是后面所有搜索题(DFS、BFS、最短路、迷宫)的公共基础设施,今天必须练到闭着眼睛能写出来。

1方向数组:搜索题的「方向盘」

没有方向数组时,代码长这样

假设我们在一个格子上,要试着往上下左右四个方向各走一步,检查能不能走。最直白的写法是:

// 分别处理四个方向,四段几乎一样的代码
if(能走(x + 1, y)) dfs(x + 1, y);       // 下
if(能走(x - 1, y)) dfs(x - 1, y);       // 上
if(能走(x, y + 1)) dfs(x, y + 1);       // 右
if(能走(x, y - 1)) dfs(x, y - 1);       // 左

能跑,但有两个毛病:一是重复,四段逻辑一模一样;二是不好扩展——如果题目改成八方向(马、水塘、单词方阵), 你就要再手写四段斜着走的代码,很容易写错一个正负号。

用两个数组存「方向」

🎯 核心想法

把「往第 d 个方向走一步,行要加多少、列要加多少」提前存进两个数组里。
走的时候只要算 nx = x + dx[d]ny = y + dy[d],然后一个 for 循环转一圈就把所有方向试完了。

四方向(上下左右)

int dx[4] = {0,  0, 1, -1};   // 行的变化量
int dy[4] = {1, -1, 0,  0};   // 列的变化量

for(int d = 0; d < 4; d++){
    int nx = x + dx[d];
    int ny = y + dy[d];
    // 现在 (nx, ny) 就是第 d 个方向的邻居
}
ddx[d]dy[d]走到的邻居怎么记
00+1(x, y+1)列变大 → 往右
10−1(x, y−1)列变小 → 往左
2+10(x+1, y)行变大 → 往下
3−10(x−1, y)行变小 → 往上
💡 顺序随便,只要 dx 和 dy 一一对应

这四个顺序你喜欢哪个都行(有人喜欢「上下左右」写成 {-1,1,0,0}{0,0,-1,1})。 唯一的要求是:dx[d] 和 dy[d] 必须描述同一个方向,不许写错位。写错位是最经典的错误之一,会让你「斜着走」。

八方向(含四个斜角)

3×3 的九宫格里,除了自己,周围八个格子各对应一个方向。把它们按「从左上角开始一行一行数过去」的顺序排,最好记:

(−1,−1) 左上(−1, 0) 上(−1,+1) 右上 ( 0,−1) 左( 0, 0) 我自己( 0,+1) 右 (+1,−1) 左下(+1, 0) 下(+1,+1) 右下
int dx[8] = {-1, -1, -1,  0, 0,  1, 1, 1};
int dy[8] = {-1,  0,  1, -1, 1, -1, 0, 1};

// 一一对应:(-1,-1)左上 (-1,0)上 (-1,1)右上 (0,-1)左 (0,1)右 (1,-1)左下 (1,0)下 (1,1)右下

for(int d = 0; d < 8; d++){
    int nx = x + dx[d];
    int ny = y + dy[d];
}
⚠️ 注意:八方向里没有 (0,0)

八个方向都必须是真邻居,不能把「原地不动」也算成一种走法。所以这八个向量里没有 (0,0)
如果不小心把 (0,0) 写进去,DFS 就会自己调用自己、无限递归,最后栈溢出(RE)。

还有第三种:马走日(Day 20 要用)

中国象棋里的马走「日」字:先走两格再拐一格,一共也是八种走法,但步长不是 1 而是 2 和 1 的组合:

int dx[8] = {-2, -2, -1, -1,  1, 1,  2, 2};
int dy[8] = {-1,  1, -2,  2, -2, 2, -1, 1};

看出来了吗?方向数组只是「一堆偏移量」,具体偏移多少完全由题目规则决定。 变化的是数组里的数字,不变的是 nx = x + dx[d] 这个写法。

✅ 一句话总结方向数组

它把「有几种走法」这个信息,从代码结构(写几段 if)转移到了数据(数组里有几个元素)。
于是「四方向改成八方向」只需要改数组内容,循环体一个字都不用动——这就是今天所有题目的基础工具。

2网格 DFS 的通用框架(背下来)

有了方向数组,网格上的 DFS 就只剩下一个固定骨架。今天四道题里有三道都是它的变形。

void dfs(int x, int y){
    vis[x][y] = 1;                        // ① 标记:我来过了
    for(int d = 0; d < 4; d++){           // ② 枚举四个方向
        int nx = x + dx[d], ny = y + dy[d];
        if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue;   // ③ 越界
        if(vis[nx][ny] || 不能走) continue;                     // ④ 重复 / 障碍
        dfs(nx, ny);                                           // ⑤ 继续深入
    }
}

五个部分各自管什么

#代码作用写错的后果
vis[x][y] = 1;声明「这个格子已经处理过」漏了会绕圈死循环 / 重复计数
for(d = 0; d < 4; d++)四个方向逐个尝试写成 < 8 就变八方向了,看题目要求
越界判断别走出棋盘数组越界 → 玄学 WA / RE
访问 / 障碍判断不重复走、不撞墙死循环或答案偏大
dfs(nx, ny);递归深入
🚫 最容易错的地方:边界条件

你的数组下标是从 0 开始的(g[0..n-1][0..m-1]),还是从 1 开始的(g[1..n][1..m])?这决定了越界判断怎么写!

下标约定越界判断
从 0 开始,合法范围 0 ~ n-1if(nx < 0 || nx >= n || ny < 0 || ny >= m) continue;
从 1 开始,合法范围 1 ~ nif(nx < 1 || nx > n || ny < 1 || ny > m) continue;

两套写法混用必错。本文的代码统一用「从 1 开始」这套(题目给的坐标就是 1 开始,直接用最省事,数组开大 5 个格子防越界)。

💡 递归深度够不够?

DFS 是用「函数调用栈」实现的,递归太深会爆栈。粗略的估算:递归深度 ≈ 最长的一条路径长度
今天的题里,P1596 是 100×100 的田地(最深 10000 层),实践下来没问题; 但如果以后遇到 1000×1000 的网格连通块,就要考虑改成 BFS 或者手写栈了。

3回溯:走过去要留脚印,走回来要擦掉

先分清两种任务

任务类型例子DFS 完要不要撤销标记
连通块 / 遍历:把整片区域走一遍,看看有多大、有几块P1596 数水塘不用撤销。走过就是走过,一个格子属于哪块水塘是定死的
路径 / 方案计数:枚举所有从起点到终点的走法P1605 迷宫必须撤销!否则不同路径会互相「挡路」
🎯 为什么路径计数必须撤销

想象你在走迷宫,题目要求「每个方格最多经过一次」。
你沿着路线 A 走到了终点,现在要回去试试路线 B。如果你不把路线 A 的脚印擦掉, 路线 B 就会因为「这些格子已经走过了」而走不通,于是你数出来的方案数会偏少,甚至变成 0。

正确做法是:进入格子时打标记,离开格子时(递归返回后)立刻把标记擦掉—— 让这个格子回到「没走过」的状态,好让下一条路径能用它。

代码对比

// ❌ 连通块写法(不撤销)—— 用在路径计数题上会漏解
void dfs(int x, int y){
    vis[x][y] = true;
    for(int d = 0; d < 4; d++){
        ...
        if(!vis[nx][ny] && 能走) dfs(nx, ny);
    }
}

// ✅ 路径计数写法(撤销)—— P1605 要这样写
void dfs(int x, int y){
    if(x == fx && y == fy){ ans++; return; }    // 到终点,记一笔
    for(int d = 0; d < 4; d++){
        int nx = x + dx[d], ny = y + dy[d];
        if(越界 || 障碍 || vis[nx][ny]) continue;
        vis[nx][ny] = true;                     // 进格子:留下脚印
        dfs(nx, ny);
        vis[nx][ny] = false;                     // 出格子:擦掉脚印 ★★★
    }
}
✅ 一句话记忆

「标记」和「撤销」总是成对出现在递归调用的上下两行。
修改状态 → dfs(...) → 恢复状态 这个三明治结构,就是「回溯」的全部秘密。 Day 18 的全排列(选完数字要把它放回候选池)用的是同一个招数。

4八皇后的冲突标记:O(1) 判断能不能放

这是今天最「聪明」的一节。P1219 的关键不在 DFS,而在于如何瞬间判断一个位置能不能放皇后。

先看规则

n×n 棋盘上放 n 个皇后,要求:每行、每列有且只有一个;每条对角线上至多有一个。

注意一个天然的好消息:我们按「一行一行放」的顺序做 DFS,第 i 行就放一个皇后。 这样一来「每行只有一个」自动满足,我们只需要检查列和两条对角线

三个标记数组

冲突类型怎么判断两个格子在同一条线上标记数组数组开多大
同一列列号相同:j 相同bool col[15]n + 5
↘ 主对角线(左上→右下)i − j 相同bool udg[30],下标用 i − j + n2n + 5
↙ 副对角线(右上→左下)i + j 相同bool dg[30],下标用 i + j2n + 5

为什么 i+j 和 i−j 能表示对角线

在 5×5 的棋盘上,把每个格子的 i+j 写出来(行 i 向下增大,列 j 向右增大):

23456 34567 45678 56789 678910

所有「↙ 方向」(右上到左下)的格子,i+j 完全相同。 比如 (1,5)、(2,4)、(3,3)、(4,2)、(5,1) 的 i+j 都是 6——它们正好在同一条副对角线上。

再看 i−j

0−1−2−3−4 10−1−2−3 210−1−2 3210−1 43210

所有「↘ 方向」(左上到右下)的格子,i−j 完全相同。 比如 (1,1)、(2,2)、(3,3)、(4,4)、(5,5) 的 i−j 都是 0。

⚠️ 负数不能当数组下标!所以要 +n

i−j 的范围是 1−nn−1,是负数。数组下标不能为负,所以统一加上 n 变成 12n−1,全部落在非负区间里。

这就是 udg[i - j + n] 里那个 +n 的由来——不是为了好看,是为了把负数搬进合法下标范围。 这和 Day06 讲的「把 −100~100 映射成 0~200」是同一招。

于是「能不能放」变成一次查表

if(col[j] || dg[i + j] || udg[i - j + n]) continue;   // 三个数组都查一遍,O(1)

对比一下暴力做法:要扫描已经放好的所有皇后、逐个算「是不是同列 / 同对角线」,那是 O(n)。 n=13 时总体差距大约是 13 倍——这就是「用数组换时间」的又一次胜利(Day 06 的桶思想在这里再次出现)。

💡 三段式:占位 → 递归 → 撤销

col[j] = dg[i+j] = udg[i-j+n] = true; 一句话同时占三个位置,撤销时也一句话同时放开三个。 占位和撤销必须严格对称,少撤一个,后面的分支就全被堵死了(这是 P1219 最常见的 WA 原因)。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是真实结果。

P1219 [USACO1.5] 八皇后 Checker Challenge 普及/提高- · 回溯 + 冲突检测

题意:在 n×n 的棋盘上放 n 个棋子,要求每行、每列都恰好有一个,每条对角线上至多一个。 把所有解按字典序输出前 3 个(每个解输出 n 个数:第 i 个数表示第 i 行的棋子在第几列),最后输出解的总数。

⚠️ 数据范围 / 关键约束

对于 100% 的数据,6 ≤ n ≤ 13

为什么上限只有 13?因为解的数量增长得非常快:n=8 有 92 个解,而 n=13 有 73712 个。 朴素的回溯在 n=13 时大约要跑几千万次操作,正好卡在 1 秒的时限附近——所以冲突检测必须是 O(1),慢一点就超时

思路

  1. 按行放dfs(i) 表示「现在要给第 i 行放一个皇后」。放完第 n 行就得到一个解。
  2. 这一行试每一列for(j = 1; j <= n; j++),看第 j 列能不能放。
  3. O(1) 判断冲突col[j] || dg[i+j] || udg[i-j+n] 任意一个为真就跳过(见第 4 节)。
  4. 占位 → 递归 → 撤销:放下去就把三个数组标上,dfs(i+1) 之后再全部清零。
  5. 输出控制:用一个 total 计数器,只在前 3 个解时打印序列,第 4 行打印总数。
样例输入
6
样例输出
2 4 6 1 3 5
3 6 2 5 1 4
4 1 5 2 6 3
4

含义:第一个解是「第 1 行放第 2 列、第 2 行放第 4 列、第 3 行放第 6 列……」,6×6 棋盘一共 4 个解。

完整代码

#include <iostream>
using namespace std;

int n, total = 0;
int pos[15];        // pos[i] = 第 i 行的皇后放在第几列
bool col[15];       // col[j]     :第 j 列有没有被占
bool dg[30];        // dg[i+j]    :副对角线(右上到左下,i+j 是常数)
bool udg[30];       // udg[i-j+n] :主对角线(左上到右下,i-j 是常数,+n 是为了不变负数)

void dfs(int i){    // 现在要给第 i 行放皇后
    if(i > n){      // 1~n 行都放好了,得到一个解
        total++;
        if(total <= 3){                  // 只输出前 3 个解
            for(int r = 1; r <= n; r++){
                if(r > 1) cout << " ";
                cout << pos[r];
            }
            cout << "\n";
        }
        return;
    }
    for(int j = 1; j <= n; j++){          // 试着把皇后放在第 j 列
        if(col[j] || dg[i + j] || udg[i - j + n]) continue;   // O(1) 冲突检测
        pos[i] = j;
        col[j] = dg[i + j] = udg[i - j + n] = true;           // 占位
        dfs(i + 1);                                           // 递归下一行
        col[j] = dg[i + j] = udg[i - j + n] = false;          // 回溯:撤销标记
    }
}

int main(){
    cin >> n;
    dfs(1);                    // 从第 1 行开始放
    cout << total << endl;      // 最后一行是解的总个数
    return 0;
}
✅ 实测结果

官方样例 6 → 输出

2 4 6 1 3 5
3 6 2 5 1 4
4 1 5 2 6 3
4

与样例完全一致(三行解 + 一行总数 4)。

最大数据 13 → 输出前三行 1 3 5 2 9 12 10 13 4 6 8 11 7 / 1 3 5 7 9 11 13 2 4 6 8 10 12 / 1 3 5 7 12 10 13 6 4 2 8 11 9, 总数为 73712(这是八皇后问题的标准答案),用时约 225 ms,在 1 秒时限内很安全。

易错点

  • 三个数组要一起撤销。只撤销 col 忘了撤销对角线,后面分支会被莫名堵死,答案偏少。
  • 忘记 +nudg[i-j] 在 i < j 时下标是负数,直接越界(RE 或玄学错误)。
  • 输出格式:数字之间用空格,行末不能有多余空格。用 if(r > 1) cout << " "; 控制。
  • 只输出前 3 个解,不是全部解。用计数器 total <= 3 控制,别把 73712 个解全打出来。
  • 数组要开够:n 最大 13,所以 col[15] 够用;对角线下标最大到 2n = 26,开 [30] 稳妥。
P1605 迷宫 普及- · 网格 DFS 路径计数

题意:给一个 N×M 的方格迷宫,里面有 T 个障碍。从起点 (SX,SY) 走到终点 (FX,FY), 每次只能上下左右走一格,每个方格最多经过一次,问一共有多少种走法。

⚠️ 数据范围 / 关键约束

对于 100% 的数据,1 ≤ N, M ≤ 51 ≤ T ≤ 101 ≤ SX, FX ≤ N1 ≤ SY, FY ≤ M。 数据保证起点上没有障碍。

棋盘最大只有 5×5 = 25 格!这不是「数据太小了没意思」,而是题目在暗示你可以放心地暴力搜索: 我们数了一下,5×5 无障碍时从 (1,1) 到 (5,5) 的走法一共 8512 种,DFS 一瞬间就跑完了。

坐标都是 1 开头的(行号从 1 到 N),所以数组开成 [10][10] 并把下标当 1~5 用最省事。

思路

  1. 存地图:读入 N、M、T,然后读起点终点,最后把 T 个障碍点标记成 g[x][y] = 1
  2. 起点先标记vis[sx][sy] = true;。起点在路径上,也得算「已经走过」。
  3. DFS 从起点出发dfs(x, y) 表示「我现在站在 (x,y) 上」。
  4. 终点就是答案 +1:一旦 (x,y) == (fx,fy)ans++ 然后 return(不用继续走了)。
  5. 回溯:四个方向试着走,能走就标记、递归、然后把标记擦掉(这是本题的灵魂)。
样例输入
2 2 1
1 1 2 2
1 2
样例输出
1

2×2 的迷宫,(1,2) 是障碍。从 (1,1) 只能先下到 (2,1) 再右到 (2,2),只有 1 条路。

完整代码

#include <iostream>
using namespace std;

int n, m, t, sx, sy, fx, fy, ans = 0;
int g[10][10];        // g[x][y] = 1 表示这格是障碍
bool vis[10][10];     // 这格走过了没有
int dx[4] = {0, 0, 1, -1};   // ★ 方向数组:行怎么变
int dy[4] = {1, -1, 0, 0};   // ★ 方向数组:列怎么变

void dfs(int x, int y){
    if(x == fx && y == fy){    // 走到终点,找到一条路径
        ans++;
        return;
    }
    for(int d = 0; d < 4; d++){                       // 四个方向逐个试
        int nx = x + dx[d], ny = y + dy[d];
        if(nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 越界(坐标从 1 开始)
        if(g[nx][ny] == 1 || vis[nx][ny]) continue;          // 障碍 或 走过的格子
        vis[nx][ny] = true;      // 进格子:留下脚印
        dfs(nx, ny);             // 继续深入
        vis[nx][ny] = false;      // ★ 出格子:擦掉脚印(回溯)
    }
}

int main(){
    cin >> n >> m >> t;
    cin >> sx >> sy >> fx >> fy;
    for(int i = 0; i < t; i++){
        int x, y;
        cin >> x >> y;
        g[x][y] = 1;             // 标记障碍
    }
    vis[sx][sy] = true;          // 起点也算走过
    dfs(sx, sy);
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 → 输出 1 与样例完全一致

补充测试:5×5 无障碍、从 (1,1) 到 (5,5) → 输出 8512(不考虑障碍时角到角的走法总数),瞬间返回,说明搜索规模完全可控。

易错点

  • 忘记回溯vis[nx][ny] = false;)→ 第一条路径把格子全占了,后面的路径全走不通,答案偏小。
  • 忘记标记起点 → 搜索可能绕回起点再走一遍,(1,1)↔(1,2) 之间来回晃,路径数虚高。
  • 障碍点在起点上:本题保证不会,但读入顺序要注意——先读起点终点,再读 T 个障碍。
  • 越界判断写成 < 0:本题坐标从 1 开始,要用 nx < 1
  • 数组开 [10][10] 时下标能到 9,而 N、M 最大才 5,多出来的空间是留给越界缓冲的。
P1596 [USACO10OCT] Lake Counting S 普及- · 八方向连通块计数

题意:给一块 N×M 的田地,每格是水(W)或干地(.)。 一个水塘由连通的水格子组成,这里的「相邻」是八邻居(包括斜角)。 问田地里一共有多少个水塘。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 100,1 ≤ M ≤ 100。字符之间没有空格,所以要么一个字符一个字符读(cin >> g[i][j] 会自动跳过空白), 要么整行读成字符串。用 cin >> g[i][j] 读单个字符最省事。

最关键的一点:这里是 8 方向,不是 4 方向!斜着相邻的两个水格属于同一个水塘。方向数组必须写 8 个元素。

思路

这就是第 2 节那个「连通块」任务的模板题,思路非常朴素:

  1. 扫一遍全场:从上到下、从左到右看每个格子。
  2. 遇到一格还没被处理过的水 W:说明发现了一个新水塘 → 水塘数 +1。
  3. 立刻从这格出发 DFS,沿着八个方向把所有能连到的 W 全部「抽干」(改成 .)。
  4. 继续扫描:被抽干的格子再也不会被当成「新水塘」,所以每个水塘正好被数一次。
🎯 为什么连通块题不用撤销标记

因为「一个格子属于哪个水塘」是客观事实,一旦确认它属于当前这块水塘,就永远不会再需要它了。 所以直接把 W 改成 .(或者 vis = true)就行,不用回头擦掉。

对比 P1605:那里要数「所有路径」,同一个格子在不同路径里要反复使用,所以必须撤销「要不要撤销」取决于题目问的是「一块区域」还是「一条路径」。

样例输入
10 12
W........WW.
.WWW.....WWW
....WW...WW.
.........WW.
.........W..
..W......W..
.W.W.....WW.
W.W.W.....W.
.W.W......W.
..W.......W.
样例输出
3

题面说明:共有三个水塘——一个在左上角,一个在左下角,还有一个沿着右侧。

完整代码

#include <iostream>
using namespace std;

int n, m, ans = 0;
char g[105][105];

// ★ 八个方向:左上、上、右上、左、右、左下、下、右下(注意没有 (0,0))
int dx[8] = {-1, -1, -1,  0, 0,  1, 1, 1};
int dy[8] = {-1,  0,  1, -1, 1, -1, 0, 1};

void dfs(int x, int y){
    g[x][y] = '.';               // 把水抽干,等价于「标记已访问」
    for(int d = 0; d < 8; d++){                 // ★ 这里是 8,不是 4
        int nx = x + dx[d], ny = y + dy[d];
        if(nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 越界
        if(g[nx][ny] == 'W') dfs(nx, ny);                    // 是水就继续扩散
    }
}

int main(){
    cin >> n >> m;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            cin >> g[i][j];               // 逐字符读入,自动跳过换行

    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            if(g[i][j] == 'W'){           // 找到一格还没被抽干的水 → 新水塘
                ans++;
                dfs(i, j);                // 把整个水塘抽干
            }

    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 → 输出 3 与样例完全一致

补充测试:100×100 全部是 W → 输出 1(整块田只有一个水塘,说明递归 10000 层也没有爆栈)。

易错点

  • 方向数组只写了 4 个:斜向相邻的水格会被漏掉,水塘数偏多。这题一定要 8 个。
  • cin >> g[i][j] 读字符是可以的(>> 会跳过空白字符), 但千万不要用 getline 混着读,那样会读到空行。整道题统一用一种读法。
  • 标记方式不统一:把 W 改成 . 之后,判断条件必须还是 == 'W', 别同时又用 vis 数组又改字符,两套状态容易打架。
  • 边界 1 ~ n:本题用 1 开始的下标,越界判断是 nx < 1 || nx > n
P1101 单词方阵 普及- · 沿固定方向搜索

题意:给一个 n×n 的字母方阵,里面可能藏着若干个单词 yizhong。 单词沿着八个方向之一连续摆放,摆放过程中不许拐弯。单词之间可以交叉、共用字母。 输出时把不属于任何单词的字母换成 *,属于单词的字母原样保留。

⚠️ 数据范围 / 关键约束

7 ≤ n ≤ 100。方阵最大 100×100 = 10000 个格子。

单词长度固定是 7(y-i-z-h-o-n-g),方向固定是 8 个。所以最坏情况枚举量是 10000 × 8 = 8 万次,每次最多比对 7 个字符,总共几十万次操作——小得可以忽略,随便怎么写都不会超时。

思路

这题和前三题的姿势都不一样:它不是「一步一格地递归」,而是「沿着一个方向一口气走到底」。

  1. 找起点:枚举每个格子,只有当它是 y(单词的第一个字母)时才可能是单词的开头。
  2. 八个方向各试一次:对每个方向 d,沿着它走 6 步,依次检查第 2~7 个字母是不是 i z h o n g
  3. 越界立刻放弃:走的过程中只要出界,这个方向就作废。
  4. 全部对上就标记:把这 7 个格子在 mark 数组里标成 true
  5. 最后统一输出mark 为真输出原字母,否则输出 *
🎯 为什么用 mark 数组「先标记、最后统一输出」

如果找到一个单词就立刻把方阵里的字母改成别的字符(比如改成 *), 会破坏后面的搜索——因为单词之间可以共用字母,别的单词还要靠这些字母来匹配。

所以正确顺序是:先把所有单词都找完、只做标记;等搜索彻底结束,再根据标记统一决定每个格子印什么。

样例输入 1
7
aaaaaaa
aaaaaaa
aaaaaaa
aaaaaaa
aaaaaaa
aaaaaaa
aaaaaaa
样例输出 1
*******
*******
*******
*******
*******
*******
*******

一个单词都没有 → 全部是星号。

样例输入 2
8
qyizhong
gydthkjy
nwidghji
orbzsfgz
hhgrhwth
zzzzzozo
iwdfrgng
yyyygggg
样例输出 2
*yizhong
gy******
n*i*****
o**z****
h***h***
z****o**
i*****n*
y******g

第 1 行横着的 yizhong,加上从左上往右下的一条斜线, 两个单词在第 1 行第 2 列的 y 处共用了一个字母。

完整代码

#include <iostream>
#include <string>
using namespace std;

int n;
char g[105][105];
bool mark[105][105];        // mark[x][y] = 这格属于某个 yizhong
string word = "yizhong";

// ★ 八个方向
int dx[8] = {-1, -1, -1,  0, 0,  1, 1, 1};
int dy[8] = {-1,  0,  1, -1, 1, -1, 0, 1};

int main(){
    cin >> n;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++)
            cin >> g[i][j];

    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++){
            if(g[i][j] != word[0]) continue;      // 起点必须是 'y'
            for(int d = 0; d < 8; d++){           // 8 个方向各试一次
                bool ok = true;
                for(int k = 1; k < 7; k++){       // 沿这个方向再走 6 步
                    int nx = i + dx[d] * k, ny = j + dy[d] * k;   // ★ 步长 k
                    if(nx < 1 || nx > n || ny < 1 || ny > n || g[nx][ny] != word[k]){
                        ok = false;
                        break;
                    }
                }
                if(ok)                            // 七个字母全对上了
                    for(int k = 0; k < 7; k++)
                        mark[i + dx[d] * k][j + dy[d] * k] = true;   // 标记这 7 格
            }
        }

    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= n; j++)
            cout << (mark[i][j] ? g[i][j] : '*');   // 属于单词就印原字母
        cout << "\n";
    }
    return 0;
}
💡 dx[d] * k 是今天的第二个小技巧

普通 DFS 是「走一步」:nx = x + dx[d]
这里是「沿着方向 d 走第 k 步」:nx = x + dx[d] * k
k 就是步数乘以方向向量,所以 (1,1) 沿方向 (1,1) 走到第 3 步就是 (4,4)。 以后遇到「沿直线摆放」「射线上的格子」这类题,都是这个写法。

✅ 实测结果

样例 1(7×7 全 a)→ 输出 7 行 ******* 与样例完全一致

样例 2 → 实际输出

*yizhong
gy******
n*i*****
o**z****
h***h***
z****o**
i*****n*
y******g

与样例完全一致(8 行逐字符比对相同)。

补充测试:100×100 全是 y → 输出 100 行,非星号字符数 0,符合预期(yy 开头的七连不构成 yizhong)。

易错点

  • 边匹配边改字符:应该只做标记、最后统一输出(见上面的 idea 框)。
  • k 忘了:写成 i + dx[d] 就变成「只看紧邻的那一格」,永远匹配不上 7 个字母。
  • 越界判断漏了一半:行和列都要判,且四个条件用 || 连起来,写成 && 就漏判了。
  • 方向数组顺序和 dy 对不上:一旦错位就会「斜着匹配」,样例能过但后面的点全错。
  • 单词长度是 7,所以循环写成 k = 1; k < 7(比对后 6 个字母),标记时才是 k = 0; k < 7, 两处的范围不一样,容易写混。

今日常见错误汇总

错误写法后果正确写法
vis[nx][ny] = true; 之后没有对应的一句 false路径计数题答案偏少甚至为 0vis[nx][ny] = true; dfs(nx,ny); vis[nx][ny] = false;
P1596 方向数组只写 4 个斜向连通的水塘被拆成好几块,答案偏大八方向,dx[8]/dy[8],且不含 (0,0)
udg[i - j] 不加 n下标为负 → 越界,RE 或玄学 WAudg[i - j + n]
八皇后的三个标记数组只撤销了一部分后面的分支被莫名堵死,解的总数偏少占位和撤销写成对称的两行
越界判断用 nx < 0,但坐标是 1~n访问到下标 0 的非法格子从 1 开始就用 nx < 1 || nx > n
P1101 找到单词就立刻把字母改成 *破坏后面的匹配(单词之间会共用字母)mark 数组先标记,最后统一输出
P1101 写成 i + dx[d] 忘了乘 k永远匹配不上 7 个字母i + dx[d] * k
数组只开到 n(如 char g[5][5]下标 1~5 越界,RE1 开始的下标 → 数组至少开 n + 5

自测:你真的会了吗

第 1 题:写出八方向的方向数组

要求顺序是「左上、上、右上、左、右、左下、下、右下」。

int dx[8] = {-1, -1, -1,  0, 0,  1, 1, 1};
int dy[8] = {-1,  0,  1, -1, 1, -1, 0, 1};

★ 检查方法:把每一对 (dx, dy) 念一遍,和九宫格的方位对上号。八个里不能有 (0,0)

第 2 题:什么时候必须撤销标记?

看题目问的是「一片区域」还是「一条路径」:

· 问「有几个连通块 / 这块区域有多大」→ 格子用过就永远不会再用 → 不用撤销(P1596)。
· 问「有多少种走法 / 枚举所有方案」→ 同一个格子在不同方案里要反复使用 → 必须撤销(P1605、P1219)。

第 3 题:手算冲突标记

棋盘 5×5,已经在 (3, 2) 放了一个皇后。下面哪些格子会被它「攻击」到?
(3, 5)、(1, 4)、(5, 4)、(2, 2)、(4, 1)、(5, 0)。

:(3,5) 同行但列相同才冲突——列 = 5 ≠ 2,i−j = −2 ≠ 1,i+j = 8 ≠ 5 → 安全
(1,4):i−j = −3,i+j = 5 → i+j 与 (3,2) 的 5 相同 → 在副对角线上,冲突
(5,4):i+j = 9 ≠ 5,i−j = 1 = (3−2) → 在主对角线上,冲突
(2,2):i−j = 0,i+j = 4 → 都不同 → 安全。
(4,1):i−j = 3,i+j = 5 → i+j 相同 → 冲突(和 (1,4) 同一条副对角线)。
(5,0):列 0 越界,棋盘上不存在这个格子。

第 4 题(手写代码):统计一块网格里有多少个「1」连通块(4 方向)
int n, m, ans = 0;
int g[105][105];
int dx[4] = {0, 0, 1, -1}, dy[4] = {1, -1, 0, 0};

void dfs(int x, int y){
    g[x][y] = 0;                        // 抹掉,等价于标记
    for(int d = 0; d < 4; d++){
        int nx = x + dx[d], ny = y + dy[d];
        if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
        if(g[nx][ny] == 1) dfs(nx, ny);
    }
}
// 主函数里:
// for i, j: if(g[i][j] == 1){ ans++; dfs(i, j); }

★ 注意:这里不撤销,因为问的是「有几块」而不是「有几条路径」。把 P1596 的 8 改成 4 就是这道题。

速查卡(截图存手机)

【四方向 / 八方向】
  int dx[4] = {0, 0, 1, -1};
  int dy[4] = {1, -1, 0, 0};

  int dx[8] = {-1,-1,-1, 0, 0, 1, 1, 1};
  int dy[8] = {-1, 0, 1,-1, 1,-1, 0, 1};   // 八个里没有 (0,0)!

【网格 DFS 框架(连通块:不撤销)】
  void dfs(int x, int y){
      g[x][y] = 0;                       // 标记
      for(int d = 0; d < 4; d++){
          int nx = x + dx[d], ny = y + dy[d];
          if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
          if(能走) dfs(nx, ny);
      }
  }
  // 主函数:每个没走过的起点 dfs 一次,次数就是连通块数

【网格 DFS 框架(路径计数:必须撤销)】
  void dfs(int x, int y){
      if(到终点){ ans++; return; }
      for(int d = 0; d < 4; d++){
          int nx = x + dx[d], ny = y + dy[d];
          if(越界 || 障碍 || vis[nx][ny]) continue;
          vis[nx][ny] = true;
          dfs(nx, ny);
          vis[nx][ny] = false;           // ★ 回溯,必须有
      }
  }

【八皇后冲突标记(i 行 j 列)】
  col[j]            列
  dg[i + j]         副对角线 ↙(i+j 是常数)
  udg[i - j + n]    主对角线 ↘(i-j 是常数,+n 防负数)
  // 数组开 2n+5;占位和撤销必须成对

【沿固定方向走(P1101 那种)】
  for(int d = 0; d < 8; d++)
      for(int k = 0; k < len; k++){
          int nx = i + dx[d] * k;        // ★ 别忘了乘 k
          int ny = j + dy[d] * k;
      }

【铁律】
  1 开始的下标 → 数组开到 n + 5,越界判断用 nx < 1
  0 开始的下标 → 越界判断用 nx < 0 || nx >= n
  修改状态 → dfs() → 恢复状态,三行永远成套出现

明天预告 · Day 20

BFS 入门:一层一层地扩散

今天的方向数组明天原封不动继续用(网格 BFS 也是 nx = x + dx[d])。 区别在于:DFS 一条道走到黑,用的是递归;BFS 一圈一圈往外扩,用的是队列

明天的主角是 P1443 马的遍历——一匹中国象棋的马在棋盘上跳,求它到每个格子的最少步数。 这类「最少步数」的问题,DFS 是做不动的(会超时),必须换 BFS。 而且马的方向数组不是那八个「斜一格」,而是 {-2,-2,-1,-1,1,1,2,2}{-1,1,-2,2,-2,2,-1,1}——今天先混个脸熟。

另外 P1219 那种「用数组记录冲突」的思路还会反复出现:以后学 N 皇后变形、数独、图着色,都是同一套「状态标记 + 回溯」。

ACM 一个月学习计划 · DAY 20

Day 20 · BFS 入门:一层一层地扩散

队列 + 方向数组 + 「入队即标记」—— 求最少步数的标准武器

今日目标:会写队列实现的 BFS,知道什么时候必须用 BFS 而不是 DFS
预计用时:约 3.5 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1443 / P1135 / P1746 / P1162
DAY 20 / 30

今日时间安排

0 - 30 minBFS 是什么,和 DFS 的分工(第 1 节)
30 - 55 min队列 queue 用法速成(第 2 节)
55 - 100 minBFS 通用模板 + 「入队即标记」(第 3 节)
100 - 130 min状态抽象:节点不一定是格子(第 4 节)
130 - 250 min做 4 道题,每题限时 30 分钟
250 - 280 min对照本文题解,把没做出来的补掉
280 - 310 min自测 + 存速查卡
💡 和昨天内容的衔接

昨天你学会了方向数组nx = x + dx[d])和网格 DFS 框架。今天这两个东西一个字都不用改, 只换一件事:把「递归深入」换成「用队列一层一层地扩散」。

为什么要换?因为 DFS 是「一条道走到黑」,它能告诉你「能不能走到」,但很难告诉你「最少几步能走到」—— 它第一次到达终点时走的路,未必是最短的。而 BFS 天生就是按距离从近到远的顺序扩散的,第一次碰到某个点,一定就是最短路

1BFS 是什么:往水里扔一块石头

先看一个画面

往平静的池塘里扔一块石头。波纹是怎么扩散的?

先是一圈小的,半径 1;然后半径 2;然后半径 3……同一圈上的水面,是「同时」被波及的。

🎯 BFS 的全部思想就这一句话

从起点开始,先把距离为 1 的所有点全部访问完,再访问距离为 2 的,再访问距离为 3 的……

「BFS」的全称是 Breadth-First Search,中文叫「广度优先搜索」——「广度优先」说的就是这个「先把一圈铺满,再走下一圈」。

为什么它能求最少步数

因为扩散顺序是按距离严格从小到大排的。所以当一个点第一次被访问到时,它必然是被「最近的那一圈」够到的。 我们把它记下来的距离,就是最短距离——不需要再比较、不需要再更新,记一次就完事。

⚠️ 前提:每一步的代价必须相同(边权为 1)

在网格里走一格、按一次电梯按钮、走一步马——这些都是「一步」,代价一样,所以 BFS 适用。

但如果题目变成「平地走一格 1 秒、上坡走一格 3 秒」,各条边的代价不一样了, BFS 就不再保证最短路,那时候要换算法(最短路:Dijkstra / SPFA,以后会学)。

DFS 和 BFS 的分工表

对比项DFS(深度优先)BFS(广度优先)
走法一条路走到黑,走不通再回头一层一层往外摊
实现工具递归(函数调用栈)队列 queue
擅长什么枚举所有方案、判断是否存在、连通块、回溯求最少步数 / 最少操作次数
空间和「最长路径深度」有关和「最宽的一层」有关
昨天的题P1219 八皇后、P1605 迷宫路径数、P1596 水塘、P1101 单词方阵
今天的题P1443 马的最少步数、P1135 最少按键、P1746 最短距离、P1162 填充
✅ 一句话判断该用哪个

题目问「有多少种」「能不能」「有哪些」→ 用 DFS
题目问「最少几步」「最短距离」「最少操作几次」→ 用 BFS
最后一道 P1162 是个例外:它只是「把区域填满」,用 DFS 或 BFS 都能做,我们用 BFS 顺手练一下。

2队列 queue:BFS 的发动机

队列是什么

排队买奶茶:先进先出。先来的人先买到,后来的人排在队尾。

队列这种「先进先出」的顺序,正好就是 BFS 需要的顺序:先把距离小的点排进去,它们自然就先被处理; 处理它们时新产生的点(距离 +1)排在队尾,等前面的都处理完才轮到它们。于是整个处理顺序就是按距离递增的。

四个操作,两分钟学会

想干什么写法说明
建一个存整数的队列queue<int> q;需要 #include <queue>
建一个存坐标的队列queue<pair<int,int>> q;存 (x, y) 一对数
把 x 放进队尾q.push(x);入队
看队头是谁q.front()只是「看一眼」,不删掉
把队头删掉q.pop();出队。注意它不返回任何值
队列空了吗q.empty()空返回 true
🚫 新手最容易踩的一个坑

q.pop() 不返回被删掉的元素!它就是个「删除」动作。

所以取出队头的标准姿势永远是两句

int cur = q.front();   // ① 先看一眼队头是什么
q.pop();               // ② 再把它删掉

写成 int cur = q.pop(); 会直接编译错误(void 值不能赋给 int)。

存坐标怎么办:pair

队列里要存 (x, y) 两个数,用 pair<int,int> 就能把两个整数打包成一个整体:

queue<pair<int,int>> q;      // 注意 >> 中间的老编译器要写空格,C++11 起可以直接写

q.push({x, y});              // 入队一个坐标(花括号打包)

pair<int,int> cur = q.front();
q.pop();
int cx = cur.first;          // 取出第一个数(行)
int cy = cur.second;         // 取出第二个数(列)
💡 关于 auto [x, y] = q.front();

你在别的地方可能见过这种写法(叫「结构化绑定」),确实好看。但它是 C++17 才有的语法, 用 -std=c++14 编译会报错。

很多 OJ 的编译器版本比较老,本文统一用 .first / .second, 兼容性最好,本文所有代码都是在 g++ -std=c++14 下编译通过的。

3BFS 通用模板(今天的核心,背下来)

queue<pair<int,int>> q;
q.push({sx, sy});
dis[sx][sy] = 0;                      // ★ 起点:入队的同时就把距离标记好

while(!q.empty()){
    pair<int,int> cur = q.front();    // ① 看一眼队头
    q.pop();                          // ② 删掉它
    int x = cur.first, y = cur.second;

    for(int d = 0; d < 4; d++){                    // ③ 昨天学的方向数组
        int nx = x + dx[d], ny = y + dy[d];
        if(nx < 1 || nx > n || ny < 1 || ny > m) continue;   // ④ 越界
        if(不能走) continue;                                  // ⑤ 墙 / 障碍
        if(dis[nx][ny] != -1) continue;                       // ⑥ 已经到过,别再走

        dis[nx][ny] = dis[x][y] + 1;                          // ⑦ 距离 = 上一层 + 1
        q.push({nx, ny});                                     // ⑧ 入队(⑦⑧两句必须挨着)
    }
}

和昨天的 DFS 框架对照着看

步骤DFS(昨天)BFS(今天)
标记初始点vis[sx][sy] = true;dis[sx][sy] = 0; 并且 push
枚举方向一模一样一模一样
越界 / 障碍判断一模一样一模一样
推进方式dfs(nx, ny); 直接递归进去dis[nx][ny] = dis[x][y]+1; q.push(...); 放进队列排队
撤销路径类题目要 vis = false永不撤销(最短距离一旦定下就不会变)

★ 最重要的一条规矩:入队时就标记

🚨 「入队时标记」和「出队时标记」,差别是天壤之别

看下面这段错误代码——它把标记放在了出队的时候:

// ❌ 错误写法
while(!q.empty()){
    auto cur = q.front(); q.pop();
    int x = cur.first, y = cur.second;
    vis[x][y] = true;              // ← 标记在出队时才做
    for(int d = 0; d < 4; d++){
        int nx = x + dx[d], ny = y + dy[d];
        if(越界 || 不能走) continue;
        if(vis[nx][ny]) continue;
        dis[nx][ny] = dis[x][y] + 1;
        q.push({nx, ny});            // ← 同一个点可能被 push 好几次!
    }
}

问题出在哪?假设 A 和 B 都和起点相邻,B 又和 C 相邻。处理 A 的时候把 C 也放进队列了, 但此时 C 还没被标记(它还在队里等着),于是处理 B 的时候又把 C 再放一次

结果:同一个点被重复入队,队列迅速膨胀,轻则超时、重则内存爆掉(MLE)。 在 400×400 的棋盘上这种情况会被放大成千上万倍。

正确做法:dis[nx][ny] = dis[x][y] + 1;q.push({nx, ny}); 这两句必须紧挨着, 一旦入队就立刻算「已经到过」。(起点也一样,push 之前就设好 dis[sx][sy] = 0。)

💡 为什么 BFS 里的 dis 数组还能兼职当 vis

因为我们一开始把 dis 全部初始化成 −1(表示「还没到过」), 之后凡是走到过的点都会变成 ≥ 0 的数。

于是 dis[nx][ny] != -1 就等价于「这个点已经到过了,跳过」—— 一个数组同时干了两件事。而且最后题目要输出「到不了的点」,我们直接输出 −1 就行, 连额外的特判都省了(P1443 就是这么做的)。

4状态抽象:BFS 的节点不一定是格子

这是今天最重要的思维跃迁,也是 P1135 最想教你的东西。

换个视角看问题

前面我们一直说「点」就是棋盘上的格子。但 BFS 本质上是在一张上跑: 图里有若干个状态(结点),状态之间如果「一步能到」就有一条边。

至于这个「状态」是什么形状,BFS 一点都不关心:

题目「点」是什么「一步」是什么距离数组
P1443 马的遍历棋盘上的一格 (x, y)马跳一次(8 种)dis[405][405] 二维
P1746 离开中山路地图上的一格 (x, y)上下左右走一格dis[1005][1005] 二维
P1162 填涂颜色方阵里的一格 (x, y)上下左右走一格只要 bool 标记
P1135 奇怪的电梯一个楼层编号 f(就一个整数!)按一次上 / 下一次按钮dis[205] 一维
🎯 一句话总结

「状态」就是「局面」的抽象。棋盘局面是两个坐标,电梯局面是一个楼层号, 以后还会遇到「坐标 + 剩余钥匙数」「坐标 + 时间」这样的状态。

一旦你把它抽象成「结点 + 边走一步」,剩下的代码就是同一个 BFS 模板—— 换的是状态的表示方式,不换的是 BFS 的骨架。

一维 BFS 和二维 BFS 的写法对照

// 二维:状态是 (x, y),队列存 pair,邻居靠方向数组算
queue<pair<int,int>> q;
q.push({sx, sy});  dis[sx][sy] = 0;
...
for(int d = 0; d < 4; d++){
    int nx = x + dx[d], ny = y + dy[d];
    ...
}

// 一维:状态就是一个楼层号,队列存 int,邻居靠「加 / 减 K」算
queue<int> q;
q.push(a);  dis[a] = 0;
...
int nxt[2] = {cur + k[cur], cur - k[cur]};   // ★ 「方向」变成了「加/减 K」
for(int t = 0; t < 2; t++){
    int nx = nxt[t];
    ...
}
💡 看出来了吗——「方向」也可以不是数组

在网格里,四个方向太规整了,所以我们用数组存偏移量。 在电梯里,两个「方向」是 +K−K,每次都不同,直接算出来比查表更自然。

所以方向数组不是必需的,它只是「一组固定的偏移量」的一种优雅写法。 当你发现偏移量是算出来的、每个点都不一样时,就老老实实写算式。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过,输出是真实结果。

P1443 马的遍历 普及- · BFS 求最短步数模板题

题意:在 n×m 的棋盘上,点 (x, y) 有一匹中国象棋的马。求这匹马走到棋盘上每一个格子最少需要几步。 走不到的格子输出 -1。输出一个 n 行 m 列的矩阵。

⚠️ 数据范围 / 关键约束

对于全部测试点,1 ≤ x ≤ n ≤ 400,1 ≤ y ≤ m ≤ 400。也就是说棋盘最大 400×400 = 16 万个格子

16 万这个规模说明:必须一次 BFS 就求出「到所有格子的距离」, 而不能对每个格子单独搜一遍(那样就是 16 万次 BFS,必超时)。BFS 一次扩散恰好覆盖所有能到达的格子, 复杂度是 O(n·m),非常合适。

输出格式:本题原版要求每个数字占 5 个字符左对齐;2022 年 8 月之后官方去掉了场宽要求, 明确说明「以空格或者合理的场宽分割每个整数都将判作正确」。所以直接空格分隔就行(下面的代码就是这么写的)。

思路

  1. 写出马的方向数组:马走「日」字,八种走法:dx[8] = {-2,-2,-1,-1,1,1,2,2}dy[8] = {-1,1,-2,2,-2,2,-1,1}。检查方法:每个方向都是「一个坐标动 2、另一个动 1」。
  2. dis 全部初始化成 −1:用 memset(dis, -1, sizeof(dis))。−1 同时表示「没到过」和「到不了」两个意思。
  3. 起点入队push({x, y})dis[x][y] = 0(马自己所在的位置需要 0 步)。
  4. 标准 BFS:出队 → 八个方向 → 越界就跳过 → dis != -1 就跳过 → 否则算距离、入队。
  5. 输出矩阵:扫描 n×m 输出 dis[i][j],到不了的自然还是 −1。
样例输入
3 3 1 1
样例输出
0 3 2
3 -1 1
2 1 4

马在 (1,1),到 (1,2) 要 3 步,到 (2,2) 到不了(3×3 的正中间是马的死穴),所以是 −1。

0 起点32 3−1 到不了1 214

完整代码

#include <iostream>
#include <queue>
#include <cstring>
using namespace std;

int n, m, x, y;
int dis[405][405];          // dis[i][j] = 马到 (i,j) 的最少步数,-1 表示到不了

// ★ 马走「日」字的 8 个方向(一个坐标动 2,另一个动 1)
int dx[8] = {-2, -2, -1, -1,  1, 1,  2, 2};
int dy[8] = {-1,  1, -2,  2, -2, 2, -1, 1};

int main(){
    cin >> n >> m >> x >> y;

    memset(dis, -1, sizeof(dis));       // 先全部设成 -1 = 还没到过(也等于到不了)
    queue<pair<int,int>> q;
    q.push({x, y});
    dis[x][y] = 0;                      // ★ 起点:入队的同时就把距离标记好

    while(!q.empty()){
        pair<int,int> cur = q.front();  // ① 看一眼队头
        q.pop();                        // ② 再删掉它
        int cx = cur.first, cy = cur.second;
        for(int d = 0; d < 8; d++){                   // ★ 马有 8 种走法
            int nx = cx + dx[d], ny = cy + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > m) continue;   // 跳出棋盘了
            if(dis[nx][ny] != -1) continue;                      // ★ 入队时判重
            dis[nx][ny] = dis[cx][cy] + 1;                       // 扩散一层,步数 +1
            q.push({nx, ny});                                    // 入队(和上一句必须挨着)
        }
    }

    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            if(j > 1) cout << " ";      // 数字之间用空格分隔
            cout << dis[i][j];          // 到不了的自然就是 -1
        }
        cout << "\n";
    }
    return 0;
}
✅ 实测结果

官方样例 3 3 1 1 → 实际输出

0 3 2
3 -1 1
2 1 4

数值与样例完全一致(样例原文中每个数字占 5 个字符的场宽,官方已说明空格分隔同样判对)。

最大数据 400 400 1 1 → 第一行是 0 3 2 3 2 3 4 5 …, 第二行以 3 4 1 2 3 4 … 开头,最后一行以 … 201 200 201 结尾, 用时约 50 ms(总共访问 16 万个格子,时限 1 秒,非常宽裕)。

易错点

  • 方向数组写错:马是 (2,1) 型而不是 (1,1) 型斜线。漏一个方向会导致大片格子算成 −1。
  • dis 没有初始化成 −1:全局数组默认是 0,那样「没到过的点」也会被当成距离 0,全盘皆错。
  • 忘记 dis[x][y] = 0:起点会被当成「没到过」,然后又被邻居重新入队,甚至输出 −1。
  • 标记时机:一定要在 push 之前/同时设置 dis(见第 3 节)。
  • 行末多打空格:用 if(j > 1) cout << " "; 控制,别每行末尾留一个空格。
  • 数组开 405×405:n、m 最大 400,下标要到 400,开 405 留余量。
P1135 奇怪的电梯 普及/提高- · 一维状态 BFS

题意:一栋 N 层的楼,第 i 层上写着一个数字 Ki。电梯只有「上」「下」两个有用按钮, 且上下都必须走 Ki 层(在 1 层按「上」就到 1+K1 层,按「下」如果超出 1~N 就失灵)。 问从 A 层到 B 层,最少要按几次按钮;到不了输出 −1。

⚠️ 数据范围 / 关键约束

对于 100% 的数据,1 ≤ N ≤ 200,1 ≤ A, B ≤ N,0 ≤ Ki ≤ N。本题共 16 个测试点。

注意 Ki 可以是 0!这时在 i 层按上按下都还在 i 层——所以「走到的楼层」有可能等于「当前楼层」, 这类「原地不动」的边必须靠 dis 判重挡掉,否则会死循环。

思路

这题是状态抽象的最佳教材:结点不是坐标,而是一个楼层编号

  1. 状态 = 楼层:一共有 N 个状态(1 到 N 层),距离数组只需要一维 dis[205]
  2. 从状态 cur 出发能到哪:两个「方向」——cur + k[cur]cur - k[cur]
  3. 越界判断nx < 1 || nx > n 就跳过(没有 0 层,也没有 N+1 层)。
  4. 标准 BFS:入队时设 dis,dis != -1 就跳过。
  5. 答案就是 dis[b]:到不了就还是 −1,直接输出即可,不用特判。
🎯 想一想:为什么用 BFS 而不是 DFS

题目问的是「最少按几次」,这正是 BFS 的主场。 如果用 DFS 把所有能走的路线都搜一遍再取最小,虽然 N ≤ 200 也许能过,但写法复杂、还容易写错。

换成 BFS:第 1 层扩散出的都是「按 1 次能到的楼层」,第 2 层是「按 2 次的」…… 第一次遇到 B 层时,按的次数就已经是最少的了。

样例输入
5 1 5
3 3 1 2 5
样例输出
3

路线:1 层上到 4 层(K₁=3),4 层下到 2 层(K₄=2),2 层上到 5 层(K₂=3),一共按 3 次。

完整代码

#include <iostream>
#include <queue>
using namespace std;

int n, a, b;
int k[205];          // 第 i 层楼上的数字
int dis[205];        // 到第 i 层最少按几次,-1 表示到不了

int main(){
    cin >> n >> a >> b;
    for(int i = 1; i <= n; i++){
        cin >> k[i];
        dis[i] = -1;                 // 先全部设成「到不了」
    }

    queue<int> q;                    // ★ 状态就是「楼层」这一个数,所以队列存 int
    q.push(a);
    dis[a] = 0;                      // 起点:入队时同时标记

    while(!q.empty()){
        int cur = q.front();
        q.pop();
        int nxt[2] = {cur + k[cur], cur - k[cur]};   // 「方向」= 上楼 / 下楼
        for(int t = 0; t < 2; t++){
            int nx = nxt[t];
            if(nx < 1 || nx > n) continue;           // 没有 0 层,也没有 N+1 层
            if(dis[nx] != -1) continue;              // 走过了就不再走(K=0 时靠这句防死循环)
            dis[nx] = dis[cur] + 1;
            q.push(nx);
        }
    }

    cout << dis[b] << endl;          // 到不了自然就是 -1,不用特判
    return 0;
}
✅ 实测结果

官方样例 5 1 5 / 3 3 1 2 5 → 输出 3 与样例完全一致

补充测试:

· 4 1 4,K 全是 0 → 输出 −1(哪一层都动不了,正确)。
· 5 3 3(起点就是终点)→ 输出 0(一步都不用按,正确)。

易错点

  • A 等于 B 时答案应为 0。代码里 dis[a] = 0 已经处理好了,但如果忘了给起点设初值就会输出 −1。
  • Ki = 0 的楼层:会生成「走到自己」的边,一定要靠 dis != -1 挡掉,否则死循环。
  • 越界判断写成 nx <= 0nx >= n:楼层是 1~N,应该是 nx < 1 || nx > n
  • 数组开 [205]:N 最大 200,下标要到 200,开 205 留余量。
  • 不要把 k 当成「方向数组」硬塞进 dx/dy:这里每个状态的步长都不同,直接算更清楚。
P1746 离开中山路 普及- · 网格 BFS 最短距离

题意:给一个 n×n 的地图,0 表示马路(可以走),1 表示店铺(不能穿过)。 给出起点 (x₁, y₁) 和车站 (x₂, y₂),只能上下左右沿着马路走。求走到目的地的最短距离(相邻两格距离为 1)。

⚠️ 数据范围 / 关键约束

对于 20% 的数据,1 ≤ n ≤ 100;对于 100% 的数据,1 ≤ n ≤ 1000

地图最大 1000×1000 = 100 万个格子,所以要注意两件事: ① 复杂度必须是 O(n²) 级别(BFS 正好); ② 距离数组一定要开在全局——int dis[1005][1005] 大约占 4 MB, 如果写在 main 里面(栈上),会直接爆栈崩溃。

地图每行的数字之间没有空格,所以要么用 cin >> g[i][j] 逐字符读,要么整行读成字符串。

思路

这就是「网格 + 边权为 1 + 求最短路」的标准场景,直接用第 3 节的模板套上去:

  1. 读地图:逐字符读进 char g[1005][1005],同时把 dis 全部初始化成 −1。
  2. 读起点终点:注意顺序是 x₁ y₁ x₂ y₂(前面是行号,后面是列号)。
  3. 起点入队dis[x1][y1] = 0
  4. 四方向 BFS:遇到 g == '1'(店铺)跳过,遇到 dis != -1(走过)跳过。
  5. 输出 dis[x2][y2]:走不到就是 −1。
样例输入
3
001
101
100
1 1 3 3
样例输出
4

路径:(1,1) → (1,2) → (2,2) → (3,2) → (3,3),共 4 步。

完整代码

#include <iostream>
#include <queue>
using namespace std;

int n;
char g[1005][1005];      // '0' 马路,'1' 店铺
int dis[1005][1005];     // ★ 4MB,必须开在全局(写在 main 里会爆栈)

int dx[4] = {0, 0, 1, -1};   // 昨天的方向数组,原封不动
int dy[4] = {1, -1, 0, 0};

int main(){
    cin >> n;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++){
            cin >> g[i][j];       // 逐字符读入,可以跳过换行
            dis[i][j] = -1;       // 顺手初始化成「没到过」
        }

    int x1, y1, x2, y2;
    cin >> x1 >> y1 >> x2 >> y2;   // 起点、终点

    queue<pair<int,int>> q;
    q.push({x1, y1});
    dis[x1][y1] = 0;                  // 起点入队 + 标记

    while(!q.empty()){
        pair<int,int> cur = q.front();
        q.pop();
        int cx = cur.first, cy = cur.second;
        for(int d = 0; d < 4; d++){
            int nx = cx + dx[d], ny = cy + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > n) continue;   // 越界
            if(g[nx][ny] == '1') continue;                       // 店铺,不能走
            if(dis[nx][ny] != -1) continue;                      // 已经到过
            dis[nx][ny] = dis[cx][cy] + 1;
            q.push({nx, ny});
        }
    }

    cout << dis[x2][y2] << endl;       // 到不了就是 -1
    return 0;
}
✅ 实测结果

官方样例 → 输出 4 与样例完全一致

补充测试:

· 1000×1000 全马路,从 (1,1) 到 (1000,1000) → 输出 1998(只往右往下走,999+999 步,正确),用时约 115 ms
· 中间被一排店铺完全隔断的地图 → 输出 −1,符合预期。

易错点

  • 数组开在 main 里面:1000×1000 的 int 数组约 4 MB,局部变量放在栈上会崩,一定要写在所有函数外面(全局)。
  • 地图读入方式混乱cin >> g[i][j] 读字符是安全的;但如果先用 cin >> n 再用 getline,会先读到那个残留的换行符。
  • '0'/'1' 当成整数 0/1 比较:地图存的是字符,判断必须写 g[nx][ny] == '1'(带单引号)。
  • 终点本身就是店铺:本题数据不保证不会,稳妥起见可以特判;不过按题意车站一定在可到达的位置。
  • 起点等于终点dis 初值是 0,输出 0,正确。
P1162 填涂颜色 普及- · 多源 BFS 填充

题意:给一个由 0 和 1 组成的 n×n 方阵,其中有一个由 1 构成的闭合圈。 把闭合圈内部的所有 0 都改成 2,其余不变,然后输出整个方阵。

题目给的判断标准很关键:如果从某个 0 出发,只走上下左右、只经过 0,却永远到不了方阵的边界,那它就在圈内。

⚠️ 数据范围 / 关键约束

对于 100% 的数据,1 ≤ n ≤ 30。方阵内只有一个闭合圈,圈内至少有一个 0。

n 只有 30,规模非常小,DFS 和 BFS 都能做。但这里用 BFS 有一个天然的优势:多个起点可以一次性全部入队(见下面的思路)。

思路:反过来想,标记「圈外」

直接找「哪些 0 在圈内」很难——你得先知道圈在哪、圈是什么形状。

但反过来想就简单多了:一个 0 只要能从边界走到,它就是圈外的。 而「从边界能走到」正好是一个 BFS 能回答的问题:

  1. 把所有边界上的 0 一次性全部入队(第 1 行、第 n 行、第 1 列、第 n 列上的 0),并标记它们「属于圈外」。
  2. 从这些起点一起做 BFS,沿着上下左右扩散,凡是能走到的 0 都标记成「圈外」。
  3. BFS 结束后,所有没被标记的 0 就是走不到边界的——它们必然在圈内。
  4. 输出1 照抄;0 且被标记 → 输出 00 且没被标记 → 输出 2
🎯 多源 BFS:多个起点一起入队

普通的 BFS 只有一个起点。这里我们把边界上所有合法的 0 在循环开始前全部 push 进同一个队列, 然后照常跑 BFS——这叫做多源 BFS

它的效果是:每个格子记录的是「到最近的那个起点的距离」。 本题我们连距离都不用存,只要一个 bool outside 标记「能不能从某条边界走到」就够。

多源 BFS 是 Day 21 的主角之一(P1332 血色先锋队就靠它),今天先用 P1162 尝个鲜。

样例输入
6
0 0 0 0 0 0
0 0 1 1 1 1
0 1 1 0 0 1
1 1 0 0 0 1
1 0 0 0 0 1
1 1 1 1 1 1
样例输出
0 0 0 0 0 0
0 0 1 1 1 1
0 1 1 2 2 1
1 1 2 2 2 1
1 2 2 2 2 1
1 1 1 1 1 1

注意输入的最后两行:第 5 行左边的 1 0 0 0 0 1, 这几个 0 其实是被圈住的(左边被第 5 行第 1 列的 1 挡住、下边被第 6 行挡住),所以它们都被涂成 2。

完整代码

#include <iostream>
#include <queue>
using namespace std;

int n;
int g[35][35];
bool outside[35][35];      // 这格的 0 能不能从边界走到(true = 圈外)

int dx[4] = {0, 0, 1, -1};
int dy[4] = {1, -1, 0, 0};

int main(){
    cin >> n;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= n; j++)
            cin >> g[i][j];

    // 第一步:把所有边界上的 0 一次性入队(多源 BFS 的「多源」就在这里)
    queue<pair<int,int>> q;
    for(int i = 1; i <= n; i++){
        if(g[i][1] == 0 && !outside[i][1]){ outside[i][1] = true; q.push({i, 1}); }
        if(g[i][n] == 0 && !outside[i][n]){ outside[i][n] = true; q.push({i, n}); }
    }
    for(int j = 1; j <= n; j++){
        if(g[1][j] == 0 && !outside[1][j]){ outside[1][j] = true; q.push({1, j}); }
        if(g[n][j] == 0 && !outside[n][j]){ outside[n][j] = true; q.push({n, j}); }
    }

    // 第二步:从边界一路扩散,能走到的 0 全是「圈外」
    while(!q.empty()){
        pair<int,int> cur = q.front();
        q.pop();
        int cx = cur.first, cy = cur.second;
        for(int d = 0; d < 4; d++){
            int nx = cx + dx[d], ny = cy + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > n) continue;   // 越界
            if(g[nx][ny] == 1 || outside[nx][ny]) continue;      // 是墙,或已经标记过
            outside[nx][ny] = true;
            q.push({nx, ny});
        }
    }

    // 第三步:输出。是 1 就照抄;是圈外的 0 也照抄;剩下的 0 全涂成 2
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= n; j++){
            if(j > 1) cout << " ";
            if(g[i][j] == 1) cout << 1;
            else cout << (outside[i][j] ? 0 : 2);
        }
        cout << "\n";
    }
    return 0;
}
💡 输出那三行是在干什么

把三种格子对应清楚就不容易写错:

格子原本是能不能从边界走到输出
1(墙)1
0能(圈外)0
0不能(圈内)2
✅ 实测结果

官方样例 → 实际输出

0 0 0 0 0 0
0 0 1 1 1 1
0 1 1 2 2 1
1 1 2 2 2 1
1 2 2 2 2 1
1 1 1 1 1 1

与样例逐字符完全一致(6 行,含数字间的空格)。

补充测试:30×30 外圈全是 1、里面全是 0 → 输出 30 行,第 1 行全是 1,第 2 行是 1 2 2 … 2 1, 即内圈 28×28 全部涂成 2,符合预期。

易错点

  • 只从 4 个角出发:圈外的 0 可能只和边上中间的某个 0 连通,角落出发会漏掉一部分,导致把圈外的 0 误涂成 2。要把四条边上的所有 0 都入队。
  • 忘记排除墙:扩散时 g[nx][ny] == 1 必须跳过,否则 1 会被当成可通行。
  • 输入带空格:本题输入的数字之间有空格,所以用 cin >> g[i][j] 读整数即可(注意别和 P1746 那种无空格字符地图搞混)。
  • 输出格式:数字间用空格分隔,行末不要留多余空格。
  • outside 反过来理解:它标记的是「圈外」,不是「圈内」,写输出时别弄反。

今日常见错误汇总

错误写法后果正确写法
在「出队时」才打访问标记同一个点被反复入队,队列爆炸 → TLE / MLE入队前就设好 dis[nx][ny] = dis[x][y] + 1;push
int cur = q.pop();编译错误(pop 不返回值)int cur = q.front(); q.pop();
忘记 dis[sx][sy] = 0;起点被当成没到过,答案错乱甚至输出 −1push 起点的同时就标记距离 0
dis 没初始化成 −1全局数组默认 0,「没到过」和「距离 0」混淆memset(dis, -1, sizeof(dis));
400×400 / 1000×1000 的 dis 写在 main栈空间不够 → 程序直接崩大数组一律写在所有函数外面(全局)
在 BFS 里加 vis = false 撤销点被重复访问,死循环或超时BFS 永不撤销(和 DFS 路径计数不同)
各边代价不同还用 BFS 求最短路答案不是最短只有边权为 1 才能用 BFS;否则要用最短路算法
P1162 只从 4 个角开始 BFS边上中间连通的圈外 0 被误涂成 2四条边上所有 0 一起入队(多源 BFS)
把字符地图当整数比较:g[nx][ny] == 1永远不成立,墙形同虚设字符地图写 == '1',整数地图才写 == 1

自测:你真的会了吗

第 1 题:什么题必须用 BFS,什么题用 DFS?

:问「最少步数 / 最短距离 / 最少操作次数」,而且每一步代价相同 → BFS
问「有多少种方案 / 能不能做到 / 有几个连通块」→ DFS(这也是为什么昨天四道题全是 DFS)。

★ 注意前提:BFS 求最短路只在边权为 1(每步代价一样)时成立。

第 2 题:为什么必须在「入队时」标记,不能「出队时」标记?

:因为一个点可能有多个邻居。若出队时才标记,那么在这些邻居被处理时,它还是「未标记」状态, 于是会被每个邻居各 push 一次,同一个点重复入队。

重复入队会让队列长度暴涨(16 万格的棋盘上可能变成几百万次操作),结果是 TLE 甚至 MLE。 入队时立刻标记,等价于「这个点我已经预约了,别人别再排队」。

第 3 题:手算 BFS 距离

3×3 棋盘,马在 (1,1),马走「日」字。请问 (2,3) 的距离是几?(2,2) 呢?

:(2,3) 是 1 步——(1,1) + (1,2) = (2,3),正好是马的一个走法。
(2,2) 是 −1(到不了)——从 (1,1) 能到 (2,3)、(3,2),从 (2,3) 又能到 (1,1)、(3,1)、(4,2)… 全都出界或回到原点。 3×3 棋盘的正中心是马的死角。这正是官方样例里那个 −1 的来历。

第 4 题:P1135 里,如果 Ki = 0 会怎样?

:在 i 层按上按下都还停在 i 层,也就是产生了一条「自己指向自己」的边。

这时候靠 if(dis[nx] != -1) continue; 挡住就行——因为 i 层本身在入队时就已经被标记过了, 「走到自己」会被判为「已经到过」直接跳过,不会死循环。

如果没有这个判重,就会无限循环。这也说明「dis 兼职 vis」这个设计有多重要。

第 5 题(手写代码):BFS 求一张 01 网格里,从 (1,1) 到每个格子的最少步数(1 是墙)
int n, m;
int g[1005][1005], dis[1005][1005];
int dx[4] = {0, 0, 1, -1}, dy[4] = {1, -1, 0, 0};

void bfs(int sx, int sy){
    queue<pair<int,int>> q;
    memset(dis, -1, sizeof(dis));
    q.push({sx, sy});
    dis[sx][sy] = 0;                    // ★ 入队即标记
    while(!q.empty()){
        pair<int,int> cur = q.front(); q.pop();
        int x = cur.first, y = cur.second;
        for(int d = 0; d < 4; d++){
            int nx = x + dx[d], ny = y + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
            if(g[nx][ny] == 1) continue;
            if(dis[nx][ny] != -1) continue;
            dis[nx][ny] = dis[x][y] + 1;
            q.push({nx, ny});
        }
    }
}

★ 请默写三遍,直到不用看就能写出来——这是后面所有 BFS 题(Day 21 的多源 BFS、状态 BFS)的骨架。

速查卡(截图存手机)

【队列】
  #include <queue>
  queue<int> q;                 // 存一个数(如楼层)
  queue<pair<int,int>> q;       // 存坐标
  q.push(x);                    // 入队
  int cur = q.front(); q.pop();  // ★ 取出队头必须两句
  q.empty()                     // 判空
  cur.first / cur.second        // pair 取两个分量(C++14 不能用结构化绑定)

【BFS 网格模板(求最少步数)】
  memset(dis, -1, sizeof(dis));
  queue<pair<int,int>> q;
  q.push({sx, sy});
  dis[sx][sy] = 0;                      // ★ 入队即标记
  while(!q.empty()){
      pair<int,int> cur = q.front(); q.pop();
      int x = cur.first, y = cur.second;
      for(int d = 0; d < 4; d++){
          int nx = x + dx[d], ny = y + dy[d];
          if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
          if(不能走) continue;
          if(dis[nx][ny] != -1) continue;   // 到过了
          dis[nx][ny] = dis[x][y] + 1;
          q.push({nx, ny});                 // 这两句必须挨着
      }
  }
  // dis 里剩下的 -1 就是「到不了」

【一维状态 BFS(P1135 型)】
  queue<int> q;  q.push(a);  dis[a] = 0;
  while(!q.empty()){
      int cur = q.front(); q.pop();
      int nxt[2] = {cur + k[cur], cur - k[cur]};   // 方向是算出来的
      for(int t = 0; t < 2; t++){
          int nx = nxt[t];
          if(nx < 1 || nx > n) continue;
          if(dis[nx] != -1) continue;
          dis[nx] = dis[cur] + 1;
          q.push(nx);
      }
  }

【多源 BFS(P1162 型)】
  // 把所有起点在循环前一次性 push 进去,其余照常
  for(每个边界上的 0) { mark = true; q.push(...); }
  // 跑完 BFS,没被标记的 0 就是「圈内」

【铁律】
  1. 入队时就标记,绝不出队才标记
  2. BFS 不撤销标记(DFS 路径计数才撤销)
  3. 大数组(> 10^5)一律开全局
  4. 边权不为 1 时,BFS 求最短路不成立

明天预告 · Day 21

BFS 进阶 + 第三周复盘

今天的 BFS 只处理了「一个起点、一种状态」。明天要把它撑开:

· 多源 BFS:把多个起点一次性全部入队,得到的就是「到最近的起点的距离」——P1332 血色先锋队、 P2895 Meteor Shower 都靠这一招(今天 P1162 已经偷偷用过一次了)。
· 带时间维度的状态:P2895 里陨石会按时间砸下来,同一格「早到」和「晚到」的处境完全不同, 这就需要把「时间」也纳入状态,或者在 BFS 时把时间一起算进去。
· 连通块 + 记忆化:P1141 01迷宫有 10⁵ 次询问,如果每次询问都 BFS 一遍必然超时, 标准做法是「先给每个连通块染个色、记下大小,之后每次询问 O(1) 回答」。

今天请务必把 BFS 模板默写到不用思考的程度——明天的所有题目都是它的变形。

ACM 一个月学习计划 · DAY 21

Day 21 · BFS 进阶 + 第二/三周复盘

BFS 不只是「在网格上走」——今天把它升级成三个通用套路:多源起点、带时间的环境、以及「搜一次、答很多次」的预处理

今日目标:掌握多源 BFS、带时间维度的状态预处理、连通块染色记忆化;并用递归分治开个新脑洞
预计用时:约 4 小时(含做题与复盘)  难度:普及/提高-
题目来源:洛谷 P1141 / P1332 / P2895 / P1228
DAY 21 / 30

今日时间安排

0 - 25 min搞清「状态」是什么,学会估算搜索复杂度(第 1 节)
25 - 55 min多源 BFS + 时间维度预处理(第 2、3 节)
55 - 85 min连通块染色——今天的重头戏(第 4 节)
85 - 175 min做 4 道题,每题限时 30 分钟(其中 P1228 可以放宽到 40 分钟)
175 - 205 min对照本文题解,把没做出来的补掉
205 - 240 min★ 第三周复盘:默写模板 + 重做卡住的题(第 6 节)
💡 昨天内容的衔接

Day 20 你已经会写「网格上的 BFS 模板」了:起点入队、四方向扩展、dist 数组记步数、队列空了就结束。

今天的四道题,模板一行都不用改,改的全是「起点有几个」「什么时候能进这个格子」「搜完之后答案怎么复用」。也就是说:今天的难点不在 BFS 本身,而在怎么给 BFS 用对地方。

1先想清楚两件事:状态是什么,代价有多大

「状态」= 你站在哪,用一个变量组合表示出来

BFS 的本质是「在一张图上按层扩散」。图上的每个结点叫一个状态,每条叫一次转移

Day 20 的迷宫题里,状态就是 (x, y) 这一个坐标对。但状态不一定是坐标,它可以是任何「能唯一描述当前局面」的东西:

题目状态是什么状态总数转移是什么
P1443 马的遍历棋盘上的格子 (x, y)n × m马的 8 种走法
P1135 奇怪的电梯当前楼层 fn上楼 ki 层 / 下楼 ki 层
P1141 01迷宫格子 (x, y)n × n = 10⁶走向四邻中数字不同的格
P1332 血色先锋队格子 (x, y)n × m四方向走一格
P2895 陨石格子 (x, y) + 时刻格子数 × 时刻数四方向走一格(耗时 1)
🎯 估算复杂度的一句话公式

总操作数 ≈ 状态总数 × 每个状态的转移数

先把状态数估出来,你就知道会不会 TLE,也就知道该往哪个方向优化。

用这个公式看今天的第一道题

P1141 里,迷宫是 n × n(n ≤ 1000),所以状态总数是 10⁶,每个状态的转移是 4。

做法一次询问的代价m 次询问的总代价n=1000, m=10⁵
每次询问都单独 BFS 一遍O(n²) = 10⁶O(m · n²)10¹¹ ❌ 必 TLE
先染色预处理,再 O(1) 查表O(1)O(n² + m)约 1.1×10⁶
⚠️ 记住这个数字对比

同样一道题,同样的 BFS 模板,一个 10¹¹,一个 10⁶,差了十万倍
差别只在于:你是在「每次询问都重新搜一遍」,还是「搜一次,把答案存下来给所有询问用」。

2多源 BFS:把多个起点一次性全塞进队列

先看场景(P1332 血色先锋队)

军团是 n 行 m 列的一片区域,每格一个人。有 a 个感染源,瘟疫每小时向四周扩散一圈。问 b 个领主各自在第几小时被感染。

如果只有一个感染源,那就是 Day 20 的模板:源点入队,dist[源] = 0,正常 BFS。

现在有 a 个源。最笨的想法是:对每个领主,从每个源各搜一次取最小值 —— 那是 a × b 次 BFS,直接爆炸。

🎯 多源 BFS 只有一句话

把所有起点在 BFS 开始前一次性全部入队,且它们的距离都是 0,然后照常 BFS。

这样搜完之后,dist[x][y] 就等于「到最近的那个起点的距离」。

为什么这样是对的

想象一个不存在的「超级起点 S」,它到每一个感染源都有一条长度为 0 的边。

那么「从超级起点 S 出发的普通 BFS」给出的距离,正是「到最近感染源的距离」。而把 a 个源同时以距离 0 入队,效果和这个超级起点完全一样——它们都是「第 0 层」的结点。

BFS 是按层扩散的,第 0 层同时站着 a 个人,第 1 层就是离这些源最近的格子……谁先扩散到某一格,那个格子的距离就是「到最近源」的距离。因为 BFS 保证第一次访问到某个点时就是最短距离,而抢先到达它的那个源,就是离它最近的那个源。

代码上要怎么改

只改读入那一段,其它一行不动:

// 普通 BFS:一个起点
dist[sx][sy] = 0;
q.push({sx, sy});

// 多源 BFS:所有起点一起入队,距离都是 0
for(int k = 0; k < a; k++){
    int x, y;  cin >> x >> y;
    if(dist[x][y] == -1){        // 防止同一个源被塞进队列两次
        dist[x][y] = 0;
        q.push({x, y});
    }
}
💡 一个坑:感染源可能重复

数据范围里 a ≤ 10⁵,但格子只有 n × m ≤ 250000 个,所以同一个格子被反复列为感染源是很正常的

加一句 if(dist[x][y] == -1) 就不会重复入队。
就算不加,BFS 里也有 dist[nx][ny] != -1 的判重保护,答案依然正确,只是队列里会多出一堆无用的元素——能加就加上,白省一点。

✅ 复杂度

入队 O(a),BFS O(n·m),回答 O(b) → O(n·m + a + b)

本题 n, m ≤ 500(25 万格),a, b ≤ 10⁵,总操作数约 4.5×10⁵,随便过。

3带时间维度的 BFS:先造一张「危险时刻表」

P2895 陨石:环境是会变的

贝茜站在原点 (0,0)。有 M 颗流星会砸下来,第 i 颗在时刻 Tᵢ 砸在 (Xᵢ, Yᵢ),并把落点以及上下左右四格全部烧成焦土。贝茜每单位时间能走一格,问最少多久能走到一个「永远不会被烧」的格子上,不可能则输出 −1。

⚠️ 和 Day 20 的迷宫最大的不同

Day 20 的迷宫,障碍是固定的:0 永远是 0,1 永远是 1。
这里障碍是随时间出现的:t = 1 时那格还能站,t = 2 时那格已经烧了。

所以「能不能进某个格子」不再只看坐标,还要看你几点到

做法一(错的方向):把时间放进状态

很自然会想:状态写成 (x, y, t) 不就行了?

但这样状态总数变成「格子数 × 时刻数」。坐标最多 300×300,时刻最多 1000,那就是 9×10⁷ 个状态,内存和时间都吃紧,而且——根本没必要

做法二(正确):先预处理,再普通 BFS

🎯 关键观察:「越早到越好」

危险区只会越来越大,不会缩小。所以如果你在时刻 t 能站在格子 (x,y),那么在 t′ < t 的时刻你当然也能站在这里;而且早到意味着你之后的选择只多不少

既然「早到」永远不吃亏,那我们只需要知道到达每一格的最早时刻——这正是 BFS 求出来的 dist

所以时间根本不用进状态,只要在「能不能踏进去」这一步加一个时刻判断即可。

分两步走:

  1. 预处理出一张危险时刻表 burn[x][y]:这个格子最早在第几时刻变成焦土。没被烧过就记成一个很大的数(表示永远安全)。
    对每颗流星,更新它的落点和四个邻居,取最小值(同一格可能被多颗流星砸到,最早那次说话)。
  2. 普通 BFS,唯一区别是扩展时的条件从「没访问过」变成「没访问过 并且 我要到的那一刻还没被烧」。
// 第一步:造危险时刻表
if(t < burn[x][y]) burn[x][y] = t;              // 落点本身
for(int d = 0; d < 4; d++){
    int nx = x + dx[d], ny = y + dy[d];
    if(nx < 0 || ny < 0 || nx >= N || ny >= N) continue;
    if(t < burn[nx][ny]) burn[nx][ny] = t;      // ★ 取最早的那次
}

// 第二步:BFS,加一个时刻判断
if(dist[nx][ny] != -1) continue;                // 访问过就跳过
if(dist[x][y] + 1 < burn[nx][ny]){             // ★ 必须「严格早于」被烧的时刻
    dist[nx][ny] = dist[x][y] + 1;
    q.push(nx * N + ny);
}
🚫 两个必踩的坑

坑 1:把 < 写成 <=
题目原话是「如果一个格子在时刻 t 被流星撞击或烧焦,那么贝茜只能在 t 之前的时刻在这个格子里出现」。
所以在时刻 t 踏进一个「时刻 t 被烧」的格子是不允许的。条件必须是 到达时刻 < burn,不能取等号。

坑 2:数组开太小。
流星坐标范围是 0 ~ 300,但「烧焦」还会波及上下左右,所以最远会烧到 301
数组要开到 305 左右(这样下标 0 ~ 304 都合法),BFS 也允许走到 304。开到 301 会漏掉边界情况,直接 RE 或者 WA。

什么时候算安全、什么时候输出 −1

BFS 按时间从小到大出队。每弹出一个格子,先看它的 burn 是不是「永远安全」那个大数:

  • 是 → 贝茜站在一个永远不会被烧的格子上,而 BFS 保证这是最早的时刻 → 直接输出 dist[x][y] 并结束程序。
  • 否 → 继续往四方向扩展。

队列空了还没找到安全格 → 她被困死在焦土里了 → 输出 −1。

另外还有个特判:如果起点 (0,0) 在时刻 0 就被流星砸中,那她一开始就没地方站,答案也是 −1。

4★ 连通块染色 + 预处理答案(P1141 的核心)

这一节是今天的重头戏,请一定看懂再往下写代码。

题目长什么样

有一个 n × n 的迷宫,每格是 0 或 1。规则很特别:

🎯 走法规则

站在 0 上,只能走到相邻四格中的 1;站在 1 上,只能走到相邻四格中的 0

换句话说:只能从一格走到「数字和它不同」的相邻格。

然后有 m 次询问,每次问「从第 i 行第 j 列出发,能移动到多少个格子(含自身)」。

⚠️ 数据范围(这题的全部难度都在这)

1 ≤ n ≤ 1000,1 ≤ m ≤ 100000

格子总数 10⁶,询问次数 10⁵。两者都很大,这才是本题的考点

为什么「每次询问单独 BFS」一定超时

最直接的想法:读一次询问,就从那个格子 BFS 一遍,数能走到多少格。BFS 一次是 O(n²) = 10⁶。

m = 10⁵ 次询问 → 10⁵ × 10⁶ = 10¹¹ 次操作。

每秒能跑多少次操作10¹¹ 次操作要跑多久
约 10⁸ 次约 1000 秒
本题时限1 秒

差了一千倍,无论你怎么优化常数都救不回来。必须换思路。

换思路:让「能互相到达」的格子共用同一个答案

先问一个问题:如果从 A 能走到 B,那从 B 能走到多少格?

规则是「数字不同才能走」,这是双向的:A 能走到 B,说明 A 和 B 相邻且数字不同,那么 B 当然也能走回 A。所以「能到达」是一个相互的关系

这意味着:所有能互相到达的格子,能到达的格子集合完全相同,因此答案也完全相同!

🎯 核心结论

把迷宫按「能互相到达」分成若干个连通块。同一个连通块里的所有格子,答案都等于这个块的大小

动手看一个小例子

下面这个 4 × 4 的迷宫:

0101 1010 0101 1010

这是一张「棋盘式」的交错图:每个格子的上下左右都是和自己不同的数字,所以整张图 16 个格子全部互通,构成 1 个连通块,块大小 16。

于是不管问哪个格子,答案都是 16。10⁵ 次询问全部 O(1) 回答。

再看一个被「隔断」的例子(第 2 行的两个 1 挡住了):

0111 1111 0111 1111

左上角 3 个格(0,1,1 竖着)互相可达;右下角那一片同理自成一块。块与块之间过不去,答案就不同。

算法:一次「染色」扫全图

  1. 开一个 id[i][j] 数组,记录「第 i 行第 j 列属于几号连通块」,初值 0 表示还没访问过(所以连通块编号从 1 开始,0 空出来当哨兵)。
  2. 从小到大扫每个格子。如果它还没被染色,说明发现了一个新连通块:编号 +1,然后从这个格子开始 BFS。
  3. BFS 时,走到一个格子就把它染成当前编号,并统计这一块总共多少个格子。
  4. BFS 结束,把这块的大小记进 cnt[块编号]
  5. 扫完整个棋盘,所有格子都有编号了。之后每个询问:读入 (i, j),直接输出 cnt[ id[i][j] ]
// 核心:一次扫描,把整张图染色
for(int i = 0; i < n; i++)
  for(int j = 0; j < n; j++){
      if(id[i][j]) continue;             // 已经染过色,跳过
      blocks++;                          // 发现一个新连通块
      // …… 从 (i,j) 开始 BFS,把所有能到的格子都染成 blocks 号 ……
      cnt[blocks] = total;               // 记下这块的大小
  }

// 询问:O(1) 查表,一次 BFS 都不用做
cin >> i >> j;
cout << cnt[ id[i-1][j-1] ] << endl;    // 题目坐标从 1 开始,减 1 转成数组下标
✅ 复杂度对比

预处理:每个格子最多被 BFS 访问一次 → O(n²)
每次询问:一次数组读取 → O(1)
总计 O(n² + m),n = 1000、m = 10⁵ 时约 1.1×10⁶ 次操作,几十毫秒就完事了。

💡 这个思想的通用名字:「预处理 / 记忆化」

把「会被反复问到的同一个问题」的答案提前算好存起来,之后直接查表。

你会发现这个套路到处都在用:

  • Day 6 的桶计数:把「值 v 出现几次」提前统计好,之后 O(1) 查。
  • Day 15 的前缀和:把「区间和」提前累加好,之后 O(1) 查。
  • Day 17 的记忆化搜索:把算过的 f(n) 存下来,不再重算。
  • 今天:把「连通块大小」提前算好,之后 O(1) 查。

看到「询问次数很多」,第一反应就应该是:能不能提前预处理?

⚠️ 实现上的三个细节

1. 转移条件不是「能走」,而是「数字不同」。
if(g[nx][ny] == g[x][y]) continue; —— 数字相同就过不去。别写成「必须是 0」或者「必须是 1」。

2. 队列里存什么?
x * n + y 这个编码(解码用 x = cur / n, y = cur % n),比存 pair 省事也省内存。

3. 内存要算一下。
char g[1005][1005] ≈ 1MB,int id[1005][1005] ≈ 4MB,int cnt[10⁶+5] ≈ 4MB,加起来约 9MB,很安全。

5顺手开个新脑洞:递归分治(P1228)

P1228 不是搜索题,但计划把它放在今天,因为它用一个非常漂亮的「分而治之」思路,而这个思路下周开始会反复出现。

规则

迷宫是 2ᵏ × 2ᵏ 的正方形。公主站在其中一格,这一格不能盖。剩下的格子要用 L 形毯子(3 个格子组成的「拐角」形状,有 4 种朝向)铺满,每格只能铺一层。要求输出一种方案。

⚠️ 数据范围

0 < k ≤ 10,也就是棋盘最大 1024 × 1024

需要铺的毯子数量是 (2ᵏ × 2ᵏ − 1) ÷ 3,k = 10 时是 349525 块。方案行数就是这么多,输出量不小,所以要用 printf 而不是 cout << endl

分治的思路:一切从「中间」下手

把 2ᵏ × 2ᵏ 的棋盘从正中间切成四块,每块是 2ᵏ⁻¹ × 2ᵏ⁻¹ 的小正方形。

公主必然落在其中一块里。剩下三块呢?它们各自都还没有「不能盖的格子」。

🎯 关键的一步:在正中间放一块毯子

棋盘正中间有一个 2×2 的小方块,它由四块小正方形各贡献 1 格。我们在正中间放一块 L 形毯子,正好盖住「不属于公主所在那块」的那 3 个格子。

这样一来:

  • 另外三块小正方形,各自都多了一个「被盖住的格子」——正好可以当成它们自己的公主格(也就是它们的洞)。
  • 公主所在的那一块,洞还是原来那个公主格。

四块小正方形,全部变成了「有一个洞、需要铺满其余部分」的同类问题,只是规模减半。递归下去就行。

画出来看

左上左上TRTR 左上TR BLBR BLBLBRBR

* = 公主(洞,不能被盖) ■ = 正中间那块毯子盖住的 3 格
公主在左上象限,所以中间这块毯子盖住 TR、BL、BR 三块各 1 格。

递归的边界和终止

  • 终止条件:边长变成 1 时,只剩「洞」那一格,没得铺,直接 return。
  • 递归深度:每次边长减半,从 2ᵏ 到 1 只要 k 步,k ≤ 10,非常浅,不会爆栈。
  • 毯子总数:T(len) = 4·T(len/2) + 1,解出来是 (4ᵏ − 1) ÷ 3,正好等于「格子数减一,再除以三」——这也解释了为什么这个数一定是整数。

输出格式怎么理解(这题最容易卡的地方)

每行输出 x y c。其中:

  • (x, y) 是这块毯子的拐角格:毯子由 3 格组成,其中有一格和另外两格都相邻,那一格就是拐角。
  • c 是 1 ~ 4,表示「空格在拐角的哪个方向」,而拐角和空格总是同一个小 2×2 方格里的对角关系
c空格相对拐角的位置毯子盖住的另外 3 格(相对拐角 x, y)
1左上(x−1,y)、(x,y−1)、(x,y)
2右上(x−1,y)、(x,y)、(x,y+1)
3左下(x,y)、(x,y−1)、(x+1,y)
4右下(x+1,y)、(x,y+1)、(x,y)
💡 别死记表格,用样例反推

官方样例里第二行是 2 2 4。棋盘 8×8,公主在 (3,3)(左上象限),所以第一行 5 5 1 盖的是 (4,5)、(5,4)、(5,5) 三格,拐角是 (5,5)。

接着处理左上象限(1~4 行、1~4 列),公主 (3,3) 在它的右下小象限,所以毯子盖住中心的另外三格:(2,2)、(2,3)、(3,2),拐角 (2,2) → 输出 2 2 4,也就是「空格在拐角的右下」,c = 4。

拿样例对着表格验一遍,比背十遍都管用。

6四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译并运行过,「实测结果」里写的是我真实跑出来的输出。

P1141 01迷宫 普及/提高- · 连通块染色 + 记忆化

题意:n × n 的 0/1 迷宫,只能走到相邻的、数字与自己不同的格子。给 m 次询问,每次问从某格出发能到达多少格(含自身)。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 1000,1 ≤ m ≤ 100000。

m 可以到 10⁵,而每次询问单独 BFS 是 O(n²) = 10⁶,乘起来 10¹¹——必然超时。
必须用第 4 节讲的「连通块染色 + 预处理答案」。

思路

「能互相到达」把格子分成若干连通块,同块内所有格子的答案都相等,等于块的大小。所以:

  1. 一次全图扫描,用 BFS 给每个格子染色(id[i][j]),同时统计每块大小(cnt[块号])。
  2. 每次询问直接输出 cnt[id[i][j]],O(1)。

总复杂度 O(n² + m),实测 n = 1000、m = 10⁵ 时约 0.2 秒(详见下面的实测结果)。

样例输入
2 2
01
10
1 1
2 2
样例输出
4
4

2×2 的棋盘上 0 和 1 交错,四格两两可达,所以从哪一格出发都能到 4 格。

完整代码

#include <cstdio>
#include <queue>
using namespace std;

int n, m;
char g[1005][1005];      // 迷宫(0/1 字符)
int id[1005][1005];      // 每个格子属于哪个连通块,0 表示还没染色
int cnt[1000005];        // 每个连通块的大小(能走到的格子数)
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

int main(){
    scanf("%d %d", &n, &m);
    for(int i = 0; i < n; i++) scanf("%s", g[i]);

    int blocks = 0;                 // 连通块编号,从 1 开始(0 留给"未访问")
    queue<int> q;                   // 队列里存 i*n+j,省内存
    for(int i = 0; i < n; i++){
        for(int j = 0; j < n; j++){
            if(id[i][j]) continue;              // 已经染过色,跳过
            blocks++;
            id[i][j] = blocks;
            q.push(i * n + j);
            int total = 0;
            while(!q.empty()){
                int cur = q.front(); q.pop();
                int x = cur / n, y = cur % n;
                total++;
                for(int d = 0; d < 4; d++){
                    int nx = x + dx[d], ny = y + dy[d];
                    if(nx < 0 || nx >= n || ny < 0 || ny >= n) continue;
                    if(id[nx][ny]) continue;                // 已经属于某个连通块
                    if(g[nx][ny] == g[x][y]) continue;      // ★ 数字相同不能走
                    id[nx][ny] = blocks;                    // 染成同一块
                    q.push(nx * n + ny);
                }
            }
            cnt[blocks] = total;        // 这块的答案就是 total
        }
    }

    // 询问直接查表,O(1) 一个
    for(int k = 0; k < m; k++){
        int i, j;
        scanf("%d %d", &i, &j);
        printf("%d\n", cnt[id[i - 1][j - 1]]);
    }
    return 0;
}
✅ 实测结果

官方样例 2 2 / 01 / 10 / 1 1 / 2 2 → 输出 44 完全一致

大数据:n = 1000、m = 100000(全 0 迷宫 + 随机询问)→ 输出 100000 行,用时 197 ms(时限 1 s)
全 0 迷宫里相邻格数字相同,谁也走不动,所以每格自成一队,答案都是 1——与实际输出一致。

最坏情况:n = 1000 的棋盘交错图案(整张图 10⁶ 格全在一个连通块里,BFS 要一次走遍全图)→ 100000 次询问全部输出 1000000,用时 213 ms

以上计时都是在本机(Windows)上同一个程序连跑 4 次取最小值,已经包含了进程启动和读写输入输出的全部开销;洛谷是 Linux 环境,通常还会更快一些。

对拍:10 组随机小迷宫(n = 3~11,每组 25 次询问),与「每次询问单独 BFS」的暴力程序逐行比对,结果完全一致

易错点

  • 每个询问都单独 BFS → 10¹¹ 次操作,必 TLE。这是本题唯一的门槛。
  • 转移条件写错:是「两格数字不同才能走」,写成「必须是 1」或「必须是 0」就错了。
  • 用 0 号连通块id 初值 0 用来表示「没访问过」,所以块编号必须从 1 开始。
  • 询问坐标忘减 1:题目行列从 1 开始,数组从 0 开始,要写 id[i-1][j-1]
  • 不用 cin/cout 加同步:n = 10⁶ 级别输入 + 10⁵ 输出,建议直接用 scanf/printf,或者开头加 ios::sync_with_stdio(false);
P1332 血色先锋队 普及/提高- · 多源 BFS

题意:n 行 m 列的军团方阵,有 a 个感染源,瘟疫每小时向上下左右扩散一格。给 b 个领主的位置,按输入顺序输出每个人被感染的时刻(在感染源上的人时刻为 0)。

⚠️ 数据范围 / 关键约束

1 ≤ n, m ≤ 500(最多 250000 格),1 ≤ a, b ≤ 10⁵。

a 最大 10⁵,但格子只有 25 万个——感染源重复出现是必然的。入队时判一下 dist == -1 就不会重复塞。

另外 b 也能到 10⁵,输出量大,用 printf

思路

多源 BFS:把 a 个感染源全部以距离 0 入队,然后照常 BFS。搜完后 dist[x][y] 就是「到最近感染源的时间」,也就是领主被感染的时刻。

为什么对?因为它们等价于从一个「超级源点」出发的普通 BFS(见第 2 节)。

样例输入
5 4 2 3
1 1
5 4
3 3
5 3
2 4
样例输出
3
1
3

感染源在 (1,1) 和 (5,4)。
(3,3):从 (5,4) 走 |3−5|+|3−4| = 3 步,从 (1,1) 要 4 步 → 取 3。
(5,3):紧挨着 (5,4) → 1 步。
(2,4):从 (5,4) 走 3 步,从 (1,1) 走 4 步 → 3。

完整代码

#include <cstdio>
#include <queue>
#include <cstring>
using namespace std;

int n, m, a, b;
int dist[505][505];                 // -1 表示还没被感染(没访问)
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

int main(){
    scanf("%d %d %d %d", &n, &m, &a, &b);
    memset(dist, -1, sizeof(dist));

    queue<int> q;
    // ★ 多源 BFS:所有感染源一起入队,距离都是 0
    for(int k = 0; k < a; k++){
        int x, y;
        scanf("%d %d", &x, &y);
        if(dist[x][y] == -1){       // 同一格重复出现也只用入队一次
            dist[x][y] = 0;
            q.push(x * (m + 1) + y);
        }
    }

    while(!q.empty()){
        int cur = q.front(); q.pop();
        int x = cur / (m + 1), y = cur % (m + 1);
        for(int d = 0; d < 4; d++){
            int nx = x + dx[d], ny = y + dy[d];
            if(nx < 1 || nx > n || ny < 1 || ny > m) continue;
            if(dist[nx][ny] != -1) continue;
            dist[nx][ny] = dist[x][y] + 1;
            q.push(nx * (m + 1) + ny);
        }
    }

    // 输出顺序和输入顺序一致,所以读完一个就输出一个
    for(int k = 0; k < b; k++){
        int x, y;
        scanf("%d %d", &x, &y);
        printf("%d\n", dist[x][y]);
    }
    return 0;
}
✅ 实测结果

官方样例 → 输出 313 完全一致

大数据:n = m = 500、a = b = 100000 → 输出 100000 行,用时 207 ms
输入把感染源和领主都循环撒在 500×500 的网格上,首行答案是 0(第一个领主正好踩在第一个感染源上),与程序输出一致。

对拍:30 组随机小数据(n, m ≤ 8,源和领主随机),与「每个领主都对每个感染源各 BFS 一次、再取最小值」的暴力程序逐行比对,结果完全一致

易错点

  • 对每个领主单独 BFS:b ≤ 10⁵,直接爆炸。多源 BFS 只需要搜一遍。
  • 感染源重复入队:a 可能远大于格子数,加 if(dist[x][y] == -1) 去重。
  • 输出顺序:题目要求「与输入顺序一致」,所以不能排序、不能存起来乱输。
  • 坐标从 1 开始:数组按 1..n、1..m 使用,memset(dist, -1, ...) 把整个数组设为 −1 正好当哨兵。
  • 编码坐标时用的是 x * (m + 1) + y,别写成 x * m + y(列下标是 1..m,用 m 做基数会撞车)。
P2895 [USACO08FEB] Meteor Shower S 普及/提高- · BFS + 时间维度

题意:贝茜在 (0,0)。M 颗流星分别在时刻 Tᵢ 砸在 (Xᵢ, Yᵢ),把落点和上下左右四格烧成焦土。她每秒走一格,只能待在 X, Y ≥ 0 的区域,问最少多久能走到一个永远安全的格子(安全 = 永远不会被烧);到不了输出 −1。

⚠️ 数据范围 / 关键约束

1 ≤ M ≤ 50000,0 ≤ Tᵢ ≤ 1000,0 ≤ Xᵢ, Yᵢ ≤ 300。

流星坐标最大 300,但烧焦会波及四邻,最远烧到 301 → 数组开到 305、BFS 允许走到下标 304。

到达时刻必须严格小于被烧时刻(< 而不是 <=)。

思路

  1. 预处理burn[x][y] = 该格最早被烧的时刻(没被烧记成 INF)。对每颗流星更新落点 + 四邻,取 min。
  2. BFS:从 (0,0) 出发,进入 (nx,ny) 的条件是 dist[x][y] + 1 < burn[nx][ny]
  3. 每弹出一个格子,如果它的 burn 是 INF,说明站在安全格上,而 BFS 保证这是最早时刻 → 输出 dist 并结束。
  4. 队列空还没找到 → 输出 −1。另外起点若在时刻 0 就被砸,直接 −1。
🎯 为什么不用把「时间」放进状态

因为危险区只会越来越大,早到永远不吃亏:早到意味着之后的选择更多。

所以「到达某格的最早时刻」就足够描述一切,而这个最早时刻正是 BFS 的 dist
把状态从 (x, y, t) 砍回 (x, y),靠的就是这种「单调性」。

样例输入
4
0 0 2
2 1 2
1 1 2
0 3 5
样例输出
5

时刻 2 被烧:(0,0)、(1,0)、(0,1)、(2,0)、(1,1)、(2,1)、(3,1)、(2,2)、(1,2)。
时刻 5 被烧:(0,3)、(1,3)、(0,2)、(0,4)。
贝茜的路线:(0,0) → (0,1) → (0,2) → (0,3) → (0,4) → (1,4),第 5 秒站上 (1,4),它永远不会被烧。

完整代码

#include <cstdio>
#include <queue>
#include <cstring>
using namespace std;

const int N = 305;                  // 流星坐标 0..300,烧焦还会波及 301,多开一点
const int INF = 0x3f3f3f3f;

int burn[N][N];                     // burn[x][y] = 最早被烧的时刻,INF = 永远安全
int dist[N][N];                     // 贝茜到达的时刻,-1 表示没到过
int dx[4] = {-1, 1, 0, 0};
int dy[4] = {0, 0, -1, 1};

int main(){
    for(int i = 0; i < N; i++)
        for(int j = 0; j < N; j++)
            burn[i][j] = INF;
    memset(dist, -1, sizeof(dist));

    int M;
    scanf("%d", &M);
    for(int k = 0; k < M; k++){
        int x, y, t;
        scanf("%d %d %d", &x, &y, &t);
        // ★ 流星把落点和上下左右四格都变成焦土,取「最早被烧」的时刻
        if(t < burn[x][y]) burn[x][y] = t;
        for(int d = 0; d < 4; d++){
            int nx = x + dx[d], ny = y + dy[d];
            if(nx < 0 || ny < 0 || nx >= N || ny >= N) continue;
            if(t < burn[nx][ny]) burn[nx][ny] = t;
        }
    }

    if(burn[0][0] == 0){            // 时刻 0 起点就被砸,活不下来
        printf("-1\n");
        return 0;
    }

    queue<int> q;
    dist[0][0] = 0;
    q.push(0);
    while(!q.empty()){
        int cur = q.front(); q.pop();
        int x = cur / N, y = cur % N;
        if(burn[x][y] == INF){      // 站在永远不会被烧的格子上,安全!
            printf("%d\n", dist[x][y]);
            return 0;
        }
        for(int d = 0; d < 4; d++){
            int nx = x + dx[d], ny = y + dy[d];
            if(nx < 0 || ny < 0 || nx >= N || ny >= N) continue;
            if(dist[nx][ny] != -1) continue;
            // ★ 必须「严格早于」被烧的时刻才能踏进去
            if(dist[x][y] + 1 < burn[nx][ny]){
                dist[nx][ny] = dist[x][y] + 1;
                q.push(nx * N + ny);
            }
        }
    }
    printf("-1\n");
    return 0;
}
✅ 实测结果

官方样例 → 输出 5 完全一致

边界测试 11 / 0 0 0(起点在时刻 0 就被砸)→ -1 正确

边界测试 25 / 0 0 1 / 1 0 1 / 0 1 1 / 1 1 1 / 2 2 1(起点四周在时刻 1 全被烧)→ -1 正确
出发后第 1 秒能到的格子全都「时刻 1 被烧」,需要 1 < 1 不成立,全部进不去 → 被困死。

边界测试 31 / 300 300 1000(唯一一颗流星在很远处)→ 0 正确
(0,0) 永远不会被烧,原地不动就是安全格,耗时 0。

大数据:M = 50000(流星撒满 0~300 的网格,T 从 0 到 1000)→ 用时 41 ms,输出 -1(构造的数据里有 T = 0 且落在 (0,0) 的流星,起点当场被烧,符合预期)

数组上界的验证:我另外写了一份把数组上界放大到 405 的版本,和上面这份 305 的版本对拍了 200 组随机数据(流星特意撒在 0~300 的边界附近以及起点附近),200 组输出全部一致 —— 说明数组开到 305 是够用的,不会因为「要走到坐标 301 ~ 304」而漏解。

易错点

  • < 写成 <=:题目规定「时刻 t 被烧的格子,只能在 t 之前出现」,所以取等号是错的。
  • 数组只开到 301:烧焦会波及坐标 301,必须留出空间(开 305)。
  • 忘记处理起点burn[0][0] == 0 时要直接输出 −1,否则会输出 0(错误答案)。
  • 把时间当成状态:(x, y, t) 状态数会膨胀到 9×10⁷,既费内存又没必要。
  • 忘了 burn 要取 min:同一格可能被多颗流星波及,写 burn[x][y] = t 会被后面的流星覆盖成较晚的时刻,导致答案错误。
P1228 地毯填补问题 普及/提高- · 递归分治

题意:2ᵏ × 2ᵏ 的棋盘,公主占一格不能铺。用 L 形毯子(3 格,4 种朝向)把其余格子铺满,每格只铺一层。输出任意一种方案。

⚠️ 数据范围 / 关键约束

0 < k ≤ 10,棋盘最大 1024 × 1024。
每行输出 x y c(x, y) 是毯子的拐角格,c 是 1~4 表示空格相对拐角的方向(1 左上 / 2 右上 / 3 左下 / 4 右下)。

本题有 Special Judge:任何合法的铺法都算对,不要求和样例一模一样(但样例本身当然也是合法方案)。

思路

把棋盘切成四个象限。公主在其中一块里,我们在正中放一块毯子盖住另外三块各 1 格,于是四块都变成「有一个洞」的同类子问题,规模减半,递归求解。

递归到边长 1 就结束。核心是那句「洞在哪个象限」的分支判断。

样例输入
3
3 3
样例输出(共 21 行)
5 5 1
2 2 4
1 1 4
1 4 3
4 1 2
4 4 1
2 7 3
1 5 4
1 8 3
3 6 3
4 8 1
7 2 2
5 1 4
6 3 2
8 1 2
8 4 1
7 7 1
6 6 1
5 8 3
8 5 2
8 8 1

8 × 8 的棋盘去掉公主那一格,还剩 63 格,63 ÷ 3 = 21 —— 正好 21 块毯子,也正好是 21 行输出。

完整代码

#include <cstdio>

int k, px, py;

// 用一个 L 形毯子盖住「洞」以外的三个中心格
// (x,y) 当前方块左上角;len 边长;(hx,hy) 洞的位置
void solve(int x, int y, int len, int hx, int hy){
    if(len == 1) return;                    // 1×1 不用盖
    int half = len >> 1;
    int cx = x + half, cy = y + half;       // 中心 2×2 块的右下角

    if(hx < cx && hy < cy){                 // 洞在左上象限
        printf("%d %d 1\n", cx, cy);        // 盖住右上、左下、右下三个中心格
        solve(x,  y,  half, hx,     hy);        // 左上:洞不变
        solve(x,  cy, half, cx - 1, cy);        // 右上:洞 = 自己的中心格
        solve(cx, y,  half, cx,     cy - 1);    // 左下
        solve(cx, cy, half, cx,     cy);        // 右下
    } else if(hx < cx && hy >= cy){         // 洞在右上象限
        printf("%d %d 2\n", cx, cy - 1);
        solve(x,  y,  half, cx - 1, cy - 1);
        solve(x,  cy, half, hx,     hy);        // 右上:洞不变
        solve(cx, y,  half, cx,     cy - 1);
        solve(cx, cy, half, cx,     cy);
    } else if(hx >= cx && hy < cy){         // 洞在左下象限
        printf("%d %d 3\n", cx - 1, cy);
        solve(x,  y,  half, cx - 1, cy - 1);
        solve(x,  cy, half, cx - 1, cy);
        solve(cx, y,  half, hx,     hy);        // 左下:洞不变
        solve(cx, cy, half, cx,     cy);
    } else {                                // 洞在右下象限
        printf("%d %d 4\n", cx - 1, cy - 1);
        solve(x,  y,  half, cx - 1, cy - 1);
        solve(x,  cy, half, cx - 1, cy);
        solve(cx, y,  half, cx,     cy - 1);
        solve(cx, cy, half, hx,     hy);        // 右下:洞不变
    }
}

int main(){
    scanf("%d %d %d", &k, &px, &py);
    int n = 1 << k;                         // 2^k
    solve(1, 1, n, px, py);
    return 0;
}
✅ 实测结果

官方样例 3 / 3 3 → 输出 21 行,与官方样例输出逐字节完全一致(包括 6 3 2 这一行)

独立校验:我另外写了一个校验程序,按题目规则把每行的毯子翻译成 3 个格子,检查「是否越界 / 是否重复覆盖 / 最终是否只剩公主格为空 / 行数是否等于 (4ᵏ−1)/3」。
对 k = 1, 2, 3, 4, 5, 6, 10 且公主分别取「左上角、正中心、右下角、右上角」共 28 组输入进行校验,28 组全部通过(覆盖合法、无重叠、无越界、只剩公主格)。

最大规模:k = 10 → 输出 349525 行(等于 (4¹⁰−1)/3),用时 546 ms

易错点

  • 递归时把洞的位置搞混:只有「洞所在的那个象限」保持洞不变,另外三个象限的洞是它们靠近中心的那一格。这四行 solve 的参数是最容易写错的地方,写完拿样例对一遍。
  • 输出用 cout << endl:35 万行输出,endl 每次都强制刷缓冲,会明显变慢。用 printf("\n")'\n'
  • 递归终止写成 len == 0:应该是 len == 1(只剩洞那一格,没得盖)。
  • 坐标从 1 开始:递归入口是 solve(1, 1, 2^k, px, py),减半时用 len >> 1,别写成除以 2 后又少算一格。
  • 这题不需要开二维数组存棋盘,只要按「象限 → 中心三格」的规律直接输出坐标就行了。

第三周复盘(连第二周一起补)

Day 21 是第二个复盘日。先把这两周学过的东西串成一条线。

第三周主线:从「暴力搜索」到「有组织地搜索」

Day主题一句话核心
15前缀和与差分把「反复求区间和」变成「提前累加 + 两次相减」
16递归函数自己调用自己,一定要有终止条件
17递推与记忆化把算过的结果存下来,别重复算(今天 P1141 用的就是它
18DFS 入门一条路走到黑,走不通再回头
19DFS 回溯「恢复现场」是回溯的灵魂
20BFS 入门一层一层扩散,天然求出最短步数
21BFS 进阶多源起点 / 预处理环境 / 把答案复用给很多次询问

第二周的一句话回顾

Day主题一句话核心
8字符与字符串基础字符本质是数字,c - 'a' 就能当下标
9字符串进阶进制转换、前导零、格式细节
10函数把重复的代码包起来,起个名字
11结构体把一组相关数据打包成一个整体
12排序sort 加自定义比较器,先排序再处理是万能开局
13 - 14贪心每步都取当前最优,但必须先论证它是对的

★ 本周复盘任务(照着做,别跳过)

  1. 默写模板:不看笔记,在白纸上写出「网格版 DFS」和「网格版 BFS」两个模板,包括边界判断、访问标记、方向数组。写不出来就回去看 Day 18 和 Day 20。
  2. 重做本周卡住的 2 道搜索题。挑你当时看题解才过的题目,隔一天再做一遍——如果还是要想很久,说明这个点没真懂。
  3. 自测:能不能在 60 分钟内独立 AC「P1605 迷宫」和「P1443 马的遍历」?这两题分别是 DFS 回溯和 BFS 的基础题,是这个专题的地板线。
💡 复盘的正确姿势

复盘不是「把笔记再看一遍」,而是合上笔记,凭记忆把东西重新写出来。看笔记时觉得「我会了」,写的时候才发现位置全对不上——这才是需要补的地方。

今日常见错误汇总

错误写法后果正确写法
P1141 每个询问单独 BFS10⁵ × 10⁶ = 10¹¹,必 TLE先染色预处理,询问 O(1) 查表
if(g[nx][ny] == g[x][y]) continue; 漏写0 和 1 之间随便走,连通块全错相邻格数字必须不同才能走
P1332 只把一个感染源入队答案偏大,WA所有源一起入队,距离都是 0
P2895 用 t <= burn[x][y]在被烧的那一刻踏进去,WA必须 t < burn[x][y](严格小于)
P2895 数组只开到 301烧焦波及 301,越界 RE开到 305,允许走到下标 304
P2895 burn[x][y] = t 直接赋值后面的流星覆盖掉更早的时刻 → WAif(t < burn[x][y]) burn[x][y] = t; 取 min
P1228 输出用 endl35 万行反复刷缓冲,慢printf("...\n")
连通块编号从 0 开始和「未访问」的哨兵 0 撞车编号从 1 开始,0 留给未访问
询问时忘了坐标减 1整体偏移一行一列 → WA/REid[i-1][j-1]
用 BFS 求「最多能走多少步」概念混淆:BFS 求的是最短距离求最长路要用 DP / 拓扑(后面会学)

自测:你真的会了吗

第 1 题:多源 BFS 和普通 BFS,代码上到底差在哪一步?

:只差在初始化那一步。普通 BFS 是「一个起点入队,dist = 0」;多源 BFS 是「把 a 个起点全部入队,每个的 dist 都是 0」,之后的主循环一行都不用改

原理上它等价于:凭空加一个「超级源点」,它到每个真实源点的距离都是 0,然后跑普通 BFS。

第 2 题:P1141 为什么不能每个询问搜一次?算一下量级。

:n ≤ 1000,一次 BFS 要访问最多 10⁶ 个格子;m ≤ 10⁵ 次询问 → 10⁵ × 10⁶ = 10¹¹ 次操作。按每秒 10⁸ 次算要 1000 秒,而时限是 1 秒。

正确做法:把所有连通块一次染色,块大小存在 cnt[] 里,询问时直接读 cnt[id[i][j]]。总复杂度 O(n² + m) ≈ 1.1×10⁶,实测约 0.2 秒。

第 3 题:P2895 为什么可以不用把「时间」放进状态?

:因为危险区只增不减,早到永远不比晚到差——早到只会让之后的选择更多。既然「越早越好」,那每个格子只需要记「最早能到达的时刻」,而这个值正好就是 BFS 的 dist

所以只要预先算出 burn[x][y],BFS 时用 dist + 1 < burn[ nx ][ ny ] 判断能不能进就行,状态仍然是 (x, y)。

第 4 题:P2895 里,一个格子在时刻 3 被烧,贝茜能在时刻 3 踏上去吗?

不能。题目写的是「如果一个格子在时刻 t 被流星撞击或烧焦,那么贝茜只能在 t 之前的时刻在这个格子里出现」。

所以判断条件是 到达时刻 < burn,写 <= 就会错。

第 5 题:P1228 里,为什么毯子数量一定是 (4ᵏ−1)/3?

:棋盘有 4ᵏ 个格子,公主占 1 格不能盖,需要盖 4ᵏ − 1 格;每块毯子盖 3 格,所以需要 (4ᵏ − 1) ÷ 3 块。

而 4ᵏ − 1 一定能被 3 整除(因为 4ᵏ − 1 = (4 − 1)(4ᵏ⁻¹ + …… + 1) = 3 × 整数),所以这个数量总是整数。k = 10 时是 349525 块。

第 6 题(手写代码):写出「多源 BFS 的初始化」三行代码。
for(int k = 0; k < a; k++){
    int x, y;  scanf("%d %d", &x, &y);
    if(dist[x][y] == -1){ dist[x][y] = 0; q.push(x * (m+1) + y); }
}

★ 关键是「所有源距离都是 0」,以及「重复的源只入队一次」。

速查卡(截图存手机)

【网格 BFS 模板(Day 20 的底子,今天全靠它)】
  queue<int> q;  dist[sx][sy] = 0;  q.push(sx*M + sy);
  while(!q.empty()){
      int cur = q.front(); q.pop();
      int x = cur / M, y = cur % M;
      for(int d = 0; d < 4; d++){
          int nx = x + dx[d], ny = y + dy[d];
          if(越界) continue;
          if(dist[nx][ny] != -1) continue;
          dist[nx][ny] = dist[x][y] + 1;
          q.push(nx * M + ny);
      }
  }

【多源 BFS(P1332)】
  把普通 BFS 的初始化换成:
      for(k = 0; k < a; k++){ 读入源;
          if(dist[x][y] == -1){ dist[x][y] = 0; q.push(编码); } }
  // 主循环一行都不用改

【带时间的 BFS(P2895)】
  // ① 预处理危险时刻表,取 min
  burn[x][y] = min(burn[x][y], t);          // 落点
  burn[nx][ny] = min(burn[nx][ny], t);      // 四邻
  // ② BFS 时多一个条件(必须严格小于!)
  if(dist[x][y] + 1 < burn[nx][ny]) { …… }
  // ③ 出队时 burn == INF 就是安全格,直接输出 dist

【连通块染色 + 记忆化(P1141)】
  int id[N][N];        // 0 = 未访问,块号从 1 开始
  int cnt[N*N];        // cnt[块号] = 块大小
  for(i, j) if(!id[i][j]){ blocks++; BFS 整块染色; cnt[blocks] = total; }
  询问:cout << cnt[ id[i-1][j-1] ];      // O(1)!

【复杂度估算】
  操作数 ≈ 状态总数 × 每个状态的转移数
  状态数先估出来,再决定要不要预处理

明天预告 · Day 22

二分查找:在有序里快速定位

今天的 P1141 你已经体会过「预处理换时间」的威力了。明天是另一种「用聪明换取时间」的经典手段:每次砍掉一半的搜索范围

会学到下面的东西:

  • lower_bound / upper_bound 的语义和手写实现(「第一个 ≥ x」和「第一个 > x」到底差在哪);
  • while(l <= r)while(l < r) 两种写法各自的边界该怎么定;
  • 二叉的死循环是怎么来的,以及「r = mid 配向下取整、l = mid 配向上取整」这条铁律;
  • 实数二分:为什么固定循环 100 次比 while(r - l > eps) 更稳。

明天四道题:P2249 查找、P1102 A-B 数对、P1678 烦恼的高考志愿、P1024 一元三次方程求解。今天先把 BFS 的账结清,明天开始新的篇章。

ACM 一个月学习计划 · DAY 22

Day 22 · 二分查找:在有序里快速定位

每次砍掉一半——这是除了「预处理」之外,竞赛里最值钱的提速手段。今天的目标是:写出一个不会死循环、不会差一位的二分

今日目标:能手写不出错的二分,会用并讲清 lower_bound / upper_bound 的语义与边界
预计用时:约 3.5 小时(含做题)  难度:入门 ~ 普及/提高-
题目来源:洛谷 P2249 / P1102 / P1678 / P1024
DAY 22 / 30

今日时间安排

0 - 20 min搞懂二分的前提和「为什么这么快」(第 1 节)
20 - 45 minSTL 三件套:lower_bound / upper_bound / 个数(第 2 节)
45 - 90 min手写二分与边界细节——今天的重头戏(第 3 节)
90 - 115 min实数二分与 P1024 的扫描思路(第 4 节)
115 - 205 min做 4 道题,每题限时 30 分钟
205 - 230 min对照本文题解,把没做出来的补掉
230 - 245 min写题解笔记 + 做自测题
💡 昨天内容的衔接

昨天(Day 21 的 P1141)我们用「预处理换时间」把 10⁵ 次询问从 10¹¹ 次操作压到 10⁶ 次。

今天换一种思路:不提前算,而是每次查询时只用 20 次比较就在 100 万个有序数里定位。核心手段是「每次砍掉一半」。

两种手段你会经常一起用:先排序 → 再二分,就是竞赛里最常见的两行组合拳。

1二分的前提:单调性

先想一个猜数游戏

我心里想一个 1 到 1000 之间的整数,你来猜,我只会告诉你「大了」「小了」「对了」。

最笨的猜法:1、2、3、4…… 最坏要猜 1000 次。
聪明的猜法:先猜 500,再猜 750 或 250,再猜中间……最多 10 次一定能猜对。因为 2¹⁰ = 1024 > 1000。

🎯 这就是二分

每问一次,就把「可能的范围」砍掉一半。

能这么干的前提是:这个范围是有序的——猜大了,所有比它大的数就都能一次性排除掉。

为什么叫「二分查找」而不是「随便砍」

之所以每次都砍正中间,是因为「每次砍掉一半」能让轮数最少。砍掉了 n 的一半、四分之一、八分之一……多少次才能砍到 1 个?答案是 ⌈log₂n⌉ 次。

数据规模 n线性查找:最多比较几次二分查找:最多比较几次快了多少倍
1001007约 14 倍
10⁴10 00014约 700 倍
10⁶1 000 00020约 5 万倍
10⁹1 000 000 00030约 3300 万倍

记两个数就够用:n = 10⁶ 时约 20 次,n = 10⁹ 时约 30 次。因为它们对应 2²⁰ ≈ 10⁶、2³⁰ ≈ 10⁹。

⚠️ 前提不能忘:必须有序(或具有单调性)

乱序的数组上二分,得到的答案是随机的。没有任何意义。

所以看到二分的题目,第一步永远是:数组排序了吗?没有就先 sort

排序是 O(n log n),二分是 O(log n)。如果只查几次,排序反而更贵;但如果是 P2249、P1678 这种「一次排序 + 很多次查询」,就非常划算。

「单调性」比「有序」更宽

二分真正需要的不是「数组排好序」,而是「一边全不满足,另一边全满足」这种可以一刀切的性质。

比如「第一个 ≥ x 的位置」:它左边的数全都 < x,它右边的数(含自己)全都 ≥ x,中间有一条清晰的分界线。这种性质叫单调性,也是明天「二分答案」的基础。

2STL 三件套:先用熟,再手写

C++ 的 <algorithm> 里已经把二分写好了。比赛时优先用它们——不会写错,不会死循环。手写二分主要是为了理解原理,以及应付需要改造的场景。

两个函数的语义

函数含义找不到时返回
lower_bound(a, a+n, x)第一个 ≥ x 的位置(lower = 下界)n(越界的开区间位置)
upper_bound(a, a+n, x)第一个 > x 的位置(upper = 上界)n

注意它们返回的是指针,要减掉数组起始地址才变成下标:

#include <algorithm>
int a[100005];

int pos  = lower_bound(a, a + n, x) - a;    // 第一个 >= x 的下标
int pos2 = upper_bound(a, a + n, x) - a;    // 第一个 >  x 的下标
int cnt  = pos2 - pos;                       // ★ x 在数组里出现了几次
⚠️ 最容易踩的坑:lower_bound 不保证「找到了」

lower_bound 只保证 a[pos] >= x不保证 a[pos] == x

数组是 1 3 5,你查 4:lower_bound 返回下标 2(因为 a[2] = 5 ≥ 4),但 4 根本不在数组里

所以「x 存在吗」必须自己再验一次:

int pos = lower_bound(a, a + n, x) - a;
bool exist = (pos < n && a[pos] == x);      // ★ 两句都不能少

P2249 就栽在这个点上——它要求「找不到输出 −1」。

为什么「个数 = upper_bound − lower_bound」

数组排好序之后,所有等于 x 的数一定是挨在一起的一段连续区间(这是有序才有的性质)。

拿 P2249 的样例数组举例(下标从 0 开始):

0
1
1
3
2
3
3
3
4
5
5
7
6
9

x = 3

  • lower_bound = 1,这一段「3」的起点(第一个 ≥ 3 的位置)。
  • upper_bound = 4,这一段「3」的终点右边那一格(第一个 > 3 的位置)。
  • 相减:4 − 1 = 3,正好是 3 出现的次数。
🎯 一句话记住

lower_bound 是这段的「左端点」,upper_bound 是「右端点再往右一格」,相减就是长度。

如果 x 不在数组里,两者会指向同一个位置,相减等于 0 —— 这也是一个「x 存不存在」的判据。

3★ 手写二分:两种写法与边界细节

这一节是今天最重要的部分。二分的所有 bug 都来自「区间开闭」和「mid 取整」这两件事。

写法一:闭区间 + while(l <= r) + 用 ans 记录(推荐新手用)

目标:找第一个 ≥ x 的位置(就是手写版的 lower_bound)。

int l = 0, r = n - 1, ans = -1;     // 闭区间 [l, r] 里找
while(l <= r){                      // ★ 区间里还有元素就继续
    int mid = l + (r - l) / 2;      // 防溢出的中点写法
    if(a[mid] >= x){
        ans = mid;                  // ★ 先记下来(它可能是答案)
        r = mid - 1;                //   但左边可能还有更靠前的,继续往左找
    } else {
        l = mid + 1;                //   a[mid] < x,mid 和它左边都不可能是答案
    }
}

逐句解释

代码含义
l = 0, r = n - 1闭区间 [l, r],两端都是可能藏着答案的合法下标
while(l <= r)区间 [l, r] 还有元素(l ≤ r)就继续;l > r 说明区间空了,结束
ans = -1初值表示「还没找到」。因为要求「第一个」,所以命中后不能直接返回
r = mid - 1a[mid] ≥ x 已经满足了,但左边可能还有更靠前的 ≥ x 的数,所以连 mid 一起排除到右边,去 [l, mid-1] 里继续
l = mid + 1a[mid] < x,那么 [l, mid] 里全都 < x,整个扔掉,去 [mid+1, r] 里找
✅ 这种写法为什么不会死循环

每一轮区间都会严格缩小:要么 r 变成 mid-1(比原来的 r 小),要么 l 变成 mid+1(比原来的 l 大)。区间长度每轮至少减 1,迟早会到 0。

诀窍就是:收缩时永远用 mid ± 1,不要用 mid 本身。

写法二:半开区间 + while(l < r) + 直接返回 l

另一种常见写法,不需要 ans 变量,最后 l 就是答案:

int l = 0, r = n;                   // ★ 注意是 n,不是 n-1!
while(l < r){                       // ★ 区间空了(l == r)就停
    int mid = (l + r) / 2;
    if(a[mid] >= x) r = mid;        // ★ mid 可能是答案,所以 r = mid(不能减 1)
    else            l = mid + 1;    //   mid 一定不是答案,所以 +1
}
return l;                           // l == r,就是第一个 >= x 的位置
                                    // 返回 n 表示「所有数都 < x」,也就是没找到
代码含义
r = n半开区间 [l, r)。r 是「答案不可能到达的位置」,所以答案可以是 0 ~ n,一共 n+1 种可能,初值取 n
while(l < r)[l, r) 里有元素当且仅当 l < r。l == r 时区间为空
r = mida[mid] ≥ x,mid 自己可能就是答案,所以只能把右边界收到 mid(而不是 mid−1)
return l循环结束时 l == r,这个位置满足「它是第一个 ≥ x 的」,直接返回

两种写法对照表(这张表建议背下来)

写法一:闭区间 + ans写法二:半开区间 [l, r)
维护的区间[l, r](闭)[l, r)(左闭右开)
初值l = 0, r = n - 1l = 0, r = n
循环条件while(l <= r)while(l < r)
命中时ans = mid; r = mid - 1;r = mid;
未命中时l = mid + 1;l = mid + 1;
结果在哪ans(没找到是 −1)l(没找到是 n)
要不要额外变量ans不要
🚫 千万不要把两种写法混着写

最常见的错误:用了 while(l <= r),却在命中时写 r = mid。看下面这个例子:

l = 3, r = 4          // 区间里有两个元素
mid = (3 + 4) / 2 = 3
若 a[mid] >= x 成立,执行 r = mid  →  r 还是 3
// l = 3, r = 3,和上一轮相比…… 一模一样!下一轮还是这些值 →
死循环!

// 正确写法(闭区间):r = mid - 1  →  r = 2,此时 l = 3 > r = 2,循环结束 ✅

★ 铁律:mid 的取整方向

🎯 死循环的根源就一句话

r = mid 时,mid 必须用 (l + r) / 2(向下取整);
l = mid 时,mid 必须用 (l + r + 1) / 2(向上取整)。

写反了,就是死循环。

为什么?因为 C++ 的整数除法是向下取整,而「取整方向」决定了 mid 会不会等于边界:

mid 的取法当 l < r 时,mid 一定满足所以
(l + r) / 2 向下取整l ≤ mid < r用它更新 r = mid 时,r 会变小 → 安全 ✅
用它更新 l = mid 时,l 可能不变(当 mid == l)→ 死循环 ❌
(l + r + 1) / 2 向上取整l < mid ≤ r用它更新 l = mid 时,l 会变大 → 安全 ✅
用它更新 r = mid 时,r 可能不变(当 mid == r)→ 死循环 ❌

再看那个死循环例子:l = 3, r = 4mid = (3+4)/2 = 3mid == l!所以 l = mid 之后 l 还是 3,什么都没变。

换成向上取整:mid = (3+4+1)/2 = 4l = 4 —— l 从 3 变成 4,区间缩掉了 ✅

💡 记忆口诀

「保住谁,就往另一边取整。」

r = mid 是想「保住 mid 作为可能的右边界」,那么 mid 就得往下取整,保证它 < r,r 才会动。
l = mid 是想「保住 mid 作为可能的左边界」,那么 mid 就得往上取整,保证它 > l,l 才会动。

更省心的办法:一律用 mid ± 1 收缩(写法一),就永远不用纠结取整方向。所以新手请先写熟写法一。

那什么时候会用到「l = mid」的写法?

当你要求「最后一个 ≤ x 的位置」时,很自然的想法是:命中之后往右走,所以写 l = mid。这时 mid 就必须向上取整:

// 找最后一个 <= x 的位置(用 l = mid 的收缩方式)
int l = 0, r = n - 1;
while(l < r){
    int mid = (l + r + 1) / 2;      // ★ 向上取整,否则 l = 3, r = 4 时会死循环
    if(a[mid] <= x) l = mid;        //   mid 可能是答案,保住它
    else            r = mid - 1;    //   mid 一定不是答案,扔掉
}
// 结束时 l == r,但还要确认 a[l] <= x 是否真的成立(可能整个数组都 > x)
if(a[l] > x) /* 没找到 */;
⚠️ 顺便提醒:这种写法最后要额外验一次

写法二(半开区间)返回的 l 也可能落在 n 或者一个不满足条件的位置上——因为它们回答的是「第一个 ≥ x 的位置」,而这个位置上的数未必等于 x

所以对 P2249 这种「找不到要输出 −1」的题,答案拿到手之后一定要再检查一遍 a[ans] == x这是本题最常见的 WA 原因。

顺手说一个细节:mid 为什么要写 l + (r - l) / 2

当 l 和 r 都很大时(比如都在 10⁹ 附近),l + r 会超过 int 的上限(约 2.1×10⁹),变成负数,然后 mid 就成了个乱七八糟的值,程序直接跑飞。

l + (r - l) / 2 先做减法再做加法,结果和 (l + r) / 2 一样,但中间过程不会超过 r,所以永不溢出。本题下标最多 10⁶,不会溢出,但这是要养成的习惯。

4实数二分:别用 eps 当循环条件

和整数二分的区别

整数二分的区间是「下标」,一格一格地走,可以用 mid ± 1。实数二分的区间是「一段长度」,里面有无穷多个点,不能 ±1,只能一直对半砍。

double l = -100, r = 100;
for(int i = 0; i < 100; i++){        // ★ 固定循环 100 次
    double mid = (l + r) / 2;
    if(满足条件(mid)) r = mid;       // 答案在左半边
    else              l = mid;       // 答案在右半边
}
printf("%.2f\n", l);                 // 此时 l 和 r 已经贴得极近
🎯 为什么固定次数比 while(r - l > eps) 更稳

区间长度每轮减半。从 200 开始,砍 100 次之后长度是 200 ÷ 2¹⁰⁰ ≈ 1.6×10⁻²⁸,远远小于 double 能表示的最小间隔(double 大约只有 15~16 位有效十进制数字,实际上砍 60 次左右就到精度极限了)。

所以 100 次一定够,而且一定会在第 100 次准时停下来

while(r - l > eps) 的风险:当 r 和 l 已经贴近到浮点数无法区分时,mid = (l + r) / 2 会算出等于 l 或等于 r 的值,区间长度再也变不小 → 死循环。这种 bug 还很难查,因为它只在精度耗尽时才出现。

P1024 的思路:先扫描,再二分

题目给了两个关键条件:根都在 [−100, 100] 里,而且根与根之差 ≥ 1

第二条特别有用:任意一个长度为 1 的区间里,最多只有一个根(要是有两个,它俩的距离就 < 1 了)。

题目还给了判根定理:若 f(x₁) × f(x₂) < 0,则 (x₁, x₂) 里必有根。

  1. 扫描:让 i 从 −100 走到 99,检查每个小区间 [i, i+1]。因为根间距 ≥ 1,每个小区间最多一个根,好处理。
  2. 两种命中情况
    • 左端点就是根:f(i) == 0 → 直接输出 i;
    • 两端异号:f(i) × f(i+1) < 0 → 在 (i, i+1) 里做 100 次实数二分。
  3. 别忘了右端点:扫描只覆盖到 [−100, 100] 的左端点,x = 100 要单独查一次。
  4. 去重:如果根恰好落在整数点上,相邻两个区间都可能「看到」它,需要防止同一个根被输出两次。因为根之间至少差 1,所以「新根和上一个已输出的根相差小于 0.5 就算同一个」这个判据是安全的。
💡 浮点判零为什么要用「容差」而不是 == 0

浮点运算有误差,f(i) 算出来可能是 1e-13 而不是精确的 0。

但容差也不能瞎定:如果系数很大,f(i) 的正常值可能有 10¹² 量级,用固定的 1e-6 反而会把「不是根」的点误判成根。

所以本文代码里用的是相对容差:先扫一遍求出 f 在整数点上的最大绝对值 maxf,再令 tol = maxf × 1e-9 + 1e-9。这样不管系数多大都能正确判断。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译并运行过,「实测结果」里写的是我真实跑出来的输出。

P2249 【深基13.例1】查找 普及- · 二分模板题

题意:给 n 个单调不减的非负整数,m 次询问,每次给一个 q,输出 q 在序列中第一次出现的位置(从 1 开始编号),没出现就输出 −1。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 10⁶,0 ≤ aᵢ, q ≤ 10⁹,1 ≤ m ≤ 10⁵

题面明确说「本题输入输出量较大,请使用较快的 IO 方式」→ 用 scanf/printf,或者开头加 ios::sync_with_stdio(false); cin.tie(0);

两个坑:①输出的是 1 开始的编号;②找不到要输出 −1,而 lower_bound 不会告诉你「找没找到」。

思路

数组已经单调不减,直接就是手写二分(写法一:闭区间 + ans)的模板题。

要的是「第一个 ≥ q 的位置」,所以命中之后不能停,要记下答案再往左找

复杂度:排序都不用做(输入已经有序),每次询问 O(log n) → 总 O(m log n) ≈ 10⁵ × 20 = 2×10⁶。

样例数组的定位过程:

1
1
2
3
3
3
4
3
5
5
6
7
7
9

(上面用的是 1 开始的编号)查 3 → 第一个 3 在第 2 位;查 6 → 第一个 ≥ 6 的数在第 6 位,但它是 7 不是 6 → 输出 −1。

样例输入
11 3
1 3 3 3 5 7 9 11 13 15 15
1 3 6
样例输出
1 2 -1

完整代码

#include <cstdio>
using namespace std;

int a[1000005];
int n, m;

int main(){
    scanf("%d %d", &n, &m);
    for(int i = 0; i < n; i++) scanf("%d", &a[i]);

    for(int k = 0; k < m; k++){
        int q;
        scanf("%d", &q);
        // ★ 手写 lower_bound:找第一个 >= q 的位置
        int l = 0, r = n - 1, ans = -1;
        while(l <= r){
            int mid = l + (r - l) / 2;      // 防溢出写法
            if(a[mid] >= q){
                ans = mid;                  // 先记下来,再往左边继续找
                r = mid - 1;
            } else {
                l = mid + 1;
            }
        }
        if(k) printf(" ");
        // ans 是第一个 >= q 的位置(没有就是 -1),还要确认它真的等于 q
        if(ans == -1 || a[ans] != q) printf("-1");
        else printf("%d", ans + 1);         // 题目要求输出 1 开始的编号
    }
    printf("\n");
    return 0;
}
✅ 实测结果

官方样例 → 输出 1 2 -1 完全一致

大数据:n = 10⁶(数组是 0,1,2,…,999999),m = 10⁵ 次询问 → 4 次取最小用时 485 ms(时限 1 s)
这个成绩已经包含了 Windows 下的进程启动和 10⁶ + 10⁵ 个整数的读写开销;洛谷是 Linux 环境,同代码通常更快。

边界 11 2 / 5 / 5 4(只有一个元素)→ 1 -1 正确

边界 25 4 / 0 0 0 1000000000 1000000000 / 0 1000000000 7 10000000011 4 -1 -1 正确
查 0 → 第 1 个;查 10⁹ → 第 4 个;查 7 → 第一个 ≥ 7 的是 10⁹ 但不等于 7 → −1;查 10⁹+1 → 全都 < 它 → −1。

易错点

  • 命中后直接 return:那样找到的是「某一个」q,不是「第一个」。必须 ans = mid; r = mid - 1; 继续往左。
  • 不验证 a[ans] == q:q = 6 时二分返回的是 7 的位置,不验证就会输出错误的位置而不是 −1。
  • 忘记 +1:题目编号从 1 开始,ans 是数组下标,要输出 ans + 1
  • cin 不开同步:10⁶ 个数 + 10⁵ 次询问,cin 不带 ios::sync_with_stdio(false) 会很慢。
  • 输出格式:m 个答案在同一行用空格隔开。用「第一个不加空格、后面都加」的写法(if(k) printf(" ");)最省事,也不用担心行尾多一个空格。
  • 数组下标从 0 开始,所以 a[1000005] 够用(n 最大 10⁶,多开 5 个保险)。
P1102 A-B 数对 普及- · 排序 + 二分计数

题意:给一串正整数和一个正整数 C,数出有多少个数对满足 A − B = C。不同位置的相同数字算不同的数对。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 2×10⁵,0 ≤ aᵢ < 2³⁰,1 ≤ C < 2³⁰。

答案要用 long long最坏情况是 N 个数全都相等且 C = 0,此时数对数是 N² ≈ 4×10¹⁰,远超 int 的 21 亿。
注意 C ≥ 1,所以 C = 0 不会出现;但即便 C ≥ 1,答案上界也在 10¹⁰ 量级(一半的数等于 x,一半等于 x+C),所以仍然必须用 long long

思路

等式 A − B = C 等价于 A = B + C,或者说「对每个 A,找有多少个 B 等于 A − C」。

于是:

  1. 排序(二分的必要前提)。
  2. 遍历每个 a[i] 当作 A,目标是 B = a[i] − C。
    代码里写的是 target = a[i] + C,因为「数组里有多少个 a[j] = a[i] + C」和「有多少个数对差为 C」是一一对应的,两种写法都对,只是把谁当 A 的区别。
  3. upper_bound − lower_bound 数出 target 出现了几次,累加进答案。

复杂度:O(N log N)。

样例输入
4 1
1 1 2 3
样例输出
3

C = 1,排序后还是 1 1 2 3
A = 1(第一个):B = A + 1 = 2,数组里有 1 个 → +1
A = 1(第二个):同上 → +1
A = 2:B = 3,数组里有 1 个 → +1
A = 3:B = 4,没有 → +0
共 3。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int a[200005];

int main(){
    ios::sync_with_stdio(false);            // 关掉 cin/cout 与 C 输入输出的同步,提速
    cin.tie(0);

    int n;
    long long c;
    cin >> n >> c;
    for(int i = 0; i < n; i++) cin >> a[i];
    sort(a, a + n);                         // ★ 二分的前提:先排序

    long long ans = 0;                      // ★ 答案要用 long long
    for(int i = 0; i < n; i++){
        long long target = (long long)a[i] + c;     // 需要多少个 a[j] == a[i] + c
        // 数 target 出现了几次:upper_bound - lower_bound
        int lo = (int)(lower_bound(a, a + n, target) - a);
        int hi = (int)(upper_bound(a, a + n, target) - a);
        ans += hi - lo;
    }
    cout << ans << endl;
    return 0;
}
💡 为什么 hi - lo 就是「等于 target 的个数」

lo = 第一个 ≥ target 的位置,hi = 第一个 > target 的位置。

排好序之后等于 target 的那些数一定挤在 [lo, hi) 这段连续区间里,长度正好是 hi - lo

如果 target 根本不在数组里,lohi 会指向同一个位置,相减是 0,不用额外判断。

💡 这里为什么用 cin/cout 而不是 scanf/printf

因为答案和 C 都是 long longprintf 输出 long long 要写 %lld,而 Windows 上部分老编译器对这个格式符支持不完善(编译会警告,个别环境运行结果还会不对);cout 自己按类型选格式,Windows 和洛谷都没问题

读入量最多 2×10⁵ 个数,加上 ios::sync_with_stdio(false); cin.tie(0); 之后速度完全够用。

✅ 实测结果

官方样例 → 输出 3 完全一致

手工验证5 2 / 1 3 3 5 5 → 输出 6 正确
数一遍:3−1 有 2 对(两个 3 各配一次),5−3 有 4 对(两个 5 × 两个 3),共 6 对。

大数据(合法最大规模):N = 200000,10 万个 1 + 10 万个 2,C = 1 → 输出 10000000000(= 10⁵ × 10⁵ = 10¹⁰),用时 64 ms
注意题面规定 C ≥ 1,所以这个才是真正的最坏情况(C = 0 的数据本身不合法)。这组数据专门用来验证 long long:用 int 存答案会溢出。

易错点

  • 答案用 int:会溢出 → WA。这是本题的头号错误。
  • 忘记排序:二分在乱序数组上毫无意义。
  • 把重复元素只算一次:题目明确「不同位置的数字一样的数对算不同的数对」,所以两个 1 要分别算,用 ub − lb 正好自动处理。
  • target 用 int 相加:aᵢ 可以接近 2³⁰,a[i] + C 会超过 int 上限(2³¹−1)吗?本题不会(2³⁰−1 + 2³⁰−1 = 2³¹−2 < 2³¹−1),但写成 (long long)a[i] + c 更稳,不用去算边界。
  • lower_bound 传成 a + n - 1:范围写错会漏掉最后一个元素。标准的写法是 [a, a+n)
  • C 的读入格式写错:C 可以到 2³⁰ 量级,用 scanf("%d", &c) 读成 int 在本题还能勉强撑住,但换成更大的数据就会读错。答案和 C 一律用 long long,读入也用 cin >> c%lld
  • cin 却不开同步:读 2×10⁵ 个数会慢一截,记得 ios::sync_with_stdio(false); cin.tie(0);
P1678 烦恼的高考志愿 普及- · 二分找最接近值

题意:m 所学校的分数线 aᵢ,n 个学生的估分 bᵢ。给每个学生配一所学校,使「分数线与估分之差」最小(可以高也可以低),这个最小值叫这名学生的不满意度。求所有学生不满意度的总和。

⚠️ 数据范围 / 关键约束

1 ≤ n, m ≤ 10⁵,0 ≤ aᵢ, bᵢ ≤ 10⁶。

答案要用 long long:每个学生的不满意度最多 10⁶,n 最多 10⁵,总和最大 10¹¹,超过 int

朴素做法是两层循环 O(nm) = 10¹⁰ → 必 TLE。必须二分。

思路

先给分数线排序。对每个学生的估分 b:

  1. lower_bound 找到第一个 ≥ b 的分数线,位置记为 p
  2. 离 b 最近的分数线,一定是 a[p] 和 a[p−1] 中的一个(见下面的解释),把两者的差值取小的那个即可。
  3. 注意边界:p == m 说明所有分数线都 < b,右边没有候选;p == 0 说明所有分数线都 ≥ b,左边没有候选。
🎯 为什么只需要看两个候选

把分数线按大小排成一排,b 夹在中间。比 b 小的那些分数线里,最接近 b 的一定是最大的那个;比 b 大的那些里,最接近 b 的一定是最小的那个

lower_bound 给出的 a[p] 正好是「大于等于 b 的最小值」,a[p-1] 正好是「小于 b 的最大值」。所以最优解必在这两个里面。

样例输入
4 3
513 598 567 689
500 600 550
样例输出
32

分数线排序后:513、567、598、689。
估分 500 → 最近的 513,差 13;
估分 600 → 最近的 598,差 2;
估分 550 → 513 差 37,567 差 17,取 17。
合计 13 + 2 + 17 = 32

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int a[100005], b[100005];

int main(){
    ios::sync_with_stdio(false);        // 关掉 cin/cout 与 C 输入输出的同步,提速
    cin.tie(0);

    int m, n;
    cin >> m >> n;
    for(int i = 0; i < m; i++) cin >> a[i];
    for(int i = 0; i < n; i++) cin >> b[i];
    sort(a, a + m);                         // 学校分数线排序

    long long ans = 0;                      // ★ 最多 1e5 × 1e6 = 1e11,必须 long long
    for(int i = 0; i < n; i++){
        // p = 第一个 >= b[i] 的分数线
        int p = (int)(lower_bound(a, a + m, b[i]) - a);
        int best = 1 << 30;                 // 先设一个大数
        if(p != m) best = min(best, a[p] - b[i]);       // 右边最近的
        if(p != 0) best = min(best, b[i] - a[p - 1]);   // 左边最近的
        ans += best;
    }
    cout << ans << endl;                    // 用 cout 输出 long long,不会有格式符的问题
    return 0;
}
💡 为什么这两题我用 cin/cout 而不是 scanf/printf

因为要输出 long long,而 printf 输出 long long 得写 %lld。在 Windows 的部分编译器(尤其是老版本 MinGW / Dev-C++)上,printf%lld 支持并不完善:编译时会警告,个别环境里运行结果还会不对。

cout 会自己按类型选格式,Windows 和洛谷(Linux)上都没问题,所以输出 long long 时用它最省心。
记得加 ios::sync_with_stdio(false); cin.tie(0);,不然读 2×10⁵ 个数会慢一截。

顺便说一句:在洛谷(Linux g++)上 printf("%lld", x) 是完全正确的标准写法,你看到别人这么写也不用奇怪。

✅ 实测结果

官方样例 → 输出 32 完全一致

大数据:m = n = 10⁵,所有分数线都是 0,所有估分都是 10⁶ → 输出 100000000000(= 10⁵ × 10⁶ = 10¹¹),4 次取最小用时 76 ms
这组数据既验证了 long long(10¹¹ 会撑爆 int),也验证了「所有分数线都小于估分」时 p == m 的边界分支。

易错点

  • 答案用 int:10¹¹ 溢出 → WA。
  • 只看 a[p] 不看 a[p-1]:题目说「可高可低」,只往上看会得到偏大的不满意度。
  • 越界访问 a[p-1]:当 p == 0a[-1] 是非法访问,必须判掉。
  • 忘记 p == m 的判断:此时 a[p] 指向数组外面。
  • 用两重循环暴力:O(nm) = 10¹⁰ → TLE。
  • 被「估分可能相等」绕晕:当 a[p] == b[i] 时差值是 0,代码自然就取到 0 了,不用特判。
P1024 [NOIP 2001 提高组] 一元三次方程求解 普及/提高- · 实数二分

题意:解方程 ax³ + bx² + cx + d = 0,系数是实数。约定方程有三个不同实根,根都在 [−100, 100] 内,且根与根的差 ≥ 1。从小到大输出三个根,精确到小数点后 2 位。

⚠️ 数据范围 / 关键约束

题面没有给出系数的数值范围,只给了根的性质:三个不同实根、都在 [−100, 100]、两两之差 ≥ 1

题目还给了判根提示:若存在 x₁ < x₂ 使 f(x₁) × f(x₂) < 0,则 (x₁, x₂) 之间一定有根。

思路

「根与根之差 ≥ 1」意味着任意长度为 1 的区间里最多只有一个根。于是把 [−100, 100] 按 1 切成 200 个小区间,逐个检查:

  • 如果左端点 f(i) 就是 0 → i 本身是根,直接记下;
  • 否则看 f(i) 和 f(i+1) 是否异号 → 异号就在 (i, i+1) 里做 100 次实数二分;
  • 最后单独补查 x = 100。

因为每个小区间最多一个根,去重只要判断「新根和上一个根相差是否小于 0.5」就行。

浮点判零用相对容差:先求出 f 在整数点上的最大绝对值 maxf,再取 tol = maxf × 1e-9 + 1e-9

样例输入
1 -5 -4 20
样例输出
-2.00 2.00 5.00

这个多项式正好能分解:x³ − 5x² − 4x + 20 = (x + 2)(x − 2)(x − 5),三个根都是整数。

完整代码

#include <cstdio>
#include <cmath>

double a, b, c, d;
double ans[3];
int cnt = 0;

double f(double x){
    return ((a * x + b) * x + c) * x + d;   // 秦九韶算法,少算几次乘法
}

// 记录一个根;根之间至少差 1,所以差距小于 0.5 的算同一个根
void add(double r){
    for(int i = 0; i < cnt; i++)
        if(fabs(ans[i] - r) < 0.5) return;
    if(cnt < 3) ans[cnt++] = r;
}

int main(){
    scanf("%lf %lf %lf %lf", &a, &b, &c, &d);

    // 用一个「相对」容差判断整数点是不是根,避免系数很大时误判
    double maxf = 0;
    for(int i = -100; i <= 100; i++){
        double v = fabs(f((double)i));
        if(v > maxf) maxf = v;
    }
    double tol = maxf * 1e-9 + 1e-9;

    for(int i = -100; i < 100 && cnt < 3; i++){
        double fl = f((double)i), fr = f((double)(i + 1));
        if(fabs(fl) < tol){                 // 情况 1:整数点本身就是根
            add((double)i);
            continue;
        }
        if(fl * fr < 0){                    // 情况 2:两端异号,区间里必有一个根
            double l = i, r = i + 1;
            for(int k = 0; k < 100; k++){   // 固定二分 100 次,精度足够
                double mid = (l + r) / 2;
                if(f(l) * f(mid) <= 0) r = mid;
                else l = mid;
            }
            add((l + r) / 2);
        }
    }
    if(cnt < 3 && fabs(f(100.0)) < tol) add(100.0);      // 别忘了右端点

    for(int i = 0; i < cnt; i++){
        if(i) printf(" ");
        printf("%.2f", ans[i]);
    }
    printf("\n");
    return 0;
}
✅ 实测结果

官方样例 1 -5 -4 20 → 输出 -2.00 2.00 5.00 完全一致

另外我跑了 5 组补充数据,全部正确:

输入 (a b c d)实际输出说明
1 -6 11 -61.00 2.00 3.00三个整数根,全靠「整数点判根」这一支命中
1 -6.5 12.5 -71.00 2.00 3.50含小数根,靠区间二分命中,3.50 精确到两位
1 0 -1 0-1.00 0.00 1.00有一根正好是 0,验证 i = 0 这个位置
-1 5 4 -20-2.00 2.00 5.00三次项系数为负,验证符号处理
1 0 -100 0-10.00 0.00 10.00根靠近区间内部,验证根的顺序是从小到大

顺带一提:1 -6 11 -6 这组里三个根都落在整数点上,如果只写「区间异号二分」而不处理「整数点就是根」的情况,就会一个根都找不到。

易错点

  • 只判区间异号,不管整数点是根:根恰好在整数点时,f(i) = 0,两端乘积是 0 而不是负数,fl * fr < 0 不成立,根就被漏掉了。
  • 忘记 x = 100:扫描区间是 [−100, 100],循环里只覆盖到 i = 99 的右端 100,最后一个点要单独判。
  • while(r - l > eps):精度耗尽时 mid 可能等于 l 或 r,区间不再缩小 → 死循环。用固定 100 次更稳。
  • 忘记去重:根落在整数点上时,可能被相邻两个区间各认领一次,同一个根输出两遍,第三个根就没位置了。
  • fabs(f(i)) == 0 精确判零:浮点误差下几乎不会恰好等于 0,要用容差。
  • 容差写死成 1e-6:系数很大时 f 的正常值可能有 10¹² 量级,固定的小容差会把非根点误判成根。所以用相对容差。
  • 输出格式:同一行、空格分隔、%.2f。用「第一个不加空格」的写法避免行尾多空格。

今日常见错误汇总

错误写法后果正确写法
在乱序数组上二分结果随机,毫无意义sort(a, a+n)
lower_bound 之后直接当成「找到了」P2249 输出错误位置而不是 −1if(pos < n && a[pos] == x) 再验一次
while(l <= r)r = mid死循环(mid 可能等于 r,区间不变)闭区间用 r = mid - 1
while(l < r)l = mid(mid 向下取整)死循环(mid 可能等于 l,区间不变)l = mid 时必须 mid = (l+r+1)/2 向上取整
半开区间却写 r = n - 1答案可能是 n(一个都不满足),漏掉这种情况while(l < r) 时初值用 r = n
命中后立刻 return mid找到的是「某一个」而不是「第一个」ans = mid; r = mid - 1; 继续往左
P2249 输出 ans 而不是 ans+1编号整体差 1 → WA题目编号从 1 开始,要 ans + 1
P1102 / P1678 答案用 int4×10¹⁰ 或 10¹¹ 溢出 → WAlong long,输出 %lld
P1678 只看 a[p]忽略左边更近的分数线,答案偏大同时比较 a[p]a[p-1]
P1678 在 p == 0 时访问 a[p-1]非法访问(RE 或读到垃圾值)if(p != 0) 判断
P1024 用 while(r - l > 1e-8)精度耗尽时死循环固定循环 100 次
P1024 不处理「整数点就是根」三个根全在整数点上时,一个都找不到先判 fabs(f(i)) < tol
mid = (l + r) / 2(l, r 很大)l + r 溢出 int → mid 变负 → REmid = l + (r - l) / 2

自测:你真的会了吗

第 1 题:lower_boundupper_bound 的语义分别是什么?差在哪里?

lower_bound(a, a+n, x) 返回第一个 ≥ x 的位置;upper_bound(a, a+n, x) 返回第一个 > x 的位置。

两者只差一个等号——判定的条件分别是 a[mid] >= xa[mid] > x

它们的差 upper_bound - lower_bound 就是 x 在数组里出现的次数。

第 2 题:数组 1 3 3 3 5 7 9(下标从 0 开始),lower_bound(3)upper_bound(3) 分别返回什么?

lower_bound = 1(第一个 3 的下标),upper_bound = 4(5 的下标,也就是第一个 > 3 的位置)。

4 − 1 = 3,正好是 3 的个数。

第 3 题:while(l <= r)while(l < r) 分别配什么样的区间?

  • while(l <= r)闭区间 [l, r]:两端都是可能藏着答案的合法下标,初值 l = 0, r = n - 1;l > r 时区间为空。
  • while(l < r)半开区间 [l, r):r 是「答案不可能到达的位置」,初值 l = 0, r = n;l == r 时区间为空。

关键是两种写法里的收缩方式必须配套:闭区间命中后写 r = mid - 1(因为已经判定过 mid 了),半开区间命中后写 r = mid(因为 mid 自己可能是答案)。混用就会死循环。

第 4 题:为什么 r = mid 时 mid 必须向下取整?举个会死循环的例子。

:向下取整时,只要 l < r,就一定有 mid < r。于是 r = mid 会让 r 严格变小,区间必定缩小,不会卡住。

如果写 r = mid 却用向上取整:mid = (l + r + 1) / 2,当区间只剩两个元素(比如 l = 3, r = 4)时 mid = 4 = r,r = mid 之后 r 还是 4,l 也没变 → 下一轮完全相同 → 死循环

对称地,l = mid 时必须向上取整,否则 l = 3、r = 4 时 mid = 3 = l,l = mid 之后 l 不变 → 同样死循环。

第 5 题:为什么实数二分推荐「固定循环 100 次」而不是 while(r - l > eps)

:因为每次区间长度减半,从 200 开始砍 100 次,长度是 200 ÷ 2¹⁰⁰ ≈ 1.6×10⁻²⁸,早就远超 double 的精度极限了,所以 100 次一定够,而且一定会停。

eps 判断的风险是:当 r 和 l 贴近到浮点数无法再区分时,mid = (l + r) / 2 会算出等于 l 或等于 r 的值,区间长度永远不再变小 → 死循环。

第 6 题(手写代码):写出「找第一个 ≥ x 的位置」的闭区间版本。
int l = 0, r = n - 1, ans = -1;
while(l <= r){
    int mid = l + (r - l) / 2;
    if(a[mid] >= x){ ans = mid; r = mid - 1; }
    else            l = mid + 1;
}
if(ans != -1 && a[ans] != x) ans = -1;   // 题目若要求「必须等于 x」再补这一句

★ 三个要点:while(l <= r)、用 ans 记录、收缩用 mid ± 1

速查卡(截图存手机)

【STL 三件套】
  #include <algorithm>
  int lo = lower_bound(a, a+n, x) - a;   // 第一个 >= x 的位置
  int hi = upper_bound(a, a+n, x) - a;   // 第一个 >  x 的位置
  int cnt = hi - lo;                     // x 出现的次数
  bool exist = (lo < n && a[lo] == x);   // ★ 一定要再验一次!

【手写 lower_bound(闭区间 + ans,推荐新手)】
  int l = 0, r = n - 1, ans = -1;
  while(l <= r){
      int mid = l + (r - l) / 2;
      if(a[mid] >= x){ ans = mid; r = mid - 1; }
      else            l = mid + 1;
  }
  // ans == -1 表示没找到

【手写 lower_bound(半开区间,返回 l)】
  int l = 0, r = n;                    // ★ r = n
  while(l < r){
      int mid = (l + r) / 2;
      if(a[mid] >= x) r = mid;         // ★ 不减 1
      else            l = mid + 1;
  }
  // l == n 表示没找到

【找最后一个 <= x(用 l = mid,必须向上取整)】
  int l = 0, r = n - 1;
  while(l < r){
      int mid = (l + r + 1) / 2;       // ★★ 向上取整,否则死循环
      if(a[mid] <= x) l = mid;
      else            r = mid - 1;
  }

【★ 铁律:mid 的取整方向】
  r = mid   →  mid 用 (l + r) / 2     向下取整
  l = mid   →  mid 用 (l + r + 1) / 2 向上取整
  写反了 = 死循环。省心办法:一律用 mid ± 1 收缩。

【实数二分】
  double l = -100, r = 100;
  for(int i = 0; i < 100; i++){        // 固定次数,别用 eps
      double mid = (l + r) / 2;
      if(条件(mid)) r = mid; else l = mid;
  }
  printf("%.2f\n", l);

【复杂度】
  排序 O(n log n) + 每次查询 O(log n)
  n = 10^6 → 约 20 次;n = 10^9 → 约 30 次

明天预告 · Day 23

二分答案:把「求解」变成「判定」

今天你学的是「在一个有序数组里找某个值」。明天要把它升级成一个更强大的思维工具:

当题目要求「最大值最小」或者「最小值最大」时,你不需要直接算出那个答案,只需要会判断「某个值行不行」,然后二分这个值就行了。

明天会学到:

  • 怎么识别一道题是不是「二分答案」(关键词:最大值最小 / 最小值最大 / 至少 / 不超过);
  • 怎么写出 check(x) 判定函数,以及它为什么必须是「单调」的;
  • 答案区间的上下界怎么定,避免开太小查不到、开太大浪费时间。

明天四道题:P1873 砍树、P2440 木材加工、P1182 数列分段、P3853 路标设置。今天先把二分的边界练到不出错——因为二分答案的代码量,一半都在二分的框架上

ACM 一个月学习计划 · DAY 23

Day 23 · 二分答案:把「求解」变成「判定」

看到「最大值最小」和「最小值最大」就条件反射地想二分 —— 这是今天要练成的肌肉记忆

今日目标:会判断一道题能不能二分答案,会写 check 函数,会选对 mid 的取整方式
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1873 / P2440 / P1182 / P3853
DAY 23 / 30

今日时间安排

0 - 25 min想通「二分答案」到底在二什么(第 1 节)
25 - 60 min啃两套模板 + 死循环分析(第 2 节)—— 今天的核心
60 - 90 mincheck 函数怎么写 + 上下界怎么定(第 3、4 节)
90 - 160 min做 4 道题,每题限时 20 分钟
160 - 180 min对照题解补齐 + 做自测 + 抄速查卡
💡 和昨天的衔接

昨天(Day 22)你学的是二分查找:在一个已经排好序的数组里,快速找某个值的位置。 判断依据是「a[mid]x 谁大」。

今天要往前跨一大步:数组不见了,我们改成在「答案可能的取值范围」里二分, 判断依据是「如果答案是 mid,这件事办得到吗?」。这个判断函数就是传说中的 check()

二分查找是「在数据里二分」,二分答案是「在答案里二分」—— 内核完全一样,都是「每次砍掉一半」。

1二分答案到底在「二」什么

先讲个生活里的例子

你心里想了一个 1 到 100 之间的整数,我猜。我每猜一个数,你只回答「大了」或「小了」。

我绝不会从 1 开始一个一个试,我会先猜 50。你说「小了」,我立刻知道答案在 51~100,一口气砍掉一半; 再猜 75……最多 7 次就猜中。

二分答案就是把这个游戏搬到程序里,只不过「大了 / 小了」换成了 check 函数的 true / false。

🎯 一句话说清二分答案

把「求最优解」这个大难题,变成「判断某个数可行不可行」这个小问题, 然后用二分把答案的取值范围一次次砍半。

关键前提:单调性

猜数字能玩,是因为有单调性:答「小了」就意味着「比它小的全都不用看了」。

二分答案也一样,必须满足:答案 x 越大(或越小),check(x) 越容易(或越难)成立。 只有这样,check 的结果才会是「一整段 true + 一整段 false」这种形状,二分才有意义。

用一个具体例子感受一下:P1873 砍树

树高分别是 20、15、10、17,要砍到至少 7 米木材。锯片高度 H 设多少最好?

显然 H 定得越高,被锯下来的部分越少。我们列一下:

锯片高度 H砍到的木材够 7 米吗?
020+15+10+17 = 62✅ 够
1010+5+0+7 = 22✅ 够
155+0+0+2 = 7✅ 刚好够
164+0+0+1 = 5❌ 不够
200❌ 不够

看到了吗?「够」永远排在前面,「不够」永远排在后面,中间只有一个分界点。 这个分界点左边的最后一个「够」,就是我们要的答案(H = 15)。

⚠️ 注意单调的方向

在砍树这题里:H 越大 → 越难满足(check 结果从 true 变成 false)。
有些题是反过来的:x 越大 → 越容易满足(例如 P1182 的「每段和不超过 x」,x 越大越好分)。

做题第一步永远是:先在草稿纸上想清楚单调方向,再决定二分怎么写。方向搞反,答案必错。

二分查找 vs 二分答案,对照着看

二分查找(昨天)二分答案(今天)
二分的对象数组的下标范围 [0, n-1]答案的取值范围 [下界, 上界]
判断依据a[mid]x 比大小check(mid) 返回 true / false
要预先有什么数组有序答案单调(check 的形状是 true…true…false…false)
复杂度O(log n)O(n log 值域)(check 通常是 O(n))
典型问法「这个数在下标几」最大能到多少 / 最小能到多少」
💡 什么题一看就要想二分答案

题目里出现下面这些字眼,八成就是二分答案:

· 「最大值最小」/「最小值最大
· 「最短跳跃距离的最大值」/「每段和的最大值最小
· 「求最大的高度 H,使得……」/「求最小的长度 l,使得……」

共同特征:答案是一个数,而且这个数「行不行」很好判断,但直接算出来很难。

2两套模板 + 死循环的根源(今天最重要的一节)

这一节请慢一点看。二分答案写挂,90% 都是因为模板用混了或者 mid 取整取反了

先分清题目问的是哪一类

类型题目的问法check(x) 的单调方向我们要找
A 类
最大化
最短距离的最大值
「能架多高
「能切多长
x 越大越满足
true…true…true…false…false
最后一个
true
B 类
最小化
「每段和的最大值最小
「空旷指数的最小值
x 越大越容易满足
false…false…true…true…true
第一个
true

模板 A:求「最大的可行值」(A 类题用)

思路:只要 mid 可行,就把它记到 ans 里,然后壮着胆子往更大的方向试

int l = 下界, r = 上界;
int ans = -1;                        // 记不住答案时的兜底值
while(l <= r){
    int mid = l + (r - l) / 2;       // 下取整(这种写法取哪种都行)
    if(check(mid)){                  // mid 可行 → 记下来,往「更大」收
        ans = mid;
        l = mid + 1;
    } else {                         // mid 不可行 → 只能往「更小」收
        r = mid - 1;
    }
}
cout << ans << endl;                // ans 就是最大的可行值
✅ 为什么这个模板绝不会死循环

两个分支分别是 l = mid + 1r = mid - 1不管走哪条,区间长度都在变小(至少少 1)。
区间一直在缩短,迟早会变成空区间,l <= r 不成立,循环自然结束。

模板 B:求「最小的可行值」(B 类题用)

思路:mid 可行就往更小的方向压(把 r 收到 mid),不可行就只能放大。

int l = 下界, r = 上界;              // ★ 前提:check(r) 必须为真!
while(l < r){                        // 注意是 < 不是 <=
    int mid = l + (r - l) / 2;       // ★ 下取整,必须配 r = mid
    if(check(mid)) r = mid;          // 可行 → 试着更小
    else l = mid + 1;                // 不可行 → 必须更大
}
cout << l << endl;                  // 循环结束时 l == r,就是答案
⚠️ 模板 B 有个隐藏要求

r 的初值必须是「一定可行」的值。因为循环里 r 只会被赋成 mid, 我们默认它一直是可行的。
如果 check(r) 本身是 false,这个模板会输出一个根本不可行的答案。
例如 P1182 里 r 取所有数之和(整段不切,一定可行);P3853 里 r 取 L(一块路标都不加,间距也不可能超过 L)。

★ 死循环的根源:l = mid 必须配上取整

还有一种「求最大可行值」的写法是这样收缩区间的:

while(l < r){
    int mid = (l + r) / 2;
    if(check(mid)) l = mid;          // ← 注意这里是 l = mid,不是 l = mid + 1
    else r = mid - 1;
}

这么写本身没错,但 mid 必须改成上取整,否则死循环。我们手动模拟一下就明白了。

反面教材:l = 3, r = 4,用下取整

轮次lrmid = (l+r)/2check(mid)动作
第 1 轮343truel = mid = 3
第 2 轮343truel = mid = 3
第 3 轮343truel = mid = 3
……永远出不去 ⛔

原因很简单:l = 3, r = 4(3+4)/2 = 3(整数除法把 3.5 砍成 3)。 于是 l = mid 执行完 l 还是 3,区间 [3, 4] 一点没变,下一轮当然一模一样。

正确写法:上取整

while(l < r){
    int mid = (l + r + 1) / 2;       // ★★ 多加了 1,向上取整
    if(check(mid)) l = mid;          // l = 4 → l == r,循环结束
    else r = mid - 1;
}

这时候 (3+4+1)/2 = 4l = mid = 4l == r,循环干净利落地结束。

🚨 把这句话刻在脑子里

出现 l = mid → mid 上取整 (l+r+1)/2
出现 r = mid → mid 下取整 (l+r)/2

也就是:谁被赋成 mid,mid 就朝谁的方向取整。
r = mid 时如果上取整(l=3, r=4 → mid=4),r = 4 区间也不动, 同样死循环 —— 危险是双向的。

💡 给新手的最佳实践

实在记不住取整规则,就永远用模板 Al = mid + 1 / r = mid - 1 / 用 ans 记答案)。
它的两个分支都在「跨过 mid」,区间必然缩短,取哪种 mid 都不会死循环。 模板 B 只是代码短一点,没有本质优势。

另外,mid = l + (r - l) / 2 这种写法比 (l + r) / 2 更安全: 当 lr 都接近 int 上限时,l + r 会溢出,而 l + (r - l) / 2 不会。养成习惯。

3check 函数:二分答案的心脏

二分的框架就那么几行,真正决定成败的是 check。 竞赛里「check 写错」比「二分写错」常见得多。

check 通常长什么样

今天 4 道题的 check 全是 O(n) 的贪心扫描:从头到尾扫一遍数组,用一个变量累计, 该「切一刀」就切、该「搬走」就搬。扫完跟给定的限制比一比,返回 true / false。

题目check(x) 在做的事复杂度
P1873h[i] - x 的正数部分加起来,看有没有 >= MO(n)
P2440把每根木头的 L[i] / x 加起来,看有没有 >= kO(n)
P1182贪心切段:累加和超过 x 就切一刀,看总段数有没有 <= MO(n)
P3853每一段间距 d 要新插 (d-1)/x 块路标,看总数有没有 <= KO(n)
🎯 check 的两条铁律

铁律一:check(x) 只回答「x 行不行」,不负责找最优解。别在里面又写二分、又求最大值。

铁律二:check 的返回值必须随 x 单调。如果发现「有时候 true 有时候 false 毫无规律」, 那不是二分写错了,是这个量根本不能二分(或者你 check 的思路错了)。

先在纸上手算 check(重要习惯)

以 P1182 为例:数列 4 2 4 5 1,要求分成 3 段,问「每段和的最大值最小是多少」。

我们猜答案 x = 6。手工跑一遍贪心切段:

  • 4 → 当前段和 4(还没超 6)
  • 4 + 2 = 6 → 没超过 6,留着
  • 6 + 4 = 10 > 6 → 切一刀!新段从 4 开始
  • 4 + 5 = 9 > 6 → 再切一刀!新段从 5 开始
  • 5 + 1 = 6 → 没超过 6,留着

结果分成 [4 2] [4] [5 1],共 3 段3 <= 3 ✅ 所以 x = 6 可行。

再试 x = 5:[4] [2] [4] [5] [1] 要 5 段,5 > 3 ❌ 不可行。 所以 6 就是最小的可行值 —— 和洛谷给的样例答案一模一样。

💡 为什么要手算

手算一遍,你就知道自己有没有理解 check。代码只是把手算过程翻译成循环而已。 如果样例都手算不出来,写出来的 check 大概率是错的。

4二分的上下界怎么定(别凭感觉乱写)

二分答案的下界和上界不是随便拍脑袋的,它有固定套路

  • 下界取「答案的理论最小值」:通常是最小的元素、0、或者 1。
  • 上界取「答案的理论最大值」:最大的元素、所有数之和、或者题面给的某个量。
  • 如果用的是模板 Br = mid 那种),还要额外确认 check(上界) 一定为真。
题目二分的量下界 l上界 r为什么
P1873锯片高度 H0max(hi)再高就一根都砍不到,木材必然是 0
P2440每段长度 len1max(Li)要求长度是正整数;比最长的木头还长就一段都切不出
P1182每段和上限 xmax(Ai)sum(Ai)x 至少得装得下最大的单个数;全放一段一定可行
P3853空旷指数 x1L间距是整数,最小 1;公路总长 L 是任何间距的上限
⚠️ 上下界写错的两种典型后果

上界开小了:真正的答案在上界外面,二分永远找不到它,输出一个偏小的错误答案。
下界开大了:同理,会漏掉更小的可行解。

另外注意 P1182 的 Ai 可以是 0(题面说的是「非负整数」), 所以下界不能想当然地写 1,要写 max(Ai) 才严密。

💡 关于溢出的一句提醒

P1873 的木材总量、P1182 的区间和都可能超过 int(21 亿)。 凡是「一堆数加起来」的地方,一律用 long long今天 4 道题里有 3 道都用上了。

5四道例题详解

下面每道题的代码我都用 g++(-std=c++14 -O2 -Wall)实际编译过,并用洛谷官方样例跑过, 「实测结果」里写的就是真实输出。

P1873 [COCI 2011/2012 #5] EKO / 砍树 普及- · 二分答案入门(A 类)

题意:有一排树,高度各不相同。伐木机的锯片可以升到整数高度 H, 比 H 高的部分会被锯下来。要拿到至少 M 米木材,问锯片最高能升到多少?

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 10⁶,1 ≤ M ≤ 2×10⁹,树的高度 ≤ 4×10⁵,所有树的高度总和 > M。

两个提醒:
· N 有 100 万,check 必须是 O(n) 且常数要小,二分的层数是 log(4×10⁵) ≈ 19 层, 总共约 2×10⁷ 次操作,可以过。
· 木材总量可能很大,一定要 long long

思路

典型的 A 类(最大化)问题:答案 H 越大,砍到的木材越少。

check(H):把每棵树超过 H 的部分加起来,看总数是不是 ≥ M。
这个函数从 H = 0 到 H = max(h) 的取值是「true…true…false…false」, 我们要最后一个 true 对应的 H

所以用模板 A:可行就 ans = mid; l = mid + 1,不可行就 r = mid - 1

样例输入 1
4 7
20 15 10 17
样例输出 1
15
样例输入 2
5 20
4 42 40 26 46
样例输出 2
36

完整代码

#include <iostream>
using namespace std;

int n;
long long m;                 // 需要的木材总量(可能到 2×10^9,必须 long long)
int h[1000005];              // 每棵树的高度,n 最大 10^6,数组开在全局

// check(H):锯片升到高度 H 时,砍下的木材总量是否 >= m
bool check(long long H){
    long long sum = 0;
    for(int i = 0; i < n; i++){
        if(h[i] > H) sum += h[i] - H;   // 只有比 H 高的部分才被锯下来
        if(sum >= m) return true;       // 够了就提前返回:既加速又防止累加溢出
    }
    return false;
}

int main(){
    ios::sync_with_stdio(false);        // 关掉 cin/cout 与 scanf 的同步,读入更快
    cin.tie(0);
    cin >> n >> m;
    int maxh = 0;
    for(int i = 0; i < n; i++){
        cin >> h[i];
        if(h[i] > maxh) maxh = h[i];    // 顺路求出上界:最高的那棵树
    }

    // 答案(锯片高度)越大,砍到的木材越少 -> 单调,可以二分
    long long l = 0, r = maxh, ans = 0;
    while(l <= r){                      // 模板 A:ans 记录 + 区间跨越 mid
        long long mid = l + (r - l) / 2;
        if(check(mid)){                 // mid 可行,记下来,再试着锯更高
            ans = mid;
            l = mid + 1;
        } else {                        // mid 太高,木材不够,只能往低处找
            r = mid - 1;
        }
    }
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 1 4 7 / 20 15 10 17 → 输出 15 与标准答案完全一致

官方样例 2 5 20 / 4 42 40 26 46 → 输出 36 与标准答案完全一致

极限数据 N = 10⁶、树高随机取 1~4×10⁵、M = 2×10⁹ → 输出 359815, 实测运行 402 ms(约 19 层二分 × 10⁶ 次扫描,符合 O(N log 值域) 的预期)。

另外与暴力枚举(从 0 到 max(h) 逐个试 H)随机对拍 300 组,全部一致

易错点

  • 累加和不开 long long:10⁶ 棵树 × 4×10⁵ 高度,最坏能到 4×10¹¹,int 直接爆掉。 代码里用 sum >= m 提前 return 也是一种保护。
  • 上界写成 10⁹ 之类的「想当然值」:树高最大只有 4×10⁵,上界取 max(h[i]) 最稳, 也顺便减少了二分层数。
  • 把 check 的方向写反:记住「H 越高,木材越少」。如果不确定,就拿样例 2 手工验一个 H 值。
  • N = 10⁶ 时用 cin 记得加 ios::sync_with_stdio(false),不然读入会慢得明显。
P2440 木材加工 普及/提高- · 二分答案(A 类)+ 整数除法

题意:有 n 根原木,要切成 k 段等长的小段(长度必须是正整数,允许有剩余)。 问每段最长能有多长?如果连 1cm 的都切不出来,输出 0。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 10⁵,1 ≤ k ≤ 10⁸,1 ≤ Li10⁸

注意 k 可能非常大(1 亿段),而木头最长才 1 亿厘米 —— 但每根木头最多切出 L[i] / len 段, 累加值用 long long 就够了。关键是别把「切出的段数」当 int 去比 k,累加过程可能中转出 int 范围。

思路

也是 A 类:小段长度 len 越长,能切出的段数越少。

check(len)Σ (L[i] / len) ≥ k 吗?这里的除法是整数除法, 天然就是「这根木头能切几段」,不用额外处理余数。

下界取 1(长度必须是正整数),上界取 max(L[i])。 如果 check(1) 都不成立,那 ans 一直是初值 0,正好输出 0 —— 初值选 0 是关键

样例输入
3 7
232
124
456
样例输出
114

验算:232/114 = 2,124/114 = 1,456/114 = 4,合计 7 段 ✅;
如果 len = 115:232/115 = 2,124/115 = 1,456/115 = 3,合计 6 段 < 7 ❌ 所以答案就是 114。

完整代码

#include <iostream>
using namespace std;

int n;
long long k;                 // 需要的小段数量,最大 10^8
int L[100005];               // 每根原木的长度

// check(len):所有原木都切成 len 厘米的小段,能不能凑够 k 段
bool check(long long len){
    long long cnt = 0;
    for(int i = 0; i < n; i++){
        cnt += L[i] / len;        // 整除:这根原木能切出几段
        if(cnt >= k) return true; // 够了就提前退出,后面不用再算
    }
    return false;
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> k;
    int maxl = 0;
    for(int i = 0; i < n; i++){
        cin >> L[i];
        if(L[i] > maxl) maxl = L[i];    // 上界:最长的那根原木
    }

    // len 越大,能切出的段数越少 -> 单调
    long long l = 1, r = maxl, ans = 0; // ★ ans 初值 0:切不出来时正好输出 0
    while(l <= r){
        long long mid = l + (r - l) / 2;
        if(check(mid)){                 // 能切够,记下来,再试试切更长
            ans = mid;
            l = mid + 1;
        } else {
            r = mid - 1;                // 切太长了,段数不够
        }
    }
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 3 7 / 232 124 456 → 输出 114 与标准答案完全一致

边界 2 5 / 1 1(两根 1cm 的木头要切 5 段,总共只有 2cm)→ 输出 0 正确 (连 1cm 都切不出来,题面要求输出 0)。

与暴力枚举(len 从 1 试到 max(L))随机对拍 300 组,全部一致

易错点

  • ans 初值写成 -1:那样「切不出来」时会输出 -1,而题目要的是 0。
  • 下界写 0L[i] / 0 会直接除零崩溃(RE)。长度是正整数,下界就该是 1。
  • int 累加段数:n 最大 10⁵、每根最多切 10⁸ 段,累加过程可能超过 21 亿,用 long long
  • 不需要判断「余料怎么处理」—— 题目允许有剩余,整数除法天然处理了。
P1182 数列分段 Section II 普及/提高- · 二分 + 贪心验证(B 类)

题意:把一个长度为 N 的正整数数列分成 M 段(每段必须连续), 让「各段和中最大的那个」尽可能小,求这个最小值。

题面例子:4 2 4 5 1 分 3 段。
[4 2][4 5][1] → 段和 6、9、1,最大 9;
[4][2 4][5 1] → 段和 4、6、6,最大 6。最大值的最小可能值就是 6。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 10⁵,M ≤ N,Ai < 10⁸,答案不超过 10⁹

两个坑:
· Ai非负整数(题面原话),也就是可能为 0,所以下界不能写死成 1,要写 max(Ai)
· 所有数加起来最多 10⁵ × 10⁸ = 10¹³,必须 long long

思路

这是今天第一道 B 类(最小化最大值)题,也是「二分答案 + 贪心」的经典组合。

我们二分的量是「允许的每段和上限 x」。check(x) 回答: 在每段和都不超过 x 的前提下,最少要分几段?如果这个段数 ≤ M,那 x 就可行。

「最少分几段」用贪心求:从前往后累加,一旦加上当前数就要超 x,就在这里切一刀。 这是最优的 —— 每一段都塞到装不下为止,段数自然最少。

🎯 为什么 x 越大段数越少(单调性)

上限放宽了,每一段能塞更多数 → 段数只会变少,不会变多。
所以 check(x) 的形状是「false…false…true…true」: x 太小分不出(段数超 M),x 够大就行。
我们要找的是第一个 true → 用模板 B:可行就让 r = mid 往小压。

上下界:l = max(Ai)(任何一段的和都不可能小于最大的单个数), r = sum(Ai)(全部塞进一段,一定可行,满足模板 B 的前提)。

样例输入
5 3
4 2 4 5 1
样例输出
6

完整代码

#include <iostream>
using namespace std;

int n, m;
int a[100005];

// check(x):限制「每段和都不超过 x」,最少要分成几段?段数 <= m 就算可行
bool check(long long x){
    int cnt = 1;             // 至少有一段
    long long cur = 0;       // 当前这一段已经累加的和
    for(int i = 0; i < n; i++){
        if(a[i] > x) return false;      // 一个数自己就超过 x,怎么分都不行
        if(cur + a[i] > x){             // 加上就爆了 -> 在这里切一刀
            cnt++;
            cur = a[i];
        } else {
            cur += a[i];                // 还能装得下,继续累加
        }
    }
    return cnt <= m;         // 用的段数不超过 m 就说明 x 可行
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> n >> m;
    long long sum = 0, mx = 0;
    for(int i = 0; i < n; i++){
        cin >> a[i];
        sum += a[i];
        if(a[i] > mx) mx = a[i];
    }

    // 「最大值最小」:x 越大越容易可行 -> 单调,二分找最小的可行 x
    long long l = mx, r = sum;             // ★ r = sum 保证 check(r) 一定为真
    while(l < r){                          // 模板 B:while(l < r)
        long long mid = l + (r - l) / 2;   // 下取整,配合 r = mid 使用
        if(check(mid)) r = mid;            // 可行,试着更小
        else l = mid + 1;                  // 不可行,必须更大
    }
    cout << l << endl;                     // 结束时 l == r,就是最小的可行值
    return 0;
}
✅ 实测结果

官方样例 5 3 / 4 2 4 5 1 → 输出 6 与标准答案完全一致

边界 3 3 / 1 2 3(N = M,每个数自成一段)→ 输出 3 正确(此时段和最大值就是最大的元素)。

极限数据 N = 10⁵、M = 50000、Ai 随机取 0~10⁸ → 输出 134021264 (在题面保证的 10⁹ 以内),实测 76 ms

与暴力枚举(枚举所有 2N-1 种切分方案)随机对拍 300 组,全部一致

易错点

  • 下界写成 0 或 1:Ai 可能比它大,会导致 check 永远返回 false,输出错误。要用 max(Ai)
  • 两个 while 循环条件搞混:模板 B 是 while(l < r),不是 l <= r。 写成 <= 会多转一轮,可能输出越界的值。
  • 模板 B 里把 r = mid 写成 r = mid - 1:那就变成模板 A 了, 而且最后没有 ans 记录,输出的是错误的值。收缩方式和「答案落在哪」必须配套。
  • 贪心切段时忘了「不足以放下一个数就先切」,或者把 cur + a[i] > x 写成 >=: 题目要求「不超过 x」,等于 x 是合法的,所以必须用 >
P3853 [TJOI2007] 路标设置 普及/提高- · 二分 + 贪心验证(B 类)

题意:一条长 L 的公路上原来有 N 个路标(起点、终点一定有)。 最多可以再增设 K 个路标(位置必须是整数)。定义「空旷指数」= 相邻路标间距的最大值。 问空旷指数最小能压到多少。

⚠️ 数据范围 / 关键约束

2 ≤ N ≤ 100000,0 ≤ K ≤ 100000,0 < L ≤ 10⁷

注意 K 可以为 0(一个人都不加),这时候答案就是原来最大的那个间距。 另外路标位置是整数,这个条件会影响 check 里的取整方式(下面细说)。

思路

典型的 B 类题:最小化「最大间距」。

二分「允许的最大间距 x」。check(x) 回答:要让所有相邻间距都 ≤ x, 最少要新增几块路标?总数 ≤ K 就可行。

★ 单段要插几块?推导一下

某两块的间距是 d。如果新插 c 块,就把 d 切成 c+1 段。 要让每一小段都 ≤ x,需要 d / (c+1) ≤ x,也就是 c ≥ d/x - 1

因为 c 要是整数,所以最少c = ⌈d/x⌉ - 1

⌈d/x⌉ = (d-1)/x + 1(整数除法),所以:

cnt = (d - 1) / x;      // 一行搞定,整数除法自动下取整
间距 d允许的最大间距 x(d-1)/x要不要真的能摆得下?
101511摆 1 块在 51 处 → 间距 51 和 50,都 ≤ 51 ✅
101502摆 2 块在 50、100 处 → 间距 50、50、1 ✅
100501摆 1 块在 50 处 → 间距 50、50 ✅
51510本来就合格,一块都不用加 ✅
💡 为什么是 (d-1)/x 而不是 d/x

如果写成 d / x,当 d 正好是 x 的倍数时会多算一块。
例:d = 50, x = 50。正确答案是 0 块(本来就不超),但 50/50 = 1 会算成 1 块。
(d-1)/x49/50 = 0 ✅ 正确。这个 -1 是本题最大的坑。

上下界:l = 1(间距是整数,最小为 1),r = L(总长是任何间距的上限, 而且 x = L 时一块路标都不用加,必然可行 → 满足模板 B 的前提)。

样例输入
101 2 1
0 101
样例输出
51

只有起点 0 和终点 101 两块路标,可以加 1 块。
加在 50 处 → 间距 50、51,最大 51;加在 51 处 → 间距 51、50,最大 51。
想让最大间距到 50,就必须加 2 块(超预算),所以答案是 51。

完整代码

#include <iostream>
using namespace std;

int L, n, k;
int a[100005];               // 原有路标位置(输入保证递增)

// check(x):想让所有相邻路标的间距都 <= x,最少要新增几块路标?<= k 就可行
bool check(long long x){
    long long need = 0;
    for(int i = 1; i < n; i++){
        long long d = a[i] - a[i - 1];      // 这一段原来的间距
        // 间距 d 用 cnt 块新路标切成 cnt+1 段,需要 d/(cnt+1) <= x
        // 所以 cnt = ceil(d/x) - 1 = (d-1)/x(整数除法自动下取整)
        need += (d - 1) / x;                // ★ 少写这个 -1 就会 WA
        if(need > k) return false;          // 已经超预算,不用再算了
    }
    return need <= k;
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> L >> n >> k;
    for(int i = 0; i < n; i++) cin >> a[i];

    // 「空旷指数」= 相邻路标的最大间距,我们要让它最小
    // x 越大越容易做到 -> 单调,二分最小的可行 x
    long long l = 1, r = L;              // ★ r = L 时一块都不用加,一定可行
    while(l < r){
        long long mid = l + (r - l) / 2;
        if(check(mid)) r = mid;          // 做得到,往更小的方向压
        else l = mid + 1;                // 做不到,只能放宽
    }
    cout << l << endl;
    return 0;
}
✅ 实测结果

官方样例 101 2 1 / 0 101 → 输出 51 与标准答案完全一致

边界 10 3 0 / 0 3 7 10(K = 0,一块都不许加)→ 输出 4 正确 (三个间距是 3、4、3,最大值 4,没得选)。

与暴力对拍(逐个试 x,用「一块一块往前跳」的独立算法统计所需路标数)随机 300 组,全部一致

易错点

  • (d-1)/x 写成 d/x:间距正好是 x 的倍数时会多算一块路标,导致答案偏大。
  • 把「空旷指数」理解成间距之和或平均值:它是相邻间距的最大值,要最小化这个最大值。
  • K = 0 的情况没考虑:此时答案就是原来最大的间距,二分必须能自然给出这个结果(我们 r = L、l = 1,没问题)。
  • 路标位置是整数这一点决定了取整公式。如果题目允许实数的路标,公式就变成 ceil(d/x)-1 的另一套算法了 —— 看清「整数」二字

6今日常见错误汇总

错误写法后果正确写法
while(l<r){ mid=(l+r)/2; if(ok) l=mid; ...} l 和 r 相邻时会死循环(TLE),例如 l=3, r=4 → mid=3 → l=3 mid = l+(r-l+1)/2;
(有 l=mid 就上取整)
用模板 B 但忘了「r 必须可行」
(如 P1182 的 r 取 max(A)
输出一个根本不成立的答案 r 取「一定可行」的值
(P1182 取 sum,P3853 取 L)
模板 A 和模板 B 的收缩混着用
r = midl = mid
区间可能不缩短 → 死循环;或漏掉答案 要么「跨越 mid + ans 记录」,要么「贴住 mid + 最后输出 l」
累加和 / 段数用 int P1873、P1182 会溢出,得到莫名其妙的负数 一律 long long
下界 / 上界拍脑袋乱写
(P1182 下界写 1、P2440 下界写 0)
答案偏大/偏小;P2440 下界 0 会除零 RE 下界 = max(单个数) 或 1;上界 = max 或 sum
check 的单调方向搞反
(可行时往错误的方向收)
输出边界值(比如总是输出上界或下界) 写代码前先问自己:x 变大是更容易还是更难?
P3853 里写 d / x d 是 x 的整数倍时多算一块路标 → 答案偏大 (d - 1) / x
check 里写 >= 还是 > 搞混 P1182 要求「不超过 x」,写成 >= 会少分一段 严格读题:「不超过」用 > 判断超限

自测:你真的会了吗

第 1 题:这道题能用二分答案做吗?

题目:给 n 个物品的价值,要选出 恰好 k 个,使总价值最大。

答:不能。「选恰好 k 个」这件事和「总价值至少为 x」之间没有单调关系 —— 总价值够了,不代表正好用 k 个物品凑出来的。二分答案的前提是答案具有单调性, 这里 check 的形状是乱的,二分毫无意义。这题是排序 / DP 的活。

第 2 题:这段代码会死循环吗?
int l = 0, r = 100;
while(l < r){
    int mid = (l + r) / 2;
    if(check(mid)) l = mid;
    else r = mid - 1;
}

答:会。因为有 l = mid,mid 必须上取整写成 (l + r + 1) / 2

举个具体的:当区间收缩到 l = 0, r = 1,若 check(0) 为真, 执行 l = mid = 0,区间还是 [0, 1],下一轮一模一样,永远出不去。

第 3 题:P1182 里,为什么下界要取 max(Ai)?

答:因为「每段和的上限 x」必须至少能装下单独一个元素。

如果某个 Ai > x,这个元素不管放进哪一段,那段的和都会超过 x, 所以 x 必然不可行。换句话说,x 的理论最小值就是最大的那个数

把下界设成 max(Ai) 还能顺便在 check 里省掉一个判断(但写上 if(a[i] > x) return false; 更保险)。

第 4 题:P3853 里,间距 d = 100,允许的最大间距 x = 25,要插几块路标?

答:用公式 (d-1)/x = 99/25 = 3 块。

验证:插在 25、50、75 处 → 四段间距都是 25,都 ≤ 25 ✅。
如果只用 2 块,最多切成 3 段,100/3 = 33.3 > 25 ❌,确实不够。 公式算出来是 3,手工验证也是 3,对上了。

第 5 题(手写代码):写出「求最小可行值」的模板
int l = 下界, r = 上界;        // 保证 check(r) == true
while(l < r){
    int mid = l + (r - l) / 2;
    if(check(mid)) r = mid;    // 可行 → 往更小压
    else l = mid + 1;          // 不可行 → 必须更大
}
cout << l << endl;              // l == r 就是答案

★ 记忆点:有 r = mid 就下取整,有 l = mid 就上取整。

速查卡(截图存手机)

【判断能不能二分答案】
  1. 答案是一个数,而且题问「最大是多少 / 最小是多少」
  2. check(x) == 「如果答案是 x,办得到吗」好写(通常 O(n) 贪心)
  3. check 的结果随 x 单调:true…true…false…false 或反过来
  三条全中 → 二分答案

【模板 A:求最大的可行值】(最大值最大化 / 最小值最大化)
  int l = 下界, r = 上界, ans = -1;
  while(l <= r){
      int mid = l + (r - l) / 2;
      if(check(mid)){ ans = mid; l = mid + 1; }
      else r = mid - 1;
  }
  cout << ans;
  // 区间每次都跨越 mid,取哪种取整都安全

【模板 B:求最小的可行值】(最大值最小化)
  int l = 下界, r = 上界;          // ★ check(r) 必须为 true
  while(l < r){
      int mid = l + (r - l) / 2;   // 下取整,配 r = mid
      if(check(mid)) r = mid;
      else l = mid + 1;
  }
  cout << l;

【防死循环口诀】
  出现 l = mid  →  mid = (l + r + 1) / 2   (上取整)
  出现 r = mid  →  mid = (l + r) / 2       (下取整)
  记不住就永远用模板 A。

【防溢出】
  mid 用 l + (r - l) / 2,不要 (l + r) / 2
  凡是「一堆数加起来」的量(木材、段和、间距和)一律 long long

【今天的上下界】
  P1873  H  ∈ [0, max(h)]
  P2440  len∈ [1, max(L)]
  P1182  x  ∈ [max(A), sum(A)]
  P3853  x  ∈ [1, L]

【今天 check 的三个小公式】
  砍树:    sum(max(0, h[i] - H)) >= M
  切木头:  Σ (L[i] / len) >= k
  切段:    累加超过 x 就切一刀,段数 <= M
  插路标:  Σ ((d - 1) / x) <= K      // ★ 别忘了 -1

明天预告 · Day 24

二分与数据结构综合 —— 把二分、前缀和、贪心、DP 串起来用。

明天四道题各有各的味道:

  • P2678 跳石头:二分 + 贪心,是今天模板 A 的进阶版(NOIP 2015 提高组)。
  • P1163 银行贷款:第一次碰实数二分。答案不是整数了, 不能再用 while(l <= r),改成固定循环 100 次最稳。
  • P3131 余数为 7前缀和 + 同余。一个很漂亮的小技巧: 只要两个前缀和 mod 7 相等,中间那一段的和就是 7 的倍数。
  • P1115 最大子段和:经典的 Kadane / 线性 DP,是 Day 25「线性 DP」的开胃菜。

今天先把两套模板写熟,明天就不会被实数二分和前缀和搞晕。

ACM 一个月学习计划 · DAY 24

Day 24 · 二分与数据结构综合

把二分、贪心、前缀和、DP 串成一条线 —— 从今天起,你开始写「组合拳」代码

今日目标:会写实数二分;会用「前缀和 + 同余」;会写最大子段和的 DP
预计用时:约 3 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P2678 / P1163 / P3131 / P1115
DAY 24 / 30

今日时间安排

0 - 30 min二分 + 贪心:跳石头(第 1 节,昨天的模板 A 升级版)
30 - 65 min实数二分:银行贷款(第 2 节)—— 今天的第一个新东西
65 - 100 min前缀和 + 同余:余数为 7(第 3 节)
100 - 130 min最大子段和 + 二维拓展(第 4 节)
130 - 170 min做 4 道题,每题限时 20 分钟
170 - 180 min做自测 + 抄速查卡
💡 和昨天的衔接

昨天你学会了二分答案的两套模板,以及「二分的核心是 check」。 今天前两道题还是二分(一道整数、一道实数),后两道题完全换赛道 —— 变成前缀和线性 DP

为什么把它们放在一天?因为计划里这一天的定位是「综合」: 真正比赛里的题,很少只考一个知识点。今天开始,你要习惯「先拆解,再组合」。

1二分 + 贪心:跳石头

先回忆一下昨天的第 2 节

看到「最短跳跃距离的最大值」这种问法,条件反射应该是:

判断结论
答案是「最大」还是「最小」?最大化最短距离 → A 类,用模板 A(ans 记录)
check(x) 单调吗?x(要求的最短距离)越大,越难做到 → true…true…false…false ✅
check(x) 好写吗?好写:一次 O(n) 贪心扫描

三条全中,所以 P2678 就是一道标准的二分答案题。

check 的贪心怎么想

题目允许我们搬走至多 M 块石头,然后看所有「相邻落脚点」的距离里最小的那个能有多大。

现在假设我们已经定死了一个要求:所有跳跃距离都必须 ≥ x。那么从左往右走:

  • 记住上一块留下来的石头位置 last(一开始是起点 0)。
  • 看下一块石头 i。如果 a[i] - a[last] < x,说明从 last 跳到 i 太近了 —— 那就把 i 搬走,跳过它,last 不动。
  • 如果距离够远,就踩上去last = i
  • 扫完之后,看看一共搬走了几块。只要 ≤ M,就说明 x 能够做到。
🎯 为什么「太近就搬走当前这块」是对的

因为我们要尽可能少搬。碰到一块太近的石头,只有两种办法:搬走它,或者搬走前面那块。
搬走前面那块有害无益 —— 那会让 before→i 的距离变得更远,但同时也让「更前面 → 前面那块」的距离全部作废重算, 而且会白扔一次机会。所以能不动就不动,只在被逼无奈时搬走当前这块。

这就是贪心的味道:每一步都做当下最不亏的选择,并且这个选择不会让后面的路变窄。

★ 起点和终点怎么处理

题目说「不能移走起点和终点的岩石」,所以我们要另外对付终点 L。

做法很巧妙:把终点 L 也当成一块「石头」塞进数组末尾(下标 n+1),然后照常扫描。

扫描过程中如果终点离 last 也太近,代码一样会 cnt++。 这个 +1 的含义其实是:「连最后一块留着的石头也保不住,必须把它搬走」。

⚠️ 为什么「把终点算作被搬走」等价于「搬走最后一块留下的石头」

因为我们是从左往右贪心「能留就留」的,所以留下的石头数量已经是最多的了。 如果连最后留下那块的落地距离都不够,说明必须再牺牲一块 —— 而搬走它之后,跳到终点的距离只会更长,不会有新麻烦。

所以「cnt 里多算的那一次」正好等于「真实需要多搬的那一块」。 结论:这段贪心扫完得到的 cnt,就是最少需要搬走的石头数。

手算一遍样例

样例:L = 25,石头位置 2、11、14、17、21,最多搬走 M = 2 块。

试 x扫描过程搬走数≤ 2?
4 2-0=2<4 搬走 ①;11-0=11≥4 留;14-11=3<4 搬走 ②;17-11=6≥4 留;21-17=4≥4 留;25-21=4≥4 留 2
5 2-0=2<5 搬①;11-0=11≥5 留;14-11=3<5 搬②;17-11=6≥5 留;21-17=4<5 搬③;25-17=8≥5 留 3

所以 x = 4 做得到、x = 5 做不到 —— 答案就是 4,和洛谷样例一致。

题面提示也印证了:搬走 2 和 14 之后,最短距离是 4(17→21,或者 21→终点)。

2实数二分:银行贷款(今天的第一个新东西)

整数二分不够用了

昨天四道题的答案都是整数:高度、长度、段和、间距。 但 P1163 问的是月利率,答案是 2.9 这种小数。这时候二分还能用吗?能用, 而且更简单 —— 因为实数没有「相邻整数」的问题。

🎯 实数二分 vs 整数二分

相同点:都是「猜一个值 → check → 砍掉一半」,都要求单调性。
不同点:整数二分的循环条件是 l <= r(区间缩到空), 实数二分不能这么写 —— 因为实数区间「永远缩不到空」。

实数二分的循环怎么写?固定次数!

很多人会写 while(r - l > 1e-12)。这么写不算错,但有两个毛病:

  • 精度 eps 不好定:写大了答案不精确,写小了可能因为浮点误差永远不收敛(死循环)。
  • 迭代次数不确定,不好估计复杂度。

更稳的做法是:循环固定次数,比如 100 次

for(int it = 0; it < 100; it++){     // 固定 100 次,绝不死循环
    double mid = (lo + hi) / 2;
    if(check(mid)) ...;
    else ...;
}
💡 为什么 100 次绝对够

每迭代一次,区间长度就除以 2。
循环 100 次后,区间长度变成原来的 1 / 2¹⁰⁰ —— 这个数大约是 7.9 × 10⁻³¹远远超过 double 能表示的精度(double 只有效 15~16 位十进制)。

也就是说,循环到 60 次左右,lohi 在计算机看来已经是同一个数了, 再迭代也不会变。100 次属于「肯定够、还便宜」的选择(本题 m ≤ 3000,100 次也就 30 万次运算)。

P1163 的 check 怎么想

题意:借了 w0 元,每月还 w 元,还 m 个月正好还清。求月利率。

给定一个月利率 r,我们可以模拟一遍还款过程

double rem = w0;                    // rem = 现在还欠多少
for(int i = 0; i < m; i++){
    rem = rem * (1.0 + r) - w;      // 先按利率涨利息,再还掉 w
}
// 还完之后:rem > 0 表示还没还完;rem < 0 表示还多了

单调性很直观:利率越高,钱涨得越快,最后欠的就越多。 所以 rem 随 r 递增:r 太小 → rem 是负数(还多了),r 太大 → rem 是正数(还不完)。

我们要找的,就是让 rem ≈ 0 的那个 r。

🚨 方向别搞反(我第一次就写反了)

看 rem 的正负来决定往哪边收:

· rem < 0(还多了)→ 说明利息太少 → 利率太低lo = mid
· rem > 0(还不完)→ 说明利息太多 → 利率太高hi = mid

我第一版代码把这两行写反了,样例 1 直接输出 300.0(一路冲到上界)。 写反了不会报错,只会输出一个离谱的数 —— 所以一定要拿样例验。

防止数值爆炸:加一道保险

上界取 300%(即 r = 3)时,如果 m = 3000,那么中间过程会出现 1.05 × ... × 4³⁰⁰⁰ 这种 天文数字,直接变成 inf。虽然 inf 的正负号是对的(不会影响二分方向), 但为了干净,我们在累乘过程中加一道「提前跳出」:

if(rem > 1e18){ rem = 1e18; break; }    // 已经爆炸,符号已确定,不用再算
if(rem < -1e18){ rem = -1e18; break; }

最后一步:按题目要求输出

题目要「月利率(用百分数表示),四舍五入精确到 0.1%」。所以最后要 × 100 再用 printf("%.1f") —— %.1f 自带四舍五入,正好符合要求。

💡 一个有意思的实测细节

样例 1000 100 12 的真实利率其实是 2.922854%,四舍五入到 0.1% 就是 2.9
我另外拿 1000 200 6 试了一下,程序输出 5.5,真实值是 5.471793%, 而且我用「拿 5.5% 正向模拟还款」独立验证过:6 个月后剩余欠款 1.23 元 ≈ 0,确实还清了

3前缀和 + 同余:余数为 7

先复习前缀和(Day 15 学过)

前缀和数组 pre[i] = a[1] + a[2] + ... + a[i],那么:

区间 [l, r] 的和 = pre[r] - pre[l-1];      // O(1) 求任意区间和

比起每次都重新累加(O(n)),前缀和把「求区间和」变成了 O(1),这是拿空间换时间的老套路

今天的新技巧:把「是 7 的倍数」翻译成「余数相等」

题目要求:找一段连续奶牛,使它们的 ID 之和是 7 的倍数,并且这段尽可能长。

如果暴力枚举左右端点,N = 50000 时要枚举 12.5 亿个区间,太慢。用前缀和就快多了:

🎯 核心推导(只有两行,但很关键)

区间 (l..r) 的和 = pre[r] - pre[l-1]

要它被 7 整除,就是要 pre[r] - pre[l-1] ≡ 0 (mod 7), 也就是 pre[r] ≡ pre[l-1] (mod 7)

翻译成人话:只要两个前缀和在模 7 意义下余数相同,它们之间那一段的和就一定是 7 的倍数。

于是问题变成了「找最远的同余点对」

余数只有 0~6 七种。对于每一种余数 r,我们只需要记住两件事:

  • first[r]:余数 r 第一次出现在哪个下标;
  • last[r]:余数 r 最后一次出现在哪个下标。

那么余数为 r 的最长区间长度就是 last[r] - first[r](下标之差就是元素个数, 因为 pre[i] 和 pre[j] 之间的区间是 (i+1 .. j),长度 = j - i)。

七种余数各算一遍,取最大,就是答案。整个算法 O(n),7 个桶。

手算样例

样例:7 个数,分别是 3、5、1、6、2、14、10。

ia[i]pre[i]pre[i] mod 7该余数的 first该余数的 last
00000
133311
258122
319233
4615124
5217315
61431316
71041677

看余数 3:first = 1,last = 6,长度 = 6 - 1 = 5
看余数 1:first = 2,last = 4,长度 = 2;余数 0 只有下标 0,长度 0;余数 2 只有下标 3;余数 6 只有下标 7。
最大值是 5,正好是样例答案。

验证:下标 2~6 对应的数是 5、1、6、2、14,和 = 28 = 7 × 4 ✅(题面提示里也是这个例子)。

⚠️ 两个必须注意的细节

细节 1:pre[0] = 0 别忘了。「从第 1 个数开始」的区间,需要用到 pre[0] = 0(余数 0 在下标 0 就出现过)。所以循环前要 first[0] = last[0] = 0;

细节 2:长度是 last - first,不是 last - first + 1 因为 pre 的下标差本身就等于元素个数:pre[6] - pre[1] 对应的是第 2~6 个数,一共 5 个。

💡 这个技巧的通用形态

区间和能被 k 整除」问题都可以这么解:把前缀和全部对 k 取模, 然后找「同余的最远点对」。余数只有 k 种,开 k 个桶就够。

类似地,「区间和等于 0」就是「前缀和相等的最远点对」,也是同一个套路。 这个思想在后面学「哈希 + 前缀」的时候还会再遇到。

4最大子段和:Kadane 算法(线性 DP 的第一课)

题目很简单:找一段连续的子数组,让和最大

数列 2 -4 3 -1 2 -4 3,选一段连续的,让和最大。 选 3 -1 2 和为 4 —— 这就是样例答案。

从暴力到 O(n),一步步来

做法思路复杂度
三重循环枚举左端点、右端点,再套一层算区间和O(n³)
二重循环 + 边扩边加枚举左端点,右端点往右扩展时顺手累加O(n²)
Kadane / DP一趟扫描,边读边决定「接上」还是「重新开始」O(n)

n 最大 2×10⁵,O(n²) 是 4×10¹⁰ 次运算,肯定超时。必须用 O(n)。

核心思想:以 i 结尾的最大子段和

定义 f[i] = 「以第 i 个数结尾」的所有子段里,和最大的那个。

f[i] 只有两种来源:

  • 接在前面f[i-1] + a[i]
  • 从自己重新开始a[i](前面的统统不要)。
🎯 转移方程(背下来)

f[i] = max(a[i], f[i-1] + a[i]),答案 = max(f[1], f[2], ..., f[n])

为什么可以「重新开始」?因为如果 f[i-1] 是负数,那把它接在前面只会拖累我 —— 还不如从 a[i] 重新起头。负的累赘,果断丢掉。

走一遍样例

ia[i]f[i-1] + a[i]a[i]f[i]当前最优
12222
2-42-4 = -2-4-22
33-2+3 = 1333
4-13-1 = 2-123
522+2 = 4244
6-44-4 = 0-404
730+3 = 3334

看第 3 步:f[2] = -2 是负数,所以 f[3] 果断选择「从 3 重新开始」而不是「-2 + 3 = 1」。 这就是 Kadane 的灵魂。最后答案是 4。

🚨 最容易错的一点:子段必须「非空」

题目原话是「选出其中连续且非空的一段」。所以如果整个数列全是负数,例如 -2 -5 -1, 答案应该是 -1(挑最大的那个负数),而不是 0

很多人写 DP 时习惯把 best 初始化成 0,结果全负数输出 0 —— 直接 WA。 正确做法:best 初始化为第一个数,或者 -inf

我做了一组边界实测:3 / -2 -5 -1 → 输出 -1,正确 ✅

💡 顺带说说溢出

n = 2×10⁵、ai ≤ 10⁴,最坏总和 = 2×10⁹。 而 int 上限是 2147483647 ≈ 2.147×10⁹ —— 理论上刚好装得下,但只剩 1 亿的余量,太贴边了。 一律用 long long 最省心。

实测:n = 200000 且全为 10000 时,输出 2000000000,没有溢出。

拓展:二维怎么套这个模板

计划里还提了一句「二维前缀和 + 枚举:把二维压成一维再求最大子段和」。 这就是经典的「最大加权矩形」问题(Day 15 的 P1719),思路非常漂亮:

  • 枚举矩形的上边界 top下边界 bottom(两层循环)。
  • 把 top 到 bottom 之间,每一列的元素加起来,压成一个一维数组 col[]
  • 对这个 col[]最大子段和—— 找到的那一段就对应矩形的左右边界。

下面这段拓展代码我用洛谷 P1719 的官方样例实测过:

样例输入
4
0 -2 -7 0
9 2 -6 2
-4 1 -4 1
-1 8 0 -2
样例输出
15
#include <iostream>
#include <algorithm>
using namespace std;

int n;
int a[125][125];
int col[125];        // col[j] = 当前上下边界之间,第 j 列的元素和

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

    long long best = -1000000000LL;   // ★ 答案可能是负数,不能初始化成 0

    // 枚举上边界 top 和下边界 bottom,把这两行之间的每一列压缩成一个数
    for(int top = 1; top <= n; top++){
        for(int j = 1; j <= n; j++) col[j] = 0;
        for(int bottom = top; bottom <= n; bottom++){
            for(int j = 1; j <= n; j++) col[j] += a[bottom][j];
            // 现在问题变成了「一维数组 col[1..n] 的最大子段和」,直接 Kadane
            long long cur = col[1], mx = col[1];
            for(int j = 2; j <= n; j++){
                cur = max((long long)col[j], cur + col[j]);
                mx = max(mx, cur);
            }
            best = max(best, mx);
        }
    }
    cout << best << endl;
    return 0;
}
✅ 实测结果

P1719 官方样例(4×4 矩阵)→ 输出 15 与标准答案完全一致

另测全负数矩阵 2 / -1 -2 / -3 -4 → 输出 -1 正确(最大矩形就是单格 -1)。

这个「降维」思路能解决一大类二维区间问题。更重要的是 —— 你已经写出了人生第一个「非平凡」DP 的转移方程 cur = max(col[j], cur + col[j])。 明天 Day 25 的线性 DP,就是把这个思路正式系统化。

5四道例题详解

同样地,下面四段代码都用 g++(-std=c++14 -O2 -Wall)实际编译过,并用洛谷官方样例跑过

P2678 [NOIP 2015 提高组] 跳石头 普及/提高- · 二分 + 贪心(A 类)

题意:河道里起点、终点之间排着 N 块岩石。最多移走 M 块(起点终点不能动), 让选手「每一步跳跃距离」的最小值尽可能大,求这个最大值。

⚠️ 数据范围 / 关键约束

20% 的数据 0 ≤ M ≤ N ≤ 10;50% 的数据 N ≤ 100; 100% 的数据 0 ≤ M ≤ N ≤ 50000,1 ≤ L ≤ 10⁹

注意三点:
· M 可以等于 0(一块都不许搬),也可以等于 N(全搬光);
· L 最大 10⁹,加上还有石头位置,所以位置要用 int 以上存,比较时注意别溢出;
· 二分约 log(10⁹) ≈ 30 层,每层 O(N) = 5×10⁴,总共 1.5×10⁶ —— 非常轻松。

思路

这是昨天模板 A 的应用:答案「最短跳跃距离」越大越难满足,我们要最大的可行值

check(x):从左往右贪心扫描(起点 0 当作已经踩上), 距离不够就搬走当前石头,最后看搬走的数量是否 ≤ M。

技巧:把终点 L 放进数组最后一格(a[n+1] = L),让它也参与扫描, 这样「最后一段距离够不够」就自然被处理了。

样例输入
25 5 2
2
11
14
17
21
样例输出
4

完整代码

#include <iostream>
using namespace std;

int L, n, m;
int a[50005];                // a[0]=起点0, a[1..n]=中间石头, a[n+1]=终点L

// check(x):能不能通过搬走 <= m 块石头,让所有「相邻落脚点」的距离都 >= x
bool check(int x){
    int cnt = 0;             // 搬走的石头数
    int last = 0;            // 上一块留下来的石头(下标),0 号是起点
    for(int i = 1; i <= n + 1; i++){
        if(a[i] - a[last] < x){   // 从 last 跳到 i 太近了 -> 把 i 搬走
            cnt++;
        } else {
            last = i;             // 距离够远,这块踩上去
        }
    }
    return cnt <= m;         // 搬走的不超过 m 块,x 就可行
}

int main(){
    ios::sync_with_stdio(false);
    cin.tie(0);
    cin >> L >> n >> m;
    a[0] = 0;
    for(int i = 1; i <= n; i++) cin >> a[i];
    a[n + 1] = L;            // ★ 终点当成最后一块「石头」参与判断

    // 要求「最短跳跃距离的最大值」:x 越大越难做到 -> 单调
    int l = 1, r = L, ans = 0;
    while(l <= r){                      // 模板 A:ans 记录 + 区间跨越 mid
        int mid = l + (r - l) / 2;
        if(check(mid)){                 // mid 能做到,记下来,再贪心地试试更大
            ans = mid;
            l = mid + 1;
        } else {
            r = mid - 1;                // 做不到,只能往小找
        }
    }
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 25 5 2 / 2 11 14 17 21 → 输出 4 与标准答案完全一致

边界 25 5 0 / 2 11 14 17 21(M = 0,一块都不能搬)→ 输出 2 正确 (此时最短距离就是最小的相邻距离:起点到 2 只有 2)。

极限数据 L = 10⁹、N = 50000 块等间距 19999 的岩石、M = 25000 → 输出 39998(正好等于「隔一块搬一块」后的间距 2×19999,符合预期), 实测 51 ms。同一组数据令 M = 0 → 输出 19999,也正确。

与暴力对拍(枚举搬走哪几块石头的所有子集)随机 300 组,全部一致

易错点

  • 忘了把起点 0 和终点 L 加进去:只对输入的 N 个位置做判断,会漏掉「起点→第一块」和 「最后一块→终点」这两段距离,答案必然偏大。
  • 数组开成 a[50005] 但下标要写到 n+1:n 最大 50000,所以至少要 50002 个元素。 写 a[50005] 是安全的(我留了余量),但别写 a[50000]
  • 把 M 理解成「必须搬走恰好 M 块」:题面是「至多移走 M 块」, 少搬是可以的。所以判断条件是 cnt <= m
  • 如果把「太近就搬走当前这块」改成「搬走前一块」,贪心就不成立了,答案可能偏小。
P1163 银行贷款 普及/提高- · 实数二分

题意:贷款 w0 元,每月还 w 元,恰好 m 个月还清。 利率按月累计,求月利率(用百分数表示,四舍五入到 0.1%)。

⚠️ 数据范围 / 关键约束

1 ≤ w0, w ≤ 2³¹ − 1,1 ≤ m ≤ 3000;数据保证答案不超过 300.0%

三点提醒:
· 金额可能到 21 亿,要用 double / long long 级别的容器,别用 int 参与运算;
· m 最大 3000,如果利率较大,中间过程会溢出 double → 需要保护;
· 「答案不超过 300%」这句话直接告诉我们二分上界取 3 就行了。

思路

二分月利率 r,范围 [0, 3](0% ~ 300%)。

check(r):拿这个 r 模拟一遍还款,看 m 个月后还剩多少欠款

· rem < 0(还多了)→ 利率太低 → 抬高下界 lo = mid
· rem > 0(还不完)→ 利率太高 → 压低上界 hi = mid

循环固定 100 次而不是判断 hi - lo > eps,这样既精确又绝不会死循环。 最后 × 100%.1f 输出(自带四舍五入)。

样例输入
1000 100 12
样例输出
2.9

完整代码

#include <iostream>
using namespace std;

int main(){
    double w0, w;            // w0 = 贷款原值, w = 每月还款额
    int m;                   // m = 还款月数
    cin >> w0 >> w >> m;

    // 二分月利率 r:r 越大,还完 m 个月后剩下的欠款越多 -> 单调
    // 题目保证答案不超过 300%,所以利率(小数形式)一定在 [0, 3] 内
    double lo = 0.0, hi = 3.0;
    for(int it = 0; it < 100; it++){      // 固定迭代 100 次,比 while(hi-lo>eps) 更稳
        double mid = (lo + hi) / 2;
        double rem = w0;                  // rem = 当前还欠银行多少
        for(int i = 0; i < m; i++){
            rem = rem * (1.0 + mid) - w;  // 每月先算利息,再还掉 w
            if(rem > 1e18){ rem = 1e18; break; }    // 数值已经爆炸,符号已确定,直接收工
            if(rem < -1e18){ rem = -1e18; break; }
        }
        if(rem < 0) lo = mid;             // 最后还多了 -> 利率太低,要调高
        else hi = mid;                    // 最后还欠着 -> 利率太高,要调低
    }
    // 换成百分数,四舍五入到 0.1%
    printf("%.1f\n", (lo + hi) / 2 * 100);
    return 0;
}
✅ 实测结果

官方样例 1000 100 12 → 输出 2.9 与标准答案完全一致

程序内部实际收敛到的利率是 2.922854%,四舍五入到 1 位小数正好是 2.9。 我还用「拿 2.9% 正向模拟 12 个月」独立验证过:剩余欠款 −1.93 元 ≈ 0,确实还得刚刚好

另外测了 1000 200 6 → 输出 5.5(真值 5.471793%,正向模拟剩余 1.23 ≈ 0 ✅); 300 50 12 → 输出 12.7(真值 12.694680% ✅); 1000000 50000 60 → 输出 4.7(真值 4.678192% ✅)。

易错点

  • rem < 0rem > 0 该往哪边收搞反: 这就是我第一次写的 bug,答案会一路冲到上界(输出 300.0)。务必拿样例验一遍。
  • while(hi - lo > 1e-12) 而不是固定次数:eps 定得太小可能因浮点误差死循环; 固定 100 次既简单又稳妥。
  • 忘了 × 100 或忘了保留 1 位小数:题目要的是百分数形式,输出 0.0 而不是 2.9 就全错了。
  • 没做溢出保护:m = 3000 且利率偏大时 (1+r)^3000 会变成 inf, 虽然符号恰好还对,但加上 1e18 的提前跳出更干净、也更快。
  • 参数用 int 读进来再参与浮点运算:金额上限 2³¹−1,读进 double 最安全。
P3131 [USACO16JAN] Subsequences Summing to Sevens S 普及/提高- · 前缀和 + 同余

题意:N 头奶牛排成一排,每头有一个整数 ID。找一段连续的奶牛, 让 ID 之和是 7 的倍数,并且这段奶牛数量最多。不存在就输出 0。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 50000,每头奶牛的 ID 在 0 ~ 1000000 之间。

N = 50000 时暴力枚举所有区间是 12.5 亿次,太慢;但 ID 之和最大 50000 × 10⁶ = 5×10¹⁰, 超过 int。不过我们只关心「模 7 的余数」,所以边读边取模(余数永远在 0~6), 既不会溢出,也不需要开大数组。

思路

设前缀和 pre[i] = a[1] + ... + a[i]。区间 (l..r) 的和 = pre[r] - pre[l-1]

要是 7 的倍数,等价于 pre[r] ≡ pre[l-1] (mod 7) —— 只要两个前缀和余数相同,中间那一段就是 7 的倍数。

于是只开 7 个桶,记录每个余数第一次最后一次出现的下标, 答案就是 max(last[r] - first[r])

别忘了前缀和的起点 pre[0] = 0:所以循环开始前 first[0] = last[0] = 0

样例输入
7
3
5
1
6
2
14
10
样例输出
5

题面提示:5 + 1 + 6 + 2 + 14 = 28,是 7 的倍数,一共 5 头奶牛。

完整代码

#include <iostream>
#include <cstdio>
using namespace std;

int main(){
    int n;
    scanf("%d", &n);

    int first[7], last[7];               // 每个余数第一次/最后一次出现的前缀下标
    for(int r = 0; r < 7; r++){ first[r] = -1; last[r] = -1; }

    int pre = 0;                         // 前缀和 mod 7,即 sum[0] = 0
    first[0] = 0;  last[0] = 0;          // ★ 余数 0 在下标 0 就出现过,别忘了

    for(int i = 1; i <= n; i++){
        int x;
        scanf("%d", &x);
        pre = (pre + x % 7) % 7;         // 边读边取模,永远不会溢出
        if(first[pre] == -1) first[pre] = i;
        last[pre] = i;
    }

    // 若 sum[r] ≡ sum[l-1] (mod 7),则区间 (l..r) 的和是 7 的倍数
    // 长度 = r - (l-1),所以每个余数都取「最后一次出现 - 第一次出现」
    int ans = 0;
    for(int r = 0; r < 7; r++){
        if(first[r] != -1 && last[r] - first[r] > ans)
            ans = last[r] - first[r];
    }
    printf("%d\n", ans);
    return 0;
}
✅ 实测结果

官方样例 → 输出 5 与标准答案完全一致

边界 1 / 1(只有一个数且不是 7 的倍数)→ 输出 0 正确(题面要求无解输出 0)。

极限数据 N = 50000 随机 ID(0~10⁶)→ 输出 49995,实测 38 ms; 同一规模下让所有 ID 都是 7 的倍数 → 输出 50000 正确(整段都是 7 的倍数)。

与暴力 O(n²) 枚举所有区间随机对拍 300 组,全部一致

易错点

  • 忘了初始化 first[0] = last[0] = 0:这样「从第 1 头奶牛开始」的合法区间会被漏掉, 答案可能偏小。
  • 长度算成 last - first + 1:前缀和的下标差本身就是元素个数 (pre[6] − pre[1] 对应第 2~6 个数,共 5 个),多加 1 就错了。
  • 用整段前缀和去取模,而不是边读边取模:虽然用 long long 也不会溢出(5×10¹⁰), 但开一个 50005 的 long long 数组多花 400KB,而边读边取模只用 7 个变量, 是这道题最优雅的写法。
  • 无解时输出 0 还是 -1:题面明确说「如果不存在这样的组,则输出 0」,所以 ans 初值取 0 正好。
P1115 最大子段和 普及- · 线性 DP(Kadane)

题意:给一个长度为 n 的整数序列,选出一段连续且非空的子段,使这段的和最大,输出这个和。

⚠️ 数据范围 / 关键约束

40% 的数据 n ≤ 2×10³;100% 的数据 1 ≤ n ≤ 2×10⁵,−10⁴ ≤ ai ≤ 10⁴

· n = 2×10⁵ 意味着 O(n²) 会超时,必须 O(n);
· ai 可以是负数;最坏总和 2×10⁵ × 10⁴ = 2×10⁹,理论上刚好卡在 int 边缘, 用 long long 保险
· 洛谷在 2026/01/21 还给这题加过一组 hack 数据(针对「全负数输出 0」这种错解), 别踩这个坑

思路

f[i] = 「以第 i 个数结尾」的最大子段和。那么:

f[i] = max(a[i], f[i-1] + a[i]);       // 要么接上前面,要么从自己重新开始

因为 f[i] 只依赖 f[i-1],所以不用开数组, 一个变量 cur 边读边滚动就行,空间 O(1)。

答案就是所有 f[i] 里的最大值。初始化时 best 要取第一个数 (不能取 0,否则全负数会错)。

样例输入
7
2 -4 3 -1 2 -4 3
样例输出
4

题面解释:选子段 [3, 5],即 {3, −1, 2},和为 4。

完整代码

#include <iostream>
using namespace std;

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

    long long best = 0, cur = 0;   // best=全局最优, cur=以当前数结尾的最大子段和
    for(int i = 1; i <= n; i++){
        long long x;
        cin >> x;
        if(i == 1){
            best = cur = x;        // ★ 第一个数直接作为初始状态(不能初始化成 0)
            continue;
        }
        // f[i] = max(a[i], f[i-1] + a[i])
        // 前面的累加和如果是负的,还不如从 x 重新开始
        cur = (cur + x > x) ? (cur + x) : x;
        if(cur > best) best = cur;
    }
    cout << best << endl;
    return 0;
}
✅ 实测结果

官方样例 7 / 2 -4 3 -1 2 -4 3 → 输出 4 与标准答案完全一致

边界 3 / -2 -5 -1(全负数)→ 输出 -1 正确 (子段非空,答案是最大的那个负数,不是 0)。

极限数据 n = 200000 且全部为 10000 → 输出 2000000000(无溢出,实测 98 ms); 全部为 −10000 → 输出 -10000 正确

与暴力 O(n²) 枚举所有子段随机对拍 300 组,全部一致

易错点

  • best 初始化成 0:全负数时输出 0,但 0 根本不是一个合法子段的和。 这是本题被 hack 的头号错误。
  • 转移写成 cur = max(0, cur + x):这是「最大子段和允许为空」的版本, 全负数会输出 0,同样错。
  • 以为要开 dp 数组:其实只依赖前一项,一个变量滚动就够。开了数组也不算错,只是没必要。
  • 用 int 存累加和:2×10⁹ 已经贴着 int 上限了,多一个数就溢出。

6今日常见错误汇总

错误写法后果正确写法
P2678 忘了把起点 0 / 终点 L 放进数组 漏算首尾两段距离,答案偏大 a[0]=0; a[n+1]=L; 一起扫
P1163 的 lo = mid / hi = mid 写反 答案一路冲到上界,样例输出 300.0 rem<0 → lo=midrem>0 → hi=mid
实数二分用 while(hi-lo > 1e-12) eps 取值不当可能不收敛(死循环) 固定循环 100 次,稳且准
P1163 忘了 × 100 或忘了 %.1f 输出格式不符,直接判错 printf("%.1f\n", r * 100)
P3131 忘了 first[0] = last[0] = 0 漏掉「从第 1 头开始」的合法区间,答案偏小 前缀和下标 0 处余数为 0,要预先登记
P3131 长度算成 last - first + 1 答案偏大 1 前缀和下标差就是元素个数
P1115 把 best 初始化成 0 全负数时输出 0,被 hack 数据卡掉 best = cur = a[1]-inf
累加类变量用 int P1115、P3131 的相关中间量会溢出 统一 long long,或边读边取模
贪心题把「至多 M 块」当成「恰好 M 块」 P2678 答案错误 判断写成 cnt <= m

自测:你真的会了吗

第 1 题:P2678 里,为什么把终点当成「可以搬走的石头」是对的?

答:因为我们的贪心是「能留就留」,留下的石头数量已经最多。 如果扫到终点时发现它离最后留下的那块石头太近,说明连最后留下的那块都得牺牲掉

真把那一块搬走之后,「上一块保留的石头 → 终点」的距离只会更远,不会引出新问题。 所以代码里「给终点记一次 cnt++」和「真实地多搬走一块」数量上完全等价。

第 2 题:实数二分为什么要循环 100 次?用 eps 判断不行吗?

答:while(hi - lo > eps) 也不算错,但要自己定 eps: 定大了精度不够,定太小(比如 1e-15)时,由于 double 的有效位数只有约 15~16 位十进制, hi - lo 可能永远大于 eps,导致死循环

固定 100 次的好处:区间长度变成 1/2¹⁰⁰ ≈ 7.9×10⁻³¹, 远小于 double 的精度,所以答案已经精确到极限了;同时循环次数完全确定,绝不会死循环,也方便估算耗时。

第 3 题:P3131 里,为什么「余数相同」就意味着「区间和是 7 的倍数」?

答:设区间是 (l..r),它的和 = pre[r] - pre[l-1]

如果 pre[r] mod 7 == pre[l-1] mod 7,那么它们的差 pre[r] - pre[l-1] 一定是 7 的倍数(同余的两个数相减,余数抵消为 0)。

反过来,如果区间和是 7 的倍数,两个前缀和的余数也必然相等。 所以这是充要条件 —— 我们可以在 7 个桶里放心地找「最远的同余点对」。

第 4 题:P1115 的 cur = max(x, cur + x) 里的 x 代表什么?

答:代表「从当前位置重新开一段」—— 也就是把前面所有累加全部丢掉,只留当前这个数。

什么时候该这么干?当 cur(前面那些数的和)是负数时。 把负数接在前面只会让结果更小。负的累赘,果断不要。

这也解释了为什么全负数时答案不是 0:因为「重新开始」拿到的仍然是那个负数本身, 而不是空集 —— 题目要求子段非空

第 5 题(手写代码):把二维矩阵压成一维求最大子矩形,核心是哪两层循环?
for(int top = 1; top <= n; top++){
    memset(col, 0, sizeof(col));              // 清空列和
    for(int bottom = top; bottom <= n; bottom++){
        for(int j = 1; j <= n; j++) col[j] += a[bottom][j];  // 累加列和
        // 在这里对 col[] 跑一次 Kadane
    }
}

★ 要点:外层枚举上下边界,内层把「这两行之间每一列的和」当成一个一维数组, 对它求最大子段和 —— 找到的那一段就决定了矩形的左右边界。

复杂度 O(n³):外层两层 n²,里面 Kadane 一层 n。n ≤ 120 时完全跑得动。

速查卡(截图存手机)

【二分 + 贪心(P2678 型:最大化最小值)】
  把起点 0 和终点 L 一起放进数组
  check(x):
      cnt = 0; last = 0(起点下标);
      for i = 1 .. n+1:
          if(a[i] - a[last] < x) cnt++;     // 太近,搬走当前
          else last = i;                    // 够远,踩上去
      return cnt <= M;
  用昨天模板 A:可行 → ans = mid, l = mid + 1

【实数二分(P1163 型)】
  double lo = 下界, hi = 上界;      // 有解区间,别乱开
  for(int it = 0; it < 100; it++){  // ★ 固定次数,不死循环
      double mid = (lo + hi) / 2;
      if(check(mid)) hi = mid;      // 或 lo = mid,看单调方向
      else           lo = mid;
  }
  printf("%.1f\n", (lo + hi) / 2 * 100);   // %.1f 自带四舍五入
  // 中间量可能爆 double 时,加 if(rem > 1e18){ rem = 1e18; break; }

【前缀和 + 同余(P3131 型)】
  // 区间和能被 k 整除  ⟺  两个前缀和 mod k 相等
  int first[7], last[7];            // 全初始化为 -1
  int pre = 0;
  first[0] = last[0] = 0;           // ★ pre[0] = 0 要先登记
  for(i = 1..n){
      cin >> x;
      pre = (pre + x % 7) % 7;      // 边读边取模,不溢出
      if(first[pre] == -1) first[pre] = i;
      last[pre] = i;
  }
  ans = max(last[r] - first[r]);    // 长度就是下标差,别 +1

【最大子段和 Kadane(P1115)】
  long long cur = a[1], best = a[1];      // ★ 不能用 0 初始化!
  for(i = 2..n){
      cur = max(a[i], cur + a[i]);        // f[i] = max(a[i], f[i-1]+a[i])
      best = max(best, cur);
  }
  // 子段必须非空 → 全负数时输出最大的那个负数

【二维压一维(最大加权矩形)】
  for top = 1..n:
      col[] 清零
      for bottom = top..n:
          col[j] += a[bottom][j]   // 累加列和
          对 col[] 跑一次 Kadane
  // 复杂度 O(n^3)

明天预告 · Day 25

动态规划入门:线性 DP

今天你已经偷偷用过一次 DP 了 —— 最大子段和的 cur = max(a[i], cur + a[i]) 就是一个正儿八经的状态转移方程。 明天我们要把这个「凭感觉写出来」的东西,变成有章法可循的套路。

明天会见到的四道题:

  • P1216 数字三角形:DP 的「Hello World」,从金字塔顶走到底,路上数字和最大。
  • P1002 过河卒:棋盘上的路径计数,顺便复习二维数组。
  • P1091 合唱队形:正着求一遍最长上升子序列,倒着再求一遍。
  • P1990 覆盖墙壁:用 2×1 骨牌和 L 型砖铺满 2×N 的墙,按「第一块砖怎么放」分类讨论递推。

每道题都按四要素来分析:① 状态定义 ② 转移方程 ③ 初始化 ④ 答案在哪。 今天先把这个口诀记住,明天逐题套。

另外,明天开始务必养成习惯:写完 DP 先手推样例表格, 就像今天最大子段和那张表一样 —— 表算对了,代码基本不会错。

ACM 一个月学习计划 · DAY 25

Day 25 · 线性 DP:四步法与三大模型

第一次正式学 DP —— 先把「状态定义 → 转移方程 → 边界 → 答案」这四步练成肌肉记忆,再上手四道题

今日目标:理解 DP 四要素,能独立推导数字三角形、过河卒、覆盖墙壁、合唱队形
预计用时:约 4 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1216 / P1002 / P1990 / P1091
DAY 25 / 30

今日时间安排

0 - 30 min搞懂 DP 四步法(第 1 节)—— 今天最重要的地基
30 - 60 min线性 DP 的三大模型(第 2 节)+ 手推数字三角形(第 3 节)
60 - 95 min路径计数 DP(第 4 节)与 LIS / 合唱队形(第 5 节)
95 - 120 min分类讨论递推:拿 P1990 覆盖墙壁当例子(第 6 节)
120 - 240 min做 4 道题,每题限时 30 分钟
240 - 270 min对照本文题解把没做出来的补掉 + 做自测题
💡 和前几天内容的衔接

其实你早就写过 DP 了,只是没这么叫它:

· Day 16 的 P1028 数的计算、Day 17 的 P1044 栈,本质都是「把答案存在数组里、用更小的答案推出更大的答案」;
· Day 17 的 记忆化搜索(P1464)就是「递归 + 把算过的结果存起来」,它和 DP 是同一件事的两种写法;
· Day 24 的 P1115 最大子段和 更是标准的线性 DP,只是当时我们用「递推」的说法糊过去了。

今天要做的是:把这套东西命名、公式化,变成一套每道题都能照着走的流程。

1动态规划到底是什么(四步法)

先看一个你一定会的问题

有一段楼梯,共 n 级台阶。你每次可以跨 1 级或 2 级,问走到第 n 级有多少种走法?

小学生都会的推法:想到达第 n 级,最后一步只有两种可能——

· 最后一步跨了 1 级,那之前站在第 n-1 级;
· 最后一步跨了 2 级,那之前站在第 n-2 级。

所以 走法数(n) = 走法数(n-1) + 走法数(n-2)。这就是斐波那契。而你刚才做的事,就是动态规划。

🎯 一句话说清 DP

把一个大问题拆成一堆「小一号的同类问题」,先把小问题的答案算好存起来,再用它们拼出大问题的答案。

「存起来」这一步是关键 —— 它保证每个小问题只算一次,这就是 DP 比暴力搜索快的原因。

关键:DP 和递归的区别在哪

做法怎么算 fib(5)复杂度
朴素递归fib(5) 要 fib(4) 和 fib(3),fib(4) 又要 fib(3) 和 fib(2)……fib(3) 被重复算了两次,越往下重复越多O(2ⁿ) 爆炸
记忆化搜索递归写,但算完 f[x] 就存进数组,下次直接取O(n)
动态规划干脆不递归:从 f[1]f[2] 开始,一行一行往前推,推的时候用到的都已经算好了O(n)

记忆化和 DP 是同一件事:记忆化是「自顶向下 + 查表」,DP 是「自底向上 + 填表」。本文只讲后者,因为它写起来更短、更快,也不会有递归爆栈的风险。

★ DP 四步法(每道 DP 题都按这个顺序想)

下面这张表是今天的核心。以后拿到任何一道 DP 题,先在草稿纸上把这四个空填出来,代码自然就出来了。

步骤要回答的问题爬楼梯这一题填什么

状态定义
dp[...] 表示什么?
下标是「位置」「容量」还是「已经考虑了几个」?
dp[i] = 走到第 i 级台阶的走法数

转移方程
这个状态能从哪些更小的状态来?把它们怎么组合起来? dp[i] = dp[i-1] + dp[i-2]

边界(初始化)
最小号的状态是多少?手算出来直接写死。 dp[0] = 1(站着不动算 1 种)
dp[1] = 1

答案
最后要输出哪个状态?注意不一定是 dp[n]! dp[n]
// 爬楼梯:四步法直接翻译成代码
int dp[1005];
dp[0] = 1; dp[1] = 1;              // ③ 边界:手算出来写死
for(int i = 2; i <= n; i++)
    dp[i] = dp[i-1] + dp[i-2];      // ② 转移方程
cout << dp[n] << endl;            // ④ 答案
⚠️ ④ 号步骤最容易翻车

很多新手写完转移方程就 cout << dp[n],但答案不一定是 dp[n]。反例就是 Day 24 的 P1115 最大子段和:答案要在所有 dp[i] 里取 max
每次写完代码都要回头问自己一句:「题目问的那个东西,对应的是哪个状态?」

DP 能用的两个前提

前提说人话
最优子结构大问题的最优解,一定是由「某个小问题的最优解」接上一步得到的。
所以你可以放心地只用小问题的最优值,不用管它内部长什么样。
无后效性状态算出来之后,它不受「我是怎么走到这里的」影响
数字三角形里,只要你走到了格子 (i,j),后面能拿多少只跟 (i,j) 有关,跟前面走的路线无关。
💡 判断「状态定义合不合格」的土办法

问自己:「只给我这个状态的值,我能不能决定下一步怎么走?」
能 → 这个状态定义是无后效的,可以用;不能 → 说明状态里缺信息(比如缺了「上一个是谁」),要么加维度,要么换定义。

2线性 DP 的三大主力模型

「线性 DP」指的是状态沿着一条线(数组下标、格子坐标)往前推的 DP。今天四道题全是这个套路,而且主要是三种形态。

模型 A:以 i 结尾 —— 一维状态

状态长这样:dp[i] = 「必须选第 i 个元素」时能拿到的最大/最优值

转移就是:枚举上一个选了谁,从 dp[j](j < i)接过来。

// 模板:以 i 结尾的线性 DP
for(int i = 1; i <= n; i++){
    dp[i] = 只选 a[i] 时的值;              // ★ 先假设"从 i 重新开始"
    for(int j = 1; j < i; j++)
        if(可以接在 j 后面)
            dp[i] = max(dp[i], dp[j] + 代价);   // 从 j 接过来
}

这个模板最典型的两个例子,一个是已学过的最大子段和(dp[i] = max(a[i], dp[i-1] + a[i])),一个是今天的 P1091 合唱队形

🎯 「以 i 结尾」是线性 DP 最常见的状态定义

为什么要「强制以 i 结尾」?因为如果不强制,状态就变成「前 i 个里随便挑」——那你没法知道最后一个是谁,也就没法判断新的元素能不能接上(无后效性被破坏了)。
强制结尾 = 把「上一个是谁」这个信息塞进状态里。这是 DP 里最常用的一个技巧。

模型 B:多阶段推进 —— 二维状态

状态长这样:dp[i][j] = 「处理到第 i 阶段、处在第 j 个位置」时的答案。转移只看上一阶段的邻居。

// 模板:多阶段推进(数字三角形、过河卒都是这个形状)
for(int i = 2; i <= n; i++)
    for(int j = 1; j <= m; j++)
        dp[i][j] = 上一行相邻位置的组合;    // 例如 max(...) 或加法;
模型状态含义转移来源今天的题
A · 以 i 结尾dp[i]:必须选 i所有更小的 dp[j](j < i)P1091 合唱队形
B · 多阶段推进dp[i][j]:走到第 i 行第 j 列上一行的邻居P1216 数字三角形
P1002 过河卒
C · 分类讨论递推f[n]:长度为 n 的整块问题的方案数按「第一步怎么开始」分成几类,各类对应更短的前缀P1990 覆盖墙壁

第 3 行的 C 和前两行不太一样:它不枚举「上一个位置」,而是枚举「第一步有哪几种不同的走法」—— 竖着放、横着放、还是用 L 形起头。每种走法都会把问题缩小成一个更短的同类问题,把各类的方案数加起来就是答案。 「分类讨论」是 DP 里最常用的一招,本文第 6 节会用 P1990 把它讲透。

3数字三角形:为什么自底向上更省事

把四步法填一遍

步骤填什么
① 状态定义dp[i][j] = 从第 i 行第 j 列这个格子出发、一路走到最底部能拿到的最大数字和
② 转移方程从 (i,j) 下一步只能去左下的 (i+1,j) 或右下的 (i+1,j+1),取大的那个:
dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]
③ 边界最后一行没有下一步了:dp[r][j] = a[r][j]
④ 答案起点就是塔尖:dp[1][1]

自顶向下 vs 自底向上,到底差在哪

同一道题,两种方向都能做,但工作量差很多:

自顶向下(从塔尖往下)自底向上(从底部往上)
状态含义dp[i][j] = 从塔尖走到 (i,j) 的最大和dp[i][j] = 从 (i,j) 走到塔底的最大和
转移dp[i][j] = max(dp[i-1][j-1], dp[i-1][j]) + a[i][j]dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]
麻烦点左边界和右边界要特判:第 1 列上面没有格子、每行最后一列上面也没有格子,不特判就会读到下标 0 或越界不用特判:每个格子下面都正好有 2 个格子(最后一行除外,而它本来就是边界)
答案max(dp[r][1..r]),还要扫一遍最后一行dp[1][1],直接输出
✅ 结论:这题无脑选自底向上

自顶向下要处理两条斜边的边界,自底向上一个边界都不用管,而且答案天然就在 dp[1][1]
以后遇到「塔形 / 三角 / 逐层推进」的题,先想想能不能倒着推

拿官方样例手推一遍(这一步千万别跳)

输入是:

5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5

自底向上填表,最后一行原样抄下来,然后从下往上逐格填:

填的过程填完的结果
第 5 行
(边界)
没有下一步,原样写4 5 2 6 5
第 4 行2+max(4,5)=7 7+max(5,2)=12 4+max(2,6)=10 4+max(6,5)=107 12 10 10
第 3 行8+max(7,12)=20 1+max(12,10)=13 0+max(10,10)=1020 13 10
第 2 行3+max(20,13)=23 8+max(13,10)=2123 21
第 1 行7+max(23,21)=3030

答案 30,和官方样例输出一致。这条路径就是 7 → 3 → 8 → 7 → 5,也正是题面里说的那条。

💡 为什么可以先手推再写代码

DP 题最怕的是「式子写对了但边界错」,手推一遍能让边界错误当场暴露(比如上面第 5 行如果忘了原样写,后面全错)。
DP 题写完务必手算一遍样例,逐格对照——这是学习计划原文里的原话,也是今天最重要的一条纪律。

⚠️ 数据范围与数组大小

r ≤ 1000,每个数在 [0,100] 之间。
最大和 = 1000 × 100 = 100000,稳稳在 int 里,不用 long long。
数组要开到 [1005][1005]:下标从 1 开始用,所以至少 1001;同时转移时要访问第 i+1 行,多留几格余量最保险。全局数组会自带清零,别写成局部数组。

4路径计数 DP:过河卒的马与 long long

没有马的时候,这题简单得离谱

卒只能往下或往右走。那么走到格子 (i,j) 的最后一步只有两种:

· 从上面 (i-1,j) 下来;
· 从左边 (i,j-1) 过来。

所以「到 (i,j) 的路径条数」= 「到 (i-1,j) 的条数」+ 「到 (i,j-1) 的条数」,又是一次加法型 DP。

步骤填什么
① 状态定义dp[i][j] = 从起点 (0,0) 走到 (i,j) 的路径条数
② 转移方程dp[i][j] = dp[i-1][j] + dp[i][j-1](加法!因为是数方案不是求最大)
③ 边界dp[0][0] = 1(起点自己算 1 条路)
④ 答案dp[n][m]

马的 9 个控制点

马的位置 (hx,hy) 本身、加上它一步能跳到的 8 个点,一共 9 个点都是控制点,卒不能进入。

处理办法很暴力也很好用:把这 9 个格子的 dp 值直接锁死为 0。因为条数是 0,后面谁从它转移过来也自动是 0,等于「此路不通」。

// 马自己 + 8 个跳跃点,一共 9 个控制点
int dx[9] = { 0, -2, -1,  1,  2,  2,  1, -1, -2};
int dy[9] = { 0,  1,  2,  2,  1, -1, -2, -2, -1};
for(int k = 0; k < 9; k++){
    int x = hx + dx[k], y = hy + dy[k];
    if(x >= 0 && y >= 0) blocked[x][y] = true;   // 在棋盘内才标记
}
🚫 经典坑 1:只标了 8 个跳跃点,忘了马本身

上面两个数组的第一个元素是 (0,0),对应的就是马自己所在的位置。很多人写 8 个偏移量写下习惯,顺手就漏掉了马自己——这样马脚下的格子还能走,答案直接错。
数一下:马的控制点一共 9 个,不是 8 个。

🚫 经典坑 2:坐标从 0 开始,dp[i-1][j] 会用到下标 -1

题目的坐标是 (0,0)(n,m),从 0 开始。但转移时要读 dp[i-1][j],当 i = 0 时就是 dp[-1][j] —— 越界,轻则 WA 重则 RE(还可能读到别的变量,出玄学错误)。

标准解法:把所有坐标整体平移 +2,起点变成 (2,2),那么 dp[1][*]dp[*][1] 这些"棋盘外面"的格子天然是 0,正好符合"从外面走不进来"的物理意义,一行特判都不用写。

🚫 经典坑 3:用 int 存答案

这题的路径条数是组合数级别的大。20×20 的棋盘,从 (0,0) 走到 (20,20) 一共要走 40 步,其中 20 步向下、20 步向右,条数就是 C(40,20) = 137846528820约 1.38×10¹¹

int 的上限只有 2147483647(约 2.1×10⁹)——差了将近 64 倍,用 int 必爆。必须用 long long

实测:n=m=20、马在 (0,1) 时答案 8597496600,也已经超过 int 上限了。

💡 什么时候该警惕「答案超过 int」

看到下面任何一种情况,先把 long long 写上再说:

· 题目在数方案数 / 路径条数 / 子集个数(组合数增长极快);
· 题目里出现阶乘、乘方(比如 Day 16 的 n! 那类);
· 数据范围里的 n 有二三十,而答案是计数型(C(40,20) 这个量级);
· 题目没给答案范围,你又算不出上界 —— 那就用 long long,代价只是多几 MB 内存。

5LIS:合唱队形 = 正反两遍 LIS

什么是最长上升子序列(LIS)

从一个序列里挑出若干个元素,不改变它们在原序列里的先后顺序,让它们严格递增,问最多能挑几个。

比如 3 7 1 4 9,可以挑 3 7 9(3 个),也可以挑 1 4 9(3 个),最长就是 3。

🎯 O(n²) 的 LIS:正是「以 i 结尾」模型

up[i] = 以第 i 个数结尾的最长上升子序列长度。
想让它以 i 结尾,那前一个数可以是任意一个 j < ia[j] < a[i]
up[i] = max(up[i], up[j] + 1)

这跟第 2 节的「模型 A」一模一样。O(n²) 在 n ≤ 100 时完全够用,今天不学 O(n log n) 的贪心+二分写法。

合唱队形长什么样

合唱队形的要求是:存在一个「最高点」i,使得最高点左边严格递增、右边严格递减。整队人从左边走到最高点是一直变高的,过了最高点就一直变矮。

那么对于固定的最高点 i

· 左边最多留 up[i] 个人(以 i 结尾的最长上升子序列);
· 右边最多留 down[i] 个人(以 i 开头的最长下降子序列);
· 但 i 自己两边都数了一遍,被重复计算了,要减 1

🎯 合唱队形这一题的全部内容就是这一个式子

留下的最多人数 = maxi=1..n ( up[i] + down[i] − 1 )
需要出列的人数 = n − 留下的最多人数

题目问「最少需要几位同学出列」,也就是「让留下的人尽量多」,所以是 n - best,不是 best。这一步别看反了。

怎么求 down[i]

down[i] 的定义是「以 i 开头、往右走的最长下降子序列」。求法有两种,效果一样:

写法怎么想
倒着扫一遍(本文代码用这个)从 n 往 1 扫,找 j > ia[j] < a[i]down[i] = max(down[i], down[j] + 1)
把序列反转,求 LIS反转之后原来的"下降"就变成"上升"了,直接套 LIS 模板,再按原下标对应回去

两种写法本质相同。倒着扫一遍少一次数组反转,本文用这种。

⚠️ 「严格」两个字是本题的关键

题目要求身高满足 严格递增、严格递减(不能有相等的人站在一起变高或变矮)。
所以判断条件写 a[j] < a[i]不能写成 a[j] <= a[i]

实测边界:两个人身高都是 130 时,答案必须是 1(得请出去一个),因为两个一样高的人没法构成严格递增或严格递减。

复杂度够不够

n ≤ 100,两遍 O(n²) 各约 100×100/2 = 5000 次比较,枚举最高点再 100 次 —— 总共一万多次操作,随便怎么写都不会超时

6分类讨论递推:从「覆盖墙壁」学会枚举第一步

先把题意翻译成人话

有一面 2 行 N 列 的墙(题面写作「长为 N 宽为 2」)。手里有两种砖:

形状怎么用
2×1 骨牌竖放占一列,旋转 90° 就占同一行的相邻两列竖放时一次铺满一列;横放时一次铺满一行的两格
L 型砖占 2×2 方块里的 3 格(挖掉一个角)可以旋转,一共 4 种朝向
// L 型砖的四种朝向(■ 表示被覆盖)
■■    ■·    ·■    ■■
■·    ■■    ■■    ·■

两种砖都可以无限提供。问把整面墙不重不漏地铺满,一共有多少种铺法(只输出末 4 位)。

四步法填一遍

步骤填什么
① 状态定义f[n] = 把 2×n 的墙完整铺满的方案数
② 转移方程f[n] = f[n−1] + f[n−2] + 2·S[n−3]
其中 S[m] = f[0] + f[1] + … + f[m](前缀和)
③ 边界f[0] = 1(空墙只有「什么都不铺」1 种)、f[1] = 1f[2] = 2
④ 答案f[N] % 10000(题目只要末 4 位)

★ 转移方程是怎么来的:看「左上角那一格」被谁盖住

这是本题的全部难点,也是分类讨论型 DP 的标准推导方式:盯着整面墙最左上角的那一格,枚举「盖住它的砖」有哪几种可能。

情况左上角被谁盖住接下来会发生什么这一类有多少种
A 一块竖放的骨牌 第 1 列两格一起被铺满,右边剩下一面干干净净的 2×(n−1) 墙 f[n−1]
B 一块横放的骨牌 左上角被横砖占掉之后,左下角那一格就没得选了:竖砖放不下(上面那格已被占)、L 砖也放不下(要覆盖的三格凑不齐),只能再横放一块。于是前 2 列一起铺满,右边剩 2×(n−2) f[n−2]
C 一块 L 型砖 L 有 2 种能盖住左上角的朝向(互为镜像)。盖完之后第 1 列满了,但第 2 列会留下一个缺口 —— 下面详细说 2·S[n−3]
🎯 情况 C 里那个「缺口」怎么办:它只能一列一列往右推

L 起头之后,第 2 列会剩下一格空着(缺口)。这一格能怎么盖?

· 往右推:用一块横放的骨牌盖住它,于是缺口被推到第 3 列(而且上下翻转了),第 2 列随之铺满;
· 就地收尾:用另一块 L 盖住这个缺口,同时把下一列的两格一起补满,于是从这里往右又是一面干净的墙。

关键在于:每一步都只有一种走法 —— 缺口在第几列、哪一行是确定的,能盖住它的砖也就唯一确定了。所以整段「阶梯」的形状是被完全决定的,唯一的选择只剩它在哪里收尾

于是:

· 阶梯可以在第 j 列收尾(j ≥ 3),对每一个收尾位置 j,都恰好有 2 种铺法(对应 L 的两种镜像朝向);
· 收尾之后,右边剩下 2×(n−j) 的干净墙,有 f[n−j] 种铺法。

把 j 从 3 到 n 全加起来:2·f[n−3] + 2·f[n−4] + … + 2·f[0] = 2·(f[0]+f[1]+…+f[n−3]) = 2·S[n−3]

✅ 三种情况合起来就是转移方程

f[n] = f[n−1] + f[n−2] + 2·S[n−3]

而这三类之所以能直接相加,是因为它们互不重叠、又盖住了全部铺法:任何一个铺法里,左上角那一格总得被某一块砖盖住,而能盖住它的砖只有竖骨牌、横骨牌、L 型这三种。

写完分类讨论的转移方程,一定要回头检查这一句「不重不漏」。这是分类讨论题最容易翻车的地方。

为什么要用前缀和 S

如果老老实实每次现算那个和,算 f[n] 就要往回扫 n 项,总复杂度 O(N²)。N 最大 10⁶ 时是 10¹² 次操作 —— 必超时

但那个和是连续前缀(f[0] 一直加到 f[n−3]),所以可以开个数组 S[m] = f[0]+…+f[m] 把它记下来:

S[0] = f[0];
for(int i = 1; i <= N; i++)
    S[i] = (S[i - 1] + f[i]) % MOD;      // 前缀和递推,O(1) 一项

这样每一项转移都是 O(1),总复杂度 O(N)「看到连续区间的求和,就用前缀和」——Day 15 学过的套路,今天在 DP 里又见面了。

⚠️ S 也要取模,别忘了

f 每次都被 % 10000 压住,但 S累加量:如果不取模,它会一路涨到 10⁶ × 10⁴ = 10¹⁰ 的量级 —— 用 int 会溢出成负数,用 long long 也是白涨。
凡是累加型的数组(前缀和、方案数),累加那一刻就要取模。

手推一遍前几项,和题面对一对

n计算过程f[n]S[n]
0边界:空墙,什么都不铺11
1边界:只能竖放一块12
2边界:两块竖放 或 两块横放24
3f[2] + f[1] + 2×S[0] = 2 + 1 + 2×159
4f[3] + f[2] + 2×S[1] = 5 + 2 + 2×21120
5f[4] + f[3] + 2×S[2] = 11 + 5 + 2×42444
6f[5] + f[4] + 2×S[3] = 24 + 11 + 2×95397

n = 3 得到 5,正好对上题面里那句「一个 2×3 的墙可以有 5 种覆盖方法」;继续往下推到 n = 13 会得到 13465,取末 4 位就是官方样例的 3465

💡 「看最左边」还是「看最右边」?

计划原文的提醒是「分情况讨论最后一块砖怎么放(竖放 / 两块横放 / L 形)」——按最右边分类和按最左边分类是一回事,因为整面墙左右对称,得到的方程完全一样。

本文选「看左上角」,只是因为这样剩下的部分永远是一个规规矩矩的前缀,可以直接写成 f[更小的下标],不用再操心「右边那块 L 是不是缺了个角」。两种写法你都该会,考场上挑顺手的用。

7四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过(0 warning), 并且跑过官方样例和额外的边界数据,输出是你看到的真实结果。

P1216 [IOI 1994 / USACO1.5] 数字三角形 Number Triangles 普及- · DP 入门模板

题意:给一个数字金字塔,从塔尖出发往下走,每一步可以到左下方或右下方的格子,一直走到最底部(在哪一格停下都行)。问走过的数字之和最大是多少。

⚠️ 数据范围 / 关键约束

行数 1 ≤ r ≤ 1000,金字塔里的数字都在 [0, 100] 范围内。

所以最大和 = 1000 × 100 = 100000,int 完全够用。
但要读入的数字个数最多有 1+2+…+1000 = 500500 个,建议关掉 cin/cout 与 stdio 的同步以加快读入。

思路

把「从塔尖走到 (i,j) 的最大和」翻过来想,改成「从 (i,j) 走到最底部的最大和」,然后自底向上递推:

dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]

最后一行没有下一步,直接等于自己的数字(边界);答案就是塔尖 dp[1][1]

为什么不用自顶向下?因为自顶向下时每一行的左右两条斜边都只有一个"父亲",得特判;自底向上时每个格子下面都正好有两个格子,一个特判都不用写(详见本文第 3 节的对比表)。

样例输入
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
样例输出
30

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

// r <= 1000,全局数组自动清零
int dp[1005][1005];

int main(){
    ios::sync_with_stdio(false);   // 最多要读 50 万个数,关掉同步更快
    cin.tie(NULL);

    int r;
    cin >> r;

    // 直接读进 dp 里,省一个数组
    for(int i = 1; i <= r; i++)
        for(int j = 1; j <= i; j++)
            cin >> dp[i][j];

    // ★ 自底向上:先把最后一行当成「从该点走到最底部的最大和」
    for(int i = r - 1; i >= 1; i--)
        for(int j = 1; j <= i; j++)
            dp[i][j] += max(dp[i + 1][j], dp[i + 1][j + 1]);

    cout << dp[1][1] << endl;      // ④ 答案就是塔尖
    return 0;
}
✅ 实测结果

官方样例 5 / 7 / 3 8 / 8 1 0 / 2 7 4 4 / 4 5 2 6 5 → 输出 30 完全一致

最大数据 r = 1000,每一格都填 100 → 输出 100000,正确(100 × 1000 级台阶)
耗时约 166 ms(含进程启动与读入 50 万个数)

编译:g++ -std=c++14 -O2 -Wall0 warning

易错点

  • 数组开小dp[1005][1005] 是安全值。下标从 1 开始用,而且转移要读 i+1 行,别只开 1001。
  • 写成局部数组:局部数组不初始化是随机值。要么开全局,要么写 = {0}
  • 手推一遍样例再交:这题的错误九成出在边界上,手推能当场发现。
  • 数字虽然都是非负的,但这题不涉及"和可能为负"的问题;等到做 P1115 最大子段和时才要注意「全为负」的情况。
P1002 [NOIP 2002 普及组] 过河卒 普及- · 路径计数 DP

题意:棋盘上卒在 (0,0),要走到 (n,m),只能向下或向右走一步。棋盘上有一匹不动的马,马自己所在的点以及它一步能跳到的 8 个点都是禁区,卒不能经过。问从起点到终点有多少条合法路径。

⚠️ 数据范围 / 关键约束

1 ≤ n, m ≤ 20,马的坐标也在 0 ~ 20 之间。保证起点不是马的控制点。

棋盘很小,但答案很大:无障碍时条数是 C(40,20) = 137846528820 ≈ 1.38×10¹¹,远超 int 上限 2147483647。
必须用 long long,dp 数组也必须是 long long。

思路

四步法:

dp[i][j] = 从起点走到 (i,j) 的路径条数;
dp[i][j] = dp[i-1][j] + dp[i][j-1](因为是数方案,所以用加法);
③ 起点 dp[起点] = 1,马的控制点全部置 0;
④ 答案 dp[n][m]

两个实现要点:

(1) 坐标整体平移 +2。所有坐标加上 2(起点变成 (2,2)),这样 dp[i-1][j] 在 i=2 时读到的是 dp[1][j] = 0,天然表示"棋盘外面走不进来",不用写任何边界特判。
(2) 9 个控制点。马自己的位置也算一个,漏了就错。

样例输入
6 6 3 3
样例输出
6

完整代码

#include <iostream>
using namespace std;

// ★ 路径条数会超过 int(20x20 棋盘最大约 1.4e11),必须 long long
long long dp[30][30];
bool blocked[30][30];

int main(){
    int n, m, hx, hy;
    cin >> n >> m >> hx >> hy;

    // 整体平移 2 格,这样 dp[i-1][j]、dp[i][j-1] 不会用到负下标
    n += 2; m += 2; hx += 2; hy += 2;

    // 马自己 + 8 个跳跃点,一共 9 个控制点(第一个 (0,0) 就是马自己!)
    int dx[9] = { 0, -2, -1,  1,  2,  2,  1, -1, -2};
    int dy[9] = { 0,  1,  2,  2,  1, -1, -2, -2, -1};
    for(int k = 0; k < 9; k++){
        int x = hx + dx[k], y = hy + dy[k];
        if(x >= 0 && y >= 0) blocked[x][y] = true;
    }

    dp[2][2] = 1;                       // 起点(平移后是 (2,2))
    for(int i = 2; i <= n; i++){
        for(int j = 2; j <= m; j++){
            if(blocked[i][j]){ dp[i][j] = 0; continue; }   // 控制点走不到
            if(i == 2 && j == 2) continue;                 // 起点不参与转移
            dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }

    cout << dp[n][m] << endl;
    return 0;
}
✅ 实测结果

官方样例 6 6 3 3 → 输出 6 完全一致

最大数据 20 20 0 1(马在 (0,1))→ 输出 8597496600
这个数已经超过 int 上限 2147483647 三倍多 —— 用 int 存必爆,这就是本题必须 long long 的实证。

边界 1 1 0 1(马挡住 (0,1))→ 输出 1 正确
从 (0,0) 到 (1,1) 本来有两条路,走上面那条会被马挡住,只能先向下再向右。

对拍验证:n、m 取 1~5,马的坐标穷举 0~5,共 825 组,本解法与暴力 DFS 枚举所有路径的结果全部一致

编译:g++ -std=c++14 -O2 -Wall0 warning

易错点

  • 用 int 存 dp 和答案 —— 本题第一大坑。答案上界约 1.38×10¹¹,必须 long long。
  • 控制点只标 8 个 —— 马自己也是禁区,一共 9 个。写 9 个元素的偏移量数组最不容易漏。
  • 忘了平移坐标 —— 直接从 (0,0) 开始递推,会读到 dp[-1][j],越界或玄学错误。
  • 起点 dp[2][2] = 1 之后,循环里要 continue 跳过它,否则会被 dp[1][2] + dp[2][1] = 0 覆盖成 0。
  • 标记控制点时判断 x >= 0 && y >= 0,防止写到数组外面去。
P1990 覆盖墙壁 普及/提高- · 线性 DP / 分类讨论递推

题意:有一面 2 行 N 列的墙,手里有 2×1 的骨牌和 L 型(2×2 挖掉一角的 3 格)砖,两种砖都能随便旋转、无限量供应。问把整面墙不重不漏铺满一共有多少种铺法;因为答案很大,只输出末 4 位(不足 4 位就直接输出,不加前导 0)。

⚠️ 数据范围 / 关键约束

1 ≤ N ≤ 10⁶。这个范围是本题最重要的信息:它意味着必须做到 O(N)。任何 O(N²) 的写法在 N = 10⁶ 时都是 10¹² 次操作,稳稳超时。

只要输出末 4 位,所以每一步都 % 10000。注意 N = 3 时答案是 5,要输出 5 而不是 0005 —— 用整数取模再输出天然满足这一点,别手动补零

模数只有 10000,int 就够;但前缀和 S 是累加量,累加时也要取模,否则量级会到 10¹⁰,溢出。

思路

四步法:

f[n] = 把 2×n 的墙铺满的方案数;
f[n] = f[n−1] + f[n−2] + 2·S[n−3]S[m] = f[0]+…+f[m] 是前缀和);
③ 边界 f[0] = 1(空墙 = 什么都不铺,也算 1 种)、f[1] = 1f[2] = 2
④ 答案 f[N] % 10000

转移方程的三个来源,就是「左上角那一格被哪块砖盖住」的三种情况(详细推导见本文第 6 节):

左上角被谁盖住接着发生什么贡献
竖放的骨牌第 1 列整列铺满,右边是干净的 2×(n−1)f[n−1]
横放的骨牌左下那格只剩「再横放一块」这一条路,于是前 2 列一起铺满f[n−2]
L 型砖2 种镜像朝向;之后缺口只能一列列往右推(每步无分支),直到用另一块 L 收尾。收尾位置 j 从 3 到 n 各 1 种,右边剩 f[n−j] 种2·S[n−3]

其中 L 那一类展开就是 Σj=3..n 2·f[n−j] = 2·(f[0]+f[1]+…+f[n−3])。用前缀和把它压成 O(1),整个算法就是一遍 O(N) 递推。

样例输入
13
样例输出
3465

题面明确说了 2×13 的铺法数是 13465,取末 4 位才是 3465。也就是说,只有真的算出 13465 再取模才会得到 3465 —— 这个样例顺便能验证我们的取模位置没写错。

完整代码

#include <iostream>
using namespace std;

const int MOD = 10000;

// f[n]:2 x n 墙壁的覆盖方案数
// S[n]:f[0] + f[1] + ... + f[n],用前缀和把转移压成 O(1)
int f[1000005];
int S[1000005];

int main(){
    int N;
    cin >> N;

    // 边界:空墙 1 种;2x1 只能竖放一块 1 种;2x2 可以两块竖放、也可以两块横放,2 种
    f[0] = 1;
    f[1] = 1;
    if(N >= 2) f[2] = 2;

    S[0] = f[0] % MOD;
    if(N >= 1) S[1] = (S[0] + f[1]) % MOD;
    if(N >= 2) S[2] = (S[1] + f[2]) % MOD;

    // f[n] = f[n-1] + f[n-2] + 2 * S[n-3]
    for(int i = 3; i <= N; i++){
        f[i] = (f[i - 1] + f[i - 2] + 2 * S[i - 3]) % MOD;
        S[i] = (S[i - 1] + f[i]) % MOD;      // S 会越滚越大,必须每步取模
    }

    cout << f[N] << endl;
    return 0;
}
✅ 实测结果

官方样例 13 → 输出 3465 完全一致

小程序实测 N = 1 … 131 2 5 11 24 53 117 258 569 1255 2768 6105 3465
其中 N = 3 的 5 与题面里「2×3 的墙有 5 种覆盖方法」一致。

最大数据 N = 1000000 → 输出 3427,耗时约 32 ms(时限 1 秒)

对拍验证:写了一个暴力程序,枚举 2×N 墙的所有精确覆盖(竖骨牌 / 横骨牌 / 4 种 L 朝向),N = 1…10 的结果是 1 2 5 11 24 53 117 258 569 1255,与递推全部一致
另外单独验证了「左上角被 L 盖住」这一类铺法数恰好等于 2·S[n−3](N = 1…10 全部吻合),也就是第 6 节里那个分类推导是对的。

编译:g++ -std=c++14 -O2 -Wall0 warning

易错点

  • 转移里现场扫一遍前缀求和:那是最直观但最慢的写法,O(N²),N = 10⁶ 必超时。要用前缀和数组 S 把它变成 O(1)。
  • S 忘记取模:S 是累加量,不取模会涨到 10¹⁰,int 直接溢出成负数。
  • 漏掉 f[0] = 1:它对应「L 阶梯正好铺满整面墙、右边一列不剩」这种情况。少了它 N = 3 会算成 3 而不是 5。
  • 手动补前导零:N = 3 要输出 5,不是 0005。用整数取模输出即可。
  • 分类分漏了:一定要检查「竖 / 横 / L」三类是不是既互不重叠、又覆盖了全部情况(就是第 6 节那句「不重不漏」)。
  • 数组要开到 10⁶ 级别。两个 int 数组约 8 MB,没问题;但别开成 long long 的多维数组。
P1091 [NOIP 2004 提高组] 合唱队形 普及/提高- · 正反两遍 LIS

题意:n 个同学站成一排,每人有身高。请走最少的人,使剩下的人排成「合唱队形」:存在一个最高点,最高点左边严格递增、右边严格递减。问最少请出去几个人。

⚠️ 数据范围 / 关键约束

2 ≤ n ≤ 100,身高 130 ≤ ti ≤ 230

n 只有 100,所以 O(n²) 的 LIS 完全够用,不需要写 O(n log n) 的优化版。
注意题目要求是严格递增 / 严格递减,身高相等的人不能构成队形。

思路

枚举「最高点」是哪个同学(设为 i),那么:

· 左边最多留 up[i] 个人 —— 以 i 结尾的最长严格上升子序列;
· 右边最多留 down[i] 个人 —— 以 i 开头的最长严格下降子序列。

于是以 i 为最高点能留下 up[i] + down[i] - 1 个人(i 被两边各数了一次,要减 1)。

把所有 i 试一遍取最大,答案 = n − 最多能留下的人数

up[] 是正着扫一遍 LIS;求 down[]倒着扫一遍,找 j > it[j] < t[i] 的转移。

样例输入
8
186 186 150 200 160 130 197 220
样例输出
4

样例最优方案:留下 150 200 160 130(先升到 200,再一路降到 130),共 4 人,所以请出去 8 − 4 = 4 人。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int t[105];
int up[105];      // up[i]:以 i 结尾的最长「严格上升」子序列长度
int down[105];    // down[i]:以 i 开头的最长「严格下降」子序列长度

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

    // 正着扫一遍:上升的 LIS
    for(int i = 1; i <= n; i++){
        up[i] = 1;
        for(int j = 1; j < i; j++)
            if(t[j] < t[i]) up[i] = max(up[i], up[j] + 1);   // 严格上升
    }

    // 倒着扫一遍:等价于「以 i 为最高点的下降部分」
    for(int i = n; i >= 1; i--){
        down[i] = 1;
        for(int j = i + 1; j <= n; j++)
            if(t[j] < t[i]) down[i] = max(down[i], down[j] + 1);
    }

    int best = 0;
    for(int i = 1; i <= n; i++)
        best = max(best, up[i] + down[i] - 1);   // i 这个最高点被数了两次,减 1

    cout << n - best << endl;                    // 出列人数 = 总人数 - 留下的
    return 0;
}
✅ 实测结果

官方样例 → 输出 4 完全一致

边界 2 / 130 130(两人一样高)→ 输出 1 正确
身高相等不满足「严格」递增,必须请出去一个。如果把 < 写成 <=,这里会错答成 0。

边界 2 / 130 23002 / 230 1300 均正确(两人本身就构成合法队形,最高点可以在端点)

对拍验证:随机生成 3000 组数据(n = 2~10,身高故意限制在很小的范围以制造大量相等身高),与「暴力枚举所有子集、逐一检查是否先严格递增后严格递减」的结果全部一致

编译:g++ -std=c++14 -O2 -Wall0 warning

易错点

  • 答案写成 best 而不是 n - best:题目问「最少请出去几个人」,不是「最多留几个人」。看清问的是哪个
  • 忘了减 1up[i] + down[i] 把最高点 i 数了两次,正确是 up[i] + down[i] - 1
  • 判断条件写成 <=:题目要严格递增/递减,必须写 t[j] < t[i]
  • down 的方向搞反down[i] 是「以 i 开头往右走」,所以倒着扫、从 j = i+1 开始,条件也是 t[j] < t[i](比 i 矮的才能接在后面)。
  • 最高点可以在最左端或最右端(此时一边为空),代码天然支持,不用特判。

今日常见错误汇总

错误写法后果正确写法
先写代码,不先用四步法填表 式子想到一半就卡住,或者状态定义有后效性,改到崩溃 先在草稿上写清状态 / 转移 / 边界 / 答案四行,再动手敲
DP 写完直接输出 dp[n] 答案根本不一定是它(最大子段和要取 max,合唱队形要取 n−best) 回头问一遍:题目问的对应哪个状态
P1216 自顶向下写,左右边界不特判 读到下标 0 或越界,样例就过不去 自底向上,一个边界都不用管
P1002 用 int dp[30][30] 答案最大约 1.38×10¹¹,直接溢出成负数,WA long long dp[30][30]
P1002 只标 8 个马的控制点 马脚下的格子还能走,方案数偏多 9 个控制点,偏移量数组第一个是 (0,0)
P1002 坐标不平移,直接用 (0,0) 起推 dp[-1][j] 越界,RE 或玄学 WA 所有坐标 +2,起点变 (2,2)
P1091 比较写 <= 身高相等的人也被算进队形,答案偏小 严格递增/递减用 t[j] < t[i]
P1091 忘了 up[i] + down[i] - 1 里的 −1 最高点被数两次,留下人数偏多,出列人数偏少 减 1,再取 max
P1990 转移里现场扫一遍前缀求和 算 f[n] 要往回扫 n 项 → O(N²),N = 10⁶ 时 10¹² 次操作,超时 用前缀和 S[n] = (S[n-1] + f[n]) % MOD;,转移变 O(1)
P1990 的 S 数组忘记取模 S 是累加量,涨得比 f 快,会溢出成负数 累加那一刻就取模:S[i] = (S[i-1] + f[i]) % MOD;
P1990 漏了 f[0] = 1 「L 阶梯正好铺满整面墙」那一类跟着少算,N = 3 会得到 3 而不是 5 空墙也算 1 种铺法(什么都不铺)
输出用 printf("%lld", ...) 本机 MinGW(GCC 6.3.0)的 printf 不认 %lld,编译时就报 warning: unknown conversion type character 'l' in format,输出行为不可靠 一律用 cout(本题用 int,本来也不需要 long long)
局部数组忘记 = {0} 初值是随机垃圾,越算越错 开全局数组,或写 int dp[1005] = {0};

自测:你真的会了吗

第 1 题:默写 DP 四步法

:① 状态定义(dp[...] 表示什么)→ ② 转移方程(从哪些更小的状态来、怎么组合)→ ③ 边界 / 初始化(最小号状态的值,手算写死)→ ④ 答案(输出哪个状态,不一定是 dp[n])。

再加一句判断标准:状态必须满足无后效性——只给这个状态的值,就能决定下一步怎么走。

第 2 题:数字三角形为什么推荐自底向上?

:因为自顶向下时,每一行的最左边和最右边只有一个"父亲",转移时会读到不存在的格子,必须特判两条斜边;而自底向上时每个格子下面都正好有两个格子(最后一行除外,它本身就是边界),一次特判都不用写。而且答案天然就是 dp[1][1],不用再扫最后一行取 max。

第 3 题:过河卒为什么要用 long long?

:因为这题在数方案数,增长极快。20×20 的棋盘从 (0,0) 到 (20,20) 要走 40 步,其中 20 步向右、20 步向下,条数是 C(40,20) = 137846528820,约 1.38×10¹¹;而 int 上限只有 2147483647(约 2.1×10⁹),装不下。

实测也印证了这一点:n=m=20、马在 (0,1) 时输出 8597496600,已经超过 int 上限。

第 4 题:合唱队形里 down[i] 是什么意思?怎么求?

down[i] = 以第 i 个同学为最高点、往右走的最长严格下降子序列长度(也就是"右边最多能留几个人,且 i 自己算在内")。

求法:倒着扫,i 从 n 到 1,枚举 j = i+1 .. n,若 t[j] < t[i]down[i] = max(down[i], down[j] + 1)

最后 答案 = n − max(up[i] + down[i] − 1)

第 5 题:P1990 的转移方程 f[n] = f[n−1] + f[n−2] + 2·S[n−3],三项分别是什么情况?

:按「左上角那一格被哪块砖盖住」分类:

· f[n−1]:竖放一块骨牌,第 1 列整列铺满,右边剩 2×(n−1);
· f[n−2]:横放一块骨牌盖住左上角,此时左下那格只剩「也横放一块」这一条路,前 2 列一起铺满,右边剩 2×(n−2);
· 2·S[n−3]:用 L 型砖起头(2 种镜像朝向)。之后第 2 列留一个缺口,缺口只能一列列往右推(每一步都没有第二种选择),最后用另一块 L 收尾;收尾位置 j 可以从 3 到 n,每个 j 恰好 2 种铺法,右边剩 f[n−j] 种,全部加起来就是 2·(f[0]+…+f[n−3]) = 2·S[n−3]。

三项互不重叠、又覆盖了全部铺法,所以可以直接相加。

再补一句:那个 Σ连续前缀,所以要用 S 数组把它压成 O(1);否则每项都要往回扫,总复杂度 O(N²),N = 10⁶ 时必超时。

第 6 题(手写代码):在 P1002 的代码里,如果把 if(i == 2 && j == 2) continue; 删掉会怎样?

:起点会被转移式覆盖成 dp[1][2] + dp[2][1] = 0 + 0 = 0,于是从起点出发的路径条数变成 0,整个答案变成 0(除非起点就是终点)。

这就是为什么必须在设完 dp[2][2] = 1 之后,把起点从循环里跳过。

速查卡(截图存手机)

【DP 四步法】
  ① 状态定义    dp[...] 表示什么
  ② 转移方程    从哪些更小的状态来、怎么组合
  ③ 边界        最小号状态的值(手算写死)
  ④ 答案        输出哪个状态(不一定是 dp[n]!)

【线性 DP 三大模型】
  A 以 i 结尾      dp[i] = max(dp[i], dp[j] + 代价),j < i
  B 多阶段推进     dp[i][j] = f(上一行的邻居)
  C 分类讨论递推   f[n] = Σ(枚举「第一步」的各种情况)

【数字三角形(自底向上)】
  dp[i][j] = max(dp[i+1][j], dp[i+1][j+1]) + a[i][j]
  边界:最后一行 = 自己;答案:dp[1][1]

【过河卒(路径计数)】
  坐标全部 +2(起点变 (2,2))
  马的控制点 9 个:马自己 + 8 个跳跃点,全部置 0
  dp[i][j] = dp[i-1][j] + dp[i][j-1];   dp[起点] = 1
  答案会超 int → 必须 long long

【LIS / 合唱队形】
  up[i]   = 以 i 结尾的最长严格上升子序列
  down[i] = 以 i 开头的最长严格下降子序列(倒着扫)
  留下最多 = max(up[i] + down[i] - 1)
  答案 = n - 留下最多        // 问的是"出列几人"

【P1990 覆盖墙壁(分类讨论递推)】
  看【左上角那一格】被谁盖住 -> 竖放 / 横放 / L 起头
  f[0] = 1, f[1] = 1, f[2] = 2
  f[n] = f[n-1] + f[n-2] + 2 * S[n-3]
  S[n] = (S[n-1] + f[n]) % 10000   // 前缀和把 Σ 压成 O(1)
  答案 = f[N] % 10000
  O(N);N 到 1e6,写成 O(N^2) 必超时

【判断该不该用 long long】
  数方案 / 数路径 / 阶乘乘方 / 题目没给答案上界 → 用 long long

明天预告 · Day 26

0/1 背包

今天你已经练熟了两种最常用的 DP 思路:「多阶段推进」(数字三角形、过河卒)和「分类讨论、枚举第一步」(P1990 覆盖墙壁)。 明天的 0/1 背包要把这两种思路合起来用:一件一件地处理物品(多阶段),每件物品做一个「选 / 不选」的决策(分类讨论)。

明天会重点讲清一件事:为什么一维写法里容量必须倒序枚举。这是背包最核心、最容易考的一点,也是区分"背过模板"和"真懂背包"的分水岭。

今天 P1990 的「看左上角那一格被谁盖住」、以及 P1216 / P1002 的逐格推进,已经把「转移方程 = 枚举最后一步的所有可能」这件事演示清楚了。明天你会看到又一个例子:最后一步是「放第 i 件物品」还是「不放」。

ACM 一个月学习计划 · DAY 26

Day 26 · 0/1 背包:为什么要倒序枚举容量

背包是 DP 里最经典的一类模型 —— 今天只要把「为什么容量必须倒序枚举」这一件事想透,后面所有背包题都好办

今日目标:掌握 0/1 背包的一维标准写法,能说清倒序枚举容量的原因
预计用时:约 3.5 小时(含做题)  难度:普及-
题目来源:洛谷 P1048 / P1049 / P1060 / P1164
DAY 26 / 30

今日时间安排

0 - 25 min0/1 背包是什么,为什么贪心不行(第 1 节)
25 - 55 min从二维写法推到一维写法(第 2 节)
55 - 100 min★ 核心:逐格手推「正序 vs 倒序」,把原因想透(第 3 节)
100 - 120 min背包的四个常见变体(第 4 节)
120 - 230 min做 4 道题,每题限时 25 分钟
230 - 260 min对照本文题解 + 做自测题
💡 和昨天内容的衔接

昨天的线性 DP 里,数字三角形是「一层一层往下推」,过河卒是「一格一格往右推」——它们的共同点是:每个阶段做一个决策,然后进入下一个阶段。

0/1 背包就是同一个套路,只不过:

· 「阶段」变成了物品(一件一件考虑);
· 「位置」变成了容量(还剩多少空间);
· 每个阶段的决策变成了最经典的二选一 —— 放这件物品,还是不放

所以昨天那套四步法(状态 → 转移 → 边界 → 答案)今天照样能用,一个字都不用改。

10/1 背包是什么:一个决策做一次

场景

你有一个容量为 W 的背包,面前有 n 件物品,第 i 件重 w[i]、价值 v[i]

每件物品你只有两种选择:要么整个装进去,要么完全不要。问在不超过容量的前提下,能带走的最大总价值是多少。

🎯 「0/1」这个名字就是字面意思

每件物品的状态只有两种:0 = 不拿,1 = 拿
不能拿半个(那是"部分背包"),也不能拿两次(那是"完全背包"),更不能拿三份。

和 Day 14 的「部分背包」比一比

还记得 Day 14 的 P2240 部分背包吗?那题里的金币可以随意切分,所以做法是按性价比(价值 ÷ 重量)从高到低贪心拿,拿不下就把最后一样切开装一部分。

那 0/1 背包能不能照抄这个贪心?不能。看一个例子:

物品重量价值性价比
A6366.0(最高)
B5255.0
C5255.0

背包容量 W = 10。

贪心的思路:性价比最高的是 A(6.0),先拿 A。拿了 A 之后剩 4 的容量,B 和 C 都要 5,一个都放不进去。总价值 36
真正的最优解:不拿 A,拿 B + C,重量 5 + 5 = 10 正好装满,总价值 25 + 25 = 50

贪心拿了"最划算的那件",结果损失了 14 点价值 —— 因为物品不能切分,「性价比高」不代表「该拿」,拿了它可能把容量浪费掉。

✅ 实测(本文真实跑出来的)

上面这组数据的程序输出:

贪心(按性价比)  = 36   (拿了 6 时间那株,剩下 4 装不下别的)
0/1 背包        = 50   (放弃 6,改拿 5+5,正好装满 10)

把同样这组数据喂给本文 P1048 的正式解法(输入 10 3 / 6 36 / 5 25 / 5 25),输出也是 50

⚠️ 一句话记住这个区别

能切分 → 贪心(按性价比);不能切分 → DP(背包)。
以后看到"可以分割/可以只拿一部分",先想贪心;看到"每种只有一份、要么拿要么不拿",那就是背包。

2从二维写法推到一维写法

先把四步法填一遍(二维版,最好懂)

步骤填什么
① 状态定义dp[i][j] = 只考虑前 i 件物品、背包容量为 j 时能获得的最大价值
② 转移方程第 i 件物品只有两种命运:
· 不拿:价值 = dp[i-1][j](容量没变,问题退化成"前 i-1 件、容量 j")
· :得先给它腾出 w[i] 的空间,价值 = dp[i-1][j-w[i]] + v[i]
取两者较大:dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
③ 边界dp[0][j] = 0(一件都不考虑,价值当然是 0)
④ 答案dp[n][W](考虑完 n 件、容量用满 W 的最优值)
🎯 转移方程里那个 dp[i-1] 是整件事的关键

注意"拿"的那一支写的是 dp[i-1][j-w[i]]是 i-1,不是 i
意思是:在"还没考虑过第 i 件物品"的世界里,用剩下的 j-w[i] 容量去装前面的物品。
正因为用了 i-1,才保证了第 i 件物品最多只被拿一次 —— 这就是"0/1"的体现。
如果写成 dp[i][j-w[i]],就变成"第 i 件可以拿了再拿",那是完全背包。

二维数组其实没必要开

看转移方程:dp[i][j] 只用到第 i-1 行的数据,跟第 i-2 行、第 i-3 行毫无关系。

既然旧的行用完就废了,那就不用开 n 行 —— 只留一行,边算边把旧的覆盖掉,这就是滚动数组(一维写法):

// 二维写法
dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i]] + v[i]);

// 去掉 i 这一维,变成一维(★ 内层倒序)
for(int j = W; j >= w[i]; j--)
    dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
一维写法里的东西对应二维写法里的谁
dp[j](等号左边,被写入)dp[i][j](正在算的这一行)
dp[j](等号右边第一个)dp[i-1][j]旧值,代表"不拿")
dp[j - w[i]]dp[i-1][j - w[i]]也必须是旧值!
⚠️ 矛盾出现了

第一个 dp[j] 我们想要旧值(还没被本轮改过的),第二个 dp[j-w[i]] 我们也想要旧值
但一维数组只有一个,怎么保证读到的都是旧的、而不是刚刚被自己改过的?

答案就是下一节:靠枚举顺序。这也是今天唯一一个必须彻底想明白的地方。

3★ 为什么容量一定要倒序枚举

别背结论,我们拿一组最小的数据逐格手推一遍,看正序到底错在哪里。

实验设置

背包容量 W = 6,只有 1 件物品:重量 w = 3,价值 v = 5。

先自己想一想:只有一件物品、它又不能切分,那正确答案显然是 5(拿它或者不拿它,拿到就是 5)。

数组 dp[0..6] 本来全是 0。

情况一:正序枚举(j 从 w[i] 到 W,也就是 3 → 6)

j执行 dp[j] = max(dp[j], dp[j-3] + 5)这一轮之后的 dp[0..6]
3max(dp[3]=0, dp[0]+5 = 0+5) = 50 0 0 5 0 0 0
4max(dp[4]=0, dp[1]+5 = 0+5) = 50 0 0 5 5 0 0
5max(dp[5]=0, dp[2]+5 = 0+5) = 50 0 0 5 5 5 0
6max(dp[6]=0, dp[3]+5 = 5+5) = 100 0 0 5 5 5 10

看最后一行:算 j = 6 时,读到的 dp[3] 已经不是 0 了,而是同一个物品刚刚在 j = 3 那一轮写进去的 5

于是 dp[6] = 5 + 5 = 10 —— 意思是"这件物品被用了两次"(3 + 3 = 6 的容量,5 + 5 = 10 的价值)。

🚫 正序的错误结果:dp[6] = 10

可我们只有 1 件物品,怎么可能拿两次?
正序枚举 = 读到了"本行"刚更新过的新值 = 允许同一件物品重复使用 = 完全背包的语义。

情况二:倒序枚举(j 从 W 到 w[i],也就是 6 → 3)

j执行 dp[j] = max(dp[j], dp[j-3] + 5)这一轮之后的 dp[0..6]
6max(dp[6]=0, dp[3]+5 = 0+5) = 5 ← dp[3] 还是旧值 00 0 0 0 0 0 5
5max(dp[5]=0, dp[2]+5 = 0+5) = 50 0 0 0 0 5 5
4max(dp[4]=0, dp[1]+5 = 0+5) = 50 0 0 0 5 5 5
3max(dp[3]=0, dp[0]+5 = 0+5) = 50 0 0 5 5 5 5

这次算 j = 6 时,dp[3] 还没被这一轮碰过(因为在倒序里,3 排在 6 后面才轮到),所以读到的是干净的旧值 0,得到 dp[6] = 5正确!

🎯 一句话说清原因

倒序枚举时,容量 j 从大到小走,而 dp[j - w[i]] 的下标一定比 j 小 —— 也就是说,它"还没有被本轮更新过",读到的必然是"没考虑第 i 件物品"时的旧值,正好对应二维写法里的 dp[i-1][j-w[i]]

反过来说:正序枚举会让 dp[j-w[i]] 读到本轮刚写进去的新值,等于让第 i 件物品被反复使用,那就变成完全背包了。

✅ 实测(把上面这个实验真跑了一遍)

W = 6、一件物品 (w=3, v=5),两种枚举顺序的实际输出:

容量 j :  0 1 2 3 4 5 6
正序   : 0 0 0 5 5 5 10   <- 错!dp[6]=10 说明这件物品被用了 2 次
倒序   : 0 0 0 5 5 5 5    <- 对,dp[6]=5 这件物品只用了 1 次

和手推的结果完全吻合。

💡 记忆口诀(但请先理解再背)

「倒序 0/1,正序完全」

· 0/1 背包(每件最多一次):内层容量倒序 for(j = W; j >= w[i]; j--)
· 完全背包(每件无限次):内层容量正序 for(j = w[i]; j <= W; j++)(明天 Day 27 学)

唯一的区别就是那个 >=<=,写反了题目就从 0/1 变成完全背包,答案必错。

循环的嵌套顺序也不能反

// ✅ 正确:外层物品,内层容量(倒序)
for(int i = 1; i <= n; i++)
    for(int j = W; j >= w[i]; j--)
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

为什么必须「外层物品、内层容量」?因为外层循环一次代表「引入了一件新物品」, 内层倒序代表「在这件物品只能选一次的前提下,更新所有容量的答案」。两层各管一件事,配合起来才等于二维的 dp[i][j]

⚠️ 顺手把循环写成模板刻在脑子里
for(int i = 1; i <= n; i++)          // 外层:一件一件物品
    for(int j = W; j >= w[i]; j--)    // 内层:容量从大到小(倒序!)
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

今天下面四道题,有三道都是这三行,只是 w 和 v 的含义换了换。

4背包的四个常见变体(今天全都会遇到)

模板一样,但题目问的东西不一样,初始化和答案的输出位置就不一样。下面这张表是今天的第二重点。

变体含义初始化今天的题

不超过容量
求最大价值
容量可以不装满,只要不超就行 dp 全部为 0
int dp[W+5] = {0};
P1048 采药
P1060 开心的金明

恰好装满
求最大价值
容量必须刚好用光,用不光就算无解 dp[0] = 0,其余 -INF 今天没有(但要会)

价值 = 重量
不求价值,求"能装进去多少体积" dp 全部为 0,转移里 v[i] 换成 w[i] P1049 装箱问题

求方案数
(恰好装满)
不数价值,数"有几种选法" dp[0] = 1,其余 0 P1164 小 A 点菜

变体 ①:不超过容量 —— 为什么全初始化成 0

dp[j] = 0 的含义是:「容量为 j 时,我什么也不装,价值就是 0」。这是一个完全合法的方案,所以可以放心地从 0 出发取 max。

题目只要求"不超过",那么多出来的容量闲着不用是允许的,所以任何容量都至少有个"什么都不装"的答案 0。

变体 ②:恰好装满 —— 为什么其余要设成 -INF

如果题目要求容量必须刚好用光,那"什么都不装"在容量 j > 0 时就是非法状态,不能拿它去参与 max(否则它会把非法答案 0 带给后面的状态)。

工程上的做法:把非法状态设成一个极小的数(-1e9 之类),这样它在 max 里永远竞争不过任何合法方案。

// 恰好装满的初始化
const int NEG = -1000000000;      // 用 -INF 表示"这个容量凑不出来"
dp[0] = 0;                        // 容量 0 恰好装满 = 什么都不装,合法
for(int j = 1; j <= W; j++) dp[j] = NEG;

// 转移完全一样
for(int i = 1; i <= n; i++)
    for(int j = W; j >= w[i]; j--)
        if(dp[j - w[i]] != NEG)                       // 前面得凑得出来才行
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

// 答案:dp[W] 如果还是 NEG,说明无解
if(dp[W] == NEG) cout << "无解" << endl;
else             cout << dp[W] << endl;
⚠️ 今天四道题都不需要 -INF

P1048、P1049、P1060 都是"不超过",P1164 是"恰好",但它是计数不是求最大值,用的是加法和 dp[0] = 1(见变体 ④)。
不过 -INF 这个套路必须会,它是背包题里非常高频的一个变体,以后一定会遇到。

变体 ③:价值 = 体积(P1049 用的就是这个)

P1049 问的是「箱子剩余空间最小」,等价于问「最多能装进去多少体积」。

于是把物品的体积同时当成价值:重量 = v[i],价值 = v[i]。跑完背包后 dp[V] 就是「能装进去的最大体积」,答案就是 V - dp[V]

dp[j] = max(dp[j], dp[j - v[i]] + v[i]);   // 注意:加的是 v[i],不是别的
cout << V - dp[V] << endl;                 // 剩余空间 = 总容量 - 装进去的

变体 ④:计数背包(P1164 用的就是这个)

把「max 求最大价值」换成「+= 数方案数」,转移方程的形状完全不变:

求最大价值求方案数
转移dp[j] = max(dp[j], dp[j-w] + v)dp[j] += dp[j-w]
初始化dp 全 0dp[0] = 1,其余 0
枚举容量倒序容量倒序(还是 0/1!)
🎯 为什么计数背包要 dp[0] = 1

问自己:「花 0 元有多少种点法?」答案是 1 种 —— 什么都不点。
如果 dp[0] = 0,那所有方案数都会是 0(因为每个方案最后都要追溯到"花 0 元"这个起点)。
计数类的 DP,起点的方案数永远是 1,不是 0。这是最容易忘的一行。

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译运行过(0 warning), 并且跑过官方样例和额外的边界数据,输出是你看到的真实结果。

P1048 [NOIP 2005 普及组] 采药 普及- · 0/1 背包模板

题意:有 T 的时间可以采药,山洞里有 M 株草药,采第 i 株要花 wi 的时间、得到 vi 的价值。每株最多采一次(采完就没了),问在 T 时间内能获得的最大总价值。

⚠️ 数据范围 / 关键约束

1 ≤ T ≤ 10001 ≤ M ≤ 100,每株药的采摘时间与价值都在 1 ~ 100 之间。

时间 T 最大 1000,所以 dp 数组只需要开到 1005 左右。总复杂度 O(M × T) = 100 × 1000 = 10 万次,飞快。
注意输入的第一个数是时间 T、第二个是草药数 M,别读反了。

思路

这是 0/1 背包的教科书模板题,把题目里的词一一对应过去就行:

背包里的叫法本题里的叫法
背包容量 W总时间 T
物品个数 n草药株数 M
物品重量 w[i]采摘第 i 株药需要的时间
物品价值 v[i]第 i 株药的价值
"每件最多选一次""每株药只能采一次"

四步法:① dp[j] = 用时不超过 j 能采到的最大价值;② dp[j] = max(dp[j], dp[j-w[i]] + v[i]); ③ dp 全 0(不超过时间,闲着也行);④ 答案 dp[T]。内层容量倒序

样例输入
70 3
71 100
69 1
1 2
样例输出
3

第一株要 71 的时间,超过了 70,采不了;剩下两株 69 + 1 = 70 正好用完时间,价值 1 + 2 = 3。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int w[105], v[105];      // w[i] 采药时间,v[i] 价值
int dp[1005];            // dp[j]:容量(时间)为 j 时的最大价值

int main(){
    int T, M;
    cin >> T >> M;
    for(int i = 1; i <= M; i++)
        cin >> w[i] >> v[i];

    for(int i = 1; i <= M; i++)
        for(int j = T; j >= w[i]; j--)                   // ★ 容量必须倒序枚举
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

    cout << dp[T] << endl;
    return 0;
}
✅ 实测结果

官方样例 70 3 / 71 100 / 69 1 / 1 2 → 输出 3 完全一致

最大数据 T = 1000、M = 100、每株都是 (100, 5) → 输出 50 正确
每株占 100 的时间,1000 的时间最多采 10 株,10 × 5 = 50。实测耗时 26 ms

贪心对照数据 10 3 / 6 36 / 5 25 / 5 25 → 输出 50(贪心会得 36,见本文第 1 节)

编译:g++ -std=c++14 -O2 -Wall0 warning

易错点

  • 输入读反:第一个数是时间 T,第二个才是草药数 M。
  • 内层写成正序 for(j = w[i]; j <= T; j++):那就变成完全背包(每株能采无限次),样例就过不去。
  • dp 数组开太小:值域是时间 T(最大 1000),不是草药数 M(最大 100)。按 M 开 105 会越界。
  • 写成"按性价比贪心":本题物品不可分割,贪心是错的(本文第 1 节给了反例实测)。
P1049 [NOIP 2001 普及组] 装箱问题 普及- · 价值 = 体积的背包

题意:箱子容量为 V,有 n 个物品,每个物品有一个体积。从中任取若干个装进箱子(也可以一个都不取),使箱子剩余空间最小,输出这个最小值。

⚠️ 数据范围 / 关键约束

0 < n ≤ 301 ≤ V ≤ 20000

n 只有 30,但不能用「枚举 2³⁰ 种取法」硬搜(10 亿级别,超时);而 V 最大 20000, 所以 dp 数组按容量 V 开到 20005,跑 O(n × V) = 30 × 20000 = 60 万次,非常轻松。
敲数组大小时,看清值域是 V 还是 n —— 这题是 V。

思路

题目要「剩余空间最小」,也就是「装进去的总体积尽量大」。所以我们干脆把物品的体积当成价值

重量 = v[i],价值 = v[i] → 跑一遍 0/1 背包 → 答案 = V − dp[V]

这样 dp[V] 就是"容量不超过 V 时能装进去的最大体积",再用总容量一减就是剩余空间。

换个角度理解:这题本质是子集和问题——问"哪些体积 s 能被某些物品凑出来",然后找 ≤ V 的最大 s。背包的 dp 数组其实就顺手把这件事做了。

样例输入
24
6
8
3
12
7
9
7
样例输出
0

六个物品体积 8、3、12、7、9、7。选 8 + 9 + 7 = 24,正好把箱子装满,所以剩余 0。

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int v[35];
int dp[20005];           // dp[j]:容量为 j 时能装进去的最大体积

int main(){
    int V, n;
    cin >> V >> n;
    for(int i = 1; i <= n; i++)
        cin >> v[i];

    for(int i = 1; i <= n; i++)
        for(int j = V; j >= v[i]; j--)                   // ★ 倒序
            dp[j] = max(dp[j], dp[j - v[i]] + v[i]);     // 价值 = 体积

    cout << V - dp[V] << endl;                           // 剩余空间 = 容量 - 装进去的
    return 0;
}
✅ 实测结果

官方样例 → 输出 0 完全一致

边界 V = 20000,n = 30,每件体积都是 1999 → 输出 10 正确
1999 × 10 = 19990 是能塞进去的最大体积,20000 − 19990 = 10。实测耗时 29 ms

编译:g++ -std=c++14 -O2 -Wall0 warning

易错点

  • 输出写成 dp[V]:题目问的是剩余空间,答案是 V - dp[V],别把最大装入体积直接交上去。
  • 数组按 n 开:值域是容量 V(20000),int dp[35] 会越界。这题要开 dp[20005]
  • 2^n 枚举硬搜:n = 30 时是 10 亿种取法,必超时,要老老实实用 DP。
  • 物品体积可能比 V 还大,此时 j >= v[i] 直接不成立,自动跳过,不用特判。
P1060 [NOIP 2006 普及组] 开心的金明 普及- · 0/1 背包

题意:妈妈给 N 元钱买 m 件物品,第 j 件价格 vj、重要度 pj。在不超支的前提下,让 「价格 × 重要度」的总和最大,输出这个最大值。

⚠️ 数据范围 / 关键约束

n < 3×10⁴,m < 25;每件价格 v ≤ 10⁴,重要度 1 ≤ p ≤ 5;答案保证 < 10⁸

钱数 n 最大到 3 万,所以 dp 数组要开到 30005 —— 这是本题最容易踩的坑,很多人看 m 只有 25 就顺手开 105,直接越界。
答案上界 10⁸,在 int 范围内(2.1×10⁹),不用 long long。

思路

标准 0/1 背包,一一对应:

背包术语本题术语
容量 W总钱数 n
物品数m
重量 w[i]物品价格 vj
价值 v[i]价格 × 重要度(vj × pj

也就是说「价值」不是直接给的,而是算出来的v * p。这是很多背包题的常见花样—— 先想清楚「谁是重量、谁是价值」,题目就变成模板了。

样例输入
1000 5
800 2
400 5
300 5
400 3
200 2
样例输出
3900

1000 元的最优买法:400(重要度 5)+ 300(重要度 5)+ 200(重要度 2),共 900 元, 价值 = 400×5 + 300×5 + 200×2 = 2000 + 1500 + 400 = 3900

完整代码

#include <iostream>
#include <algorithm>
using namespace std;

int dp[30005];           // dp[j]:花 j 元钱能得到的最大「价格 x 重要度」总和

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= m; i++){
        int v, p;
        cin >> v >> p;
        for(int j = n; j >= v; j--)                      // ★ 倒序,物品只能买一次
            dp[j] = max(dp[j], dp[j - v] + v * p);       // 价值 = 价格 x 重要度
    }

    cout << dp[n] << endl;
    return 0;
}
✅ 实测结果

官方样例 1000 5 / 800 2 / 400 5 / 300 5 / 400 3 / 200 2 → 输出 3900 完全一致

边界 100 2 / 60 5 / 40 5 → 输出 500 正确
60×5 + 40×5 = 300 + 200 = 500,两件刚好花完 100 元。

编译:g++ -std=c++14 -O2 -Wall0 warning

易错点

  • 数组按 m 开dp[30]):本题值域是钱数 n(最大 3 万),必须开 30005 以上。
  • 把价值写成 p 而不是 v × p:题目要最大化的就是「价格 × 重要度」的总和。
  • 输入顺序搞反:第一行是 n m(钱数、物品数),之后每行是 v p(价格、重要度)。
  • 内层还是倒序。这题每件物品只有一件,属于 0/1 背包。
P1164 小 A 点菜 普及- · 计数背包

题意:uim 身上有 M 元钱,餐馆有 N 种菜,第 i 种卖 ai 元,每种菜只有一份。 小 A 必须刚好把 M 元花完,问一共有多少种点菜方法。

⚠️ 数据范围 / 关键约束

0 < M ≤ 100001 ≤ N ≤ 1000 < ai ≤ 1000(价格可能重复)。

答案保证在 [0, 2³¹−1] 之内 —— 也就是说题目承诺用 int 存得下,这是题目给的保证。
dp 数组按钱数 M 开到 10005
注意「每种菜只有一份」,所以是 0/1 背包;而价格重复的菜算作不同的菜(方案数按"第几种菜"区分,不是按价格区分)。

思路

把昨天的「转移方程」从"求最值"换成"数方案":

0/1 背包求最大值本题:求方案数
状态dp[j] = 容量 j 的最大价值dp[j] = 刚好花掉 j 元的点法数
转移dp[j] = max(dp[j], dp[j-a]+v)dp[j] += dp[j-a[i]]
初始化全 0dp[0] = 1
答案dp[M]dp[M]

为什么转移是加法?因为「刚好花 j 元的方案」= 「不点第 i 种菜的方案」+「点了第 i 种菜的方案」, 两类方案互不重叠、合起来就是全部,所以直接相加,不需要取 max。

为什么 dp[0] = 1?因为"花 0 元"只有一种做法:什么都不点。这是所有方案的起点,设成 0 的话答案会全变 0。

内层仍然要倒序——每种菜只有一份,不能重复点。

样例输入
4 4
1 1 2 2
样例输出
3

菜品价格是 1、1、2、2(两种 1 元菜、两种 2 元菜)。刚好花 4 元的三种点法:
· 两份 2 元菜(第 3 + 第 4 种) · 两份 1 元菜 + 第 3 种(1+1+2) · 两份 1 元菜 + 第 4 种(1+1+2)
最后两种看起来一样,但点的是不同的菜,所以算两种方案。

完整代码

#include <iostream>
using namespace std;

int a[105];
int dp[10005];           // dp[j]:刚好花掉 j 元的点菜方案数

int main(){
    int N, M;
    cin >> N >> M;
    for(int i = 1; i <= N; i++)
        cin >> a[i];

    dp[0] = 1;                                       // ★ 花 0 元 = 什么都不点,算 1 种方案
    for(int i = 1; i <= N; i++)
        for(int j = M; j >= a[i]; j--)               // ★ 倒序,每道菜只点一次
            dp[j] += dp[j - a[i]];                   // 计数用加法

    cout << dp[M] << endl;
    return 0;
}
✅ 实测结果

官方样例 4 4 / 1 1 2 2 → 输出 3 完全一致

组合数校验 N = 30、M = 100、30 道菜全是 10 元 → 输出 30045015 正确
这正好是从 30 道菜里挑 10 道的组合数 C(30,10) = 30045015,程序输出与之完全吻合。

对拍验证:随机生成 3000 组数据(N ≤ 12、M ≤ 24、价格 1~8),与「暴力枚举所有 2ⁿ 种子集、数出和为 M 的个数」的结果全部一致

编译:g++ -std=c++14 -O2 -Wall0 warning

易错点

  • 忘了 dp[0] = 1:这是本题第一大坑。设成 0 的话所有答案都是 0,样例就会输出 0。
  • 转移写成 max:本题是数方案,要用 +=。看到"有多少种"就该条件反射地想加法。
  • 内层写成正序:那每道菜就能重复点,方案数会暴涨。
  • 数组按 N 开:值域是钱数 M(最大 10000),要开 10005。
  • 同价菜去重:价格相同的菜是不同的菜,不能合并,否则方案数会少算。
  • 用 long long 反而要小心:题目保证答案在 int 范围内,用 int 就好;如果用 long long 也完全没问题。

今日常见错误汇总

错误写法后果正确写法
for(j = w[i]; j <= W; j++)
(内层正序)
0/1 背包退化成完全背包,同一件物品被反复使用,答案偏大 for(j = W; j >= w[i]; j--)
倒序 0/1,正序完全
循环嵌套写反:外层容量、内层物品 物品之间互相"串味",得到没有意义的结果 外层物品 i,内层容量 j
数组按物品数 n 开 值域其实是容量 / 钱数,直接越界 RE 或玄学 WA 容量值域 + 5 开:P1048→1005、P1049→20005、P1060→30005、P1164→10005
P1049 输出 dp[V] 交的是"最多装多少",题目问的是"剩多少" cout << V - dp[V]
P1164 忘了 dp[0] = 1 所有方案数都是 0 计数背包起点方案数为 1
P1164 转移用 max 求成了"最多能点几道菜"之类的怪东西 数方案用 dp[j] += dp[j-a[i]]
把 0/1 背包当部分背包做贪心 物品不能切分时贪心是错的(实测 36 vs 最优 50) 不能切分 → DP;能切分 → 才用贪心
"恰好装满"用全 0 初始化 非法状态(容量凑不出来)被当成合法答案 0 参与 max dp[0] = 0,其余 -INF
P1048 把 T 和 M 读反 数组大小、循环边界全错 看清题面:先时间 T,再草药数 M
P1060 价值直接填 p 求的不是"价格 × 重要度"的总和 价值 = v * p

自测:你真的会了吗

第 1 题:默写 0/1 背包的一维模板(三行)
for(int i = 1; i <= n; i++)
    for(int j = W; j >= w[i]; j--)          // ★ 倒序
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

三个细节:外层物品、内层容量、容量倒序。少一个都不行。

第 2 题(核心):为什么容量必须倒序枚举?用自己的话说一遍

:一维写法里,dp[j-w[i]] 必须代表"还没考虑第 i 件物品"时的旧值(也就是二维写法里的 dp[i-1][j-w[i]]),这样第 i 件物品才只会被用一次。

倒序枚举时,容量 j 从大到小走,而 j - w[i] 一定比 j 小,还没被本轮更新过,读到的正是旧值 —— 语义正确。
正序枚举时,j - w[i] 已经在更早的轮次里被本件物品更新过了,读到的是"拿了第 i 件之后"的新值,等于允许第 i 件被重复拿,那就变成完全背包了。

验证:W = 6、一件物品 (w=3, v=5) 时,正序得 dp[6] = 10(用了两次),倒序得 dp[6] = 5(正确)。

第 3 题:什么时候 dp 要初始化成 -INF?

:当题目要求「恰好装满」时。dp[0] = 0(容量 0 恰好装满 = 什么都不装,合法),其余全部设为 -INF,表示"这个容量目前凑不出来"。

如果此时用全 0 初始化,那些"容量凑不出来"的非法状态会带着答案 0 参与 max,最终可能输出一个"没装满"的答案。

今天四道题里 P1048 / P1049 / P1060 都是"不超过"(全 0),P1164 是"恰好"但属于计数,用 dp[0] = 1 即可。

第 4 题:P1049 装箱问题怎么套 0/1 背包?答案是什么?

:把物品的体积同时当成价值(重量 = 体积,价值 = 体积),转移写 dp[j] = max(dp[j], dp[j-v[i]] + v[i])

这样 dp[V] 就是"容量不超过 V 时能装进去的最大体积",题目要的"最小剩余空间"= V - dp[V]

它本质上是在做子集和:判断「体积 s 能不能被凑出来」,再找 ≤ V 的最大的 s。

第 5 题:P1164 的 dp 转移为什么用加法?为什么 dp[0] = 1

:因为状态的含义是"方案数"。dp[j] = 刚好花掉 j 元的点法数。花掉 j 元的所有方案可以分成两类:不点第 i 种菜、点第 i 种菜。这两类互不重叠且覆盖全部,所以直接把它们的方案数相加 —— dp[j] += dp[j-a[i]]

dp[0] = 1 是因为「花 0 元」只有一种做法:什么都不点。它是所有方案的源头,如果设成 0,那么任何方案数都会被推成 0。

第 6 题(手写):把"恰好装满"的 0/1 背包初始化代码写出来
const int NEG = -1000000000;     // -INF,表示"这个容量凑不出来"
dp[0] = 0;
for(int j = 1; j <= W; j++) dp[j] = NEG;

for(int i = 1; i <= n; i++)
    for(int j = W; j >= w[i]; j--)
        if(dp[j - w[i]] != NEG)                        // 前驱必须凑得出来
            dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

// 输出:dp[W] 仍是 NEG 就说明无解
if(dp[W] == NEG) cout << "无解" << endl;
else             cout << dp[W] << endl;

速查卡(截图存手机)

【0/1 背包 · 一维模板(背下来)】
  for(int i = 1; i <= n; i++)                  // 外层:物品
      for(int j = W; j >= w[i]; j--)          // 内层:容量【倒序】
          dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
  复杂度和记忆:O(n x W);倒序 0/1,正序完全

【为什么倒序】
  dp[j-w[i]] 必须是「没考虑第 i 件」的旧值
  倒序 -> j-w[i] 还没被本轮更新 -> 旧值 -> 每件只用一次 ✅
  正序 -> j-w[i] 已被本轮更新   -> 新值 -> 每件用无限次 ❌

【二维对照】
  dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])
                    ↑ 不拿           ↑ 拿(注意是 i-1)

【四种变体】
  ① 不超过容量求最大价值:dp 全 0,答案 dp[W]
  ② 恰好装满求最大价值:dp[0]=0,其余 -INF;NEG 则无解
  ③ 价值 = 重量        :v[i] 处写 w[i],答案 W - dp[W]
  ④ 求方案数(恰好)   :dp[0] = 1,dp[j] += dp[j-w[i]]

【数组开多大】
  按【容量值域 + 5】开,不是按物品数!
  P1048 T≤1000   -> dp[1005]
  P1049 V≤20000  -> dp[20005]
  P1060 n<3e4    -> dp[30005]
  P1164 M≤10000  -> dp[10005]

【怎么认出一道题是背包】
  有「容量上限」+ 每件东西「要么选要么不选」+ 求最大/最小/方案数
  能切分 -> 贪心(按性价比);不能切分 -> 背包(DP)

明天预告 · Day 27

完全背包

今天留了一个尾巴:把内层循环的 >= 改成 <=(容量正序枚举), 背包的语义就从「每件最多一次」变成了「每件可以用无限次」 —— 这就是明天的完全背包。

明天还会遇到几道"看不出是背包"的题(比如 P1466 集合 Subset Sums、P1387 最大正方形), 到时候你会发现:判断一道题是不是背包,关键就是找到"谁是容量、谁是物品、每次决策选或不选"。

今天的四道题已经把背包的四种常见问法(求最大值、求剩余、价值=重量、求方案数)走了一遍, 明天只多一个变化:物品可以重复拿。把今天的「倒序」想透了,明天几乎不用学新东西。

ACM 一个月学习计划 · DAY 27

Day 27 · 完全背包 + DP 综合

一模一样的代码,只把内层循环的方向反过来 —— 每种物品就从「最多选一次」变成「随便选多少次」

今日目标:吃透完全背包的「正序枚举容量」,并认识多决策背包、计数型 DP、二维 DP 三种新题型
预计用时:约 3.5 小时(含做题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1616 / P1802 / P1466 / P1387
DAY 27 / 30

今日时间安排

0 - 40 min搞懂「正序 vs 倒序」的区别(第 1、2 节)—— 今天的命根子
40 - 60 min算清楚数组开多大、什么时候必须用 long long(第 3 节)
60 - 90 min多决策背包 + 计数背包 + 二维 DP 的思路(第 4、5 节)
90 - 180 min做 4 道题,每题限时 25 分钟
180 - 210 min对照本文题解,把没做出来的补掉
💡 昨天内容的衔接

昨天你学会了 0/1 背包的一维滚动数组写法,核心口诀是「外层枚举物品,内层倒序枚举容量」。

今天你会发现:把那个「倒序」改成「正序」,一维数组就自动变成了完全背包。昨天那道题的代码你几乎不用改。 但你必须搞清楚「为什么」,否则考场上你会分不清该用哪个。

1完全背包:把「倒序」改成「正序」

先回忆昨天的 0/1 背包

昨天的 P1048 采药是这样的:有若干种草药,每种最多采一次,给定总时间,求最大价值。

int w[N], v[N];                 // w[i] 花费,v[i] 价值
long long dp[W + 1] = {0};

for(int i = 1; i <= n; i++)
    for(int j = W; j >= w[i]; j--)        // ★ 倒序
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

今天的问题:同一种药可以无限采

P1616 疯狂的采药把题目改了一句话:「每种草药可以无限制地疯狂采摘」。 也就是说,同一种草药你可以采 0 次、1 次、3 次、100 次……只要总时间不超标。

那要怎么改代码?很多人的第一反应是:「那我在内层再加一层循环,枚举这种草药采几件」—— 那样确实能做,但复杂度变成 O(n·W·W),P1616 的 t 能到 10⁷,直接超时。

🎯 正确的答案只有一句话

把内层循环从「倒序」改成「正序」,别的都不用动。

0/1 背包:for(j = W; j >= w[i]; j--) 倒着走
完全背包:for(j = w[i]; j <= W; j++) 正着走

两个写法并排看

下面左右两段代码,只有箭头方向这一个字符不一样

0/1 背包(每种最多 1 件) 完全背包(每种无限件)
for(int i = 1; i <= n; i++)
    for(int j = W; j >= w[i]; j--)   // 倒序 ↓
        dp[j] = max(dp[j],
                    dp[j - w[i]] + v[i]);
for(int i = 1; i <= n; i++)
    for(int j = w[i]; j <= W; j++)   // 正序 ↑
        dp[j] = max(dp[j],
                    dp[j - w[i]] + v[i]);
🚫 这是整个背包问题里最容易记混的一处

如果你把两个方向记反了:

· 该用倒序的题写成正序 → 变成了完全背包 → 每种物品被重复使用 → 答案偏大(WA)
· 该用正序的题写成倒序 → 变成了 0/1 背包 → 每种物品只能用一次 → 答案偏小(WA)

所以不要死记,要理解下面第 2 节的手推过程。理解了就再也不会记反。

两者对比一览表

对比项0/1 背包完全背包
每种物品能选几次最多 1 次无限次
内层容量循环方向倒序(W → w[i])正序(w[i] → W)
内层用到的 dp[j-w[i]]上一轮(没用过 i)的值本轮(可能已经用过 i)的值
典型题目P1048 采药P1616 疯狂的采药
时间复杂度O(n·W)O(n·W)
✅ 好消息:完全背包并没有比 0/1 背包更慢

两者都是 O(n·W),都是一维数组。你不需要为了「无限件」多付任何代价。 这就是为什么「改一个方向」是标准做法,而不是什么奇技淫巧。

2为什么「正序」就等于「无限件」——把 dp 数组打出来看

光看代码是记不住的,我们把中间过程打印出来。用一个最小的例子: 只有 1 种物品,花费 w = 2,价值 v = 3,总容量 W = 6

情况一:倒序(0/1 背包)

从 j = 6 一路走到 j = 2,下面是每一步执行完之后的 dp 数组(下标 0~6):

这一步dp[0]dp[1]dp[2]dp[3]dp[4]dp[5]dp[6]
初始0000000
j = 60000003
j = 50000033
j = 40000333
j = 30003333
j = 20033333

最终 dp[6] = 3。 也就是「只装得下 1 件」——完全符合 0/1 背包的定义。

关键在 j = 4 那一步:dp[4] = max(dp[4], dp[2] + 3)。此时 dp[2] 还没被更新(因为倒序, 下标 2 排在 4 后面才轮到),所以读到的还是初始值 0,于是 dp[4] = 3
每一步都读到「没装过这件物品」的旧值 → 这件物品最多被用 1 次。

情况二:正序(完全背包)

从 j = 2 一路走到 j = 6:

这一步dp[0]dp[1]dp[2]dp[3]dp[4]dp[5]dp[6]
初始0000000
j = 20030000
j = 30033000
j = 40033600
j = 50033660
j = 60033669

最终 dp[6] = 9 = 3 × 3,正好是「装了 3 件」!

看 j = 4 那一步:dp[4] = max(dp[4], dp[2] + 3)。这次 dp[2] 已经被更新过了 (第 j = 2 步时就变成了 3),所以读到的值是 3,于是 dp[4] = 3 + 3 = 6——相当于「又装了一件」。
j = 6 同理:读到的 dp[4] = 6,再加 3 得到 9。

✅ 一句话记住

正序枚举时,dp[j - w[i]] 是本轮刚刚更新过的值,里面可能已经包含了物品 i; 所以「再加一件 i」是允许的 → 无限件。

倒序枚举时,dp[j - w[i]] 还是上一轮的旧值,绝对没有物品 i; 所以「再加一件 i」相当于第一次用它 → 最多 1 件。

💡 上面这两张表是真的跑出来的

我写了个小程序把每一步的 dp 数组打印出来(就是上面表格里的数字), 不是手算的。你也可以自己试:把 w、v、W 改掉,看看方向不同会不会导致结果不同。

3开数组之前先算命:数组多大、要不要 long long

背包题的代码都很短,真正让人 WA 的是数据范围。今天两道题都卡在这里。

第一步:数组开多大?

一维背包的数组长度 = 容量上限 + 1(因为要放下标 0)。

题目容量上限数组至少多大用 int 时的内存
P1616 疯狂的采药t ≤ 10⁷10⁷ + 1约 40 MB
P1802 5 倍经验日x ≤ 10³10³ + 1约 4 KB
P1466 集合half ≤ 780/2 = 390391约 1.5 KB
⚠️ 但是今天 P1616 和 P1802 的 dp 数组都不能用 int

内存只是问题的一半,另一半是数值会不会溢出。见下面。

第二步:会不会溢出?(这一步必须做)

做法:拿「最大答案」和 int 的上限(约 2.147×10⁹)比一比。

题目最大答案怎么估估算值int 够吗
P1616 ai 最小 1、bi 最大 10⁴,时间最多 10⁷ → 全采最划算的那种:10⁷ × 10⁴ 10¹¹ ❌ 必须 long long
P1802 n ≤ 10³、wini ≤ 10⁶ → s ≤ 10³ × 10⁶ = 10⁹;但题目要求输出 5s 5×10⁹ ❌ 必须 long long
P1466 n = 39 时方案数(本题实测) 1512776590 ⚠️ 勉强够,但保险起见用 long long
P1387 边长最大 100 100 ✅ int 绰绰有余
🚨 P1802 这个坑特别阴

你算 s 的时候会想:「n ≤ 1000,每人最多 10⁶ 经验,那 s 最多 10⁹,int 装得下啊」——

但题目要你输出的是 5s,也就是 5×10⁹,这就超过 int 上限 2.147×10⁹ 了。 很多人 dp 数组开了 long long,最后却写 printf("%d", dp[x] * 5) 或者 int ans = dp[x] * 5;,一样溢出。

结论:dp 数组、5 倍运算、输出,全程都得是 long long。

第三步:P1616 的内存账

vector<long long> dp(t + 1, 0);      // t = 10^7 时:10^7 × 8 字节 = 80 MB

算法本身是 O(m·t) 次操作,但空间要单独算一笔账long long 占 8 字节, 而 int 只占 4 字节。t 取到上限 10⁷ 时,光这一个数组就要 80 MB

⚠️ 这里有一个我不能从题面确认的地方

本题的题面和数据范围里并没有给出内存限制,所以我无法确认 80 MB 一定放得下。 一般竞赛题目的内存限制在 128 MB 或 256 MB 这个量级,80 MB 通常没问题 —— 但这是我基于常见情况的判断,不是题面的保证。你在洛谷提交前可以自己看一眼题目的内存限制。

反正代码是对的:我实测 t = 10⁷ 这组极限数据能正常跑完并输出正确结果。

💡 那能不能改用 int 省一半内存?

不行。用 int 时 dp 里的值最大会到 10¹¹,早就溢出了。这题的内存就是拿 long long 换来的,省不掉。

(顺带一提,如果你真的遇到内存吃紧的情况,思路是「把答案换个表示方法」,而不是把 dp 改成 int。)

🎯 为什么 P1616 的 O(m·t) 不会超时?

看它的数据范围里藏着一句关键的话:1 ≤ m × t ≤ 10⁷

这句话的意思是:m 和 t 不可能同时很大。t 最大 10⁷ 时 m 只能是 1;m 最大 10⁴ 时 t 最多只有 10³。

而我们的双重循环次数正好就是 m × t ≤ 10⁷ —— 1 千万次操作,对 1 秒的时限来说非常轻松。

这就是为什么这题看着数据很吓人(t 到 10⁷、m 到 10⁴),却只用最朴素的完全背包就能过。 读数据范围时,一定要把每个数字和别的数字连起来看。

4从背包长出来的两种新题型

4.1 多决策背包(P1802 的做法)

普通背包每件物品只有两个选择:不拿。今天 P1802 是:

决策花费收益
这个人打输了0 个药losei
这个人打赢了usei 个药wini

注意「打输」也要花 0 个药、也能拿经验(题目里 losei > 0)。所以每个人不管怎样都有收益, 区别只是「要不要花药去拿更多」。

转移就很自然了:

long long a = dp[j] + lose[i];              // 这个人打输:不花药,拿 lose
long long b = -1;
if(j >= use[i])
    b = dp[j - use[i]] + win[i];            // 这个人打赢:花 use 个药,拿 win
dp[j] = max(a, b);                          // 两个决策取最优 —— 这就是「多决策」
⚠️ 两个必须注意的地方

1. 外层容量要倒序。 因为每个人只能被打一次,本质还是 0/1 背包(每个人是一件「最多用一次」的物品)。

2. 药不够的时候也要加 losei 也就是 j < use[i] 时不能跳过这个人, 他还是会贡献 losei。写成 if/else 只处理一半就错了。

🎯 「多决策」是一个通用套路

以后你还会见到「不选 / 选一半 / 全选」「不买 / 买 1 个 / 买 2 个」这类题。 做法都一样:把每个决策写成一条转移式子,外面套一层 max(或 min,或加法)。

4.2 计数型背包 / 「恰好装满」(P1466 的做法)

背包问题有三种常见问法,转移方程长得像但意思完全不同:

问法dp 的含义转移dp[0] 初值
最大价值(P1048、P1616)最大价值dp[j] = max(dp[j], dp[j-w]+v)0
方案数(P1466、P1164)方案总数dp[j] += dp[j-w]1
可行性(能不能凑出)true / falsedp[j] |= dp[j-w]true
🚫 计数型的两条铁律

铁律一:dp[0] = 1 意思是「凑出和 0 有 1 种方案:什么都不选」。 如果你写 dp[0] = 0,那么所有 dp 值都会是 0,因为加法是从 dp[0] 推出去的。

铁律二:加法要倒序。 每个数(或每件物品)只能用一次,所以内层还是倒序。 正序的话方案数会被重复统计。

P1466 就是这个套路的应用:把「划分成两个和相等的集合」变成「从 1~n 里挑出若干个数、让它们的和恰好是总和的一半」。 挑出来的那一堆就是一个集合,剩下的自动就是另一个集合。

5二维 DP:从「左、上、左上」推出当前格(P1387)

今天最后一道题换了个维度:状态要带两个下标 dp[i][j],表示网格里的位置

题意转化

要找「不含 0 的最大正方形」的边长。如果直接枚举左上角和边长再逐格检查,复杂度是 O(n³·面积),n、m ≤ 100 时能过但很笨。

聪明的做法是:让 dp[i][j] 表示「以 (i, j) 这个格子为右下角,能构成的最大全 1 正方形的边长」。

🎯 核心转移式(背下来)

如果 a[i][j] == 0dp[i][j] = 0(这个格子是 0,不可能当全 1 正方形的右下角)

如果 a[i][j] == 1dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1

答案 = 所有 dp[i][j] 里的最大值。

为什么是「三个方向取最小」

假设你想让 (i, j) 当右下角、边长做到 k。那么它必须同时满足三件事:

  • 正上方那一格 (i−1, j) 当右下角,能撑起边长 k−1 的正方形;
  • 左边那一格 (i, j−1) 当右下角,能撑起边长 k−1 的正方形;
  • 左上那一格 (i−1, j−1) 当右下角,能撑起边长 k−1 的正方形。

三个条件必须同时成立,也就是取它们的最小值;然后加上自己这一格,边长就是 min + 1。

方向状态它代表的那块正方形在哪
dp[i-1][j]「正上方」那块(宽 k−1,贴着当前格的左侧)
dp[i][j-1]「左边」那块(高 k−1,贴着当前格的上侧)
左上dp[i-1][j-1]「左上角」那块(完全在内部)
🚨 最经典的错法:min 里只写了两个

写成 min(dp[i-1][j], dp[i][j-1]) + 1,漏掉 dp[i-1][j-1]

为什么错?考虑这个 2×3 的矩阵(本来是 2 行):

1 1 1
1 0 1

到 (2,3) 时,左边 dp[2][2] = 0、上面 dp[1][3] = 3,只取这两个的 min 是 0,结果 dp[2][3] = 1,还算对。 但换一个形状就会出问题:

1 1 0 1
1 1 1 1
1 1 ? 

这种「左边很大、上面很大,但左上角那块其实缺口」的情形,只有带上 dp[i-1][j-1] 才能正确拦住。 三个状态一个都不能少。

💡 从 (1,1) 开始存,边界自动是 0

把矩阵存进 a[1..n][1..m],dp 同样从 1 开始。这样 dp[0][*]dp[*][0] 全自动是 0(全局数组默认值),不需要手写任何边界特判

注意数组要开 [105][105] 而不是 [100][100] —— 下标要用到 n 和 m,多留 5 个位置最稳。

6四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译通过(零警告)并跑过官方样例,输出是你看到的真实结果。

P1616 疯狂的采药 普及- · 完全背包模板

题意:山洞里有 m 种草药,采第 i 种要花 ai 的时间,能得到 bi 的价值。 总共有 t 的时间。每种草药可以无限制地采。问最多能采到多少总价值。

它就是昨天 P1048 采药的改编版,唯一的改动是「每种可以无限采」。

⚠️ 数据范围(原文)

1 ≤ m ≤ 10⁴,1 ≤ t ≤ 10⁷,且 1 ≤ m × t ≤ 10⁷,1 ≤ ai, bi ≤ 10⁴。
另有 30% 的数据保证 m ≤ 10³。

两个关键推论
m × t ≤ 10⁷ 说明双重循环最多转 10⁷ 次,O(m·t) 完全够用;
② 最大答案可达 10⁷ × 10⁴ = 10¹¹必须用 long long

思路

完全背包模板题。一维滚动数组,外层枚举草药种类,内层正序枚举时间。

注意输入顺序:第一行是 t 在前、m 在后(先时间,后草药种类数),别读反。

样例输入
70 3
71 100
69 1
1 2
样例输出
140

后两种草药一个要 69 时间只值 1,一个要 1 时间值 2。最优解是采第 3 种 70 次: 70 时间、70 × 2 = 140 价值。(第 1 种要 71 时间,超了,一次都采不了。)

完整代码

#include <bits/stdc++.h>
using namespace std;

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

    int t, m;
    cin >> t >> m;                       // ★ 第一行是「时间 种类数」

    vector<int> w(m + 1), v(m + 1);
    for(int i = 1; i <= m; i++)
        cin >> w[i] >> v[i];

    // dp[j] = 时间限制为 j 时能采到的最大总价值(用 long long!)
    vector<long long> dp(t + 1, 0);

    for(int i = 1; i <= m; i++){
        // ★ 完全背包:容量正序枚举,j 从小到大
        for(int j = w[i]; j <= t; j++){
            if(dp[j - w[i]] + v[i] > dp[j])
                dp[j] = dp[j - w[i]] + v[i];
        }
    }

    cout << dp[t] << "\n";
    return 0;
}
✅ 实测结果

官方样例 70 3 / 71 100 / 69 1 / 1 2 → 输出 140 完全一致

极限规模 10000000 1 / 1 10000(t = 10⁷,是数据范围的上限)→ 输出 100000000000(10¹¹),约 80 ms 跑完
这个结果同时也证明了 long long 是必须的:10¹¹ 用 int 早就溢出了。

另做了 60 组随机小数据,和一份「二维完全背包」的独立实现逐一对照,结果零差异

易错点

  • 内层写成倒序 → 退化成 0/1 背包,每种草药只能采一次 → 答案偏小。
  • dp 用 int → 答案可达 10¹¹,直接溢出成负数或错误值。
  • 第一行读反:题目是 t m(时间在前),写成 cin >> m >> t 就全错了。
  • 数组开成 dp[t] 而不是 dp[t+1] → 下标 t 越界。
  • 这题用 cin/cout 时建议加 ios::sync_with_stdio(false),数据量大时更稳(我的代码加了)。
P1802 5 倍经验日 普及/提高- · 多决策背包

题意:有 n 个好友和 x 个「属性药」。对第 i 个人:打输了得 losei 经验; 想打赢至少要花 usei 个药,赢了得 wini 经验。 药花得不够 usei必输,而且那些药也白白浪费。

求最多能拿到的经验 s,然后输出 5s

⚠️ 数据范围(原文)

0 ≤ n, x ≤ 10³;0 < losei ≤ wini ≤ 10⁶;0 ≤ usei ≤ 10³。

(另有 10% 的数据保证 x = 0。)

关键:s 最大约 10³ × 10⁶ = 10⁹,看着 int 装得下;但题目要输出 5s = 5×10⁹超过 int 上限 2.147×10⁹ —— 必须全程 long long。

思路

把「每个好友」看成一件物品,但它的决策有两个

决策花费收益
打输0losei
打赢useiwini

因为每个人只打一次,本质是 0/1 背包,所以容量倒序枚举。转移时对两个决策取 max。

另外注意 usei = 0 的情况:不花药也能赢,而 wini ≥ losei, 所以公式会自动选「打赢」,不需要特判。

样例输入
6 8
21 52 1
21 70 5
21 48 2
14 38 3
14 36 1
14 36 2
样例输出
1060

6 个人全打输保底 21+21+21+14+14+14 = 105 点。
用 8 个药去赢第 1、2、3 个人(花 1+5+2 = 8 个药),比打输多赚 (52−21)+(70−21)+(48−21) = 31+49+27 = 107 点。
总共 105 + 107 = 212,乘 5 就是 1060

完整代码

#include <bits/stdc++.h>
using namespace std;

int main(){
    int n, x;
    cin >> n >> x;

    vector<int> lose(n + 1), win(n + 1), use(n + 1);
    for(int i = 1; i <= n; i++)
        cin >> lose[i] >> win[i] >> use[i];

    // dp[j] = 药最多用 j 个时,累计获得的最大经验
    vector<long long> dp(x + 1, 0);      // ★ long long

    for(int i = 1; i <= n; i++){
        // 0/1 背包:容量倒序枚举,保证每个人只被考虑一次
        for(int j = x; j >= 0; j--){
            long long a = dp[j] + lose[i];          // 这个人打输了
            long long b = -1;
            if(j >= use[i])                          // 药够,可以打赢
                b = dp[j - use[i]] + win[i];
            dp[j] = (a > b ? a : b);                 // ★ 两个决策取最优
        }
    }

    cout << dp[x] * 5 << "\n";       // ★ 输出 5 倍,必须 long long
    return 0;
}
💡 为什么最后直接输出 dp[x] 就行?

因为「多花药不会有坏处」,所以 dp[j] 随 j 增大是单调不减的。 药最多有 x 个,那当然用满最划算,答案就是 dp[x]。

(严格说 dp[j] 表示「最多用 j 个药」,用不满也没关系,它会自动取到最优。)

✅ 实测结果

官方样例 → 输出 1060 完全一致

边界 x = 0(题目里 10% 的数据就是这种):输入 1 0 / 5 10 3 → 输出 25(一个药都没有,只能打输拿 5 点经验,5 × 5 = 25,正确。)

另做了 60 组随机小数据,和「2ⁿ 枚举每个人赢/输」的暴力程序逐一对照,结果零差异

易错点

  • 只写了「打赢」的分支,药不够时忘了加 losei → 答案偏小。
  • 最后乘 5 时用了 int → 5×10⁹ 溢出。dp 是 long long 也没用,要保证乘出来还是 long long。
  • 容量写成正序 → 一个人被反复打了,答案偏大。
  • 误以为「打输就不打这个人了」。题目里 6 个人是要全打的,打输也有 losei 经验。
  • usei 可以为 0,dp[j - use[i]] 就是 dp[j] 本身,这里的顺序要想清楚(我的代码用 a、b 两个临时变量,所以没问题)。
P1466 [USACO2.2] 集合 Subset Sums 普及/提高- · 计数型 DP

题意:把集合 {1, 2, 3, …, n} 划分成两个子集合,要求两个子集合里所有数字的和相等。 问一共有多少种划分方案。交换两个集合的位置算同一种方案。

⚠️ 数据范围(原文)

对于 100% 的数据,1 ≤ n ≤ 39

n = 39 时总和 39 × 40 / 2 = 780,一半是 390,所以 dp 数组只要开 391 个就够了 —— 非常小。

思路

第一步:把问题翻译一下。

两个集合和相等,那么每个集合的和都必须是总和的一半。设 sum = n(n+1)/2。

🚨 如果 sum 是奇数,答案是 0

总和是奇数时,不可能分成两个整数和相等的集合,直接输出 0。 这一步不能省:如果你不判断,half = sum / 2 会向下取整, 程序会去数「和为 ⌊sum/2⌋ 的子集」,输出一个毫无意义的错误答案。

第二步:转化成背包。 从 1 ~ n 里挑出若干个数字,让它们的和恰好等于 half —— 这问的是方案数

dp[j] = 从 {1..n} 中选出若干个数、和恰好为 j 的方案数
转移:  dp[j] += dp[j - i]        // 数字 i 选或不选
初值:  dp[0] = 1                 // 「一个都不选」只有 1 种

每个数字只能用一次,所以内层倒序

第三步:为什么要除以 2。

dp[half] 数的是「和为 half 的子集有多少个」。假设某个子集是 S,那它的补集就是另一半, S 和补集是同一个划分方案的两种写法。所以每个划分方案在 dp[half] 里被数了两次, 答案要除以 2。

💡 用小例子验证一下

n = 3,sum = 6,half = 3。和为 3 的子集有两个:{3}{1,2}
所以 dp[3] = 2,除以 2 得 1 —— 而题目原话也正是「{3} 和 {1,2} 是唯一一种分法」。✅

样例输入
7
样例输出
4

n = 7 时 sum = 28,half = 14。和为 14 的子集有 8 个(就是题目列的 4 种划分 × 2 种写法), dp[14] = 8,除以 2 得 4

完整代码

#include <bits/stdc++.h>
using namespace std;

int main(){
    int n;
    cin >> n;

    int sum = n * (n + 1) / 2;
    if(sum % 2 != 0){                 // ★ 总和是奇数,不可能平分
        cout << 0 << "\n";
        return 0;
    }
    int half = sum / 2;

    // dp[j] = 从 {1..n} 中选出若干数、和恰好为 j 的方案数
    vector<long long> dp(half + 1, 0);
    dp[0] = 1;                        // ★ 一个都不选,和为 0,算 1 种

    for(int i = 1; i <= n; i++)
        for(int j = half; j >= i; j--)   // 每个数只能用一次 -> 倒序
            dp[j] += dp[j - i];

    cout << dp[half] / 2 << "\n";     // ★ 两个集合互换算同一种,除以 2
    return 0;
}
✅ 实测结果

官方样例 7 → 输出 4 完全一致

边界测试:
· 10(sum = 1 是奇数,走特判分支)
· 31(题目原文里说「{3} 和 {1,2} 是唯一一种分法」)
· 39(数据范围上限)→ 1512776590

因为 n = 39 这组数据我没有官方答案可以对照,所以我又写了一份完全不同的程序 —— 用「折半枚举(meet in the middle)」把 1~39 拆成两半、 枚举所有子集和再用二分配对。两份代码在 n = 7 / 1 / 3 / 39 上结果完全一致

易错点

  • 忘记除以 2 → 输出恰好是正确答案的两倍。
  • 忘记判断 sum 是奇数 → 会数出一个错误的非零答案。
  • dp[0] 忘了设成 1 → 所有 dp 值都是 0,答案恒为 0。
  • 内层写成正序 → 同一个数字被反复用,方案数暴涨。
  • sum 用 int 算没问题(最大 780),但 dp 里的方案数要 long long(n = 39 时是 15 亿)。
  • 内层循环下界写成 j >= 0j >= 1:因为 j - i 必须 ≥ 0,所以条件是 j >= i,写成别的会数组越界。
P1387 最大正方形 普及/提高- · 二维 DP

题意:给一个 n × m 的、只含 0 和 1 的矩阵,找出一个不包含 0 的最大正方形, 输出它的边长。题目保证矩阵里至少有一个 1。

⚠️ 数据范围(原文)

输入第一行为两个整数 n, m(1 ≤ n, m ≤ 100),接下来 n 行每行 m 个用空格隔开的 0 或 1。

数据很小,所以二维数组开 105 × 105 绰绰有余。答案最大是 100,int 完全够。

思路

dp[i][j] = 以 (i, j) 为右下角的最大全 1 正方形的边长。

  • a[i][j] == 0dp[i][j] = 0(自己是 0,当不了全 1 正方形的角)
  • a[i][j] == 1dp[i][j] = min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + 1

最后扫一遍所有 dp 值取最大。复杂度 O(n·m) = 10⁴,飞快。

样例输入
4 4
0 1 1 1
1 1 1 0
0 1 1 0
1 1 0 1
样例输出
2

最大的全 1 正方形是左上角那块 2×2((1,2)、(1,3)、(2,2)、(2,3)),边长 2。 没有更大的了,所以输出 2。

完整代码

#include <bits/stdc++.h>
using namespace std;

int a[105][105];
int dp[105][105];        // 全局数组,自动全部初始化为 0

int main(){
    int n, m;
    cin >> n >> m;
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++)
            cin >> a[i][j];

    int ans = 0;
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            if(a[i][j] == 1){
                // 左、上、左上 三个状态取最小,再加 1
                int mn = min(min(dp[i - 1][j], dp[i][j - 1]), dp[i - 1][j - 1]);
                dp[i][j] = mn + 1;
                if(dp[i][j] > ans) ans = dp[i][j];
            }
        }
    }

    cout << ans << "\n";
    return 0;
}
✅ 实测结果

官方样例 → 输出 2 完全一致

边界测试 3 3 全 1 矩阵 → 输出 3(整个矩阵就是正方形)。

另做了 60 组随机小数据(n、m ≤ 6),和「枚举每个左上角 + 每个边长 + 逐格检查」的暴力程序 逐一对照,结果零差异

易错点

  • min 里只写两个状态(漏掉 dp[i-1][j-1])→ 某些形状下边长算大,WA。三个都要写。
  • 输出面积而不是边长:题目要的是边长,别写成 ans * ans
  • dp 定义成局部数组 int dp[105][105];(在 main 里)→ 不会自动清零,边界值成了随机数。放全局就自动是 0。
  • 数组开成 a[100][100]:下标要用到 n = 100,会越界。开 105 留余量。
  • a[i][j] == 0 时不用手动赋值,全局数组本来就是 0;但如果你为了省事把 dp 清成别的数,这里就得补一句 dp[i][j] = 0;

今日常见错误汇总

#错误写法后果正确写法
1 完全背包内层写成
for(j=W; j>=w[i]; j--)
退化成 0/1 背包,每件只能用一次 → 答案偏小 for(j=w[i]; j<=W; j++)
2 0/1 背包内层写成
for(j=w[i]; j<=W; j++)
升格成完全背包,每件能被用无限次 → 答案偏大 for(j=W; j>=w[i]; j--)
3 dp 数组用 int P1616 答案有 10¹¹、P1802 要输出 5×10⁹
→ 溢出,答案变负数或乱码
long long dp[...]
4 P1802 只在药够时更新 漏掉了「打输也有 losei 经验」→ 答案偏小 两个决策都写,再取 max
5 P1466 忘记 dp[half] / 2 输出正好是答案的 2 倍 最后除以 2
6 P1466 不判断总和是奇数 half 被截断,数出无意义的方案数 if(sum % 2) { cout << 0; return 0; }
7 计数型 DP 的 dp[0] 设成 0 所有 dp 值都推不出来,答案恒为 0 dp[0] = 1;(最值型才是 0)
8 P1387 的 min 只写两个状态 边长算大 → WA 左、上、左上 三个都要进 min
9 数组开成 dp[W] / a[100][100] 下标 W 或 100 越界 → 玄学 RE / WA W+1 / 105,永远留余量
10 P1616 第一行读成 m t 时间和种类数调换,全盘皆错 题目是 t m(时间在前)

自测:你真的会了吗

第 1 题:下面这段代码是 0/1 背包还是完全背包?
for(int i = 1; i <= n; i++)
    for(int j = w[i]; j <= W; j++)
        dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

完全背包。 内层 j 从小到大(正序), 所以 dp[j - w[i]] 读到的是本轮已更新的值,物品 i 可以被反复使用。

第 2 题:为什么完全背包内层用正序就能表示「无限件」?请说出理由,不要背结论。

:一维数组是「滚动」的,同一个 dp 数组在本次外层循环里被反复覆盖。

· 正序时,算 dp[j] 用到的 dp[j - w[i]] 因为下标更小、已经在本轮被更新过, 它里面很可能已经装了若干个物品 i。于是 dp[j] = dp[j-w[i]] + v[i] 相当于「在已经装了 i 的基础上再装一件 i」 → 允许无限件。
· 倒序时,下标更小的 dp[j - w[i]] 还没轮到,仍是上一轮的旧值, 里面绝不含物品 i,所以只能「第一次装 i」→ 最多 1 件。

w=2, v=3, W=6 实测:倒序得到 dp[6] = 3(1 件),正序得到 dp[6] = 9(3 件)。

第 3 题:P1466 里,为什么最后要除以 2?n = 4 时答案是多少?

dp[half] 数的是「和为 half 的子集个数」。设子集为 S,它的补集也是和为 half 的子集, 而 {S, 补集} 是同一个划分方案。所以每个方案被数了两次,要除以 2。

n = 4 时 sum = 10,half = 5。和为 5 的子集有 {1,4} 和 {2,3},只有 2 个,除以 2 得 1。 (划分就是 {1,4} 和 {2,3},确实只有一种。)

第 4 题:P1387 的转移式为什么要取三个状态的 min?

:想让 (i, j) 当右下角、边长做到 k,必须同时满足:上方那块能撑起 k−1、左边那块能撑起 k−1、 左上那块能撑起 k−1。三个条件同时成立,所以取最小;再加上自己这一格,边长 = min + 1。

漏掉任何一个,都可能在某些形状下把边长算大。比如左边和上边都很长、但左上角那块有缺口的情形, 只有 dp[i-1][j-1] 能拦住它。

第 5 题(手写代码):把 0/1 背包改成完全背包,一共要改几个地方?

只改内层循环的方向,其它一个字都不用动。

// 0/1 背包
for(int j = W; j >= w[i]; j--)

// 完全背包
for(int j = w[i]; j <= W; j++)

★ 注意连「起止点」也是配套的:倒序从 W 到 w[i],正序从 w[i] 到 W。不要只把 -- 改成 ++ 却忘了调整起点,那样循环会从 W 一直加到无穷(数组爆炸)。

第 6 题:什么时候必须用 long long?给一个判断方法。

:把「最大可能的答案」估出来,和 int 的上限 2147483647(约 2.147×10⁹)比较。

· P1616:时间上限 10⁷ × 单件价值上限 10⁴ = 10¹¹ > 2.147×10⁹ → 用 long long
· P1802:s ≤ 10⁹ 看着安全,但输出 5s = 5×10⁹ → 用 long long
· P1466:n = 39 时方案数 1512776590,接近但没超 → 保险起见也建议 long long
· P1387:边长最大 100 → int 足够

★ 估算的时候别忘了题目可能让你对答案做运算(比如乘 5)。

速查卡(截图存手机)

【0/1 背包 · 每种最多 1 件】
  for(int i = 1; i <= n; i++)
      for(int j = W; j >= w[i]; j--)          // 倒序
          dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

【完全背包 · 每种无限件】
  for(int i = 1; i <= n; i++)
      for(int j = w[i]; j <= W; j++)          // 正序(只改这里!)
          dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

  // 记法:正序 = 可以反复拿同一个东西;倒序 = 每个只能拿一次

【多决策背包 · 每个物品多个选择】
  dp[j] = max( 决策1, 决策2, ... );
  // 例:dp[j] = max(dp[j] + lose[i], dp[j-use[i]] + win[i]);

【计数型背包 · 方案数】
  dp[0] = 1;                                  // ★ 不能写成 0
  dp[j] += dp[j - w[i]];                      // 内层倒序

【恰好装满的判断】
  目标和是奇数(或除不尽)→ 直接输出 0
  两个集合互换算一种 → 最后答案 / 2

【二维 DP · 最大全 1 正方形】
  if(a[i][j] == 1)
      dp[i][j] = min(min(dp[i-1][j], dp[i][j-1]),
                     dp[i-1][j-1]) + 1;       // ★ 三个都要写
  ans = max(ans, dp[i][j]);                   // 答案是边长,不是面积

【开数组铁律】
  一维 dp 长度 = 容量上限 + 1
  二维 dp 开 [n + 5][m + 5]
  答案估一遍:超过 2.1×10⁹ → long long

明天预告 · Day 28

栈与队列:STL 容器

DP 先告一段落,明天换一个完全不同的方向:用现成的容器解决问题。 你会学到 std::stackstd::queuestd::priority_queue 的接口, 并用它们写出「括号匹配」和「后缀表达式求值」这两个经典模板。

今天学的背包是一维数组上的技巧,明天的栈和队列是后面 BFS、单调栈、 表达式解析等一大堆内容的地基 —— 接口一定要练到不用查文档就能写出来。

ACM 一个月学习计划 · DAY 28

Day 28 · 栈与队列:STL 容器

栈是「一摞盘子」,队列是「排队买饭」—— 两种最简单的规则,却能解决括号匹配、表达式求值这类经典问题

今日目标:背熟 stack / queue / priority_queue 的接口,会写括号匹配和后缀表达式求值
预计用时:约 3 小时(含做题)  难度:普及-
题目来源:洛谷 B3614 / B3616 / P1449 / P1739
DAY 28 / 30

今日时间安排

0 - 30 min理解 LIFO / FIFO 两种规则,背下接口(第 1、2 节)
30 - 45 min三个容器的接口对照表(第 3 节)—— 考试前扫一眼
45 - 75 min括号匹配 + 后缀表达式两个模板(第 4、5 节)
75 - 150 min做 4 道题,每题限时 20 分钟
150 - 180 min对照本文题解补题 + 做自测
💡 和前面内容的衔接

从 Day 1 到今天,你用的容器只有「数组」。数组的特点是:想访问哪里就访问哪里a[7] 随便读), 但你也得自己管下标。

今天要学的两个容器刚好相反:你只能碰「一头」。听起来像是限制,但正是这种限制让某些问题变得特别简单 —— 比如「最近出现的那个还没配对的左括号是哪个」,答案显然就是「最后一个」,也就是栈顶。

1栈 stack:后进先出(LIFO)

先打个比方

食堂里有一摞干净盘子。阿姨洗完一个就往最上面放一个;你要拿盘子,也只能从最上面拿。 最后放上去的那个,最先被拿走。

这就是栈:Last In First Out(后进先出),简称 LIFO。 能操作的那一端叫栈顶(top),另一端叫栈底。

操作写法作用
入栈s.push(x)把 x 放到栈顶
出栈s.pop()把栈顶元素扔掉(不返回它!
看栈顶s.top()返回栈顶元素的值(不删除
判空s.empty()栈空返回 true
大小s.size()栈内元素个数
🚨 两个新手必踩的坑

坑 1:pop() 不会返回被删掉的元素。 这跟很多语言(比如 Python 的 list.pop())不一样。你要先 top() 看一眼, 再 pop() 删掉。这两步必须分开写:

int x = s.top();   // 先拿到值
s.pop();           // 再删掉

坑 2:栈空的时候不能 top() 也不能 pop() 对空栈调用这两个函数是未定义行为(可能崩溃、可能读到垃圾值,也可能这次碰巧没事下次就 RE)。 铁律:动手之前先 if(!s.empty())

实际跑一遍看看

下面这段代码我实际编译运行过,输出是真实的:

stack<int> s;
cout << "empty? " << (s.empty() ? "是" : "否") << "\n";
s.push(1); s.push(2); s.push(3);
cout << "size=" << s.size() << " top=" << s.top() << "\n";
s.pop();
cout << "pop 后 size=" << s.size() << " top=" << s.top() << "\n";
while(!s.empty()){ cout << "弹出 " << s.top() << "\n"; s.pop(); }
cout << "清空后 empty? " << (s.empty() ? "是" : "否") << "\n";

真实输出:

empty? 是
size=3 top=3
pop 后 size=2 top=2
弹出 2
弹出 1
清空后 empty? 是
✅ 看懂了什么

依次 push 了 1、2、3,栈顶是 3(最后进的)。
pop 一次以后栈顶变成 2,size 从 3 变成 2。
清空时弹出的顺序是 2、1 —— 和进来的顺序(1、2、3)完全相反。这就是「后进先出」。

顺便注意那个 while(!s.empty()):这是清空栈的标准写法。 C++ 的 stack 没有 clear() 函数(这是个常见的抱怨),只能这样一个个弹掉。

💡 用栈的时候脑子里要有画面

把栈竖着画:push 是往上叠,pop 是从上面拿走,top 是看最上面那个。 想不清楚的时候就在草稿纸上竖着写一列数字,画个箭头,比死看代码有用得多。

2队列 queue:先进先出(FIFO)

比方

食堂打饭的队伍。新来的人站在队尾,打饭的阿姨从队首叫下一个人。 先来的先走。

这就是队列:First In First Out(先进先出),简称 FIFO。

操作写法作用
入队q.push(x)把 x 放到队尾
出队q.pop()队首元素扔掉(同样不返回值)
看队首q.front()返回队首元素(下一个轮到谁)
看队尾q.back()返回队尾元素(最后进来的那个)
判空q.empty()队列空返回 true
大小q.size()队列内元素个数
⚠️ 队列最容易记错的一点

队列是两头都能看,但只有一头能进、另一头能出

· push 永远加在 back(队尾)
· pop 永远删掉 front(队首)

所以「frontback 是两个不同的元素」(除非队列里只有一个)。 刚学的时候很容易以为 push 进去马上就能 front 出来。

实际跑一遍

queue<int> q;
q.push(10); q.push(20); q.push(30);
cout << "size=" << q.size()
     << " front=" << q.front() << " back=" << q.back() << "\n";
q.pop();
cout << "pop 后 front=" << q.front()
     << " back=" << q.back() << " size=" << q.size() << "\n";

真实输出:

size=3 front=10 back=30
pop 后 front=20 back=30 size=2

依次 push 了 10、20、30。队首是 10(先进来的),队尾是 30(最后进来的)。
pop 一次以后,10 出去了,队首变成 20,队尾还是 30。先进先出,顺序和进来时一样。

🎯 队列真正的用武之地在 Day 20

今天你只是拿队列做「模拟排队」这种直白的题。但队列真正的威力在 BFS(广度优先搜索): BFS 要「一层一层往外扩」,而「先发现的先处理」正好就是队列的规则。

今天把接口练熟,到时候写 BFS 就只管想逻辑,不用再纠结语法。

3三个容器接口对照表(截图存手机)

今天还要认识第三个容器 priority_queue(优先队列)。 它不在今天的 4 道题里,但接口必须现在背下来 —— 后面贪心、最短路(Dijkstra)都会用到它。

想做的事 stack queue priority_queue
头文件<stack><queue><queue>
加入元素push(x)push(x)push(x)
删除元素pop()pop()pop()
看「下一个是谁」top()front()top()
看另一端—(没有)back()—(没有)
判空empty()empty()empty()
元素个数size()size()size()
规则后进先出 LIFO先进先出 FIFO每次出最大
🚨 最容易记混的一格:top 还是 front?

stackpriority_queuetop(); 只有 queuefront()

记法:栈和优先队列都是「一堆东西里取出最该拿的那个」→ top;队列是「排队」→ 看队首 front。

写错了编译器会直接报错('class std::queue<int>' has no member named 'top'), 不算致命,但考场上白白浪费时间。

priority_queue 默认是「大根堆」

它的规则是:不管谁先进来,出队时永远是当前最大的那个先出。 我用一段代码实测过:

// 默认写法:大根堆,top() 是最大值
priority_queue<int> pq;
for(int x : {5, 1, 9, 3, 7}) pq.push(x);
cout << "top=" << pq.top() << " size=" << pq.size() << "  -> 依次弹出:";
while(!pq.empty()){ cout << " " << pq.top(); pq.pop(); }
cout << "\n";

// 想要小根堆:加上 vector<int> 和 greater<int> 两个参数
priority_queue<int, vector<int>, greater<int>> minpq;
for(int x : {5, 1, 9, 3, 7}) minpq.push(x);
cout << "top=" << minpq.top() << "  -> 依次弹出:";
while(!minpq.empty()){ cout << " " << minpq.top(); minpq.pop(); }
cout << "\n";

真实输出:

top=9 size=5  -> 依次弹出: 9 7 5 3 1
top=1  -> 依次弹出: 1 3 5 7 9
⚠️ 小根堆的声明要写三个尖括号,一个字都不能少

priority_queue<int, vector<int>, greater<int>> minpq;

· 第 1 个参数:元素类型(int)
· 第 2 个参数:底层用哪个容器装,一般就写 vector<int>
· 第 3 个参数:怎么比较greater<int> 表示「小的优先」→ 小根堆

注意最后那个 >> 在现代 C++ 里可以直接连着写(老编译器要求写成 > > 加空格, C++11 之后不需要了)。

三个容器都是「只能在一头或两头操作」,所以都不支持下标访问(不能写 s[0]), 也都不能用 for 循环遍历。想知道里面有什么,只能一个个 pop 出来。

4经典应用一:括号匹配

这是栈最著名的用途,也是今天 P1739 的做法。

问题

给一个只含 () 的表达式,判断括号是否配对。

为什么用栈

从左往右读,每遇到一个 ),它要和左边最近的那个还没配对的 ( 配对。

「最近的那个还没配对的」—— 这不就是栈顶吗?

🎯 把栈和栈顶翻译成大白话

· push 一个左括号 = 「记下来,我还欠一个右括号」
· pop 一个 = 「刚才那个左括号被配掉了,不欠了」
· 栈顶 = 「最近一个还没被配掉的左括号」
· 最后栈空 = 所有左括号都被配掉了

算法步骤

  1. 从左往右扫表达式。
  2. 遇到 ( → 入栈。
  3. 遇到 )先检查栈是不是空的。如果空,说明这个右括号没有左括号配它, 立刻判定不匹配(不用再往后看了,直接输出 NO)。如果不空,就 pop 一个。
  4. 扫完之后,检查栈是否为空。如果不空,说明有左括号没被配掉 → 不匹配。
🚫 两个地方必须检查,少一个就错

① 遇到右括号时栈为空 —— 这是「右括号多了」。例子:)x+y(
② 扫完之后栈不为空 —— 这是「左括号多了」。例子:((x+y)

很多人只写了第 ② 条,遇到 )( 这种「右括号在前」的情况就会判成 YES,其实应该是 NO。 第 ① 条必须在循环里当场判掉,不能等到最后。

手推一个例子:((x+y))*

读到的字符动作栈内容(底 → 顶)
(入栈(
(入栈( (
x不管( (
+不管( (
y不管( (
)栈不空 → 弹一个(
)栈不空 → 弹一个(空)
*不管(空)
结束栈空 → 匹配YES
💡 其实只数个数也行(本题因为只有圆括号)

如果括号只有一种(全是大括号或全是小括号),那么用一个计数器 cnt 就够了: 遇 (cnt++,遇 )cnt--, 中途 cnt 不能变成负数,最后 cnt 必须为 0。

但一旦出现多种括号(比如同时有 ()[]),计数器就不够用了 —— 因为 ([)] 里括号个数是配平的,可它是错的。那时必须用栈(栈里存左括号的类型,遇到右括号时检查栈顶类型是否对应)。 今天先用圆的练手,但心里要知道栈才是通用解法。

5经典应用二:后缀表达式求值

什么是后缀表达式

我们平时写的是中缀表达式3*(5-2)+7。运算符在中间,还要考虑优先级和括号。

后缀表达式(又叫逆波兰式)把运算符写在两个操作数后面,而且不需要括号、不需要考虑优先级, 从左往右按顺序算就行。3*(5-2)+7 的后缀写法是:

3.5.2.-*7.+@

其中 . 是「一个操作数结束了」的标记,@ 是整个表达式的结束标记。

求值规则:一句话概括

🎯 遇数字入栈,遇运算符弹出两个数算完再入栈

最后栈里只剩一个数,那就是答案。

手推一遍:3.5.2.-*7.+@

读到的内容动作栈内容(底 → 顶)
3.数字入栈3
5.数字入栈3 5
2.数字入栈3 5 2
-弹出 2 和 5,算 5−2=3,入栈3 3
*弹出 3 和 3,算 3×3=9,入栈9
7.数字入栈9 7
+弹出 7 和 9,算 9+7=16,入栈16
@结束答案 = 16

走完之后栈里只剩 16,和样例输出一致。(顺便也能看出为什么不需要括号: 5−2 先算,是因为 - 写在 5 和 2 后面、排在 * 前面。)

🚨 今天最大的坑:减法和除法的操作数顺序!

遇到运算符时,我们要弹出两个数。假设先弹出来的是 b、后弹出来的是 a,那么:

a = 先压进去的(后弹出的)  // 左操作数
b = 后压进去的(先弹出的)  // 右操作数

结果 = a - b     // 一定要写成 a-b,不是 b-a!
结果 = a / b     // 除法同理

为什么? 因为栈是后进先出。在后缀表达式 5.2.- 里,5 先进栈、2 后进栈, 所以 2 在栈顶、先被弹出。而我们要算的是 5−2,也就是「先弹出的当右操作数」。

加法和乘法无所谓(交换律),但减法和除法写反了就是错的。这是 P1449 最常见的 WA 原因。

第二个坑:多位数怎么读

题目里说「数字可能是多位的」。像 10.28.30./*7.-@ 里的 102830 都是两位数。所以我们不能读一个字符就当做一个数字

🎯 正确做法:一位一位拼,直到遇到 .
long long num = 0;                 // 正在拼的数
if(c 是数字)   num = num * 10 + (c - '0');   // 拼上去
else if(c == '.')   { 入栈(num); num = 0; }  // 一个数读完了

比如读 10.:num 先变 1,再变成 1×10+0 = 10,遇到 . 时入栈 10。 这跟 Day 6 学的「数字拆分」是同一个套路,只是方向反过来 —— 那边是往外拆,这边是往里拼。

⚠️ 关于除法的「向 0 取整」

题目特别说明:/ 的结果要向 0 取整,并且「与 C++ / 运算的规则一致」。

这是好消息:C++ 的整数除法天生就是向 0 取整(−7/2 = −3,7/2 = 3),所以你直接用 / 就行, 不需要任何额外处理。(这一点和其他一些语言不一样,那些语言里负数除法是向下取整。)

6四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译通过(零警告)并跑过官方样例,输出是你看到的真实结果。

B3614 【模板】栈 普及- · 栈的基本操作

题意:实现一个栈,支持四种操作:

  • push(x):把 x 入栈。
  • pop():弹出栈顶。如果栈是空的,不做任何操作,输出 Empty
  • query():输出栈顶元素。如果栈是空的,输出 Anguei!
  • size():输出栈内元素个数。
⚠️ 数据范围(原文)

本题单测试点内有多组数据。 1 ≤ T, n ≤ 10⁶,且 Σn ≤ 10⁶0 ≤ x < 2⁶⁴

两个关键点:
Σn ≤ 10⁶ —— 操作总数一百万,必须用快读/关同步,否则可能 TLE;
x < 2⁶⁴ —— x 最大接近 1.8×10¹⁹,超过 unsigned long long 就装不下了, 必须用 unsigned long long(4 字节的 int、8 字节的 long long 里, 只有 unsigned long long 能表示到 2⁶⁴−1)。

题目自己也提醒:「请注意大量数据读入对程序效率造成的影响」。

思路

std::stack 直接做。要点有三个:

1. 数据类型用 unsigned long long,因为 x 可以到 2⁶⁴−1。

2. 每次 pop / query 之前都要判空,空的时候输出对应的字符串而不是去动栈。

3. 六种操作怎么区分。 这里有个陷阱:

🚨 我在写这题时真的踩了这个坑

我一开始想偷懒,用第二个字符来区分:if(op[1]=='u') 认为是 push, if(op[1]=='o') 认为是 pop。

这是错的! 因为 "query" 的第二个字符也是 'u' (q-u-e-r-y)!于是所有的 query 都被当成了 push, 程序去读一个根本不存在的整数 x,cin 进入失败状态,后面的所有读入全部失效 —— 结果就是程序陷入死循环、疯狂输出 0。

正确做法:先看第一个字符。 'p' 开头是 push 或 pop(这两个才用第二个字符区分), 'q' 开头是 query,'s' 开头是 size。

样例输入
2
5
push 2
query
size
pop
query
3
pop
query
size
样例输出
2
1
Anguei!
Empty
Anguei!
0

第一组:push 2 → query 输出 2 → size 输出 1 → pop(成功,静默)→ query 时栈已空,输出 Anguei!
第二组:栈一直是空的,pop 输出 Empty,query 输出 Anguei!,size 输出 0。

完整代码

#include <bits/stdc++.h>
using namespace std;

int main(){
    ios::sync_with_stdio(false);      // 数据量大,关掉同步更快
    cin.tie(nullptr);

    int T;
    cin >> T;
    while(T--){                       // ★ 多组数据
        int n;
        cin >> n;
        stack<unsigned long long> st;  // ★ x 可达 2^64-1,必须 ull
        while(n--){
            string op;
            cin >> op;
            // 注意:push / pop 都以 'p' 开头,query 缩写成 q,size 缩写成 s
            // 千万不能用 op[1] 区分 push 和 query —— "query"[1] 也是 'u'!
            if(op[0] == 'p'){
                if(op[1] == 'u'){          // push
                    unsigned long long x;
                    cin >> x;
                    st.push(x);
                }else{                     // pop
                    if(st.empty()) cout << "Empty\n";
                    else st.pop();
                }
            }else if(op[0] == 'q'){        // query
                if(st.empty()) cout << "Anguei!\n";
                else cout << st.top() << "\n";
            }else{                         // size
                cout << st.size() << "\n";
            }
        }
    }
    return 0;
}
✅ 实测结果

官方样例 → 输出 2 / 1 / Anguei! / Empty / Anguei! / 0 完全一致

极值测试:push 18446744073709551615(就是 2⁶⁴−1,unsigned long long 能存的最大值) 然后 query → 输出 18446744073709551615size1
这验证了 unsigned long long 的选择是对的:换成长度不够的类型这里就已经错了。

易错点

  • intlong long 存 x → x 可以到 2⁶⁴−1,存不下。必须 unsigned long long
  • op[1] 区分操作 → 会把 query 误判成 push(我实际踩过,会死循环)。要先看 op[0]
  • 忘记判空 → 对空栈 top() 是未定义行为,可能 RE。
  • 注意两个字符串的拼写Empty 没有感叹号,Anguei! 感叹号。题目专门提醒过。
  • 多组数据要重新声明栈:把 stack 定义在 while(T--) 循环里面, 否则上一组残留的数据会污染下一组。
  • 不关 ios::sync_with_stdio 的话,10⁶ 次读入可能超时。
B3616 【模板】队列 普及- · 队列的基本操作

题意:实现一个队列。操作不是用字符串给的,而是用数字编号

输入含义空队列时的行为
1 x把 x 加入队尾
2弹出队首输出 ERR_CANNOT_POP
3查询队首输出 ERR_CANNOT_QUERY
4查询元素个数输出 0(正常输出)
⚠️ 数据范围(原文)

对于 100% 的测试数据,满足 n ≤ 10000,且被插入队列的所有元素值都是 [1, 1000000] 以内的正整数

数据非常小,int 存元素完全够用,也不需要考虑读入效率。这题的重点是把接口用对

思路

纯模拟题,读一个操作号,分支处理即可。三个要点:

1. 操作 1 后面还跟着一个数 x,要接着读进来。

2. 操作 2 和 3 都要先判空,空的时候输出错误提示字符串。

3. 输出格式。 题目文字里有一句「每条输出之间应当用空行隔开」, 但官方样例输出里并没有空行

💡 遇到「题面描述」和「样例」打架时怎么办

样例为准。评测用的就是标准输出文件,而样例就是标准输出的一部分。

我的做法是按样例输出(不加空行),实测通过样例。如果评测真的要求空行,样例不可能长这样。 但你要知道这个坑存在 —— 这是我这次唯一不太确定的点,已如实说明。

样例输入
13
1 2
3
4
1 233
3
2
3
2
4
3
2
1 144
3
样例输出
2
1
2
233
0
ERR_CANNOT_QUERY
ERR_CANNOT_POP
144

逐步走一遍(题目提示里的解释):
插入 2 → 队首是 2、个数 1 → 插入 233 → 队首还是 2(先进先出!)→ 弹出 → 队首变成 233 → 再弹出 → 队列空、个数 0 → 查询队首(空)报错 → 弹出(空)报错 → 插入 144 → 队首 144。

完整代码

#include <bits/stdc++.h>
using namespace std;

int main(){
    int n;
    cin >> n;

    queue<int> q;
    while(n--){
        int op;
        cin >> op;
        if(op == 1){
            int x;  cin >> x;              // ★ 操作 1 后面还有个数
            q.push(x);
        }else if(op == 2){
            if(q.empty()) cout << "ERR_CANNOT_POP\n";
            else q.pop();
        }else if(op == 3){
            if(q.empty()) cout << "ERR_CANNOT_QUERY\n";
            else cout << q.front() << "\n";  // ★ 队列用 front(),不是 top()
        }else{
            cout << q.size() << "\n";       // 操作 4:空队列也正常输出 0
        }
    }
    return 0;
}
✅ 实测结果

官方样例 → 输出 2 / 1 / 2 / 233 / 0 / ERR_CANNOT_QUERY / ERR_CANNOT_POP / 144 (共 8 行),与官方样例逐行完全一致

注意这 8 行输出里没有空行,这印证了上面那条判断:以样例为准。

易错点

  • front() 写成 top() → 编译报错(queue 没有 top)。
  • 操作 1 忘了读 x → 后面的读入全部错位。
  • 空队列时去 front() / pop() → 未定义行为。必须先判空。
  • 操作 4 也去判空 → 空队列时 size 应该正常输出 0,不需要任何特殊处理。
  • 两个错误字符串拼写要一字不差:ERR_CANNOT_POPERR_CANNOT_QUERY(都是大写 + 下划线)。
P1449 后缀表达式 普及- · 栈的应用

题意:给一个后缀表达式(逆波兰式),求它的值。运算符只有 + - * /。 操作数后面跟 . 表示这个数结束,整个表达式用 @ 结尾。 保证除法除数不为 0,且除法结果向 0 取整

⚠️ 数据范围(原文)

数据保证 1 ≤ |s| ≤ 50答案和计算过程中的每一个值的绝对值不超过 10⁹

字符串长度只有 50,所以时间、空间都毫无压力。难点完全在于解析和运算顺序。

注意「过程中每个值的绝对值不超过 10⁹」这句话 —— 用 int 其实也放得下, 但用 long long 更保险(反正内存不差这点)。

思路

一个栈搞定,从左往右扫字符串:

  1. 读到 数字字符 → 拼到当前数字里:num = num * 10 + (c - '0')
  2. 读到 . → 一个数读完了,把 num 入栈,然后 num = 0 重新开始。
  3. 读到 @ → 表达式结束,跳出循环。
  4. 读到 运算符先弹出的是右操作数,后弹出的是左操作数,算完把结果入栈。

循环结束后,栈顶就是答案。

样例输入 1
3.5.2.-*7.+@
样例输出 1
16
样例输入 2
10.28.30./*7.-@
样例输出 2
-7

样例 2 手推:102830 依次入栈 → 遇 / 弹出 30 和 28, 算 28 / 30 = 0(整数除法)→ 入栈得到 [10, 0] → 遇 * 弹出 0 和 10, 算 10 × 0 = 0 → 入栈得到 [0] → 7 入栈得到 [0, 7] → 遇 - 弹出 7 和 0, 算 0 − 7 = −7注意这里就是「先弹出的当右操作数」:0 − 7,不是 7 − 0。

完整代码

#include <bits/stdc++.h>
using namespace std;

int main(){
    string s;
    cin >> s;

    stack<long long> st;
    long long num = 0;                // 正在拼的数字

    for(size_t i = 0; i < s.size(); i++){
        char c = s[i];
        if(c >= '0' && c <= '9'){
            num = num * 10 + (c - '0');   // ★ 多位数:一位一位拼起来
        }else if(c == '.'){
            st.push(num);                 // 遇到 '.' 说明一个数读完了
            num = 0;
        }else if(c == '@'){
            break;                        // 表达式结束
        }else{                            // 运算符
            long long b = st.top(); st.pop();   // ★ 先弹出的是右操作数
            long long a = st.top(); st.pop();
            long long r = 0;
            if(c == '+')      r = a + b;
            else if(c == '-') r = a - b;
            else if(c == '*') r = a * b;
            else              r = a / b;  // C++ 的 / 本身就是向 0 取整
            st.push(r);
        }
    }

    cout << st.top() << "\n";
    return 0;
}
✅ 实测结果

官方样例 1 3.5.2.-*7.+@16 完全一致
官方样例 2 10.28.30./*7.-@-7 完全一致

多位数专项测试(这题的评分点就在这儿):
· 123.456.+@579(123 + 456)
· 1000.1000.*2.-@999998(1000×1000 − 2)
· 42.@42(表达式里只有一个数)

易错点

  • 减法和除法把操作数顺序弄反 → 这是本题第一大 WA 原因。记住:先弹出的是右操作数。
  • 多位数没处理 → 读到 10. 时只识别成 1,后面全乱。必须用 num * 10 + digit 累积。
  • 读到 . 忘了把 num 清零 → 下一个数字会接在旧的后面(比如 12 之后读 3 会变成 123)。
  • cin >> s 读入是可行的(表达式里没有空格),但如果题目说「一行可能带空格」就得用 getline
  • 循环条件写成 i < s.size() 但忘了处理 @@ 会被当成运算符处理。要显式 break
  • 判断数字要用 c >= '0' && c <= '9'不要用字符的 ASCII 码硬编码
P1739 表达式括号匹配 入门 · 普及- · 栈 / 计数

题意:表达式由数字、小写字母、运算符 + - * / 和左右小括号组成,以 @ 结尾。 判断圆括号是否匹配:匹配输出 YES,否则输出 NO

只有括号要管,方括号、大括号题面里没有。

⚠️ 数据范围(原文)

表达式长度小于 255,左圆括号少于 20 个。

数据极小,用栈还是用计数器都毫无压力。这题的重点是把两种情况都想全

思路

用栈做(这是通用解法):遇 ( 入栈;遇 )先判空 —— 空说明右括号多了,立刻判 NO;不空就弹一个。扫完后再判一次栈是否为空,不空说明左括号多了。

🎯 这题其实可以不用栈

因为只有一种括号,用一个计数器就够了:遇 ( 加一、遇 ) 减一。

但为了跟 Day 28 的主题呼应、也为了以后遇到「多种括号」时能直接套用, 我的代码还是用栈写的。栈的写法通用性更强,建议你练熟这一版。

样例输入 1
2*(x+y)/(1-x)@
样例输出 1
YES
样例输入 2
(25+x)*(a*(a+b+b)@
样例输出 2
NO

样例 1:两个 ( 两个 ),且顺序正确 → YES。
样例 2:有两个 ( 但只有一个 ),结束时栈里还剩一个 → NO。

完整代码

#include <bits/stdc++.h>
using namespace std;

int main(){
    string s;
    cin >> s;

    stack<char> st;
    bool ok = true;

    for(size_t i = 0; i < s.size(); i++){
        char c = s[i];
        if(c == '@') break;                 // 表达式结束
        if(c == '('){
            st.push(c);                     // 左括号入栈
        }else if(c == ')'){
            if(st.empty()){                 // ★ 右括号多了,当场判错
                ok = false;
                break;
            }
            st.pop();                       // 和栈顶的左括号配对
        }
    }

    if(!st.empty()) ok = false;             // ★ 左括号多了
    cout << (ok ? "YES" : "NO") << "\n";
    return 0;
}
✅ 实测结果

官方样例 1 2*(x+y)/(1-x)@YES 完全一致
官方样例 2 (25+x)*(a*(a+b+b)@NO 完全一致

边界测试:
· ((x+y))*@(嵌套 + 并列)→ YES
· )x+y(@(右括号在前)→ NO ← 如果只判「最后栈是否为空」,这里会错判成 YES
· x+y@(一个括号都没有)→ YES(空表达式也算匹配)

易错点

  • 只在最后判一次栈是否为空,遇到 )( 这种会错判成 YES。必须在循环里对空栈判错。
  • 对空栈调用 top() / pop() → 未定义行为。要先判空。
  • 忘记在 @ 处停下 → 后面的字符会被当成表达式内容。
  • 输出是 YES / NO,全大写,别写 Yesyes
  • 题目说的是「左圆括号少于 20 个」,所以栈的深度很小,不用担心中间会爆栈。

今日常见错误汇总

#错误写法后果正确写法
1 对空栈直接 top()pop() 未定义行为,可能 RE、可能读到垃圾值 if(!s.empty()) 之后再动手
2 以为 pop() 会返回被删的值
int x = s.pop();
编译报错(pop 返回 void) int x = s.top(); s.pop();
3 队列用 q.top() 编译报错:queue 没有 top 队列是 q.front(),栈和优先队列才是 top()
4 后缀表达式里
r = b - a
减法和除法结果全反 → WA r = a - b;(先弹出的是右操作数)
5 后缀表达式一次只读一位数字 多位数(10、28、30)被拆散 → WA num = num*10 + (c-'0'),遇 . 才入栈
6 括号匹配只在最后判一次空栈 )(x+y( 这类会错判成 YES 遇到右括号时若栈空 → 立刻判 NO
7 B3614 用 op[1] 区分操作 "query"[1] 也是 'u',被当成 push
→ 读入失败 → 死循环
先看 op[0],再决定要不要看 op[1]
8 B3614 用 int / long long 存 x x < 2⁶⁴ 超出范围 → WA unsigned long long
9 多组数据时把 stack 定义在循环外 上一组残留元素污染下一组 定义在 while(T--) 循环体内
10 输出字符串拼错
Empty! / Anguei
答案错误 Empty(无叹号)
Anguei!(有叹号)

自测:你真的会了吗

第 1 题:下面这段代码错在哪?
stack<int> s;
int x = s.pop();

:两个错。

pop() 的返回值是 void不能赋值给任何变量,这行直接编译不过。 想拿值要先 top()

② 就算写成 int x = s.top();,如果此时 s 是空的,同样是未定义行为。 必须先 if(!s.empty())

第 2 题:栈和队列都能用 pushpop,那怎么区分「下一个出来的是谁」?

· stackpushpop 都在同一头(栈顶),用 top() 看 → 后进先出
· queuepush 进队尾、pop 从队首出,用 front() 看下一个、 back() 看最后进来的 → 先进先出

一句话:栈是一头进出,队列是两头分工。

第 3 题:手算 5.3.-2.*@ 的值,并写出每一步的栈内容。

读到动作
5.入栈5
3.入栈5 3
-弹 3(右)、弹 5(左),算 5−3=22
2.入栈2 2
*弹 2(右)、弹 2(左),算 2×2=44
@结束答案 4

它对应的中缀表达式是 (5−3)*2,确实是 4。

第 4 题:括号匹配里,为什么「只在最后判栈是否为空」不够?举一个反例。

:因为那只能发现「左括号多了」,发现不了「右括号多了」。

反例:)x+y(@。第一个字符就是 ),此时栈是空的 —— 它没有左括号可配。 但如果只在最后判空,扫完后栈恰好也是空的(因为中间又补了一个 ( 和一个 )), 程序就会错误地输出 YES。正确答案是 NO。

所以必须在循环里遇到 ) 时先判空,一发现空就立刻判 NO 并 break。

第 5 题(手写代码):用栈实现「把字符串反过来输出」。
string rev(string s){
    stack<char> st;
    for(char c : s) st.push(c);        // 全部压进去
    string r;
    while(!st.empty()){                // 再一个个弹出来
        r += st.top();
        st.pop();
    }
    return r;
}

★ 因为栈是后进先出,最后一个字符最先被弹出,所以弹出来的顺序正好和压进去的顺序相反。 「反转」是栈最本质的用途 —— 括号匹配里「找最近的左括号」本质上也是在利用这个性质。

第 6 题:什么时候该用 priority_queue 而不是 queue?

:当你需要「每次取出的不是最早进来的,而是当前最大/最小的那个」时。

比如「每次从一堆任务里挑优先级最高的执行」「Dijkstra 最短路里每次取当前距离最小的点」。 这些场景的顺序和「进来时间」无关,只和「值的大小」有关,所以要用优先队列。

默认的 priority_queue<int>大根堆(每次出最大值); 要小根堆得写 priority_queue<int, vector<int>, greater<int>>

速查卡(截图存手机)

【stack · 后进先出 LIFO】
  #include <stack>
  stack<int> s;
  s.push(x);      // 入栈
  s.top();        // 看栈顶(★ 不删除)
  s.pop();        // 删栈顶(★ 不返回)
  s.empty();  s.size();
  // ★ 先 top 再 pop;动手前先 if(!s.empty())

【queue · 先进先出 FIFO】
  #include <queue>
  queue<int> q;
  q.push(x);      // 进队尾
  q.front();      // 看队首(★ 下一个出队的)
  q.back();       // 看队尾
  q.pop();        // 删队首
  q.empty();  q.size();

【priority_queue · 每次出最大 / 最小】
  priority_queue<int> pq;                       // 大根堆
  priority_queue<int, vector<int>, greater<int>> mp;  // 小根堆
  pq.push(x);  pq.top();  pq.pop();  pq.empty();  pq.size();

【三个容器的「看下一个」】
  stack → top()      queue → front()      priority_queue → top()
  // 都不要用下标访问,都不能 for 遍历

【括号匹配】
  for(char c : s){
      if(c == '@') break;
      if(c == '(') st.push(c);
      else if(c == ')'){
          if(st.empty()){ ok = false; break; }   // ★ 右括号多了
          st.pop();
      }
  }
  if(!st.empty()) ok = false;                    // ★ 左括号多了

【后缀表达式求值】
  long long num = 0;
  for(char c : s){
      if(isdigit(c)) num = num*10 + (c-'0');     // ★ 多位数
      else if(c == '.') { st.push(num); num = 0; }
      else if(c == '@') break;
      else {
          long long b = st.top(); st.pop();      // ★ 先弹出的是右操作数
          long long a = st.top(); st.pop();
          if(c=='+') st.push(a+b);
          if(c=='-') st.push(a-b);
          if(c=='*') st.push(a*b);
          if(c=='/') st.push(a/b);               // 向 0 取整,C++ 默认行为
      }
  }
  cout << st.top();

明天预告 · Day 29

限时模拟赛(第一次完整比赛体验)

前面 28 天你都在按知识点一天一题地练。明天要换一种练法:给你 4 道题、3 个小时, 自己判断难度、自己分配时间。

题目是 P1067 多项式输出、P1328 生活大爆炸版石头剪刀布、P1981 表达式求值、P1996 约瑟夫问题 —— 都不难,但在压力下把简单的题写对,本身就是一项需要专门练的能力。

今天学的栈和队列明天不会直接用上,但「用容器管理数据」的思路会一直跟着你。 另外 Day 20 的 BFS 就是靠队列撑起来的 —— 今天的接口就是那时候的地基。

ACM 一个月学习计划 · DAY 29

Day 29 · 模拟赛

今天不学新算法 —— 今天练的是「在 3 小时里把会做的题全部拿到分」

今日目标:按真实比赛节奏限时完成 4 题,赛后把没做出来的题补掉
预计用时:约 4 小时(3 小时模拟赛 + 1 小时复盘补题)  难度:普及- ~ 普及/提高-
题目来源:洛谷 P1067 / P1328 / P1981 / P1996
DAY 29 / 30

今日时间安排(按真实比赛节奏走)

0 - 10 min四道题全部读一遍,判断难度,决定做题顺序(先别写代码)
10 - 70 min拿下最容易的 1 ~ 2 题(每题限时 30 分钟)
70 - 130 min啃第 3 题;卡住超过 50 分钟就先去写暴力拿分
130 - 160 min第 4 题 + 回头检查数组大小 / long long / 输出格式
160 - 180 min最后 20 分钟:不写新代码,只做检查
180 - 240 min赛后复盘:把没 AC 的题不看题解重写一遍(这才是重点)
💡 和昨天内容的衔接

Day 28 你刚学完栈和队列。今天的 4 道题里,P1981 表达式求值P1996 约瑟夫问题 正好就是栈和队列的直接应用 —— 出题人就是这么设计的。另外两道(P1067、P1328)是纯模拟, 考的是 Day 1 ~ Day 6 攒下来的基本功:数组、循环、条件判断、边界处理

1模拟赛到底在考什么

很多人第一次打模拟赛会有一种很憋屈的感觉:这四道题的算法我全都学过,为什么还是拿不到分?

原因通常不是「不会」,而是下面这几种:

丢分原因典型表现今天怎么防
读题太快漏掉「系数为 1 时省略」「输出最后 4 位」这类细节把题面里每一句「要求」抄成清单,写完逐条打钩
顺序选错一上来死磕最难的题,一小时过去 0 分先花 10 分钟全部读一遍,从最简单的开始
卡死不放一道题耗掉 100 分钟,其余三题没时间单题超过 50 分钟就换题,先把能拿的分拿到手
不检查数组开小、忘开 long long、多测没清空留最后 20 分钟专门检查,不写新代码
没有暴力兜底正解写不出就交白卷很多题有子任务分,写个暴力能拿 30 ~ 60 分
🎯 一句话记住今天的主题

比赛比的不是「谁会得更多」,而是「谁丢得更少」。
会做的题全对 + 不会的题骗到部分分,这个分数往往比死磕一道难题高得多。

今天的四道题分别是什么类型

题号题目难度类型
P1067多项式输出普及-细节模拟(特判地狱)
P1328生活大爆炸版石头剪刀布普及/提高-打表 + 模拟
P1981表达式求值普及-栈 / 优先级处理
P1996约瑟夫问题普及-队列模拟

建议顺序:P1996 → P1067 → P1981 → P1328,或者 P1067 → P1996 → P1981 → P1328。 P1996 最短最快,先拿一题稳住心态;P1328 要自己打一张表,读题最花时间,放后面。

2拿到一道题,先做三件事

第一件:把题面翻译成「输入 → 处理 → 输出」

别急着想算法。先用一句话说清楚:程序读进来什么,要吐出去什么。

拿 P1067 举例,翻译完是这样:

环节内容
输入第一行次数 n,第二行 n+1 个整数(从最高次到常数项的系数)
处理按次数从高到低,把非零项拼成一个字符串
输出一行,形如 100x^5-x^4+x^3-3x^2+10

翻译完你立刻就能发现:这题没有算法,只有一个循环加一堆 if。剩下的全是细节,那就去抠细节。

第二件:把「细节」抄成一张清单

题面里凡是出现「如果……则……」「不需要……」「只输出……」的句子,每一句都对应代码里一个 if

P1067 的清单长这样(这题一共 5 条):

#规则代码里对应什么
1按次数递减输出循环写 for(i = n; i >= 0; i--)
2只输出系数不为 0 的项if(a == 0) continue;
3最高次项为正不输出 +,为负输出 −first 标记第一项
4非最高次项用 + / - 连接后面每项都先输出符号
5次数 > 0 且系数绝对值为 1 → 不输出 1
次数为 1 → 写 x;次数为 0 → 只写系数
一层 if(i == 0) / else if(i == 1) / else
✅ 这个清单法能救命

写完代码后,把这张表拿出来一条一条打钩。P1067 这种题,只要你 5 条都实现了,就一定能过; 少一条就是 0 分。比赛里靠感觉写细节题,翻车率极高。

第三件:先用样例手动验算一遍

样例 2 是 n=3,系数 -50 0 0 1,期望输出 -50x^3+1。手动走一遍:

i(次数)系数该输出什么结果拼接
3-50第一项且为负 → 输出 −,|系数|=50 ≠ 1 → 输出 50,次数>1 → x^3-50x^3
20跳过-50x^3
10跳过-50x^3
01非第一项且为正 → +,次数为 0 → 只输出系数 1-50x^3+1

和样例输出一模一样。这一步花你 2 分钟,能省掉 20 分钟调试。

3细节模拟题的通关套路

套路一:用一个 first 标记替代「特判最高次项」

新手最容易写出这样的代码:

// ❌ 又长又容易错:对最高次项单独特判一遍
if(最高次项){
    if(a > 0) cout << a << "x^" << n;
    else      cout << a << "x^" << n;
}
for(int i = n - 1; i >= 0; i--){
    // 这里还要再写一遍系数、次数、符号的处理……
}
🎯 更好的写法:只用一个布尔标记

不要问「这是不是最高次项」,而问「我前面输出过东西没有」。

bool first = true;
// 每一项的处理只有一份,符号部分按 first 分叉:
if(first){ if(a < 0) cout << '-'; }        // 开头:只有负号
else     { cout << (a < 0 ? '-' : '+'); }  // 后面:正负都要
first = false;

这样「系数 / 字母 / 指数」的逻辑只写一遍,不会出现两份代码改了一处忘了另一处的情况。
而且它还顺手解决了「最高次项系数为 0 怎么办」这个题面没明说的边界 —— 因为判断的是「有没有输出过」, 不是「是不是最高次项」。

套路二:能把「关系」写成表,就别写成一堆 if

P1328 石头剪刀布升级版有 5 种手势,两两组合有 25 种情况。用 if 写就是 25 个分支,写完自己都不敢改。

改成二维数组,逻辑就从「代码」变成了「数据」:

int r[5][5] = {
    { 0, -1,  1,  1, -1},   // 剪刀
    { 1,  0, -1,  1, -1},   // 石头
    {-1,  1,  0, -1,  1},   // 布
    {-1, -1,  1,  0,  1},   // 蜥蜴人
    { 1,  1, -1, -1,  0}    // 斯波克
};
// r[a][b] = 1 甲赢 / 0 平 / -1 甲输

查结果就是一句话:r[a][b]。这张表有两条性质,写完可以自查: 对角线全是 0(自己和自己平),关于对角线反对称r[a][b] == -r[b][a],甲赢就是乙输)。

套路三:周期性的东西用「取模」代替「真的去拼无穷长」

P1328 里两人要出拳 N 次,但每个人的出拳规律是长度 NA、NB 的循环。 根本不用造出一个长度 N 的序列,直接:

int res = r[ a[i % na] ][ b[i % nb] ];   // i % na 自动回到周期开头
⚠️ 取模是 0 下标的好朋友

i % na 的结果正好落在 0 ~ na-1,刚好是数组下标的合法范围,不需要 +1 / −1 折腾。
如果题目给的是「第 i 个人」这种从 1 开始的东西,就要写成 (i - 1) % n —— 用错会越界。

套路四:只要求「后几位」,就每一步都取模

P1981 只要答案的最后 4 位。这时候不需要真的算出完整的大数,每一步都对 10000 取模即可:

a * b % 10000 == ((a % 10000) * (b % 10000)) % 10000   // 恒成立
(a + b) % 10000 == ((a % 10000) + (b % 10000)) % 10000 // 同理
💡 为什么这招这么好用

取模不改变「低位」,只丢「高位」。题目只要低位,高位丢了也不心疼 —— 这就是用信息换范围
它同时解决了两件事:结果不会溢出 long long,而且数字本身也能读进来就取模(题面里数字可以到 2³¹−1)。

4P1981 的核心:加法和乘法怎么混着算

先想清楚「优先级」到底卡在哪

表达式 1+2*3,如果从左往右一个个算,会得到 (1+2)*3 = 9,答案是错的。乘法的优先级更高,必须先把 2*3 粘在一起。

🎯 关键洞察:加法把式子切成了「一个个项」

因为乘法优先级最高,所以整个式子可以看成:若干「项」用 + 连接起来, 而每个项内部只有乘法。
1+2*3+4*5  =  (1) + (2*3) + (4*5)  =  1 + 6 + 20

所以只要维护两个变量:已经结算完的总和当前正在累乘的这一项

手推一遍 1+1*3+4

读到的字符numopterm(当前项)ans(总和)说明
1110数字,继续攒
+0+10第一个数遇到运算符,它就是当前项
11+10数字
*0*11上一项结算进 ans,开始新项
33*11数字
+0+31乘法优先:term = 1 * 3 = 3不结算
44+31数字
结束44结算上一项 → ans = 1+3 = 4
收尾8别忘了最后一项!ans = 4+4 = 8
🚫 这里有一个 90% 的人都会踩的坑

循环里只处理「遇到运算符时结算上一项」,所以最后一项永远不会被结算
必须在循环结束后补一句 ans = (ans + term) % 10000;

样例 1+1*3+4 里最后一项是 4,忘了它就是输出 4 而不是 8 —— 一眼就能看出错, 但如果最后一项是 0,你就永远发现不了这个 bug。这种"有时对有时错"的 bug 最要命。

为什么还要在「读数字」的时候就取模

题面说参与运算的数字可以是 0 到 2³¹−1。如果先完整读进 long long 再取模,其实也行(2³¹−1 没超)。 但更省事的做法是边读边取模:

num = (num * 10 + (s[i] - '0')) % 10000;   // 读的时候就只留后四位

因为 num 始终小于 10000,num * 10 + d < 100000连 int 都不会溢出。 于是整份代码里所有中间量都在 10000 以内,你把 long long 全换成 int 都不会错。

✅ 这才是「取模题」的正确姿势

不是「先算完再取模」,而是让数字从一开始就长不大。范围小 = 不溢出 = 少一类 bug。

5四道例题详解

下面每道题的代码我都用 g++ -std=c++14 -O2 -Wall 实际编译并跑过官方样例, 「实测结果」里写的就是真实输出。

P1067 [NOIP 2009 普及组] 多项式输出 普及- · 细节模拟

题意:给一个一元 n 次多项式从高次到低次的 n+1 个系数,按竞赛规定的格式把它输出成一行字符串。

⚠️ 数据范围 / 关键约束

0 ≤ n ≤ 100,−100 ≤ 系数 ≤ 100。系数可能是 0,也可能是 1 / −1 / 100 / −100 这些边界值。

另外题目还给了一条容易漏的:「多项式只包含系数不为 0 的项」—— 也就是说系数为 0 的项要整个消失,不是输出一个 0x^3

思路

这题没有算法,只有一个循环加一堆特判。核心是把题面 5 条规则翻译成一张清单(见第 2 节), 然后逐条实现。最关键的技巧是用一个 first 标记区分「第一项」和「后面的项」——

位置正数怎么输出负数怎么输出
第一个被输出的项直接输出,不带 +输出 -
后面所有项输出 +,再输出系数输出 -,再输出系数

然后按次数分三种情况输出后半截:

次数 i|系数| = 1|系数| ≠ 1
i = 0输出 1输出系数
i = 1输出 x输出 3x 这样
i ≥ 2输出 x^i输出 3x^i 这样
样例输入 1
5 
100 -1 1 -3 0 10
样例输出 1
100x^5-x^4+x^3-3x^2+10
样例输入 2
3 
-50 0 0 1
样例输出 2
-50x^3+1

样例 1 一次性考了 4 个特判:系数 1 省掉(x^4x^3)、系数 0 整项跳过(x 那一项没了)、 常数项只写系数(+10)、以及正负号切换。这就是它被叫做「特判地狱」的原因。

完整代码

#include <iostream>
using namespace std;

int main(){
    int n;
    cin >> n;

    bool first = true;              // 还没有输出过任何一项

    for(int i = n; i >= 0; i--){    // i 就是当前这一项的次数
        int a;
        cin >> a;

        if(a == 0) continue;        // ★ 系数为 0 的项直接不输出

        // ---------- 1. 符号 ----------
        if(first){
            if(a < 0) cout << '-';      // 开头只有负数才输出负号
        }else{
            cout << (a < 0 ? '-' : '+'); // ★ 后面每项都要带符号
        }

        // ---------- 2. 系数 + 字母 + 指数 ----------
        int ab = (a < 0 ? -a : a);      // 系数的绝对值

        if(i == 0){
            cout << ab;                 // 常数项:只输出系数
        }else if(i == 1){
            if(ab != 1) cout << ab;     // ★ 一次项:|系数|=1 时省略 1
            cout << 'x';                //           指数 1 不写
        }else{
            if(ab != 1) cout << ab;     // ★ 高次项:|系数|=1 时省略 1
            cout << "x^" << i;
        }

        first = false;
    }

    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 1 5 / 100 -1 1 -3 0 10100x^5-x^4+x^3-3x^2+10 完全一致

官方样例 2 3 / -50 0 0 1-50x^3+1 完全一致

边界 n=0, 系数 1010 正确(n=0 时只有常数项)

边界 n=1, 1 1x+1n=1, -1 -1-x-1 正确(系数 ±1 被省略、指数 1 不写)

边界 n=2, 1 6 5x^2+6x+5 正确(首项系数 1 省略且不带 + 号)

边界 n=100, 只有 x^100 和常数项 7x^100+7 正确(中间 99 个 0 项全部消失)

边界 n=2, 系数 0 0 55 正确(最高次项系数为 0 时也不多打 + 号)

另外我还写了一个自检程序:随机生成 200000 组 n ∈ [0,100]、系数 ∈ [−100,100] 的多项式, 把输出串再解析回系数数组和原多项式比对(往返一致性),200000 组全部通过、0 组失败

易错点

  • 符号和系数要分开处理。负系数输出 - 之后,后面只能输出 |a|。 如果直接 cout << a,就会出现 --3x^2 这种两个负号叠在一起的东西。
  • 省略 1 只针对 |系数| = 1 的非常数项。常数项 1 是要老老实实输出 1 的, 别顺手也省掉(那就什么都不剩了)。
  • 「省略 1」和「省略指数 1」是两条独立规则。x(系数 1、次数 1)会同时触发两条; -x 同理。别只写一条。
  • 输出末尾不能有空格。本题的合法输出里根本没有空格, 所以千万不要用「每项后面补一个空格再删掉最后一个」这种写法,容易出错。
  • 题面没明说「最高次项系数为 0」怎么办。用 first 标记的写法会自然处理成「谁最先被输出,谁就是第一项」, 这是社区通行做法,我也按这个实现了。
P1328 [NOIP 2014 提高组] 生活大爆炸版石头剪刀布 普及/提高- · 打表 + 模拟

题意:石头剪刀布的升级版,共 5 种手势(0 剪刀、1 石头、2 布、3 蜥蜴人、4 斯波克)。 小 A 和小 B 各有自己的周期出拳规律,一共比 N 局,赢的加 1 分,问两人各得几分。

⚠️ 数据范围 / 关键约束

0 < N ≤ 200,0 < NA ≤ 200,0 < NB ≤ 200。

数据极小,N 最多 200 —— 这题的难点 100% 在读题和打表,不在效率。 不要想复杂了,就是老老实实模拟 N 局。

最容易错的是手势编号的含义0 是剪刀不是石头!石头是 1。记反了全盘皆错。

思路

5 种手势两两组合共 25 种,用 if 写就是 25 个分支。正确的做法是把胜负关系存成一张二维表, 用的时候只需要 r[a][b] 查一下。

升级版的 10 条胜负规则(每句记一半,反过来就是输)是:

赢的一方赢法输的一方
剪刀 (0)剪断布 (2)
剪刀 (0)斩首蜥蜴人 (3)
石头 (1)砸碎剪刀 (0)
石头 (1)压扁蜥蜴人 (3)
布 (2)包住石头 (1)
布 (2)揭穿斯波克 (4)
蜥蜴人 (3)毒死斯波克 (4)
蜥蜴人 (3)吃掉布 (2)
斯波克 (4)蒸发石头 (1)
斯波克 (4)砸碎剪刀 (0)

整理成 5×5 的表(行是甲、列是乙):

甲 \ 乙0 剪刀1 石头2 布3 蜥蜴人4 斯波克
0 剪刀 甲输甲赢甲赢甲输
1 石头 甲赢甲输甲赢甲输
2 布 甲输甲赢甲输甲赢
3 蜥蜴人 甲输甲输甲赢甲赢
4 斯波克 甲赢甲赢甲输甲输
💡 打完表的两条自查规则

1. 对角线全是 0(自己打自己一定平)。
2. 关于对角线反对称r[a][b] == -r[b][a](甲赢就等于乙输)。
我这版表两条都满足。你也可以顺便记一个规律:每种手势都恰好赢 2 种、输 2 种

然后模拟 N 局,第 i 局两人出的是 a[i % na]b[i % nb] —— 取模就实现了循环。

样例输入 1
10 5 6
0 1 2 3 4
0 3 4 2 1 0
样例输出 1
6 2
样例输入 2
9 5 5
0 1 2 3 4
1 0 3 2 4
样例输出 2
4 4

完整代码

#include <iostream>
using namespace std;

// 手势编号:0 剪刀  1 石头  2 布  3 蜥蜴人  4 斯波克
// r[a][b] = 甲出 a、乙出 b 时甲的结果: 1 甲赢  0 平  -1 甲输
int r[5][5] = {
    { 0, -1,  1,  1, -1},   // 剪刀  vs 剪/石/布/蜥/斯
    { 1,  0, -1,  1, -1},   // 石头
    {-1,  1,  0, -1,  1},   // 布
    {-1, -1,  1,  0,  1},   // 蜥蜴人
    { 1,  1, -1, -1,  0}    // 斯波克
};

int main(){
    int n, na, nb;
    cin >> n >> na >> nb;

    int a[205], b[205];
    for(int i = 0; i < na; i++) cin >> a[i];
    for(int i = 0; i < nb; i++) cin >> b[i];

    int sa = 0, sb = 0;
    for(int i = 0; i < n; i++){
        int res = r[a[i % na]][b[i % nb]];   // ★ 取模实现无限循环
        if(res == 1)       sa++;
        else if(res == -1) sb++;
    }

    cout << sa << ' ' << sb << endl;
    return 0;
}
✅ 实测结果

官方样例 1 10 5 66 2 完全一致

官方样例 2 9 5 54 4 完全一致

边界 N=1、甲出斯波克(4)、乙出蜥蜴人(3) → 0 1 正确(蜥蜴人毒死斯波克,乙赢)

边界 N=1、甲出剪刀(0)、乙出石头(1) → 0 1 正确

最大规模 N=200, NA=2, NB=3(0 1 / 2 3 4)101 99 合理(两人总分 101+99 = 200 = N,说明每局都分出了胜负,没有平局)

易错点

  • 编号记反0剪刀1 才是石头。写表之前先把这句话抄在草稿纸上。
  • 表的反对称性写错:比如 r[0][3] 填了 1(剪刀斩首蜥蜴人), 那 r[3][0] 必须是 −1。手写表很容易两边都填成 1,一定要回头查一遍。
  • 取模写错:是 i % na 不是 (i + 1) % na。这里的周期序列本来就从下标 0 开始存, 直接取模就对。
  • 数组开小:NA、NB 都是 ≤ 200,我开了 [205]。开成 [200] 就是越界。
  • 用 if 硬写 25 个分支:能过,但改一个规则就要重看一遍,极容易漏。 这类「两两关系」的题,打表永远比 if 稳
P1981 [NOIP 2013 普及组] 表达式求值 普及- · 栈 / 优先级处理

题意:给一个只含 +* 的表达式(没有括号、没有空格),算出它的值, 但只输出最后 4 位

⚠️ 数据范围 / 关键约束

参与运算的数字都是 0 到 2³¹−1 之间的整数;保证输入这一行只含 0123456789+* 这 12 种字符。

运算符总数:30% 的数据 ≤ 100,80% 的数据 ≤ 1000,100% 的数据 ≤ 100000

所以表达式长度大约 10⁶ 级别。O(长度) 的线性扫一遍就够了,别写 O(n²) 的。

思路

用第 4 节讲的「加法切项」思路:维护 ans(已结算总和)和 term(当前正在累乘的项)。

  • 读到数字 → 往 num 里攒,并且边攒边取模 10000
  • 读到一个数字结束(遇到运算符或到行尾)时,看上一个运算符 op
    • op 还没记过 → 这是第一个数,直接 term = num
    • op == '*'term *= num(乘法优先,并进当前项,不结算
    • op == '+' → 把 term 结算进 ans,然后 term = num 开新项
  • 循环结束后补一句把最后一项结算掉。

整个过程中所有变量都小于 10000,不会有任何溢出风险。

样例输入 1
1+1*3+4
样例输出 1
8
样例输入 2
1+1234567890*1
样例输出 2
7891
样例输入 3
1+1000000003*1
样例输出 3
4

样例 2 说明:1234567890 的后四位是 7890,1 + 7890 = 7891。
样例 3 说明:1000000003 的后四位是 0003 = 3,1 + 3 = 4。 注意这里不能先把 1000000003 当完整数字算完再取模——虽然这题数字没超 int,但读进来就取模更省心。

完整代码

#include <iostream>
#include <string>
#include <cctype>
using namespace std;

int main(){
    string s;
    cin >> s;                       // 这一行只含 0123456789+*

    long long ans  = 0;             // 已经算完的所有项的和
    long long term = 1;             // 当前正在累乘的这一项
    long long num  = 0;             // 正在读的这个数
    char op = 0;                    // 上一个读到的运算符,0 表示还没读到

    for(int i = 0; i <= (int)s.size(); i++){
        if(i < (int)s.size() && isdigit(s[i])){
            num = (num * 10 + (s[i] - '0')) % 10000;   // ★ 只留后四位就够
        }else{
            // 走到这里说明一个数字读完了,用 op 决定怎么合并它
            if(op == 0)        term = num;                          // 第一个数
            else if(op == '*') term = term * num % 10000;           // ★ 乘法优先,先并到本项
            else { ans = (ans + term) % 10000; term = num; }        // ★ 遇到加号:本项结算,开新项

            if(i < (int)s.size()) op = s[i];   // 记下这个运算符
            num = 0;
        }
    }

    ans = (ans + term) % 10000;      // ★ 别忘了最后一项
    cout << ans << endl;
    return 0;
}
✅ 实测结果

官方样例 1 1+1*3+48 完全一致

官方样例 2 1+1234567890*17891 完全一致

官方样例 3 1+1000000003*14 完全一致

边界 0(只有数字、没有运算符)→ 0 正确

边界 2147483647(单个最大数)→ 3647 正确(2147483647 mod 10000 = 3647)

9999*99991 正确(99980001 的后四位 0001)

1+2+3+4+5152*3*4*5120 正确(纯加法 / 纯乘法都对)

随机对拍:用 bigint 写了一个「按 + 拆项、项内连乘、最后整体取模」的参考实现, 随机生成 30 组表达式(每项数字到 9×10⁶)对拍,30 组全部一致,0 组失败

满规模压测:造了一条含 100000 个运算符(长度 1044219 字符)的表达式, 输出 6460耗时 84 ms(时限 1000 ms,非常宽裕)。

易错点

  • 忘了结算最后一项——详见第 4 节的红色警示框。这是本题第一大坑。
  • 把数字当成单个字符读:数字可以有多位(最多 10 位),必须用 num = num*10 + (c-'0') 攒起来。
  • 从左往右直接算1+2*3 会算成 9。乘法的优先级必须处理。
  • cin >> n 一个个读:这题没有空格分隔,cin >> int1+1*3+4 会在 1 后面直接失败。要么整行读成字符串自己拆,要么用 cin >> char 逐个读字符。
  • 只对最终答案取模:中间过程会爆 long long(100000 个 2³¹ 量级的数连乘,天文数字)。 必须每一步都取模
P1996 约瑟夫问题 普及- · 队列模拟

题意:n 个人围成一圈,从第 1 个人开始报数,数到 m 的人出圈,然后从下一个人重新从 1 报数, 直到所有人都出圈。按顺序输出每个出圈人的编号。

⚠️ 数据范围 / 关键约束

1 ≤ m, n ≤ 100。非常小,随便怎么模拟都行 —— 队列、数组、链表都能过。

题目特意提醒了一句:本题要求「全部出圈」,不是像某些教材那样只淘汰 n−1 个人。 所以输出必须是 n 个数,不是 n−1 个。

思路

题目描述本身就已经是算法了:「数到 m 的出圈 → 下一个人重新从 1 开始报数」。 这正是队列的操作方式:

步骤队列操作含义
1q.pop()队头的人报数
2计数器 +1他报的是几
3如果计数器 == m → 输出他出圈,不再回队
4否则 q.push(x)没数到 m,回到队尾继续排队

把队头拿到队尾,循环 n 轮,每个人都恰好被取出并处理到出圈为止。

🎯 为什么队列刚好合适

报数是一个「转圈」的过程。数组要自己维护下标并处理「走到底了绕回开头」, 而队列的 pop + push 天然就是「转了一圈回到队尾」—— 数据结构的形状正好对上题目的形状,代码就短。

样例输入
10 3
样例输出
3 6 9 2 7 1 8 5 10 4

10 个人数到 3 出圈:先出 3、6、9,然后从 10 后面的 1 继续数,出 2、7、1、8、5、10,最后剩 4。

完整代码

#include <iostream>
#include <queue>
using namespace std;

int main(){
    int n, m;
    cin >> n >> m;

    queue<int> q;
    for(int i = 1; i <= n; i++) q.push(i);   // 所有人排队,队头是 1 号

    int cnt = 0;                             // 当前报数报到几了
    bool first = true;

    while(!q.empty()){
        int x = q.front();
        q.pop();
        cnt++;

        if(cnt == m){                        // ★ 数到 m,出圈
            if(!first) cout << ' ';
            cout << x;
            first = false;
            cnt = 0;                         // ★ 下一个人重新从 1 开始报
        }else{
            q.push(x);                       // ★ 没数到 m,回到队尾接着排
        }
    }

    cout << endl;
    return 0;
}
✅ 实测结果

官方样例 10 33 6 9 2 7 1 8 5 10 4 完全一致

边界 1 11 正确(1 个人,数到 1 就出圈)

边界 5 55 1 3 4 2 正确(5 个人数到 5 出圈)

最大规模 100 100 → 输出 100 个数,第一个是 100,最后一个 22,n=100 个数一个不少。

穷举对拍:我又写了一个朴素做法(bool 数组 + 下标绕圈)作参考实现, 对 n = 1…100 × m = 1…100 全部 10000 组逐一比对, 10000 组答案完全一致、0 组失败;同时检查了每组输出都是 1…n 的一个排列(不多不漏不重复),也全部通过。

易错点

  • 输出格式:n 个数用空格隔开,最后一个后面不要多打空格。 用 if(!first) cout << ' '; 这种「先判断再输出」的写法最稳。
  • 报数计数器要清零:出圈时 cnt = 0,等于下一次循环 cnt++ 后正好是 1。 如果忘了清零,报数就错位了。
  • 循环条件用 while(!q.empty()),不要用 for(i = 1; i < n; i++)—— 那样只会淘汰 n−1 个人,少输出一个数(正是题面提醒的那个坑)。
  • 别把「出圈的人」又 push 回去:出圈的分支里不能再 q.push(x),否则死循环。
  • 数据只有 100,队列法 O(n·m) = 10⁴ 次操作,实际上是瞬时完成的, 不需要任何优化(真正的数学做法是递推公式,Day 17 已经见过类似思想,但这里完全不必)。

今日常见错误汇总

#错误写法 / 习惯后果正确做法
1 细节题不列清单,凭感觉写 漏掉一条特判就是 0 分,而且样例可能还过 把题面里每句「如果……则……」抄成表格,写完逐条打钩
2 P1067 用 cout << a 直接输出负系数 出现 --3x^2 这种双负号 符号单独输出,系数只输出 |a|
3 P1067 常数项也省略 1 常数项 1 什么都输出不出来 省略 1 只对次数 > 0 的项生效
4 P1328 把 0 当成石头 整张胜负表全错,样例都过不了 0 剪刀、1 石头、2 布、3 蜥蜴人、4 斯波克
5 P1328 手写 25 个 if 分支 改一处漏一处,反对称性对不上 用 5×5 二维数组打表,查 r[a][b]
6 P1981 忘记结算最后一项 答案少了最后一项;最后一项为 0 时又不报错,极难发现 循环后补 ans = (ans + term) % 10000;
7 P1981 数字按单个字符读 1234567890 会变成 1、2、3…… 一串单数字 num = num * 10 + (c - '0') 攒多位数
8 P1981 只在最后取模 中间结果溢出 long long,WA 每读一位、每做一次运算都 % 10000
9 P1996 用 for(i=1;i<n;i++) 只淘汰 n−1 个人,少输出一个编号 while(!q.empty()) 直到全部出圈
10 输出多余空格 / 少空格 PE 或 WA。P1996 是空格分隔、P1067 完全没空格 统一用「第一个不加分隔符,后面都加」的写法
11 比赛时不检查就交 数组开小 → RE;int 溢出 → WA 留 20 分钟专门核对数组大小、数据范围、边界样例

自测:你真的会了吗

第 1 题:P1067 里 n=2,系数 1 0 1 应该输出什么?

x^2+1

过程:i=2 系数 1 → 是第一项且为正,不打 +;|1| = 1 且次数 > 0 → 省略 1;次数 > 1 → 输出 x^2。 接着 i=1 系数 0 → 跳过。最后 i=0 系数 1 → 不是第一项且为正 → 打 +;次数为 0 → 只输出系数 1

拼起来就是 x^2+1注意结尾不是 x^2+1x^0,也不会把常数项的 1 省掉。

第 2 题:P1328 的胜负表为什么必须关于对角线反对称?

:因为同一场比赛,站在甲的视角和站在乙的视角看是同一件事。 r[a][b] = 1(甲赢)等价于 r[b][a] = -1(乙输),也就是 r[a][b] == -r[b][a]

这是个很好的自查工具:填完表后逐对检查一遍,能揪出大半的手写错误。

第 3 题:手算 P1981 的 2+3*4+5*6,按本文的算法每一步是什么?

读到optermans
2 ++20
3 **22
4 ++122
5 **514
6 结束*3014
收尾44

验算:2 + 12 + 30 = 44 ✓(如果从左往右硬算,会得到 ((((2+3)*4)+5)*6) = 150,完全错了)

第 4 题:P1996 中 n=4, m=2 的输出是什么?手动推一遍。

2 4 3 1

队列初始 1 2 3 4

· 1 报 1 → 回队尾,队列 2 3 4 1
· 2 报 2 → 出圈 2,队列 3 4 1
· 3 报 1 → 回队尾,队列 4 1 3
· 4 报 2 → 出圈 4,队列 1 3
· 1 报 1 → 回队尾,队列 3 1
· 3 报 2 → 出圈 3,队列 1
· 1 报 1 → 回队尾,队列 1
· 1 报 2 → 出圈 1,队列空

结果 2 4 3 1

第 5 题(比赛策略):还剩 40 分钟,还有一道题完全没思路,怎么办?

:先判断这题有没有子任务分(题面里常常写「对于 30% 的数据,……」)。 有的话立刻写一个最朴素的暴力版本交上去,把部分分拿到手 —— 这比继续干想正解划算得多。

如果连暴力都想不出,就把前面已过题的边界情况再验一遍,保证会做的题一分不丢。 最后 20 分钟不要写新代码。

速查卡(截图存手机)

【细节模拟的三步】
  1. 翻译:输入什么 → 处理什么 → 输出什么
  2. 列清单:题面每句「如果……则……」= 一个 if
  3. 手算样例:拿样例走一遍表格,再写代码

【"第一项"的通用写法】
  bool first = true;
  if(first){ 只有负数才输出负号; }
  else     { 正负都要输出; }
  first = false;
  // 好处:主逻辑只写一份,顺便解决最高次项系数为 0

【两两关系 → 打表】
  int r[5][5] = {...};   // r[a][b]: 1 甲赢 / 0 平 / -1 甲输
  res = r[a[i % na]][b[i % nb]];
  // 自查:对角线全 0 + 关于对角线反对称

【只保留后 n 位的取模技巧】
  num = (num * 10 + (c - '0')) % 10000;   // 边读边模
  term = term * num % 10000;
  ans  = (ans  + term) % 10000;
  // 让所有中间量都长不大 → 永不溢出

【加法切项的表达式求值(无括号,只有 + *)】
  ans = 0; term = 1; num = 0; op = 0;
  扫到数字结尾时:
    op == 0   → term = num;              // 第一个数
    op == '*' → term = term * num % MOD; // 乘法优先,不结算
    op == '+' → ans += term; term = num; // 结算上一项,开新项
  循环结束后:ans += term;               // ★★ 最容易忘的一句

【约瑟夫问题 · 队列模拟】
  q = 1..n;
  while(!q.empty()){
      x = q.front(); q.pop(); cnt++;
      if(cnt == m){ 输出 x; cnt = 0; }   // 出圈,不回队
      else          q.push(x);           // 回队尾继续排
  }

【比赛前 10 分钟 / 最后 20 分钟】
  开局:4 题全读一遍 → 定顺序 → 从最简单的开始
  收尾:数组大小?long long?输出格式?多测清空?边界样例?
  // 会做的题一分不丢,比多做一道难题更重要

明天预告 · Day 30

高精度运算 + 一个月总结

long long 大概只能装 19 位十进制数,可有些题要算 500 位、2000 位、甚至上万位。 明天学的就是:用字符串/数组把大数拆成一位一位存起来,自己实现加减乘

四道题是 P1601 高精度加法、P1303 A*B Problem、P2142 高精度减法、P1009 阶乘之和。 我们会写一套统一的模板(倒序存 → 逐位算 → 处理进位 → 去前导零),你以后可以直接抄进自己的代码本。

而且明天是 30 天计划的最后一天,会有一节完整的知识脉络回顾和下一步建议。

ACM 一个月学习计划 · DAY 30

Day 30 · 高精度运算 + 30 天总结

当数字大到 long long 装不下 —— 自己用数组造一个「无限长整数」出来

今日目标:掌握高精度加减乘的原理,写出属于自己的模板;完成整月复盘
预计用时:约 4 小时(含做题与总结)  难度:普及-
题目来源:洛谷 P1601 / P1303 / P2142 / P1009
DAY 30 / 30

今日时间安排

0 - 20 min搞清楚「为什么要高精度」(第 1 节)
20 - 70 min吃透统一模板的三件事:倒序存 / 逐位算 / 去前导零(第 2 节)
70 - 110 min逐个拆解加法、减法、乘法的手推过程(第 3、4 节)
110 - 180 min做 4 道题,把模板默写一遍(不看正文)
180 - 215 min30 天总结:读错题本、整理个人模板文件、写避坑清单
215 - 240 min做最后的自测(2 小时 AC 四题挑战,可另找时间)
💡 最后一天了

今天有两件事:上午学高精度(这是最后一个新知识点),下午做整月复盘。 高精度本身不难,但它是「必须会」的东西 —— 因为几乎每场比赛都会有一道大数题, 而且学会之后可以直接封装成模板,以后一辈子都能用。

1为什么需要高精度

先看 long long 到底能装多大

类型字节能表示的最大值十进制位数
short232,7675 位
int42,147,483,647(约 2.1×10⁹)10 位
unsigned int44,294,967,295(约 4.3×10⁹)10 位
long long89,223,372,036,854,775,807(约 9.2×10¹⁸)19 位
🎯 记住这个数:19

不管你的电脑多快、编译器多新,C++ 里最大的内置整数类型也就 19 位十进制数。 这是由「用固定字节存数」这件事本身决定的,跟语言无关。

但题目要你算的东西,动辄几百上千位

题目要求算的东西大概多少位
P1601a + b,a、b ≤ 10⁵⁰⁰501 位
P1303a × b,每个 ≤ 10²⁰⁰⁰4000 位
P2142a − b,每个 ≤ 10¹⁰⁰⁸⁶10086 位
P10091! + 2! + … + 50!65 位

看 P1009:50! 大约是 3×10⁶⁴,早就飞出 long long 的射程了。 更别说 P2142 的 10086 位数 —— 用 long long 连零头都存不下。

⚠️ 溢出的表现是「静默出错」

C++ 里整数溢出不会报错、不会崩溃、不会警告(开 -Wall 也不会), 它只会悄悄地把高位丢掉,给你一个看着挺正常但其实是错的数。

所以在竞赛里看到「大数」「阶乘」「乘积」「不超过 10^k」这类字眼, 第一反应应该是判断会不会爆 long long,而不是直接上手写。

高精度的本质:把一个大数拆成很多个「个位数」

既然一个变量装不下 1000 位,那就每一位存一个数组元素。 1000 位的数就用长度 1000 的数组存 —— 数组想开多长开多长,位数就没有上限了。

✅ 一句话概括

高精度 = 自己实现「小学竖式」。
你在小学学的加法进位、减法借位、乘法逐位相乘,就是全部的算法。 计算机只是把它一位一位地做一遍而已 —— 不需要任何新数学。

2统一模板:所有高精度题都靠这三件事

四道题看起来不一样(加法、减法、乘法、阶乘之和),但它们的骨架完全一样。 把骨架固化下来,就得到一套能反复用的模板。

第一件事:倒序存放(低位在数组前面)

小学竖式是从个位开始算的,因为进位是从低位往高位传的。 数组也应该这样:下标 0 存个位,下标 1 存十位,下标 2 存百位……

正常写法倒序存进数组
12345"12345"a[0]=5, a[1]=4, a[2]=3, a[3]=2, a[4]=1
1000"1000"a[0]=0, a[1]=0, a[2]=0, a[3]=1
💡 为什么必须倒过来

如果不倒序,a[0] 是最高位,那么「进位」就要从数组末尾往前传, 而且两个位数不同的数相加时,还得先把它们右对齐——非常麻烦。

倒序之后:a[i] 天然对应「第 i 位」,两个数的同一位就是同一个下标, 进位也天然从下标小往下标大走。整个算法变成了一次线性扫描。

第二件事:逐位运算 + 进位 / 借位

三种运算的逐位公式:

运算第 i 位的计算进位 / 借位怎么传
加法s = a[i] + b[i] + carry本位 = s % 10,进位 = s / 10
减法t = a[i] - b[i] - borrowt < 0t += 10、借位 = 1;否则借位 = 0
乘法c[i+j] += a[i] * b[j]先全部堆着,最后统一从低位到高位处理进位
🎯 加法和减法是一对,乘法是另一路

加法和减法边算边进位,一遍扫完就出结果。
乘法不能这样:99 × 99 里,c[1] 会被加两次(9×9 和 9×9), 所以必须先把所有乘积堆到对应位置上,最后再统一处理进位。这一点是初学者最容易写错的地方。

第三件事:去掉前导零

倒序存的时候,结果的高位可能会多出一堆 0。比如 1000 − 999 = 1, 算出来数组是 [1, 0, 0, 0],正着输出就是 0001 —— 错。

while(a.size() > 1 && a.back() == 0) a.pop_back();   // ★ 从高位往下砍 0
⚠️ > 1 这个条件不能省

如果写成 while(a.back() == 0) a.pop_back();,当结果是数字 0 时, 整个数组会被砍空,然后 a.back() 访问空数组 → 运行错误(RE)

a.size() > 1 的意思是:至少给我留一位。这样 0 就变成「一位的 0」, 输出正好是 0

完整的「六件套」模板

背下这套模板,四道题全部够用。强烈建议今天就把它抄进你自己的模板文件里。

#include <bits/stdc++.h>
using namespace std;

typedef vector<int> Big;            // 倒序存放:big[0] 是个位

// ① 字符串 → 倒序数组
Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

// ② 倒序数组 → 字符串(顺便去掉前导零)
string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();   // ★ 去前导零
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

// ③ 比较大小:a > b 返回 1,a < b 返回 -1,相等返回 0
int cmpBig(Big a, Big b){
    while(a.size() > 1 && a.back() == 0) a.pop_back();
    while(b.size() > 1 && b.back() == 0) b.pop_back();
    if(a.size() != b.size()) return a.size() < b.size() ? -1 : 1;
    for(int i = (int)a.size() - 1; i >= 0; i--)
        if(a[i] != b[i]) return a[i] < b[i] ? -1 : 1;
    return 0;
}

// ④ 高精度加法
Big addBig(const Big &a, const Big &b){
    Big c;
    int len = (int)max(a.size(), b.size());
    int carry = 0;                              // 进位
    for(int i = 0; i < len || carry; i++){       // ★ carry 也当循环条件
        int t = carry;
        if(i < (int)a.size()) t += a[i];
        if(i < (int)b.size()) t += b[i];
        c.push_back(t % 10);
        carry = t / 10;
    }
    return c;
}

// ⑤ 高精度减法(要求调用前保证 a >= b,结果非负)
Big subBig(const Big &a, const Big &b){
    Big c;
    int borrow = 0;                             // 借位
    for(int i = 0; i < (int)a.size(); i++){
        int t = a[i] - borrow - (i < (int)b.size() ? b[i] : 0);
        if(t < 0){ t += 10; borrow = 1; }       // ★ 不够减就借一位
        else borrow = 0;
        c.push_back(t);
    }
    return c;                                   // 前导零交给 toStr 处理
}

// ⑥ 高精度乘法
Big mulBig(const Big &a, const Big &b){
    if((a.size() == 1 && a[0] == 0) || (b.size() == 1 && b[0] == 0))
        return Big(1, 0);                       // 任何数乘 0 都是 0

    Big c(a.size() + b.size(), 0);              // 结果最多这么长
    for(int i = 0; i < (int)a.size(); i++)
        for(int j = 0; j < (int)b.size(); j++)
            c[i + j] += a[i] * b[j];            // ★ 先统统堆到 i+j 位上

    int carry = 0;                              // ★ 最后统一处理进位
    for(int i = 0; i < (int)c.size(); i++){
        int t = c[i] + carry;
        c[i]  = t % 10;
        carry = t / 10;
    }

    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}
💡 为什么用 vector<int> 而不是定长数组

定长数组要自己维护「当前有多长」这个变量(比如 int len), 传参时还得把长度一起传过去,很容易忘记更新。

vector 自带 size()push_back(), 长度就是数据的长度,不可能对不上。代价只是稍微慢一点点,完全可以接受。

3手推一遍加法和减法

加法:1001 + 9099

两个数倒序存:1001 → [1,0,0,1]9099 → [9,9,0,9]

ia[i]b[i]carry(进)t = 和c[i] = t%10carry(出)
01901001
10911001
2001110
31901001
41110

结果数组 [0,0,1,0,1],倒过来读是 10100 ✓(1001 + 9099 = 10100)

💡 最后多出来的那个 1 是哪来的

两个 4 位数相加,结果可能是 5 位数(比如 9999 + 1 = 10000)。 所以循环条件必须写 i < len || carry即使两个数的位都取完了,只要还有进位,就要再补一位。

如果只写 i < len9999 + 1 就会算成 0000 —— 这就是最经典的「加法进位丢失」bug。

减法:1000 − 999(用来看前导零)

1000 → [0,0,0,1]999 → [9,9,9]

ia[i]b[i]borrow(借)t = 差c[i]borrow(出)
0090−9 → +10 = 111
1091−10 → +10 = 001
2091−10 → +10 = 001
31没有这一位1000

结果数组 [1,0,0,0],正着读是 0001 —— 这里就必须去前导零了, 砍掉高位的 3 个 0,剩下 [1],输出 1

🎯 减法为什么要先比大小

模板里的 subBig(a, b) 只处理 a ≥ b 的情况,因为「借位」的定义就是「上一位借给下一位」, 如果 a < b,最高位会一直借下去,借出一个负数出来,这套逻辑就崩了。

所以标准写法是:

if(cmpBig(a, b) >= 0) cout << toStr(subBig(a, b)) << endl;
else                 cout << '-' << toStr(subBig(b, a)) << endl;  // 负数:输出负号 + 绝对值的差

先判断符号、再算绝对值 —— 这就是 P2142 的全部难点。

4高精度乘法:位置对齐是关键

核心洞察:a[i] × b[j] 的结果落在第 i + j

为什么是 i + j?用位数来想:a[i] 代表的是 a[i] × 10ⁱb[j] 代表 b[j] × 10ʲ,两者相乘就是 (a[i]×b[j]) × 10^(i+j) —— 10 的幂次是 i+j,所以它属于第 i+j 位。

手推:99 × 99

两个数都是 [9, 9]。先开一个长度为 2+2 = 4 的结果数组,全部填 0。

ij乘积累加到c 数组现在的样子
009×9 = 81c[0][81, 0, 0, 0]
019×9 = 81c[1][81, 81, 0, 0]
109×9 = 81c[1][81, 162, 0, 0] ← 同一个位置被加了两次!
119×9 = 81c[2][81, 162, 81, 0]

然后从低位到高位统一处理进位:

it = c[i] + carryc[i] 最终carry
081 + 0 = 8118
1162 + 8 = 170017
281 + 17 = 9889
30 + 9 = 990

结果数组 [1,0,8,9],倒过来读是 9801 ✓(99 × 99 = 9801)

🚫 乘法最容易错的两件事

1. 边乘边进位。如果你在 c[i+j] += a[i]*b[j] 的同时就处理进位, 后面还会再往 c[i+j] 里加东西,进位就乱了。 必须先全部堆好,再统一进位(因为 c[i+j] 会被访问多次)。

2. 结果是 0 的情况。0 × 12345,两层循环算出来 c 全是 0, 去前导零之后只剩一个 0,输出 0,其实是对的 —— 但如果结果数组长度为 0 就会崩。 模板里我直接特判了「任何数乘 0 都是 0」,最省心。

💡 结果数组开多长才够

m 位数 × n 位数,结果最多 m + n 位(比如 99 × 99 是 4 位)。 所以 Big c(a.size() + b.size(), 0); 一定够,多一个都不用。

比如 2000 位数相乘,结果数组开 4000 —— 和实际输出 4000 位正好吻合(我在实测里验证过)。

顺带一个更简单的兄弟:高精度 × 低精度

如果乘数是一个普通的 int(比如 P1009 里的 i! = (i−1)! × i, 这里的 i 只是个不超过 50 的小整数),那就根本不用两层循环

Big mulInt(const Big &a, int k){
    if(k == 0) return Big(1, 0);
    Big c;
    long long carry = 0;
    for(int i = 0; i < (int)a.size() || carry; i++){
        long long t = carry;
        if(i < (int)a.size()) t += (long long)a[i] * k;   // ★ 一位对上整个 k
        c.push_back((int)(t % 10));
        carry = t / 10;
    }
    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}

它其实就是「把乘法退化成加法」:每一位乘 k 再传进位,一遍线性扫描结束。 P1009 用它比自己写高精度乘法快得多也好写得多。

⚠️ 这里的 carry 必须用 long long

a[i] * k 最大是 9 × 50 = 450,加上进位也远没超 int。 但如果 k 是个更大的数(比如 10⁹),9 × 10⁹ 就爆 int 了。 养成习惯:乘法里所有中间变量一律用 long long

5四道例题详解

下面每道题的代码都用 g++ -std=c++14 -O2 -Wall 实际编译并运行验证过。为了每份代码都能独立编译,我只贴出本题用得到的那几个函数 (完整的「六件套」在第 2 节)。

P1601 高精度加法 普及- · 加法模板

题意:给定两个非负整数 a、b,求 a + b。题目明确说了不用考虑负数

⚠️ 数据范围 / 关键约束

20% 的数据 a, b ≤ 10⁹;40% 的数据 a, b ≤ 10¹⁸;100% 的数据 0 ≤ a, b ≤ 10⁵⁰⁰

看清楚:10⁵⁰⁰ 是 1 后面 500 个 0,也就是最多 501 位
long long 只有 19 位,所以 40% 那两档用 long long 能过, 但满分必须用高精度。这就是"子任务分"的典型结构 —— 比赛时不会写高精度也先把 40 分拿了。

另外注意 a、b 都可以是 0,别漏了 0 + 0 这种情况。

思路

就是第 3 节手推的那套:读成字符串 → 倒序存 → 逐位相加并传进位 → 去前导零 → 倒着输出拼回字符串。

关键的一句是循环条件 i < len || carry,它保证了「最高位进位」不会丢。

样例输入 1
1
1
样例输出 1
2
样例输入 2
1001
9099
样例输出 2
10100

完整代码

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;

typedef vector<int> Big;            // 倒序存放:big[0] 是个位

Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();   // ★ 去前导零
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

Big addBig(const Big &a, const Big &b){
    Big c;
    int len = (int)max(a.size(), b.size());
    int carry = 0;                              // 进位
    for(int i = 0; i < len || carry; i++){       // ★ carry 也当循环条件
        int t = carry;
        if(i < (int)a.size()) t += a[i];
        if(i < (int)b.size()) t += b[i];
        c.push_back(t % 10);
        carry = t / 10;
    }
    return c;
}

int main(){
    string sa, sb;
    cin >> sa >> sb;
    cout << toStr(addBig(toBig(sa), toBig(sb))) << endl;
    return 0;
}
✅ 实测结果

官方样例 1 1 / 12 完全一致

官方样例 2 1001 / 909910100 完全一致

边界 0 / 00 正确(去前导零的 > 1 条件生效,没把数组砍空)

边界 10⁵⁰⁰ / 1(1 后面 500 个 0 加上 1)→ 输出 501 位,开头是 1000…、结尾是 …0001 正确

边界 500 个 9 加 1 → 输出 1 后面 500 个 0,共 501 位 正确(这是纯进位测试,验证了 || carry 那一句)

随机对拍:用 40 组随机大数(位数 1 ~ 60)和 PowerShell 的 bigint 精确值逐一比对, 加法 40 组全部一致(同一次对拍里还顺带验证了乘法和减法,见下面两题)。

易错点

  • 循环条件漏了 || carry999 + 1 会输出 000
  • cin >> long long:501 位的数读进来直接失败(这是 40 分和 100 分的分界线)。
  • 忘了去前导零1 + 1 会输出 02
  • 字符串转数组时忘了减 '0':存进去的是 ASCII 码 48 ~ 57,算出来全是错的。
  • a、b 有前导零:题目说「非负整数」,理论上输入可能写成 00123。 我的模板对这种情况天然兼容(去前导零在 toStr 里做了,输入有前导零也不影响求和)。
P2142 高精度减法 普及- · 减法 + 前导零

题意:给定两个正整数 a、b,求 a − b。如果结果是负数,要输出负号。

⚠️ 数据范围 / 关键约束

20% 的数据 a、b 在 long long 存储范围内; 100% 的数据 0 < a, b ≤ 10¹⁰⁰⁸⁶

注意两点:① 10¹⁰⁰⁸⁶ 是 10087 位数,相当大(这就是为什么必须用 vector 而不是固定数组 —— 也有人直接开 int[10100]); ② 题目没说 a ≥ b,而且输出格式里专门写了「如果 a − b < 0,请输出负号」, 所以结果可能是负的,必须处理

思路

减法模板只能算「大减小」,所以分两步:

  1. 先用 cmpBig 比较 a 和 b 的大小。
  2. 如果 a ≥ b,直接输出 subBig(a, b);否则输出一个负号,再输出 subBig(b, a)
🎯 cmpBig 怎么写才不出错

不能直接比字符串("100" < "99"的,但 100 > 99)。正确的比较顺序是:

① 先去掉两边的前导零(否则 "099" 会被当成 3 位数)
② 位数不同 → 位数多的更大
③ 位数相同 → 从最高位往下逐位比,第一个不同的位决定大小
④ 全都相同 → 相等

样例输入
2
1
样例输出
1

完整代码

#include <iostream>
#include <string>
#include <vector>
using namespace std;

typedef vector<int> Big;

Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

// 比较大小(先去掉前导零再比):a > b 返回 1,a < b 返回 -1,相等返回 0
int cmpBig(Big a, Big b){
    while(a.size() > 1 && a.back() == 0) a.pop_back();   // ★ 必须先规范化
    while(b.size() > 1 && b.back() == 0) b.pop_back();
    if(a.size() != b.size()) return a.size() < b.size() ? -1 : 1;   // 位数多的更大
    for(int i = (int)a.size() - 1; i >= 0; i--)                      // 从高位往下逐位比
        if(a[i] != b[i]) return a[i] < b[i] ? -1 : 1;
    return 0;
}

// 高精度减法(要求已经保证 a >= b,结果非负)
Big subBig(const Big &a, const Big &b){
    Big c;
    int borrow = 0;                             // 借位
    for(int i = 0; i < (int)a.size(); i++){
        int t = a[i] - borrow - (i < (int)b.size() ? b[i] : 0);
        if(t < 0){ t += 10; borrow = 1; }       // ★ 不够减,向前借一位
        else borrow = 0;
        c.push_back(t);
    }
    return c;                                   // 前导零交给 toStr 处理
}

int main(){
    string sa, sb;
    cin >> sa >> sb;

    Big a = toBig(sa), b = toBig(sb);

    if(cmpBig(a, b) >= 0){
        cout << toStr(subBig(a, b)) << endl;
    }else{
        cout << '-' << toStr(subBig(b, a)) << endl;   // ★ 负号 + 绝对值的差
    }
    return 0;
}
✅ 实测结果

官方样例 2 / 11 完全一致

前导零测试 1000 / 9991 正确(不是 0001

负数测试 1 / 2-1 正确(负号输出正确)

相等测试 1000000 / 10000000 正确(不是空输出、也不崩)

最大规模 10¹⁰⁰⁸⁶ / 1(a 是 1 后面 10086 个 0,即 10087 位数,正好是题面上限) → 输出 10086 位,每一位都是 9,与 10¹⁰⁰⁸⁶ − 1 的理论值逐字符精确一致 正确

随机对拍:40 组随机大数(位数 1 ~ 60,故意让 a 可能小于 b)和 bigint 精确值比对, 减法 40 组全部一致,含负数结果也正确

易错点

  • 直接用字符串比大小"100" < "99",位数不同的比较一定错。必须比长度
  • 比大小前忘了去前导零"099" 长度是 3,会被误判成比 "100" 还大吗? 其实 "099" vs "100" 长度相同,逐位比第一位 0 < 1 就对了; 但 "099" vs "9" 长度 3 > 1 就会误判成 099 > 9。所以 先规范化
  • 忘了 a < b 的情况:只写 subBig 不判符号,会输出一堆乱码(借位借穿了)。
  • 负数结果是 0 的时候1 − 1 = 0cmpBig 返回 0,走的是 >= 0 分支, 输出 0不会输出 -0。如果你写成 if(cmpBig(a,b) > 0), 那么 1 − 1 就会走 else 分支输出 -0 —— 这是个很隐蔽的坑
  • 借位忘了减t = a[i] - b[i] 少了 - borrow,结果会偏大。
P1303 A*B Problem 普及- · 乘法模板

题意:给出两个非负整数,求它们的乘积。

⚠️ 数据范围 / 关键约束

每个非负整数不超过 10²⁰⁰⁰。

10²⁰⁰⁰ 是 2001 位数,乘起来结果是 4000 位左右。 题目说「非负」,所以 0 也要能正确处理

另外这题没有子任务分long long 一分都拿不到,必须老老实实写高精度。

思路

套第 4 节的乘法模板。三个关键点再强调一次:

  1. 结果数组开 a.size() + b.size() 位 —— m 位数乘 n 位数最多 m + n 位。
  2. c[i + j] += a[i] * b[j]先全部堆着
  3. 最后从低位到高位统一处理进位。
样例输入
1 
2
样例输出
2

完整代码

#include <iostream>
#include <string>
#include <vector>
using namespace std;

typedef vector<int> Big;

Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

// 高精度乘法
Big mulBig(const Big &a, const Big &b){
    if((a.size() == 1 && a[0] == 0) || (b.size() == 1 && b[0] == 0))
        return Big(1, 0);                       // ★ 任何数乘 0 都是 0

    Big c(a.size() + b.size(), 0);              // 结果最多这么长
    for(int i = 0; i < (int)a.size(); i++)
        for(int j = 0; j < (int)b.size(); j++)
            c[i + j] += a[i] * b[j];            // ★ 先统统堆到 i+j 位上

    int carry = 0;                              // ★ 最后统一处理进位
    for(int i = 0; i < (int)c.size(); i++){
        int t = c[i] + carry;
        c[i]  = t % 10;
        carry = t / 10;
    }

    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}

int main(){
    string sa, sb;
    cin >> sa >> sb;
    cout << toStr(mulBig(toBig(sa), toBig(sb))) << endl;
    return 0;
}
✅ 实测结果

官方样例 1 / 22 完全一致

边界 0 / 9990 正确(乘 0 特判生效)

999 / 999998001 正确

20 个 9 × 20 个 99999999999999999999800000000000000000001(40 位) 正确 (验算:(10²⁰−1)² = 10⁴⁰ − 2×10²⁰ + 1

最大规模 2000 个 9 × 2000 个 9 → 输出 4000 位,和理论值 (10²⁰⁰⁰−1)² = 10⁴⁰⁰⁰ − 2×10²⁰⁰⁰ + 1 的字符串逐字符精确比对通过(1999 个 9、1 个 8、1999 个 0、1 个 1)。

2000 个 7 × 1 → 输出 2000 位,和输入完全一样 正确(边界:乘数是 1 位数)

随机对拍:40 组随机大数(位数 1 ~ 60)与 bigint 精确值比对,乘法 40 组全部一致

易错点

  • 边乘边进位c[i+j] 会被加多次,先堆后进位是硬性要求。
  • 结果数组开小了:开成 a.size() + b.size() - 1 在最高位有进位时会越界。 直接开 a.size() + b.size(),多一位不用白不用。
  • 最多只有 4000 位,别开 10⁶ 的数组:按输入长度动态开就好,用 vector 最省心。
  • 乘数是 0 时输出了空串:如果不用 toStr 里的 > 1 保护,数组会被砍空。
  • 这题没有负数和前导零的坑(题面说非负整数),但下一题 P1009 会用到乘法模板的变体。
P1009 [NOIP 1998 普及组] 阶乘之和 普及- · 高精度综合应用

题意:用高精度算出 S = 1! + 2! + 3! + … + n!,n ≤ 50。

⚠️ 数据范围 / 关键约束

1 ≤ n ≤ 50。

看起来很小,但 50! 大约是 3.04×10⁶⁴,有 65 位 —— long long 差得远。

题目里还有一句提醒很值得注意:《深入浅出基础篇》拿这题当例题,但书上数据只有 n ≤ 20, 用书里的代码过不了本题。 20! 约 2.4×10¹⁸ 刚好卡在 long long 边缘 —— 这正是「数据范围决定算法」的活教材。

思路

不要想着「先算 50! 再回头算 49!」—— 那样每次都要重算一遍乘法,很浪费。递推着来:

步骤做什么用哪个模板函数
初始fact = 1(代表 0!),sum = 0
循环 i = 1…nfact = fact × i → 得到 i!mulInt(高精度 × 低精度)
同上sum = sum + factaddBig
结束输出 sumtoStr
🎯 为什么这里用「高精度 × 低精度」而不是完整乘法

因为乘数 i 最大就是 50,是个普通 intmulInt 只要一遍线性扫描(O(位数)),而 mulBig 要两层循环(O(位数²))。
n = 50 时位数才 65,两者都很快,但 「能用低精度就别上高精度」是个好习惯 —— 如果哪天要算 100000!,差距就是几万倍。

先手算一遍小数据(n = 3)

ifact = fact × isum = sum + fact
开始10
11 × 1 = 10 + 1 = 1
21 × 2 = 21 + 2 = 3
32 × 3 = 63 + 6 = 9

1! + 2! + 3! = 1 + 2 + 6 = 9 ✓ 正好是样例输出。

样例输入
3
样例输出
9

完整代码

#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;

typedef vector<int> Big;

Big toBig(const string &s){
    Big a;
    for(int i = (int)s.size() - 1; i >= 0; i--) a.push_back(s[i] - '0');
    return a;
}

string toStr(Big a){
    while(a.size() > 1 && a.back() == 0) a.pop_back();
    string s;
    for(int i = (int)a.size() - 1; i >= 0; i--) s += char('0' + a[i]);
    return s;
}

// 高精度加法
Big addBig(const Big &a, const Big &b){
    Big c;
    int len = (int)max(a.size(), b.size());
    int carry = 0;
    for(int i = 0; i < len || carry; i++){
        int t = carry;
        if(i < (int)a.size()) t += a[i];
        if(i < (int)b.size()) t += b[i];
        c.push_back(t % 10);
        carry = t / 10;
    }
    return c;
}

// 高精度 × 低精度(k 是一个普通的 int)
Big mulInt(const Big &a, int k){
    if(k == 0) return Big(1, 0);
    Big c;
    long long carry = 0;
    for(int i = 0; i < (int)a.size() || carry; i++){
        long long t = carry;
        if(i < (int)a.size()) t += (long long)a[i] * k;   // ★ 一位对上整个 k
        c.push_back((int)(t % 10));
        carry = t / 10;
    }
    while(c.size() > 1 && c.back() == 0) c.pop_back();
    return c;
}

int main(){
    int n;
    cin >> n;

    Big fact = toBig("1");      // 当前的阶乘 i!
    Big sum  = toBig("0");      // 累加和

    for(int i = 1; i <= n; i++){
        fact = mulInt(fact, i);     // i! = (i-1)! * i
        sum  = addBig(sum, fact);   // 累加
    }

    cout << toStr(sum) << endl;
    return 0;
}
✅ 实测结果

官方样例 39 完全一致

边界 n = 11 正确

n = 50(最大数据) → 输出 65 位
31035053229546199656252032972759319953190362094566672920420940313

全程对拍:把 n = 1, 2, 3, …, 50 全部 50 个答案都算出来, 和用任意精度整数(bigint)独立算出的精确值逐一比对,50 个全部一致,0 个失败

易错点

  • fact 初始化成 0:那样整个循环乘出来一直是 0,答案也是 0。 必须初始成 1(0! = 1 是数学定义)。
  • 每次重新从头算阶乘for i: for j = 1..i: fact *= j, 虽然 n ≤ 50 也能过,但完全没必要 —— 用递推 i! = (i−1)! × i 一遍就够。
  • 乘低精度时进位用 int:k 一大就溢出了。统一用 long long
  • 忘了 sum 也要用高精度:50 个 65 位的数相加,肯定也是高精度。
  • 以为 n ≤ 20 就够了:题目提示专门说了书上代码只支持 n ≤ 20, 必须真的用高精度才能过 n = 50。

今日常见错误汇总

#错误写法后果正确做法
1 正序存(a[0] 是最高位) 进位方向反了,两个不同位数的数还得右对齐,代码立刻复杂一倍 低位在数组前面a[0] = 个位
2 转数组时忘了 - '0' 存的是 ASCII 码 48~57,算出来全是垃圾 a.push_back(s[i] - '0')
3 加法循环只写 i < len 999 + 1 = 000,最高位进位丢掉了 i < len || carry
4 去前导零写 while(a.back()==0) pop_back() 结果是 0 时数组被砍空 → RE 加保护:while(a.size() > 1 && a.back()==0)
5 忘了去前导零 1000−999 输出 0001 统一在 toStr 里做,所有输出都过一遍
6 减法不比较大小 a < b 时借位借穿,输出乱码 cmpBig,负数就输出负号 + subBig(b,a)
7 用字符串直接比大小 "100" < "99" 恒为真,判断全反 先比位数,位数相同再从高位逐位比
8 乘法边乘边进位 c[i+j] 后面还会被累加,进位全乱 先全部 c[i+j] += a[i]*b[j]最后统一进位
9 乘法结果数组开 m+n-1 最高位有进位时越界 a.size() + b.size()
10 P1009 的 fact 初始化成 0 答案恒为 0 初始化成 1(0! = 1)
11 中间变量用 int 乘法/进位悄悄溢出 高精度里的乘法、进位一律 long long
12 看到大数才想起 long long 不够 WA 半天找不到原因(溢出无提示) 读到「≤10^k」先估位数,超过 18 位就要高精度

自测:你真的会了吗

第 1 题:为什么高精度要「倒序存放」?

:因为进位和借位都是从低位往高位传的。

倒序存放(a[0] = 个位)之后,第 i 位就是下标 i,进位只需要「往后一个下标传」, 整个算法变成一次从下标 0 到末尾的线性扫描。

顺带还解决了位数对齐问题:1234 + 56,倒序后是 [4,3,2,1][6,5], 第二位直接取 a[1]=3b[1]=5,天然的个位对齐个位。如果正序存,还得先把两个数右对齐

第 2 题:9999 + 1 用模板算,结果数组会是什么?

[0, 0, 0, 0, 1],倒过来读是 10000

9999 → [9,9,9,9]1 → [1]

i=0: 9+1 = 10 → c[0]=0, carry=1
i=1: 9+0+1 = 10 → c[1]=0, carry=1
i=2: 9+0+1 = 10 → c[2]=0, carry=1
i=3: 9+0+1 = 10 → c[3]=0, carry=1
i=4: i < len(4) 不成立,但 carry = 1 成立 → 再补一位 c[4]=1

如果循环条件少了 || carry,第 5 位就不会产生,输出变成 0000

第 3 题:手推 12 × 34,把乘法的两个阶段都写出来。

12 → [2,1]34 → [4,3]。结果数组长度 2+2 = 4,初始 [0,0,0,0]

阶段一 · 堆乘积

  • i=0,j=0: 2×4 = 8 → c[0] = 8
  • i=0,j=1: 2×3 = 6 → c[1] = 6
  • i=1,j=0: 1×4 = 4 → c[1] = 6+4 = 10
  • i=1,j=1: 1×3 = 3 → c[2] = 3

得到 [8, 10, 3, 0]注意 c[1] = 10 已经超过 9 了,这是正常的,进位留到第二阶段。

阶段二 · 统一进位

  • i=0: t = 8+0 = 8 → c[0]=8, carry=0
  • i=1: t = 10+0 = 10 → c[1]=0, carry=1
  • i=2: t = 3+1 = 4 → c[2]=4, carry=0
  • i=3: t = 0+0 = 0 → c[3]=0, carry=0

[8,0,4,0] → 去前导零 → [8,0,4] → 倒读 408 ✓(12 × 34 = 408)

第 4 题:为什么 P1009 用 mulInt 而不直接用 mulBig

:因为乘数 i 是个小整数(≤ 50),不是高精度数。

mulInt 把「每一位乘 k」一次扫完,复杂度 O(位数); mulBig 要两层嵌套循环,复杂度 O(位数²)。 把 i 硬转成高精度再乘,白白多算一位 × 一位的乘法。

n = 50 时两者都很快,但习惯要养对:能用低精度的场合就别上高精度。

第 5 题:0 × 0 用乘法模板算,会发生什么?

:模板里第一句就特判了:a[0]==0 || b[0]==0 时直接返回 Big(1, 0), 也就是「长度为 1、内容为 0」的数组。经过 toStr 输出就是 0

如果不特判:c 长度是 1+1 = 2,全是 0;去前导零后剩 [0],输出也是 0 —— 其实也对。 特判主要是为了省掉无谓的循环,同时避免「长度为 0 的数组」这种危险状态。

速查卡(截图存手机)

【高精度 · 三件事】
  ① 倒序存      a[0] 是个位,a[1] 是十位 …
  ② 逐位算      + 进位 / - 借位 / × 先堆后排
  ③ 去前导零    while(size > 1 && back() == 0) pop_back();

【为什么倒序】
  进位、借位都是从低位往高位传
  倒序后第 i 位 = 下标 i,天然对齐,线性扫一遍

【六件套】
  Big  toBig(const string &s);     // 字符串 → 倒序数组
  string toStr(Big a);             // 倒序数组 → 字符串(去前导零)
  int  cmpBig(Big a, Big b);       // 先比位数,再逐位比
  Big  addBig(const Big &a, const Big &b);
  Big  subBig(const Big &a, const Big &b);   // 要求 a >= b
  Big  mulBig(const Big &a, const Big &b);
  Big  mulInt(const Big &a, int k);          // 高精度 × 低精度

【加法】
  carry = 0;
  for(i = 0; i < len || carry; i++){
      t = carry + a[i] + b[i];
      c.push_back(t % 10);
      carry = t / 10;
  }
  // ★ 少了 "|| carry",999+1 会变成 000

【减法】
  borrow = 0;
  for(i = 0; i < a.size(); i++){
      t = a[i] - borrow - b[i];
      if(t < 0){ t += 10; borrow = 1; } else borrow = 0;
      c.push_back(t);
  }
  // 先用 cmpBig 判符号:a<b 就输出 '-' 再算 subBig(b,a)
  // ★ 相等时走 >= 分支,不会输出 -0

【乘法】
  // 阶段一:先堆,不进位
  c.assign(a.size() + b.size(), 0);
  for(i) for(j) c[i + j] += a[i] * b[j];
  // 阶段二:统一进位
  carry = 0;
  for(i = 0; i < c.size(); i++){ t = c[i] + carry; c[i] = t % 10; carry = t / 10; }
  // ★ a[i]*b[j] 落在第 i+j 位
  // ★ 结果最多 m+n 位

【什么时候该上高精度】
  ≤ 9 位   → int
  ≤ 18 位  → long long
  > 18 位  → 高精度
  看到「阶乘」「大数乘积」「≤10^k 且 k ≥ 19」→ 立刻警觉

【P1009 阶乘之和】
  fact = 1;  sum = 0;
  for(i = 1..n){ fact = mulInt(fact, i); sum = addBig(sum, fact); }
  // ★ fact 初始是 1 不是 0(0! = 1)

🎓30 天总结 · 你走过的路

恭喜你走到这里。回头看,30 天前你还在为「输入两个数输出它们的和」调格式, 今天你已经能手写高精度乘法、能分析二分答案的单调性、能设计出背包的状态转移方程了。 这不是一点点进步,这是换了一个人。

知识脉络:这 30 天到底学了什么

阶段主题核心收获
Day 1–6 语法与数组基本功
评测机制与输入输出 / 循环与累加(整数浮点陷阱)/ while 与终止条件 / 多重循环与图形输出 / 一维数组 / 桶计数
把「想法翻译成代码」这件事练到不用想;理解数据范围决定变量类型;学会「用值当下标」这个影响深远的思路
Day 7–14 二维数组、字符串、函数与贪心
二维数组 / 字符与字符串 / 数字进制格式 / 函数 / 结构体 / 排序 / 贪心入门 / 贪心进阶
从「写一段代码」升级到「写一个模块」;掌握 sort 和自定义比较函数;第一次接触「证明贪心正确性」这件事
Day 15–21 前缀和、递归、搜索
前缀和与差分 / 递归 / 递推与记忆化 / DFS 入门 / DFS 回溯 / BFS 入门 / BFS 进阶
学会「用预处理换查询速度」;掌握递归的思考方式;能独立写出 DFS 回溯和 BFS 最短路 —— 这是第一次真正意义上的算法
Day 22–28 二分、动态规划、栈与队列
二分查找 / 二分答案 / 二分综合 / 线性 DP / 0-1 背包 / 完全背包 / 栈与队列
掌握「二分答案」这个把求解变判定的强套路;跨过 DP 这道大坎(状态设计 + 转移方程 + 边界);熟悉 STL 容器
Day 29–30 模拟赛 + 高精度
限时模拟赛 / 高精度加减乘、阶乘之和
把前面所有基本功在压力下用一遍;补上最后一块拼图 —— 没有内置类型够用时的兜底手段

回头看:这 30 天你反复在学的其实是同一件事

🎯 三条贯穿始终的主线

主线一:用空间换时间。桶计数是(用数组换 O(1) 查询)、前缀和是(用数组换 O(1) 区间和)、 记忆化搜索是(用数组换重复子问题)、DP 也是(用数组换指数级枚举)。这是算法里最核心的交易。

主线二:把大问题切成小问题。递归、分治、二分、DP —— 全都是这一个思想的不同外衣。

主线三:先把边界想清楚再写代码。数组开多大、从 0 还是 1 开始、 空集怎么处理、n = 0 / n = 1 怎么办 —— 你这一个月踩的坑,十有八九都在这里。

整月总结任务(今天就做完)

  1. 统计你的 AC 数量。按难度分三类:入门 / 普及- / 普及提高-。 数字本身不重要,重要的是看哪一类最少 —— 那就是你的短板。
  2. 把整月的错题本从头读一遍。不要重做,只读。然后把反复犯的错误写成一张 「个人避坑清单」,贴在电脑旁边。常见的几条:
    • 数组开小了 / 下标从 1 开始但只开了 n
    • 该开 long long 却开了 int
    • 循环边界写成 <=< 搞反
    • 输出格式:多余空格、少空格、大小写、换行
    • 多测数据没有清空全局数组
    • 去前导零 / 空集 / n = 0 这些边界没处理
  3. 建立你自己的代码模板文件。建议包含: 万能头与快读、素数筛、二分模板(整数 + 浮点)、DFS / BFS 模板、背包模板、 以及今天这套高精度六件套。写一次,用一辈子。
  4. 完成最终自测(见下)。

最终自测:2 小时内独立 AC 这 4 题

题号题目考什么难度
P1047校门外的树数组标记(Day 5–6 的基本功)入门
P1094纪念品分组排序 + 贪心双指针(Day 12–13)普及-
P1605迷宫DFS 回溯 + 网格(Day 19)普及-
P1048采药0/1 背包(Day 26)普及-
✅ 四题全部 AC,说明什么

说明你已经具备参加新生赛的实力了。

这四道题分别考察数组、贪心、搜索、DP —— 正好覆盖了新生赛最常考的四个方向, 而且没有一道是「偏题」。能两小时内全过,你的基本功是扎实的。

如果卡住了,别灰心,去翻对应的那一天: 哪一题卡住,就说明那一块需要二刷。这本身就是最有价值的诊断结果。

下一步建议

方向具体建议
短期
(1–2 周)
· 把 30 天里所有没做出来或看了题解才会的题重写一遍(不看题解)
· 在洛谷「官方题单 → 新手村 / 普及练习场」里按专题刷,每个专题刷 5–10 题
· 重点补:DP搜索。这两块是分水岭,也是 30 天里练得最少的
中期
(1–2 月)
· 学新知识点:并查集、图论基础(最短路、最小生成树)、树与二叉树、STL 深水区(map / set / priority_queue)
· 开始打 洛谷月赛 / Div.3 难度的比赛,每场都写赛后复盘
· 建立「一题三刷」习惯:第一遍看思路、第二遍不看题解、第三遍限时重写
长期 · 目标定在 CSP-J / 蓝桥杯省赛 这类入门级赛事
· 从「会做」升级到「做得快、写得稳」—— 这需要模板化 + 限时训练
· 保持一周至少 5 题的节奏,比一次爆刷 50 题有用得多
习惯 · 每道 AC 的题都写 2–3 行题解笔记(记录关键洞察,不用长)
· 每场比赛后统计:哪题超时、哪题 WA 几次、下次怎么改
· 永远先看数据范围,再想算法
💌 最后几句话

算法竞赛最有趣的地方在于:你不会的东西,一定是能被学会的。 今天让你抓耳挠腮的 DP,三个月后就是肌肉记忆;

这 30 天你最大的收获其实不是某个具体算法,而是「遇到不会的题不慌,能自己拆解、查资料、试错、最终解决」的能力。 这个能力跟竞赛无关,但会跟着你很久。

计划到这里就结束了,但你的路才刚开始。去做那道你一直不敢点开的题吧。 🎉