Java优选算法Day1:冒泡排序与二分查找的边界陷阱
2026/9/24 18:41:58 网站建设 项目流程

我最近在帮团队做Java技术面试复盘,发现一个挺有意思的现象:问起候选人“你熟悉的排序算法有哪些”,十个人里有九个会提到冒泡排序;但真要他在白板上手写一遍,能一次写对边界条件的,不到三成。更典型的是二分查找,很多干了三五年的Java开发,能在五分钟内无Bug写出标准版的人,说实话,不算多。

所以我想把“Java优选算法”这个系列认真写下去。不追KMP、不卷红黑树,就从最扎实的地基开始,每天两个算法,讲清楚原理、代码、坑点和面试问法。今天是第一天的内容,我选的是两个看起来“简单到不好意思写”的算法:冒泡排序二分查找。如果你以为这两题太基础不值得看,那我建议你往下翻翻——我见过太多在它们身上翻车的人了,包括曾经的我。

1. Day1的选题逻辑:为什么优先选这两个算法

算法学习的误区,我踩得太多了。早些年我也干过“收藏即学会”的事:把KMP、红黑树、B+树的解析存了一堆,张口闭口就是高级词汇,结果真到写代码时,连一个数组逆序都要想半天。

后来我才想明白一个朴素道理:算法这玩意,是金字塔结构,不是积木拼盘。底层不稳,上层全是空中楼阁。

1.1 排序:算法体系里最通用的“母题”

排序为什么值得作为整个系列的开篇?因为几乎每一种经典排序算法,都对应着一类重要的算法思想:

  • 冒泡排序,本质是暴力枚举 + 相邻比较交换,是最朴素的问题求解直觉;
  • 选择排序,对应线性扫描 + 极值选取,是很多贪心策略的雏形;
  • 插入排序,对应增量构建 + 局部有序,是希尔排序的基石;
  • 归并排序,是分治思想最标准的教科书实现;
  • 快速排序,是分区递归 + 随机化思维的典型代表。

也就是说,你把排序全部吃透,等于同时掌握了暴力、贪心、分治、递归这几套核心方法论。以后无论是刷LeetCode还是写业务代码,这些思维都会反复出现。DAY1选冒泡,不是因为它实用,是因为它能把“排序算法长什么样”这个最基本的感觉带出来,后面学快排、归并时,对比着看,理解会快得多。

1.2 查找:业务代码里出现频率最高的操作

二分查找则完全是另一维度的价值。我工作这些年,在真实业务里写过的二分查找,一只手数得过来;但二分背后暴露出的问题——边界条件、循环不变量、整数溢出——几乎是每天都会遇到的编码细节。更关键的是,二分查找是面试算法题的“前置技能”。你去看那些中高难度的题目:搜索旋转排序数组、寻找峰值、在排序数组中查找元素的第一个和最后一个位置……它们的解法本质上都是二分查找的变体。Day1就把它拿下,后续刷题会顺畅很多。

一句话总结选题思路:冒泡负责建立“算法感”,二分负责训练“边界脑”。一个练框架思维,一个抠边界细节,这俩凑齐了,DAY2再进入选择排序和插入排序,节奏正好。

2. 冒泡排序的完整推导:从“交换直觉”到代码落地

2.1 一句话理解冒泡

冒泡排序的思想,用一句大白话讲:每一轮,让相邻的两个元素两两比较,如果顺序不对就交换,经过一轮之后,当前未排序区间里最大的数就会像气泡一样“浮”到最右侧。

被交换到右侧的大数,看着就像水底冒到水面的气泡,冒泡排序因此得名。理解这个名字很重要,因为很多人在写代码时会忘记“每一轮结束后,最右侧已经排好了一位”,导致做了大量无效比较。

2.2 手写走一遍冒泡过程

我们用一个具体数组来推演。假设有这样一个待排序数组:

[5, 1, 4, 2, 8]

目标是升序排列。第一轮,从头开始比较相邻元素:

  • 比较 5 和 1,5 > 1,交换,数组变为[1, 5, 4, 2, 8]
  • 比较 5 和 4,5 > 4,交换,数组变为[1, 4, 5, 2, 8]
  • 比较 5 和 2,5 > 2,交换,数组变为[1, 4, 2, 5, 8]
  • 比较 5 和 8,5 < 8,不交换,数组保持[1, 4, 2, 5, 8]

第一轮结束,8被“冒泡”到了最后一个位置,这是本轮确定的最大值,下一轮就不需要再管它了。

第二轮,只需要比较前4个元素:

  • 比较 1 和 4,不交换,保持[1, 4, 2, 5, 8]
  • 比较 4 和 2,4 > 2,交换,变为[1, 2, 4, 5, 8]
  • 比较 4 和 5,不交换,保持[1, 2, 4, 5, 8]

