这几天的热搜词里,“二分查找”和“排序算法”已经被问烂了,从PTA函数题到期末复习,从Java的List排序到MySQL的排序规则,几乎每个方向都有人在踩坑。作为一个常年和数据结构打交道的开发者,我想把这些碎片化的知识点串起来,用一篇完整的项目式总结,讲讲二分查找和排序算法背后的原理、选型逻辑,以及真实项目里容易被忽略的细节。
1. 为什么“会背模板”不等于“会写二分查找”
很多人面试或者考试前都会背二分查找的代码模板,但一到手写或者改边界条件就翻车。这其实不是记性不好,而是没搞懂二分查找的核心机制到底在做什么。
1.1 二分查找的本质是一个“区间收缩”问题
二分查找不是单纯地在数组里“猜数字”,它本质上是维护一个“可能的解区间”,然后不断根据中间值,把这个区间缩小一半。你写的每一行代码,边界条件怎么定,都是在回答四个问题:
- 区间是什么?(左闭右闭,还是左闭右开)
- 中间值怎么算?(会不会溢出)
- 下一步缩小区间时,中间值要不要保留?
- 循环结束条件是什么?结束后left和right分别指向哪里?
这四个问题只要有一个没想清楚,代码就会在某个测试用例上崩掉。
cpp复制// 标准左闭右闭写法
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
else if (nums[mid] < target) left = mid + 1;
else right = mid - 1;
}
return -1;
}
注意我写的是mid = left + (right - left) / 2,而不是(left + right) / 2。原因很简单:当left和right都是很大的整数时,两者相加可能超过int的表示范围,导致溢出变成负数,这在C++/Java里都是真实会发生的问题。left + (right - left) / 2这个写法从数学上等价,但永远不会溢出。
1.2 循环不等号和边界移动必须配套
很多人只记住了while (left <= right),但没注意到在这个条件下,当left移动到right的右边一个位置时循环才会退出。这意味着搜索区间是[left, right],左闭右闭。
而如果写成while (left < right),那搜索区间就变成了[left, right),此时right通常指向的是“不包含”的位置,初始值应该是nums.size()而不是nums.size() - 1。
cpp复制// 左闭右开写法
int binarySearch(vector<int>& nums, int target) {
int left = 0, right = nums.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) left = mid + 1;
else right = mid;
}
// 此时left == right,是第一个 >= target 的位置
return left < nums.size() && nums[left] == target ? left : -1;
}
这两种写法没有谁对谁错,但它们是不同的约定。最忌讳的是混搭:用左闭右开的思想初始化right,却用<=做循环条件,或者反过来,这样必然出现死循环或者漏查元素。
1.3 实战中高频卡壳的二分变体
实际项目中我们更多遇到的是带条件的二分查找,比如“查找第一个等于target的下标”“查找最后一个小于等于target的下标”。这些变体本质上是在问:“当nums[mid] == target时,该往左走还是往右走?”
cpp复制// 查找第一个等于target的位置
int findFirst(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
int ans = -1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
ans = mid;
right = mid - 1; // 找到之后不返回,继续向左搜
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return ans;
}
记住这个套路:找最左(第一个)就往左缩,找最右(最后一个)就往右缩。找到命中值后不是立即return,而是记录当前位置,继续向目标方向收缩区间,直到区间无法再收缩。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 排序全家桶:从冒泡到快排,你需要的不是“背代码”而是“选型直觉”
排序算法是数据结构里“背了忘、忘了背”的重灾区。平心而论,真正常用的排序并没有那么多,但理解它们的底层机制,直接决定了你在面对不同数据规模、不同内存限制、不同稳定性要求时,能不能做出正确的选择。
2.1 比较类排序的核心衡量维度
衡量排序算法,有三个维度必须清楚:
- 时间复杂度:最好、最坏、平均。
- 空间复杂度:是否是原地排序。
- 稳定性:相同元素的相对顺序是否保持不变。
我把常见排序算法的核心指标整理成了下面的表格:
| 排序算法 | 平均时间 | 最坏时间 | 空间 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n^2) | O(n^2) | O(1) | 稳定 | 几乎不用于生产,教学为主 |
| 插入排序 | O(n^2) | O(n^2) | O(1) | 稳定 | 近乎有序的小数据量 |
| 选择排序 | O(n^2) | O(n^2) | O(1) | 不稳定 | 交换次数少 |
| 快速排序 | O(n log n) | O(n^2) | O(log n) | 不稳定 | 通用排序首选 |
| 归并排序 | O(n log n) | O(n log n) | O(n) | 稳定 | 大数据量、外部排序 |
| 堆排序 | O(n log n) | O(n log n) | O(1) | 不稳定 | 需要原地且最坏时间有保证 |
2.2 快速排序的“选主元”是真正的分水岭
快排的核心思想是分治:选一个基准元素(pivot),把小于它的元素放左边,大于它的放右边,然后递归处理左右两部分。这一步叫partition。
但快排的性能上限被“选主元”死死卡住。如果每次都能选到中位数作为pivot,递归深度是log n,时间复杂度是稳定的O(n log n);如果每次都不幸选到最大或最小值,递归深度退化成n,时间复杂度退化成O(n^2)。
实际工程中常用的优化策略:
- 三数取中:从首、中、尾三个位置取中间值作为pivot,能极大降低有序数组触发最坏情况的概率。
- 随机选主元:用随机数选pivot,几乎不可能每次都触发最坏情况。
- 小区间插入排序:当递归拆分的子区间长度小于某个阈值(比如16)时,改用插入排序,减少递归开销。
很多人以为快排是“教科书算法”,但你在Java的Arrays.sort()、C++的std::sort里面看到的正是这些优化。
cpp复制// 快排的三数取中partition
int medianOfThree(vector<int>& nums, int left, int right) {
int mid = left + (right - left) / 2;
if (nums[left] > nums[mid]) swap(nums[left], nums[mid]);
if (nums[left] > nums[right]) swap(nums[left], nums[right]);
if (nums[mid] > nums[right]) swap(nums[mid], nums[right]);
// 此时 nums[left] <= nums[mid] <= nums[right]
return nums[mid];
}
void quickSort(vector<int>& nums, int left, int right) {
if (left + 16 > right) {
// 小区间直接插排
for (int i = left + 1; i <= right; i++) {
int key = nums[i];
int j = i - 1;
while (j >= left && nums[j] > key) {
nums[j + 1] = nums[j];
j--;
}
nums[j + 1] = key;
}
return;
}
int pivot = medianOfThree(nums, left, right);
// 把pivot换到右端,然后进行标准Lomuto分区
int i = left;
for (int j = left; j < right; j++) {
if (nums[j] < pivot) {
swap(nums[i], nums[j]);
i++;
}
}
swap(nums[i], nums[right]);
int pos = i;
quickSort(nums, left, pos - 1);
quickSort(nums, pos + 1, right);
}
2.3 归并排序在“稳定”这件事上不可替代
如果业务需求要求“稳定排序”,比如按成绩排序后,相同成绩的学生要保留原始提交顺序,那快排就无能为力了(它是稳定性排序吗?显然不是,交换操作会打乱相同元素的相对顺序),这时候归并排序是首选。
归并排序的时间复杂度无论什么情况都是O(n log n),代价是需要额外的O(n)空间。在实际应用中,Java的Collections.sort()对对象排序时用的就是归并排序的优化版(TimSort),因为它要保证稳定性和最坏情况的性能。
归并排序还有一个隐藏用途:外部排序。当数据量大到内存放不下时(比如对10GB的日志文件排序),就把数据切成多块分别排序,然后多路归并。
3. 大数据量、近乎有序、内存受限:排序选型的真实战场
前面讲了排序的原理,这节结合我在实际项目中遇到的问题,聊聊真实的选型逻辑。
3.1 当“数据基本有序”时,插入排序是隐形的王者
很多算法教科书把插入排序放到很基础的位次,给人感觉是个“教学玩具”。但真实场景里,如果数据是“基本有序”的,插入排序的性能优势几乎是碾压级的。
举个例子,假设有一个长度为10万的数组,其中90%的元素已经排列正确,只有少量元素需要调整位置。此时用快排需要递归调用、分配栈帧,而插入排序只需将少数“逆序元素”向前移动,实际比较次数接近O(n)。
所以很多工程实现里的混合排序策略都是这样的:快速排序负责宏观分治,当递归到子区间规模小于某个阈值时切到插入排序。这个阈值通常取16或者32,是根据实际性能测试调出来的,不是凭空定的。
3.2 内存敏感场景下,堆排序是最后的底牌
如果数据量很大,而且你不能申请额外内存,只能原地排序,那堆排序就是比快排更稳妥的选择。因为快排在partition过程中会用系统栈递归,最坏情况下递归深度可能达到n,而堆排序没有递归,只用常数级的额外空间。
堆排序的实现思路:先把数组调整成大顶堆(或小顶堆),然后反复把堆顶元素和未排序区间的最后一个元素交换,堆大小减一,重新调整堆。
cpp复制void heapify(vector<int>& nums, int n, int i) {
int largest = i;
int left = 2 * i + 1;
int right = 2 * i + 2;
if (left < n && nums[left] > nums[largest]) largest = left;
if (right < n && nums[right] > nums[largest]) largest = right;
if (largest != i) {
swap(nums[i], nums[largest]);
heapify(nums, n, largest);
}
}
void heapSort(vector<int>& nums) {
int n = nums.size();
// 建堆
for (int i = n / 2 - 1; i >= 0; i--) heapify(nums, n, i);
// 排序
for (int i = n - 1; i > 0; i--) {
swap(nums[0], nums[i]);
heapify(nums, i, 0);
}
}
堆排序的“不稳定”在大多数场景下可以接受,但如果你要求稳定性,那就只能用归并排序。
3.3 数据量大到内存放不下:外部排序是唯一解
当数据总量远超内存容量时,排序就得换思路了。外部排序的核心策略是“分块排序 + 多路归并”。
- 把大文件切成若干个小块,每个小块都能加载进内存排序(用快排或者归并都行)。
- 将排序后的每个小块写回磁盘,形成多个有序子文件。
- 用优先队列(小顶堆)从所有子文件中依次取出当前最小的元素,写入最终文件,完成归并。
这里用到的数据结构就是堆。每一路归并都要读取一个元素进入堆中,堆顶就是当前全局最小值,取出后从对应子文件继续读入下一个元素。这样无论最终文件多大,内存里只需要维护“当前每个子文件头部元素的堆”,堆的大小等于归并路数。
4. 排序与数据结构的关系:远不只是“把数组排个序”
排序从来不只是数组的专利。你看到的热搜词里,“redis数据结构”“搜索二叉树”“字符串排序”“MySQL排序”,其实都跟排序的底层逻辑有关。
4.1 二叉搜索树:动态排序的元数据结构
如果数据不仅需要排序,还需要频繁地插入和删除,那数组排序就不合适了,因为数组的插入和删除操作是O(n)的。此时二叉搜索树(BST)可以作为动态排序结构:中序遍历的结果就是有序的,插入删除的平均复杂度是O(log n)。
但普通的BST在极端情况下会退化成链表,所以工程上更常用的是自平衡版本:红黑树。Java的TreeMap、C++的std::map底层就是红黑树,MySQL的InnoDB索引底层则是B+树——它本质上是树结构,但把每个节点的子节点数从2扩大到了很多,目的就是减少磁盘I/O次数。
4.2 稳定排序在业务排序中的实际意义
再强调一次稳定性。业务系统里经常出现“多字段排序”的需求:先按A字段排序,A相同再按B字段排序。一个常见做法是先按次要字段B排序,再按主要字段A做稳定排序。这样当A字段相同时,B字段的顺序就会保持第一次排序的结果。
如果你用的是不稳定的排序(比如快排),第二次按A排序后,A相同的元素之间的B顺序是乱的,结果就完全不符合预期了。
实际上,Java中list.sort(comparator)的实现底层用的是Arrays.sort的TimSort版本,它能保持稳定。但如果你自己手写排序或者用某些数据库的排序实现,就一定要注意稳定性问题。
4.3 非比较排序:字符串排序和基数排序的启发
讨论排序算法时,还有一个容易被忽略的分支——非比较排序,比如计数排序、基数排序。这类算法不依赖元素之间的比较,而是利用元素本身的某种结构。
最典型的就是字符串排序。如果对一组等长字符串排序,可以用基数排序:从最低位开始,按每一位字符依次做稳定排序。由于字母表大小是有限的,每一位的排序可以用计数排序完成,总复杂度是O(n * k),其中k是字符串长度。这在特定场景下比O(n log n)的比较类排序更快。
这种“把排序拆成多轮,每轮利用稳定排序”的思路在搜索引擎索引构建、字典排序等场景中有广泛应用。
5. 实操中那些容易让二分查找和排序“翻车”的细节
理论知识讲完了,说说我在实际开发和教学中经常遇到的坑。有些问题看起来微不足道,但真的会让人debug到怀疑人生。
5.1 mid溢出不是理论问题,是线上事故
我有一年写一个在线系统,对几亿条记录做分页查询优化,用的二分查找定位初始偏移量。代码写得很顺,本地测试也通过,但上线后在高并发场景下偶发崩溃。排查半天才发现,就是(left + right) / 2在大数据量下溢出了问题。
从那以后,我的代码规范里写死一条:计算mid一律用left + (right - left) / 2。这条规则值得放在你可见的地方。
5.2 死循环的根源:区间收缩停滞
写二分查找时死循环通常不是逻辑太复杂,而是区间收缩停滞了。典型场景:
cpp复制while (left < right) {
int mid = (left + right) / 2;
if (条件) {
left = mid; // 如果 left == mid,区间没缩小,死循环
} else {
right = mid - 1;
}
}
当left和right相邻时,mid = (left + right) / 2会等于left(整数除法向下取整),如果此时条件成立走了left = mid,区间根本没变,下一轮循环又是同样的left、right,于是死循环。
解决方式很简单:
- 如果
left = mid这种写法,把mid计算改成向上取整:mid = left + (right - left + 1) / 2。 - 或者把left的更新改成
left = mid + 1,这样无论mid取什么值,区间一定能缩小。
这个细节是二分查找变体题的核心考点之一,也是面试官最喜欢深挖的地方。
5.3 自定义比较器的坑:返回值的正负号别搞反
Java里写自定义排序时经常有人忘记比较器的返回约定:
- 返回负数表示第一个参数排在第二个参数之前;
- 返回正数表示第一个参数排在第二个参数之后;
- 返回0表示相等。
很多人排完发现结果不是升序而是降序,一查就是comparator返回值的正负号和预期反了。更隐蔽的问题是,如果你的比较器返回了不等价的结果(比如两个元素既大于又小于),可能触发Java的排序算法抛异常(“Comparator violates its general contract”)——这是TimSort的防御性检查,在数据量大时会启动。
我建议在写比较器时先想清楚:a.compareTo(b)的默认语义是升序,b.compareTo(a)是降序,不要靠试错。
5.4 数据库排序:别把索引和排序割裂开
热搜词里有“mysql排序”,这个点在数据库场景下容易被忽略。MySQL默认使用的排序规则是utf8mb4_general_ci还是utf8mb4_unicode_ci,直接决定了字符串排序的结果。如果你对中文、多语言字符串排序有特定需求,一定要在DDL阶段就明确排序规则,否则后面数据量大了,想改排序规则可能需要重建整张表。
另外,ORDER BY后面接的字段顺序影响索引的利用效率。如果查询语句中的排序字段和索引顺序不一致,MySQL可能会放弃索引而使用filesort,在大表上就是灾难。这也是为什么很多慢查询优化的核心工作就是调整索引设计以匹配排序需求。
6. 回到二分查找:两个有序数组场景下的进阶应用
算法面试和竞赛中,二分查找还有一个经典进阶题:寻找两个有序数组的中位数。这个题的价值不在于那个解法本身,而在于让你真正理解二分查找的“查找目标”可以不是某个具体的值,而是一个“边界位置”。
6.1 问题转化:二分的位置就是“分割线”
假设两个数组分别有m和n个元素,要在O(log(min(m, n)))时间内找到合并后数组的中位数。我们可以把问题转化为:在两个数组中各取一个分割点,使得左边的元素总数等于右边元素总数(奇数个时左边多一个),同时左边的最大值小于右边的最小值。
这样一来,二分查找的目标就不再是查某个值,而是在较短的数组中找到合适的分割位置。
cpp复制double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
if (nums1.size() > nums2.size()) return findMedianSortedArrays(nums2, nums1);
int m = nums1.size(), n = nums2.size();
int left = 0, right = m;
int totalLeft = (m + n + 1) / 2;
while (left < right) {
int i = left + (right - left) / 2;
int j = totalLeft - i;
if (nums1[i] < nums2[j - 1]) {
left = i + 1;
} else {
right = i;
}
}
int i = left;
int j = totalLeft - i;
int nums1LeftMax = (i == 0) ? INT_MIN : nums1[i - 1];
int nums1RightMin = (i == m) ? INT_MAX : nums1[i];
int nums2LeftMax = (j == 0) ? INT_MIN : nums2[j - 1];
int nums2RightMin = (j == n) ? INT_MAX : nums2[j];
if ((m + n) % 2 == 1) {
return max(nums1LeftMax, nums2LeftMax);
} else {
return (max(nums1LeftMax, nums2LeftMax) + min(nums1RightMin, nums2RightMin)) / 2.0;
}
}
这个解法的核心启发是:二分查找不仅能查“值”,还能查“位置”——只要你能推导出单调性。把这个思路迁移到其他问题里(比如旋转数组的查找),你的算法能力会上一个台阶。
6.2 旋转数组的二分查找:让二分思想真正“活”起来
热搜词里出现“旋转数组”相关的内容不算少。在一个原本有序、但被旋转过的数组中搜索target,比如[4,5,6,7,0,1,2]里找5,直接用标准二分是不行的,因为整个数组不是单调的。但如果你能利用“旋转点把数组分成两个有序段”这一特性,仍然可以用二分。
关键判断是:mid落到了左边有序段还是右边有序段。如果nums[left] <= nums[mid],说明mid在左半部分,此时如果target也在nums[left]到nums[mid]之间,就收缩右边界,否则收缩左边界。反之同理。
这个变体训练的就是你“能不能跳出一眼能看到的单调性”,从局部单调性入手。
7. 从算法题到工程:排序与二分的惯性思维陷阱
最后写点我认为比算法本身更重要的内容:把算法应用到工程时,哪些思维惯性会害了你。
7.1 别在O(n)能解决的地方强行用二分
二分的优势是O(log n),但前提是数据能够随机访问并且已经有序。如果你每次二分前都要先排序,那排序的O(n log n)就把二分的优势彻底抹平了。这种情况下,如果数据量不大,一次线性扫描反而更简单可靠。
工程里常用的一句话是“先把功能做出来,再考虑优化”。如果一段代码从业务逻辑上看运行次数极少,数据量又不大的话,线性查找完全够用。过早引入二分,除了增加理解成本,没有实际收益。
7.2 排序的消费方:别忽略调用者对顺序的假设
如果你的函数返回一个排序后的集合,调用方可能会基于“有序”做各种假设,比如进行二分查找、取前N条、按顺序输出等。如果你在内部排序时用了不稳定的排序且没有明确说明,调用方在特定数据下可能会看到顺序的不确定性,这种bug往往最难排查。
所以在设计接口时,如果函数内部做了排序,最好的实践是在接口注释里明确说明排序规则和稳定性,而不是让调用方自己去猜。
7.3 排序里的“空间换时间”思维
前面提到归并排序为了稳定性付出了O(n)的空间。这是一个很好的例子,说明很多算法设计本质上都是在“时间、空间、稳定性、实现复杂度”之间做权衡。没有完美的排序算法,只有适合当前场景的排序算法。
我个人的选择习惯是这样的:
- 数据量小(<1000)或者基本有序:插入排序。
- 数据量大且内存充足,不需要稳定:快排。
- 任何情况下都要求稳定:归并排序。
- 内存紧张或者需要绝对最坏时间保证:堆排序。
- 数据是整数且范围有限:计数排序/基数排序。
这个决策流程比单纯背某个排序算法要实用得多。
8. 一些学习资源与练习路径的取舍建议
热搜词里有人找“严蔚敏数据结构ppt和视频”,也有人找“排序动画”,还有人在准备“数据结构期末复习”。这些关键词说明很大一部分读者是学生。我根据自己自学和带新人的经验,给一个学习路径的建议。
8.1 先动手画,再动手写
学排序算法时,我强烈建议你先在纸上把每一次交换的过程画出来。比如冒泡排序,画出第一趟排序后最大元素是如何“冒”到末尾的;快排,体会partition之后pivot是如何归位的。视觉化理解之后再写代码,效率比直接看代码快得多。
网上有很多排序动画网站,可以作为辅助工具,但别过度依赖。动画看懂了是别人的,你手写一遍跑通才是自己的。
8.2 刷题时用“题型组块”代替“海量刷题”
拿二分查找来说,与其做完一道题就跳到下一个类型,不如把同类的变体放在一起对比:
- 标准二分查找
- 查找第一个等于target的元素
- 查找最后一个等于target的元素
- 查找第一个大于等于target的元素
- 旋转数组中的二分查找
每组题目按“核心思想 + 边界判断 + 代码实现”做一次梳理,你会发现规律高度一致。这个方式比盲目每天刷几十道题有效得多。
