插入排序Java实现与优化:从原理到面试考点详解
2026/9/24 23:58:36 网站建设 项目流程

写了这么多年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] 等于当前元素,所以循环到 j==0 时 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次。总的比较次数是1+2+...+(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 < jarr[i] > arr[j]。所有逆序对的数量,就是数组的“混乱程度”。

插入排序每处理一个元素,本质上就是在消除它和前面所有元素形成的逆序对。如果数组有k个逆序对,插入排序至少需要k次移动,这也就是为什么完全逆序的数组会拖到O(n²)。每次移动,都是在消灭一个逆序对。理解了逆序对,你就明白了“近似有序数组为什么快”——因为它逆序对本来就少。

希尔排序的出发点正是这个:既然插入排序移动太慢是因为一次只能把一个元素往前挪一位,那如果我让元素先跨过较远的距离进行粗调整,减少数组里的逆序对数量,再让插入排序进行一次精细调整,速度是不是就能上来?

4.2 希尔排序:分组来做“粗调”

希尔排序(Shell Sort)也叫“缩小增量排序”。它先把数组按某个增量gap分成若干组,对每一组做插入排序,然后逐步减小gap,最后gap等于1时相当于做一次完整的插入排序。

举个例子,数组是[9, 5, 8, 3, 7, 6],取gap=3,那么下标0、3为一组,1、4为一组,2、5为一组,三组分别做插入排序后,数组变成[3, 5, 6, 9, 7, 8]。注意这时7和8的相对位置已经比原来靠前了。然后取gap=1,做最后一次整体插入排序,很快就能排完。

希尔排序的代码实现很简单,本质上就是在插入排序外圈加了一层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()非常接近,甚至更快。这就是插入排序在小规模数据上的统治力。


我个人在实际操作中的体会是:排序算法不能只背代码,尤其插入排序这种“代码简单、逻辑密”的算法,最好能拿着扑克牌在桌子上摆一遍,再用代码复现一遍,最后再把复杂度推导一遍。三轮下来基本就忘不了了。最后再送大家一个小技巧:面试如果要求手写排序,先不要急着动笔,先把数组分成“已排序区”和“待排序区”画出来,标出每一轮的变化,再落代码。这样写出来的代码不仅清晰,还能避免“先写再改”带来的各种低级错误。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询