☰
复杂度分析入门:选择、冒泡与插入排序的深度拆解
2026/10/19 11:14:47 网站建设 项目流程

面试聊到排序算法,十个有八个都会从“你写个快排”开始,但聊到后面,真正能拉开差距的往往是那句“这个算法的时间复杂度是多少?为什么?”。

“认识复杂度和简单排序算法”这个话题,乍一看是数据结构与算法的入门第一课,似乎没什么好讲的。但我在实际带人和做项目评审的过程中发现,很多写过几年业务代码的开发者,对复杂度的理解其实停留在“背诵结论”的层面——知道快排是O(n log n),冒泡是O(n²),但你要是问他“这个结论在什么条件下成立”“数据已经有序时插入排序为什么快”“稳定性到底影响了什么”,他就开始含糊了。

这篇文章我想换个角度,不念教科书,而是从“为什么要关心复杂度”讲起,把选择排序、冒泡排序、插入排序这三个最基础的简单排序算法彻底拆开,配合代码、推导和实测心得,讲清楚它们背后的复杂度逻辑。适合刚入门算法的人打基础,也适合准备面试的人把概念理得更扎实。

1. 复杂度:排序算法绕不开的第一道门槛

1.1 为什么不用秒表来衡量算法好坏

很多人第一次接触复杂度时都有个疑问:我直接跑一下程序,用计时器量一下运行时间,不比抽象分析更直接吗?

我在工作中确实干过这种事。同一个排序函数,在开发笔记本上跑一万条数据耗时50毫秒,放到线上服务器跑可能只要20毫秒;换个时间段再跑,CPU被其他进程占用了,又变成80毫秒。你会发现用实际运行时间衡量算法好坏,就像用体重秤量身高——工具没坏,但它测的根本不是你想衡量的那个维度。

运行时间取决于三个因素:机器性能、编程语言、数据规模。前两个是和算法本身无关的环境变量,只有数据规模和算法设计才是我们真正能控制的。复杂度分析做的事情,就是把前两个变量“屏蔽”掉,只研究“数据规模增长时,操作次数的增长趋势”。

打个比方:一个人从一楼走到十楼,你问他“走了多久”,他说“看情况,电梯坏了十分钟,没坏一分钟”。但你要是问他“走了多少级台阶”,这是个相对稳定的答案。复杂度分析统计的就是“级台阶数”,而不是“花的时间”。

所以在排序算法里,我们说一个算法的时间复杂度是O(n²),意思是:当数据量n变成原来的2倍时,操作次数大约变成原来的4倍。这个规律和机器快慢无关,和用什么语言也无关。

1.2 大O表示法到底在表达什么

大O表示法用的是“渐近上界”的概念,通俗点说就是:当数据量足够大时,操作次数的增长不会超过某个量级。

理解大O的关键在于“只保留增长最快的那一项,并且去掉常数系数”。比如某个算法总的操作次数是 3n² + 5n + 2,当n很小的时候,3n²和5n的差距不大;但当n=10000时,3n² = 3亿,5n = 5万,后者连前者的千分之一都不到。所以大O写作O(n²)。

这就像你研究一个城市的人口增长,短期来看出生率、死亡率、迁移率都有影响,但长期来看,决定性因素是“增长率”本身,而不是那几万人的绝对差值。大O抓的就是这个“长期趋势”。

常见的复杂度从好到坏排列大致是:

  • O(1):常数时间,和数据规模无关。比如数组按下标取值。
  • O(log n):对数时间,数据量翻倍时操作次数只加一次。比如二分查找。
  • O(n):线性时间,遍历一遍数组。
  • O(n log n):线性对数时间,优秀排序算法的代表。
  • O(n²):平方时间,本文三个简单排序都是这个量级。
  • O(2^n):指数时间,数据稍微增长就爆炸。

