29:别让 TLE 和 MLE 偷走你的分——复杂度估算与数据范围速查

29:别让 TLE 和 MLE 偷走你的分——复杂度估算与数据范围速查

在前面的文章中,我们学习了文件重定向操作 freopen。有了它,我们的代码就可以规范地在评测机上进行输入和输出。

但在实际的信奥比赛(如 GESP、CSP-J/S)中,光是写出“能运行出正确答案”的代码是不够的。每道题目都对资源有着极其严格的限制,通常表现为:

  • 时间限制:例如 1.0s(1.0 秒)
  • 空间限制:例如 256MB(256 兆字节)

如果你的程序运行时间超过了限制,就会遭遇 TLE(Time Limit Exceeded,运行超时);如果占用的内存超过了限制,就会遭遇 MLE(Memory Limit Exceeded,内存超限)。两者都会导致你失去该测试点的分数。

那么,我们该如何预估自己的代码会不会超时或超内存呢?这就需要用到时间复杂度空间复杂度。本文不谈复杂的数学公式和推导,我们直接用具体的代码实例直观的数据大小,带大家快速建立对复杂度的感觉。

往期回顾:


一、 时间复杂度:1 秒钟,程序能跑多少次?

在计算机科学中,时间复杂度用大写字母 OO(Big O)来表示。我们不需要去抠它的数学定义,只需要记住一个在信奥竞赛中通用的“黄金法则”:

Important

竞赛黄金法则: 在主流的信奥评测机上,C++ 程序在 1 秒钟内 大约可以执行 10810^8(1 亿)次 基础运算(如加减乘除、大小比较、数组读写等)。

利用这个法则,我们来看看常见的时间复杂度,它们在代码中长什么样,以及在 1 秒的限制下,它们能处理多大的数据量(通常用 NN 表示)。


1.1 常见时间复杂度实例与数据范围

Note

什么是“数据范围 NN”? 在算法比赛中,每道题都会在“数据范围”一栏明确写出 NN 的最大值(例如 N105N \le 10^5N1018N \le 10^{18}),代表测试数据的最大规模。 下文中的 【数据范围 NN 是指:在 1 秒的时间限制下,如果题目给出的 NN 在这个级别内,我们使用该时间复杂度的算法是安全的(不会超时)。 例如,如果题目给出的 NN 达到了 101810^{18} 级别,你就绝对不能使用 O(N)O(N) 算法(需要跑几百年),而必须使用 O(logN)O(\log N)O(1)O(1) 级别的算法。

O(1)O(1) —— 常数复杂度 (Constant Time)

【代码长相】:没有任何循环,只有简单的算术运算或单次数组/容器访问。

int a = 10, b = 20;
int sum = a + b;           // 算术运算,O(1)
int val = my_vector[5];    // 数组/vector通过下标直接访问,O(1)

【计算速度】:瞬间完成,忽略不计。 【数据范围 NNNN 可以是任意大小(哪怕是 101810^{18} 甚至更大,只要不超过数据类型本身的上限即可)。

O(logN)O(\log N) —— 对数复杂度 (Logarithmic Time)

【代码长相】:每次折半的数据查找,或者循环中变量每次乘/除以 2。

// 实例 1:二分查找
std::lower_bound(a, a + n, target);

// 实例 2:折半循环
for (int i = 1; i <= n; i *= 2) {
    // 每次 i 翻倍,循环一共执行约 log2(N) 次
}

【计算速度】:极快。因为是折半递减/递增,即使 N=1018N = 10^{18}(一百万万亿,即 long long 类型的上限附近),log2(1018)60\log_2(10^{18}) \approx 60 次运算,在计算机中仅需微秒级(百万分之一秒)即可完成。 【数据范围 NNNN 最大可达 101810^{18} 级别(因此,如果题目中 NN 极大,二分查找等对数级算法往往是唯一的解法)。

O(N)O(N) —— 线性复杂度 (Linear Time)

【代码长相】:单层 for 循环,将数据从头到尾扫一遍。

int sum = 0;
for (int i = 0; i < n; ++i) {
    sum += a[i]; // 循环执行 N 次
}

【计算速度】:当 N=108N = 10^8 时,刚好达到 1 秒钟的红线。 【数据范围 NNNN 最大可达 10710810^7 \sim 10^8 级别。如果题目中的 N107N \le 10^7,手写一个单层循环的算法绝对安全。

O(NlogN)O(N \log N) —— 线性对数复杂度 (Linearithmic Time)

【代码长相】:最经典的就是 C++ 标准库中的排序算法,或者利用了“二分查找”的循环。

