ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

插入排序Java实现与优化:从原理到面试考点详解

插入排序Java实现与优化:从原理到面试考点详解 写了这么多年Java如果让我只挑一个排序算法讲给刚入门的朋友听我多半会选插入排序Insertion Sort。别看它在面试八股文里常常只是“三个基本排序之一”这个算法背后的“搬移思想”直接打通了希尔排序、链表插入排序甚至是JDK源码里某些小数组排序策略的逻辑。今天就从头到尾把它拆开讲透包括怎么写、怎么优化、怎么在面试里答出亮点以及我实际调试时踩过的那些坑。这篇文章适合正在准备Java基础面试的人也适合刚学完语法、想认真过一遍经典算法的读者。我会尽量说人话把原理和代码放在一起讲争取让你看完就能自己写出来并且能解释清楚它为什么快、为什么慢、什么时候该用它。1. 插入排序的思路拆解它不是“交换”而是“腾位子”1.1 核心思想像整理扑克牌一样插入排序的思路可以用一个特别生活化的场景来解释你在打扑克左手拿着已经排好序的牌右手摸到一张新牌你需要把这张新牌插到左手牌堆里正确的位置。具体到数组上就是把数组分成两个区左边是“已排序区”右边是“待排序区”。一开始已排序区只有一个元素第一个元素天然是有序的然后每次从待排序区拿第一个元素跟已排序区的元素从右往左逐个比较找到它该待的位置把那个位置之后的元素全部往后挪一位再把拿出来的元素放进去。重复这个过程直到待排序区为空。这里有个关键认知插入排序的核心动作不是“交换”而是“腾位子”。交换是两个元素互相换位置而腾位子是一个元素先拿出来然后后面的元素依次往后移最后把拿出来的元素放进空出来的位置。很多新手写插入排序写不明白就是因为脑子里一直在想着交换没有建立起“先搬移、再放入”的模型。另外一个容易忽略的点是插入排序是“在线算法”也就是说它可以边读入数据边排序不需要等到所有数据都到位。这一点在数据流场景里非常有用后面我会细讲。1.2 为什么它值得你花时间细看有人可能会说插入排序时间复杂度是O(n²)比起快速排序的O(n log n)差远了学它有什么意义我的回答是意义非常大。第一插入排序是理解“减治法”的好模板。减治法就是每次问题规模减小一个固定量然后递归或迭代处理。插入排序每轮只处理一个新元素剩下的问题规模和之前一样只是数组变短了。这个思想贯穿了很多高级算法。第二插入排序“对近乎有序的数据”表现极佳。如果数据本身已经基本有序插入排序每次插入只需要比较一两次整体复杂度可以降到O(n)。这个特性在工程上非常实用很多高级排序算法在处理小规模或近似有序的子数组时都会切换到插入排序。JDK里的Arrays.sort()对长度小于47的基本类型数组使用的就是插入排序的变体。第三插入排序的实现极其稳定不会因为数据分布不均而退化出更差的性能。它没有快排的递归栈风险也没有归并排序的额外内存开销。在数据量小、代码需要极简健壮的场景里它往往是首选。所以不要小看这个“基础算法”它是很多复杂算法的基础积木。2. Java实现从最简版本到两步优化2.1 先写一个最直观的版本先上一个没有做任何微优化的标准版注释我写得详细一点看完这段代码你就能在心里跑起来整个过程。public class InsertionSort { public static void insertionSort(int[] arr) { if (arr null || arr.length 2) { return; } // i 表示待排序区的第一个元素下标 // 初始时下标0已经有序所以从下标1开始 for (int i 1; i arr.length; i) { int insertValue arr[i]; // 先“取出”要插入的元素 int j i - 1; // j 指向已排序区的最后一个元素 // 从右往左找插入位置同时把比 insertValue 大的元素依次右移 while (j 0 arr[j] insertValue) { arr[j 1] arr[j]; // 右移腾出位置 j--; } // 循环结束后j 1 就是腾出来的空位 arr[j 1] insertValue; } } public static void main(String[] args) { int[] arr {9, 5, 8, 3, 7, 6}; insertionSort(arr); for (int num : arr) { System.out.print(num ); } } }调试这段代码时我建议你盯着这三步走一遍第一步取出当前元素存到外部变量第二步把前面所有比它大的元素右移第三步把元素放回空位。我自己带新人时发现只要他能口述出“取数、腾位、放入”这三个词代码基本不会写错。这段代码的时间复杂度很好分析最外层循环执行n-1轮内层while循环最多执行i次比较和搬移总体比较和移动次数大约是n²/2量级所以时间复杂度是O(n²)空间复杂度是O(1)因为只用了一个临时变量属于原地排序。稳定性方面插入排序是稳定的。关键点在while判断里用的是arr[j] insertValue不是arr[j] insertValue。如果两个元素相等我们不会让已排序区里的元素右移而是把新元素放到相等元素后面这样相对顺序不变。我面试新人时经常会问如果把改成会发生什么答案就是排序变成不稳定的了。这是个特别好的细节问题。2.2 带哨兵位的写法省掉一个边界判断基础版本里每轮while循环都要判断j 0。如果能把数组下标0空出来当哨兵就可以少一个边界判断稍微快一点。// arr[0] 作为哨兵位真正的数据从下标1开始存储 public static void sentinelInsertionSort(int[] arr) { // 这里约定 arr[0] 是哨兵位不参与排序 for (int i 2; i arr.length; i) { arr[0] arr[i]; // 把当前要插入的元素暂存在哨兵位 int j i - 1; // 因为 arr[0] 等于当前元素所以循环到 j0 时 arr[0] arr[0] 为 false while (arr[j] arr[0]) { arr[j 1] arr[j]; j--; } arr[j 1] arr[0]; } }这个写法少了一个j 0判断理论上减少了每个元素比较时的条件分支次数。但现实中它有个致命伤数据结构里必须留一个位置当哨兵这个约定在业务代码里往往很难维持。另外如果数组里存的是对象哨兵位需要额外处理null逻辑会比较绕。所以我的建议是笔试或面试时你提一下“我知道有哨兵优化版”然后老老实实写基础版就够了因为基础版更容易让面试官看懂你的思路。工程上JDK源码里的插入排序也没有用哨兵位而是借助局部变量和循环展开来做优化。2.3 二分插入排序把查找插入位置优化到O(log n)既然插入排序每轮都要在已排序区“查找”插入位置那能不能用二分查找快速定位呢当然可以这就是二分插入排序Binary Insertion Sort。public static void binaryInsertionSort(int[] arr) { for (int i 1; i arr.length; i) { int insertValue arr[i]; int left 0; int right i - 1; // 在 [left, right] 区间内找到第一个大于 insertValue 的位置 while (left right) { int mid (left right) 1; if (arr[mid] insertValue) { left mid 1; } else { right mid - 1; } } // left 就是插入位置 for (int j i; j left; j--) { arr[j] arr[j - 1]; } arr[left] insertValue; } }需要注意虽然查找插入位置从O(n)降到了O(log n)但是“把后面元素整体右移”这件事仍然是O(n)的所以总的时间复杂度依然是O(n²)。二分插入排序的优点是减少了比较次数适合那种“比较代价大、移动代价小”的场景。举个例子如果数组存的是字符串对象字符串比较compareTo代价较高这时候二分插入排序就能省下不少比较开销。实际工程里二分插入排序还有个别名叫“折半插入排序”在考研数据结构教材里经常出现。面试时如果你能写出这个优化说明你对“比较”和“移动”这两个成本拆得比较清楚会是个加分项。3. 复杂度分析与横向对比我们不能光会写还要会讲道理3.1 最好、最坏、平均情况怎么算很多博客会说插入排序“最好的情况是O(n)”但没说清楚为什么。我来把推导过程讲明白。最好情况是数组已经完全有序。这时每轮while循环里的arr[j] insertValue第一次比较就为false内存循环直接跳过每轮只做一次比较和一次赋值总共做n-1轮所以时间复杂度是O(n)。最坏情况是数组完全逆序。此时第i轮插入时前面的i个元素都要右移比较次数为i次。总的比较次数是12...(n-1)也就是n(n-1)/2数量级就是O(n²)。平均情况其实也接近O(n²)。你可以这样理解插入排序每轮处理一个元素时插入位置在已排序区前半部分的概率和在后半部分的概率差不多期望需要移动的元素大约是i/2个所以总移动次数还是约n²/4。去掉常数项依然是O(n²)。这里有一个小的面试加分点插入排序的比较次数和移动次数是分开计算的。如果只聊“时间复杂度是O(n²)”那就太粗糙了。说清楚“最好O(n)、最坏和平均O(n²)”再补一句“交换/移动次数同样跟随比较次数变化”才显得你是真的懂。空间复杂度方面无论哪个版本都是O(1)因为它只用了常数个额外变量。这种原地排序算法在内存受限的嵌入式设备上很受欢迎。3.2 与冒泡排序、选择排序的同台竞技既然热搜里很多人把“冒泡排序java”和“选择排序”放在一起搜索我就顺手做个对比。这三个都是O(n²)级别的经典排序面试也经常被放在一起问“它们有什么区别”。排序算法最好时间最坏时间空间稳定性交换/移动特点冒泡排序O(n)O(n²)O(1)稳定相邻元素两两交换每轮把最大值冒到最后选择排序O(n²)O(n²)O(1)不稳定每轮选最小值放到最前交换次数最少插入排序O(n)O(n²)O(1)稳定元素右移腾位把当前值放到正确位置选择排序的不稳定性可能有些人没注意。举个例子数组[5a, 3, 5b, 1]第一轮选择会找到最小值1跟第一个元素5a交换结果变成[1, 3, 5b, 5a]两个5的相对顺序就变了。所以不要默认“简单排序都稳定”。冒泡排序和插入排序在最好情况下都能达到O(n)但冒泡排序的交换次数通常比插入排序的移动次数多。因为插入排序是把多个元素“一起平移”而冒泡排序是两两交换每一步都要三次赋值操作。所以同样是O(n²)在随机数据上插入排序通常比冒泡排序快两三倍尤其在数组比较大的时候。选择排序有一个“优势”它的交换次数永远只有n-1次这是所有排序算法里最少的。如果交换数组中的两个对象代价极高比如对象很大或者交换触发复杂的监听逻辑选择排序的“少交换”特性就非常值钱。所以在某些场景下选择排序反而会被优先选择。这个世界没有“最好的排序”只有“最适合当前场景的排序”。3.3 它真正的战场小数组、近有序、在线数据说完了理论谈点工程场景。插入排序最适合的三类场景如下。第一类是数组规模小。当n小于几十的时候O(n²)和O(n log n)的差距根本体现不出来反而插入排序的代码简单、没有递归调用、没有额外内存分配实测往往跑得更快。JDK源码里Arrays.sort对基本类型数组在长度小于47时就是用插入排序的优化版本对对象类型数组在长度小于32时用插入排序的变体。第二类是数据接近有序。比如一个排行榜每天只更新少量几条记录比如日志系统里的时间戳数组大部分时候都是追加式的偶尔几条被修改。这种情况下插入排序的实际耗时接近O(n)非常高效。第三类是在线实时数据处理。插入排序可以在拿到一个新数据的同时就把它放到正确位置不需要等完整数据集。比如实时展示比分、实时监控里的Top N榜单都可以用插入排序维护一个有序集合。我自己做过一个简单的实时日志排序工具日志一条条到达需要按时间戳排序展示。用插入排序维护一个有序链表每次新日志到达后从头扫描插入位置当天的日志量不到几千条性能完全够用而且代码极其简洁。这种场景就算你拿快排来反而因为需要等数据全量到位后才排序体验更差。4. 从插入排序到希尔排序一个递进的故事4.1 逆序对才是排序性能的根源要想把插入排序理解到骨头里需要明白一个概念逆序对。说白了就是一对下标(i, j)满足i j但arr[i] arr[j]。所有逆序对的数量就是数组的“混乱程度”。插入排序每处理一个元素本质上就是在消除它和前面所有元素形成的逆序对。如果数组有k个逆序对插入排序至少需要k次移动这也就是为什么完全逆序的数组会拖到O(n²)。每次移动都是在消灭一个逆序对。理解了逆序对你就明白了“近似有序数组为什么快”——因为它逆序对本来就少。希尔排序的出发点正是这个既然插入排序移动太慢是因为一次只能把一个元素往前挪一位那如果我让元素先跨过较远的距离进行粗调整减少数组里的逆序对数量再让插入排序进行一次精细调整速度是不是就能上来4.2 希尔排序分组来做“粗调”希尔排序Shell Sort也叫“缩小增量排序”。它先把数组按某个增量gap分成若干组对每一组做插入排序然后逐步减小gap最后gap等于1时相当于做一次完整的插入排序。举个例子数组是[9, 5, 8, 3, 7, 6]取gap3那么下标0、3为一组1、4为一组2、5为一组三组分别做插入排序后数组变成[3, 5, 6, 9, 7, 8]。注意这时7和8的相对位置已经比原来靠前了。然后取gap1做最后一次整体插入排序很快就能排完。希尔排序的代码实现很简单本质上就是在插入排序外圈加了一层gap循环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 temp arr[i]; int j i; while (j gap arr[j - gap] temp) { arr[j] arr[j - gap]; j - gap; } arr[j] temp; } } }这段代码你可以看出来它跟插入排序的结构几乎一模一样区别只在比较和移动时跨越的步长是gap而不是1。希尔排序的时间复杂度分析比较复杂取决于增量序列的选取。不同gap序列下最坏时间复杂度可能是O(n²)、O(n^1.5)、O(n log² n)等。这不是本文重点你只需要记住它的核心思想通过大跨度移动先快速减少逆序对再逐步细化调整。面试时如果被问到“你能说出几个排序算法的优化思路”把冒泡的“加入是否交换标志”、插入的“二分查找优化”、以及“插入排序扩展到希尔排序”这三条线串起来讲一般就能给面试官留下不错的印象。4.3 工程里的真实取舍什么时候不该迷信高级算法有一点我想多说一句不是所有场景都该用快排或归并。我们团队后来维护一个内部配置中心配置项数量通常只有几百个每次变更后需要把配置按key排序输出。最初我用的是Collections.sort()底层是归并排序的优化版本性能当然没问题。但后来发现配置项经常是“大部分未变、极少数新增”的状态于是干脆换成了插入排序维护的有序列表代码少了几十行还省去了每次全量排序的无谓开销。再比如处理大规模数据但内存极小时外部排序External Sort经常用多路归并而内部的小块排序阶段用的就是插入排序或快排的小数组优化。插入排序在小规模数据上的优势是经过工业级调优的JDK代码都认可的不是纸上谈兵。所以你在面试时如果能说出“在数据规模小于某个阈值时插入排序反而比快排好”这种话配合JDK源码里的具体数值比如小于47用插入排序会显得你不仅会背八股文还真的研究过源码。5. 面试考点与常见问题排查实录5.1 面试官最常问的“插入排序四大问”我面试过不少候选人也帮朋友模拟过面试围绕插入排序的高频问题基本集中在下面四个。第一问手写插入排序要求一次写对。这个问题看起来简单但挂人率不低。常见错误包括忘了先保存arr[i]导致被覆盖while循环里忘了j--导致死循环边界条件j 0写成了j 0漏掉了下标0处的比较。我会在下面的“踩坑”部分展开讲。第二问插入排序是否稳定为什么答案是要先区分解释稳定性的定义然后说明稳定是因为相等时不交换。最好顺便补充一句“如果用作为判断条件就会变得不稳定”这句话能体现你真的理解实现细节。第三问插入排序和选择排序哪个更适合链表排序很多面试者会愣住。其实数组适合插入排序链表也一样适合。对于单向链表只需要改变指针指向就能完成插入不需要大规模搬移元素而且不需要额外的辅助空间。所以对链表做插入排序其实是很好的方案这就是LeetCode上“对链表进行插入排序”这道题的核心思路。第四问在什么业务场景下你会主动选插入排序这部分就是在考察工程判断力。可以往“小数组、近似有序、在线数据”三个方向答再结合JDK源码里的阈值说明基本就能过关。5.2 我调试中踩过的坑从死循环到数组越界我刚开始学插入排序时写过一个特别经典的bug版本。当时的while循环条件我写的是while (arr[j] temp)完全忘了j 0这个前置条件。结果当temp比已排序区所有元素都小的时候j会一路减到-1下一轮循环直接访问arr[-1]抛出ArrayIndexOutOfBoundsException。这个bug几乎每个新手都会踩一次。还有一个坑是忘记把arr[i]提前存到临时变量里。如果直接拿arr[i]去和前面的元素比较而前面的元素右移时又会覆盖arr[i]数据就丢了。这也是“先取出来再腾位”这个动作存在的意义。再讲一个只有写优化版本时才会遇到的坑二分插入排序里我在计算mid时用了(left right) / 2。如果left right超过int上限虽然排序场景里数组长度很难达到这个值但面试里聊到就暴露了会溢出。用(left right) 1就能避免。这个细节我在代码里已经写了但很多人会下意识写除法。还有一个隐性问题我特别想强调当数组长度是0或1时任何排序都应该直接返回。很多新手在写排序算法时拿到一个空数组就傻眼了。所以我在所有排序代码入口都加了if (arr null || arr.length 2) return;。这个习惯能帮你少处理很多边界case。5.3 Java自带排序的“隐藏关卡”最后一个实操小技巧Java标准库里的Arrays.sort对基本类型数组采用的是双轴快速排序Dual-Pivot QuickSort对对象类型数组采用的是TimSort一种归并排序的优化版本。但要记住这两种算法在小数组场景下都会回退到插入排序或者插入排序的变体。换句话说就算你以后写业务代码只用Arrays.sort()插入排序的思想也已经在底层默默工作了。明白这一点后你以后看排序性能问题时会更有感觉为什么一个几乎有序的大列表在Java里排序那么快因为底层归并排序检测到连续有序段后效率极高为什么一个小数组排序也能那么快因为底层已经切到了插入排序。我建议你写一个小实验随机生成一个长度为20的数组分别用插入排序和Arrays.sort()跑一万次对比耗时。你大概率会发现插入排序的裸性能其实和Arrays.sort()非常接近甚至更快。这就是插入排序在小规模数据上的统治力。我个人在实际操作中的体会是排序算法不能只背代码尤其插入排序这种“代码简单、逻辑密”的算法最好能拿着扑克牌在桌子上摆一遍再用代码复现一遍最后再把复杂度推导一遍。三轮下来基本就忘不了了。最后再送大家一个小技巧面试如果要求手写排序先不要急着动笔先把数组分成“已排序区”和“待排序区”画出来标出每一轮的变化再落代码。这样写出来的代码不仅清晰还能避免“先写再改”带来的各种低级错误。
返回列表