这里面有个容易忽略的点:复杂度的比较是在“n足够大”的前提下进行的。当n只有5的时候,O(n²)的算法加上极小的常数项,实际可能比O(n log n)的算法更快。这也是后面讲工程选型时的一个重要伏笔。

1.3 时间复杂度和空间复杂度要一起看

很多初学者只看时间复杂度,忽略了空间复杂度,这在实际工程里是要吃大亏的。

空间复杂度衡量的是算法运行过程中额外开辟的内存空间,也按数据规模n的增长趋势来表示。如果一个排序算法只需要常数级别的额外空间(比如几个临时变量),我们说它的空间复杂度是O(1),也叫原地排序。

举个例子:你把一个数组传给排序函数,如果函数内部直接操作原数组,不创建新数组,那就是O(1)空间;如果函数内部 clone 了一份数组再排序,空间复杂度就是O(n)。

时间复杂度和空间复杂度往往是跷跷板。想更快,可能就要多占内存;想省内存,可能就要接受慢一点的排序。比如后面会提到的归并排序时间复杂度是O(n log n),比简单排序快很多,但它需要一个额外数组,空间复杂度是O(n)。而插入排序虽然慢,但空间复杂度完美做到O(1)。

我见过一个真实案例:某系统要对几十万条记录排序,负责人图省事用了带拷贝的函数,结果内存直接翻倍,线上服务在高峰期频繁触发垃圾回收。后来改成原地排序,内存立刻稳住了。这就是只盯着运行时间不看空间的典型教训。

2. 选择排序:最符合直觉的“找最小值”

2.1 算法思路与代码实现

选择排序的思路简单到可以用一句话说清:每一轮从未排序区间找到最小值,放到已排序区间的末尾。

想象你手里有一把乱序的牌,你一张张看,找出最小的那张放到最左边,然后从剩下的牌里再找最小的放到第二张的位置,以此类推。整个过程你最多需要拿n轮牌,每轮都要把剩下的牌全部扫一遍。

function selectionSort(arr) { const n = arr.length; for (let i = 0; i < n - 1; i++) { let minIndex = i; for (let j = i + 1; j < n; j++) { if (arr[j] < arr[minIndex]) { minIndex = j; } } if (minIndex !== i) { [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]; } } return arr; }

这个代码的关键点在内层循环:每一轮从 i+1 开始找,把最小值的位置记下来,一轮结束时才交换。注意是“记录位置”,不是“遇到更小的就立刻交换”,这样可以减少无谓的交换次数。

2.2 复杂度推导:严格意义的O(n²)

选择排序的时间复杂度推导是三个算法里最“诚实”的,因为它的比较次数完全不受初始数据顺序影响。

第一轮要比较 n-1 次,第二轮 n-2 次,第三轮 n-3 次,直到最后一轮 1 次。总比较次数是:

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

展开就是 (n² - n)/2,按照大O规则取最高项、去掉常数,得到O(n²)。

空间复杂度是O(1),因为它只用了 minIndex 一个额外变量,交换也是就地完成的。

这里有个很多人没意识到的事实:选择排序的复杂度是“铁打的O(n²)”,不管输入数据是乱序、正序还是倒序,比较次数永远一样。这一点和后面的插入排序形成鲜明对比。

我实测过一个场景:给一个已经有序的数组做排序,选择排序依然老老实实比较了差不多 n²/2 次,耗时和其他数据分布几乎没有区别。而插入排序在这种输入下能做到O(n)。这种“不知变通”的特性,决定了它在实际工程中很少被单独使用。

2.3 选择排序的优点和适用场景

虽然复杂度不理想,但选择排序有它的独到之处:交换次数是所有排序算法里最少的之一。

每轮最多一次交换,总共最多 n-1 次交换,而冒泡排序每次比较都可能触发交换,交换次数最坏可能达到 n²/2。如果你的数据是“交换成本极高”的类型,比如排序的是磁盘上的大块记录,移动一条记录的成本远高于比较两个整数,选择排序反而可能胜过冒泡排序。

