ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java数组逆序详解:双指针原地交换与Collections工具类对比

2026/10/5 2:54:32 拓冰建站 浏览量
Java数组逆序详解:双指针原地交换与Collections工具类对比 1. 先搞懂题目到底在问什么1.1 动手之前先分清“逆序输出”和“数组逆序”很多刚学 Java 的人看到“将一个数组逆序输出”这个题目第一反应是这不就倒着打印一遍嘛。我面试过不少候选人十个人里有三四个就是这么理解的代码也写得飞快public static void printReverse(int[] arr) { for (int i arr.length - 1; i 0; i--) { System.out.print(arr[i] ); } }这段代码对吗对但只对了一半。它确实把数组从尾到头打印出来了输出结果是逆序的可数组本身的元素排列没有任何变化。打个比方这就像你把一本书从最后一页往前面翻着看看完之后书还是原来的顺序你只是改变了阅读顺序并没有改变书本身。题目里“逆序输出”四个字在不同场景下有两种理解第一种是上面这种只要求打印结果逆序第二种是真正把数组元素的位置颠倒过来让arr[0]变成原来的最后一个元素arr[arr.length - 1]变成原来的第一个元素然后再输出。面试和实际开发中第二种“原地逆序”才是考察的重点因为它涉及数组下标操作、元素交换、边界判断这些基本功。本文后面讲的所有方案都以“真正改变数组元素的排列顺序”为核心把两种理解都覆盖到。我的建议是拿到题目先问清楚需求是“只要输出逆序”还是“数组本身要逆序”。如果对方只说“逆序输出”那就两种都实现并说明区别这本身就是加分项。1.2 先绕开一个经典的大坑数组的引用传递有位读者曾经私信我说他在方法里写完逆序逻辑返回之后发现原数组没变代码大概是这样的public static void reverse(int[] arr) { int[] temp new int[arr.length]; for (int i 0; i arr.length; i) { temp[i] arr[arr.length - 1 - i]; } arr temp; // 想通过重新赋值让原数组变成逆序 }问题出在 Java 的传参机制上。Java 的参数传递是“值传递”但数组变量本身存的是一个引用可以简单理解为指向堆内存中真实数据区域的地址。你把arr传进方法时方法里的arr和外面的arr指向的是同一块内存区域通过arr[i] xxx这种下标操作确实能修改原来的数组但如果你执行arr temp这只是在方法内部把局部变量arr重新指向了另一个数组外面的数组变量还指着原来的内存区域当然什么变化都没有。这个坑如果不搞清楚后面写“新数组方案”时会一头雾水。记住一个核心准则数组对象内部的数据可以通过下标修改方法内对数组变量本身的重新赋值不会影响外部引用。理解了这一点再来看各种逆序实现思路会清晰很多。2. 核心做法用临时变量交换实现原地逆序2.1 双指针法的原理与边界条件原地逆序最经典、最推荐的写法是双指针法。思路非常简单定义两个下标left和right一个指向数组第一个元素一个指向最后一个元素然后交换这两个位置的值交换完后left往右走一步right往左走一步继续交换直到两个指针相遇或者错开为止。这个过程很像两个人从一根绳子的两端往中间走边走边交换手里的东西走到碰头时整根绳子上的东西就全部倒过来了。用生活里的事打比方一队人排队从左到右编号 1 到 10想让队伍倒过来就让排头和排尾互换位置然后第二个和倒数第二个互换依次进行最后队伍就反过来了。边界条件是这个写法的关键。循环继续的条件是left right注意不是left right。举个例子数组长度是奇数比如{1, 2, 3, 4, 5}left 和 right 会在中间元素3这里相遇。中间的元素本来就只有一个不需要跟谁交换所以当left right时循环就该停了。如果写成left right中间元素会被自己和自己交换一次虽然结果一样但多了一次无意义的操作。数组长度是偶数时指针永远不会相遇而是会“错身而过”left会越过right所以循环条件用left right也能正确处理偶数长度的情况。还要单独处理两个特殊输入空数组和单元素数组。空数组时left 0right -1left right不成立直接跳过单元素数组时left 0right 0条件同样不成立跳过。这两个边界天然被条件覆盖不需要额外写if判断这也是双指针写法优雅的地方。2.2 三种常见写法while、for、异或双指针法的代码有几种等价写法我逐个说一下。最直白的写法是用 while 循环public static void reverse(int[] arr) { if (arr null || arr.length 1) { 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--; } }也有很多人喜欢用 for 循环看起来更紧凑public static void reverse(int[] arr) { for (int left 0, right arr.length - 1; left right; left, right--) { int temp arr[left]; arr[left] arr[right]; arr[right] temp; } }还有一种用异或运算交换两个数的写法public static void reverse(int[] arr) { int left 0; int right arr.length - 1; while (left right) { arr[left] arr[left] ^ arr[right]; arr[right] arr[left] ^ arr[right]; arr[left] arr[left] ^ arr[right]; left; right--; } }三种写法的核心逻辑完全一样只是语法组织不同。我个人的经验是while 版本在面试时最好用因为每一步left和right--写得很明确方便向面试官解释思路for 版本写起来最省事适合平时开发。异或版本虽然不用临时变量看起来“很炫”但我真的不建议在面试和项目里用——异或交换要求两个数指向的是不同内存地址如果以后把这段逻辑改成对同一个下标操作会直接把数据清零可读性也远不如临时变量版本清楚。代码是写给人看的为了省一个临时变量牺牲可读性不划算。2.3 为什么双指针法是首选时间复杂度到底是多少分析一下复杂度双指针法只用了一个临时变量额外空间是 O(1)这叫作“原地算法”。循环从两端向中间走最多执行arr.length / 2次交换时间复杂度是 O(n)。这里很多人有个小误解觉得循环只走了数组一半长度所以是 O(n/2)在时间复杂度表示里O(n/2) 和 O(n) 是等价的因为常数系数不影响增长趋势。和另一种常见做法对比新建一个等长数组从原数组尾部开始遍历赋值给新数组头部这个方案时间复杂度也是 O(n)但额外空间是 O(n)。在数组规模大时原地逆序明显更省内存。比如处理一个包含 100 万条用户 ID 的数组原地逆序只需要一个 int 临时变量而新建数组方案要额外分配 4MB 内存。虽然现代机器内存很充裕但在 Android 开发、嵌入式环境、高并发服务端等场景下少分配一次大对象就能少触发一次 GC性能差异是能感知到的。所以双指针法是这个题目的最优解也是面试官最希望看到的思路。后面所有的高阶变形比如逆序字符串、逆序链表的一部分底层都是这套双指针交换的思想。3. 不动原数组的方案新数组与工具类的适用场景3.1 什么时候该用新数组倒序赋值不是所有场景都适合原地逆序。有的业务场景里原数组是共享数据或者基础数据别的地方还在用原来的顺序你直接把它逆序了会引发连锁问题。我举个实际例子一个报表系统里historyData数组缓存了按时间升序排列的销售记录一个统计模块要按最近时间优先展示数据。如果这里用原地逆序historyData就变成降序了其他依赖升序的模块全部遭殃。正确的做法是拷贝一份在新数组上做逆序。新建数组的写法很直观public static int[] reverseCopy(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[0]对应arr[arr.length - 1]result[1]对应arr[arr.length - 2]规律就是result[i] arr[arr.length - 1 - i]。这一步下标关系理清楚了代码就不会错。自己写的时候可以先拿一个长度为 5 的数组在纸上推一遍比如arr {1, 2, 3, 4, 5}i 0 时取 5 放到新数组第 0 位i 1 时取 4 放在第 1 位依次类推结果就是{5, 4, 3, 2, 1}。Java 里还有个更省事的拷贝方式System.arraycopy加手动处理但核心逻辑还是赋值。另外也可以用int[] arr {1,2,3};这种匿名数组再遍历但可读性不如上面这段清晰。3.2 想用现成 APICollections.reverse 的局限性不少同学上来就问Java 有没有现成的逆序方法答案是有但有限制。java.util.Collections类提供了一个reverse(List? list)方法可以直接把 List 里的元素顺序反转。但注意两点第一它操作的是 List不是数组。如果手里是一个Integer[]引用类型数组可以用Arrays.asList(arr)转成 List 后调用。但如果是int[]基本类型数组Arrays.asList(arr)得到的 List 里只有一个元素——整个数组对象本身调用reverse根本没用。这是 Java 泛型不支持基本类型造成的很多新手在这里踩过坑。第二reverse方法要求 List 是可变的Arrays.asList返回的 List 底层还是原来的数组长度固定但元素可以替换所以可以正常 reverse反转结果会同步反映到原数组上。这段代码可以这样写Integer[] arr {1, 2, 3, 4, 5}; ListInteger list Arrays.asList(arr); Collections.reverse(list); // 此时 arr 已经变成 {5, 4, 3, 2, 1}如果你手里是int[]又想用 API就得先转成Integer[]int[] arr {1, 2, 3, 4, 5}; Integer[] boxed Arrays.stream(arr).boxed().toArray(Integer[]::new); Collections.reverse(Arrays.asList(boxed));这个过程涉及装箱和流操作性能不如手动写循环。数据量大时这个转换带来的开销和不可读性远超收益。所以我的结论很直接基本类型数组逆序老老实实写双指针循环引用类型数组或 List用Collections.reverse是干净利落的方案。3.3 三种方案横向对比教你选把三种主流方案放在一起对比方案是否修改原数组时间复杂度额外空间代码复杂度适合场景双指针原地交换是O(n)O(1)低默认首选面试、大部分业务场景新建数组倒序赋值否O(n)O(n)低需要保留原数组顺序时Collections.reverse是针对ListO(n)O(1)最低Integer[] 或 List 场景选择逻辑不复杂如果原数组的逆序不会影响任何其他模块就用双指针原地交换省内存、速度快如果原数组还需要保持原来的顺序就用新建数组方案如果数据结构是 List 或Integer[]并且不需要考虑性能极致优化直接Collections.reverse一行搞定。还有一种少见但值得说的情况数组非常大比如千万级别逆序操作本身 O(n) 就够慢这时需要考虑分块处理或者用多线程分段逆序但这是大型系统才会遇到的问题面试里不会考这么深作为知识延伸了解一下就好。4. 完整可运行的实例与高频衍生问题4.1 写一个可以直接跑的逆序工具类光讲片段不给完整代码很多新手拼不起来。这里我写一个可以直接拿去用的工具类包含两种核心方式和一个测试入口import java.util.Arrays; public class ArrayReverseUtils { // 方式一原地逆序修改传入的数组 public static void reverseInPlace(int[] arr) { if (arr null || arr.length 1) { 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--; } } // 方式二返回新数组不修改原数组 public static int[] reverseCopy(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; } public static void main(String[] args) { int[] arr1 {1, 2, 3, 4, 5, 6, 7}; int[] arr2 {1, 2, 3, 4, 5, 6, 7}; System.out.println(原始数组 Arrays.toString(arr1)); reverseInPlace(arr1); System.out.println(原地逆序后 Arrays.toString(arr1)); int[] copied reverseCopy(arr2); System.out.println(原数组仍为 Arrays.toString(arr2)); System.out.println(新数组逆序为 Arrays.toString(copied)); } }运行结果原始数组[1, 2, 3, 4, 5, 6, 7] 原地逆序后[7, 6, 5, 4, 3, 2, 1] 原数组仍为[1, 2, 3, 4, 5, 6, 7] 新数组逆序为[7, 6, 5, 4, 3, 2, 1]Arrays.toString是调试数组的利器打印出来的结果直观可见。如果你在 IDE 里写练习建议在每一步循环处理完都打印一次数组观察元素如何逐渐交换这对建立“数组是连续内存区域”的直觉非常有帮助。4.2 逆序思想能解决哪些高频变体题这个基本题目的思路可以延伸到很多看起来毫不相关的题目上。字符串逆序是最高频的变体。字符串底层是字符数组思路和数组逆序完全一样只是 Java 提供了更便捷的StringBuilder.reverse()String str hello; String reversed new StringBuilder(str).reverse().toString(); System.out.println(reversed); // olleh如果你面试时被要求“不许用现成 API 实现字符串逆序”本质就是在考你字符数组的双指针交换public static String reverseString(String str) { char[] chars str.toCharArray(); int left 0; int right chars.length - 1; while (left right) { char temp chars[left]; chars[left] chars[right]; chars[right] temp; left; right--; } return new String(chars); }List 的逆序则直接交给Collections.reverse。二维数组逆序稍微绕一点分两种情况如果要求每一行内部逆序就遍历每一行对每一行调用双指针交换如果要求整个二维数组的行顺序颠倒就交换上下对称的行。前者是“行内逆序”后者是“行间逆序”本质还是双指针。还有一类看起来很难的题比如“给定一个数组把前 k 个元素逆序”“把区间 [i, j] 内的元素逆序”核心就是调整双指针的初始位置前 k 个元素逆序就让left 0, right k - 1区间逆序就让left i, right j。会了基础的双指针这些变形题基本就是改两行代码的事。4.3 面试官追问的经典问题怎么答这个题目在面试里不会只让你写代码写完之后大概率有追问。我整理几个高频的追问方向追问一数组逆序的时间复杂度和空间复杂度是多少答双指针法时间复杂度 O(n)因为每个元素最多被访问一次空间复杂度 O(1)因为只用了一个临时变量。如果被追问为什么不是 O(n/2)说明 O(n/2) 和 O(n) 在渐近复杂度里等价。追问二能不能用递归实现能但没必要。递归版本思路是每次都交换首尾元素然后递归处理去掉首尾的子数组代码如下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); }递归版本的时间复杂度同样是 O(n)但空间复杂度变成 O(n)因为每次递归调用都要占用一层调用栈。数组深度大时可能引发StackOverflowError所以生产环境不推荐。面试中能写出递归版本是加分项但最好主动说出它的缺点显示你不仅会写还理解底层代价。追问三如果数组是逆序的再执行一次逆序会发生什么结果是回到原始顺序。逆序操作是“对合”的也就是逆序的逆序等于原序列这可以用双指针的执行过程直观理解每个位置的值被交换两次又回到原位。问这个问题通常是考察你对操作本质的理解。追问四能否通过Arrays.sort实现逆序需要配合Comparator.reverseOrder()对数组进行降序排序但注意两点一是Arrays.sort对基本类型数组不支持自定义 Comparator必须用Integer[]二是逆序和降序排序是两回事——逆序是{1,3,2}变成{2,3,1}降序排序是{3,2,1}前者保持元素间的相对关系反转后者是重排。如果数组本身不是有序的用排序实现逆序会直接改变内容。这个追问的坑就在这里。5. 避坑指南这些错误我踩过你可能也会踩5.1 高频 Bug 排行榜与排查思路我带过不少刚入门的新人数组逆序这道题里有几个 Bug 出现的频率高得离谱。Bug 一for 循环遍历整个数组做交换结果又变回原来的顺序。比如有人写for (int i 0; i arr.length; i) { int temp arr[i]; arr[i] arr[arr.length - 1 - i]; arr[arr.length - 1 - i] temp; }数组{1,2,3,4,5}经过这个循环后还是{1,2,3,4,5}。原因是i 0 时交换了第 0 位和第 4 位i 1 时交换了第 1 位和第 3 位i 2 时交换了第 2 位和第 2 位自己和自己i 3 时交换了第 3 位和第 1 位——注意第 1 位已经被换过了这次相当于把之前的交换又还原了。循环走完每个元素被交换了两次等于没变。修复方式是把循环条件改成i arr.length / 2或者直接用 while 双指针并控制left right。Bug 二只打印逆序不改原数组还跟面试官说“已经逆序了”。这是对需求理解不到位。如果题目要求“逆序输出”打印倒序就够了如果要求“数组逆序”必须真正交换元素。我的经验是写代码之前先问清楚写完以后再主动说一句“我这里做了原地交换原数组已经被修改如果你的场景需要保留原数组我可以改成返回新数组”。这句话在面试里很加分。Bug 三忘记处理 null 或空数组。对null直接调用arr.length会抛NullPointerException对空数组arr.length - 1得到 -1后面再用arr[-1]访问会抛ArrayIndexOutOfBoundsException。虽然双指针版本的边界条件天然覆盖了空数组和单元素数组但入口处加一个防御判断更加稳妥这一习惯在大项目里非常重要。5.2 调试心得三步定位逆序过程中的问题很多初学者代码写错了不知道从哪里排查。我分享一下自己调试这类问题的固定流程。第一步打印数组初始状态。用Arrays.toString(arr)把原始数组完整输出分别记录操作前和操作后的数组内容对比就能看出结果是否符合预期。第二步在循环内部打印每一步的 left、right 和当前数组。比如在 while 循环里加上while (left right) { System.out.println(交换前left left , right right , 数组 Arrays.toString(arr)); int temp arr[left]; arr[left] arr[right]; arr[right] temp; System.out.println(交换后数组 Arrays.toString(arr)); left; right--; }这样能直观看到指针移动的过程。几乎所有的下标错误在“逐步打印”面前都无处遁形。第三步用边界用例验证。我每次写完这类算法都会用一个数组快速验一遍空数组{}、单元素数组{1}、双元素数组{1,2}、奇数长度数组{1,2,3,4,5}、偶数长度数组{1,2,3,4,5,6}。这几个用例能覆盖绝大多数边界场景。有条件的话写一个 JUnit 测试固化成自动化用例以后改代码也不怕回归。5.3 笔试做题时的答题策略与提分细节这道题在笔试和面试中都有出现答题策略直接影响评分。先说笔试。如果是在线判题环境要求你写完整方法并保证编译通过有几点要注意方法签名要跟题目要求完全一致比如题目要求public static void reverse(int[] arr)你就别改成public static int[] reverse(int[] arr)不要 import 无用的包变量名尽量语义化left、right就比a、b好写完检查一下循环边界确认left right而不是。在线笔试题往往有隐藏用例空数组、单元素数组、大数组都可能出现前面的边界处理要到位。再说面试。面试官考察的往往不是代码本身而是你的思维过程。拿到题目先不要急着写说清楚思路“我打算用双指针一头一尾交替交换直到中间位置这样时间 O(n)、空间 O(1)。”然后边写边解释每行代码的作用。写完以后主动提一下边界条件“这个方法对空数组和单元素数组也不会出错。”最后说一句升级版本“如果要求不能修改原数组我会用新建数组的方式但空间复杂度会变成 O(n)。”这一套流程下来就算代码有小瑕疵面试官也会看到你的工程素养。还有一个很多人不知道的细节面试手写代码时尽量避免使用 IDE 的自动补全也不要写太高级的语法特性。用最简单直白的写法减少出错概率也让面试官更容易理解。如果你用了异或交换这种写法除非面试官问“能不能不用临时变量”否则没必要主动秀这个操作。算法题的第一目标永远是正确其次才是优雅。6. 这个题目背后的工程思维从基础题到系统设计6.1 逆序操作在真实项目中长什么样数组逆序看起来像是教学题目实际工程里的应用比我最初以为的广泛得多。举几个现实例子。第一个场景是分页数据的最新优先展示。数据从数据库按时间升序查出来放在数组里展示时想要最新在最前最简单直接的方式就是对数组做逆序。如果原始数组还要继续用于计算逻辑就做一份逆序拷贝。第二个场景是滚动数组的环状处理。在一些队列实现里数组需要支持从尾部弹出并插入头部而底层没有链表结构时会先做整体逆序再插入。这种操作在环形队列、LRU 缓存的双端操作里偶尔能看到。第三个场景是算法里的数组变换。比如判断一个数组是否是对称的回文数组本质就是比较原数组和逆序数组是否相等旋转图像、矩阵翻转等二维数组问题底层也是逆序思想加转置操作。如果只会机械地“用 API 逆序”遇到这些题就会卡住理解了“双指针从两端向中间逼近”这个思维模型很多题目都能举一反三。6.2 从一道基础题看 Java 数组的内存布局逆序操作虽然简单但背后涉及一个很重要的基础认知Java 数组在内存里是连续存储的。对于一个int[]数组每个元素占 4 个字节取第 i 个元素就是通过首地址 i * 4计算出地址然后取值。所以通过下标访问数组任意位置的时间复杂度是 O(1)这也是双指针交换能够在 O(n) 内完成整个逆序的底层原因——如果数组是链表那种非连续结构双指针根本没法随机定位到尾部。还有一个值得了解的点是数组的不可变性边界。数组本身是长度固定的一旦创建就不能增加或删除元素但元素内容可以任意修改。所以“逆序”这个操作永远不会改变数组的长度它只是把所有元素重新排列了一遍。正因为数组长度固定、元素可改逆序才能用原地交换完成。这个特性也决定了如果你用ArrayList做逆序Collections.reverse底层同样是通过把elementData这个 Object 数组做双指针交换实现的思路完全一致。理解内存布局还有一个实际收益面试中被问到“为什么不是新建数组”时你能从内存分配和 GC 角度解释。新建一个百万元素的数组意味着要重新分配一块内存、逐元素拷贝、然后再把旧数组回收掉这对常驻内存的服务来说是很大的压力。原地逆序省下了这块开销也减少了 GC 的触发频率。这个层面的理解是区分“会写代码”和“真正理解代码”的分水岭。6.3 扩展思考逆序思想能推演出的进阶算法能在逆序题上做延伸的进阶内容很多我说几个自己认为最有价值的。一个是“部分逆序”与旋转数组。典型题目是把数组前 k 个元素逆序、后 n - k 个元素逆序、再整体逆序三步操作就能实现数组的“循环右移 k 位”。这是我特别推荐你自己推演一遍的练习给定{1,2,3,4,5,6,7}把前 3 个逆序变成{3,2,1,4,5,6,7}再把后 4 个逆序变成{3,2,1,7,6,5,4}最后整体逆序变成{4,5,6,7,1,2,3}——这就是数组右移 3 位的结果。这个过程把“逆序操作”提升为“数组变换工具”思路非常优雅。另一个是数据流场景下的逆序处理。当数据不是一次性全部到位而是逐个流入时没有办法整体逆序只能借助栈这种后进先出的结构。比如逐行读取日志文件想逆序输出所有行可以把每行压入栈中读完后依次弹出。这和数组逆序是同一个需求在不同数据结构下的实现方式。还有一个是并行环境下的逆序。多线程处理大数组逆序时可以把数组分成若干段每个线程各自对自己的段做双指针交换最后把整段逆序或者通过分段逆序再加整体逆序组合起来。这个思路不常用但如果你在系统设计面试中谈到大数据量处理能说出来绝对是一个亮点。我把这些延伸放在最后是想强调一个观念一道基础题的价值不在于题目本身而在于你能不能把它背后的方法抽象出来迁移到其他问题上。数组逆序的核心是“双指针相向遍历 交换元素”这个模型可以在字符串、链表、矩阵、甚至自定义对象列表上反复使用。你每掌握一种迁移方式Java 基本功就扎实一层。这也是为什么哪怕工作了几年我面试时仍然喜欢从这类基础题聊起——从简单的代码里最能看出一个开发者对数据结构底层逻辑的理解程度。如果你准备面试建议自己动手把前面每一种写法都敲一遍再用笔在纸上走一遍指针变化过程别只是眼睛看会。代码能力没有捷径手熟才是硬道理。