简介:一份面向数据结构初学者的排序算法对比实践资源,围绕随机生成一千个整数,使用冒泡、插入、选择、快速、归并、堆排序等多种主流算法进行排序,并统计赋值次数以量化比较各算法的操作开销,帮助读者直观理解不同排序策略的时间性能差异。压缩包为RAR格式,仅含1个C++源文件,包体大小约3KB,代码紧凑且可直接运行,适合本地编译验证。目前已有1627人学习/下载,可服务于算法入门、课程实验与面试复习。资源核心价值在于提供完整的随机数生成与排序实现,并嵌入赋值计数机制;读者可自行调整数据规模和算法种类,观察赋值次数与时间复杂度之间的关系,进而加深对算法稳定性、原地排序等特性的理解。通过实际执行结果可直接对比不同算法的赋值次数差距,为算法选型提供数据参考。
1. 给 1000 个随机数字排序,顺便把四种算法的赋值次数数清楚
“排序算法”的复杂度课听再多,都不如亲手对 1000 个随机数字跑一圈来得直观。这个实验的思路很直接:随机生成 1000 个数字,让插入排序、冒泡排序、选择排序和快速排序先后处理同一份数据,再把每次对数组元素的赋值次数记下来。赋值次数比运行时间更接近算法本身的数据搬移成本,也比比较次数更容易被忽略,真正统计完你会发现选择排序的赋值次数低得反常,而冒泡排序高得离谱。这套实验适合刚学完基础排序、想从赋值维度重新理解算法代价的人,也适合要用数据说服别人“某某排序不该这么写”的场景。它不依赖任何第三方库,一份 C 代码就能跑通。
2. 随机生成 1000 个数字:三种生成方式与分布形态如何左右赋值次数
2.1 为什么排序实验要用可复现的伪随机序列
随机生成数字听起来一句话的事,但“随机”这两个字在实验里必须被驯服。如果你直接用系统时间当种子,每次运行得到的数组都不一样,排序算法的赋值次数自然也不一样,第二天回来看结果会完全无法解释。我的做法是使用确定性伪随机序列:给随机数生成器一个固定种子,比如 42,那么无论运行多少次,生成的 1000 个数字都完全一致。这样插入排序和快速排序面对的输入是同一份,计数差异才真正来自算法本身,而不是输入数据的随机抖动。
真随机数通常来自硬件熵源,适合安全和统计抽样,但排序实验要的是“可复现的随机”,不是不可预测。伪随机数生成器(PRNG)的核心参数就是种子和范围,种子决定序列起点,范围决定数值落点。实际项目里可以准备几组固定种子,比如 42、2024、7,分别生成多组测试数据,用来观察一组种子下的偶然波动。常见错误是在程序开头调用 srand(time(NULL)),理由是“更像随机”,结果每次跑出来的赋值次数都不同,无法定位是算法差异还是输入差异。数据文件一旦生成,就应该当成不可变的基准,后续所有排序都从这份文件读入,才算守住对照实验的底线。
另外需要区分两个经常摆在一起的术语:比较运算符判断两个值的大小关系,赋值运算符把右侧的值写入左侧变量。排序算法的代价由比较次数和赋值次数组成,交换元素时主要消耗在赋值上;如果只盯着循环次数看,很容易把两种代价混在一起。后面统计用的是赋值次数,它度量的是数据搬了多少次,对结构体、字符串这类大对象排序时,赋值次数往往比比较次数更贴近真实的开销。
2.2 用 C 语言和 Python 各生成一份随机数据文件
先在 C 里生成一份基础输入文件。下面代码把 1000 个 0 到 9999 之间的随机整数写入 random_1000.txt,每行一个数字。
#include <stdio.h> #include <stdlib.h> #define N 1000 #define RANGE 10000 int main(void) { int data[N]; srand(42); // 固定种子,保证每次运行得到同一组数字 for (int i = 0; i < N; i++) { data[i] = rand() % RANGE; // 值域 0~9999 } FILE *fp = fopen("random_1000.txt", "w"); if (fp == NULL) { perror("fopen"); return 1; } for (int i = 0; i < N; i++) { fprintf(fp, "%d\n", data[i]); } fclose(fp); printf("generated %d numbers\n", N); return 0; }说明:srand(42) 是整份数据的“后悔药”,只要种子不变,序列就固定,实验翻车后还能回到同一份输入重新核对。RANGE 取 10000 而不是 100 或 10,是为了让重复值比例可控:数值范围太小,1000 次抽样里会出现大量重复,排序的比较和赋值次数都会被明显压低。fprintf 写纯文本,是为了方便你用 Python、Java 或 Excel 打开同一份文件复现。
Python 版更简洁,适合快速做多组实验时用:
import random random.seed(42) data = [random.randint(0, 9999) for _ in range(1000)] with open("random_1000.txt", "w", encoding="utf-8") as f: f.write("\n".join(str(x) for x in data))这里 random.seed(42) 的作用与 C 的 srand(42) 等价,randint(0, 9999) 等价于 rand() % 10000。如果你想生成随机唯一值,也就是 1000 个数字互不重复,需要再加一个去重循环:
data = [] seen = set() while len(data) < 1000: x = random.randint(0, 9999) if x not in seen: seen.add(x) data.append(x)这样生成的数组没有重复元素,适合用来观察“无重复输入下赋值次数的高位表现”,但要注意它和真实随机抽样得到的重复率不同,得到的结果不能混在一起比。实际做对比时,我会为每种分布单独存文件,文件名里带分布标签,例如 random_uniform.txt、random_unique.txt、sorted_1000.txt,避免后面统计时拿错输入。
2.3 分布形态会把赋值次数带偏:唯一值、重复值与近乎有序数组
同样的排序算法,在不同分布的输入上表现完全不一样。如果生成的数字只落在 0 到 19 之间,1000 个位置里大量数字相同,插入排序内部的 while 经常一两次就停,赋值次数明显偏低;如果数据本来有序,插入排序几乎不做数据搬移。反过来,把数组逆序生成,插入排序每次都要把新元素一路搬回开头,赋值次数接近最坏情况 O(n²)。因此实验之前就要确定你到底想测哪一种输入形态,不能笼统说“跑一下看看”。
实际排序性能对比里,最常用的三个输入形态是:均匀随机分布、随机唯一值分布、已有序或逆序数组。均匀随机分布用 rand() 取模最容易得到,缺点是碰撞不可避免;随机唯一值分布适合测所有元素都不相等时的行为;近乎有序数组专门用来暴露插入排序的线性优势,同时也会让固定基准的快速排序退化成 O(n²)。我一般会为每个形态单独生成数据文件,并在实验记录里写清文件来源,因为后续所有赋值次数结论都建立在这批输入的基础上。输入文件一旦混用,统计数字再漂亮也是假的。
这些数据文件用不着很大,1000 个数字足够看出差异。更大规模比如 10000,会让 O(n²) 算法跑到肉眼可见的慢,赋值次数也会从几十万跳到几千万,计数器类型要跟着从 int 换成 long long。鉴于是从 1000 起步,我建议先固定 N=1000 跑通流程,再逐步扩大数组规模观察增长斜率,这样既能验证理论复杂度,又不会因为一次实验等太久。下一章就把计数规则落实成可编译的 C 代码。
3. 用同一份数据跑四种排序算法:赋值次数的计数规则与 C 实现
3.1 赋值次数数的是什么:数据移动与临时变量的边界
排序算法性能比较的经典维度有两个:比较次数和赋值次数。比较次数衡量的是“判断谁大谁小”的调用次数,赋值次数衡量的是“把数据从一个位置搬到另一个位置”的执行次数。二者不是一回事:交换一次元素,比较可能一次都不发生,但赋值必然发生。赋值次数越少,说明算法搬运数据的开销越低,在设计结构体、字符串等大对象排序时尤其关键,因为一次记录的搬移可能是几十字节甚至几十 KB。
这里必须把“赋值次数”的边界说清楚,否则统计结果没法复核。我采用的规则是:把数组元素或保存数组元素的临时变量发生一次写入,就记一次赋值;循环用的下标、min_idx 这类索引变量不计入。这样一次 swap(arr, i, j) 算三次赋值:tmp = arr[i]、arr[i] = arr[j]、arr[j] = tmp。插入排序里 key = arr[i] 算一次,arr[j+1] = arr[j] 每次后移算一次,最后 arr[j+1] = key 算一次。选择排序外层交换里的三次赋值照算,但 min_idx = j 不算,因为它更新的是下标,不是数据。这套规则和数据结构排序算法教材里常用的“记录移动次数”基本一致,便于做理论推导时对得上。
实际统计时,不要在算法内部到处手写 count++,容易漏。我的做法是把交换封装成函数,其他排序函数一律通过这个函数交换;插入排序的 key 操作显式计数。这样代码里只有少数几个计数点,出错时一眼能找到。更稳妥的插桩方式是用宏包装赋值操作,但 C 宏在组合下标时容易出现多次求值副作用,得不偿失,所以函数显式计数更可靠。
3.2 用 C 语言实现计数插桩:一个计数器与一套统一规则
下面这段代码是整组实验的核心骨架。它先用 long long 声明全局计数器 assign_count,避免 int 溢出;reset_count 在每次排序前清零;swap 函数在交换元素时精确计三次。注意这里我特意把临时变量 tmp 的赋值也算进去,因为一次交换本质上就是三次数据搬移。
#include <stdio.h> #include <stdlib.h> #include <string.h> #define N 1000 #define RANGE 10000 static long long assign_count; static void reset_count(void) { assign_count = 0; } static void swap(int arr[], int i, int j) { if (i == j) return; // 相同位置不搬,避免无意义计数 int tmp = arr[i]; assign_count++; // tmp = arr[i] arr[i] = arr[j]; assign_count++; // arr[i] = arr[j] arr[j] = tmp; assign_count++; // arr[j] = tmp }swap 里开头加一个 i == j 判断,能让选择排序在无需交换时不产生假的赋值次数。如果你希望统计“代码里赋值语句执行次数”,可以去掉这个提前返回,但那样该次交换会把 tmp 搬来搬去却没有实际改变数组,对数据移动语义来说是噪声。跑实验前先决定自己遵循哪套规则,并在实验记录里写清楚,否则后期换一种统计口径,结果会完全对不上。
3.3 四种排序算法的赋值计数实现
插入排序的赋值点有三类:保存 key、后移元素、写回 key。注释里标出了每个 count 对应的语义。
static void insertion_sort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; assign_count++; // 把 arr[i] 搬到 key int j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; assign_count++; // 把 arr[j] 后移一位 j--; } arr[j + 1] = key; assign_count++; // 把 key 写回正确位置 } }冒泡排序用 swap 完成交换,所以赋值计数全部落在 swap 函数里。为了保留一个“已经有序就提前退出”的版本,我加了 swapped 标记,这会让有序数组的赋值次数直接逼近 0,符合真实工程里常见写法。
static void bubble_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int swapped = 0; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1); swapped = 1; } } if (!swapped) break; } }选择排序的赋值点最少,只有外层交换。内层只是不断更新 min_idx,并不搬动数据。这正好体现了它的特征:比较次数依然是 O(n²),但数据搬移量被压到了 O(n)。
static void selection_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { if (arr[j] < arr[min_idx]) { min_idx = j; // 更新下标,不计入赋值次数 } } if (min_idx != i) { swap(arr, i, min_idx); } } }快速排序这里选最后一个元素做基准。pivot = arr[hi] 是一次数据搬移,要计数;后续 partition 过程中,交换操作统一走 swap,所以计数点也不散。
static void quick_sort_range(int arr[], int lo, int hi) { if (lo >= hi) return; int pivot = arr[hi]; assign_count++; // pivot = arr[hi] int i = lo - 1; for (int j = lo; j < hi; j++) { if (arr[j] < pivot) { i++; if (i != j) { swap(arr, i, j); } } } if (i + 1 != hi) { swap(arr, i + 1, hi); } quick_sort_range(arr, lo, i); quick_sort_range(arr, i + 2, hi); }代码说明:这个实现用了 Lomuto 分区,优点是直观、计数点少;缺点是有序数组下 i 会一路增长,基准总在末端,递归退化为 O(n²) 深度,赋值次数也会暴涨。后续章节会给出改造方案。注意快速排序的 pivot 赋值计入赋值次数,而普通临时变量不算,这会让快速排序的统计比“只数交换”的常见做法多出 O(n) 级别的小量,但和 O(n log n) 的主项相比可忽略。
3.4 每次排序前必须复制原始数组,否则结果全是假的
排序算法是原地排序,会把传入数组改得面目全非。如果先跑冒泡排序,数组已经有序;再跑插入排序,插入排序遇到有序数组赋值次数只有几千,这个结果显然不能拿来和冒泡的第一次结果比。因此主函数要维护一份原始数据 base,每次调用排序函数前用 memcpy 恢复到工作缓冲区 buf。下面是主流程。
int main(void) { int base[N], buf[N]; FILE *fp = fopen("random_1000.txt", "r"); for (int i = 0; i < N; i++) { fscanf(fp, "%d", &base[i]); } fclose(fp); memcpy(buf, base, sizeof(base)); reset_count(); insertion_sort(buf, N); printf("insertion %lld\n", assign_count); memcpy(buf, base, sizeof(base)); reset_count(); bubble_sort(buf, N); printf("bubble %lld\n", assign_count); memcpy(buf, base, sizeof(base)); reset_count(); selection_sort(buf, N); printf("selection %lld\n", assign_count); memcpy(buf, base, sizeof(base)); reset_count(); quick_sort_range(buf, 0, N - 1); printf("quick %lld\n", assign_count); return 0; }参数说明:memcpy 的第三个参数用 sizeof(base) 而不是 N * sizeof(int),省得每次改 N 还要同步改这里;base 在程序运行中始终不变。打印时用 %lld 对应 long long 计数器。跑完这一份,四个算法的赋值次数就同框了。第 4 章会告诉你如何做多轮采样和结果解读。
4. 排序算法赋值次数对比:多轮采样的数据表与快速排序的阈值参数
4.1 多轮采样:用多个固定种子消除单组数据的偶然性
单次实验只能证明“这一组 1000 个数字下”的赋值次数,不能证明算法的一般表现。随机数组本身有方差,快速排序的赋值次数对基准位置尤其敏感,一组数据可能正好落在好分区或坏分区上。我一般会准备 5 个固定种子,分别生成 5 组输入,每组输入都让四种算法走一遍,最后取平均或中位数。这样报告的赋值次数更接近期望值,也能顺手看几种算法之间的方差。
下面用一个 Python 脚本统一调度:它调用前面编译好的 C 可执行程序,并捕获 stdout 里的输出结果。这样做的好处是不用把 C 代码的统计逻辑再抄一份到 Python,避免两套实现出来两种答案。
import subprocess import statistics import random seeds = [42, 2024, 7, 65535, 1] results = {algo: [] for algo in ["insertion", "bubble", "selection", "quick"]} for seed in seeds: # 每个种子生成一份数据文件,交给 C 程序统计 random.seed(seed) data = [random.randint(0, 9999) for _ in range(1000)] with open("random_1000.txt", "w", encoding="utf-8") as f: f.write("\n".join(map(str, data))) out = subprocess.check_output(["./sort_counter"], text=True) for line in out.strip().splitlines(): algo, count = line.split() results[algo].append(int(count)) for algo, counts in results.items(): print(f"{algo:10s} mean={statistics.mean(counts):>10.0f} " f"min={min(counts):>10d} max={max(counts):>10d}")说明:C 程序本身已经把数据从 random_1000.txt 读进来,所以 Python 只负责换文件并重复调用。每个种子生成的文件覆盖上一次,不会累积。statistics.mean 是标准库函数,不需要额外安装。如果你的 C 程序输出里带了额外调试文字,解析前先过滤掉非 “算法名 数字” 格式的行,否则 int(count) 会抛异常。
注意一个容易混淆的点:Python 的 random.seed(42) 和 C 的 srand(42) 并不生成同一串数字,这不影响实验。只要每次统计面对的是同一个文件,“输入一致”这个条件就满足了。跨语言复现不需要两边的随机序列完全相同。
4.2 实测结果表:为什么选择排序赋值次数最少、耗时却不少
按上述流程跑一轮,赋值次数的数量级会落在下面这张表里。我没有把每次的精确数字写死,因为换编译器、改分支写法都会造成几百到几千的波动,但相对关系是稳定的。
| 算法 | 赋值次数(种子 42,N=1000) | 5 组种子平均 | 赋值次数增长 |
|---|---|---|---|
| 插入排序 | 约 25 万 | 约 25 万 | O(n²),与逆序对数量相关 |
| 冒泡排序 | 约 75 万 | 约 75 万 | O(n²),每次交换计 3 次赋值 |
| 选择排序 | 约 3 千 | 约 3 千 | O(n),只有交换才搬数据 |
| 快速排序 | 约 3 万 | 约 3 万 | O(n log n),分区交换主导 |
表格里最反直觉的一项是选择排序:赋值次数只有几千,比快速排序还低一个数量级。原因在于它每趟只做一次交换,内层循环只比较不搬移,n=1000 时最多 999 次交换,每次 3 次赋值。但它一点也不快,因为它做了大约 50 万次比较,“少搬数据、多比较”是对它最准确的概括。这也说明赋值次数不是性能的全部,它只反映数据搬移这一面,评估排序算法要同时看赋值次数和比较次数。
快速排序的 3 万次赋值比插入排序的 25 万次低一个数量级,也印证了 O(n log n) 的优势从数据搬移角度同样成立。随着 N 从 1000 涨到 10000,插入排序的赋值次数会往千万级走,快速排序大约只到几十万级,这个差距会比表格里更刺眼。如果你做的实验里冒泡排序赋值次数反而比插入排序少,那多半是提前退出逻辑在有序数据上生效了,后面第 5 章会专门讲这类陷阱。
4.3 影响赋值次数的两个参数:数据规模与快速排序的切分阈值
数组规模是最直接的参数。赋值次数的绝对数字随 N 增大明显放大,但每种算法的相对关系基本由复杂度决定。调 N 时顺便把工作缓冲区数组大小改掉,同时确认计数器类型是 long long,否则 O(n²) 算法在 N=10000 时就能撞上 int 上限。N=1000 时三种 O(n²) 算法尚能秒回,N=50000 时插入排序和冒泡排序要等好几秒,正好用来演示复杂度差异。
第二个参数是快速排序的小数组切分阈值。很多工程实现会在区间长度小于某个阈值时改用插入排序,因为插入排序在小规模数组上常数小,赋值次数也更少。下面是加了一个 CUTOFF 的版本:
#define CUTOFF 16 static void quick_sort_cutoff(int arr[], int lo, int hi) { if (hi - lo <= CUTOFF) { insertion_sort(arr + lo, hi - lo + 1); return; } int pivot = arr[hi]; assign_count++; int i = lo - 1; for (int j = lo; j < hi; j++) { if (arr[j] < pivot) { i++; if (i != j) swap(arr, i, j); } } if (i + 1 != hi) swap(arr, i + 1, hi); quick_sort_cutoff(arr, lo, i); quick_sort_cutoff(arr, i + 2, hi); }重点看 CUTOFF 的取值:太大会让 O(n²) 的插入排序处理大块数据,赋值次数明显上升;太小则递归过深,小数组上的递归开销和额外交换没法被吸收。常见范围是 8 到 32,取 16 是折中。调这个参数时,你会在赋值次数上看到一个先降后升的曲线,最低点附近就是当前环境下比较合适的阈值。这也顺带解释了为什么标准库的排序实现很少是“裸”快速排序,它们普遍混用了插入排序和堆排序。
如果不加任何优化,快速排序在逆序数组上的表现会很糟:固定选最后一个元素做基准,每次分区都极度不平衡,递归深度接近 n,赋值次数会退化到 O(n²) 量级,甚至比插入排序还高。三数取中是一种常见补救:在 arr[lo]、arr[mid]、arr[hi] 中选中间值当基准,可以避免最常见的有序输入退化。我把这个优化留给做扩展实验的读者,先确认普通版本能跑通并且计数稳定,再考虑改基准选择策略。
5. 排序算法赋值次数统计的五个常见坑:现象、原因与解决
5.1 后面的排序算法拿到的是上一轮排好的数组,赋值次数直接“崩”了
现象:冒泡排序先跑,赋值次数 75 万;紧接着插入排序对同一个数组跑,赋值次数只剩几百。插入排序在近乎有序的数组上确实很快,但这个结果不能用于算法对比,因为输入已经不再是原始的 1000 个随机数了。更隐蔽的版本是:四个算法按顺序跑,前两个结果正常,后两个结果全部偏低,你还会误以为快速排序优化得很好。
原因:所有排序函数都是原地排序,主循环里没有每次从原始数据恢复数组,导致后跑的算法在有序或接近有序的数据上做无用功。赋值次数因此虚低,结论完全失真。这个坑几乎每个做对比实验的人都会踩一次,因为它不影响编译,只影响结论,不打印数组内容根本看不出来。
解决:在 main 函数里维护一份 base 数组,每次调用排序前 memcpy 到 buf;或者最土的办法,每次排序前从 random_1000.txt 重新读文件。注意 memcpy 必须在排序调用之前完成,reset_count 在哪一步都无所谓。我习惯把“先恢复输入、再清零计数、再排序”固定成一个三步流程,少一步都算污染样本。排序完成后还可以顺手检查 buf 是否有序,防止计数对但排序本身出错。
5.2 固定了种子却每次跑结果不一样,怀疑自己写了假代码
现象:明明在 2.2 的代码里有 srand(42),但连续运行两次,快速排序的赋值次数一次是 3 万、一次是 6 万,插入排序也跟着变。你可能觉得“种子固定了怎么会变”,于是开始怀疑编译器或者量子涨落,实际上原因通常很朴素。
原因:最可能是生成数据与排序没有在同一个程序里同步。比如你用 C 程序生成数据后,又用 Python 脚本重新生成 random_1000.txt,Python 的随机序列和 C 的 rand() 完全不同;或者程序里 srand(42) 之外,另有模块偷偷调用了 srand(time(NULL)),把序列重置了。还有一层原因是 C 的 rand() 在不同平台上的实现不一样,同一份代码在 Linux 和 Windows 下生成的序列不同,所以跨平台对比时不能要求绝对数字一致,只能对比相对趋势。
解决:一旦生成好数据文件,就把它当作只读文件对待,排序程序只读取、不生成。固定种子的意义在于复现,不在于“随机”本身。每次排序前,先打印数组前五个元素核对是否与预期一致,这个动作花不了几毫秒,却能挡住大部分数据错乱问题。跨平台对比时,先确认输入的 1000 个数字完全相同,再比较赋值次数才有意义。
5.3 开编译器优化后计数变成零和负数,排序像被“吞”了
现象:调试版编译得到正常计数,改成 -O2 编译,输出变成 0,或者中间结果乱码;增加数组规模后,冒泡排序的赋值次数甚至出现负数。第一次遇到这个问题的反应往往是“程序写错了”,但同样的代码换个优化级别又正常,最容易让人一头雾水。
原因:两个问题可能叠加。负数大概率是计数器 int 溢出:N=1000 时冒泡排序约 75 万次赋值,int 能扛住,但如果同时把 N 改成 100000,赋值次数轻松突破 21 亿,int 溢出后回绕成负数。计数为 0 则可能是编译器看到排序结果没有被使用,把整个排序函数判定为死代码消除,尤其在 -O2 以上优化级别常见。计数器变量如果被优化到寄存器且从未被外部读取,也会出现看似正常的代码却输出保留值。
解决:计数器一律用 long long,输出用 %lld。为了防死代码消除,排序后不要把结果丢掉,至少打印数组前三个元素,或者算一个数组元素的校验和再输出。更直接的办法是把 assign_count 声明成 volatile long long,让编译器知道这个变量的写操作可能有外部副作用,但真正可移植的解法是让实验结果被后续逻辑消费掉。检查计数是否有效的一个办法是:把 N=1000 的插入排序赋值次数和理论量级对比,应该在十几万到二十几万之间,如果只有几百,基本可以断定排序被编译器优化掉或者输入文件错乱。
5.4 选择排序赋值次数最低,但运行耗时比冒泡排序还长
现象:计时测试里,选择排序的赋值次数只有几千次,但运行耗时和冒泡排序不相上下,甚至更慢。你会开始怀疑赋值次数这个指标有没有意义,是不是统计错了。
原因:赋值次数只统计数据搬移次数,选择排序内层循环的大量比较并没有纳入赋值计数。它用 O(n²) 次比较换来 O(n) 次交换,赋值次数确实漂亮,但比较指令一样吃 CPU 时间。赋值次数低和运行时间短不是一回事,比较次数、缓存行为、分支预测都会影响最终耗时。对 int 数组来说,比较比数据搬移还贵的情况并不少见。
解决:把赋值次数和比较次数同时统计,观察选择排序“比较次数约等于冒泡、赋值次数远低于冒泡”的组合,才能解释为什么它赋值最少却慢。扩展方法是在所有 if 条件判断处增加 compare_count,比如:
static long long compare_count; static void selection_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; for (int j = i + 1; j < n; j++) { compare_count++; // 一次侵入比较 if (arr[j] < arr[min_idx]) { min_idx = j; } } if (min_idx != i) { swap(arr, i, min_idx); } } }定义好计数规则后,每个算法的 “比较次数 / 赋值次数” 组合就完全不同:插入排序比较和赋值都随逆序对走,选择排序比较极多但赋值极少,冒泡排序两者都不少。实际工程里排序对象如果是几十字节的结构体,赋值次数对耗时的权重会上升;如果只是 int 数组,比较次数往往占主导。所以不要拿着赋值次数单一指标去仲裁算法优劣,它只负责说清楚“搬数据”这一面。
5.5 只跑一次就下结论,把“玄学波动”当成算法差异
现象:某一次快速排序赋值次数 1.2 万,另一次却 5 万,于是得出结论“快速排序不稳定,别用”。细看两次的数据文件并不相同,或者一次跑了切分阈值版本,另一次跑的是普通递归版,结论自然南辕北辙。
原因:快速排序的赋值次数对基准选择和数据分布都很敏感。固定选择最后一个元素做基准时,有序或近似有序数组会退化成 O(n²),赋值次数暴涨;随机打乱过的数组,赋值次数本身也有波动。单次实验落入好情况或坏情况的偶然性太大,尤其 N=1000 这个规模,一次好运气可能让冒泡排序少跑一半交换,单点结果毫无统计意义。
解决:多组种子取均值,或对同一文件重复运行 5 到 10 次取中位数。做基准测试时永远保持同一份输入文件、同一个排序实现,只改你想改的参数。我习惯把每次运行的种子、数据范围、CUTOFF 阈值记在输出里,让实验日志自带“后悔药”,过后能定位是哪一步改坏了结果。如果两组数据结论相反,不要急着怀疑算法,先回放输入文件和编译参数,绝大多数冲突都是实验变量没有控制住造成的。
6. 把赋值次数对比做成可重复的基准脚本:自检与扩展技巧
到这里,你已经有一套能跑通的最小实验:固定种子生成 1000 个数字,用 C 插桩统计四种排序的赋值次数,再用 Python 做多组均值。最后一步是让脚本自己验证结果可信。我一般会在 C 程序里加一个自检函数,每次排序后判断 buf 是否已经升序,一旦乱序立刻报错并退出。这个自检不花太多时间,却能挡住绝大多数“计数对、排序错”的翻车现场。
static int is_sorted(int arr[], int n) { for (int i = 1; i < n; i++) { if (arr[i - 1] > arr[i]) return 0; } return 1; }在主流程里每个排序函数跑完后补一句 if (!is_sorted(buf, N)) 就打印 FAIL。排序结果正确都保证不了,赋值次数再低也没有意义。另一个验证技巧是把计数器输出和理论量级对号:插入排序赋值次数大约在 n²/4 附近,冒泡排序大约在 3n²/4 附近,选择排序是 3n 量级,快速排序是 n log n 量级。如果实测数量级差了一百倍,优先怀疑输入文件错了或者计数点漏了,而不是算法本身多神秘。
再往下走,你可以把实验朝三个方向扩展:把 N 改成 5000、10000,观察赋值次数随规模的增长是否符合复杂度曲线;把数据换成唯一直分布或近乎有序数组,测试相同算法在不同输入下的表现差异;给快速排序加三数取中或切分阈值,对比优化前后的赋值次数。这些扩展都不需要换语言或换框架,仍然围绕“随机生成、排序、比较赋值次数”这个主轴转。
我个人的操作习惯是:把生成数据、跑排序、打印结果三步拆成三个独立小脚本,留出 shell 管道,这样调参数时不用反复改 C 代码,也不会因为注释了某行导致数据文件被覆盖。说句实话,这个实验最大的坑不是排序算法本身,而是输入数据不干净、计数规则不统一。只要你每次跑实验前都先看一眼随机文件是否和预期一致,每次排序前都恢复数组,赋值次数这个指标就是可靠且可解释的。希望帮到你。
本文还有配套的精品资源,点击获取