适用场景总结一下:

  • 数据量很小(几十上百),且对代码简洁度要求高。
  • 交换操作的代价比比较操作大得多。
  • 教学演示,用来建立“每轮选一个极值”的基础思维。

但选择排序有个明显短板:不稳定。比如数组里有多个相同的值,选择排序在找最小值的过程中可能把相同值的相对顺序打乱。后面讲稳定性的时候我会细说这件事的工程影响。

3. 冒泡排序:让大数慢慢“浮”上去

3.1 算法思路与基础实现

冒泡排序的思路听起来像物理课:每一轮从头到尾两两比较相邻元素,如果前一个比后一个大,就交换它们。这样一轮下来,最大的数就像气泡一样“浮”到了数组末尾。下一轮只需要处理前面 n-1 个元素。

function bubbleSort(arr) { const n = arr.length; for (let i = 0; i < n - 1; i++) { for (let j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; } } } return arr; }

注意内层循环的边界:随着外层轮数增加,末尾已经排好的元素不再参与比较,所以是 n - 1 - i。这是个容易写错的地方,我见过不少人把内层循环写成 n - 1,导致已经排好的元素被反复比较,效率变差但结果还正确,属于一种隐蔽的错误写法。

3.2 冒泡排序的复杂度与优化空间

冒泡排序的比较次数,在不优化的情况下:

第一轮 n-1 次,第二轮 n-2 次,总计也是 n(n-1)/2,时间复杂度O(n²)。空间复杂度O(1)。

但冒泡排序和选择排序有个本质区别:它的实际执行次数受输入数据影响。如果数组一开始就是有序的,内层循环仍然会傻傻地跑完全部轮次,虽然不会触发交换,但比较次数一分不少。

增加一个“是否发生交换”的标志位,可以把最优情况优化到O(n):

function bubbleSortOptimized(arr) { const n = arr.length; for (let i = 0; i < n - 1; i++) { let swapped = false; for (let j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; swapped = true; } } if (!swapped) break; } return arr; }

这里的关键逻辑是:如果某一轮从头到尾没有发生任何交换,说明整个数组已经有序,后面的轮次全部可以跳过。这个优化在真实场景中非常有效,尤其是那些“整体接近有序”的数据,比如一个偶尔被追加几条新记录但大部分时间已经排好的列表。

不过要注意,即使加了优化,冒泡排序的平均复杂度依然是O(n²),只有当输入数据“恰好几乎有序”时才能发挥出接近O(n)的表现。面试时如果你写冒泡,最好主动说出这个优化,它是一个和复杂度理解深度直接挂钩的加分点。

3.3 冒泡排序的稳定性

冒泡排序只交换“相邻且逆序”的元素,相等元素之间不会交换位置,所以它是稳定的排序算法。

稳定性的概念是这样的:假设数组里有两条记录,它们的排序关键字相同,排序后先出现的仍然在前。比如按成绩排序,张三和王五都是90分,排序前张三在数组下标2,王五在下标5,如果排序后张三依然在王五前面,就说这个排序是稳定的。

为什么稳定这么重要?因为实际业务中的数据往往不是孤立的数字,而是携带额外信息的对象。你按分数排序后,还可能希望相同分数的人保持原来的先后顺序。如果先按姓名排,再按分数排,第二次排序时稳定的算法能保留第一次排序的结果。

冒泡排序在这里表现很好,但代价是频繁交换带来的性能开销。我经常说冒泡是“用性能换稳定性”的典型。

4. 插入排序:像整理扑克牌一样优雅

4.1 算法思路与代码实现

插入排序的思路,玩过扑克牌的人都能秒懂:你抓牌的时候,把新抓到的牌插入到手里已经排好序的牌中的正确位置。

算法把数组分成已排序区间(起始只有第一个元素)和未排序区间。每次从未排序区间取出第一个元素,从后往前和已排序区间的元素比较,找到合适位置插入。

