先说一个我这两年面试实习生和应届生时特别爱问的一道题:有一个已经排好序的数组,现在要往里面插入一个数,保持原来的排序规律不变,你怎么实现?很多人的第一反应是——这不就是排序吗,把新数加进去然后Arrays.sort一下。这个答案不算错,但离“写出让面试官点头的代码”还有一段距离。这篇文章我想把这道经典的Java基础题拆透:从“插入”和“排序”的本质区别讲起,给出一版又一版逐步优化的实现,然后把数组满了怎么办、重复值怎么处理、降序数组怎么兼容这类细节全部覆盖到。无论你是正在刷题准备面试的新人,还是想夯实数据结构底子的老开发,照着这篇的代码走一遍,应该都能彻底吃透这个知识点。
1. 这道题到底在考什么:数组插入的本质是“移动”
1.1 一句话讲清题目
题目给出的条件有三个:一个已排序数组、一个待插入的数、一个原有规律(多数情况是升序)。要求你做一件事:把这个数放进去,放完之后数组依然有序。
听起来简单,但“插入”和“排序”在数组里完全是两件事。排序是在已有元素之间调整相对顺序,而插入是在不破坏当前顺序的前提下,把新元素塞进它该待的位置。放在数组这个数据结构里,插入动作天然包含两步:
- 找出新数应该落在哪个下标位置;
- 把这个位置以及它后面的所有元素整体后移一格,腾出空位,再赋值。
很多初学者把注意力全放在第1步上,忽略了第2步才是数组插入的精髓。为什么?因为数组在内存里是一段连续空间,每个元素紧挨着下一个元素,中间没有“缝隙”。你不可能像链表那样改一下指针就完成插入,只能靠搬动元素硬生生挤出一个位置来。所以这道题表面上在考“你能否找到插入点”,实际上是在考“你对数组存储结构的理解程度”。
1.2 为什么数组插入和链表插入是两码事
我在给初学者讲这块时经常用格子铺打比方。数组就像一排连在一起的储物柜,每个柜子都有编号,你想在1号柜和2号柜之间塞一个新箱子,唯一办法是先把2号柜到最后一个柜子里的东西全部往后挪一格,空出2号柜,再把新箱子放进去。链表就不一样,它是一串散落的箱子,每个箱子里装着一个箭头指向下一个箱子,你要插入新箱子,只需要找到前一个箱子,把它的箭头改指向新箱子,再让新箱子的箭头指向原来的下一个箱子即可,不需要挪动任何已有箱子。
这个区别直接决定了两类结构的插入复杂度:数组插入平均要移动一半的元素,时间复杂度O(n);链表插入找到前置节点后只需要改指针,时间复杂度O(1)(前提是已经拿到了插入位置)。题目选择了数组,等于故意让你处理元素移动这件事,这也是它能在Java面试题里经久不衰的原因——它能把“数据结构基础是否扎实”一次性试出来。
1.3 这类题在笔试和面试中的常见变形
在实际笔试里,这道题通常不会以原样出现,而是换几层皮:
- 给一个ArrayList,要求把元素按排序规则插入(本质上还是数组,只不过扩容逻辑由ArrayList内部代劳);
- 把“数组”换成“字符串数组”,要求按字典序插入;
- 把“升序”改成“降序”,考察你是否会把比较方向写死;
- 把“插入一个数”改成“插入多个数”,考察循环内插入的累积性能;
- 把“数”换成“对象”,按对象的某个属性排序,考察Comparable/Comparator的理解。
题目千变万化,核心永远只有两条:找位置和移动元素。这两点吃透了,变形再大也能应付。
2. 从后往前搬元素:第一版完整解法
2.1 如何找到插入位置:顺序扫描
先把最简单、也最容易让逻辑跑通的办法写出来。假设数组是升序,我从下标0开始逐个比较,一旦发现当前元素比新数大,那当前位置就是新数应该待的下标。
public static int findInsertIndex(int[] arr, int target) { for (int i = 0; i < arr.length; i++) { if (arr[i] > target) { return i; } } return arr.length; // 所有元素都小于等于target,插在末尾 }这里有几个边界要交代清楚:
- 如果新数比第一个元素还小,循环第一次就触发
arr[0] > target,返回0,插在数组最前面; - 如果新数比所有元素都大,循环跑完都不满足条件,返回
arr.length,表示插在末尾; - 遇到相等元素时,
arr[i] > target为false,不会返回i,所以新数会被放到所有相等元素之后。这是一种稳定的插入策略,在绝大多数场景下是合理的。
2.2 关键操作:从后往前移动元素
找到插入位置之后,如果直接在原数组上操作,必须先把插入位置及其后面的元素都往后挪一格。这里有一个极其容易犯的错误——从前往后挪。如果从前往后,前面的元素会把后面的元素覆盖掉,数据直接损坏。
正确做法是从数组最后一个元素开始,逐个往后复制:
public static int[] insertSorted(int[] arr, int target) { // 新数组长度比原数组多1 int[] result = new int[arr.length + 1]; // 先按升序规则找出插入位置 int pos = findInsertIndex(arr, target); // 复制插入位置之前的元素 for (int i = 0; i < pos; i++) { result[i] = arr[i]; } // 插入新元素 result[pos] = target; // 复制插入位置之后的元素 for (int i = pos; i < arr.length; i++) { result[i + 1] = arr[i]; } return result; }上面这段代码是“新建数组再复制”的思路。但这种写法其实没体现出“移动元素”的精髓,因为新数组天然多了一个空位,不需要严格意义上的挪动。更贴近题目原意的做法(面试官更愿意看到的做法之一)是:先在原数组上腾位置,再把新数放进去。只有当原数组长度恰好能容纳时才能这样做,我们先把准备工作放一边,看一个直接在原数组末尾腾位置的版本。
public static void insertIntoArray(int[] arr, int size, int target) { // size表示当前数组中已有元素个数 int pos = size; for (int i = 0; i < size; i++) { if (arr[i] > target) { pos = i; break; } } // 从后往前移动:把[pos, size-1]区间的元素整体后移一格 for (int i = size; i > pos; i--) { arr[i] = arr[i - 1]; } arr[pos] = target; }这个版本用了一个size参数来标明当前数组里实际存了多少元素,剩下的空间是闲置的。for (int i = size; i > pos; i--)这行就是搬元素的核心:从最后一个有效元素开始,依次把前一个元素复制到后一个位置,直到插入位置被腾空。
2.3 运行验证与初步结果
下面写一个main方法把流程串起来:
public class InsertSortedDemo { public static void main(String[] args) { int[] arr = new int[8]; // 预分配8个位置 arr[0] = 2; arr[1] = 5; arr[2] = 8; arr[3] = 13; int size = 4; // 当前只有4个有效元素 insertIntoArray(arr, size, 7); size++; for (int i = 0; i < size; i++) { System.out.print(arr[i] + " "); } } }输出结果为:
2 5 7 8 13整个过程走一遍:查找时发现arr[2]=8 > 7,所以pos=2;移动阶段从下标4开始,把13移到下标5,把8移到下标4,把5移到下标3,最后把7写入下标2。移动的顺序是从后往前,保证了数据不被覆盖。
这里一定要记住:移动数组元素务必从后往前循环,这是数组插入类问题中最高频的翻车点,没有之一。
3. 数组满了怎么办:扩容细节和边界情况的完整讨论
3.1 原数组已满:先扩容再插入
上一节的insertIntoArray有一个隐含前提:数组里预留了空的格子。但在真实的笔试输入里,经常给的就是一个“满载”数组——长度就是有效元素个数,根本没地方塞新元素。这时候怎么办?
答案很直接:数组长度不可变,所以必须先创建一个更长的数组。这也是Java中ArrayList的扩容思路——内部数组不够用了,就new一个新数组,把旧数据拷过去,规格通常是旧长度的1.5倍。
对应到我们的题目,写一个通用的实现:
public static int[] insertIntoFullArray(int[] original, int target) { int n = original.length; int[] newArr = new int[n + 1]; // 找到插入位置 int pos = n; for (int i = 0; i < n; i++) { if (original[i] > target) { pos = i; break; } } // 从后往前移动 for (int i = n - 1; i >= pos; i--) { newArr[i + 1] = original[i]; } // 插入目标数 newArr[pos] = target; // 复制插入位置之前的元素 for (int i = 0; i < pos; i++) { newArr[i] = original[i]; } return newArr; }这个版本的好处是逻辑链完整:原数组一个没少,新数组比原数组多一格,目标数落位,顺序保持。坏处是代码有点冗余——三个循环各干各的事,可读性一般。
3.2 用System.arraycopy压缩代码长度
Java标准库其实提供了一个专门搬数组的利器:System.arraycopy。它是本地方法,由JVM调用系统级的数组复制能力,效率比手写循环高,而且语义清晰。用它重构上面的代码:
public static int[] insertSorted(int[] arr, int target) { int n = arr.length; int[] result = new int[n + 1]; int pos = n; for (int i = 0; i < n; i++) { if (arr[i] > target) { pos = i; break; } } // 前半段 [0, pos) System.arraycopy(arr, 0, result, 0, pos); // 插入目标 result[pos] = target; // 后半段 [pos, n) System.arraycopy(arr, pos, result, pos + 1, n - pos); return result; }System.arraycopy的五个参数分别是:源数组、源起点、目标数组、目标起点、复制长度。后半段从源数组的pos位置开始,复制到目标数组的pos+1位置,长度是n-pos,正好把所有原有元素整体后移一格。这段代码逻辑一目了然,面试中这样写既能体现你对JDK工具的熟悉度,又不会显得炫技。
3.3 边界情况逐一验证
写算法题最怕“看起来对,一测就炸”。下面三个边界必须单独过一遍:
插入到最前面。比如原数组是[3, 6, 9],插入1。pos=0,前半段复制长度为0,目标数直接落到第0位,后半段把[3, 6, 9]整体移到下标1、2、3。结果是[1, 3, 6, 9],正确。
插入到最后面。原数组[1, 4, 7],插入10。循环不触发条件,pos=n=3。前半段复制[0,3)即全部三个元素,目标数落到下标3,后半段复制长度n-pos=0,什么都不复制。结果是[1, 4, 7, 10],正确。
插入重复值。原数组[1, 4, 7],插入4。循环判断条件是arr[i] > target,也就是只有遇到严格大于4的元素才停下来。所以跳过下标0的1和小标1的4,直到下标2的7才停下,pos=2。最终数组是[1, 4, 4, 7],新数排在原有4的后面。如果你希望新数排在相等元素前面,只需要把判断条件改成arr[i] >= target,一行之差,语义完全不同。这个细节面试官经常会追问,提前想明白很重要。
用哪个条件取决于业务需求。插入排序的稳定性在数组插入场景下很少被讨论,但一旦牵扯到对象数组按某个属性插入,稳定与不稳定会直接影响结果顺序。
4. 二分查找加System.arraycopy:写出更专业的实现
4.1 顺序查找在小数组上没毛病,大数组上会吃亏
前面的查找方式都是线性扫描,时间复杂度O(n)。如果你处理的数组长度只有几十上百,完全够用,毕竟移动元素也是O(n),总复杂度再怎么优化都还是O(n)。但面试官很容易加一句追问:“数据量特别大的时候,怎么优化?”
移动元素这O(n)是省不掉的,因为你要给新元素腾位置,但查找位置这一步可以优化。已排序数组天然适合二分查找,能把找插入点的耗时从O(n)降到O(log n)。虽然总复杂度依然是O(n),但常数项小了很多,面试观感也完全不同。
4.2 借力JDK:Arrays.binarySearch的返回值设计
JDK的Arrays.binarySearch返回的并不是传统意义上的“没找到就返回-1”,它的返回值很有讲究:
- 找到目标值:返回该值在数组中的下标;
- 没找到目标值:返回
-(insertion point) - 1,其中insertion point是该值应该被插入的位置下标。
举个例子,数组[1, 4, 7],搜索5返回值是-3,因为插入点下标是2,-(2)-1=-3。想拿到插入点,只需要对返回值取反再减一:int pos = -(res) - 1;。
4.3 用二分查找优化后的最终版
把Arrays.binarySearch和System.arraycopy组合起来,代码会非常简洁:
import java.util.Arrays; public static int[] insertSortedByBinary(int[] arr, int target) { int n = arr.length; int[] result = new int[n + 1]; int pos = Arrays.binarySearch(arr, target); if (pos < 0) { pos = -pos - 1; } // 注意:如果找到了相同值,pos就是该值的下标, // 此时为了保持“新数放在相等元素后面”的规律,需要 pos+1 System.arraycopy(arr, 0, result, 0, pos); result[pos] = target; System.arraycopy(arr, pos, result, pos + 1, n - pos); return result; }这里有一个坑:当binarySearch恰好命中已有元素时,返回的是该元素下标,比如数组[1, 4, 7]搜索4,返回1。如果直接把target放在下标1,就覆盖了原来的4。想要和前面顺序查找的行为保持一致——新数放相等元素后面——就得把pos加1,变成2。所以更严谨的写法是加一个分支:
int pos = Arrays.binarySearch(arr, target); if (pos < 0) { pos = -pos - 1; } else { pos = pos + 1; // 新数放在相等元素后面 }少掉这个分支是很多人在这一版实现上翻车的原因。你要么用>=风格重新定义插入位置,要么就接受“命中时插在相等元素后面”的设定并处理pos+1。
Arrays.binarySearch只支持升序数组,降序数组要先反转或者自己手写带Comparator的二分。这又是一个容易踩的扩展点。
5. 扩展场景一:降序数组怎么保持“原有规律”
5.1 题目没说一定是升序
现实场景中,“已排序”不一定是升序。比如排行榜分数从高到低、时间线从新到旧,都是降序需求。如果题目没有明确说明规律,最稳妥的办法是写一个可以指定方向的版本。
思路很简单:查找插入位置时,比较规则不再是固定的“当前元素 > 目标数就停”,而要根据是升序还是降序来决定。一个通用但不算太复杂的方式,是把比较逻辑提取出来:
public static int[] insertSorted(int[] arr, int target, boolean ascending) { int n = arr.length; int[] result = new int[n + 1]; int pos = n; for (int i = 0; i < n; i++) { if (ascending && arr[i] > target) { pos = i; break; } if (!ascending && arr[i] < target) { pos = i; break; } } System.arraycopy(arr, 0, result, 0, pos); result[pos] = target; System.arraycopy(arr, pos, result, pos + 1, n - pos); return result; }这个写法通过一个boolean参数控制方向,比较直观,也方便调用方决定升降序。如果你不想加参数,也可以直接用Comparator接口,但这对于基本类型数组来说反而更繁琐,所以我个人更推荐boolean版本。
5.2 什么时候该考虑对象数组
如果插入的不是int,而是Student、Product这类对象,比较规则就要从“大于/小于”升级成“按某个属性比较”。这时候有两个选择:
- 让实体类实现
Comparable,重写compareTo; - 在方法里接收一个
Comparator对象,动态传入比较规则。
public static <T> T[] insertSorted(T[] arr, T target, Comparator<? super T> comparator) { T[] result = Arrays.copyOf(arr, arr.length + 1); int pos = arr.length; for (int i = 0; i < arr.length; i++) { if (comparator.compare(arr[i], target) > 0) { pos = i; break; } } System.arraycopy(arr, 0, result, 0, pos); result[pos] = target; System.arraycopy(arr, pos, result, pos + 1, arr.length - pos); return result; }泛型方法的好处是任何对象数组都能用,插入位置由Comparator决定,升降序通过comparing方向不同来区分。想在面试里显摆一手,写出这个版本基本就封顶了。
6. 复杂度、特殊数据结构和面试追问
6.1 时间复杂度与空间复杂度
无论哪种实现,数组插入的总复杂度都不低于O(n),因为移动元素无可避免。拆开看:
| 实现方式 | 查找位置 | 移动元素 | 总时间复杂度 | 额外空间 |
|---|---|---|---|---|
| 顺序查找 + 循环移动 | O(n) | O(n) | O(n) | O(1)(原地) |
| 二分查找 + System.arraycopy | O(log n) | O(n) | O(n) | O(1)(原地含量版)/ O(n)(新建数组版) |
| 新建数组版 | O(n) | O(n) | O(n) | O(n) |
空间复杂度取决于你是否允许使用新数组。如果题目允许创建新数组,代码好写很多;如果要求原地插入,必须确保原数组有空余容量,否则物理上做不到——数组内存大小是创建时固定的。这个道理说出来简单,但面试时真的会有人在这里卡壳,因为习惯了ArrayList永远“能插进去”。
6.2 对比:ArrayList和LinkedList的插入有什么不同
面试官常常顺带问一句:如果换成ArrayList,或者LinkedList,你的做法会变吗?
ArrayList的插入:内部就是数组,add(int index, E element)的实现逻辑和我们的手写版本完全一致——先System.arraycopy把index后面的元素后移,再把元素放到index位置。区别是ArrayList自动处理扩容,不需要你手动new一个更长的数组。所以你可以这样回答:ArrayList的插入本质上就是我上面写的过程,只是把找位置、扩容、移动这些步骤封装了起来。
LinkedList的插入:底层是双向链表,add(int index, E element)需要先遍历链表找到第index个节点,再改变前后节点的引用。它的查找是O(n),但插入本身只需要改指针,常数极低。如果数据量巨大且频繁在中间插入,LinkedList有优势;如果只是单次插入且主要靠随机访问,ArrayList更快。
6.3 稳定性、重复值和“原有规律”的语义
这道题还有一个容易忽略的细节:判断“原有规律”时,相等元素算不算破坏了规律?严格来说,[1, 4, 4, 7]在插入一个4后依然是升序,所以无论插到相等元素前还是后,结果都满足“保持排序”。但在实际业务中,比如按时间排序的记录列表,相同时间戳的记录谁先谁后可能很重要,这时就必须考虑插入位置的稳定性。
我在上面3.3详细对比了arr[i] > target和arr[i] >= target两种判断条件的差别,这一个细节值得在面试中主动说出来,它能向面试官证明你不是背代码,而是真的理解比较规则与业务语义的关系。
6.4 如何用Collections.binarySearch处理List场景
如果题目换成List<Integer>,我们可以用Collections.binarySearch来找插入点,再调用list.add(pos, target):
List<Integer> list = new ArrayList<>(Arrays.asList(2, 5, 8, 13)); int pos = Collections.binarySearch(list, 7); if (pos < 0) { pos = -pos - 1; } list.add(pos, 7); System.out.println(list); // [2, 5, 7, 8, 13]这段代码看起来简单得不像话,但把查找和插入的逻辑都藏在JDK方法里了。读懂它之后,再回看手写版,你会更清楚每一行代码在JDK内部对应的是什么步骤。我个人推荐的学习顺序是:先手写实现一版,再换成JDK方法实现一版,两相对照,数组插入的知识点才算真正闭环。
这道题刷完之后,我建议你顺手做一件事情:把“插入”换成“删除”,按同样的思路写一遍删除已排序数组中的某个值——你会发现删除操作同样需要数组元素移动,只不过方向变成了从前往后。一插一删,数组操作的基础就算打牢了。面试时如果能把代码写得简洁、把复杂度分析说得清楚、再把稳定性和扩容边界都考虑到,这道送分题就能变成你的加分题。