排序查找算法模板详解:从冒泡到二分,手撕代码必备
排序和查找数据结构里最基础的两块拼图。我在大学搞ACM、后来面试手撕算法、再到现在日常处理数据这么多年下来最大的感触就是这两个东西重要到值得你专门背一套自己的模板。网上代码一大堆但真到了赛场上或面试官面前你不可能现场推演逻辑靠的全是肌肉记忆。这篇文章就把我自己整理并反复验证过的排序查找模板完整放出来从最朴素的冒泡选择到进阶的快排归并再到边界最容易出错的二分查找每一段都配了注释和注意点。新手可以直接抄作业老手也可以对照一下自己的写法有没有隐患。1. 为什么我强烈建议你背熟一套排序查找模板1.1 手撕代码时肌肉记忆比临场思考靠谱参加过算法竞赛或者经历过技术面试的人应该都有体会越是基础的题目越不能靠现场想。原因很简单人的工作记忆容量是有限的你在考场上同时要处理思路、边界条件、输入输出格式如果连排序查找这种基础模块都要现场推导留给整体逻辑的精力就少了一大截。我见过不少同学排序算法背得滚瓜烂熟但一到手写就各种小毛病循环边界写错、变量名搞混、递归出口漏了这些都是典型的“没有固定模板”的症状。把排序查找固定成模板本质上就是把这一类问题的认知负担从“思考”降级为“提取”。就好比打字不需要想每个键的位置骑自行车不需要想怎么保持平衡你只需要把注意力放在真正的难题上。我自己在面试手撕环节凡是涉及排序查找的基本能做到闭着眼睛写对靠的不是运气而是这套代码我已经写过几百遍每个边界条件都刻在脑子里了。1.2 模板的价值少踩坑留出思考空间模板的第二个价值是稳定性。你可能觉得“排序嘛我临时写一个也能跑”但能跑和能稳定跑是两回事。举个例子快速排序如果每次取第一个元素做基准遇到近乎有序的数据直接退化成O(n²)而如果随手写一个随便选基准的版本大概率不会去考虑极端数据。再比如二分查找看起来就几行代码但死循环的坑我踩了不下五次全是边界处理不当造成的。一套成熟的模板必须经过大量测试随机小数据、大数据、有序数据、逆序数据、全相同数据、空数组这些场景都得跑过。只有把这些极端情况都验证一遍才能在实战中真的放心用。我自己的这套模板光是二分查找就专门花了一整天做边界测试后面会详细讲。1.3 一套好模板的评判标准什么样才算“好模板”我觉得有三条标准。第一逻辑清晰注释到位。不是说代码短就好而是别人看你的代码能秒懂你的思路你自己三个月后再看还能秒懂。第二边界条件完整。空数组、单元素数组、全相同元素这些都是高频坑好模板必须覆盖。第三风格统一。所有算法都用一致的循环变量习惯、一致的命名方式。我习惯用n表示数组长度用l、r表示左右边界这样在多个模板之间切换时不需要重新适应。满足这三点这套模板才算真正属于你而不是从网上复制粘贴的别人的代码。2. 排序算法模板怎么选从冒泡到快排的全套代码2.1 排序算法横向对比别只会背时间复杂度很多人对排序算法的认知停留在“快排最快”这个层面但真实场景远没那么简单。我整理了一个表格把常见排序算法按多个维度对比了一下方便大家选型时有个全局视角。算法平均时间最坏时间空间稳定性适合场景冒泡排序O(n²)O(n²)O(1)稳定教学入门、极小数据量选择排序O(n²)O(n²)O(1)不稳定交换次数最少一些特殊场景插入排序O(n²)O(n²)O(1)稳定近乎有序数据、小规模数据快速排序O(n log n)O(n²)O(log n)不稳定通用排序首选配合随机基准归并排序O(n log n)O(n log n)O(n)稳定需要稳定排序、求逆序对堆排序O(n log n)O(n log n)O(1)不稳定空间受限、需要O(1)空间计数排序O(nk)O(nk)O(k)稳定数据范围小、整数基数排序O(d(nk))O(d(nk))O(nk)稳定整数、字符串等固定位数数据注意稳定性是关键稳定意味着相等元素的相对顺序不变。有时候排序的对象是不带唯一标识的复合结构稳定性会直接影响后续操作。比如按成绩排序后还要保持学号原来的顺序那稳定排序就必不可少。2.2 基础排序模板冒泡、选择、插入先放基础三件套。这部分看起来简单但细节其实不少什么优化技巧值得加、什么不值得我都直接标注在注释里了。// 冒泡排序基础版本 优化 void bubbleSort(vectorint a) { int n a.size(); for (int i 0; i n - 1; i) { bool swapped false; // 优化本轮没有交换说明已有序 for (int j 0; j n - 1 - i; j) { if (a[j] a[j 1]) { swap(a[j], a[j 1]); swapped true; } } if (!swapped) break; // 提前结束必须加 } }冒泡排序加了提前退出标志后最好情况复杂度直接降到O(n)面对近乎有序的数据表现好很多。不加这个标志的冒泡排序在竞赛中基本没有使用价值。// 选择排序每轮找最小值放到前面 // 优点交换次数总是 O(n)适合交换代价大的场景 void selectionSort(vectorint a) { int n a.size(); for (int i 0; i n - 1; i) { int minIdx i; for (int j i 1; j n; j) { if (a[j] a[minIdx]) { minIdx j; } } if (minIdx ! i) swap(a[i], a[minIdx]); } }选择排序虽然在时间复杂度上和冒泡同级但交换次数远少于冒泡。如果排序对象是结构体数组每次交换都要复制整个结构体选择排序的优势就体现出来了。// 插入排序像整理扑克牌适合近乎有序的数据 void insertionSort(vectorint a) { int n a.size(); for (int i 1; i n; i) { int key a[i]; int j i - 1; while (j 0 a[j] key) { a[j 1] a[j]; j--; } a[j 1] key; } }插入排序在数据量小于一定阈值时实际运行速度甚至快于快排和归并。原因在于它的常数极小内存访问模式也是顺序的缓存友好度极高。这也是为什么很多工业级排序库在小数据量时会切换到插入排序。2.3 进阶排序模板快排、归并、堆排序基础排序在实际场景中太少被直接使用真正挑大梁的还是进阶三件套。快速排序是通用排序的王者归并排序是稳定排序的担当堆排序则在空间受限时有奇效。这三个模板值得反复默写。// 快速排序随机化基准防止有序数据退化 int partition(vectorint a, int l, int r) { int randIdx l rand() % (r - l 1); swap(a[l], a[randIdx]); // 随机化避免最坏情况 int pivot a[l]; int i l, j r; while (i j) { while (i j a[j] pivot) j--; a[i] a[j]; while (i j a[i] pivot) i; a[j] a[i]; } a[i] pivot; return i; } void quickSort(vectorint a, int l, int r) { if (l r) return; int mid partition(a, l, r); quickSort(a, l, mid - 1); quickSort(a, mid 1, r); }我来解释一下这个partition的实现思路先随机挑一个基准值交换到最左边然后用双指针从两边往中间扫描。右边的指针先走找到第一个小于基准的值移动到左边空出的位置然后左边的指针走找到第一个大于基准的值移动到右边的空位。最后两个指针相遇时把基准值放回去。整个过程是原地操作空间复杂度只有递归栈的O(log n)。这里必须强调随机化。我之前一直用固定取第一个元素作为基准结果在排序一个逆序数组时递归深度直接变成n栈溢出程序崩了。加一行随机化交换后这个问题彻底解决。永远不要小看这行代码。// 归并排序稳定排序适合求逆序对 void merge(vectorint a, int l, int mid, int r, vectorint temp) { int i l, j mid 1, k l; while (i mid j r) { if (a[i] a[j]) temp[k] a[i]; else temp[k] a[j]; } while (i mid) temp[k] a[i]; while (j r) temp[k] a[j]; for (int p l; p r; p) a[p] temp[p]; } void mergeSort(vectorint a, int l, int r, vectorint temp) { if (l r) return; int mid (l r) / 2; mergeSort(a, l, mid, temp); mergeSort(a, mid 1, r, temp); merge(a, l, mid, r, temp); }归并排序的关键点在于它需要临时数组temp来辅助合并。很多人第一次写归并直接在原数组上交换发现结果不对那是因为合并过程中左右两个有序区间需要同时被读取没法原地完成。temp数组应该提前分配好不要每次递归都创建新数组那个开销非常大。手写归并排序还有个经典应用求逆序对数量。在merge过程中每当右边的元素小于左边的元素时左边剩余的元素数量就是逆序对的数量因为那些元素都大于当前右边这个元素。这是我当年学算法时觉得最巧妙的地方之一。// 堆排序基于最大堆原地排序 void heapify(vectorint a, int n, int i) { int largest i; int l 2 * i 1, r 2 * i 2; if (l n a[l] a[largest]) largest l; if (r n a[r] a[largest]) largest r; if (largest ! i) { swap(a[i], a[largest]); heapify(a, n, largest); } } void heapSort(vectorint a) { int n a.size(); for (int i n / 2 - 1; i 0; i--) heapify(a, n, i); for (int i n - 1; i 0; i--) { swap(a[0], a[i]); heapify(a, i, 0); } }堆排序的代码我承认比快排难记因为heapify这个下滤操作需要理解堆的结构。我的记忆技巧是永远从最后一个非叶子节点开始建堆节点下标是n/2-1每次取堆顶元素和末尾交换交换后对新的堆顶做一次下滤。还有一点这里的建堆过程是从下往上的因为只有子树已经是堆了才能对父节点做heapify。2.4 工程内置排序接口的正确使用方式现实中真正的手写排序场景其实不多。大部分工程代码里直接用语言内置的排序接口就够用了。但内置接口也不是无脑调用有几个细节很容易踩坑。C里是sort()和stable_sort()。sort()基于快排不是稳定排序stable_sort()基于归并稳定且保证最坏也是O(n log n)但需要额外内存。如果数据量不大比如几千个元素用stable_sort()完全没问题代码更安全。Python里的sorted()和list.sort()都值得多说两句。Python的排序算法是TimSort一种结合了归并和插入的高性能稳定排序专门为真实世界的数据设计对近乎有序的数据特别友好。但有个坑Python默认按元素本身排序如果元素是混合类型比如一个list里既有int又有str直接排序会报TypeError。这时候需要指定key参数。JavaScript的数组排序是另一个经典坑。Array.prototype.sort()默认把元素转成字符串再按字典序排所以[1, 10, 2, 20].sort()得到的结果是[1, 10, 2, 20]完全不是数值升序。必须显式传比较函数arr.sort((a, b) a - b)。这个坑我在实际项目中遇到过好几次前端同事写完排序后没注意导致线上数据显示错乱。工程中的另一个重要经验是不要重复造轮子。内置排序经过几十年优化对缓存、分支预测都有超前优化普通人手写版本很难超越。手写排序模板的价值主要在于学习、竞赛、面试以及理解内置排序的原理这本身是有意义的但不要为了用模板而用模板。3. 查找算法模板二分查找的边界问题一次讲透3.1 顺序查找与哈希查找的取舍查找算法家族里顺序查找其实是最朴素的遍历数组逐个比较。它的时间复杂度是O(n)但有个好处是简单、稳健、适合链表这种不支持随机访问的数据结构。我在实际工程中很少直接用顺序查找因为大多数场景下用哈希表就能把查找降到O(1)。但哈希查找也不是万能的。哈希表的构建需要时间内存开销也更大。如果数据量很小比如几十个元素直接用顺序查找反而更快因为不需要计算哈希值也不需要处理冲突。我之前做过一个性能优化把一个频繁调用的查找函数从哈希表改成顺序查找结果运行时间下降了不少原因就是这个函数处理的数据量太小哈希表的开销反而成了负担。所以顺序查找模板虽然简单也是有用的int linearSearch(const vectorint a, int target) { int n a.size(); for (int i 0; i n; i) { if (a[i] target) return i; } return -1; }真正值得精讲的是二分查找。它要求数据有序但代价仅为O(log n)在百万级数据里查找也只需要二十多次比较性能极其出色。3.2 二分查找的经典模板必须逐行理解二分查找看起来只有几行代码但细节决定成败。我见过无数版本但自己最终固定下来的是下面这套它把边界条件处理得极其干净。// 标准二分查找查找 target 的下标不存在返回 -1 int binarySearch(vectorint a, int target) { int l 0, r a.size() - 1; while (l r) { int mid l (r - l) / 2; if (a[mid] target) return mid; else if (a[mid] target) l mid 1; else r mid - 1; } return -1; }关键点在于循环条件是l r以及每次l和r更新时都跳过mid本身。这两个细节配合起来保证了不会发生死循环。为什么因为mid的计算是向下取整的如果l r那mid就等于l如果a[mid]不等于targetl会变成mid1或r变成mid-1区间变小循环必然结束。另一个容易踩的坑是mid的计算。我见过有人写mid (l r) / 2这在l和r都很大的时候可能会整数溢出。用l (r - l) / 2就完全避免了这个问题。这是画出经典左闭右闭区间示意图后最容易理解的边界处理方式。3.3 二分查找的四种变体模板标准二分查找之外实际需求往往是变体找左边界、找右边界、找大于等于target的第一个元素、找小于等于target的最后一个元素。C的lower_bound()和upper_bound()就是干这个的但很多人不理解它们的区别。我整理了一套统一的二分变体模板用左闭右开区间来写因为左闭右开的写法在各种变体中逻辑最统一。// 变体一查找第一个 target 的下标lower_bound int lowerBound(vectorint a, int target) { int l 0, r a.size(); // 注意 r 是 size()左闭右开 while (l r) { int mid l (r - l) / 2; if (a[mid] target) r mid; else l mid 1; } return l; }// 变体二查找第一个 target 的下标upper_bound int upperBound(vectorint a, int target) { int l 0, r a.size(); while (l r) { int mid l (r - l) / 2; if (a[mid] target) r mid; else l mid 1; } return l; }这两段代码的写法是统一的r初始化为a.size()而不是a.size() - 1循环条件是l rmid用左中位数即向下取整。a[mid] target时收缩右边界否则收缩左边界。这个模板比标准二分查找好在哪它返回的结果可以直接用作数组下标比如插入位置的语义无需额外判断。还有第三种变体查找最后一个等于target的元素。这个用上面两个函数组合即可[l, r) [lowerBound(a, target), upperBound(a, target))如果l r那里面的所有元素都等于target最后一个就是r - 1。组合复用永远比自己另写一个不容易出错。3.4 浮点数二分查找的特别写法二分查找不只作用于整数数组浮点数域上同样常用典型场景是求方程根、求最优值等。浮点数二分和整数二分有个明显区别就是循环终止条件不能写成l r因为浮点数没有精确相等。// 浮点数二分求单调函数的零点eps 控制精度 double binarySearchFloat(double l, double r) { double eps 1e-9; // 根据题目精度要求调整 for (int i 0; i 100; i) { // 固定迭代次数比 while 更稳 double mid (l r) / 2; if (check(mid)) r mid; // check 是单调函数 else l mid; } return l; // l 和 r 足够接近 }这里我用固定迭代100次来替代while循环原因很实际浮点误差会导致while(true)里的fabs(r-l) eps在某些场景下永远不满足程序死循环。固定迭代次数则完全没有这个风险100次迭代已经能把区间长度缩小到原来的2⁻¹⁰⁰远超任何实际需要的精度。4. 排序查找模板的真实应用场景字符串、结构体与实战4.1 字符串排序和结构体多关键字排序排序模板放到真实数据上时绝大多数不是排整数而是排字符串或结构体。字符串排序很容易被想起“字典序”这个概念但字典序本身也有讲究。C里std::string默认比较就是按字符的ASCII码比较这个符合C语言的strcmp语义。Python里字符串比较也是字典序但大小写与中文需要特别注意默认的字典序是按Unicode码点排的。结构体多关键字排序是最常考的模板变形。比如学生记录有学号和成绩要求按成绩降序排列成绩相同按学号升序排列。这就需要一个自定义比较器。struct Student { string name; int score; int id; }; bool cmp(const Student a, const Student b) { if (a.score ! b.score) return a.score b.score; // 成绩降序 return a.id b.id; // 学号升序 } vectorStudent students /* ... */; sort(students.begin(), students.end(), cmp);这里有个很容易忽略但非常关键的点比较器必须是严格弱序strict weak ordering。简单来说就是cmp(a, a)必须返回false且如果cmp(a,b)和cmp(b,c)都成立cmp(a,c)也必须成立。很多人在比较器中直接写return a.score b.score这样cmp(a,a)返回true破坏了严格弱序的要求会导致sort出现诡异行为比如重复比较、运行时间暴涨甚至越界访问。另外一个实用技巧在多关键字排序时与其写复杂的比较器不如用元组。C17以后可以返回std::tuplePython里天然支持元组排序。# Python 多关键字排序先按分数降序再按 id 升序 students.sort(keylambda x: (-x.score, x.id))这个一行代码的写法非常简洁-x.score负号实现了降序实际上是把降序问题转换成了升序问题。4.2 二分查找在真实开发场景中的应用二分查找在工程中最大的价值之一是在大规模数据中定位问题。比如我在做性能排查时经常需要理解“从什么时候开始系统的某个指标开始异常”。这个问题的本质就是一个二分查找在时间轴上找到一个分界点前面是正常的后面是异常的。热词里提到的poolmon查找内存泄漏也是一个类似思路。系统内存从哪个时间点开始持续增长确认了那个分界时间段就缩小了排查范围。虽然具体工具不同但方法论都是二分定位每次把搜索范围缩小一半用不了几次就能锁定目标。另一个经典场景是数据库索引的B树查找。索引本质上就是一颗多叉的“有序结构”每次比较都能排除大量数据。MySQL的排序和查找之所以能在大数据量下保持高性能靠的就是B树和有序索引结构。理解了二分查找的原理再看B树的查找过程就很容易理解无非是多次二分定位到叶子节点。4.3 算法竞赛中排序查找的组合套路算法竞赛里排序和查找经常作为预处理手段出现让复杂问题迎刃而解。我印象最深的是这样一类经典题给你n个数要求找到某类配对比如“两数之和等于target”。暴力做法是O(n²)但先排序再二分就能降到O(n log n)甚至配合双指针降到O(n)。具体到模板的组合使用先排序然后用双指针在有序数组里寻找配对的技巧。左指针指向开头右指针指向结尾如果两数之和大于target右指针左移小于target则左指针右移相等则找到。这个操作常用在数组问题中类似热词里提到的“sqlserver排序统计”“分组排序”等场景核心思路都是先让数据有序再利用有序性简化后续逻辑。树状数组、线段树等数据结构模板也常和排序结合使用。比如需要动态维护前缀和或区间最值往往先对原数据排序建立偏序关系再套用树状数组。这类组合套路在竞赛题里层出不穷理解和吃透排序查找模板是进行一切后续组合的前提。5. 排序查找模板实战避坑常见问题与排查技巧5.1 边界条件与死循环我踩过最深的坑二分查找的死循环问题值得单独拿出来讲。我用左闭右闭写法时就遇到过一种典型的死循环当l 0, r 1时mid 0如果a[mid] target那么l mid 1 1区间变成[1, 1]继续循环判断a[1]但如果我不小心把更新写成l mid那区间就永远是[0, 1]mid永远是0死循环。这类问题的根源在于mid的计算是向下取整的。当区间只有两个元素时mid一定等于左边界此时如果某条更新路径把左边界设置成mid而不是mid1区间就不会缩小。排查方法很简单打日志或者用一个小数组单步调试。但我更推荐直接规避——严格使用左闭右开模板更新时永远跳开mid这样在数学上就杜绝了死循环的可能。快速排序也有类似的边界陷阱。我遇到过partition返回的下标在极端情况下等于区间端点导致递归区间没有缩小。比如处理两个相同元素时partition可能总是返回区间左端点然后左半部分递归quickSort(a, l, mid-1)是一个空区间没问题但右半部分quickSort(a, mid1, r)就会丢掉一个元素。这也是为什么我的快排模板里用了双指针的partition实现它能保证返回的mid一定处于合适位置不会让单元素区间反复处理。5.2 稳定性与相等元素的处理排序的稳定性在我最初学的时候被严重低估了直到一次项目里真正吃亏。场景是这样的有一批订单数据需要先按日期排序再按优先级排序但要求同优先级的订单仍然按日期从早到晚排列。如果第二次排序用的是不稳定的快排同等优先级的订单相对顺序就被打乱了日期顺序就乱了。正确的做法是第二次排序时用稳定排序。C里用stable_sort()Python里sorted()本身就是稳定的JavaScript的sort()在现代浏览器实现中也是稳定的V8和SpiderMonkey都已经是稳定排序。如果你不确定语言内置排序是否稳定最保险的做法是手动把次要关键词并入比较器。比如上面这个场景比较器直接写成“先按优先级排相等再按日期排”一次性排序完成就不需要依赖后续排序的稳定性了。还有相等元素的另一个陷阱自定义比较器返回错误值也会破坏排序的健壮性。正如前面提到的严格弱序问题如果比较器出现非对称行为比如cmp(a,b)返回true且cmp(b,a)也返回true排序算法可能产生未定义行为。排查这类问题的方法是对比较器做随机的成对测试或者直接检查比较器里有没有、这类符号。5.3 性能陷阱与递归深度递归型排序算法有一个容易被忽视的性能陷阱函数调用开销和栈空间。快速排序在最优情况下的递归深度是O(log n)但如果没有随机化基准面对某些特定数据如几乎有序的大数组或大量重复元素递归深度可能达到O(n)直接导致栈溢出。归并排序的递归版本同样有递归深度问题。线下写代码还好但在算法竞赛或在线评测环境中系统栈通常只有几MB递归深度太深必然爆栈。我的经验是如果你确定要处理的数据量超过百万级别最好先把归并排序改成迭代版本或者改用快排配合随机基准。当然工程上直接调用内置排序最省心内置版本通常做了防爆栈处理且对数据分布做了自适应优化。另一个性能相关的点是排序算法对缓存和分支预测非常敏感。插排在小数据量下快得惊人快排在中大数组上无敌但它们的组合往往最强。这也是为什么很多语言内置排序在数据量比较小时会切换到插入排序。我自己的模板里也保留了这个习惯在quickSort的递归体里如果区间长度小于16直接用插入排序替代快排递归。别小看这个优化实测能带来约20%的整体提速。5.4 模板的本地化适配C、Python、Java的区别我前面给的模板都是C风格但实际工作中语言是多样的模板也需要适配。我总结了一下主要语言里排序查找相关的特性和坑。C的核心是类模板和函数模板。类模板名称不能重复静态成员的定义和处理方式也需要注意。用模板写排序算法时尽量让比较器作为模板参数传入这样可以同时支持自定义类型和无自定义比较器的普通类型。模板函数实例化的过程是编译期完成的不会引入额外运行开销这一点比Java泛型和Python动态类型都更高效。Python注重的是简洁和内置函数。排序直接用sorted()和list.sort()查找直接用bisect模块。bisect_left和bisect_right对应了我前面讲的lowerBound和upperBound用起来非常方便。但我必须提醒Python的排序是稳定的所以多关键字排序优先用元组bisect模块要求操作的数据是已经排好序的而且它的比较逻辑只在查找键和目标之间做比较不会回看元素本身。Java里是Arrays.sort()和Collections.sort()。对基本类型数组的排序用的是双基准快速排序不稳定对对象数组的排序用的是TimSort稳定。所以如果你要对一个复合对象的数组做排序并且需要稳定性一定要用Collections.sort()或Arrays.sort(T[], Comparator)不要用Arrays.sort(int[])。排序的范围和自定义比较器的写法也值得多花点时间记忆。结尾这套模板我还在用说了这么多其实最想表达的是模板不是抄来就算掌握必须反复写、反复测直到形成肌肉记忆。我自己的这套代码前前后后改过很多版本快排从固定基准改成随机基准二分从右闭区间改成左闭右开插入排序被融合进快排的小区间优化每一次修改都是在实际踩坑后做出的改进。现在每次手写排序查找我基本不需要思考代码自动就流出来了。这种状态让你在面试和比赛时能腾出脑子处理真正的问题也让日常开发中的数据处理工作变得非常顺手。如果你也想有这种状态建议把文中的模板抄下来自己跑一遍各种边界测试空数组、单元素、全相等、已经有序、完全逆序。跑通了这套模板就是你的了。后续还可以把归并排序扩展成求逆序对把二分扩展成三分查找或二分答案路会越走越宽。