ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

冒泡排序从原理到优化:相邻交换、稳定排序与时间复杂度解析

冒泡排序从原理到优化:相邻交换、稳定排序与时间复杂度解析 1. 冒泡排序到底在干什么冒泡排序真的是排序算法里最贴近直觉的一个我当初学它的时候几乎没费什么劲。你想象一个水池水底有一堆大小不一的石头大的石头天然沉底小的气泡往上飘。冒泡排序的每一趟循环就像一次“让一个最大的值慢慢浮到正确位置”的过程所以这个算法的名字起得非常形象。它解决的问题很简单给我一组乱序的数字我通过反复比较相邻的两个元素如果顺序不对就交换它们的位置最终让整个序列排列整齐。这个“反复比较相邻元素并交换”的动作就是冒泡排序的全部核心没有复杂的递归没有精巧的分治就是老老实实地一遍遍扫过去。我刚接触编程的时候学的第一个排序算法就是它。那时候不理解时间复杂度也不关心什么是稳定排序我就是把代码敲了一遍然后指着打印出来的结果说“哦原来排序是这么回事”。所以如果你刚入门编程拿冒泡排序当作理解“算法到底是什么”的第一个例子是很合适的。这个算法适合谁来学我觉得有三类人第一类是刚接触数据结构和算法的大学生需要一个不那么吓人的入门案例第二类是准备面试的开发者虽然冒泡排序在工程里基本不会用但面试官偶尔会拿它考基本功尤其是它的优化写法第三类是自学编程的朋友你可能没有系统的算法课想通过一个简单的例子建立对代码执行流程的直觉。这篇文章我会从思路、代码、实操、踩坑四个维度把它讲透最后还会拿它和选择排序做个对比帮你看清楚为什么它简单但确实不是效率最优的选择。2. 设计思路拆解三个关键问题想明白代码自然就写出来了2.1 为什么是“相邻比较”而不是“随便挑两个比”冒泡排序最核心的动作是每一轮循环里依次比较相邻的两个元素也就是第0个和第1个、第1个和第2个这样一路比到最后一个。如果左边的数大于右边的数就交换它们的位置。为什么非要相邻比较这里面其实藏着一个很重要的设计逻辑每一次交换都让一个较大的值向序列末尾方向移动一位经过一整趟的比较就能确定一个“本轮最大的值”被推到了它最终应该在的位置。这种“一趟确定一个最终位置”的特性让整个排序过程天然是收敛的。你不需要额外记录什么极值下标也不需要额外的数据结构帮你存储中间状态就靠相邻交换信息自然传递。如果你跳着比较比如把第0个元素和第3个元素比较那确实也能找出来最大值但中间那几个元素的状态完全没被整理你需要额外写很多逻辑才能保证整个序列有序。所以说相邻比较正好是“信息传递最直接、代码最简单”的方式这也是冒泡排序设计上的巧思。2.2 为什么需要多轮循环每轮扫描的范围为什么越来越小光一趟扫描肯定不够。第一趟扫描结束你只能保证最大值到了最后前面还有一堆乱序的数字。所以需要再来一趟把第二大的值推到倒数第二的位置再第三趟推出第三大的值……总共需要 n-1 趟n 是元素个数。这里有一个特别容易忽略的细节每一趟扫描的范围在缩小。第一趟扫描到最后一个位置第二趟其实只需要扫描到倒数第二个位置因为最后一个位置已经是最大值再去比较它纯属浪费。这个“每趟少比较一个位置”的优化是初学冒泡排序时最容易忘记的。很多教科书代码里会这么写for (int i 0; i n-1; i) { for (int j 0; j n-1-i; j) { // 比较并交换 } }注意内层循环的条件是n-1-i不是n-1。这就是在每一轮扫描中自动把已经排好的尾部区域排除掉。我见过不少人把这个-i丢了程序其实也能跑出正确结果因为多比较的只是已经排好序的元素不会产生错误但效率确实低了一截。这种丢写法只能说“侥幸正确”不严谨。2.3 稳定排序是什么意思冒泡为什么天生是稳定的冒泡排序是稳定排序。所谓稳定就是如果两个元素的值相同它们在排序完成后的相对顺序不会改变。比如有两个同为 5 的数字一开始前面的那个 5排完序之后依然在前面。冒泡排序为什么天然稳定因为它的交换条件写的是“如果左边的大于右边才交换”注意是严格大于不是大于等于。如果两个元素相等条件不成立就不会交换所以相同值的相对顺序被保住了。这一点在大部分普通场景下没什么感觉但当你排序的是对象比如员工对象按工资排序而工资相同的员工希望保持原来的入职顺序那稳定排序就很有意义。我以前写一个报表工具时需要在两轮排序的基础上保持前一轮的排序痕迹稳定排序就成了唯一合理的选择。所以不要觉得稳定性只是一个概念题实际工程里是真的会用到的。3. 核心细节实操用代码把冒泡排序写出来并理解每一行的含义3.1 C语言版本最接近底层的写法帮你建立内存视角C语言的实现最直观因为它让你直面数组操作和交换逻辑。先看一个最基础版本#include stdio.h void bubbleSort(int arr[], int n) { for (int i 0; i n-1; i) { for (int j 0; j n-1-i; j) { if (arr[j] arr[j1]) { // 交换两个数 int temp arr[j]; arr[j] arr[j1]; arr[j1] temp; } } } } int main() { int arr[] {5, 1, 4, 2, 8}; int n sizeof(arr) / sizeof(arr[0]); bubbleSort(arr, n); for (int i 0; i n; i) { printf(%d , arr[i]); } return 0; }这里的sizeof(arr) / sizeof(arr[0])是C语言里计算数组长度最常用的方式sizeof(arr)是整个数组占用的总字节数除以单个元素的字节数就得到元素个数。记住这个写法面试和笔试经常用。交换逻辑用了临时变量temp这是最朴素的交换思想。两个杯子要互换液体必须先有一个空杯子。这个类比帮我理解了很多年的交换操作。3.2 Java版本更符合工程习惯的写法Java里的写法在思路上完全一致只是语法有些区别。Java传入数组本身就是引用传递所以在方法内直接修改数组外面的变量也会随之改变这一点和C语言通过指针传入是异曲同工的public class BubbleSort { public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n-1; i) { for (int j 0; j n-1-i; j) { if (arr[j] arr[j1]) { int temp arr[j]; arr[j] arr[j1]; arr[j1] temp; } } } } public static void main(String[] args) { int[] arr {64, 34, 25, 12, 22, 11, 90}; bubbleSort(arr); for (int num : arr) { System.out.print(num ); } } }Java版本需要注意的细节是arr.length是一个属性不是方法所以别写arr.length()。初学Java从C语言转过来的人经常在这个地方犯迷糊C语言是sizeof(arr)/sizeof(arr[0])Java直接.length两个语言的风格差异就在这里体现出来了。3.3 Python版本最简洁的表达适合快速验证思路Python的写法可以把冒泡排序压缩到几乎不能再短def bubble_sort(arr): n len(arr) for i in range(n-1): for j in range(n-1-i): if arr[j] arr[j1]: arr[j], arr[j1] arr[j1], arr[j] return arr if __name__ __main__: test [5, 1, 4, 2, 8] print(bubble_sort(test))Python的arr[j], arr[j1] arr[j1], arr[j]这种并行赋值写法底层其实是一口气完成了取值和赋值不需要临时变量。这个语法糖写起来很爽但要注意右边是“先算完再统一赋值”理解了这个你就不会担心顺序错乱的问题。Python版最适合什么场景当你脑子里有了一个算法的模糊想法想快速验证一下对不对用Python最合适写起来省时间能让你把注意力集中在逻辑上而不是语言细节上。3.4 最关键的那行代码为什么是j n-1-i少个-i行不行这一小节我要重点讲因为它是冒泡排序无数初学者最容易栽跟头的地方。外层循环控制的是“一共要跑几趟”n 个元素最多需要 n-1 趟。为什么是 n-1因为当 n-1 个元素已经跑到正确位置之后剩下的那一个元素自然就在正确位置了不需要再处理。这是排序算法的通用逻辑。内层循环控制的是“这一趟比较哪些位置”。第一趟j 从 0 扫到 n-2也就是说比较第0和第1个、第1和第2个、……、第n-2和第n-1个覆盖整个数组。每比较一次如果左边大于右边就交换一趟结束最大的值到了最后。第二趟开始就不同了。最后一个位置已经是最大值我再去比较第 n-2 和第 n-1 个是毫无意义的比较。所以第二趟只需要扫到 n-2 即止也就是j n-2。第三趟需要扫到 n-3以此类推。用i表示当前是第几趟那这一趟的扫描上限就是n-1-i。如果你写成j n-1代码依然能正确排序因为多比较的那些部分不会产生错误交换只是白白消耗了CPU时间。对于算法学习来说这是不合格的因为算法的意义之一就是效率而效率就藏在这些细节里。4. 实操过程还原从一个乱序数组开始手动走一遍排序过程4.1 用例子走完整流程我拿[5, 1, 4, 2, 8]这个数组来手动模拟一遍冒泡排序的每一轮状态这是理解冒泡排序最快的办法。强烈建议你也用纸笔画一遍只看代码是形成不了直觉的。第一趟i0j 从 0 扫到 3比较 j05 和 151交换数组变成[1, 5, 4, 2, 8]比较 j15 和 454交换数组变成[1, 4, 5, 2, 8]比较 j25 和 252交换数组变成[1, 4, 2, 5, 8]比较 j35 和 858不交换数组保持[1, 4, 2, 5, 8]第一趟结束8 这个最大的数已经浮到了最后它的位置确定了。第二趟i1j 从 0 扫到 2比较 j01 和 414不交换比较 j14 和 242交换数组变成[1, 2, 4, 5, 8]比较 j24 和 545不交换第二趟结束5 的位置确定了。第三趟i2j 从 0 扫到 1比较 j01 和 212不交换比较 j12 和 424不交换第三趟结束没有发生任何交换。第四趟i3j 从 0 扫到 0比较 j01 和 212不交换全部结束数组已经有序。注意一个细节第三趟结束的时候实际上就已经完全有序了但冒泡排序不知道它还会继续跑第四趟。这就是为什么它需要额外的优化手段来处理这些无用的趟数这部分在后面的常见问题章节我会详细讲。4.2 手动走一遍之后观察到了什么规律手动模拟完有三个规律是我希望你能自己体会到的第一每一趟都能确定一个最大值到它最终的位置这个位置是从后往前依次确定的。所以外层循环跑完 n-1 趟之后所有位置都确定了。第二数组如果本来接近有序冒泡排序也不会自动“感知”到这一点它依然傻傻跑完全部趟数除非你加了优化。这也是它和插入排序的一个重要区别插入排序在接近有序的数据上表现非常好冒泡排序在没有优化的情况下不行。第三交换操作发生的位置和次数是没有规律的。有时候这趟交换了很多次有时候一次都没有。所以判断一个数组是否提前完成排序就看某一趟有没有发生任何交换这也是“哨兵优化”的思想基础。4.3 用表格看每一轮的变化我把上面的过程整理成表格方便你对照。趟数扫描范围比较过程是否有交换本轮结束后的数组第1趟j0~35和1交换5和4交换5和2交换5和8不换是[1, 4, 2, 5, 8]第2趟j0~21和4不换4和2交换4和5不换是[1, 2, 4, 5, 8]第3趟j0~11和2不换2和4不换否[1, 2, 4, 5, 8]第4趟j0~01和2不换否[1, 2, 4, 5, 8]这个表格建议你亲手画一遍。我见过很多学生代码写得很溜但问他[3,1,2]排序过程中第二趟长什么样他就答不上来。这类问题面试经常考画图能力代表了你对算法的真实理解程度。5. 常见问题与踩坑复盘运行结果不对先想想这4个地方5.1 问题一为什么我的输出没有变化数组好像完全没被排序出现这种情况最典型的错误是交换逻辑没生效。我见过有人把交换写成这样arr[j] arr[j1]; arr[j1] arr[j];很明显第一步执行之后arr[j]已经被覆盖了第二步赋值等于把同一个值写了两遍数组自然没变化。正确写法必须借助临时变量或者用像Python那样的一次性赋值。这种问题在C和Java里很常见因为它们的赋值是“先算右边再把结果覆盖左边”不会自动保留原值。另一个可能原因是你传参的方式不对。C语言里如果你传的是数组的副本而不是指针函数内部修改就不会影响到外部数组。Java里数组是引用传递基本不会踩这个坑。C里如果你写函数时形参用了vectorint而不是vectorint也会发生拷贝排序只作用于副本。5.2 问题二循环边界条件到底怎么记每次写都要翻书边界条件这块我提供一个记忆口诀外层循环i从 0 到 n-2内层循环j从 0 到 n-i-2那么比较的下标就是arr[j]和arr[j1]。这个口诀等价于上面的代码但更好记因为 j 的最大值是 n-i-2所以 j1 的最大值是 n-i-1也就是当前趟数需要处理的最后一个元素位置。如果你不放心可以随手代入数字验证n5i0 时j 最大是3比较的是arr[3]和arr[4]覆盖了所有元素。i3 时j 最大是0比较的是arr[0]和arr[1]只比较最前面的一对。这样算下来逻辑就通顺了。5.3 问题三我加了优化为什么反而排错序了很多人在理解了冒泡排序之后会想着加一个“提前结束”的优化如果某一趟没有发生任何交换就说明数组已经有序直接退出循环。这个思路是对的但实现的时候容易出错。常见的错误写法是这样的for (int i 0; i n-1; i) { int flag 0; for (int j 0; j n-1-i; j) { if (arr[j] arr[j1]) { int temp arr[j]; arr[j] arr[j1]; arr[j1] temp; flag 1; } } // 下面的写法是错误的 // if (flag) break; }注意flag 1表示发生了交换那么“没有发生交换”应该是flag 0退出条件应该是if (!flag) break;。如果你写成if (flag) break;那就变成“只要有交换就退出”只跑一趟就结束了排序结果当然不对。这个坑我当年也踩过本质上是把标志位的语义搞反了。写优化代码时建议先把“这个变量到底表示什么”想清楚再写判断条件。我这里贴一个正确的优化版本void bubbleSortOptimized(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[j1]) { int temp arr[j]; arr[j] arr[j1]; arr[j1] temp; swapped 1; } } if (swapped 0) { break; } } }5.4 问题四数据量一大就慢得离谱是不是我写错了不是写错了是冒泡排序本身确实慢。它的时间复杂度是 O(n²)什么意思如果数组有 1000 个元素内外两层循环大概要执行百万次比较操作如果数组有 10000 个元素就要上亿次比较。这个增长速度是平方级别的数据量翻一倍耗时大约翻四倍。所以工程上没人用冒泡排序处理大规模数据这是正常的。如果你只是写完代码做测试建议用几百个元素的数组感受一下逻辑不要去排 10 万个数。真要对大数组排序用 C 标准库的qsortJava 的Arrays.sort()或者 C 的std::sort这些底层都是快排、归并这类 O(n log n) 的算法性能差距是数量级的。6. 选择排序和冒泡排序对比谁更好什么时候用哪个6.1 选择排序的思路回顾选择排序和冒泡排序经常被放在一起比较因为它们的实现难度差不多思路也都是在每一轮中确定一个元素的最终位置。区别在于选择排序不再是相邻元素交换而是每一轮在剩余未排序部分里找出最小值或最大值记录它的下标等这一轮扫描结束后再把它和当前轮次的起始位置交换。举个例子[5, 1, 4, 2, 8]第一轮扫描全部元素找到最小值 1 在下标1的位置然后让下标0的位置和下标1的位置交换得到[1, 5, 4, 2, 8]。第二轮扫描下标1到4的部分找到最小值 2交换到下标1得到[1, 2, 4, 5, 8]。以此类推。6.2 冒泡和选择的核心差异两者在时间复杂度上是一样的都是 O(n²)。但在实际行为上差别很大冒泡排序每一轮可能需要很多次交换而选择排序每一轮最多只交换一次。所以虽然比较次数差不多但选择排序的“数据搬动”次数远小于冒泡排序在交换代价较高的场景里选择排序更优一些。再看稳定性冒泡排序是稳定的选择排序是不稳定的。选择排序的不稳定体现在哪里比如数组[5, 8, 5, 2, 9]第一轮找到最小值 2和第一个 5 交换这样原本排在后面的第二个 5 就跑到了第一个 5 的前面相对顺序被破坏了。这个区别在纯数字排序中感受不到但排序对象是带附加信息的结构体时就很重要。6.3 一张表讲清三个常见基础排序为了方便对比我把冒泡、选择、插入三个基础排序放在一起排序算法时间复杂度平均稳定性交换次数特点冒泡排序O(n²)稳定多每轮可能多次交换思路最直观适合入门选择排序O(n²)不稳定少每轮最多一次交换交换代价低但不稳定插入排序O(n²)稳定视数据而定接近有序时很少对接近有序的数据效率极高通常的建议是工程里这三个都不常用但如果一定要在冒泡和选择之间选一个用于特定场景我会看两个因素如果数据的“写入”成本远高于“读取”成本我选选择排序因为它能显著减少交换次数如果对稳定性有明确要求我选冒泡排序。6.4 初中级面试里冒泡排序被问到的几种变形面试官问你冒泡排序往往不是让你背代码而是让你写一个“优化的冒泡排序”或者问你“冒泡排序怎么改进”。我自己被问过的问题包括你能让冒泡排序在输入已经有序的情况下只扫描一趟就停止吗你能让内层循环每一轮少比较一些位置吗如果数组后半部分已经有序怎么跳过它第一个问题的答案是加标志位优化。第二个问题的答案是j n-1-i。第三个问题稍微难一点记录最后一次发生交换的位置lastSwapIndex因为在这个位置之后的元素都已经在正确的位置上下一轮扫描只需要扫到lastSwapIndex即可。这种优化代码比基础版多一点但耐人寻味值得写一遍public static void bubbleSortWithLastSwap(int[] arr) { int n arr.length; int lastSwapIndex n - 1; while (lastSwapIndex 0) { int currentLast -1; for (int j 0; j lastSwapIndex; j) { if (arr[j] arr[j1]) { int temp arr[j]; arr[j] arr[j1]; arr[j1] temp; currentLast j; } } lastSwapIndex currentLast; } }这段代码的可读性没有基础版好但效率在特定数据下提升很大。面试时如果你能主动写出这个版本至少说明你对冒泡排序的理解不是停留在背代码层面。7. 写在最后的实操体会冒泡排序是我讲给别人最多的一个排序算法倒不是因为它在实际工程中多常用而是因为它包含了一整套学习算法的“基本功”边界条件的推演、临时变量的交换、标志位的优化、时间复杂度的分析。它就像编程界的“Hello World”虽然简单但五脏俱全。我在实际教学中发现最快掌握冒泡排序的方式不是看视频而是拿笔在纸上画两个数组一个乱序的一个有序的然后手动执行每一轮比较和交换把你看到的每一步写下来。这个过程大概需要十五分钟但做完之后你再看任何语言的冒泡排序代码都不会再觉得陌生。最后分享一个小技巧当你面试或考试被要求手写冒泡排序时写完基础版本之后主动补一句“这里可以加一个标志位如果某一趟没有交换就提前结束最坏情况还是 O(n²)但最好情况下能降到 O(n)”。这句话一说出来面试官通常就会对你刮目相看因为大多数人只会默写代码而你展现了改进意识。这个算法后续还可以继续扩展的方向是理解它和插入排序的相似与不同进而是希尔排序再到分治思想下的归并排序和快速排序。冒泡排序是你排序算法地图的第一块拼图拼好它后面的路会平顺很多。
返回列表