循环排序复杂度深挖:从O(n)误判到O(n log n)的完整复盘
2026/8/31 16:20:14 网站建设 项目流程

前几天逛技术论坛,看到一个很有趣的帖子。作者发了一套号称 O(n) 的“循环排序”代码,核心理由是:“每个元素最多只被移动一次,所以时间复杂度是 O(n)。”没想到评论区很快反转,有网友逐行分析后发现,这个算法根本没有 O(n),即使是优化过的版本,时间复杂度也只是 O(n log n) 而已。这个案例非常适合拿来做一次排序算法复杂度复盘。本文将完整拆解循环排序的原理、代码和复杂度推导,重点说清楚“O(n log n)”到底是怎么被挖出来的。

1. 事件回顾:论坛上一个“O(n) 排序”引发的讨论

1.1 原帖声称的结论

帖子的标题大概意思是:作者实现了自己的循环排序,并声称时间复杂度是 O(n)。整篇帖子的论证逻辑是这样的:

  • 每个元素在一次排序过程中最多被交换一次。
  • 因此写入次数是 O(n)。
  • 既然是 O(n) 的写入次数,整个排序算法就应该是 O(n)。

从表面看,这个推理似乎合理。因为很多排序算法的时间复杂度都被“交换次数”或“比较次数”主导,比如冒泡排序的交换次数是 O(n^2),插入排序的移动次数是 O(n^2)。如果一个算法能把移动次数压到 O(n),那确实值得兴奋。

不过评论区马上就有人问了一句:“定位环节呢?”

1.2 评论区为什么翻车

在原帖给出的循环排序实现里,每个元素要被放到“它最终应该在的位置”。问题是:程序怎么知道某个元素最终应该在哪个位置?答案是:在剩余区间里做比较统计。

也就是说,对于每个待处理元素,都要扫描一遍它之后的元素,数一数有多少元素比它小。只要数清楚了这个数量,就能知道当前位置应该排在剩余元素中的第几个位置。这个扫描过程本身,就是一个内部循环,复杂度是 O(n)。

于是整个算法变成了:

  • 外层循环:遍历起点,O(n)。
  • 内层循环:线性扫描统计“有几个元素比我小”,O(n)。
  • 每一轮都要做一次这样的扫描。

两者相乘,时间复杂度的数量级就是 O(n^2),而不是 O(n)。

1.3 复杂度结论最终变成了 O(n log n)

后来有网友提出:既然线性扫描太慢,能不能用二分查找来优化定位?如果能在一开始拿到一个有序副本,那么每个元素的目标位置就可以通过二分查找得到,定位成本从 O(n) 降到 O(log n)。这样理论上总复杂度可以变成 O(n log n)。

听完这个建议后,发布者把代码改成了“排序副本 + 二分查找 + 环交换”的版本,并再次测试。结果这次分析发现:

  • 构造排序副本需要 O(n log n)。
  • 每个元素做一次二分查找定位,需要 O(n log n)。
  • 所有元素的移动次数才是 O(n)。

所以总时间复杂度不是发布者最初说的 O(n),而是 O(n log n)。帖子标题最后也被修改成“循环排序——结果发现它的时间复杂度为 O(n log n)”。

这就是整个事件的来龙去脉。接下来我们回到算法本身,把循环排序的实现和复杂度彻底讲清楚。

2. 循环排序原理与标准实现

2.1 循环排序的思想

循环排序(Cycle Sort)是一种原地排序算法,它最大的特点是把“写入次数”控制得非常少。理论上,最优情况下只需要 O(n) 次写入。它特别适合那些“元素移动代价极高”的场景,例如大对象数组的排序,因为减少元素拷贝就是减少开销。

它的核心思想是:把数组看成若干个不相交的“环”。排序的过程,就是沿着这些环,把每个元素送到它最终应该在的位置。

举个例子:

索引: 0 1 2 3 值: 2 1 4 3

如果按照升序排序,那么:

  • 元素 2 最终应该在索引 1。
  • 元素 1 最终应该在索引 0。
  • 元素 4 最终应该在索引 3。
  • 元素 3 最终应该在索引 2。

这时数组可以被拆成两个环:

环 A:索引 0 -> 索引 1 -> 索引 0 环 B:索引 2 -> 索引 3 -> 索引 2

循环排序要做的,就是沿着这些环逐个交换元素,让每个元素一次到达最终位置。

2.2 标准算法步骤

