大概不少Java开发者在面试里都被问过这个题:"把一个数组逆序输出,你会怎么写?"我见过太多人第一反应是开一个新的数组,从尾巴往头遍历塞进去,然后就没有然后了。其实这道题背后远不止"写个循环"这么简单——面试官真正想听的,是你面对一个看似简单的问题时,有没有考虑过内存占用、是否修改原结构、API的边界条件、以及能不能把思路迁移到其他场景。这篇文章就从"数组逆序"这个小切口展开,把时间复杂度和空间复杂度的权衡、原地操作和新数组方案的取舍、Collections.reverse的坑、递归与调用栈的关系,以及面试里常见的延伸题一次性讲透。无论你是刚学Java的初学者,还是写了几年业务代码的老手,应该都能从中找到几处平时没留意的细节。
1. 从一个看似"闭眼能写"的问题说起:数组逆序背后的三个分岔路口
1.1 你说的是"逆序输出",还是"逆序存放"?
很多面试题的描述就两个字:"逆序"。但实现之前必须问清楚一个关键问题:你要的是把数组里的元素从头到尾打印一遍(比如[1,2,3]打印成3,2,1),还是要真的把数组里的元素顺序改掉,让后续代码拿到的就是一个倒过来的数组?
前者只需要一个循环从后往前print,原数组安安稳稳不动;后者才需要真正写逆序逻辑。这两种诉求的代码差别很大,如果你上来闷头就写原地交换,面试官心里可能已经在扣分了——因为你连需求边界都没先确认。我自己见过的很多真实场景里,项目里要的往往只是"取一个逆序后的副本去展示",而不是把原始数据改掉,所以"先弄清楚目标"永远比"马上写代码"重要。
1.2 三个分岔路口决定了这个问题的全部答案
一旦确认了需求,接下来有三个岔路口,每一条都会把你带到不同的实现方案:
- 第一岔路:要不要修改原数组? 要改成原地逆序,用双指针交换;不要改就用新数组反向填充。
- 第二岔路:数组的元素是基本类型还是引用类型? 基本类型比如
int[]不能直接玩泛型,想用Collections.reverse必须boxing;引用类型数组可以直接转成List再用工具方法。 - 第三岔路:自己写循环,还是用现成API? 手写适合算法面试、底层控制;API适合业务开发快速完成,但熟悉API的边界条件也很重要。
这三个问题只要想清楚了,所谓"数组逆序"的答案就已经有了骨架。接下来每一段我都会沿着某一条岔路往里走,把对应的写法和理由拆开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 新数组法:把代码写对很容易,把理由说清楚才是难点
2.1 最小可行的新数组实现
先说最简单、对初学者最友好的方案:新建一个同样大小的数组,从原数组尾部开始遍历,把元素依次放到新数组里。代码长这样:
java复制public static int[] reverseWithNewArray(int[] arr) {
if (arr == null) {
return null;
}
int[] result = new int[arr.length];
for (int i = 0; i < arr.length; i++) {
result[i] = arr[arr.length - 1 - i];
}
return result;
}
这段代码里唯一需要琢磨的索引关系是result[i] = arr[arr.length - 1 - i]。思路可以这样理解:新数组的第i个位置,应该放原数组倒数第i+1个位置的值。比如原数组长度是5,那么result[0]放arr[4],result[1]放arr[3],依此类推。这个对应关系想通了,整段代码就没有秘密了。
2.2 另一种反向遍历写法
也有的人喜欢顺着原数组倒着取,再用一个计数器往新数组里放:
java复制public static int[] reverseWithNewArrayV2(int[] arr) {
int[] result = new int[arr.length];
int index = 0;
for (int i = arr.length - 1; i >= 0; i--) {
result[index++] = arr[i];
}
return result;
}
两种写法本质完全一样,时间复杂度都是O(n),空间复杂度都是O(n)。区别只是你脑子里的"映射方式"是正向算对应位置,还是反向遍历再顺序写入。我个人觉得第一种更不容易出错,因为索引关系集中在一行里;第二种多了一个变量,代码对新手友好一些。无论用哪种,都要记得原数组没有被改掉——这是新数组法最大的好处。
2.3 为什么要主动暴露它的缺点
新数组法最大的缺点是空间开销。你为了逆序复制了一份完整数据,这在数组长度为几百、几千时无所谓,但如果一个数组有上千万个元素,而你的业务场景只是临时展示一下逆序结果,马上就会多出几十兆的内存占用。
另一个隐藏问题出现在"你以为你改了原数组"的时候。新数组法返回的是一个全新对象,如果你忘了接住返回值,写成了reverseWithNewArray(arr);,什么事情都不会发生,原数组纹丝不动。这个错误在真实项目里经常出现——我见过同事调用了一个返回新数组的翻转方法,却没把返回值赋值给任何变量,结果调试了半天数据没变。所以新数组法的使用前提是"你要的就是一个副本",别把它当原地修改用。
3. 原地双指针法:最省内存、最适合手写面试的写法
3.1 双指针版的代码与推演
如果面试官追问"能不能不申请新数组?"那就该上原地交换了。经典写法是双指针,一头一尾向中间靠拢:
java复制public static void reverseInPlace(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
int i = 0;
int j = arr.length - 1;
while (i < j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
i++;
j--;
}
}
以[1, 2, 3, 4, 5]为例推演一轮:初始i=0、j=4,交换1和5,数组变成[5,2,3,4,1];然后i=1、j=3,交换2和4,数组变成[5,4,3,2,1];最后i=2、j=2,循环条件i < j不成立,停在这里。中间那个3从头到尾没有挪过位置——因为奇数长度数组的中间元素本来就不需要移动。偶数长度的[1,2,3,4]则恰好两两配完:i=0/j=3交换,i=1/j=2交换,然后i=2、j=1退出循环,输出[4,3,2,1]。
3.2 为什么循环条件是 i < j 而不是 i <= j
这个问题我几乎每次带新人时都会被问到。关键在于:当i == j时,指向的是同一个位置,这个位置的元素和它自己交换,不是不行,而是完全没有必要。
如果你把条件写成i <= j,奇数长度的数组在最后一轮会多做一次"自己换自己"的操作。结果虽然一样,但面试官可能就会问一句:"中间这步有什么意义吗?"更危险的是,如果有人在代码里为了省一个临时变量,用异或交换来实现swap,当i == j时异或交换会直接把那个位置的值变成0:
java复制arr[i] ^= arr[j]; // 同一个元素异或自己,结果变成0
arr[j] ^= arr[i];
arr[i] ^= arr[j];
这个坑我在真实代码评审里见过不止一次。所以双指针方法请老老实实用临时变量,循环条件用i < j,既安全又清晰。
3.3 for循环写法与复杂度结论
如果更喜欢for风格,可以写成这样,效果一模一样:
java复制public static void reverseInPlaceFor(int[] arr) {
for (int i = 0, j = arr.length - 1; i < j; i++, j--) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
}
这个方案的时间复杂度是O(n),因为每个元素刚好被访问一次;额外空间是O(1),只有一个临时变量。从算法题的角度看,这是数组逆序的标准答案,也是后续很多进阶题(部分逆序、数组轮转、回文判断)的基座。
3.4 引用类型数组的"原地逆序"要小心
如果你的数组是String[]、Integer[]这类引用类型,双指针交换的其实只是引用地址,对象本身完全没变。比如一个String数组["a","b","c"],逆序后变成["c","b","a"],但三个字符串对象的内容没有变化。这符合引用数组的直觉,却经常被忽略了——如果你先逆序了元素,再去调用元素上的方法,会发现元素内部状态一切如初。只有当你需要的是"元素内容也反过来"(比如字符串数组的每个字符串本身也要反转),才需要额外处理。
4. Collections.reverse与List视角:业务代码里更常见的玩法
4.1 手里是Integer[]或ArrayList时的写法
在日常业务里,我们经常面对的不是裸int[],而是Integer[]包装类型数组,或者干脆就是从数据库查出来的List<Integer>。这时候根本不用自己写双指针,Java集合框架自带逆序方法:
java复制Integer[] arr = {1, 2, 3, 4, 5};
List<Integer> list = Arrays.asList(arr);
Collections.reverse(list);
System.out.println(Arrays.toString(arr)); // [5, 4, 3, 2, 1]
这里有个非常容易踩的坑:Arrays.asList(arr)返回的是一个基于原数组视图的List,这个List没有扩容能力,不能add也不能remove,但它对元素的修改会直接同步到原数组。所以Collections.reverse(list)之后,arr本身也已经变成逆序的了——根本不需要再写list.toArray(arr)那一套。
4.2 int[]能不能直接转List?不能
很多新手一上来就想写:
java复制int[] arr = {1, 2, 3};
List<Integer> list = Arrays.asList(arr); // 编译不报错,但list.size() == 1
这是Java泛型的一个经典陷阱:基本类型不能作为泛型参数,int[]在这里被当作了一个对象,所以Arrays.asList(arr)得到的是List<int[]>,里面只有一个元素,就是整个数组。你reverse它,相当于把装着"一整份数组"的单元素列表原地翻转,毫无效果。
正确的转型姿势是用Java 8的Stream:
java复制int[] arr = {1, 2, 3, 4, 5};
List<Integer> list = Arrays.stream(arr).boxed().collect(Collectors.toList());
Collections.reverse(list);
System.out.println(list); // [5, 4, 3, 2, 1]
如果你还想要回int[],再从list倒回去:
java复制int[] reversed = list.stream().mapToInt(Integer::intValue).toArray();
这样做多消耗了一份装箱对象的开销,复杂度仍然O(n),在业务代码里完全可接受。
4.3 不想修改原集合时怎么办
Collections.reverse是原地操作,它会直接修改传入的List。如果你不想动原有的列表,只想拿到一个逆序后的副本,在转成List之后先套一层:
java复制Integer[] original = {1, 2, 3, 4, 5};
List<Integer> copy = new ArrayList<>(Arrays.asList(original));
Collections.reverse(copy);
// original 保持不变
这套组合拳在真实项目里非常常用。我自己的习惯是:只要确定不改原数据,一律先new ArrayList<>()包一下再reverse,省得后面排查"数据怎么被改了"的问题。
5. 递归逆序:理解调用栈与边界条件,比代码本身更重要
5.1 三行递归实现
递归版本的逆序代码非常短:
java复制public static void reverseRecursively(int[] arr, int left, int right) {
if (left >= right) {
return;
}
int temp = arr[left];
arr[left] = arr[right];
arr[right] = temp;
reverseRecursively(arr, left + 1, right - 1);
}
调用时写reverseRecursively(arr, 0, arr.length - 1)即可。这段代码和双指针的"容貌"很像,只是交换完一对元素之后,把剩余的区间通过递归调用交给下一层处理。
5.2 调用过程拆解
以长度5的数组[1,2,3,4,5]为例:
- 第一次调用
left=0, right=4,交换1和5,进入left=1, right=3 - 第二次调用
left=1, right=3,交换2和4,进入left=2, right=2 - 第三次调用
left=2, right=2,满足left >= right,return
每一次递归调用都保存了当前层的left和right参数,所以本质上是用调用栈代替了循环里的i和j。如果你把递归逻辑想象成一个不断往里缩小的区间,边界条件'left >= right'就是区间的出口,想清楚了就非常自然。
5.3 为什么生产代码里很少用它
递归最大的问题在于深度。上面的实现递归深度是n/2,当数组长度到达一万、十万时,每一层调用都要占用一部分栈空间,很容易触发StackOverflowError。我自己试过一个五万长度的数组,在本机默认栈配置下稳定宕掉。所以在真实项目里,能用循环解决的就不要用递归——这跟"递归不好"无关,而是栈空间是有限的公共资源,不该为一个线性操作付这么高的代价。
但递归的思维迁移价值很高。链表逆序、树的后续遍历、甚至很多分治算法,核心框架都是"处理完当前单元,把剩余问题交给下一层"。你搞懂了递归版的数组逆序,等于给自己铺了一条理解更复杂递归的路。
6. 必踩的坑与面试延伸:从"会写"到"答得漂亮"
6.1 四个高频坑,我第一次都踩过
先说代码层面的坑,这些几乎每个学过Java的人都踩过一轮:
第一个坑:忘了null和空数组。 一上来就写arr.length,输入是null时直接NPE,输入是空数组时虽然不报错但也没意义。写工具方法时,建议开头统一加一行判断:
java复制if (arr == null || arr.length < 2) {
return;
}
第二个坑:Arrays.asList(int[])。 上面已经详细说过,这是基本类型数组和泛型碰撞出的世纪陷阱。只要看到Arrays.asList接一个int[],基本可以断定写代码的人踩过坑。
第三个坑:数组是引用传递。 Java传参虽然按值传递,但数组本身是引用类型,方法内修改元素会影响原数组。很多人以为方法里改了无所谓,结果外部数据被"悄悄"改掉了。如果你不希望原数组变动,记得先clone:
java复制int[] copy = arr.clone();
reverseInPlace(copy);
第四个坑:字符串逆序不能照搬数组方法。 字符串是不可变的,不能原地交换字符,只能通过char[]或StringBuilder.reverse()来操作:
java复制String s = "hello";
String reversed = new StringBuilder(s).reverse().toString(); // "olleh"
如果在面试中遇到字符串反转,最好能主动说出"String不可变,所以需要额外空间"这一个点,比闷头写出正确代码更能体现功底。
6.2 延伸题1:区间逆序
题目:给定数组、起始下标start和结束下标end,把这段区间内的元素逆序,其余不变。解法就是双指针的局部版:
java复制public static void reverseRange(int[] arr, int start, int end) {
if (arr == null || start < 0 || end >= arr.length || start >= end) {
return;
}
while (start < end) {
int temp = arr[start];
arr[start] = arr[end];
arr[end] = temp;
start++;
end--;
}
}
这个题是数组轮转的前置技能。很多人在LeetCode风格题目里栽跟头,就是因为只会整段逆序,不会区间逆序。
6.3 延伸题2:循环右移K位
数组循环右移K位,比如[1,2,3,4,5]右移2位得到[4,5,1,2,3]。如果用暴力逐个搬,需要额外的数组或者频繁移动,但更优雅的解法就是把"逆序"当工具用:
java复制public static void rotateRight(int[] arr, int k) {
if (arr == null || arr.length == 0) {
return;
}
int n = arr.length;
k = k % n;
if (k == 0) {
return;
}
reverseRange(arr, 0, n - 1); // 整体逆序
reverseRange(arr, 0, k - 1); // 逆序前k个
reverseRange(arr, k, n - 1); // 逆序剩余
}
三步逆序,时间复杂度O(n),空间O(1)。能把这个解法顺畅地讲出来,面试官对你的"数组基本功"基本就有数了。
6.4 延伸题3:回文判断与链表逆序
判断一个数组是不是回文,本质也是双指针:
java复制public static boolean isPalindrome(int[] arr) {
int i = 0;
int j = arr.length - 1;
while (i < j) {
if (arr[i] != arr[j]) {
return false;
}
i++;
j--;
}
return true;
}
链表逆序则和数组逆序有本质区别:数组可以通过下标在O(1)时间随机访问,所以能用双指针直接交换;链表只能顺着next指针走,所以要逐个修改引用方向,通常是三指针或递归。很多人理解不了链表反转,其实就是因为还在用"数组思维"去想:总想着直接跳到最后一个节点,但在链表里根本跳不过去。
6.5 自检测试清单
最后分享一个我每次写完逆序方法都会做的自检测试。不要只测正常数组,一定要覆盖这几种输入:
java复制int[] case1 = null; // 不崩溃
int[] case2 = {}; // 空数组
int[] case3 = {1}; // 单元素
int[] case4 = {1, 2, 3, 4}; // 偶数长度
int[] case5 = {1, 2, 3, 4, 5}; // 奇数长度
尤其要测一测数组中包含重复元素的情况,比如{1, 2, 1}逆序后应该还是{1, 2, 1}。这类测试用例写顺手之后,会变成一个下意识动作:不管写什么数组操作,先把边界case过一遍。这个方法帮我挡下了很多次线上事故,也让我在给别人讲代码时更有底气——因为每一个分支都是亲手验证过的,而不是背下来的。
