我最早对“复杂度”这三个字产生真正敬畏,不是在上学时的课堂,而是在一次实际的数据处理任务里。那会儿我需要给一批几百MB的订单记录做排序去重,随手写了个最简单的排序逻辑,结果程序跑起来之后,咖啡都续了两杯,进度条还在原地磨蹭。旁边同事扫了一眼代码,说了一句让我记到现在的话:“你这里套了双层循环,数据规模一万的时候是万次操作,数据规模一千万的时候就成了一万亿次,你说它慢在哪?”那一刻我才意识到,不懂复杂度,你连自己的程序为什么慢都说不清楚。
今天这篇就围绕“认识复杂度和简单排序算法”这个主题,把我自己学习、复习、带新人时反复用到的思路整理一遍。内容上不整那些花哨的高级结构,就踏踏实实把时间复杂度、空间复杂度的概念讲透,再把选择排序、冒泡排序、插入排序这三个最简单也最经典的排序算法逐行拆开,分析它们到底快在哪、慢在哪、什么时候用得上。适合刚接触算法的学生,也适合工作几年但对基础有些生疏、想回头补课的朋友。
1. 复杂度不是数学考试,而是算法的体检报告
1.1 为什么算法一定要谈“规模”
很多初学者刚开始写程序时,第一反应是“能跑就行”。数据量只有几十条、几百条时,确实怎么写都快,用户根本感知不到差异。可问题在于,真实世界的软件很少只处理几百条数据——你有几千个商品、几万个用户、几百万条日志,这些都是很常见的量级。一旦数据规模上去,不同算法的差距就会被放大到肉眼可见的程度。
我习惯把算法比作一次搬砖的过程。一个人搬十块砖,用什么姿势都无所谓;但让你搬一万块砖,是每次搬一块来回跑一万趟,还是一次能扛五十块跑二百趟,差别就极其明显了。复杂度的本质,就是用一套数学语言去描述“随着砖块数量增加,干活的总时间会怎样增长”,而不是纠结某一次具体跑了多长时间。
这里有一个关键认知:复杂度描述的是增长趋势,不是精确耗时。同样的算法,在性能好的机器上可能只需要1秒,在性能差的机器上需要10秒,但“数据翻倍后耗时是否也跟着翻倍、翻四倍,甚至翻更多”,这个趋势是相对稳定的。我们衡量算法好坏,核心就是看这个趋势是否可控。
1.2 大O表示法到底在说什么
大O表示法是描述复杂度最通用的语言。它的完整数学定义比较复杂,但实际理解时可以抓三个要点:
- 只看数据规模n的影响,忽略各种常数项和低阶项。
- 关注n趋向无穷大时,表达式的主导项是什么。
- 用O(表达式)的形式表示算法耗时或占空间的规模级别。
举个例子,某个算法实际的详细操作次数是 3n² + 5n + 100,当n只有10时,三项分别是300、50、100,看起来低阶项和常数还挺有存在感;但当n变成1000时,3n²是300万,5n是5000,100的常数项几乎可以忽略。所以这个算法的时间复杂度就记作O(n²)。
大O里的“O”可以理解成“order”,也就是数量级。它不回答“你这次具体花了多少毫秒”,它回答的是“你这个算法的耗时和操作次数按什么数量级增长”。从工程角度看,这比精确毫秒数更有指导意义,因为硬件会变、编译器会变、数据分布会变,唯独“两层循环嵌套处理n个数据”这种结构性的代价不会变。
1.3 常见复杂度级别,建立直觉印象
下面这张表我每次给新人讲复杂度时都会拿出来,它展示了同一个算法在不同复杂度级别下,数据规模翻10倍、100倍时的耗时增长倍数:
| 复杂度级别 | 含义 | 数据规模10倍时耗时大约变为 | 数据规模100倍时耗时大约变为 | 典型代表 |
|---|---|---|---|---|
| O(1) | 常数时间 | 1倍 | 1倍 | 数组按下标取元素 |
| O(log n) | 对数时间 | 略增(约3.3倍,如果底数为2) | 约6.6倍 | 二分查找 |
| O(n) | 线性时间 | 10倍 | 100倍 | 单层循环遍历 |
| O(n log n) | 线性对数时间 | 约23倍(考虑10*log10大致估算) | 约660倍左右 | 归并排序、快速排序 |
| O(n²) | 平方时间 | 100倍 | 10000倍 | 冒泡排序、选择排序 |
这张表我建议直接保存下来,比背一堆公式都有用。你看O(n²)和O(n)之间的差距:数据只扩大了100倍,O(n²)的耗时扩大了整整一万倍。这就是为什么在真实项目中,只要你稍微能写出一个O(n²)的双层循环,就得反复掂量输入的规模到底有多大。
空间复杂度也是同样的思路,只不过它衡量的不是耗时,而是算法运行过程中额外占用的内存规模。比如排序算法里有一类原地排序,额外空间基本是O(1)或O(log n)级别的常数;而归并排序因为需要临时数组来合并,空间复杂度通常是O(n)。两者没有绝对的好坏,看场景需要。
2. 三个简单排序的逐行拆解:选泡插三兄弟
2.1 选择排序:最直观的“找最小值”思路
选择排序的逻辑是所有排序里最好理解的:从头到尾扫描,每轮找到剩余部分的最小值,把它放到当前轮次的位置上。说直白点,就像你在排队里不停地挑个子最矮的人拉出来排在最前面,直到队伍全部排好。
我用Java写了一个经典的实现:
public static void selectionSort(int[] arr) { if (arr == null || arr.length < 2) { return; } for (int i = 0; i < arr.length - 1; i++) { int minIndex = i; // 在 i 之后的范围里寻找最小值的下标 for (int j = i + 1; j < arr.length; j++) { if (arr[j] < arr[minIndex]) { minIndex = j; } } // 找到后,把最小值和 i 位置做交换 swap(arr, i, minIndex); } } private static void swap(int[] arr, int i, int j) { int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; }来看一个具体走查。假设数组是 [5, 3, 8, 6, 2]:
- 第一轮i=0,从下标1到4扫描,找到最小值2,下标是4,交换后数组变成 [2, 3, 8, 6, 5]。
- 第二轮i=1,从下标2到4扫描,最小值是3,下标本来就是1,不需要交换(代码里即使等于自己也可以swap一次)。
- 第三轮i=2,从下标3到4扫描,最小值5在下标4,交换后数组变成 [2, 3, 5, 6, 8]。
- 第四轮i=3,只剩下下标4,最小值就是8本身,结束。
选择排序的时间复杂度非常稳定:无论数据原本是什么顺序,它都要做完整的双重扫描,比较次数永远是n(n-1)/2次,所以时间复杂度固定是O(n²)。交换次数则相对少,最多n-1次,这是它比冒泡排序的一个隐性优势。空间复杂度O(1),属于原地排序。
但它不稳定。为什么?因为交换会把元素直接挪到很远的位置,如果数组里有相同值的元素,靠前的那个可能被换到后面去。我第一次给别人讲“稳定性”的时候,对方不太理解,我打了个比方:假设你有一排同学,先按学号排了序,再用稳定排序按身高排序,学号小的同等身高同学会保持在前面;万一用的是不稳定排序,同身高的人学号顺序就可能被打乱。如果你后面的业务逻辑依赖这种先后顺序,稳定性就非常关键。
2.2 冒泡排序:相邻比较,把最大值“顶”到末尾
冒泡排序的思路可以类比水里的气泡慢慢浮到水面:每一轮从头开始,两两比较相邻元素,如果顺序不对就交换,一趟走完,当前范围内的最大值就被“冒”到了最后面。下一轮就不需要再看最后一个位置了。
经典实现如下:
public static void bubbleSort(int[] arr) { if (arr == null || arr.length < 2) { return; } // end 逐渐缩小,代表每轮冒泡的结束边界 for (int end = arr.length - 1; end > 0; end--) { for (int j = 0; j < end; j++) { if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1); } } } }有些教材里会把内层循环写成j < arr.length - 1 - i,实际上等价于这里用end变量逐步收缩边界。这个写法更直观,也便于做优化。
继续用 [5, 3, 8, 6, 2] 走一遍:
- 第一轮end=4:5和3比较,交换成[3,5,8,6,2];5和8不用动;8和6交换成[3,5,6,8,2];8和2交换成[3,5,6,2,8]。最大值8到了末尾。
- 第二轮end=3:范围是[3,5,6,2],3和5不动,5和6不动,6和2交换成[3,5,2,6,8]。
- 第三轮end=2:范围是[3,5,2],3和5不动,5和2交换成[3,2,5,6,8]。
- 第四轮end=1:范围是[3,2],3和2交换成[2,3,5,6,8]。排序完成。
冒泡排序有一个常见优化点:如果某一轮从头到尾扫描完,一次交换都没有发生,说明数组已经有序,可以直接提前退出。这个优化对近乎有序的数据非常有效,能把最好情况的时间复杂度优化到O(n)。我后来在工程代码里看到很多低级双层循环,都会下意识想想“是不是可以加个early stop标志”。
但即便如此,冒泡排序平均情况下依然是稳定O(n²)的排序。它最大的特点就是交换次数非常多,最坏情况下要做n(n-1)/2次交换,数据量大一点,光是swap的开销就够受的。写代码时,swap本身是一个很轻的操作,但轻不代表免费,海量数据下频繁交换会显著拖慢性能。稳定性方面,冒泡排序是稳定的,因为只有相邻元素严格大于才交换,相同元素不会跨位交换。
2.3 插入排序:像整理扑克牌一样
插入排序的思路,当我第一次学的时候就被它的生活化类比打动了:你打扑克牌时,摸到新牌会直接插入手里已经排好序的牌堆中,插入到合适的位置,而不是把所有牌重新理一遍。程序里的做法是:维护一个“左侧已排序区”,每次取出右侧下一个元素,在左侧已排序区里从后往前找位置,边找边把比它大的元素往右挪,直到找到正确位置放入。
public static void insertionSort(int[] arr) { if (arr == null || arr.length < 2) { return; } for (int i = 1; i < arr.length; i++) { // 当前要插入的值 int current = arr[i]; int j = i - 1; // 从右往左扫描已排序区,比 current 大的都往右挪 while (j >= 0 && arr[j] > current) { arr[j + 1] = arr[j]; j--; } // 找到位置,放入 current arr[j + 1] = current; } }用 [5, 3, 8, 6, 2] 模拟一遍:
- i=1,current=3。左侧已排序区是[5],3比5小,把5往右挪成[5,5,8,6,2](实际是[5,5,6,2占位]),然后3放到下标0,数组变成[3,5,8,6,2]。
- i=2,current=8。左侧已排序区是[3,5],8比5大,直接放在末尾,数组不变为[3,5,8,6,2]。
- i=3,current=6。左侧是[3,5,8],8比6大,右移;5比6小,停止。6放到下标2,数组变成[3,5,6,8,2]。
- i=4,current=2。左侧是[3,5,6,8],全部比2大,逐一右移,最后2放到下标0,数组变成[2,3,5,6,8]。
插入排序最好情况的时间复杂度是O(n)——数组整体基本有序时,每次current几乎只需要比较一次就能落位,内层循环基本不执行。最坏和平均情况是O(n²)。空间复杂度O(1)。而且它是稳定排序,因为这边的移动是逐个平移覆盖,不会出现跳跃式交换破坏相同元素的相对顺序。
这里值得多提一句:在三个O(n²)的简单排序里,插入排序往往被低估。尤其是当数据量不大或者数据近乎有序时,插入排序的实际常数很小,甚至比一些理论上更优、但常数较大的O(n log n)算法还快。很多高级排序算法在“小区间优化”时,都会选择插入排序来收尾,这是一个非常重要的工程经验。
3. 横向对比:三家各自适合什么场景
3.1 复杂度与稳定性总表
把三个算法放在一张表里看,差异就非常清晰:
| 排序算法 | 最好时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 是否稳定 | swap/挪动特点 |
|---|---|---|---|---|---|---|
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 否 | 交换少,最多n-1次 |
| 冒泡排序 | O(n)(有优化时) | O(n²) | O(n²) | O(1) | 是 | 交换频繁,最坏O(n²) |
| 插入排序 | O(n) | O(n²) | O(n²) | O(1) | 是 | 移动次数多,但赋值比swap轻 |
从纯粹的大O角度看,三兄弟的平均复杂度都是O(n²),好像半斤八两。但工程里不能只看大O,还要看常数项和数据的初始状态。选择排序虽然交换次数少,但它无论数据多好,都要做完整的双重循环比较,得不到任何“提前发现有序”的收益。冒泡排序和插入排序在数据基本有序时都能提前感知到,插入排序的感知能力尤其强,而且它挪动数据用的是赋值操作,比swap少一次临时变量读写,实际常数更小。
我个人的排序是:数据规模小于几十条、或者数据本身基本有序时,直接选插入排序;需要保证相同关键字相对顺序时,优先考虑稳定算法,冒泡和插入都可以;如果明确数据量小且乱序、尤其交换成本很高(比如数组元素是体积很大的对象而你想避免频繁交换),选择排序也有一席之地。
3.2 为什么O(n²)算法在工程里还没绝迹
有人可能会问:既然有快排、归并这些O(n log n)的算法,为什么还要学这三个简单的?
第一个原因是理解成本。O(n²)的算法逻辑极其朴素,是建立“算法直觉”的最佳起点。你先能徒手写出选择排序、能说出冒泡为什么慢,之后再去理解快排的partition、归并的merge,才有参照物。
第二个原因是实际性能竞争力。现代很多排序库在元素数量很少时,会从递归或分治策略切换到插入排序。因为在n很小的情况下,递归带来的函数调用开销和额外数组分配可能比O(n²)本身还要贵。比如某个知名排序算法就在小区间内使用插入排序来降低整体耗时,这不是玄学,是工程上反复压测出来的选择。
第三个原因是它们可以充当“正确性对照”的基准。我在实际做项目时,如果自己实现了一个快排或归并,第一件事不是直接上线,而是先用一个简单正确的排序算法跑一遍随机数组,拿结果做对照,验证高级实现有没有写错边界条件。这种测试策略,即使用不上简单排序的生产性能,也值得做。
3.3 从稳定性说到“排序数据”的迁移成本
再展开一下稳定性。咱们做真实的业务时,排序往往不是孤立行为。比如先按时间字段排好,又需要按优先级字段再排一次,如果第二次排序是稳定的,则优先级相同的数据会保持第一次的时间顺序;如果不稳定,那第一次的排序结果可能会被破坏。
假设有一批工单数据,你希望同优先级的工单仍然按照创建时间从早到晚展示。如果第二次按优先级排序用的是稳定算法,一次处理就满足需求;如果用了不稳定算法,你不得不再加一个组合比较器,把优先级和时间一起比较。一个是排序两次还未必对,一个是一次就能直接搞对,稳定性在真实场景就是这种级别的差别。
回到简单排序:选择排序不稳定,是因为它做的是“远距离交换”,很容易把等值元素里的某个直接甩到另一个后面。冒泡和插入则不同,它们的调整都是相邻比较、相邻挪动,相同元素之间没有跨越式交换。这也是你在简单算法里,可能就要开始为业务埋下的那颗种子。
4. 学完这三个排序,我沉淀下来的几个实操心得
4.1 边界条件:内层循环的起止点,用“排好区域”来推导
带新人学排序时,我发现最容易写错的就是边界。选择排序的内层循环到底从i+1开始,还是从i开始?冒泡排序的内层循环要不要包含最后一个元素?插入排序的while循环是j>=0还是j>0,arr[j] > current还是>=?
我的经验是,不要死记代码,而是用“已排序区域边界”来推:
- 选择排序里,下标i左边是已经排好的区域,右边是待处理区域。所以内层j从i+1开始,寻找待处理区域的最小值。
- 冒泡排序里,end变量代表“本轮参与比较的最大下标”,内层j只需要走到end-1,因为每次比较是arr[j]和arr[j+1],一旦j等于end,j+1就越界了。
- 插入排序里,i指向“新拿出来的待插入元素”的原位置,左边0到i-1是已排序区,j从i-1开始向左移动,直到arr[j] <= current,此时j+1就是插入点。
这套推导方式你自己多走查几遍小数组,比背代码可靠得多。我每次教别人,都要求先在草稿纸上写一遍[4,3,2,1]这样的逆序数组,把每一轮的状态人工模拟出来,再对照代码看差异。能徒手写出这个过程的人,基本不会再在边界上栽跟头。
4.2 别小看swap的代价:赋值与交换不是一回事
冒泡和选择都用了swap,插入排序用的是平移赋值。表面上看都是O(n²)的算法,但常数项的差距一直存在。交换一次,做的事情是:把a存到临时变量,把b赋给a位置,把临时变量赋给b位置,涉及三次赋值。而插入排序的平移,每次只需要做一次赋值,只是把右边的值覆盖到右边相邻的位置。
我记得有一次写一个对自定义对象按Score字段排序的批量任务,对象本身不大,但数量到了百万级。我最初用了冒泡排序(其实这种规模本就不该用O(n²),但一时图省事),跑下来慢得离谱。我换成插入排序做了初步优化,性能立刻好了不少,虽然没到快排的量级,但也说明常数项确实是O(n²)算法内部很重要的区分因素。
所以当你需要在“三个简单排序”里做选择,我会建议:交换成本低、数据比较小、无所谓稳定性,选哪个都行;交换成本高,少用swap;数据大概率接近有序,插入排序的正确率最高。
4.3 验证排序正确性:用随机小数组打底,别光看一次跑通
我自己的习惯是,刚写完一个排序算法,不会直接丢很大的测试数据进去,而是先写一个简单的check程序:生成随机长度、随机值的数组,同时用数组库自带的排序得到一个标准答案,再和自己实现的排序结果做逐元素比较。这种做法能在你调试代码时快速暴露问题是出在交换上、比较符上、还是边界条件上。
另外,一个建议是测试数据一定要包含重复值,否则稳定性的问题根本测不出来。你会看到选择排序处理[2,1,2]这种输入,原本下标0和下标2都是2,但经过一次交换,两个2的相对顺序可能已经变了。如果你没有包含重复元素的测试用例,很多和稳定性相关的潜在问题,都会悄悄溜过去。
4.4 一个可以立刻用起来的练习路线
如果你初学或者想复习,我强烈建议按这个顺序做一组练习:
- 手写选择排序,能写出外循环和内循环的完整边界。
- 给冒泡排序加上“如果本轮无交换则提前退出”的优化,并用一个已经有序的数组测试,观察它能提前多少轮退出。
- 手写插入排序,然后打印每一轮结束时数组的状态,重点关注“比它大的元素逐个右挪”这个过程。
- 把三个算法的耗时在小规模数据上做一个直观对比(比如长度50、500、5000的乱序数组),复现一下O(n²)的增长趋势。
做这组练习的意义,不是让你今后真的用冒泡排序去处理海量业务数据,而是帮你建立一套“预判算法表现”的直觉。当你之后看到某些双重循环代码时,能立刻意识到数据规模对耗时的放大效应;看到别人把插入排序用在小区间优化时,也能明白背后的工程逻辑。
5. 写在后面:简单排序是最好的复杂度思维启蒙
学完这三个排序,再回头看“复杂度”这个概念,你就不会觉得它只是考试里的一个名词了。它的本质是你和计算机之间的一场对话:你告诉计算机“按这个思路来”,计算机用运行时间告诉你“这个思路在n增长时扛不扛得住”。简单排序之所以值得反复咀嚼,恰恰因为它们足够简单,能让你清清楚楚看到每一条循环、每一次交换和整个算法的复杂度是如何一步步生成的。
我在实际带人的时候,见过不少一上来就背快排代码的同学。代码确实是背下来了,但问他为什么快排平均是O(n log n)、为什么最坏会退化到O(n²)、为什么需要随机化选基准,他就答不上来。问题根源就在于,他对复杂度还没有建立起真正的肌肉记忆。而选泡插这三个简单排序,正是建立这种肌肉记忆最好的起点。
以后再看到“认识复杂度和简单排序算法”这种标题,别觉得它只是入门课的目录页。把复杂度想成一种分析工具,把选择、冒泡、插入想成三个最容易上手的练习样本,你会发现学算法这件事,最值钱的反而是最开始那些看上去很简单的东西。