// 实例 1:对长度为 N 的容器进行排序
std::sort(v.begin(), v.end());

// 实例 2:单层循环内嵌套二分查找
for (int i = 0; i < n; ++i) {
    // 循环执行 N 次,内部进行一次 O(log N) 的查找
    std::lower_bound(v.begin(), v.end(), target[i]);
}

【计算速度】:当 N=106N = 10^6 时,大约需要执行 2×1072 \times 10^7 次计算,运行时间通常在 0.1~0.2 秒左右。 【数据范围 NNNN 最大可达 10510610^5 \sim 10^6 级别。在大部分题目中,如果 N=105N = 10^5,我们就要立刻联想到需要使用排序或者分治算法。

O(N2)O(N^2) —— 平方复杂度 (Quadratic Time)

【代码长相】:双重嵌套的 for 循环(如冒泡排序、两两配对检查等)。

for (int i = 0; i < n; ++i) {
    for (int j = 0; j < n; ++j) {
        // 内外层循环嵌套,共执行 N * N 次
        if (a[i] == a[j]) { ... }
    }
}

【计算速度】:当 N=104N = 10^4(1万)时,N2=108N^2 = 10^8,运行时间恰好接近 1 秒。 【数据范围 NNNN 最大可达 100030001000 \sim 3000 级别(通常在 N2000N \le 2000 时比较安全,若 N5000N \ge 5000 则极易超时)。

O(N3)O(N^3) —— 立方复杂度 (Cubic Time)

【代码长相】:三重嵌套的 for 循环(如最朴素的矩阵乘法、三点组合检查等)。

for (int i = 0; i < n; ++i) {
    for (int j = 0; j < n; ++j) {
        for (int k = 0; k < n; ++k) {
            // 三重循环嵌套,共执行 N * N * N 次
        }
    }
}

【计算速度】:当 N=500N = 500 时,N3=1.25×108N^3 = 1.25 \times 10^8,已经超出了 1 秒的执行能力。 【数据范围 NNNN 最大可达 100300100 \sim 300 级别。

O(2N)O(2^N) —— 指数复杂度 (Exponential Time)

【代码长相】:通常出现在没有记忆化的递归(如直接求斐波那契数)、枚举所有子集(爆搜)等场景。

// 斐波那契递归,每次分裂出两个子调用
int fib(int n) {
    if (n <= 1) return n;
    return fib(n - 1) + fib(n - 2);
}

【计算速度】:随 NN 的增长极速爆炸。例如 2201062^{20} \approx 10^6(百万级),2301092^{30} \approx 10^9(10 亿级,远超 1 秒承受力)。 【数据范围 NNNN 最大可达 202520 \sim 25 级别。

O(N!)O(N!) —— 阶乘复杂度 (Factorial Time)

【代码长相】:通常出现在全排列的枚举、旅行商问题的暴力搜索中。

// 生成包含 N 个元素的所有排列
do {
    // 共执行 N! 次
} while (std::next_permutation(a, a + n));

【计算速度】:增长速度比指数还恐怖。例如 10!3.6×10610! \approx 3.6 \times 10^611!4×10711! \approx 4 \times 10^712!4.7×10812! \approx 4.7 \times 10^8【数据范围 NNNN 最大可达 101110 \sim 11 级别。


1.2 总结表:如何根据题目数据范围倒推算法?

在赛场上,我们拿到题目后,首先要去看 “数据范围” 这一栏。根据 NN 的大小,我们可以直接从下表中找出我们能写的“最大复杂度上限”,从而锁定解题思路:

题目给定的 NN 范围1秒限制下的最大时间复杂度上限推荐/可能的算法方向
N10N \le 10O(N!)O(N!)深度优先搜索(DFS)暴搜全排列
N20N \le 20O(2N)O(2^N)状压 DP、DFS 枚举所有子集
N100N \le 100O(N4)O(N^4)O(N3)O(N^3)弗洛伊德算法(Floyd)、区间 DP
N1000N \le 1000O(N2)O(N^2)朴素动态规划、双重循环暴力、大部分图论算法
N105N \le 10^5O(NlogN)O(N \log N)排序(sort)、二分查找、快速幂、线段树/树状数组
N107N \le 10^7O(N)O(N)单层循环扫描、前缀和、双指针、单调栈/单调队列
N109N \le 10^9O(logN)O(\log N)O(1)O(1)二分答案、数论算法(如 GCD)、公式直接推导

Tip