标准的循环排序过程如下:

  1. 从 start = 0 开始,取出当前起点元素 item。
  2. 在 start 之后的区间中,统计有多少个元素比 item 小,记为 pos。
  3. 如果 pos 等于 start,说明 item 已经在自己该在的位置,跳过。
  4. 如果 pos 不等于 start,说明 item 应该被放到索引 pos,于是把 item 放到 pos,取出原来 pos 位置上的元素,继续重复“统计位置、交换”。
  5. 当取出的元素回到 start 位置时,当前这个环就处理完毕。
  6. start 后移一位,继续处理下一个环,直到数组尾部。

这个过程不需要额外数组空间,所以空间复杂度是 O(1)。

2.3 标准 Python 实现

下面是一份完整的标准循环排序实现,代码可以直接复制运行:

def cycle_sort(nums): n = len(nums) writes = 0 for start in range(n - 1): item = nums[start] pos = start # 统计 start 之后有多少元素比 item 小 for i in range(start + 1, n): if nums[i] < item: pos += 1 # 如果已经在正确位置,直接进入下一轮 if pos == start: continue # 如果遇到重复元素,跳过相同的值 while item == nums[pos]: pos += 1 # 把 item 放到 pos,同时取出 pos 上的原值 nums[pos], item = item, nums[pos] writes += 1 # 继续处理同一环上的其他元素 while pos != start: pos = start for i in range(start + 1, n): if nums[i] < item: pos += 1 while item == nums[pos]: pos += 1 nums[pos], item = item, nums[pos] writes += 1 return nums, writes # 测试 data = [5, 2, 3, 1, 4] sorted_data, writes = cycle_sort(data) print("排序结果:", sorted_data) print("写入次数:", writes)

这段代码里有几个关键点:

  • pos表示当前元素最终应该去的位置。
  • while item == nums[pos]是为了处理重复元素。如果不做跳过处理,两个相同值可能陷入无限交换。
  • writes记录的是写入次数,也是循环排序最重要的性能指标。

标准循环排序的时间复杂度是 O(n^2)。虽然它的写入次数是 O(n),但定位过程需要反复扫描,整体性能并不优秀。

3. 复杂度分析:移动 O(n) 不等于排序 O(n)

3.1 被忽略的定位环节

很多初学算法的人,看到循环排序的“交换次数是 O(n)”之后,会下意识认为整个算法是 O(n)。这就是原帖作者翻车的地方。

时间复杂度衡量的是一整套算法中所有操作的执行次数,而不只是某一个你关心的操作。循环排序里有两个核心操作:

  • 定位操作:确定一个元素最终应该在哪个位置。
  • 交换操作:把元素移动到正确位置。

标准循环排序里,定位是通过线性扫描完成的。为了给一个元素找到正确位置,需要扫描它后面的所有元素,统计出比它小的元素个数。这个步骤消耗的时间,往往比交换本身大得多。

如果把算法比喻成“快递分拣”:交换只是把包裹扔上车,而定位是查找包裹应该送到哪个小区。查地址的时间可能比搬包裹的时间还长。

3.2 线性扫描定位:O(n^2)

标准实现中,外层循环遍历 start 位置,内层循环从 start + 1 扫描到数组末尾。总的比较次数大约是:

(n-1) + (n-2) + ... + 1 = n(n-1)/2

这是一个典型的等差数列求和,结果明显是 O(n^2)。就算某些元素已经就位、可以提前跳过,最坏情况下仍然需要走完几乎全部扫描。

因此标准循环排序的时间复杂度是 O(n^2)。

3.3 二分查找定位:O(n log n)

既然线性扫描定位太慢,能不能用更快的方式定位?

如果用二分查找,需要先有一个有序数组来查询某个元素的位置。比如先复制一份原数组并排序,得到 sorted_nums,然后对每个元素执行:

import bisect pos = bisect.bisect_left(sorted_nums, item)

这样每次定位只需要 O(log n)。

但是,注意两部分开销:

  • 构建有序副本 sorted_nums 本身需要 O(n log n)。
  • n 个元素各自二分查找一次,需要 O(n log n)。

于是整体复杂度变成 O(n log n)。循环交换的部分 O(n) 在总复杂度里反而不重要了。

所以在“二分查找 + 环交换”这种优化版本中,循环排序的时间复杂度是 O(n log n),而不是 O(n)。

