1915 字
10 分钟
模拟与暴力枚举
2025-08-28
2025-10-16
统计加载中...

模拟与暴力枚举#

0. 引入#

模拟就是用计算机来模拟题目中要求的操作。

模拟题目通常具有码量大、操作多、思路繁复的特点。由于它码量大,经常会出现难以查错的情况,如果在考试中写错是相当浪费时间的。


1. 技巧#

写模拟题时,遵循以下的建议有可能会提升做题速度:

  • 在动手写代码之前,在草纸上尽可能地写好要实现的流程。
  • 在代码中,尽量把每个部分模块化,写成函数、结构体或类。
  • 对于一些可能重复用到的概念,可以统一转化,方便处理:如,某题给你 “YY-MM-DD 时:分” 把它抽取到一个函数,处理成秒,会减少概念混淆。
  • 调试时分块调试。模块化的好处就是可以方便的单独调某一部分。
  • 写代码的时候一定要思路清晰,不要想到什么写什么,要按照落在纸上的步骤写。

实际上,上述步骤在解决其它类型的题目时也是很有帮助的。

2. 一些简单的模拟#

  1. 枚举无序对 (i,j),i < j,避免重复
for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j)
// 用 a[i], a[j]
  1. 枚举三元组 (i,j,k),i < j < k
for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j)
for (int k = j + 1; k < n; ++k)
// 用 a[i], a[j], a[k]
  1. 子集枚举(n ≤ 20 常用)
for (int mask = 0; mask < (1 << n); ++mask) {
// if (__builtin_popcount((unsigned)mask) == k) ...
for (int i = 0; i < n; ++i)
if (mask >> i & 1) { /* 选了第 i 个 */ }
}
  1. 全排列(next_permutation)
vector<int> p = {1,2,3,4,5};
do {
// 使用当前排列 p
} while (next_permutation(p.begin(), p.end()));

3. 基础例子#

3.1 P1909《买铅笔》(枚举三种方案)#

题意:买 n 支铅笔,给出 3 种“每包数量+价格”,问最小花费。

思路:每种方案都算一次 ceil(n / pack) × price,取 min。


3.2 P1047《校门外的树》(差分优化的模拟)#

题意:路段 [0..L] 初始有树;给出 M 个区间要“砍掉”,问剩余多少棵。

方法一(朴素):bool 标记 + 区间打掉,O(L×M),很慢。

方法二(差分):对每个 [l, r] 做 diff[l]++, diff[r + 1]—,最后前缀和>0 表示被砍。


3.3 P1008《三连击》(全排列枚举)#

题意:找出 3 个三位数 A、B、C,使得 A:B = 1:2:3,且 1..9 每个数字恰好出现一次。

思路:对 1..9 的全排列,每 3 位拼一个数,检查比例。


3.4 P1042《乒乓球》(字符串模拟)#

题意:输入一串 ‘W’/‘L’/‘E’,输出 11 分制与 21 分制两套比分。

做法:遍历字符,遇 E 结束,胜负分到达 limit 且相差 ≥2 就结算一局。


3.5 P1980《计数问题》(枚举数位 + 进阶做法)#

题意:统计 1..n 中数字 x 出现了多少次。

做法 A:朴素枚举每个数的每一位(n×位数)。