很多选手做题超时,就是因为题目给的 N=105N = 10^5,但他们却写了一个双重嵌套的 O(N2)O(N^2) 循环。根据上表可以看出,10510^5 的范围只允许写 O(NlogN)O(N \log N)O(N)O(N) 的代码,写 O(N2)O(N^2) 必定超时!


二、 空间复杂度:内存限制如何折算成数组大小?

空间复杂度用来衡量你的程序占用了多少内存。在实际做题时,我们极少因为定义了几个变量而内存超限。导致 MLE 的罪魁祸首几乎只有一个:数组开得太大了

因此,我们不需要死记空间复杂度的概念,只需学会如何把字节(Byte)转换为数组长度

2.1 基础转换公式

首先,我们需要知道 C++ 中常见数据类型占用的内存大小:

  • char / bool1 字节(Byte)
  • int / float4 字节(Bytes)
  • long long / double8 字节(Bytes)

内存单位的换算关系如下:

1 MB=1024 KB=1024×1024 Bytes106 字节1\text{ MB} = 1024\text{ KB} = 1024 \times 1024\text{ Bytes} \approx 10^6\text{ 字节}

也就是说,1 MB 的空间,大约可以存储 10610^6 字节的数据。


2.2 常见数据类型数组的内存上限

我们以常见的 256MB 内存限制为例,来算算不同类型的数组最大能开到多少:

int 数组的最大安全大小

一个 int 占 4 字节,那么 256MB 内存最多可以开:

256×106 字节4 字节/int6.4×107 个元素\frac{256 \times 10^6\text{ 字节}}{4\text{ 字节/int}} \approx 6.4 \times 10^7\text{ 个元素}

【实战建议】

  • 如果开一维 int 数组,最大长度写成 int a[50000000](5千万)是安全的。
  • 如果开二维 int 数组,最大大小约为 int a[7000][7000](因为 7000×7000=4.9×1077000 \times 7000 = 4.9 \times 10^7)。

long long 数组的最大安全大小

一个 long long 占 8 字节,是 int 的两倍。那么 256MB 最多可以开:

256×106 字节8 字节/long long3.2×107 个元素\frac{256 \times 10^6\text{ 字节}}{8\text{ 字节/long long}} \approx 3.2 \times 10^7\text{ 个元素}

【实战建议】

  • 一维 long long 数组,最大开到 long long a[25000000](2千5百万)。
  • 二维 long long 数组,最大开到 long long a[5000][5000]

2.3 空间复杂度在代码中的直观体现

  • O(1)O(1) 空间复杂度:只定义了几个变量,几乎不占用内存。
  • O(N)O(N) 空间复杂度:定义了一个长度为 NN 的一维数组(如记录状态、存储数据)。
  • O(N2)O(N^2) 空间复杂度:定义了一个 N×NN \times N 的二维数组(如邻接矩阵、二维网格图)。

Caution

切记:信奥比赛中,全局数组的内存是在程序启动时就一次性分配好的。千万不要在看到 N=105N = 10^5 时,随手定义一个 int grid[100000][100000]。这样的二维数组会占用大约 40 GB40\text{ GB} 内存,测评系统会立刻判定你的程序为内存超限(MLE)或直接编译失败!


结语

通过上面的实例和数据对比,相信大家已经对时间和空间复杂度有了清晰而具体的感觉:

  • 时间上:看清 NN 的大小,对照“1秒跑1亿次”的黄金法则,合理设计循环的嵌套层数。
  • 空间上:记住“1MB 存 25 万个 int”,谨慎计算大数组的占用内存,避免内存超限。

掌握了复杂度的估算,你就拥有了一把在写代码前就能预测程序能否拿满分的“神兵利器”。


本篇是 【C++的奇妙之旅】 系列的完结篇。

在这 29 篇文章中,我们一起从计算机硬件演化与图灵、冯·诺伊曼体系出发,走过了 C++ 的 Hello World、变量、分支、循环、数组、指针与引用,掌握了 STL 常用容器与 <algorithm> 库,并最终学习了规范比赛的文件操作与效率分析。

掌握了这些,代表你已经完成了 C++ 编程语言基础的积淀,拿到了通往算法世界大门的钥匙!

本文由coderli.com原创,按照CC BY-NC-SA 4.0 进行授权

所有代码已上传至Github:https://github.com/lihongzheshuai/yummy-code

luogu-”系列题目可在 洛谷题库 在线评测。

bcqm-”系列题目可在 编程启蒙题库 在线评测。

GESP/CSP认证交流QQ群: 688906745

GESP/CSP 认证学习微信公众号
GESP/CSP 认证学习微信公众号
最后更新于