4. 三版实现:从 O(n^2) 到 O(n log n) 再到 O(n)

4.1 版本一:标准循环排序,O(n^2)

前面已经给出完整代码,这里从复杂度角度再做一次拆解。

def cycle_sort(nums): n = len(nums) for start in range(n - 1): item = nums[start] pos = start # 线性扫描定位,O(n) for i in range(start + 1, n): if nums[i] < item: pos += 1 if pos == start: continue # 交换沿着环进行 while pos != start: # 这里又重新从头扫描,也是 O(n) pos = start for i in range(start + 1, n): if nums[i] < item: pos += 1 # 交换 nums[pos], item = item, nums[pos] return nums

虽然代码看着不复杂,但每一轮都伴随着完整的数组扫描。测试大数据时,耗时增长非常明显。

4.2 版本二:二分定位优化,O(n log n)

下面是论坛讨论中出现的“二分查找定位”优化版本。代码适用于元素互不相同的情况,用来演示复杂度变化非常直观:

import bisect def cycle_sort_with_bisect(nums): # 仅用于演示:要求 nums 中所有元素互不相同 n = len(nums) sorted_nums = sorted(nums) for start in range(n - 1): item = nums[start] pos = bisect.bisect_left(sorted_nums, item) while pos != start: nums[start], nums[pos] = nums[pos], item item = nums[start] pos = bisect.bisect_left(sorted_nums, item) return nums data = [3, 1, 4, 2, 0] print(cycle_sort_with_bisect(data))

这个版本的核心变化,是把原来“线性扫描统计比当前元素小的个数”改成了二分查找。每个元素的目标位置可以直接通过有序副本查出来。

复杂度分析:

  • sorted(nums) 排序副本:O(n log n)。
  • 每次二分查找:O(log n)。
  • n 个元素的定位总成本:O(n log n)。
  • 交换次数:O(n)。

所以整体时间复杂度是 O(n log n)。这里要注意,空间复杂度不再是 O(1),因为需要保存一份排序副本,额外空间是 O(n)。

4.3 版本三:特殊输入下的 O(n) 映射法

看到这里,有人可能会问:循环排序真的能做到 O(n) 吗?

在某些特殊输入下,可以。

如果数组元素恰好是 0 到 n-1 的一个排列,那么每个元素最终应该放在哪个位置是直接确定的,就是它本身的值。此时不需要扫描,也不需要二分查找,直接根据值映射下标即可:

def cycle_sort_permutation(nums): # 仅适用于数组元素是 0..n-1 的一个排列 n = len(nums) for i in range(n): while nums[i] != i: target = nums[i] nums[i], nums[target] = nums[target], nums[i] return nums data = [3, 1, 0, 2] print(cycle_sort_permutation(data))

这段代码的时间复杂度是 O(n),因为每个元素被交换一次后就会到达最终位置。这也是很多面试题“原地将数组按序排列”的标准解法。

但它不是通用排序算法。它利用了输入数据的强约束:元素必须是连续整数且互不重复。一旦换成普通数组,这个方法就不成立。

4.4 用运行时间验证复杂度

为了直观感受不同版本的时间增长趋势,可以写一个简单测试脚本。

import random import time import bisect def cycle_sort(nums): # 标准版代码略,直接使用前面实现的函数 pass def cycle_sort_with_bisect(nums): # 优化版代码略 pass def measure(sort_func, data): arr = data.copy() start = time.perf_counter() sort_func(arr) return time.perf_counter() - start for n in [500, 1000, 2000, 4000]: data = random.sample(range(n * 2), n) t1 = measure(cycle_sort, data) t2 = measure(cycle_sort_with_bisect, data) print(f"n={n:5d} 标准版={t1:.4f}s 二分优化版={t2:.4f}s")

运行后你会发现,标准版随着 n 翻倍,耗时大约增长到原来的 4 倍左右,这是典型的 O(n^2) 曲线;而优化版耗时大约增长到原来的 2 倍多一点,更接近 O(n log n) 曲线。用实验数据辅助判断复杂度,是排查算法性能问题的常用手段。

5. 我从中总结的复杂度分析经验

5.1 分析每个循环都要算清执行次数

很多算法被误判为更低复杂度,都是因为只盯着最显眼的操作,而忽略了隐藏循环。

