我最近在帮团队做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 从推演中总结规律
看完整过程,你会发现三个关键规律:
- 总共需要 n-1 轮外层循环。n个元素,每轮确定一个最大值,前 n-1 个确定后,最后一个自然就位,所以外层循环次数是 n-1。
- 第 i 轮只需要比较前 n-i 对相邻元素。因为每完成一轮,右侧就多一个已经排好序的元素,不需要再参与比较。
- 内层比较的索引范围是 [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 面试中关于冒泡的追问
面试官考冒泡排序,一般不会只让你写代码。我总结几个常见的追问方向:
- 最好情况时间复杂度是多少?答 O(n),但前提是加了有序标记优化,否则是 O(n²)。
- 冒泡排序是稳定的吗?答稳定,因为相等元素不交换。
- 和插入排序比有什么区别?这个问题很阴险。两者最坏复杂度都是 O(n²),但插入排序在“接近有序”的数据上表现极好,且交换次数往往少于冒泡,所以工程实现里插入排序更常见。冒泡更多是教学用途。
- 一万个随机整数排序要多少次比较?最坏约 5000 万次(n(n-1)/2),平均也接近这个量级。所以它只适合小规模数据。
4. 二分查找:二十行代码里的边界陷阱
如果说冒泡排序是“大白话算法”,那二分查找就是“高段位陷阱王”。它代码量极少,逻辑看似简单,但写对的概率和代码行数成反比——越短越容易错。我自己面试别人时,统计过这个题的错误率,超过一半的候选人都不能一次性通过边界测试。
4.1 使用前提:有序数组 + 随机访问
二分查找的前提有两个:
- 数据必须是有序的(通常是升序)。无序数组需要先排序,或者改用哈希表等方式。
- 支持随机访问。也就是说底层数据结构得是数组(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,看起来没问题,但如果left和right都很大,两者相加可能超出int的最大值 2147483647,造成整数溢出,mid 变成负数,程序直接崩了。
left + (right - left) / 2的写法,先计算区间长度的一半,再加上左边界,数学上和(left+right)/2完全等价,但不会溢出。这是一道经典面试题,很多人以为考的是二分,其实考的是溢出意识。
4.5 三个常见边界错误对照表
| 错误写法 | 后果 | 正确写法 |
|---|---|---|
while (left < right)(配闭区间) | 区间内只剩一个元素时循环退出,漏判 | while (left <= right) |
right = mid(配闭区间) | 可能死循环,因为 mid 可能等于 left | right = 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 里按以下步骤操作:
- 在
while循环第一行打上断点; - 以 Debug 模式运行,观察
left、right、mid三个变量的实时变化; - 单步执行,每走一步,对照当前区间判断程序行为是否符合预期。
这比任何口头讲解都直观。二分写错的人,通常调试一两次就能找到规律,比如发现left和right始终无法收敛、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] | 5 | 0 |
| 单元素数组未命中 | [5] | 3 | -1 |
| 目标在数组开头 | [1, 3, 5, 7] | 1 | 0 |
| 目标在数组结尾 | [1, 3, 5, 7] | 7 | 3 |
| 目标不存在但介于中间 | [1, 3, 5, 7] | 4 | -1 |
每一类都过了,标准版二分才算合格。变体版还需要额外测试重复元素的场景,比如[1, 2, 2, 2, 3]查第一个2返回1、最后一个2返回3。
5.4 Day1的收尾节奏与作业
DAY1不建议贪多,两个算法彻底吃透,比囫囵吞枣看十个有效得多。我给自己定的节奏是:
- 上午:通读本文,跟着推演过程手写一遍冒泡和二分,不要复制代码,合上文章自己写;
- 下午:跑通6类二分测试用例,再自己设计3个冒泡测试用例验证两次优化逻辑;
- 晚上:把两个变体二分的代码默写一遍,不看参考答案。
明天DAY2会进入选择排序和插入排序,以及“为什么插入排序比冒泡更适合工程场景”这个很多人想不明白的问题。到时候我会给出一组同样的数组在这几种排序下的实测算例对比,相信看完你会对“复杂度相同但表现不同”有更直观的体会。
算法学习的路上,今天是第一步。没有花哨的技巧,但地基,就这么一块一块打的。