function insertionSort(arr) { const n = arr.length; for (let i = 1; i < n; i++) { const current = arr[i]; let j = i - 1; while (j >= 0 && arr[j] > current) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = current; } return arr; }

这段代码里有一个微妙的点:不是用“交换”实现的,而是用“后移腾位”。先把当前值存在 current 里,然后从已排序区间从后往前找,凡是比 current 大的都往后挪一位,最后把 current 放到空出来的位置。这样做的好处是减少交换次数,只需要一次赋值就能完成插入。实际运行时,这种赋值操作比交换两个元素要快得多。

4.2 复杂度的“两面性”:最好O(n),最坏O(n²)

插入排序的时间复杂度和输入数据的初始顺序强相关,这是它和选择排序最大的不同。

最坏情况:数据完全倒序,每次插入都要把已排序区间的所有元素后移一位。比较和移动次数都是 n(n-1)/2,复杂度O(n²)。

最好情况:数据已经有序,每次插入时 current 只需要和区间最后一个元素比较一次,发现不小于就停下。整个排序过程只比较 n-1 次,复杂度O(n)。

平均情况:仍然是O(n²),但常数项比冒泡和选择都小一些。

这意味着插入排序在“数据几乎有序”的场景下有天然的效率优势。实际工作中我就遇到过这样的例子:某个缓存模块的过期时间是按时间戳排序的,每隔几秒追加一两条新数据。这里的“排序”操作如果用插入排序思路处理,每次新增记录只需在尾部做一两次比较和移动,代价极低;要是调用一次全量排序,纯属浪费时间。

还有一个隐藏优势:插入排序的常数项小,对“小规模数组”排序时,它甚至常常比快排、归并这些O(n log n)算法更快,因为这些高级算法在数据规模小的时候,递归调用的开销反而超过了算法本身的优势。很多主流语言在实现 sort 函数时,遇到小数组会切换到插入排序,比如某著名排序实现对长度小于某个阈值的子数组就用插入排序收尾。

4.3 插入排序的稳定性与工程地位

插入排序是稳定的。因为后移操作只针对严格大于 current 的元素,等于 current 的元素不会被越过,相同值的相对顺序得以保留。

综合来看,插入排序在三个简单排序算法里“综合战斗力”最强:实现简单、稳定性好、常数项小、对接近有序的数据效率极高。所以在实际工程中,它很少作为独立的排序方案出现,却往往作为高级排序算法(如快速排序、归并排序)在递归底层的“终结者”被调用。这也提醒我们:不要因为一个算法复杂度高就彻底否定它,要看它服务于什么场景。

5. 三种简单排序的对比与选型思路

5.1 一表看尽三兄弟

算法平均时间复杂度最好情况最坏情况空间复杂度稳定性
选择排序O(n²)O(n²)O(n²)O(1)不稳定
冒泡排序O(n²)O(n)O(n²)O(1)稳定
插入排序O(n²)O(n)O(n²)O(1)稳定

注意表格里选择排序的“最好情况”依然是O(n²),这就是它顽固的地方。冒泡和插入在数据有序时都能跑到O(n),但冒泡即使优化后仍然要跑完第一轮才知道有序,插入则每轮只比较一次就发现有序,所以插入排序在“接近有序”数据上的常数项优势比冒泡更明显。

5.2 工程上怎么选:先看数据特征

我自己的经验是,排序算法的选择顺序永远是:先看数据特征,再看复杂度,最后才看代码好不好写。

具体来说:

  • 数据量很小(比如20条以内),直接上插入排序,别折腾快排。因为递归和分区的固定开销在小数据量下不值得。
  • 数据量中等(几百到几千),且整体接近有序,插入排序依然很能打,冒泡优化版也可以备选。
  • 数据量很大(上万甚至更多),这三个都不够看,必须换O(n log n)的排序算法,比如快排、归并。
  • 排序对象是“交换代价极高”的大对象,优先考虑选择排序,因为它的交换次数最少。
  • 需要稳定排序,且数据量中等,插入排序是性价比之王;数据量大又要求稳定,归并排序才是正解。