第二轮结束,5到了倒数第二个位置。可以看到,此时数组已经整体有序了,但因为算法没有“感知”能力,它还会继续执行第三轮和第四轮比较。这里就埋下了第一个优化点,后面我会具体讲。

第三轮:比较 1 和 2,不交换;比较 2 和 4,不交换。结束。 第四轮:比较 1 和 2,不交换。结束。

最终得到有序数组:[1, 2, 4, 5, 8]

2.3 从推演中总结规律

看完整过程,你会发现三个关键规律:

  1. 总共需要 n-1 轮外层循环。n个元素,每轮确定一个最大值,前 n-1 个确定后,最后一个自然就位,所以外层循环次数是 n-1。
  2. 第 i 轮只需要比较前 n-i 对相邻元素。因为每完成一轮,右侧就多一个已经排好序的元素,不需要再参与比较。
  3. 内层比较的索引范围是 [0, n-1-i],对应代码里的j < n - 1 - i

2.4 标准版冒泡排序的Java实现

基于以上规律,标准版代码长这样:

public class BubbleSort { public static void bubbleSort(int[] arr) { if (arr == null || arr.length < 2) { return; } int n = arr.length; // 外层循环:控制轮数,共 n-1 轮 for (int i = 0; i < n - 1; i++) { // 内层循环:第 i 轮只需比较到 n-1-i for (int j = 0; j < n - 1 - i; j++) { // 相邻元素比较,升序排列,左边 > 右边就交换 if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1); } } } } private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } }

注意:j < n - 1 - i这个边界是冒泡代码最容易写错的地方。写成j < n - i会数组越界,写成j < n - 1则每一轮都在做重复比较。写完后心里默念一遍“第 i 轮,最后 i 个已归位,所以比较上限是 n-1-i”,基本就不会错了。

3. 冒泡排序的两次关键优化与性能实测

标准版代码能跑,但面试官大概率会追问一句:“还能优化吗?”这时候你的回答质量,直接决定了他对你基础扎不扎实的判断。

3.1 第一次优化:有序标记,提前退出

回到刚才推演的例子,第二轮结束时数组已经整体有序了,但标准版代码依然傻乎乎地跑完了第三轮、第四轮。如果数组原本就接近有序,这种无脑比较非常浪费。

优化思路很直接:加一个布尔标记,记录本轮是否发生过交换。如果某一轮全程没有发生任何交换,说明数组已经整体有序,直接终止外层循环。

public static void bubbleSortOptimized1(int[] arr) { if (arr == null || arr.length < 2) { return; } int n = arr.length; for (int i = 0; i < n - 1; i++) { boolean swapped = false; // 每轮开始时重置 for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1); swapped = true; // 发生了交换 } } // 如果本轮没有发生交换,说明已全部有序 if (!swapped) { break; } } }

这版优化的意义在于:最好情况的时间复杂度从 O(n²) 降到了 O(n)。比如对一个已经排好序的数组,第一轮扫描完发现一次交换都没发生,立刻退出,只做了 n-1 次比较。

3.2 第二次优化:记录最后交换位置,缩小比较区间

第一次优化解决的是“完全有序提前退出”的问题。但还有一种情况:数组的后半段已经有序,而前半段还是乱的。比如:

[3, 1, 4, 6, 7, 8, 9, 10]

第一轮结束时,最后一次交换发生在索引 0 和 1 之间,也就是数字 4 浮到索引2之后,后面的 6、7、8、9、10 本身就是升序,没有再发生交换。但下一轮,标准版依然会把比较范围扩展到 n-1-1,也就是一直比较到倒数第二对,而这些比较完全是浪费。

优化思路是:维护一个变量记录本轮最后一次交换发生的索引位置,这个位置之后的所有元素都已经有序,下一轮只需要比较到这个位置即可。

public static void bubbleSortOptimized2(int[] arr) { if (arr == null || arr.length < 2) { return; } int n = arr.length; // 当前轮的比较边界,初始为数组末尾 int sortBound = n - 1; while (sortBound > 0) { int lastSwapIndex = 0; // 记录本轮最后一次交换的位置 for (int j = 0; j < sortBound; j++) { if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1); lastSwapIndex = j; // 更新为当前交换位置 } } sortBound = lastSwapIndex; // 下一轮只需要比较到 lastSwapIndex } }

这版优化把“比较边界”变成了动态的。每次循环结束,sortBound会收缩到上一轮最后一次发生交换的位置。这样既自然地包含了“提前退出”的效果(如果某轮没发生交换,lastSwapIndex保持为 0,sortBound变为 0,循环结束),又避免了稳定区段的无效比较。

3.3 时间与空间复杂度对照

指标标准版优化版(有序标记)优化版(记录交换边界)
最坏时间复杂度O(n²)O(n²)O(n²)
最好时间复杂度O(n²)O(n)O(n)
平均时间复杂度O(n²)O(n²)O(n²)
空间复杂度O(1)O(1)O(1)
稳定性稳定稳定稳定

稳定性这一点容易被忽略:冒泡排序只在arr[j] > arr[j+1]时交换,相等的元素不会交换位置,所以相同值的相对顺序不会改变,它是稳定排序。这个特性在面试中可能会被单独拎出来问,记住它。

3.4 面试中关于冒泡的追问

面试官考冒泡排序,一般不会只让你写代码。我总结几个常见的追问方向:

  1. 最好情况时间复杂度是多少?答 O(n),但前提是加了有序标记优化,否则是 O(n²)。
  2. 冒泡排序是稳定的吗?答稳定,因为相等元素不交换。
  3. 和插入排序比有什么区别?这个问题很阴险。两者最坏复杂度都是 O(n²),但插入排序在“接近有序”的数据上表现极好,且交换次数往往少于冒泡,所以工程实现里插入排序更常见。冒泡更多是教学用途。
  4. 一万个随机整数排序要多少次比较?最坏约 5000 万次(n(n-1)/2),平均也接近这个量级。所以它只适合小规模数据。

4. 二分查找:二十行代码里的边界陷阱

如果说冒泡排序是“大白话算法”,那二分查找就是“高段位陷阱王”。它代码量极少,逻辑看似简单,但写对的概率和代码行数成反比——越短越容易错。我自己面试别人时,统计过这个题的错误率,超过一半的候选人都不能一次性通过边界测试。

4.1 使用前提:有序数组 + 随机访问

二分查找的前提有两个:

  1. 数据必须是有序的(通常是升序)。无序数组需要先排序,或者改用哈希表等方式。
  2. 支持随机访问。也就是说底层数据结构得是数组(ArrayList也行),不能是链表。链表的“二分”代价极高,因为每次取中间元素都要遍历。

这也解释了为什么业务代码里直接二分用得少:大部分业务数据不会专门维护有序数组,而且频繁增删场景下维护有序数组的成本太高。但算法题和面试场景里,它出现的频率极高。

4.2 核心思想:每次排除一半

二分查找的思路一句话讲完:每次看中间位置的元素,如果等于目标值直接返回;如果目标值小于中间值,说明目标在左半区,把右边界移到中间位置左边一位;如果目标值大于中间值,说明目标在右半区,把左边界移到中间位置右边一位。重复这个过程,直到区间为空。

这里的关键在于:每一轮都把搜索区间缩小一半,所以查找 n 个元素最多需要 log₂(n) 次比较。100万个元素,最多20次比较就能定位,这就是它强大的地方。

4.3 标准版实现:循环不变量是核心

写二分查找之前,先想清楚你的循环不变量target只能在[left, right]这个左闭右闭区间内。只要这个区间还在收缩,就一直循环;一旦区间为空,说明找不到。

public class BinarySearch { /** 在升序数组 arr 中查找 target,返回下标;找不到返回 -1 */ public static int binarySearch(int[] arr, int target) { if (arr == null || arr.length == 0) { return -1; } int left = 0; int right = arr.length - 1; // 右边界取最后一个下标,闭区间 while (left <= right) { // 区间合法条件:left <= right // 防止 left + right 整数溢出 int mid = left + (right - left) / 2; if (arr[mid] == target) { return mid; // 找到了,直接返回 } else if (arr[mid] < target) { left = mid + 1; // target 在右半区 } else { right = mid - 1; // target 在左半区 } } return -1; // 区间为空,没找到 } }

4.4 为什么 mid 要用left + (right - left) / 2计算

这是二分查找里最经典的坑。很多人初学时习惯写(left + right) / 2,看起来没问题,但如果leftright都很大,两者相加可能超出int的最大值 2147483647,造成整数溢出,mid 变成负数,程序直接崩了。

left + (right - left) / 2的写法,先计算区间长度的一半,再加上左边界,数学上和(left+right)/2完全等价,但不会溢出。这是一道经典面试题,很多人以为考的是二分,其实考的是溢出意识。

4.5 三个常见边界错误对照表

错误写法后果正确写法
while (left < right)(配闭区间)区间内只剩一个元素时循环退出,漏判while (left <= right)
right = mid(配闭区间)可能死循环,因为 mid 可能等于 leftright = mid - 1
left = mid(配闭区间)同样可能死循环left = mid + 1

记忆口诀:闭区间写法,右边界动过之后是 mid-1,左边界动过之后是 mid+1,循环条件是 left <= right。这个组合是配套的,改一个就得全改。

4.6 二分查找的变体:面试高频追问题

标准版仅仅是个开始,面试官真正想考你的是变体。这里列三个最常见的:

变体一:查找第一个等于 target 的位置

数组中可能有重复元素,需要找到最左边的那个。

public static int findFirst(int[] arr, int target) { int left = 0, right = arr.length - 1; int result = -1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] >= target) { // 即使相等也继续向左找 if (arr[mid] == target) { result = mid; } right = mid - 1; } else { left = mid + 1; } } return result; }

变体二:查找最后一个等于 target 的位置

对称操作,等于或小于时向右半区继续找。

public static int findLast(int[] arr, int target) { int left = 0, right = arr.length - 1; int result = -1; while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] <= target) { if (arr[mid] == target) { result = mid; } left = mid + 1; // 即使相等也继续向右找 } else { right = mid - 1; } } return result; }

变体三:查找第一个大于等于 target 的位置(左边界)

这个变体是很多高级算法的基础,比如求最长递增子序列的贪心优化,还有TreeMap 的 ceilingEntry 操作。

public static int findFirstGreaterOrEqual(int[] arr, int target) { int left = 0, right = arr.length - 1; int result = arr.length; // 默认没有找到,返回数组长度表示“都不满足” while (left <= right) { int mid = left + (right - left) / 2; if (arr[mid] >= target) { result = mid; right = mid - 1; // 向左收缩,找更小的满足索引 } else { left = mid + 1; } } return result; }

这三种变体值得你在本地跑一遍测试用例,尤其是全等于、全小于、全大于、空数组这些边界,跑通了之后,你对二分的理解会上一个台阶。

5. 刷题落地的实战心得:调试方法与避坑清单

最后这部分,不写原理,全是实操中积累的经验。算法光看不练等于白看,但练的方式方法决定了效率。

5.1 用断点调试代替“人脑debug”

我曾经见过很多人刷题,代码跑不过就盯着屏幕看,试图“人脑模拟”出问题。这种方式对复杂算法来说效率极低,我建议直接用IDE的断点调试。

IntelliJ IDEA 里按以下步骤操作:

  1. while循环第一行打上断点;
  2. 以 Debug 模式运行,观察leftrightmid三个变量的实时变化;
  3. 单步执行,每走一步,对照当前区间判断程序行为是否符合预期。

这比任何口头讲解都直观。二分写错的人,通常调试一两次就能找到规律,比如发现leftright始终无法收敛、mid 重复计算同一个位置导致死循环等。

5.2 冒泡排序的典型错误排查

冒泡最容易出的Bug是数组越界,报错信息一般是ArrayIndexOutOfBoundsException。我之前帮同事排查过一版代码,他写的内层循环是j < n,当j = n-1时,arr[j+1]直接越界。

排查方法很简单:用边界输入跑一遍。比如n = 1的数组[5],和n = 2的数组[2, 1]。这两个最小用例能暴露九成以上的边界问题。

另外还有一个隐蔽错误:内层循环写成j < n - i - 1还是j <= n - i - 2?两者在数学上等价,但前者更容易理解,也更好记。写代码时优先选语义清晰的写法。

5.3 二分查找的测试用例设计

二分查找想验证正确性,至少准备这六类测试数据:

测试类别示例数组目标值期望结果
空数组[]任意-1
单元素数组命中[5]50
单元素数组未命中[5]3-1
目标在数组开头[1, 3, 5, 7]10
目标在数组结尾[1, 3, 5, 7]73
目标不存在但介于中间[1, 3, 5, 7]4-1

每一类都过了,标准版二分才算合格。变体版还需要额外测试重复元素的场景,比如[1, 2, 2, 2, 3]查第一个2返回1、最后一个2返回3。

5.4 Day1的收尾节奏与作业

DAY1不建议贪多,两个算法彻底吃透,比囫囵吞枣看十个有效得多。我给自己定的节奏是:

  • 上午:通读本文,跟着推演过程手写一遍冒泡和二分,不要复制代码,合上文章自己写;
  • 下午:跑通6类二分测试用例,再自己设计3个冒泡测试用例验证两次优化逻辑;
  • 晚上:把两个变体二分的代码默写一遍,不看参考答案。

明天DAY2会进入选择排序和插入排序,以及“为什么插入排序比冒泡更适合工程场景”这个很多人想不明白的问题。到时候我会给出一组同样的数组在这几种排序下的实测算例对比,相信看完你会对“复杂度相同但表现不同”有更直观的体会。

算法学习的路上,今天是第一步。没有花哨的技巧,但地基,就这么一块一块打的。

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

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

立即咨询