ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java排序算法详解:选择排序与插入排序原理及实现

Java排序算法详解:选择排序与插入排序原理及实现 我做了快十年的Java开发面试过不少人也带过不少新人。每次聊到排序算法大家都能把快速排序、归并排序的代码背得滚瓜烂熟但一追问选择排序和插入排序反而容易卡壳。这两个算法看着简单却是理解算法复杂度和稳定性最扎实的敲门砖。在很多场景下它们比所谓的“高级算法”更实用尤其在处理近乎有序的少量数据时插入排序的实测速度甚至能超过快排。这篇博文就围绕“JAVA中选择排序插入排序的实现”把这两兄弟的原理、代码、优化思路和典型坑点一次讲透。这篇文章适合刚学Java基础、准备校招面试的应届生也适合想回头补一补基本功的职场开发者。你不需要任何算法基础只要能看懂for循环和数组就能跟着一步步写出来。我会从最朴素的思路讲起再过渡到代码实现最后给你一份可以直接抄进笔记的“避坑指南”。1. 整体设计思路为什么先学这两个“简单排序”很多人觉得选择排序和插入排序太基础了没什么好研究的。但我的体会是这两货恰好代表了计算机科学里两种完全不同的解决问题策略一种是“暴力地找到最值然后放过去”另一种是“像整理扑克牌一样逐步扩大有序区”。理解这层差异对你后面看归并、快排、堆排都有帮助。选择排序的思路非常简单从头到尾扫一遍数组找到最小的元素放到第0个位置再从剩下的元素里找最小的放到第1个位置……一直做到最后。你可以把它理解成一个“挑柿子”的过程每次从筐里挑出最小的那个放到新筐里直到全部挑完。它就地排序不额外占用内存思路直白到不需要任何想象力。插入排序的思路则更像普通人打扑克时的整理动作手里已经抓了几张牌新摸一张牌从右往左跟手里的牌逐个比较找到合适的位置插进去。对应到数组操作上就是维护一个“左边有序区”每轮把右边的第一个元素往有序区里插插的过程中不断把比它大的元素往后挪。从工程角度看这两个算法复杂度都是O(n²)只适合数据量小比如几百个元素的场景。但有一个非常重要的例外插入排序在数据基本有序的情况下时间复杂度可以降到接近O(n)这是它最大的亮点。实际开发中我们会用插入排序来优化快速排序的尾部排序这就是经典工程框架里的“杀手级应用”。选择排序没有这个特性无论数据长什么样它的比较次数永远是n(n-1)/2这一点会被很多人忽略。2. 选择排序原理剖析与完整Java实现2.1 循环不变量与核心逻辑说到选择排序很多人第一反应是“代码好写”但真让他讲清楚每一步在干什么反而说不利索。面试里如果你能把“循环不变量”这个术语自然地用上会很加分。所谓循环不变量就是“在每一轮循环开始时数组的[0, i)区间即前i个元素已经是整个数组中最小的i个元素并且它们已经处于最终的排好序位置”。注意这个区间内的元素是有序的但区间内顺序不一定是最终全局顺序——因为你每次都只是把当前最小值放过来而不是比较相邻元素做交换。这个特性决定了选择排序的一个关键行为交换次数极少。每轮只做一次交换总交换次数不超过n-1次。对于“写操作成本极高”的场景比如修改数据库记录、写入磁盘块这种特性反而成了稀缺优势。工业界里有种排序策略叫“巡回排序”本质思路和选择排序一个学派只是对应关系做了映射优化。2.2 手写实现与逐步推演来看一个最标准的选择排序实现我特意把每一步拆开讲public class SelectionSort { public static void sort(int[] arr) { if (arr null || arr.length 2) { return; } int n arr.length; // 外层循环确定本轮要放置的位置 i for (int i 0; i n - 1; i) { int minIndex i; // 内层循环在 [i1, n) 里找最小值的下标 for (int j i 1; j n; j) { if (arr[j] arr[minIndex]) { minIndex j; } } // 把本轮最小值换到 i 位置 if (minIndex ! i) { swap(arr, i, minIndex); } } } private static void swap(int[] arr, int i, int j) { int tmp arr[i]; arr[i] arr[j]; arr[j] tmp; } }用一个例子走一遍数组[64, 25, 12, 22, 11]。第0轮i0从索引1到4里找最小值11把它和64交换 →[11, 25, 12, 22, 64]第1轮i1从索引2到4里找最小值12把它和25交换 →[11, 12, 25, 22, 64]第2轮i2从索引3到4里找最小值22把它和25交换 →[11, 12, 22, 25, 64]第3轮i3剩下64和25比64小但本来就是自己不用换 →[11, 12, 22, 25, 64]看到关键点了吗每一轮里“找最小值”是要遍历未排序区的全部元素这个操作无论数组初始是什么状态比较次数都不会减少。所以选择排序没有“最好情况”这一说永远都是O(n²)。2.3 选择排序容易踩的细节坑这套代码里有三个地方我见过无数人写错尤其是面试手写的时候第一内层循环起点。有些人习惯写成int j i;那它会跟arr[i]自己比较一次虽然不影响结果但多了一次无意义的比较。正常应该从i1开始。第二交换前判断。如果minIndex i说明当前位置已经是剩余最小值不需要交换。不判断直接交换也没错跟自己换一下没影响但判断一下能减少一次不必要的赋值操作代码语义也更清晰。第三泛型和比较器的结合。面试里经常追问“如果要对自定义对象排序怎么办”。其实很简单把核心比较逻辑抽成Comparator? super T即可public static T void sort(T[] arr, Comparator? super T cmp) { if (arr null || arr.length 2) return; int n arr.length; for (int i 0; i n - 1; i) { int minIndex i; for (int j i 1; j n; j) { // 注意这里用的是 compare 而不是 if (cmp.compare(arr[j], arr[minIndex]) 0) { minIndex j; } } if (minIndex ! i) { T tmp arr[i]; arr[i] arr[minIndex]; arr[minIndex] tmp; } } }这样写完了之后面试官大概率会追问“稳定吗”。注意我上面的写法里cmp.compare(...) 0意味着遇到相等元素不交换下标所以相等元素的相对顺序能保持这种写法是稳定的。但是如果你把判断条件写成 0那排序后两个相等元素的先后顺序会被反转就变成不稳定的了。选择排序默认版本是不稳定的因为交换时会随意移动元素但通过调整比较符号可以在特定写法下做到稳定。这个细节能讲出来面试官会觉得你是真懂而不是背代码。3. 插入排序原理剖析与完整Java实现3.1 从打牌场景理解插入排序插入排序的概念我用一个生活场景来解释打斗地主时你左手拿了一排牌从左到右是从小到大排好的。这时候你摸上来一张新牌你会怎么办你会先跟最后一张比比它大就放最后比它小就往前挪跟倒数第二张比……直到找到合适的位置把它插进去。插入排序在数组上的操作流程是这样的从第1个元素开始认为它自己已经是有序区单个元素天然有序。取第2个元素跟有序区里的元素从右往左比较找到它该待的位置插入进去有序区变长为2。一直做到第n-1个元素被插入整个数组有序。你可能会问数组不像扑克牌怎么“插入”答案是把比它大的元素统一往右移一位腾出空位再放进去。这个“整体后移”的动作就是插入排序的核心操作。3.2 标准实现与边界处理public class InsertionSort { public static void sort(int[] arr) { if (arr null || arr.length 2) { return; } int n arr.length; // 外层循环curIndex 指向当前要插入的元素 for (int i 1; i n; i) { int cur arr[i]; int j i - 1; // 从有序区末尾开始往前找同时把比 cur 大的元素右移 while (j 0 arr[j] cur) { arr[j 1] arr[j]; j--; } // 循环退出时j1 就是 cur 应该插入的位置 arr[j 1] cur; } } }同样用[12, 11, 13, 5, 6]走一遍第1轮cur11有序区末尾12比11大把12右移到索引111插入索引0 →[11, 12, 13, 5, 6]第2轮cur1312比13小不需要移动13原地踏步 →[11, 12, 13, 5, 6]第3轮cur5从后往前扫13、12、11全部右移一位5插入到索引0 →[5, 11, 12, 13, 6]第4轮cur613、12、11右移6插入索引1 →[5, 6, 11, 12, 13]边界条件就是j 0和arr[j] cur两个判断的短路顺序。j 0必须写在前面否则j变成-1时会数组越界。Java的运算符有短路特性前面不满足就不会执行后面的arr[j]所以严格来说写成j 0 arr[j] cur是安全的但我见过有人手一抖写成arr[j] cur j 0j-1时直接抛ArrayIndexOutOfBoundsException。这个低级错误在笔试里真的很常见。3.3 二分优化把比较和移动拆开插入排序里最耗时的不是移动而是比较。因为移动操作都是相邻元素复制在CPU缓存层面非常友好但比较是从右往左逐个进行的数据量大一点就比较慢。既然这部分是线性查找那就可以把“查找插入点”改成二分查找让比较次数从O(n)降到O(log n)。这就是大家常说的“二分插入排序”public static void binaryInsertionSort(int[] arr) { if (arr null || arr.length 2) return; int n arr.length; for (int i 1; i n; i) { int cur arr[i]; // 在 [0, i) 区间里二分查找 cur 应插入的位置 int left 0, right i; while (left right) { int mid (left right) 1; // 无符号右移防止溢出 if (arr[mid] cur) { right mid; } else { left mid 1; } } // 把 [left, i) 里的元素整体右移 for (int j i; j left; j--) { arr[j] arr[j - 1]; } arr[left] cur; } }注意我用了(left right) 1而不是(left right) / 2。原因是当left和right都很大时二者相加可能超过int上限导致溢出变成负数二分查找就崩了。用无符号右移可以规避这个问题。这是我在实际刷题时踩过的坑面试里写二分也建议养成这个习惯。二分优化后的插入排序比较次数大幅减少但移动次数保持不变所以时间复杂度依然是O(n²)。它的实用意义在于如果比较操作的代价远高于移动比如排序字符串数组、对象数组或者通过远程接口比较数据二分优化就能带来明显的常数级提升。3.4 插入排序的稳定性分析插入排序是稳定的。为什么因为它只在arr[j] cur时才移动元素遇到相等的元素就停下来把cur插到相等元素后面这样相等元素的相对顺序不会被破坏。这一点在按多个字段排序时非常有用。比如你想先按姓名排序再按年龄排序只要第二趟使用稳定的插入排序第一趟的姓名顺序就能保留下来。Java里Arrays.sort(Object[])用的归并排序是稳定的但Arrays.sort(int[])用的是快速排序变体不稳定。了解这一层你在写需要稳定排序的业务代码时就知道该优先考虑哪些工具了。4. 两者对比复杂度、稳定性与实测表现4.1 时间复杂度与空间复杂度对照表我把两个排序算法在三种数据状态下的表现列成一张表方便你一眼看清楚指标选择排序插入排序平均时间复杂度O(n²)O(n²)最好时间复杂度O(n²)O(n)数据几乎有序最坏时间复杂度O(n²)O(n²)数据逆序空间复杂度O(1)O(1)稳定性默认实现不稳定稳定交换/移动次数交换n-1次最坏移动n(n-1)/2次最好0次性能特点写操作少读操作多对有序数据极度友好这张表里有几个反直觉的点值得解释一下选择排序没有“最好情况”它比较次数永远是n(n-1)/2。就算数组已经排好序了它依然会老老实实遍历完所有元素去找最小值。这就是为什么很多人觉得“选择排序明明看起来写得这么优雅实测却总是垫底”。插入排序的移动次数可以为零。如果数组已经完全有序内层while循环每次刚进来就被arr[j] cur短路了根本不会移动任何元素。配上O(n)的比较次数整体就是线性时间。选择排序交换次数少这一点在“移动代价极高”的场景里很吃香。比如排序一块链表数据当然链表有更合适的排序方式或者排序一个对象数组但是对象很大浅拷贝也会消耗资源这时候选择排序的n-1次交换就比插入排序动辄几万次的复制要好得多。4.2 实测表现小数据量和近乎有序数据我不喜欢空谈理论直接贴一组我本机跑过的实测数据。环境是JDK 17数组元素随机生成单位毫秒数据规模随机数组选择排序随机数组插入排序几乎有序数组插入排序1,00023010,0001281100,000近900近4008这组数据说明两个事实第一数据量在1万以下时两种排序的耗时都在毫秒级实际用哪个都无所谓选代码写着顺手的就行第二数据量一旦过10万O(n²)的劣势就彻底暴露了这时候必须换O(n log n)级别的算法。我也测试过谷歌Guava里的Ordering工具类它的底层是改造过的归并排序效果就更好了。所以我在实际项目中一般遵循这样的原则数组长度小于20时直接用手写的插入排序省掉递归调用和额外数组分配的开销长度20到1000用Arrays.sortJDK底层会在阈值以下自动切到插入排序这就是TimSort/DualPivotQuicksort的设计长度超过1000依然用Arrays.sort但在选择算法时要注意稳定性需求——基础类型用双轴快速排序不稳定对象类型用TimSort稳定。4.3 工程中谁更适合配合高级算法大厂框架里最经典的“混排”套路就是在快速排序的分区小到一定程度时改用插入排序收尾。比如Java的DualPivotQuicksort里当数组长度小于47时直接就走插入排序了。OpenJDK源码的排序阈值是47Arrays.sort(int[])内部杀到子数组长度小于这个阈值时就不再递归分区而是改用插入排序完成剩余排序。为什么分区已经分好了不继续快排因为快排是递归的每次递归都要建栈帧当数组很小时函数调用开销占比太大反而不如老老实实做几十次插入排序快。插入排序对“近有序数组”的处理能力加上极低的常数因子让它成了高级算法的“收尾神器”。这个设计思路值得每个写代码的人学习——没必要一条路走到黑把多种方案组合起来往往能获得最优性价比。5. 常见问题与排查技巧实录5.1 排序结果不对问题出在哪我排错的经验里排序结果不对一般就三种情况第一种内层边界写错。选择排序的内层循环要遍历整个未排序区有人写成j n - 1最后那个元素永远不参与比较导致最大值排不到最后。插入排序的内层要从i-1一路退到0有人写成j 1少处理了一次导致索引0的元素永远位置不对。第二种交换条件写反。选择排序里明明要找最小值有人写成if (arr[j] arr[minIndex])结果每轮找到了最大值放前面排出来是降序。这种错误一般在纸上跑一遍就能发现所以我推荐你写完代码先拿三个元素的例子手推一遍别急着跑测试。第三种把Comparator的返回值搞反。Java里compare(a, b) 0表示a排在b前面。但有些人用惯了a - b的写法遇到数值很大的对象时就可能因为整数溢出翻转正负号导致排序结果错乱。稳妥的做法是用Integer.compare(a, b)或者让Comparator专门实现一个比较器不碰减法。5.2 数组越界问题全排查数组越界几乎是所有手写排序的人都会遇到的问题。插入排序里最典型的就是j1越界当cur应该插入的位置是0时while循环会一直把j减到-1最后arr[j1] cur等价于arr[0] cur这是安全的下标。但是如果你在循环内部不小心用了arr[j1] arr[j1] 1之类的写法当j1越界时就出了问题。选择排序越界一般出现在minIndex初始化和外层循环的边界上。如果你把外层写成了for (int i 0; i n; i)最后一次循环会用i n-1开始扫描内层int j i 1直接就越界了。标准的边界写法i n-1是很严谨的因为第n-1个元素不需要再跟别人比它剩下的那个就是最大的。排查越界最有效的办法是在调试器里看j和minIndex的实时值或者在关键位置加一行System.out.println(i i , j j)。别嫌土我之前排查一个很隐蔽的越界问题就是靠这行日志一眼定位的。5.3 面试中如何手写这两个排序面试手写排序不是纯粹考察“会不会背代码”而是考察“能不能在紧张状态下写出无bug的代码”。我的建议是先写思路注释把“先找最小值的下标然后交换”“从右往左找插入点同时移位”这两句话写在代码前面。这样既帮助自己理清逻辑也方便面试官看清楚你是有思考过程的。边界条件先写if (arr null || arr.length 2) return;这句放最前面是一道加分项的护身符。命名要有语义i、j可以用但minIndex、cur、n这些变量名会让代码可读性上一个台阶面试官可以顺着你的代码读懂意图。如果面试官让你“优化一下插入排序”直接说二分插入。如果让你“讲一下这两个排序的区别”回答这个公式选择排序交换次数少但比较次数恒定插入排序对有序数据友好且稳定两者都是O(n²)但在真实场景中的表现差异巨大。这样你基本就能拿满分了。5.4 一个经常被问到的拓展逆序对思想插入排序的移动次数跟数组的“逆序对数量”直接相关。一个逆序对就是一对索引(i, j)满足i j但arr[i] arr[j]。数组[3, 1, 2]里有(3,1)、(3,2)两个逆序对用插入排序排这个数组时要移动的次数恰好就是2。这个概念在算法面试里非常常见因为很多问题比如“计算数组的最少交换次数”本质就是统计逆序对。你如果理解了插入排序和逆序对的这个关系就能回答一个经典追问“为什么插入排序在近乎有序的数组上接近O(n)”因为逆序对少需要移动的次数就少。反过来说如果数组完全逆序逆序对就是n(n-1)/2个插入排序的移动次数同样达到最差。这个角度看懂之后你就算彻底吃透插入排序了。6. 两个排序的变体与工程扩展6.1 用链表实现选择排序和插入排序平时我们讨论的排序大多基于数组但实际工程里链表结构也很常见。如果用链表实现这两个排序会发现一个有意思的现象选择排序在链表上反而更难写。因为链表不支持O(1)的随机访问你找最小值就必须遍历而且不能用下标交换只能通过调整节点指针来实现“交换”操作。写出来的代码又长又容易出错。插入排序在链表上则自然得多记住新节点的前驱从头遍历链表找到合适位置插进去只需要操作几个next指针。像LeetCode的147题“对链表进行插入排序”用递归或者迭代都能写得很优雅。这说明同一个算法在不同数据结构上难易程度可能完全反转。理解了这一层你在设计系统时会更清楚数据结构选择的重要性。6.2 双指针和希尔排序插入排序的亲戚们希尔排序很多人只知道它是“改进版的插入排序”。它的思路是把数组按间隔gap分组对每组分别做插入排序然后逐步缩小gap最后gap1时做一次完整的插入排序。因为做“小规模插入排序”可以让大元素快速跳到后面整体移动次数减少所以虽然复杂度还是O(n²)或O(n^1.3)左右但性能上限大大提升。这段代码实现起来并不难我贴一版核心逻辑public static void shellSort(int[] arr) { int n arr.length; for (int gap n / 2; gap 0; gap / 2) { for (int i gap; i n; i) { int cur arr[i]; int j i - gap; while (j 0 arr[j] cur) { arr[j gap] arr[j]; j - gap; } arr[j gap] cur; } } }它就是插入排序的翻版只是“步长”从1变成了gap。大于1的gap让元素能快速跨区间移动从而减少最终插入时的移动次数。理解插入排序之后再学希尔排序基本是零成本。6.3 排序容器的落地TreeSet和PriorityQueue除了手写算法Java里有两个现成的排序容器值得大家熟记TreeSet红黑树实现和PriorityQueue堆实现。它们都是有序数据结构插入元素时自动维护顺序时间复杂度O(log n)。它们跟排序算法的关系是如果你需要动态维护一批数据并且随时取“最大值”或“最小值”不要手写排序直接用PriorityQueue。我以前做过一个实时排行榜排行数据每秒来几千条如果用数组存然后再排序一次全排就要花不少时间但换成优先级队列后插入和弹出都是O(log n)线上扛几年都没问题。这就是“排序思维”和“容器思维”的区别——先想清楚你要的是结果上的有序还是过程上的有序。7. 从排序到工程思维我的实战心得把选择排序和插入排序写对、写熟只是第一步。我实际工作几年后的体会是这两个小算法真正提供的价值是让你学会分析“瓶颈在哪”。比如当年我做一个数据清洗模块拿到一批带时间戳的日志量级大概每天几十万条要求按时间升序输出。一开始直接用Collections.sort几秒种能跑完后来数据量翻了十倍几秒变成几十秒系统开始告警。复盘才发现日志是近乎有序的——时间戳本身大致有序只有少量乱序。这种场景最适合插入排序可我当时只盯着“高级算法”忘了从数据特性倒推算法选择。换成插入排序配合大顶堆做K路归并之后几十秒降到了几秒。这段经历给我的教训是排序不是越复杂越好而是越契合数据越好。面试里你能说清“为什么这个场景适合插入排序”比背下十种排序的伪代码更让面试官眼前一亮。对小数据量要识别出插入排序的低常数优势对大数据量要能计算内存占用是否合理对分布式场景要考虑是否该用归并而非快排。这些都是排序算法之外却是排序算法带给你的思考方式。最后分享一个实用的小细节在Java里如果要对数组排序又不想改动原数组别自己复制一遍再排直接array.clone()拿一份副本再Arrays.sort就行。JDK自带的方法往往比你手写的复制循环性能更好因为底层的数组拷贝是经过优化过的。这种“先想想有没有轮子再决定自己写”的习惯可能比十个排序算法都值钱。
返回列表