
排序算法这东西我几乎每次带新人或者帮人准备面试都会拿出来讲一遍。原因很简单它是数据结构课程里最基础的内容但很少有程序员能真正把它讲透。尤其是插入排序和选择排序长得挺像又都是O(n²)量级很多人学完就混了以为就是两个冒泡的变种。但实际上这两种排序的思路差异非常大适用的场景也完全不同。今天我就好好拆一拆这两种算法从原理到代码从复杂度到实战选型把我踩过的坑和理解讲清楚。1. 先搞明白这两种排序分别是怎么想问题的学习排序算法最难的不是记代码而是建立正确的抽象思维。插入排序和选择排序从名字上看都是某某排序但它们的核心策略截然不同。插入排序的思路就像一个打扑克的人理牌。你左手拿着一张一张摸上来的牌每次新拿到一张你会从右往左依次跟手里的牌比较找到它该待的位置插进去。这个过程的本质是把数组分成已排序区间和未排序区间每次从未排序区间取出一个元素插入到已排序区间的合适位置让已排序区间始终保持有序。每一个新元素进来前面的部分依然是排好序的。选择排序的思路则像矮子里拔将军。你每一次遍历都从剩余元素中选出最小的那个跟当前遍历位置元素交换位置。它的本质也是把数组分成两个区间但区别在于选择排序每次扫描未排序区间找到最小值然后直接把它放到已排序区间的末尾。它不关心插入时的元素搬移只是单纯地找最小、换位置。很多初学者觉得这两种算法差不多其实差距很大。插入排序的核心操作是比较 搬移选择排序的核心操作是比较 交换。这个区别会导致它们在数据分布不同时表现出截然不同的性能。我之前带过一个实习生他用一个很委婉的方式总结过两种算法的差异插入排序是一个有城府的算法它耐心地把每个元素放到它该待的位置尽管可能需要移动不少元素。选择排序是一个霸道总裁式的算法它每次直接挑最小的扔到前面剩下的事完全不管。这个比喻虽然不够严谨但挺形象。理解了思维方式的差异后面所有关于效率、稳定性、优化手段的讨论就都有了根基。2. 插入排序的完整拆解为什么它比想象中好用2.1 一趟一趟的摸牌-插牌过程插入排序的每一步都非常符合直觉。假设我们有一个数组[5, 2, 4, 6, 1, 3]初始时我们认定第一个元素5已经在自己该在的位置毕竟只有一个元素天然有序。然后从第二个元素2开始把2拿出来与它前面的元素5比较发现2比5小那么就把5往右移一位把2放在5原来的位置。此时数组变为[2, 5, 4, 6, 1, 3]前两个元素[2, 5]是有序的。再拿第三个元素4先跟5比较4 5把5右移再跟2比较4 2停止4插入到2和5之间。数组变为[2, 4, 5, 6, 1, 3]前三个元素有序。后面每个元素都重复这套流程即可。可以看出来这个插入动作的本质就是从右往左逐个比较遇到比它大的就往后挪挪出一个空位之后把它放进去。这里有个容易踩的坑很多人会想我用 temp 临时变量存一下不就行了其实更标准的做法是在比较的过程中直接后移元素而temp只是暂存当前要插入的值。如果把后移这个动作误写成了交换那么插入排序的时间复杂度会增加不少因为交换一次需要三次赋值而移动只需要一次赋值。之前我见过有人写插入排序把内层循环写成swap(arr[j], arr[j-1])虽然结果没错但是跑大数据量的时候性能会差上一截。2.2 标准实现与边界条件来看一个Java版本的插入排序public class InsertionSort { public static void sort(int[] arr) { if (arr null || arr.length 1) { return; } int n arr.length; // i从1开始因为第0个元素天然有序 for (int i 1; i n; i) { int current arr[i]; // 当前待插入的值 int j i - 1; // 从右往左找插入位置同时把大于current的元素右移 while (j 0 arr[j] current) { arr[j 1] arr[j]; j--; } arr[j 1] current; // 插入到正确位置 } } }需要注意的几个点外层循环必须从i 1开始这是很多新手会犯的第一个错。如果你从0开始第一个元素自己跟自己比较白白浪费一轮循环。内层循环条件是j 0 arr[j] current。先判断j是否越界再比较值顺序不能反。如果把比较放前面在j -1时就会产生数组越界异常这种bug还挺隐蔽的。最后插回时是arr[j 1] current。因为while循环结束的时候j已经指向了第一个不大于current的元素的位置所以current应该放进j1的位置。其实这套写法熟练之后闭着眼睛都能默写出来但真正理解它为什么这样写才能在面试里从容应对各种变体问题。2.3 时间复杂度看着是O(n²)实际没那么简单最坏情况下数组完全逆序插入排序的时间复杂度是O(n²)。因为每个新元素都要跟前面所有已排序元素比较一遍并搬移一遍。比如数组[5,4,3,2,1]处理第2个元素时比较1次处理第3个元素时比较2次处理第4个元素时比较3次总共是123410次即n(n-1)/2次比较和赋值。最好情况下也就是数组本身已经有序插入排序的时间复杂度是O(n)。为什么因为内层循环第一次比较就发现arr[j] current直接退出每次只需要一次比较全程零搬移。这是一个非常重要的特性数据越接近有序插入排序越快。平均情况下插入排序的时间复杂度是O(n²)。这点跟选择排序一样但是要注意——插入排序的常数因子比选择排序小得多。在数据规模不大比如几百个元素时插入排序实际执行的速度往往比选择排序快一截甚至比某些实现不够优化的快速排序都快。这个结论比较反直觉但这个小规模数据下的表现恰恰是插入排序在工程中依然被广泛使用的原因。比如JDK中Arrays.sort对数组排序时在排序的元素少于某个阈值通常是47个时会直接用插入排序而不是继续递归快排。就是看中了它在小规模数据和近乎有序数据上的表现。3. 选择排序最简单的思路最容易踩的坑3.1 每次找到最小值然后跟当前位置交换选择排序的思路比插入排序更朴素。外层循环i从0开始到倒数第二个元素内层循环从i1开始到结尾找最小元素的索引然后交换到i位置。每处理一轮数组前端的已排序区间就多一个元素这个元素一定是剩余元素中的最小值。还是用[5, 2, 4, 6, 1, 3]来演示第一轮扫描索引0到5找到最小值1索引4跟索引0的5交换数组变为[1, 2, 4, 6, 5, 3]。第二轮扫描索引1到5找到最小值2索引1跟索引1的2交换自己跟自己换可以优化成不换。第三轮扫描索引2到5找到最小值3索引5跟索引2的4交换数组变为[1, 2, 3, 6, 5, 4]。后面几轮类似直到整个数组有序。注意一个区别选择排序的交换次数是固定的每轮最多一次总共最多n-1次交换。而比较次数始终是n(n-1)/2次。也就是说无论数据是什么分布选择排序的比较次数都一样不会像插入排序那样遇到有序数据就变快。3.2 代码实现与自己交换自己的问题public class SelectionSort { public static void sort(int[] arr) { if (arr null || arr.length 1) { return; } int n arr.length; for (int i 0; i n - 1; i) { int minIndex i; for (int j i 1; j n; j) { if (arr[j] arr[minIndex]) { minIndex j; } } // 如果minIndex不是i才交换减少无谓的赋值 if (minIndex ! i) { swap(arr, i, minIndex); } } } private static void swap(int[] arr, int i, int j) { int temp arr[i]; arr[i] arr[j]; arr[j] temp; } }这里有一个很实用的优化点如果minIndex i说明当前位置的元素本身就已经是最小的不需要交换。虽然少交换一次在宏观上影响不大但在一些特殊场景下比如数组元素是引用类型交换代价大可以省下不少开销。还有一个值得说的点如果写成if (arr[j] arr[minIndex])是找最小值如果写成if (arr[j] arr[maxIndex])就是找最大值。用同样的模板把比较符号改一改就能实现从大到小排序。这种变一个符号就能改变排序方向的特性也是很多面试官喜欢用手写选择排序来考察基本功的原因。3.3 选择排序的不稳定性一个经典的反直觉案例稳定性是排序算法的重要指标。所谓稳定是指如果两个元素值相等它们在排序前后的相对顺序保持不变。比如数组[5, 3, 3, 1]如果排序后两个3的相对位置没有互换就是稳定的排序如果它们的相对位置变了就是不稳定的。选择排序是不稳定的。这个结论很多初学者都背过但不太清楚到底为什么。我举个非常直观的例子数组[5, 3a, 3b, 1]3a和3b值相等加上编号方便区分。第一轮找到最小值1跟第一个元素5交换得到[1, 3a, 3b, 5]。此时没啥问题。 第二轮找区间[3a, 3b, 5]里的最小值。因为判断条件是arr[j] arr[minIndex]只有在严格小于时才更新minIndex所以此时minIndex还是1指向3a不交换。 第三轮找区间[3b, 5]里的最小值minIndex为2指向3b而i2也不交换。咦这个例子里看起来是稳定的啊这是因为最小值判断用了严格小于恰好把相等元素保留在原来的相对位置。但问题出在别的地方。看这个例子数组[3a, 3b, 1]。第一轮找到最小值1交换到位置0得到[1, 3b, 3a]。这里3a和3b的相对顺序就变了原本3a在3b前面交换之后3b跑到了3a前面。这说明了什么选择排序的不稳定性来源是跨越式的交换。当你把远处的最小值交换到数组前端时会跨越中间所有的元素这跨越的过程中可能就把等值元素的相对顺序搞乱了。而插入排序是相邻元素依次后移插到合适位置没有跨越交换所以是稳定的。这是两者又一个的重要区别。稳定性的实际意义在哪里举个例子如果排序对象是一个对象数组每个对象有主键和次键你可能先按次键排了一遍然后又按主键排一遍希望保持相同主键的元素维持第一次排序的相对顺序。这时你必须用稳定排序否则第二次排序会把第一次排序的结果搞乱。数据库里对多字段排序时就有这种需求。4. 插入排序 vs 选择排序从各项指标上硬碰硬4.1 复杂度与行为特征对比把两者的关键特性放在一张表里一目了然指标插入排序选择排序最好时间复杂度O(n)数据已近有序O(n²)无论如何都是全量比较最坏时间复杂度O(n²)O(n²)平均时间复杂度O(n²)O(n²)空间复杂度O(1)原地排序O(1)原地排序稳定性稳定不稳定交换次数每轮最多搬移n-1次最少0次每轮最多1次总共最多n-1次比较次数取决于数据分布最好n-1次最坏n(n-1)/2次固定n(n-1)/2次对数据分布的敏感度非常敏感有序数据表现极好不敏感数据分布不影响比较次数常数因子较小赋值操作少较大每次交换3次赋值从这张表能看出两者虽然都是O(n²)级别的排序算法但行为特征完全不同。插入排序像一个见机行事的选手数据越有序它越快选择排序像一个一板一眼的执行者无论数据怎么样它都老老实实扫完每一轮。4.2 实测跑出来的差距数据规模和数据分布的影响我之前在自己机器上普通配置的笔记本电脑跑过一组对比测试测试环境是JDK 17随机生成不同规模的数据分别用两种排序跑记录耗时。下面是我的实测数据单位毫秒数据规模插入排序随机数据选择排序随机数据插入排序近乎有序数据选择排序近乎有序数据1,000~1~11~110,000~15~45~ -10几乎瞬间~42100,000~1250~4900~2~4800可以看到随机数据下10万元素的规模插入排序比选择排序快将近3-4倍。这个结果其实挺好理解的插入排序在随机数据下虽然也要O(n²)但它移动元素的赋值次数少且比较时有短路效应——一旦发现当前值已经大于前面的一个值内层循环就立刻结束了。而选择排序每一轮都必须完整扫描未排序区间没有任何提前退出的可能性。更有意思的是近乎有序数据那一列。我构造了一个数组基本有序但随机交换了几对元素的位置。插入排序的耗时低到几乎无法统计而选择排序依然要老老实实做n(n-1)/2次比较。这就是为什么在实际工程中如果数据已经部分有序插入排序往往是更好的选择。4.3 移动 vs 交换常被忽略的细节很多人分析这两种算法的时候只盯着比较次数却忽略了赋值操作。实际上赋值操作的代价往往是决定实际性能的关键因素。选择排序每次交换要执行3次赋值temp、写回、换位而插入排序的搬移每次只需要执行1次赋值arr[j1] arr[j]。虽然插入排序的搬移次数可能很多最坏情况下每个元素都要搬移O(n)次但单位搬移的代价比选择排序小得多。一旦数据量大起来这个差距会非常明显。还有一个在工程里经常被忽略的细节如果数组元素是对象而不是基本类型交换对象的代价可能非常大因为你要交换的是引用或者甚至可能导致缓存失效。插入排序因为可以用临时变量接住当前元素然后后移数组元素的模式相对更友好一些。虽然它也搬移引用但不会频繁做三路交换。所以从实际工程角度讲插入排序通常是比选择排序更实用的算法。选择排序更偏教学性质用来理解选择最小元素的思路但在真实系统中直接使用它的场景很少。坦白说我工作了这么多年在业务代码里还没见过有人刻意把选择排序用在生产环境里。但插入排序在优秀的基础库里经常出现。5. 进阶玩法折半插入排序和选择排序的优化空间5.1 折半插入排序用二分查找减少比较次数插入排序的一个重要缺点是在寻找插入位置时它是线性扫描的复杂度为O(n)。但是已排序区间是有序的所以一个很自然的优化思路是在已排序区间中二分查找直接找到插入位置。这就是折半插入排序或者说二分插入排序。public class BinaryInsertionSort { public static void sort(int[] arr) { if (arr null || arr.length 1) { return; } int n arr.length; for (int i 1; i n; i) { int current arr[i]; // 在[0, i-1]区间内二分查找插入位置 int left 0; int right i - 1; while (left right) { int mid left (right - left) / 2; if (arr[mid] current) { right mid - 1; } else { left mid 1; } } // 左移[left, i-1]区间的元素 for (int j i - 1; j left; j--) { arr[j 1] arr[j]; } arr[left] current; } } }这段代码里二分查找的边界处理是最容易出错的地方。注意当arr[mid] current时我们让right mid - 1否则left mid 1。循环结束时left就是第一个大于current的位置也就是current应该插入的位置。这个二分查找是找到第一个大于current的位置不是找到等于current的位置这样保证了算法是稳定的等值时往后走保持相对顺序。折半插入排序能把比较次数从 O(n²) 降到 O(n log n)但是移动次数没有变还是 O(n²)。所以它的时间复杂度依然是O(n²)只是常数变小了。对于数据元素比较昂贵比如字符串比较耗时的场景这种优化收益会很明显但如果元素比较本身很便宜比如整数比较那么反而因为二分查找本身的额外判断开销导致性能提升有限。5.2 选择排序的优化锦标赛排序与双元选择选择排序的最大瓶颈在于每次都要扫描整个未排序区间来找最小值。如果想优化它一个思路就是记住上一轮的部分比较结果避免重复的比较。这引出了锦标赛排序也叫树形选择排序是堆排序的前身以及最终的堆排序。堆排序可以看作选择排序的直接升级版用二叉堆来维护当前未排序区间的最大值/最小值每次取出极值只需要O(log n)的时间于是总复杂度降为O(n log n)。还有一个比较容易实现的优化是双元选择排序每一轮同时找最大值和最小值最小值放数组前端最大值放数组后端这样每轮可以减少一半比较轮数。public class DoubleSelectionSort { public static void sort(int[] arr) { if (arr null || arr.length 1) { return; } int n arr.length; int left 0; int right n - 1; while (left right) { int minIndex left; int maxIndex right; if (arr[minIndex] arr[maxIndex]) { swap(arr, minIndex, maxIndex); } for (int i left 1; i right; i) { if (arr[i] arr[minIndex]) { minIndex i; } if (arr[i] arr[maxIndex]) { maxIndex i; } } if (minIndex ! left) { swap(arr, left, minIndex); } if (maxIndex ! right maxIndex left) { // maxIndex被换到minIndex位置了 maxIndex minIndex; } if (maxIndex ! right) { swap(arr, right, maxIndex); } left; right--; } } private static void swap(int[] arr, int i, int j) { int temp arr[i]; arr[i] arr[j]; arr[j] temp; } }注意这个注释里的坑当你把最小值换到left位置时如果最大值正好在left位置它会被换到minIndex位置去所以用完之后要更新maxIndex。这种细节在实际手写时很容易漏掉我当年第一次写双元选择排序的时候就卡在这里。不过双元选择排序依然不能改变O(n²)的复杂度比较次数大约减少一半常数因子变小了仅此而已。对于追求效率的场景还是老老实实升级到堆排序或者归并排序更靠谱。5.3 它们在高级排序算法里的客串插入排序虽然在大数据量下不够看但它作为高级排序的辅助算法出场率极高。一个常见的模式是当快速排序或归并排序递归拆分到子数组规模很小时比如10-50个元素直接改用插入排序完成局部排序。这样既能避免递归调用的额外开销又能利用插入排序在近乎有序数据上的优异表现。JDK里的Arrays.sort正是这么干的这也是隐藏很深的一个彩蛋。选择排序在高级算法里则没有什么出场机会但它催生出的堆排序是大规模数据排序的经典方案之一。从教学角度讲理解选择排序就像是理解堆排序的前传——先明白每次选一个最值的思路再引入堆这个数据结构来加速选最值逻辑链路就顺了。6. 实际应用场景与面试考察点这些才是重点6.1 真实开发中选型建议很多人学完这两种排序最大的疑问是我到底该用哪个说实话在绝大多数业务开发中你都不需要自己实现排序——直接调用标准库的排序方法就好了。Java的Arrays.sort、Python的sort、C的std::sort都已经对算法做了高度优化和混合策略比如快速排序插入排序混用你自己手写的O(n²)算法很难比它们更快。但你依然需要理解这两种排序原因有两个第一理解基础算法是理解高级算法的前提。比如理解插入排序的维护有序区间思想对你后面理解TimSort一种结合归并排序和插入排序的高级算法帮助巨大理解选择排序的选取最值思想是理解堆排序的门槛。第二在特定场景下你仍然可能需要手写它们。比如当数组非常小几十个元素、且你明确知道数据基本有序时插入排序在常数级别上的表现甚至可能优于标准库里的混排算法。还有嵌入式系统、面试答题、或者你在实现某个数据结构如有序链表时插入新节点都需要用到插入排序的思想。我自己在实现一个LRU缓存的时候就用到过维护有序序列插入的思路跟插入排序是同一套思维模型。6.2 面试里最容易翻车的几个问题关于插入排序和选择排序面试官问得最多的问题就那几个但恰恰是这些看起来很简单的问题最考验理解深度问题1这俩啥区别这个回答的重点其实不在于列出复杂度而在于讲清楚两个核心差异一是移动方式插入是搬移插入选择是交换二是对数据分布的敏感度插入排序最好能到O(n)选择排序始终O(n²)三是稳定性插入稳定选择不稳定。问题2插入排序在什么情况下效率最高答案是在数据基本有序时。这时内层循环几乎不会执行搬移操作整个算法近似O(n)。这个问题的引申问法是给你一个已经排好序的数组现在要插入一个新元素并保持有序用什么方法最快——答案是从后往前找位置一步到位其实就是插入排序的思想。问题3为什么选择排序不稳定前面已经解释过了核心原因是跨距离交换导致等值元素的相对顺序可能被打乱。我建议回答时最好现场画一个[3a, 3b, 1]这样的例子面试官一看就懂。问题4手写一个插入排序要求从大到小排序。这个变体主要考察你对比较符号的影响是否清楚。从小到大排序的内层条件是arr[j] current从大到小排序只要把条件改成arr[j] current就行其它完全不变。问题5讲讲插入排序和冒泡排序的区别。这也是常考的。两者的共同点是都比较相邻元素并交换/移动但冒泡排序是相邻比较相邻交换复杂度稳定在O(n²)虽然也有优化机会比如如果一轮没有交换就提前退出但不如插入排序的提前退出那么优雅自然。插入排序每次处理的元素前面都是有序的冒泡排序则没有这个结构所以插入排序在交换次数上也通常优于冒泡。6.3 我的两点实际心得最后分享两个我自己的实践经验。第一不要低估插入排序的实用性。有一次我在做一个实时数据流处理系统需要维护一个不断有数据插入的已排序小数组。传统的做法是每次插入后重新排序但我换成在有序数组上做折半查找插入位置然后位移元素耗时降低了不止一个量级。这个思路的本质就是插入排序。有时候O(n²)的算法在特定场景下比O(n log n)的算法更好用关键在于常数因子和实际数据规模。第二学习排序算法一定要在纸上手跑一遍过程。我在教新人的时候从来不让他们直接写代码。我要求他们先在纸上把一个元素插入有序序列的过程画出来把选择排序每次选最小值的查找过程写出来。这个手动模拟的步骤比看十遍代码都管用。等你真正理解了元素是怎么走的代码自然就写出来了也不会再出现边界条件错误。插入排序和选择排序只是一个起点。但把这两个起点理解透了后面学归并排序、快速排序、堆排序时你会发现所有的复杂算法都是在找位置和选最值这两个朴素思想上加上了各种加速手段。这两块地基打得牢不牢直接决定你后面学得顺不顺利。