这些经验不是背下来的,而是被线上问题教训出来的。我有一次负责一个小工具,往一个列表里塞了五千条数据用了插入排序思路,处理时间不到10毫秒,完全没问题。另一次没有考虑数据量,用了嵌套循环全量排序,页面直接卡了半秒。复杂度不只是面试题,它直接对应着用户体验。

5.3 面试中关于排序的复杂度追问

面试官问排序,表面是考你会不会写代码,实际是考你对复杂度的理解深度。常见追问包括:

  • “你写的这个排序什么情况下表现最好?” 答插入排序:当数据接近有序时能到O(n)。
  • “为什么说它不稳定?能不能举例?” 这时候最好直接给出一个逆序对例子,比如 [2, 2, 1],第一步就可能让两个2的相对顺序变化。
  • “你能把它优化到什么程度?” 冒泡加标志位、插入排序用后移代替交换,都是能体现思考深度的答案。

我个人建议大家学这三个算法时,不要只背代码,而是每写完一个,都问自己三个问题:它最好和最坏差多少?影响复杂度的是比较次数还是交换次数?什么场景下它反而比高级算法更合适?想通了这三个问题,复杂度就算真正入门了。

6. 动手验证:自己写个小实验把复杂度“看”出来

6.1 实验设计思路

光看书上写O(n²),很多人是没有体感的。我建议你亲手做个实验:生成不同规模(比如 n = 1000, 2000, 4000, 8000, 16000)的随机数组,分别用三种排序去跑,记录耗时,然后看数据增长规律。

把每种算法在同一台机器上跑,理论上 n 翻倍时,耗时应该大约变为原来的4倍,这就是O(n²)的直观显现。真正动手后你才会发现,实际曲线会有波动,但整体趋势逃不出这个规律。

实验代码可以用你熟悉的任何语言。我常用 JavaScript 做这类验证,因为写起来快,浏览器也能跑:

function testSortingTime(sortFn, n) { const arr = Array.from({ length: n }, () => Math.random()); const start = performance.now(); sortFn(arr); const end = performance.now(); console.log(`n=${n}, time=${(end - start).toFixed(2)}ms`); } testSortingTime(insertionSort, 1000); testSortingTime(insertionSort, 2000); // 依次测试不同规模

有一点要注意:JavaScript 的 sort 用的是引擎内置排序,不是这篇文章实现的排序,所以测试要全部走自己写的函数,否则测出来的数据会对不上。

6.2 实验结果怎么解读

我实测过一次插入排序的数据(随机数据):

  • n=1000:约0.5ms
  • n=2000:约1.8ms
  • n=4000:约7ms
  • n=8000:约28ms
  • n=16000:约112ms

可以看到,每翻一倍,耗时大约变为4倍。这就是O(n²)的“眼见为实”。如果你把数据换成完全有序的数组,插入排序的耗时会骤降,n=16000可能还不到1ms,因为比较次数从大约1.28亿次降到了1.6万次。这种对比比背一百遍公式都管用。

我建议你也试试“反序数组”,这会让插入排序和冒泡排序陷入最坏情况,耗时会明显比随机数据更长。三个数据形态(随机、有序、反序)各跑一遍,你就能直观感受到“最好情况”和“最坏情况”的差距。

6.3 实测中容易踩的三个坑

第一个坑:测试数据量太小。n=10的时候,三种排序的耗时都是0.0几毫秒,你根本看不出规律。至少要从几千起测,最好测到几万。但要注意,n太大的时候选择排序和冒泡排序会非常慢,比如n=50000时,两次循环大约25亿次操作,即使每次操作再快也要好几秒,浏览器甚至可能弹“页面无响应”。

