给你一个数组要求把元素顺序倒过来比如输入 {1, 2, 3, 4, 5}输出 {5, 4, 3, 2, 1}。这个问题在 Java 题单里出镜率极高既适合刚学完数组和循环的人练手也经常作为面试前的热身题出现。我见过不少开发者第一次写它时栽跟头要么数组越界要么反转完跟没动一样要么在“新建一个数组”和“原地交换”之间犹豫半天。这篇文章就从 Java 数组逆序输出这件事讲起把核心思路、常见写法、边界条件和排查技巧一次性说清楚。内容不追求花哨只求能让你看完后自己动手写对并且能解释清楚为什么这么写。无论你是在准备笔试、做课程作业还是单纯想把基础打扎实这篇都适用。我会尽量口语化但涉及原理和复杂度时一样会讲严谨。1. 先把“逆序”翻译成人话思路与设计选择1.1 逆序的本质是索引重映射数组在内存里是一块连续空间元素靠下标访问。把一个数组逆序本质上不是“把数组旋转”而是调整下标映射关系原来下标为 i 的元素在逆序后的数组里应该跑到下标 n - 1 - i 的位置n 是数组长度。这个公式是整个问题的地基。举个例子数组长度是 5 时下标 0 的元素去下标 4下标 1 的去下标 3下标 2 的原地不动。如果你写 target[i] source[i]那只是复制不叫逆序只有写出 target[n - 1 - i] source[i]才真正完成了映射。我习惯把这个过程理解成“一摞书换个方向摆”。书还是那几本但顺序正好反过来了。数组跟物理世界的书有一点不同它可以不搬动元素只改变访问顺序甚至连数组本身都不用动这就是后面要说的倒序遍历。1.2 两条路线新建数组与原地交换面对逆序需求新手最先想到的是新建一个等长数组然后倒着填进去。这个方案的好处是原数组保持原样逻辑一眼就能看懂坏处是需要额外开一块同样大小的内存空间。另一条路线是原地交换用两个指针一个从开头走一个从结尾走每走一步就把两个位置的元素换掉直到两个指针在中间相遇。它不需要额外数组只需要一个临时变量代价是会直接修改原数组。这两种方案的取舍在真实开发中很常见。我整理了一张简单的对比表方案原数组是否改变额外空间代码直观度适用场景新建数组法不变O(n)高适合新手理解需要保留原数据时原地双指针交换改变O(1)中理解指针移动即可内存紧张、原数据可覆盖时没有哪个方案绝对更好。如果后续代码还要用原数组的原始顺序新建数组更安全如果内存本来就吃紧而且原数组最终就是要倒序使用那原地交换更合适。写之前先想清楚这一点比急着敲代码更重要。1.3 复杂度与边界想清楚再动手复杂度概念很多初学者听着头疼但数组逆序这个例子其实特别适合理解它。时间复杂度方面无论新建数组还是原地交换都要把每个元素至少过一遍所以是 O(n)n 是数组长度。这很好理解你总得看一眼每一个位置的值才能把它放到正确的地方。空间复杂度方面新建数组法额外占用 O(n) 空间原地交换法只多一个临时变量所以是 O(1)。在算法面试里“空间复杂度 O(1)”往往是个加分项因为这意味着即使数组有百万级数据内存开销也不会随着数据量增长。除了复杂度边界条件才是真正的坑。数组长度为 0 或 1 时不需要任何交换。长度是偶数时左右指针会对称地配对交换长度是奇数时中间元素不需要动。很多代码 bug 不是主逻辑错了而是没处理空数组和单元素数组。比如 in-place 方法里没判空直接拿 arr.length - 1 当 right空数组时 right 变成 -1循环虽然不会执行但如果循环内部先访问 arr[right]就会当场越界。所以任何数组操作前第一步永远是想清楚空数组会不会进来2. 四种常见写法的代码与细节坑2.1 新建数组法把索引映射写成循环新建数组法最容易理解先展示最标准的写法public static int[] reverseWithNewArray(int[] source) { if (source null) { return null; } int n source.length; int[] result new int[n]; for (int i 0; i n; i) { result[n - 1 - i] source[i]; } return result; }注意循环体里的关键写法result[n - 1 - i] source[i]。这是“正序遍历源数组逆序填目标数组”。也可以反过来写逆序遍历源数组正序填目标数组for (int i n - 1; i 0; i--) { result[n - 1 - i] source[i]; }两种写法效果一样我建议新手先只掌握第一种因为它只需要关心一个方向上的 i 变化写错的概率更低。这里有个很典型的错误写法result[i] source[i];这不是逆序是复制。但为什么那么多人会写错因为人脑容易把“逆序输出”理解成“每个位置换一个值”下意识就走了复制逻辑。真正动手前先在纸上写出几个下标对应关系比如 n 5 时 i 从 0 到 4目标下标依次是 4、3、2、1、0看到这串数字就知道代码该长什么样了。2.2 原地双指针交换最省内存的标准解法原地交换的核心是双指针向中间靠拢代码不长但每一步都有讲究public static void reverseInPlace(int[] arr) { if (arr null) { return; } int left 0; int right arr.length - 1; while (left right) { int temp arr[left]; arr[left] arr[right]; arr[right] temp; left; right--; } }流程拆开来看left 指向开头right 指向结尾只要两者没有相遇就一直交换当前两个位置的元素交换完 left 右移一位right 左移一位。当数组长度是奇数时left 和 right 最终会指向同一个中间位置循环停止偶数时两者会交错而过循环同样停止。为什么中间必须用一个 temp 临时变量因为数组赋值是覆盖式的。直接用 arr[left] arr[right]arr[left] 原来的值就丢了下一步 arr[right] arr[left] 赋给右边的还是刚覆盖进来的值等于白换。这就像两个杯子要互换液体你必须先找一个空杯把其中一边倒出来。还有一个小细节循环条件我建议写成 left right而不是 left ! right。原因是如果数组长度为偶数left 会从右边越过 right两者永远不会相等用 ! 判断就会一直循环下去直到越界。用 则天然安全。2.3 递归、栈与工具类不是主路但值得知道说完两种主流方案再聊几种非主流思路。它们在实际工程中用得少但能帮你加深对“逆序”这件事的理解。递归写法本质上还是双指针的思路只是把迭代换成函数调用public static void reverseRecursive(int[] arr, int left, int right) { if (left right) { return; } int temp arr[left]; arr[left] arr[right]; arr[right] temp; reverseRecursive(arr, left 1, right - 1); }调用时传 reverseRecursive(arr, 0, arr.length - 1)。递归版本看起来简洁但有两个问题一是函数调用本身有额外开销二是递归深度等于数组长度的一半数据量一大就可能触发栈溢出异常。JVM 默认栈大小有限几万层递归就可能扛不住。所以递归更适合用来理解分治思想不适合在真实业务里反转超大数组。栈的思路也很直接利用后进先出的特性把元素逐个压栈再依次弹出回填。熟悉栈结构的同学可以试着自己写一下但不推荐作为第一选择因为空间开销明显比双指针大。工具类 Collections.reverse 则是最省事的写法Integer[] array {1, 2, 3, 4, 5}; ListInteger list Arrays.asList(array); Collections.reverse(list); System.out.println(Arrays.toString(array)); // [5, 4, 3, 2, 1]但这里有个高频坑Arrays.asList 不能直接接收 int[]。如果你传一个 int[] 进去它会把整个 int[] 当成一个元素得到的 Listint[] 只有一个元素reverse 完毫无效果。必须使用 Integer[] 这种包装类型数组它才会被展开成多个元素。另外还要知道Arrays.asList 返回的 List 是原数组的视图修改列表元素会直接反映到数组上这也是为什么 Collections.reverse(list) 执行后原数组会被翻转。但该视图不支持增删操作如果你后面又调用 add 或 remove会抛出 UnsupportedOperationException。2.4 深拷贝与浅拷贝数组里复制的到底是什么这部分和逆序输出关系紧密因为很多人没意识到“新建数组”到底复制了什么。如果数组是基本类型比如 int[]那么新建数组时的赋值是逐字节复制值新旧数组互不影响。修改新数组中的某个整数原数组不会发生变化。如果数组是引用类型比如 String[] 或者自定义对象数组那么新建数组时复制的是引用而不是对象本身。也就是说新旧数组里的对应位置指向同一个对象。数组逆序只是让引用换了位置并没有复制对象内容。举个例子你有一个数组装了几个可变对象逆序之后你去修改下标 0 的对象原数组下标 n-1 的那个对象也会变因为它们是同一个对象。有一个简单的办法判断要不要担心这个问题数组元素是不可变对象比如 String、Integer逆序操作基本无风险数组元素是可变对象而且后续会修改元素内容就要重新评估是不是需要深拷贝。深拷贝通常要遍历每个元素重新构建对象那是另一个复杂话题这里只要意识到“引用复制”和“对象复制”的区别就够了。3. 动手实操从需求确认到测试验证3.1 动手前先问三个问题我拿到一个逆序需求时第一件事不是写代码而是先确认三件事。第一原数组能改吗能改就用原地交换不能改就新建数组。第二数组可能为 null 吗可能的话必须加判空否则一上来就访问 length 直接抛空指针异常。第三需求要的是“返回一个新数组”还是“只是打印倒序结果”这两个看起来差不多但代码路径完全不同。在真实业务场景里接口设计如果一开始没确认清楚后面返工成本很高。比如展示层要倒序展示数据后端接口返回的是按时间正序排列的数组你就只需要遍历时就倒序读而不是专门为展示去改底层数据。在线判题系统通常已经把输入输出格式定死了真实项目却常有歧义所以先问清楚永远是最高效的做法。3.2 完整可运行的演示程序我写一个可以直接编译运行的完整例子把新建数组法和原地交换法放在一起方便对比效果import java.util.Arrays; public class ArrayReverseDemo { public static void main(String[] args) { int[] source {1, 2, 3, 4, 5}; int[] reversed reverseWithNewArray(source); System.out.println(原数组: Arrays.toString(source)); System.out.println(新数组: Arrays.toString(reversed)); reverseInPlace(source); System.out.println(原地反转后: Arrays.toString(source)); } public static int[] reverseWithNewArray(int[] source) { if (source null) { return null; } int[] result new int[source.length]; for (int i 0; i source.length; i) { result[source.length - 1 - i] source[i]; } return result; } public static void reverseInPlace(int[] arr) { if (arr null) { return; } int left 0; int right arr.length - 1; while (left right) { int temp arr[left]; arr[left] arr[right]; arr[right] temp; left; right--; } } }运行结果是这样原数组: [1, 2, 3, 4, 5] 新数组: [5, 4, 3, 2, 1] 原地反转后: [5, 4, 3, 2, 1]这个演示把两种方案的核心差异表现得很清楚调用新建数组法后原数组 source 依然保持原来的顺序调用原地交换法后source 自己变成了倒序。如果你不想新建一个完整工程也可以直接用 jshell 把两个方法定义贴进去然后手动调用几秒钟就能看到效果特别适合快速验证。3.3 边界用例与自测技巧写完方法后一定要用边界用例自测。我习惯准备这样一个验证函数public static void assertReversed(int[] input, int[] expected) { int[] actual reverseWithNewArray(input); if (!Arrays.equals(actual, expected)) { throw new AssertionError( 测试失败: Arrays.toString(input) 期望 Arrays.toString(expected) 实际 Arrays.toString(actual)); } }然后在 main 里跑这一组用例assertReversed(new int[]{}, new int[]{}); assertReversed(new int[]{100}, new int[]{100}); assertReversed(new int[]{1, 2, 3, 4}, new int[]{4, 3, 2, 1}); assertReversed(new int[]{1, 2, 3, 4, 5}, new int[]{5, 4, 3, 2, 1}); assertReversed(new int[]{7, 7, 7}, new int[]{7, 7, 7}); System.out.println(全部通过);注意第一行的空数组用例经常会被人忽略但它恰恰能暴露判空逻辑有没有写对。只要 assert 跑通说明空数组、单元素、偶数长度、奇数长度、重复元素这些常见场景都没问题。测试原地版本时有个更巧妙的方法对一个数组连续反转两次结果应该和最初完全一样。你可以先把原数组用 Arrays.copyOf 复制一份作为快照反转一次后跟期望的逆序数组比对再反转一次跟快照比对。这样就不需要手动写很多期望值在回归测试里特别实用。3.4 只做逆序输出时还有一条更省的路第三种思路我先放在实操部分讲因为它解答了一个很容易被忽略的问题如果需求只是“把数组倒序打印出来”是不是非要反转数组答案是没必要。你可以直接倒序访问public static void printReverse(int[] arr) { if (arr null) { return; } for (int i arr.length - 1; i 0; i--) { System.out.print(arr[i]); if (i 0) { System.out.print( ); } } System.out.println(); }这样既没有新建数组也没有改变原数组时间复杂度依然是 O(n)空间复杂度更是低到只有几个循环变量。很多新手看到“逆序输出”四个字就条件反射地把数组反转一遍其实如果不需要保留反转后的数组倒序遍历才是最优解。这一点在业务里很常见。比如后端返回了一批按时间正序排列的数据前端展示时希望最新的在最前面。这时候直接把遍历下标反过来就行完全不用修改数据本身也不用额外复制一份倒序数组。看起来平平无奇但能让你的代码少掉很多没必要的内存分配。3.5 数据量变大时怎么选当数组长度只有几十、几百时新建数组和原地交换的差别根本感觉不出来。但一旦数据量上到百万级、千万级两者就拉开差距了。我粗略算一下一个 int 占 4 字节一百万个 int 的数组大约是 4MB。新建数组法意味着要额外分配 4MB对于大多数桌面应用和服务器来说无压力但如果在内存受限的环境里或者数组元素不是 int 而是更大的对象引用额外开销会叠加。原地交换在内存上几乎零负担但代价是原数组顺序被破坏。还有一个容易被忽略的点如果数组元素是对象原地交换只是在交换引用不产生对象复制所以效率上比新建数组更高。但如果你既不能动原数组又想省内存那就只能回到倒序遍历这也是为什么我在上一节反复强调“先判断需求到底要什么”。4. 踩坑实录常见问题与排查技巧4.1 高频 Bug 速查表我把实际交流中遇到最多的数组逆序问题整理成了这张表方便你对照排查症状常见原因排查方向ArrayIndexOutOfBoundsException循环里写了 n - i而不是 n - 1 - i检查 i 0 时是否访问到 n反转后数组完全没变代码写成了 result[i] arr[i]确认索引映射关系原地反转后数值丢失交换时没有用 temp 临时变量回看交换代码死循环循环条件用了 left ! right且数组长度为偶数换用 left right空指针异常入参为 null 时直接访问 length方法开头加判空Arrays.asList 反转 int[] 无效int[] 被当成单个元素改用 Integer[]后续增删列表报 UnsupportedOperationException误解了 Arrays.asList 返回的视图列表需要可变列表就 new ArrayList每一条都是我见过或踩过的真实问题。尤其是第一和第三新手几乎必中一个。4.2 一次典型的索引越界排查记录聊一个很有代表性的例子。有人写过这样一段代码for (int i 0; i source.length; i) { result[source.length - i] source[i]; }运行到 i 0 时直接报 ArrayIndexOutOfBoundsException。原因很简单source.length - 0 等于 source.length而数组最大有效下标是 source.length - 1。看到这个错误时我建议第一步不是改代码而是把下标关系写出来。拿长度 5 的数组举例i 从 0 到 4目标下标要依次是 4、3、2、1、0对应公式就是 source.length - 1 - i。一旦写出这串数字问题就一目了然了。排查这类问题时我习惯在循环里临时打印两个值当前 i 和目标下标。比如System.out.println(i i , targetIndex (source.length - 1 - i));看到控制台输出 0 - 4、1 - 3、2 - 2、3 - 1、4 - 0基本就能确认映射关系正确。这一步虽然简单但非常有效特别是当你盯着代码半天都找不到问题的时候。4.3 面试里常见的追问与回答思路数组逆序这个题目本身不难面试官真正想通过追问考察的是你对基础概念的理解程度。下面几个问题我遇到过多次这里给出回答思路。追问一这个算法的时间复杂度是多少能不能更快答案是 O(n)因为每个元素都至少要访问一次理论上不可能低于线性时间。更快的优化空间不在于降低时间复杂度而在于减少额外空间。追问二为什么要用双指针而不是新建数组因为双指针空间复杂度是 O(1)新建数组是 O(n)。如果数组很长双指针更节省内存适合内存受限的场景。追问三循环条件为什么不能写成 left ! right因为数组长度为偶数时left 和 right 会交错而过永远不可能相等可能造成死循环或越界。写成 left right 是更安全的选择。追问四如果只要求逆序打印不修改数组、不新建数组怎么办直接倒序遍历下标从 n - 1 走到 0。这算是第三种方案也是空间上最优的方案。追问五如果数据结构换成链表还能用同样思路吗链表不能随机访问下标所以不能直接用双指针交换。通常的做法是逐个调整节点的 next 指针也可以借助栈或递归实现这是另外一个经典问题了。能答到这个层次说明你是真的理解了数据结构差异而不是只会背代码。4.4 写完代码后的三个自查问题写完之后不要急着提交花一分钟问自己三个问题。第一边界情况考虑了吗传 null、空数组、单元素数组会怎样如果方法内部会直接抛异常说明防御还不够。第二副作用清楚吗开发中调用你的方法之后原数组还是不是调用前的顺序方法返回的到底是一个新数组还是原数组本身第三数据量大了怎么办如果这个方法会被高频调用或者处理的数组特别大新建数组的策略是否会带来明显的内存压力这三个问题覆盖了我过去踩过的大部分数组坑。很多代码 review 时被问住往往不是主逻辑有问题而是根本没想过这些边界和副作用。养成自己先自查的习惯能省下不少被追问的时间。关于数组逆序我以前也总觉得它简单到不值得专门总结。后来才发现越是简单的问题越能暴露基本功下标从 0 开始、边界条件、临时变量、浅拷贝与深拷贝每一个都是 Java 编程里绕不开的点。如果你正在准备笔试或面试我建议别光看文章把上面三种方案都手写一遍再跑几个边界用例。手写一遍和看一遍是完全不同的体验很多细节真的只有自己敲进去才会变成你的肌肉记忆。