前几天逛技术论坛,看到一个很有趣的帖子。作者发了一套号称 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 标准算法步骤
标准的循环排序过程如下:
- 从 start = 0 开始,取出当前起点元素 item。
- 在 start 之后的区间中,统计有多少个元素比 item 小,记为 pos。
- 如果 pos 等于 start,说明 item 已经在自己该在的位置,跳过。
- 如果 pos 不等于 start,说明 item 应该被放到索引 pos,于是把 item 放到 pos,取出原来 pos 位置上的元素,继续重复“统计位置、交换”。
- 当取出的元素回到 start 位置时,当前这个环就处理完毕。
- 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)。
回到论坛那个帖子,真正的收获不在于“谁对谁错”,而在于一个通用的算法分析原则:时间复杂度要看所有操作的累计开销,而不是单看某个操作的成本。
如果你最近也在研究排序算法,建议亲手敲一遍标准循环排序,再用不同数据规模测一测运行时间,体会一下“写入次数少”和“比较次数多”到底是怎么共存的。这一层理解打通之后,再去看快速排序、堆排序、归并排序的复杂度分析,会顺畅很多。