数组右移插入这个操作,面试里经常被考到,但很多人在白板上写到一半就翻车。明明思路听懂了,一下笔就开始纠结到底从哪边开始遍历、下标要不要减一、会不会覆盖掉还没搬的数据。更常见的是,写完一跑就报 ArrayIndexOutOfBoundsException,或者插入之后数组里出现了重复值。我在实际帮别人 review 算法题的时候,几乎每周都能看到一两版这样的代码。
这篇文章就把这个“小而经典”的问题彻底讲透。我会先用一个具体场景说明为什么要右移,然后重点解释为什么必须从后往前搬移,再给出手写循环和 System.arraycopy 两种实现方式,最后把你面试和工作中很可能遇到的几个变体(插入排序、有序数组合并、删除元素)串起来。读完你不仅能写对,还能跟面试官把原理讲清楚,而不是背一份标准答案。
1. 数组插入为什么这么麻烦:先搞清楚数组的数据模型
1.1 数组不是仓库,是“连排座位”
理解数组插入之前,先得把数组和集合类(比如 ArrayList)在底层模型上分开。Java 数组在内存里是一段连续的存储空间,声明的时候就确定了长度,比如 int[] arr = new int[10],这 10 个位置是排好队的,谁在前谁在后物理上就是连续的。
你可以把数组想象成电影院里的连排座位:座位号就是下标 0 到 9,每个座位只能坐一个观众。这时候如果来了一个新观众,要坐在 3 号座位上,而 3 号座位上已经有人了,怎么办?唯一合法的操作就是让 3 号以及后面所有观众都往右挪一个位置,让 3 号座位空出来,新观众坐进去。注意最后一排的观众如果挪出去了,就真的没有座位了——在数组里,这意味着最后一个元素会被丢掉,或者说,数组空间不够时,插入根本没法完成。
这和不连续存储的链表完全不同。链表每一个节点都可以散落在任意内存地址,只要用 next 指针串起来就行,插入新节点只需要改前后两个节点的指针,不需要搬动任何已有元素。这也是“为什么数组插入慢、链表插入快”这个面试问题的根源。但反过来,数组因为空间连续,按下标访问是 O(1) 的,链表要找到第 k 个节点得从头遍历,所以二者各有取舍。
1.2 右移插入到底想解决什么问题
搞清楚“右移”这个词的含义很重要。它不是排序算法里那种整体平移,而是“在数组中某个指定下标处插入一个新元素,并把原位置及其后面的所有元素统一向后移动一位”的动作。
这种操作有几个典型使用场景:
- 往一个有序数组里插入一个新值,插入后仍然保持有序。比如当前数组
[1, 3, 5, 7],要插入4,就得先找到 4 应该落在的位置(下标 2),把5和7右移,变成[1, 3, _, 5, 7],再赋值arr[2] = 4。 - 手写简易的数组列表、栈、队列等底层结构时,需要维护一个
size变量,插入元素时在数组内搬移数据。 - 各类排序算法的内部实现,比如插入排序的核心动作就是“右移+插入”;快速排序的某些区间调整思路也跟挪位有关。
所以说,右移插入不是一道孤立的八股题,它是很多高级逻辑的基本零件。把它写熟练,后面看插入排序、合并有序数组这类题会轻松很多。而且面试官特别喜欢在这个小动作上做文章,从“从前往后还是从后往前”开始追问,一路问到复杂度、扩容策略、边界条件,最后还可能让你用 System.arraycopy 来优化,考察你对 JDK 基础库的熟悉程度。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 右移插入的核心原理:从后往前才是正确方向
2.1 错误的示范:从前往后移为什么数据会被覆盖
很多第一次手写这个逻辑的人,都会写出一版类似下面的代码:
java复制// 错误示例:从前往后搬移
for (int i = index; i < size; i++) {
arr[i + 1] = arr[i];
}
arr[index] = value;
size++;
表面上看,这行代码把 index 位置的元素放到 index + 1,然后 index + 1 位置的元素放到 index + 2,听起来好像没问题。但实际运行一下你就傻了,假设数组是 [1, 3, 5, 7],size = 4,要在下标 1 插入 2。
第一次循环 i=1:arr[2] = arr[1],数组变成 [1, 3, 3, 7],原来的 5 被覆盖了。
第二次循环 i=2:arr[3] = arr[2],此时 arr[2] 已经是 3 了,数组变成 [1, 3, 3, 3],原来的 7 也没了。
第三次循环 i=3:arr[4] = arr[3],但 arr[4] 可能越界。
最终结果 [1, 3, 3, 3],数据血肉模糊。为什么?因为当你把 index 的元素往右挪的时候,index + 1 位置原来的值还没被搬走,就直接被覆盖了。这是“搬运”和“复制”之间的本质区别:数组赋值 arr[i+1] = arr[i] 是复制,不是移动,原位置的值不会自动消失。
所以,从前往后搬,就相当于在排队的时候,第一个人往后挪一步,直接踩到了第二个人脚上,第二个人还没动呢。正确的做法,是让队伍最后一个人先动。
2.2 正确的书写方式:从后往前搬移的循环细节
正确方向是“从后往前”搬移。也就是说,先处理数组末尾的元素,把它往右挪一位,腾出空间,再处理倒数第二个,依此类推,直到 index 位置被空出来。
代码长这样:
java复制// 正确示例:从后往前搬移
for (int i = size; i > index; i--) {
arr[i] = arr[i - 1];
}
arr[index] = value;
size++;
我还是用 [1, 3, 5, 7]、size = 4、插入 2 到下标 1 来走一遍:
- i=4:
arr[4] = arr[3],数组变成[1, 3, 5, 7, 7](假设容量足够)。 - i=3:
arr[3] = arr[2],数组变成[1, 3, 5, 5, 7]。 - i=2:
arr[2] = arr[1],数组变成[1, 3, 3, 5, 7]。 - 循环结束,因为 i=1 时条件
1 > 1不成立。 arr[1] = 2,数组变成[1, 2, 3, 5, 7]。size++,有效长度变成 5。
这里有几个细节必须注意:
循环条件为什么是 i > index 而不是 i >= index?因为我们要保留 index 位置给新值,它不需要被搬走。arr[index] = arr[index-1] 会把 index-1 的值复制到 index,而 index-1 本身不搬,留给后续循环处理。当你把条件写成 i >= index 时,最后一次循环执行 arr[index] = arr[index - 1],看似也没错,但会多做一次无意义的复制,而且如果 index = 0,i 会变成 0,执行 arr[0] = arr[-1],直接越界。
为什么从 i = size 而不是 i = size - 1 开始?因为新元素要插入,所有 [index, size-1] 范围内的元素都要右移,移动后最右边的空位是 size。所以循环起点是 size,把 arr[size-1] 搬到 arr[size]。如果你从 i = size - 1 开始,第一次循环执行 arr[size-1] = arr[size-2],最末尾原来的元素 arr[size-1] 就没被搬到新位置,直接等着被覆盖,同样白白丢了一个值。这段逻辑我当年第一次手写时也栽过,记住一句话:搬移的起点是 size,不是 size-1。
2.3 边界与极端情况:空数组、插头部、插尾部
写算法题就怕边界条件,右移插入的边界主要有三类。
边界一:数组是空的(size = 0)。 这时候直接 arr[0] = value,size++ 即可。如果 index 也等于 0,循环条件 i > index 就是 0 > 0,直接不进去,没毛病。
边界二:插入头部(index = 0)。 这是最考验循环设计的情况。从 i = size 一直搬移到 i = 1,刚好把整个数组都右移一位,空出 arr[0]。如果循环条件写错,最容易在 index = 0 时翻车。很多人写 for (int i = size; i >= index; i--),倒也没错,只要不在循环体里访问 arr[i - 1] 时 i = -1 就行。但 i > index 的写法在 index = 0 时语义最干净:所有下标大于 0 的元素都搬,0 号位直接留给新值。
边界三:插入尾部(index = size)。 这时候没有任何元素需要搬移,循环一次都不执行,直接把 arr[size] = value,size++。这是最理想的情况,时间复杂度 O(1)。但如果 index 传的是 size,而数组容量刚好等于 size,那 arr[size] 就越界了。所以插入前必须判断容量。
这三类边界,背后其实是一个统一的定义:插入位置 index 的合法范围是 [0, size]。注意是闭区间,因为你可以插到有效元素最后面。数组容量 arr.length 必须大于等于 size + 1,否则就要先扩容。
3. 工程实现:手写循环与 System.arraycopy 的取舍
3.1 手写 for 循环实现右移插入:一个完整可跑的示例
先给一个完整的、能直接跑的类,里面包含了最基本的右移插入逻辑和容量检查:
java复制public class ArrayInsertDemo {
private int[] data;
private int size;
public ArrayInsertDemo(int capacity) {
data = new int[capacity];
size = 0;
}
/**
* 在指定下标处插入元素
*
* @param index 插入位置,合法范围 [0, size]
* @param value 待插入的值
*/
public void insert(int index, int value) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("插下标不合法: " + index);
}
if (size == data.length) {
throw new IllegalStateException("数组已满,无法插入");
}
// 从后往前搬移
for (int i = size; i > index; i--) {
data[i] = data[i - 1];
}
data[index] = value;
size++;
}
public void print() {
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < size; i++) {
sb.append(data[i]);
if (i != size - 1) {
sb.append(", ");
}
}
sb.append("]");
System.out.println(sb);
}
public static void main(String[] args) {
ArrayInsertDemo demo = new ArrayInsertDemo(10);
demo.insert(0, 1);
demo.insert(1, 3);
demo.insert(2, 5);
demo.insert(3, 7);
demo.print(); // [1, 3, 5, 7]
demo.insert(1, 2);
demo.print(); // [1, 2, 3, 5, 7]
demo.insert(5, 9); // 尾部插入
demo.print(); // [1, 2, 3, 5, 7, 9]
}
}
这个类里我用 data 和 size 来模拟一个简易的数组列表。size 表示当前有效元素个数,data.length 表示底层数组的最大容量。之所以引入 size 而不直接用 data.length,是因为数组一开始就让所有位置都有值,那就没法区分“有效元素”和“空位”了。实际写业务代码时,如果只是在一个固定长度数组里做插入,通常也需要维护 size 这个“有效长度”变量,否则右移范围说不清楚。
3.2 System.arraycopy 的语义和使用要点
面试场景下,手写循环是最基础的要求。但如果你在代码审查里看到有人用循环做大批量数组搬移,你当场就能指出可以优化。JDK 早就提供了 System.arraycopy 这个方法,它是一个 native 方法,底层通常走 memmove 或者类似的 C 库搬移,性能优于普通 for 循环,尤其是在搬移元素数量较大的时候。
方法签名是:
java复制public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length)
参数含义分别是:源数组、源数组起始下标、目标数组、目标数组起始下标、要复制的元素个数。用 System.arraycopy 实现右移插入,代码可以精简成:
java复制public void insertWithArraycopy(int index, int value) {
if (index < 0 || index > size) {
throw new IndexOutOfBoundsException("插入下标不合法: " + index);
}
if (size == data.length) {
throw new IllegalStateException("数组已满,无法插入");
}
// 把 [index, size-1] 范围内的元素整体右移一位
System.arraycopy(data, index, data, index + 1, size - index);
data[index] = value;
size++;
}
这里的 size - index 就是要搬移的元素个数。拿 [1, 3, 5, 7]、size = 4、插入下标 1 举例,System.arraycopy(data, 1, data, 1, 3),意思是从 data[1] 开始,一共 3 个元素(1、2、3 位置),原样复制到目标数组从 data[2] 开始的连续位置。结果 data[2] = 3,data[3] = 5,data[4] = 7。注意复制之前系统会先把源区域的副本内容保存好,所以即使源和目标在同一数组上,区域有重叠,也不会出现覆盖丢数据的问题。
在使用 System.arraycopy 时要特别注意几个坑:
- 源数组和目标数组都必须是同类型或者可以兼容的引用类型,否则抛
ArrayStoreException。 srcPos + length不能超过源数组长度,destPos + length不能超过目标数组长度,否则抛IndexOutOfBoundsException。- 如果
src和dest是同一个数组,两个区域重合是允许的,JDK 规范保证结果像先复制到临时区域再复制回去一样。这也是它能安全实现“右移”的理论基础。
3.3 参数与下标计算对照表:避免差一错误
System.arraycopy 的参数虽少,但很容易搞错。我整理了一个对照表,方便你在写的时候对号入座。
| 操作场景 | 源数组起始 | 目标数组起始 | 复制长度 |
|---|---|---|---|
| 在 index 处插入,元素右移 | 当前数组,起点 index |
当前数组,起点 index + 1 |
size - index |
| 删除 index 处元素,元素左移 | 当前数组,起点 index + 1 |
当前数组,起点 index |
size - index - 1 |
| 扩容后拷贝旧数组到新数组 | 旧数组,起点 0 |
新数组,起点 0 |
size |
| 在 index 处插入,先扩容再插 | 旧数组,起点 index |
新数组,起点 index + 1 |
size - index |
这张表的关键记忆点有两个:一是“插入右移”始终把 index 作为源起点,把 index + 1 作为目标起点;二是长度永远看“这一段有多少个有效元素”,不要用 size + 1 这类数,越加越乱。
我自己在实际写代码时有个习惯:先画一个下标图,把数组元素和下标标出来,再在图上圈出要搬移的区间,最后才落到代码里。画图不用多规整,哪怕拿笔在草稿纸上画几格再标上数字,也能杀掉一半以上的 off-by-one 错误。
4. 面试高频变体与扩展应用
4.1 插入排序:右移插入思想的最典型应用
插入排序内部干的事,其实就是反复的右移插入。假设当前数组左侧已经有序,要处理一个新的元素 key,就先从右往左找插入位置,找到比 key 大的元素就右移一位:
java复制public void insertionSort(int[] arr) {
int n = arr.length;
for (int i = 1; i < n; i++) {
int key = arr[i];
int j = i - 1;
// 从右往左找插入位置,同时右移比 key 大的元素
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
这段代码里,arr[j + 1] = arr[j] 就是右移,arr[j + 1] = key 就是插入。它跟前面单独的插入操作唯一的区别是:前面是“给定 index,直接搬移”,这里是“边找位置边搬移”。两种思路在实际算法题里经常互换。
面试官如果让你手写插入排序,你最好能顺带解释清楚它的复杂度:最坏情况下数组是逆序的,每次都要把新元素挪到最前面,总移动次数是 1 + 2 + ... + (n-1),也就是 O(n²)。最好情况下数组已经有序,每次只比较一次就结束,时间是 O(n)。很多初学者背下“插入排序 O(n²)”就完事了,但能讲清楚“O(n²) 的时间主要消耗在右移搬移上”,会让面试官觉得你是真懂,不是背稿子。
4.2 合并两个有序数组:从后往前填的经典套路
LeetCode 88 题“合并两个有序数组”是右移思想的一个很好应用。原题是把两个有序数组合并到第一个数组里,第一个数组提前预留了足够空间,有效元素保存在前 m 个位置。
很多人的第一反应是从前往后比较,然后插入,但这样每插入一次都要右移一次,效率低,代码也容易乱。更优的做法是从后往前填:因为合并后数组更长的,我们从最后一个位置(下标 m+n-1)开始,从大到小放置元素。这样一来,原来数组元素的搬移和插入在同一个循环里完成,不需要单独做右移操作。
java复制public void merge(int[] nums1, int m, int[] nums2, int n) {
int p1 = m - 1; // nums1 有效元素最后一个下标
int p2 = n - 1; // nums2 最后一个下标
int p = m + n - 1; // 合并后最后一个下标
while (p1 >= 0 && p2 >= 0) {
if (nums1[p1] > nums2[p2]) {
nums1[p] = nums1[p1];
p1--;
} else {
nums1[p] = nums2[p2];
p2--;
}
p--;
}
// 如果 nums2 还没用完,直接复制剩余部分
while (p2 >= 0) {
nums1[p] = nums2[p2];
p--;
p2--;
}
}
留意这个解法里用了三个指针从后往前遍历,本质上就是借助了“先移动后面的元素”这个核心思路,跟右移插入的“先搬尾部”是一致的。面试官再追问一句“为什么从后往前就不会覆盖 nums1 还没处理的元素”时,你可以这样答:因为 nums1 后面预留了空位,从后往前填的时候,p1 指向的元素如果被覆盖,说明它已经放到了更靠后的位置,所以不会被误伤。
4.3 删除元素左移:右移的孪生操作
掌握了右移插入,再学删除就非常简单。删除本质上就是“左移”:把目标位置后面的所有元素往前搬一格,覆盖掉要删的那个元素。
java复制public void removeAt(int index) {
if (index < 0 || index >= size) {
throw new IndexOutOfBoundsException("删除下标不合法: " + index);
}
int moved = size - index - 1;
if (moved > 0) {
System.arraycopy(data, index + 1, data, index, moved);
}
// 最后一个元素残留无所谓,size 已经缩小
size--;
}
这里最容易忽略的细节是:左移之后,数组最后一个位置会残留旧值,但因为 size 已经减一,这个位置不再属于有效区。如果是对象数组,残留引用会阻止垃圾回收器回收那个对象,所以严谨的写法是在 size-- 之后把 data[size] = null。这个点如果在面试里主动提出来,是很加分的细节,大部分人只会做到 size-- 就收工了。
另外你可能会发现,在 Java 热词里反复出现“数组去重”、“双指针合并有序数组”这类问题,它们虽然不直接叫“右移插入”,但核心都离不开“移动元素、腾位置、维护有效长度”这套基本功。比如简单的有序数组原地去重,就是用两个指针,一个负责遍历,一个负责维护不重复元素的有效位置,本质上也是“把后面的有效元素搬到前面”。所以,右移插入不只是一个小语法题,它是一系列数组题的地基。
5. 实际开发中的注意事项与避坑清单
5.1 当数组容量不够时:扩容策略怎么写
数组长度固定,所以右移插入前必须先确认容量。实际使用时,底层数据结构的扩容方案一般是“新建一个更大的数组,把旧数据搬过去,再执行插入”。常见扩容策略是每次扩容到原来的 1.5 倍,比如 ArrayList 的扩容逻辑,会先定义一个 newCapacity = oldCapacity + (oldCapacity >> 1),再调用 Arrays.copyOf 创建新数组。
如果你是在面试手写代码,不一定要把扩容写得很复杂,但至少要说清楚思路。一个最简版本是:如果 size == data.length,直接把容量翻倍,用 Arrays.copyOf 或者 System.arraycopy 把旧数据搬到新数组。值得注意的是,扩容之后,原来的 size 不变,数组长度变了,后续插入逻辑不受影响,只是底层引用指向了新数组。
java复制private void ensureCapacity() {
if (size < data.length) {
return;
}
int newCapacity = data.length + (data.length >> 1);
// 处理容量为 0 或 1 的极端情况
if (newCapacity <= data.length) {
newCapacity = data.length + 1;
}
data = Arrays.copyOf(data, newCapacity);
}
5.2 避免数组操作,优先用 ArrayList 的场景
面试里写算法题,使用数组是很正常的。但在真实项目里,如果你发现自己频繁地对数组做右移插入、删除、扩容这类操作,那几乎可以断定这个数据集合不适合用原始数组,而应该直接用 ArrayList 或者 LinkedList。ArrayList 底层虽然也是数组,但它帮你把扩容、搬移、边界检查都封装好了,调用 add(index, element) 一行搞定,而且内部用的正是 System.arraycopy 来做搬移,性能上是同一套思路。
不过,这不代表数组操作在工作中完全没用。在一些高性能场景或者内存受限的环境中,你可能会主动选择数组来避免装箱、减少对象头开销、精确控制内存布局,这时候手写右移插入的知识就派上用场了。另外,很多面试题明确要求“不要使用现成的集合类”,目的就是考察你能不能原地操作数组。所以,面试和真实开发是两条路线:面试讲原理,开发用封装,但原理必须懂,否则你连 ArrayList 的源码都看不明白。
5.3 常见 bug 复盘:覆盖、越界、残留引用
最后做个小复盘。右移插入这个操作虽然代码量不大,但常见的坑我来回见过不下五种,这里集中说一下。
第一个坑是方向反了,从前往后搬,数据被覆盖。这个前面详细讲过,是最常见的错误,没有之一。
第二个坑是起点写错。很多人把 for (int i = size - 1; i > index; i--) 当成正确写法,结果一跑发现最后一个元素没被搬走,反而丢了。记住,size - 1 是最后一个有效元素的下标,它要被搬到 size 这个新位置,所以循环必须从 size 开始。
第三个坑是插入下标越界。合法插入范围是 [0, size],但很多业务代码会传 size + 1 或者负数进来,没有做校验。在真实系统里,这种入口参数问题直接抛异常比默默吞掉要好得多,否则数据错乱后排查成本更高。
第四个坑是对象数组的残留引用。插入操作如果伴随扩容,或者删除后不置空,对象数组里就可能残留无用引用,导致内存无法回收。热词里出现的 java: outofmemoryerror: insufficient memory,有时候就藏在这样的细节里。虽然单个残留引用不会立刻撑爆内存,但长期运行的系统,尤其是缓存类组件,最容易在这种地方慢慢漏。
第五个坑是忽视 System.arraycopy 的 ArrayStoreException。当你把 Object[] 往 String[] 里复制时,类型不匹配会直接抛异常。数组的类型在运行时是确定的,不是只看声明类型,面试里问你“数组的协变性和类型检查”,根源就在这里。
我个人在实际调试时的经验是:先不要急着写循环,先把数组画出来,标上 size 和 index,然后问自己三个问题——要搬的元素从哪里开始?要搬到哪里结束?会不会覆盖还没搬的数据?三个问题答完,代码基本不会出错。遇到 off-by-one 的问题也不要靠猜,直接在草稿纸上跑一遍小例子,比如 [1, 2, 3] 里插 4,几步就能验证出来。数组操作说到底是内存和下标的事,把图画清楚,比背任何模板都管用。
