资讯动态

冒泡排序原理图解与Java实现:从基础到优化全面解析

发布时间:2026/10/15 12:05:21 来源:尧图企业网站定制
1. 冒泡排序的原理从名字就能读出它的思路先拆一下这个算法的名字。冒泡这个词是为了让你脑子里能浮现一个画面水底有气泡往上冒越靠近水面气泡越大。冒泡排序做的正是这件事——每一轮处理完当前范围内最大的那个元素就会像气泡一样浮到这一轮范围的末尾。我当初学排序算法的时候第一个接触的就是冒泡排序。因为它的逻辑在所有排序里最朴素、最直白几乎不需要什么数学基础就能看懂。它做的事情就一句话反复比较相邻的两个元素如果顺序不对就交换位置直到没有需要交换的元素为止。这句话里藏着三个关键信息。第一反复比较相邻元素决定了它是一个基于比较的排序算法不依赖额外空间。第二顺序不对就交换说明核心操作是两两交换所以它天生支持原地排序。第三直到没有需要交换意味着它有一个天然的停止条件这个条件后来还被用来做性能优化。1.1 为什么叫冒泡而不是叫相邻交换排序命名是有讲究的。如果你拿一个数组[5, 1, 4, 2, 8]跑一遍冒泡排序你会看到 5 这个数在第一轮里不断往后移动先是和 1 交换再和 4 交换再和 2 交换。它一路上浮过了三个比它小的元素最后停在了数组倒数第二个位置。这个过程非常像水里的气泡不断上升、变大最后浮出水面。而小的元素呢它们会像反向的气泡一样慢慢往前沉但因为我们在交换时是相邻两两比较、只把较大的往后推所以小元素的移动是链式传导的每一步只能顺着往前挪一个位置。理解这个特性很重要它直接影响了冒泡排序的性能——最坏情况下最小的元素在数组最后一个位置那它要经过 n-1 次交换才能挪到最前面。从实际物理规律的角度讲气泡在水中的上升速度并不均匀但冒泡排序里每个元素的移动节奏是固定可预测的第 k 轮结束时数组倒数第 k 个位置一定已经存放了当前数组中最大的 k 个元素。这个规律可以让你在任何一轮执行完后放心地缩小下一轮的处理范围。1.2 核心机制拆解相邻比较逆序交换把整个过程拆到不能再拆冒泡排序的每一轮循环其实只做三件事。从数组的最左边开始拿起第一个元素和它右边紧挨着的元素做比较。如果左边元素比右边元素大就把这两个元素的位置交换如果不大于就保持原样。把当前拿着的位置往右移一位继续比较下一对相邻元素直到这一轮范围的最右侧。这三件事循环执行就是完整的一轮冒泡。一轮结束后你手里不需要记录任何额外信息只需要知道这一轮范围内的最大元素已经到位这一个事实即可。下一轮开始时范围缩小一格跳过已经排好的末尾位置。生活化的类比是一排人从矮到高排队你从头到尾走一遍每看到两个人站反了左边比右边高就让他们换一下位置。走完一遍之后队伍里最高的人一定站在最右边。然后你再来一遍这次最高的人不用管了倒数第二高的人会被推到右边倒数第二个位置。反复这么走队伍就排好了。这个类比里有个容易被忽略的点每一轮你只需要从头走一遍不需要来回走。因为最高的那个人会自己一路被顶到末尾就像气泡自己会往上浮一样你不需要专门去指挥它。2. 图文详解用一组完整数据走一遍冒泡全流程图和文结合是最容易理解算法的方式。下面我用数组[6, 3, 8, 2, 9, 1]作为示例完整展示 5 轮排序的每一轮里到底发生了什么。为什么选这个数组因为它同时包含了几个特殊情形6 和 3 是逆序对8 和 2 也是逆序对9 已经在比较靠右的位置1 是整个数组最小且位于最末。这些情形能让你看到冒泡排序在不同数据分布下的处理节奏。2.1 第一轮完整过程6 如何浮到正确位置初始数组索引: 0 1 2 3 4 5 值: 6 3 8 2 9 1第一轮的目标把最大值 9 送到索引 5 的位置。比较步骤逐对拆开这就是上面说的图的核心部分比较对当前数组状态加粗为正在比较的元素是否需要交换交换后数组6 vs 3[6,3, 8, 2, 9, 1]是6 3[3, 6, 8, 2, 9, 1]6 vs 8[3,6,8, 2, 9, 1]否6 8[3, 6, 8, 2, 9, 1]8 vs 2[3, 6,8,2, 9, 1]是8 2[3, 6, 2, 8, 9, 1]8 vs 9[3, 6, 2,8,9, 1]否8 9[3, 6, 2, 8, 9, 1]9 vs 1[3, 6, 2, 8,9,1]是9 1[3, 6, 2, 8, 1, 9]第一轮结束后数组变成了[3, 6, 2, 8, 1, 9]最大值 9 已经固定在索引 5。这里有个值得注意的细节8 在这一轮里先和 2 交换、然后和 9 比较后没有交换它其实被迫停在索引 3 的位置。也就是说一个较大的元素并不会一路浮到最顶部它只是被顶到了比它更大的元素出现之前的位置。这个顶的过程完全由相邻比较自动完成不需要额外判断当前元素是不是本轮最大。2.2 第二轮到第五轮演进图第二轮开始时范围缩小为索引 0 到 4因为索引 5 已经确定是 9。轮次每轮结束后的数组本轮确认放置好的最大元素初始[6, 3, 8, 2, 9, 1]-第 1 轮[3, 6, 2, 8, 1,9]9 放到索引 5第 2 轮[3, 2, 6, 1,8, 9]8 放到索引 4第 3 轮[2, 3, 1,6, 8, 9]6 放到索引 3第 4 轮[2, 1,3, 6, 8, 9]3 放到索引 2第 5 轮[1,2, 3, 6, 8, 9]2 放到索引 11 自然归位注意第 5 轮当 2 被放到索引 1 后索引 0 只剩 1它不用再参与比较就已经就位。所以长度为 n 的数组最多只需要 n-1 轮冒泡。观察这个演进过程你会发现两个规律。第一个规律是每一轮的未排序区都在收窄收窄的速度是固定的每轮少看一个元素。第二个规律是如果某一轮中一次交换都没发生说明数组已经整体有序可以立刻终止排序——这就是后面代码优化中提前退出机制的理论依据。3. Java 代码实现从基础版本逐步升级原理看懂了代码就只是把上面的过程翻译成语法而已。下面给出 Java 的实现从最朴素的版本开始逐步加上优化并解释每一步为什么要这么做。3.1 最基础的双层循环实现public class BubbleSortBasics { // 最基础的冒泡排序所有轮次完整跑完 public static void bubbleSort(int[] arr) { int n arr.length; // 外层循环控制轮数每轮确定一个最大值 for (int i 0; i n - 1; i) { // 内层循环控制比较范围从 0 到 n-1-i // 每轮结束后最后 i1 个元素已经有序不需要再参与比较 for (int j 0; j n - 1 - i; j) { // 相邻元素两两比较 号决定是升序还是降序 // 升序左 右时交换降序左 右时交换 if (arr[j] arr[j 1]) { // 交换两个元素的位置这里用了临时变量 temp int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } // 调试用打印每一轮结束后的数组状态 System.out.println(第 (i 1) 轮: Arrays.toString(arr)); } } public static void main(String[] args) { int[] arr {6, 3, 8, 2, 9, 1}; System.out.println(排序前: Arrays.toString(arr)); bubbleSort(arr); System.out.println(排序后: Arrays.toString(arr)); } }代码里的两层循环分别对应前面说的两个动作外层循环控制轮数内层循环控制这一轮从头走到尾的比较过程。内层循环的边界n - 1 - i是整个代码最关键的地方漏掉- i或者漏掉- 1都会导致出错。先解释- 1每一次比较访问的是arr[j]和arr[j 1]所以 j 的最大值只能是n - 2否则arr[j 1]会数组越界。再解释- i第 i 轮结束时数组末尾的 i 个元素已经是排好的下一轮不需要再碰它们。两处减去合起来就是n - 1 - i。这段基础版本有个明显缺点即使数组在第 3 轮已经整体有序它仍然会把剩余轮次全部跑完。以[1, 2, 3, 4, 5]这样已经有序的数组为例它依然会执行(n-1) (n-2) ... 1次比较总共大约 10 次虽然一次交换都不会发生。这就是冗余操作的来源。3.2 优化第一版利用已经有序信号提前结束如果一个数组在某一轮中一次交换都没有发生那就说明所有相邻元素都已经是左小右大的状态整个数组已经有序后续轮次纯属白跑。加入一个布尔标记来捕捉这个信号public static void bubbleSortOptimized(int[] arr) { int n arr.length; // 标记某一轮是否发生过交换 boolean swapped; for (int i 0; i n - 1; i) { swapped false; for (int j 0; j n - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; } } // 如果一轮下来没有发生任何交换数组已经有序直接结束 if (!swapped) { break; } } }这个优化的效果在几乎有序的数组上极其明显。比如数组[2, 1, 3, 4, 5, 6, 7, 8]第一轮会把 2 和 1 交换然后后面的元素都是有序的不再触发任何交换。第二轮开始时swapped初始为 false遍历完整个范围后依然为 false于是直接 break总共只跑了 2 轮就结束了。如果没有这个优化即便是这种几乎有序的数组也要跑满 7 轮。不过这里有个容易踩的细节swapped必须在每轮开始时重置为 false而不是只在一开始初始化一次。如果忘了重置只要之前某轮发生过交换这个标记就会一直是 true提前退出的逻辑就永远触发不了。3.3 优化第二版记录最后一次交换位置缩小有用范围有一个更隐蔽的优化点。假设某一轮中从某个位置 k 之后的所有元素都没有发生交换说明从 k 到末尾这一段已经是局部有序的。那么下一轮只需要比较到 k 位置即可不需要再跑到n - 1 - i。public static void bubbleSortWithBound(int[] arr) { int n arr.length; int lastSwapPos 0; // 记录最后一次交换发生的位置 int sortBoundary n - 1; // 当前轮次需要比较到的边界 while (sortBoundary 0) { boolean swapped false; // 只遍历到当前的 sortBoundary而不是固定的 n-1-i for (int j 0; j sortBoundary; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; swapped true; lastSwapPos j; // 更新最后一次交换的位置 } } if (!swapped) { break; } sortBoundary lastSwapPos; } }这个版本的核心思想是下一轮需要扫描的范围取决于这一轮最后一次发生交换的位置。如果这一轮在索引 3 之后再也没交换过说明索引 3 之后已经有序下一轮的边界就设为 3而不是机械地减 1。我还是用实际数据演示一下差别。数组[1, 8, 2, 3, 4, 5, 6, 7]基础版本需要跑 7 轮加了提前退出优化后第一轮会把 8 一路顶到最末尾期间 8 和后面的每一个元素都发生了交换最后一位交换位置是索引 68 和 7 交换时后续没有交换。第二轮边界变成 6但此时整个数组已经有序这一轮不会有任何交换发生于是提前退出。总共只跑 2 轮。这第三版优化的核心价值在于既然你这一轮交互只发生到 3 号位置那么 3 号之后的元素已经彼此有序没必要下一轮再去看它们。它不是总体的复杂度优化但在存在大量已排序尾部元素的场景下能减少不少无效比较。3.4 三种版本对比附建议使用场景版本代码复杂度有序数组耗时随机数组耗时推荐场景基础版最简单慢浪费大量比较正常只用于理解算法原理提前退出版增加一个 boolean快1 轮即结束略快适合近似有序的实际业务数据缩小边界版增加两个变量快1 轮即结束略快适合尾部大范围有序的场景个人建议平时写代码用提前退出版就够了因为实现成本最低、可读性最好。缩小边界版虽然能在特定场景下省一些比较次数但代码可读性下降收益却不明显。真正追求性能时没人会用冒泡排序直接用内置排序方法就好。4. 时间复杂度、稳定性与适用场景很多人在初学阶段把冒泡排序的实现背熟了但对它什么时候快、什么时候慢没有太多概念。这一节把复杂度分析讲透你要能自己推导出来而不是死记结论。4.1 三种场景下的时间复杂度最坏情况数组是逆序排列比如 [9, 8, 7, 6, 5, 4, 3, 2, 1]每一轮比较都触发交换交换次数等于比较次数。第一轮比较 n-1 次、第二轮比较 n-2 次、第 k 轮比较 n-k 次一直到最后 1 次。总比较次数(n-1) (n-2) ... 1 n * (n-1) / 2所以最坏时间复杂度是 O(n²)。这种场景下冒泡排序每一步都做满工作量交换操作还会带来额外的数组写开销整体表现很差。最好情况数组已经排好序基础版依然要执行 n*(n-1)/2 次比较时间复杂度 O(n²)。但加了提前退出优化版后第一轮从头走到尾发现没有任何交换立即结束只比较了 n-1 次。时间复杂度降为 O(n)。平均情况随机排列的数组期望上大约有一半的相邻对是逆序的所以比较次数依然在 n² 级别的量级上时间复杂度平均也是 O(n²)。这里没有捷径冒泡排序的比较次数不随数据的有序程度显著下降除非你加了提前退出优化且数据恰好接近有序。4.2 空间复杂度与稳定性分析三个版本的冒泡排序都只使用了一个临时变量temp来做交换没有额外的新数组空间复杂度是 O(1)属于典型的原地排序。稳定性需要特别说明一下。排序算法中稳定的定义是如果两个元素值相等排序后它们的相对位置保持与排序前一致。冒泡排序的判断条件是arr[j] arr[j 1]时才交换也就是说当两个相邻元素相等时它们不会互换位置。因此相同值的元素之间的相对顺序在整个排序过程中不会被改变冒泡排序是稳定排序。稳定性的实际意义主要体现在多字段排序场景里。比如你有一组对象先按 id 排好序再按 score 排序。如果排序算法是稳定的那么按 score 排完之后score 相等的那部分对象会保留之前 id 的顺序。你不小心用了不稳定的排序算法可能就把前一次排序的结果完全打乱了。4.3 和数据量之间的关系冒泡排序的 O(n²) 复杂度决定了它只适合小数据量场景。以现代 CPU 的算力排序几千个元素看不出什么差别但随着 n 增大到几万和 O(n log n) 的快速排序、归并排序相比差距会指数级拉大。我用同样环境跑过一次对比对 10 万个随机整数排序内置的排序算法耗时几十毫秒冒泡排序则需要好几十秒差距接近千倍。什么时候真的该用冒泡排序我的判断标准有三条数据规模在几百以内对代码可读性要求极高不愿意引入复杂算法或者数据本身基本有序比如实时日志中按时间戳追加的记录偶尔乱序。除此之外直接使用标准库的排序方法不要自己造轮子。5. 实操中容易踩的坑与排查经验写冒泡排序看起来简单实际在纸上手写或现场编码时出错的概率比想象中高得多。下面几个坑我见过很多人踩包括我自己。5.1 边界条件写错导致数组越界最经典的一个错是内层循环写成for (int j 0; j n - i; j)然后比较arr[j]和arr[j 1]。第一次循环没问题但每一轮循环都会导致越界因为 j 的最大值是n - i - 1此时arr[j 1]的最大下标已经超出范围。排查技巧很简单把数组长度 n 代入边界表达式验证一遍。比如 n6、第一轮 i0 时j n - i允许 j 走到 5arr[5 1]就是arr[6]越界。正确写法必须是j n - i - 1确保 j 最大为 4arr[5]是合法的。5.2 交换操作的顺序写反交换三个语句的正确顺序是int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp;新手最容易写错的地方是写成了arr[j] arr[j 1]; arr[j 1] arr[j];。这样两步之后两个元素的值其实是被覆盖掉的并不是交换。换过之后数组只会丢失原来的左值。实际编码时建议写完交换立刻用长度较小的数组跑一遍比如{2, 1}如果排序结果变成{1, 2}且数据没丢说明交换写对了。5.3 内层循环优化用错了索引第三版中我用sortBoundary lastSwapPos来缩小范围但很多人会顺手写成sortBoundary j - 1或者sortBoundary sortBoundary - 1。这两种写法都会跳过尚未比较的位置。正确逻辑是这一轮中最后一次交换发生在位置lastSwapPos也就是说arr[lastSwapPos]和arr[lastSwapPos 1]是最后一次发生交换的一对。lastSwapPos之后的元素已经相互有序因此下一轮边界设为lastSwapPos即可。如果你不明白推导就先用提前退出版别强行用这个优化。5.4 被外层循环的轮数困住还有些人会把外层循环的i n - 1写成i n这本身不会出大错只是会多跑一轮空转最大的问题是可读性变差。要清楚的是长度 n 的数组最多需要 n-1 轮因为当 n-1 个元素各自归位后最后一个元素必然也归位。我平时在代码调试时习惯在每个外层循环结束后打印数组当前状态这能直接看到每一轮是否在做预期的事。基础版代码里我留了System.out.println实际工作中如果是正式工具类会把打印去掉避免大量日志拖慢性能。6. 冒泡排序的变体与扩展思考标题提到理解冒泡排序的原理但真正理解一件事最好的方式是看它的变体和边界情形。这些变体有助于加深对冒泡排序核心机制的认识有时在面试中也用得上。6.1 降序冒泡和反向冒泡把升序的判断条件从arr[j] arr[j 1]改成arr[j] arr[j 1]就成了降序排序较大的元素向数组头部浮动小的元素沉向尾部。实现上只改一个大于号但思维上要重新理解一轮冒泡把当前最小元素送到末尾的新含义。反向冒泡则指从右往左比较相邻元素每轮把当前范围的最小值下沉到最左端。实际写一遍代码就会发现它和正向冒泡本质上是对称的唯一的区别是遍历方向不同。6.2 冒泡排序和鸡尾酒排序冒泡排序有一个活跃的变体叫鸡尾酒排序也叫做双向冒泡。它在一轮迭代中先从左到右再从右到左各扫一遍这样一对方向就能同时把最大值和最小值送到两端。对于存在大量少数元素位置严重错位的数组比如最小的元素在最右端鸡尾酒排序能显著减少轮数。写法和冒泡排序的差异不复杂只是在每轮外层循环里加了反向遍历。但它的代价是代码更复杂且整体复杂度依然是 O(n²)。实际项目里用得很少不过拿来检验自己是否真正理解了冒泡是个不错的练习题。6.3 冒泡排序与其他简单排序的横向对比排序算法最好时间最坏时间是否稳定原地主要特点冒泡排序O(n) 优化版O(n²)稳定是简单、交换频繁选择排序O(n²)O(n²)不稳定是交换次数少但比较次数固定插入排序O(n)O(n²)稳定是几乎有序时效率高快速排序O(n log n)O(n²)不稳定是实际最快工程首选插入排序和冒泡排序的适用场景高度重叠但插入排序在近有序数组上表现得更好且数据量较大时通常仍优于冒泡排序。如果必须在简单和性能之间找一个平衡插入排序往往是比冒泡更好的选择。6.4 冒泡排序思维的价值尽管冒泡排序在实际开发中几乎不被大规模使用但我仍然建议你亲手多写几遍原因有两点。第一它是最直观展示交换思想的地方理解交换操作是后续学习快速排序、堆排序的基础。第二它的优化过程提前退出、缩小边界可以帮你建立用算法执行信息反推下一轮操作的思维模式这种模式在写复杂业务代码时同样有价值。我个人还喜欢拿冒泡排序作为代码审查的演示材料。因为它简单可以让新手快速上手理解循环边界和状态标记等掌握了之后再进入更复杂的算法会平滑很多。7. 我在实操中的一些体会写完这个排序我最深的一个感受是一个简单的算法真正理解到位需要从原理、图示、代码、优化、复杂度、稳定性、变体等多个角度反复验证。这个过程不复杂但覆盖的细节比大部分人预期的要多。如果你现在正在学排序算法我的建议是先不看任何代码在纸上用笔画出[6, 3, 8, 2, 9, 1]每一轮比较后的数组状态再对比代码执行结果。能画对说明理解到位了画不对说明代码里还有逻辑没对上。这个纸面验证的方法我到现在调试算法还会用。最后分享一个小技巧调试冒泡排序时别用太长的数组5 到 8 个元素足矣元素中故意放一个最大值在中间、一个最小值在最后这两种特殊分布能帮你更快暴露边界和交换逻辑的 bug。等这几类数据全部跑通再换随机数据做压力测试就稳了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