做法 B(进阶,推荐):逐位统计(高位/当前位/低位法,O(logn)O(log n)

要点:0 的处理与前导零有关,按题意选择写法。


3.6 P1328《生活大爆炸版石头剪刀布》(规则模拟)#

题意:两人出拳模式循环给出,进行 n 次对决,规则是 5 种手势的胜负表。
做法:预先写好胜负矩阵 win[i][j],轮流取序列 a[i%la], b[i%lb] 比较。


4. 进阶枚举技巧#

4.1 去重与剪枝#

  • 两元对去重:强制 i < j;
  • 三元组去重:i < j < k;或排序后跳过相同值;
  • 非降约束:枚举划分/组合时,下一步从“当前选择及之后”开始;
  • 上下界剪枝:已超出目标就提前 break;
  • 对称剪枝:如果方案与镜像等价,固定一侧。

示例:枚举三元组满足 a[i]+a[j]+a[k]=T, O(n3)O(n^3)

for (int i = 0; i < n; ++i)
for (int j = i + 1; j < n; ++j)
for (int k = j + 1; k < n; ++k)
if (a[i] + a[j] + a[k] == T) ++ans;

可剪枝

  • 提前排序后,若最小可能和 > T 或最大可能和 < T,则可 break / continue;
  • 固定 i, j,二分或双指针找 k。

4.2 子集枚举的两种写法#

  • 位掩码(通用)
  • 递归/回溯(可加剪枝)

位掩码统计子集和为 T 的个数

long long count_subset_sum(const vector<int>& a, int T) {
int n = a.size();
long long ans = 0;
for (int mask = 0; mask < (1 << n); ++mask) {
long long s = 0;
for (int i = 0; i < n; ++i)
if (mask >> i & 1) s += a[i];
ans += (s == T);
}
return ans;
}

4.3 区间/子串枚举#

  • 所有子数组 O(n2)O(n^2)
[l..r]
for (int l = 0; l < n; ++l)
for (int r = l; r < n; ++r)
  • 所有子串 O(n2)O(n^2)
for (int i = 0; i < (int)s.size(); ++i)
for (int len = 1; i + len <= (int)s.size(); ++len)
// s.substr(i, len)

配合前缀和,可以 O(1)O(1) 求区间和,整体 O(n2)O(n^{2})

4.4 数位枚举与“回文/质数/进制”#

  • 判断质数:
bool is_prime(long long x) {
if (x < 2) return false;
for (long long d = 2; d * d <= x; ++d)
if (x % d == 0) return false;
return true;
}
  • 判断回文(字符串法):
bool is_pal(const string& t) {
for (int i = 0, j = (int)t.size()-1; i < j; ++i, --j)
if (t[i] != t[j]) return false;
return true;
}
  • 反转整数(去前导零)
long long rev_num(long long x) {
long long y = 0;
while (x) { y = y*10 + x%10; x/=10; }
return y;
}

5. 复杂度估算与常用优化#

  • 粗略估算
    • 1e7 级别操作:通常能过
    • 1e8 边缘,需注意常数/IO
    • 1e9 基本会超时
  • 常用优化
    • 预处理:前缀和、差分、筛素数
    • 剪枝:越界/不可能就提前 break/continue
    • IO 加速:ios::sync_with_stdio(false); cin.tie(nullptr);
    • 数据结构选择:数组/静态容器优于频繁 new/delete
    • 减少重复计算:把不变的量移出循环

例如:计数问题从 O(nlogn)O(n log n)(逐数拆位)降到 O(logn)O(log n)(逐位统计)。


6. 对拍(双程序验证)#

6.1 什么是对拍?#

在算法竞赛中,我们有时会写两个版本的程序来解决同一道题目:

  • 暴力程序(通常写得很简单,但复杂度高,只能跑小数据);
  • 优化程序(复杂度优秀,但实现复杂,容易写错)。

对拍(double check / stress test) 的思想就是:

  1. 写一个随机数据生成器(gen_input())。

  2. 用同一份输入分别喂给两个程序(如 1.exe2.exe)。

  3. 比较输出是否一致。

    • 如果一致,多测几次,就能增加对优化程序正确性的信心;
    • 如果不一致,就保存输入,方便 Debug。

这种方法在调试优化算法时非常有用,尤其是在数据范围较小,可以轻松跑完的情况下。


6.2 基本流程#

一个对拍脚本主要分为三步:

  1. 生成输入
std::string gen_input() {
std::ostringstream oss;
int n = randomInt(1, 200);
oss << n << "\n";
for (int i = 0; i < n; ++i) {
int x = randomInt(-200, 200);
oss << x << " \n"[i == n - 1];
}
return oss.str();
}
  • 使用随机数生成器(如 mt19937)保证样例覆盖面广;
  • 注意输入格式必须符合题目要求。
  1. 运行并获取输出
auto res1 = run("1.exe", input);
auto res2 = run("2.exe", input);
std::string output1 = res1.first;
std::string output2 = res2.first;

run(...) 函数会写入临时输入文件,再调用系统命令运行可执行文件,并读取输出。

  1. 比较结果
if (output1 != output2) {
std::cout << "Mismatch found!\n";
std::cout << "Input:\n" << input << '\n';
std::cout << "Output1:\n" << output1;
std::cout << "Output2:\n" << output2;
break;
}

6.3 注意事项#

  • 随机性
  • 速度
  • 输出一致性
  • 定位 bug

6.4 小结#

  • 对拍不是算法本身,而是一种调试工具
  • 它能帮助我们快速发现优化代码中的细节错误;
  • 日常写题时,如果一直WA还找不出错误,可以尝试写一个暴力来对拍。
模拟与暴力枚举
https://lansganbs.cn/posts/算法竞赛/模拟与暴力枚举/
作者
LANSGANBS
发布于
2025-08-28
许可协议
CC BY-NC-SA 4.0