ACM 学习篇 01:复杂度、输入输出和代码模板

上一篇先把 ACM 学习篇的目录 画了出来。这一篇开始真正学习,但先不碰具体算法。

原因很简单:复杂度、输入输出和代码模板,是后面所有专题的基本装备。很多题还没到“不会算法”的程度,就已经死在数据范围看错、输入格式没处理好、变量类型溢出、模板写得别扭这些地方了。

一、先看数据范围

拿到一道题,不要急着写代码,先看数据范围。数据范围基本上是在告诉你:这个题允许你用多慢的算法。

比如题目给了 n <= 100,你可以考虑三重循环、区间 DP、Floyd;如果给了 n <= 2e5,大概率就不能写 O(n^2),要往排序、二分、前缀和、树状数组、线段树、图的线性遍历这些方向想。

一个粗略但很实用的判断表:

数据范围通常能接受的复杂度常见方向
n <= 10O(n!)O(2^n)全排列、状态压缩、爆搜
n <= 20O(2^n * n)状压 DP、折半搜索
n <= 100O(n^3)Floyd、区间 DP
n <= 1000O(n^2)普通 DP、二维枚举
n <= 1e5O(n log n)排序、二分、堆、树状数组
n <= 1e6O(n)O(n log n)线性扫描、前缀和、筛法
n >= 1e7接近 O(n)极简线性算法、注意常数

这个表不是绝对标准,因为机器、语言、时限、常数都会影响结果。但它能帮我们建立第一反应:先从规模反推复杂度,再从复杂度反推算法。

二、复杂度不是背出来的

复杂度说白了,就是看代码大概要执行多少次核心操作。

一层循环通常是 O(n)

for (int i = 0; i < n; i++) {
    // O(1)
}

两层独立循环通常是 O(n^2)

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        // O(1)
    }
}

如果每次规模减半,通常会出现 log n

while (l <= r) {
    int mid = (l + r) / 2;
    if (check(mid)) r = mid - 1;
    else l = mid + 1;
}

学习复杂度时,不要只背“二分是 O(log n)”。更重要的是形成感觉:循环走多少次,状态有多少个,每个状态转移要算多久。

三、输入输出先写稳

ACM 题很少有交互式界面,大部分都是标准输入和标准输出。输入输出看起来简单,但格式一错,后面全白写。

C++ 里我一般先放这两行:

ios::sync_with_stdio(false);
cin.tie(nullptr);

它们的作用是让 cin / cout 更快。用了之后就不要再混用 scanf / printf,除非你很清楚自己在做什么。

常见输入格式大概有三类。

第一类:题目给定测试组数 T

int T;
cin >> T;
while (T--) {
    solve();
}

第二类:一直读到文件结束。

int a, b;
while (cin >> a >> b) {
    cout << a + b << '\n';
}

第三类:先读规模,再读数组或图。

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

输出也要保持克制。多组数据尽量用 '\n',不要频繁用 endl,因为 endl 会强制刷新缓冲区,通常没有必要。

四、变量类型要保守一点

算法竞赛里最常见的隐形错误之一是溢出。

int 大概能到 2.1e9。如果题目里有 1e5 个数,每个数最大 1e9,总和就可能到 1e14,这时必须用 long long

long long sum = 0;
for (int x : a) {
    sum += x;
}

我的习惯是:数组下标、计数、小范围循环可以用 int;答案、前缀和、乘法结果、距离、权值,优先考虑 long long

还有一个容易忽略的点:两个 int 相乘,结果会先按 int 算,再赋值给 long long。所以要提前转型。

long long area = 1LL * width * height;

五、一份基础模板

模板不是为了显得专业,而是为了减少每次开始写题时的摩擦。模板越稳定,注意力越能留给题目本身。

我会先用这份基础版:

#include <bits/stdc++.h>
using namespace std;

using ll = long long;

const int INF = 0x3f3f3f3f;
const ll LINF = 4e18;
const int MOD = 1e9 + 7;

void solve() {
    // 每道题的核心逻辑写在这里
}

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

    int T = 1;
    // 如果题目有多组数据,就打开下面两行
    // cin >> T;
    while (T--) {
        solve();
    }

    return 0;
}

这份模板只保留最基础的东西:快速输入输出、常用类型、常量和 solve()。后面学到图论、线段树、并查集、字符串算法时,再逐步把专题模板加进去。

六、读题时的第一轮检查

真正做题时,我希望自己先养成一个固定动作。读完题后,不急着写代码,先检查这几件事:

  • 输入有几组数据,是给 T 还是读到 EOF;
  • nm、值域分别多大;
  • 答案会不会超过 int
  • 要求的是最值、计数、可行性,还是具体方案;
  • 有没有排序后更简单、二分后更简单、前缀和后更简单;
  • 边界情况是什么,比如空、一个元素、全相等、全负数、不连通。

这几个问题看起来普通,但它们能把很多题从“没头绪”变成“有方向”。

七、这一篇先记住什么

这一篇不用记很多术语,先记住三句话就够了。

  • 数据范围会提示复杂度,复杂度会提示算法方向。
  • 输入输出和变量类型要先写稳,不要让低级错误挡住真正的问题。
  • 模板不是答案,只是让自己更快进入思考状态。

下一篇可以进入第一个真正常用的基础工具:排序与二分。二分看起来只是查找,其实它背后更重要的是“单调性”这个思想。