例如循环排序里,人们容易只看到“交换次数 O(n)”,但没去看“为了找到交换位置,每次都要遍历一段数组”。这在二分查找优化版本里尤其容易混淆:

  • 二分查找是 O(log n)。
  • 但是要做 n 次二分查找。
  • 所以总成本是 O(n log n),不是 O(log n)。

判断复杂度时,建议把代码中每一层循环都标出来,写出每层的大约执行次数,再做乘法。如果某一层循环内部还调用了耗时的函数,还要继续展开分析。

5.2 注意比较排序的下界

如果面试中遇到“写一个通用排序算法”的题目,并且要求时间复杂度低于 O(n log n),那一定要提高警惕。因为对于任意基于比较的排序算法,都存在一个理论下界:

最坏情况下至少需要 O(n log n) 次比较

这个结论来自决策树模型:n 个元素的排列有 n! 种可能,每次比较最多把可能性缩小一半,因此至少需要 log2(n!) 次比较,而 log2(n!) 约等于 n log2 n。

所以,任何通用比较排序算法,想达到 O(n) 都是不可能的。除非排序对象有特殊约束,例如:

  • 元素是连续整数,可以直接映射下标。
  • 元素范围有限,可以使用计数排序。
  • 元素有固定位数,可以使用基数排序。

循环排序的优化版同样无法突破这个下界,它的最终复杂度是 O(n log n) 是非常合理的。

5.3 用不同规模实验验证数量级

复杂度的数学推导可能出错,实验验证是很好的补充手段。

做法很简单:取 n = 1000、2000、4000、8000,分别记录算法耗时。然后观察耗时随 n 的变化比例:

  • 如果 n 翻倍,耗时也翻倍,接近 O(n)。
  • 如果 n 翻倍,耗时约变为原来的 2 到 3 倍,接近 O(n log n)。
  • 如果 n 翻倍,耗时约变为原来的 4 倍,接近 O(n^2)。

这种验证方式特别适合排查“以为算法是 O(n),但实际跑起来慢得离谱”的情况。

6. 循环排序的使用场景与工程建议

6.1 循环排序的真正优势

循环排序虽然时间复杂度不占优势,但它有一个非常独特的优点:写入次数少。

在一些场景中,写入和拷贝的代价远高于比较。例如:

  • 数组元素是大型对象,拷贝一次代价很高。
  • 数据要写入寿命有限的存储设备,减少写入次数可以延长设备寿命。
  • 对内存带宽敏感的嵌入式环境。

循环排序的写入次数是 O(n),这是很多 O(n log n) 排序算法做不到的。快速排序、归并排序的平均交换次数虽然也是 O(n log n),但常数通常比循环排序大。

6.2 工程上什么时候不要自己造轮子

大多数业务开发场景,不建议手写循环排序。

原因很直接:

  • 内置排序经过大量优化,稳定性、常数因子、内存占用都优于手写版本。
  • 循环排序最坏情况 O(n^2),数据量大时性能波动明显。
  • 标准循环排序不稳定,无法保留相等元素的相对顺序。
  • 二分定位优化版需要额外 O(n) 空间,代码复杂度也更高,收益却很有限。

所以在 Java、Python、Go、C++ 这些语言里,直接使用标准库排序函数通常是更合理的选择。

6.3 如果你真的想用循环排序

如果经过性能测试,确认写入次数是核心瓶颈,可以考虑循环排序。但要注意以下几点:

  • 确认输入数据规模不会触发 O(n^2) 的最坏情况。
  • 确认不要求排序稳定性。
  • 对重复元素做充分测试,避免死循环。
  • 如果使用二分定位优化版,要额外准备有序副本,评估内存是否充足。
  • 生产环境使用前,必须做好基准测试和压力测试,而不是只看理论复杂度。

7. 小结

循环排序是一个很有教学意义的算法。它的移动次数可以做到 O(n),但整体时间复杂度却没有那么理想。标准实现的定位过程是线性扫描,复杂度为 O(n^2);即使使用二分查找优化定位,也需要构建有序副本并重复二分,最终复杂度是 O(n log n)。

回到论坛那个帖子,真正的收获不在于“谁对谁错”,而在于一个通用的算法分析原则:时间复杂度要看所有操作的累计开销,而不是单看某个操作的成本。

如果你最近也在研究排序算法,建议亲手敲一遍标准循环排序,再用不同数据规模测一测运行时间,体会一下“写入次数少”和“比较次数多”到底是怎么共存的。这一层理解打通之后,再去看快速排序、堆排序、归并排序的复杂度分析,会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询