前言
「枚举、模拟、排序」是算法入门阶段三种最朴素的解题手段。它们不是某一类数据结构,而是三种思考方式:枚举是把所有可能逐一试一遍,模拟是按题目描述的规则一步步推进,排序是把无序数据变成有序以便后续处理。很多复杂算法在想不出更优解时,先用这三种方法写出一个正确的基线版本,再逐步优化。
标题里的「枚举」指的是枚举算法(穷举),不是 C 语言的enum关键字。两者中文同名,但前者是解题策略,后者是定义整型常量的语法。本文讲的是前者,同时会在示例里顺带用到enum定义状态,读者不要把概念混在一起。
初学者常见的问题是:枚举不剪枝导致组合爆炸,模拟的下标更新写错导致漏掉或重复处理元素,排序的比较函数不满足严格弱序或者用减法比较造成溢出。这三个问题在 C 语言里尤其隐蔽,因为 C 不做运行时检查,越界和溢出往往表现为「结果不对」而不是报错。
本文用 C99 实现三个小例子:枚举勾股数、模拟约瑟夫环、用qsort排序。代码只依赖标准库,不涉及平台 API。
一、枚举:确定范围、条件与剪枝
枚举算法的骨架是多重循环:每一层枚举一个变量的取值,最内层判断是否满足条件。写对它需要三件事:
- 确定每个变量的取值范围,宁窄勿宽;
- 消除对称重复,例如勾股数只枚举 a < b,避免 (3,4,5) 和 (4,3,5) 重复输出;
- 尽早剪枝,在进入内层循环前排除不可能的分支。
以求 n 以内所有勾股数(a² + b² = c²,a < b < c ≤ n)为例。最直接的三重循环是 O(n³)。利用 a < b 可以把第二层从 b = a+1 开始,减少一半;再利用 c 必须大于 b 且不超过 n,可以由 a² + b² 算出 c 后判断是否为整数,把三重降为两重。
判断完全平方不能直接比较浮点sqrt的返回值,浮点误差可能让本该相等的值差一。稳妥做法是先取近似值,再用整数乘法向两侧修正:
/* 适用于 C99 */
#include <math.h>
long long isqrt_ll(long long x) {
if (x < 0) return -1;
long long r = (long long)sqrt((double)x);
while ((r + 1) * (r + 1) <= x) ++r; /* 向上修正 */
while (r * r > x) --r; /* 向下修正 */
return r;
}对 n 不超过 10^6 的情况,a*a + b*b不超过 2×10^12,double能精确表示这个范围内的整数,sqrt的结果经修正后可靠。若 n 更大,应该改用整数二分求平方根,避免(r+1)*(r+1)本身溢出。
剪枝还可以更细:固定 a 后,b 从 a+1 枚举到 n,一旦 a² + b² 的平方根超过 n,就可以break,因为 b 再增大 c 只会更大。这类「单调性剪枝」是把枚举从「能跑」推向「跑得动」的关键。
二、模拟:按规则逐步推进
模拟题的特点是:题目已经把过程描述清楚,代码只需忠实复现每一步。难点不在算法,而在状态表示和下标的正确更新。
约瑟夫环是典型的模拟题:n 个人围成一圈,从第 1 个人开始报数,报到 k 的人出列,下一个人重新从 1 报数,求最后剩下的人。用数组模拟时,可以用「删除后把后面元素前移」的方式维护一个紧凑数组:
/* 适用于 C99 */
#include <stdlib.h>
int josephus(int n, int k) {
if (n <= 0 || k <= 0) return -1;
int *ring = (int *)malloc(sizeof(int) * (size_t)n);
if (ring == NULL) return -1;
for (int i = 0; i < n; ++i) ring[i] = i + 1;
int count = n;
int idx = 0; /* 当前报数起点在数组中的下标 */
while (count > 1) {
idx = (idx + k - 1) % count; /* 报到 k 的人 */
for (int i = idx; i < count - 1; ++i) {
ring[i] = ring[i + 1]; /* 前移覆盖,删除该人 */
}
--count;
/* idx 现在指向原位置的下一个人,无需额外调整 */
}
int survivor = ring[0];
free(ring);
return survivor;
}这段代码的时间复杂度是 O(n²):每轮删除都要搬移后面的元素。n 很大时会慢,可以用链表把删除降到 O(1),或用树状数组/线段树做「第 k 个存活者」的查询。但作为模拟入门,数组版本最直观,也最容易验证每一步的状态。
模拟题有两个通用检查点:删除元素后,下一次的起点在哪里;循环终止时,最后一个元素是否被正确处理。前者决定了会不会跳过或重复处理,后者决定了边界是否漏算。写完后用 n = 1、k = 1、k > n 这几个小例子手工推一遍,能发现大多数下标错误。
三、排序:qsort 与比较函数
C 标准库的qsort声明在<stdlib.h>:
void qsort(void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));它排序nmemb个元素,每个size字节,比较函数返回负值、零、正值分别表示第一个参数小于、等于、大于第二个。qsort不保证稳定:相等元素的相对顺序可能改变。需要稳定排序时,要么在比较函数里加一个「原始下标」作为次要关键字,要么自己实现归并排序。
比较函数最常见的写法错误是用减法:
/* 错误:差值可能溢出 int */
int cmp_bad(const void *a, const void *b) {
return *(const int *)a - *(const int *)b;
}当两个元素分别是INT_MIN和1时,减法溢出,是未定义行为(undefined behavior, UB),比较结果不可信。正确写法只做关系判断:
/* 适用于 C99 */
int cmp_int(const void *a, const void *b) {
int x = *(const int *)a;
int y = *(const int *)b;
return (x > y) - (x < y); /* 结果为 -1、0、1 */
}对于结构体,比较函数要先转换类型,再按关键字逐级比较。例如按分数降序、同分按学号升序:
/* 适用于 C99 */
typedef struct {
int id;
int score;
} Student;
int cmp_student(const void *a, const void *b) {
const Student *x = (const Student *)a;
const Student *y = (const Student *)b;
if (x->score != y->score) return (x->score < y->score) ? 1 : -1;
return (x->id > y->id) - (x->id < y->id);
}比较函数必须满足严格弱序(strict weak ordering):不出现cmp(x, x) != 0,且传递性成立。若写成<=返回真,相等元素会被认为「小于自己」,qsort的行为不可预测。
四、完整示例
下面的程序把三部分串起来:枚举 100 以内的勾股数,把结果存进结构体数组,按斜边升序排序后输出;同时演示约瑟夫环。
/* 适用于 C99 */
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int a;
int b;
int c;
} Triple;
static long long isqrt_ll(long long x) {
long long r = (long long)sqrt((double)x);
while ((r + 1) * (r + 1) <= x) ++r;
while (r * r > x) --r;
return r;
}
static int cmp_triple(const void *p, const void *q) {
const Triple *x = (const Triple *)p;
const Triple *y = (const Triple *)q;
if (x->c != y->c) return (x->c > y->c) - (x->c < y->c);
if (x->a != y->a) return (x->a > y->a) - (x->a < y->a);
return (x->b > y->b) - (x->b < y->b);
}
int main(void) {
const int n = 100;
Triple found[256];
size_t count = 0;
for (int a = 1; a <= n; ++a) {
for (int b = a + 1; b <= n; ++b) {
long long c2 = (long long)a * a + (long long)b * b;
long long c = isqrt_ll(c2);
if (c > n) break; /* b 再大只会更超 */
if (c * c == c2 && c > b && count < 256) {
found[count].a = a;
found[count].b = b;
found[count].c = (int)c;
++count;
}
}
}
qsort(found, count, sizeof(found[0]), cmp_triple);
for (size_t i = 0; i < count; ++i) {
printf("(%d,%d,%d)\n", found[i].a, found[i].b, found[i].c);
}
printf("total=%zu\n", count);
return 0;
}按标准语义,输出会按斜边从小到大排列,斜边相同的再按 a、b 升序;total是 100 以内勾股三元组的个数。break那行依赖「a 固定时,c 随 b 单调增加」,所以一旦 c 超过 n,后续 b 不必再试。
found数组固定 256 个元素,这是教学示例的简化。工程代码应该动态扩容,或者先统计再分配;写死容量又不检查,是缓冲区溢出的常见来源。这里加了count < 256的检查,超出部分被丢弃,属于有意的取舍,实际使用时应改成可增长的容器或明确报错。
常见坑点
1. 用减法写比较函数
❌return *(const int *)a - *(const int *)b;—— 极端值相减溢出,UB。
✅return (x > y) - (x < y);,只产生 -1、0、1。
2. 比较函数不满足严格弱序
❌ 用<=或>=作为「小于」的判断 —— 相等元素被认为小于自己,排序行为不可预测。
✅ 相等时返回 0;确保反对称性和传递性。
3.qsort的元素大小传错
❌qsort(arr, n, sizeof(arr), cmp);—— 第三个参数应是单个元素大小,不是整个数组。
✅qsort(arr, n, sizeof(arr[0]), cmp);
4. 枚举范围没有去重
❌ 同时枚举 a、b 的所有组合,输出 (3,4,5) 和 (4,3,5)。
✅ 固定a < b,必要时再加b < c。
5. 用浮点相等判断完全平方
❌if (sqrt(c2) == (int)sqrt(c2))—— 浮点误差可能导致误判。
✅ 取整后用整数乘法验证,并向两侧修正。
6. 模拟删除后下标不调整
❌ 删除ring[idx]后仍执行++idx—— 跳过了接上来的元素。
✅ 删除后idx自然指向下一个元素;若采用「标记删除」而非搬移,则要循环跳过已删除位置。
7. 模拟题忽略 k 大于人数
❌ 只处理k < n的情况,idx = (idx + k - 1) % count本身没问题,但用「走 k 步」的循环写法可能绕圈多次。
✅ 统一用取模计算目标下标,天然支持 k 任意大。
8.malloc结果不检查
❌ 直接ring[i] = ...—— 分配失败时解引用空指针,UB。
✅ 判断ring == NULL后返回错误码或提前退出。
总结
| 方法 | 核心 | 复杂度来源 | 常见失误 |
|---|
| 枚举 | 遍历所有候选,逐条验证 | 循环层数、剪枝强度 | 范围过宽、重复计数、漏边界 |
| 模拟 | 忠实复现过程 | 每步操作的成本 | 下标更新、终止条件 |
| 排序 | 用比较函数定义顺序 | 比较次数、比较本身的开销 | 比较函数溢出、非严格弱序 |
这三种方法的价值在于「先有正确解,再谈优化」。枚举靠剪枝把指数级降下来,模拟靠选对数据结构把单步成本降下来,排序靠合适的比较规则把无序变有序。C 语言不提供越界检查,所以写的时候要自己守住三条线:数组下标在范围内、整数乘法不溢出、比较函数满足严格弱序。守住这三条,基线版本就能稳定地跑出正确答案。