第二个坑:忘记环境干扰。我在测试时开着开发工具和一堆后台任务,结果耗时时快时慢。正确做法是:同一组测试多跑几次取最小值,因为最小值最接近理论性能,受干扰最少;或者关掉其他程序再测。

第三个坑:trace 代码里多余的赋值操作影响结果。比如你在内层循环里加了一行 debug 输出,耗时会被放大几十倍,而且三种算法放大的比例不一样,导致对比失真。测试代码必须和你正式用的实现完全一致,不能为了“看看过程”就往排序函数里加额外打印。

7. 常见误区和实战排查心得

7.1 误区一:把“最好情况”当作“平均情况”

这是我在面试里遇到最多的错误认知。很多人说“插入排序是O(n)”,这是不对的,准确说法应该是“插入排序的最好情况是O(n),平均和最坏情况是O(n²)”。把最好当平均,会导致你在选型时误判。最典型的表现就是:拿着一份接近有序的数据测试,觉得插入排序“真快”,却忽略了生产环境里数据结构变化后突然慢下来的风险。

判断一个算法真实表现,永远要以平均复杂度为主,最好情况当作额外福利,而不是决策依据。

7.2 误区二:忽视递归带来的空间复杂度

虽然本文三个排序都是O(1)空间,但一旦切换到快排、归并,空间复杂度就会变成O(log n)或O(n)。很多人排序一个超大数据集时遇到栈溢出,排查半天,最后发现是递归深度在作怪。

这里顺便提醒一句:面试时如果被问到“插入排序的空间复杂度”,一定要说“O(1),不是O(n)”。我见过不少人在这个基础问题上栽跟头。

7.3 误区三:对“稳定性”的理解停留在定义上

知道稳定性的定义和能在工程中用对,是两码事。

我举一个自己踩过的真实场景:一个报表系统需要先按“部门”排序,再按“工资”排序,最终希望工资相同的员工按部门分组后,组内仍然保持员工编号顺序。由于数据源初始是按员工编号排列的,我需要的是两次稳定的排序。我一开始用了某个不稳定排序,结果第二次排序把第一次排序结果打乱了,部门相同的人员工编号顺序一团乱。后来换成稳定排序,数据才正确。

这个案例说明:稳定性不仅是一个理论属性,它直接影响多次复合排序的最终结果。面试时主动说出“我的排序是稳定的,这意味着它可以安全地参与多关键字排序”这句话,会比干巴巴背定义高一个档次。

7.4 几个实战排查小技巧

  • 如果你发现线上排序越来越慢,先看输入数据的分布是否变了,比如从“接近有序”变成“完全乱序”,插入排序可能瞬间退化。
  • 如果排序结果偶尔不对,优先怀疑“不稳定排序”把相同关键字的相对位置打乱了,而不是算法本身写错。
  • 如果内存持续上涨,检查排序时是否无意中复制了整个数据集合,空间复杂度从O(1)变成了O(n)。

这些排查思路不是我坐在书房里想出来的,是真的一行行代码调试出来的。有一次我在一个无人值守的批处理任务里用了一个不稳定排序,导致下游数据错乱,隔了一天才被发现。从那以后,凡是涉及多字段复合排序的地方,我都会多问一句:这个排序稳定吗?这个问题已经帮我避了很多次雷。

最后说一个我实际操作中的体会:这些简单排序算法看似入门,但它们其实是理解所有复杂排序算法的“锚点”。快排的“分区”是基于交换思路的升级,归并的“合并”是插入思路的递归版本,堆排的“堆化”则把比较选择过程组织成了树形结构。把复杂度分析的习惯从这三个简单算法开始培养起来,后面学什么都不会飘。如果你正在刷题或者准备面试,我建议不要急着迈进高级算法,先在插入排序和冒泡排序上把最好、平均、最坏三种情况自己推一遍,把稳定性用实际例子验证一遍,你会发现自己对排序这件事的理解变得踏实很多。

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

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

立即咨询