拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java数组全解析:基础用法、扩容原理、内存模型与面试避坑

Java数组全解析:基础用法、扩容原理、内存模型与面试避坑

有人问过我一个问题:都这个年头了,写“Java数组常见用法”还有人看吗?我的回答是:正因为常见,才真正值得往深里刨一刨。数组是Java里最容易被“以为会了”的基础知识点。增删改查谁都能写两行,可真到面试桌上、读框架源码、刷算法题的时候,关于数组的细节能卡住一大批人。ArrayList底层是Object[],HashMap的table是Node[],String内部是byte[],线程池的阻塞队列虽然是Queue,但它管理线程的核心数据结构也离不开数组。几乎你能叫得上名字的集合和组件,底座都是数组。这篇内容我会把数组的声明初始化、Arrays工具类操作、扩容原理、与List和String的互转、二维数组的内存模型,以及高频面试题一次性讲透。适合准备Java面试的开发者、刚学完语法想进阶的初学者,也包括写了好几年业务代码想回头补地基的同学。

1. 别被“基础”两个字骗了:数组的声明与初始化边界

1.1 四种写法背后的差异

先说声明和初始化。数组在Java里最常见的写法有下面四种:

// 动态初始化:只给长度,元素取默认值 int[] arr1 = new int[5]; // 静态初始化:直接给元素,长度由编译器推断 int[] arr2 = {1, 2, 3}; // 静态初始化的另一种形式:new 关键字 + 元素列表 int[] arr3 = new int[]{1, 2, 3}; // 匿名数组:没有变量名,直接作为方法参数 printSum(new int[]{4, 5, 6});

这里有个很容易踩的坑:int[] arr = {1, 2, 3};这种写法必须在声明的同时初始化,不能先写int[] arr;再写arr = {1, 2, 3};,编译会直接报错。如果你想“先声明,后初始化”,只能用第二种形式:

int[] arr; arr = new int[]{1, 2, 3}; // 合法
int[] arr; arr = {1, 2, 3}; // 编译错误

为什么会这样?因为{1, 2, 3}是数组初始化器,它只在声明语句里被编译器识别。而new int[]{1, 2, 3}是一个完整的“创建数组对象”表达式,随时可以用。这个区别看起来很细,但面试时真的会有人被问到“为什么不能先声明再初始化”,说不清楚。

1.2 默认值、匿名数组与方法传参

“动态初始化”只指定长度,不指定内容,那元素到底等于什么?答案是各类型的默认值:

数组元素类型默认值
byte / short / int / long0 / 0L
float / double0.0f / 0.0d
char'\u0000'(空字符)
booleanfalse
引用类型(String、对象等)null

很多初学者会在这里犯错,尤其是引用类型数组。String[] names = new String[3];创建出来的三个元素全是null,不是空字符串""。你要是直接names[0].length(),得到的不是0,而是NullPointerException。所以在遍历初始化出来的数组之前,养成先判空的习惯,能省下很多排查时间。

匿名数组的价值在于方法传参。比如你要调一个方法,方法签名是void handle(int[] values),你用handle(new int[]{1, 2, 3})直接传,不用单独定义一个变量。实际开发里这种写法不算多,但刷算法题和写工具类时很常用。另外要注意,匿名数组的写法里new int[]{1, 2, 3}里面不能写长度,一旦写了new int[3]{1, 2, 3},编译立刻报错。长度要么由列表元素决定,要么只给长度不写元素,二者不能混用。

1.3 数组是有length属性的特殊对象

我在带新人时经常问一个看起来很简单的问题:数组的length是属性还是方法?答案是属性。String.length()是方法,所以后面跟着括号;但arr.length没有括号,它是一个字段。原因在于,Java里的数组是一种特殊的对象:它不是通过new MyClass()这种方式由普通类实例化出来的,而是由JVM在运行时创建的一种“数组类”对象。这个数组类由JVM动态生成,自动带有一个公开的final int length字段,用于记录数组长度。正因为没有对应的.java类定义,所以数组无法像普通对象那样添加自定义方法,也就没有length()方法。

这一点还能引申出一个冷知识:int[].class是存在的,你甚至可以int[] arr = new int[3]; System.out.println(arr.getClass());,输出会带[I这样的前缀,其中[表示这是一维数组,I表示元素类型是int。数组对象还重写了clone()方法,所以arr.clone()可以直接调用。但也因为toString()没有被重写,System.out.println(arr)打出来的是一串像[I@1b6d3586的哈希串,并不是元素内容。想看内容得用Arrays.toString(arr),后面工具类部分会专门讲。

2. Arrays工具类:让sort、binarySearch、copy替你干活

日常开发中,真正徒手写数组遍历逻辑的场景没有想象中那么多,因为java.util.Arrays这个工具类已经把最常见操作封装好了。但问题在于,很多人只会用其中一两个方法,遇到排序、查找、拷贝时会重复造轮子,甚至造出带bug的轮子。下面按使用频率逐个讲。

2.1 排序:两个底层算法和自定义比较器

排序是数组操作里的顶流。Arrays.sort有两个典型的重载方向:

int[] nums = {5, 3, 8, 1}; Arrays.sort(nums); // 对基本类型数组排序,底层是双轴快排 String[] names = {"Tom", "jerry", "Alice"}; Arrays.sort(names); // 对对象数组排序,底层是TimSort(归并+插入的混合)

为什么要分两套算法?这里有个底层逻辑:基本类型数组不要求排序稳定性(稳定的意思是值相等的两个元素排序后相对位置不变),而对对象数组来说,稳定性常常是业务需要的——比如先按姓名排序,再按年龄排序,如果排序不稳定,第二次排序会把第一次的结果打乱。所以JDK对基本类型直接上更快的双轴快排,对对象则用稳定的TimSort。面试如果被问到“为什么Arrays.sort对不同类型有不同实现”,这个稳定性理由就是标准答案。

再看自定义排序。二维数组排序在业务里很常见,比如我有一颗热搜词叫“二维数组”,很多LeetCode题目和报表排序都会用到:

int[][] arr = {{1, 9}, {2, 3}, {3, 6}}; Arrays.sort(arr, (a, b) -> Integer.compare(a[1], b[1])); // 结果:{{2, 3}, {3, 6}, {1, 9}}

这里要特别提醒一下,Comparator里不要直接写a[1] - b[1]。看着很简洁,但如果两个值一正一负,相减可能溢出,导致排序结果错误。用Integer.compare(a[1], b[1])是更稳妥的写法。排序后想降序,可以用Comparator.reverseOrder(),或者比较器里调换前后项的位置。

2.2 查找与填充:binarySearch的返回值规则

数组查找最容易被误解的方法就是Arrays.binarySearch。它要求数组必须是有序的,否则结果不可靠。这个方法的返回值有固定规则:找到就返回索引,找不到就返回-(插入点) - 1。

int[] nums = {1, 3, 5, 7}; System.out.println(Arrays.binarySearch(nums, 5)); // 找到,输出2 System.out.println(Arrays.binarySearch(nums, 4)); // 找不到,插入点在下标2,输出 -(2)-1 = -3

为什么设计成-(插入点)-1而不是直接返回负数?原因很简单:如果直接返回负数,那0就会产生歧义——到底是找到了下标0,还是找不到返回了0?用-(插入点)-1之后,所有“找不到”的结果都严格小于等于-1,和“找到下标0”的返回值不会再混淆。这个设计在Java里到处都是,理解后对你读源码很有帮助。

填充方法Arrays.fill就直白多了,把数组里所有元素替换成同一个值:

int[] counter = new int[10]; Arrays.fill(counter, 0); // 初始化计数表

它也可以指定区间:Arrays.fill(counter, 1, 5, 42),把下标1到4的元素设为42,右边界5是不包含的。这个区间语义和大量Java API保持一致,别记反。

2.3 拷贝三兄弟:copyOf、copyOfRange与System.arraycopy

数组长度固定,所以“扩容”在底层必然涉及拷贝。Arrays.copyOf是把老数组复制到一个新长度的数组里:

int[] old = {1, 2, 3}; int[] bigger = Arrays.copyOf(old, 5); // 结果:[1, 2, 3, 0, 0],多出来的位置填充默认值

Arrays.copyOfRange是复制指定区间:Arrays.copyOfRange(old, 1, 3)得到[2, 3],右边界同样不包含。它还有一个好处:即使右边界超过原数组长度,也会用默认值补齐,不会抛越界异常。

底层真正干重活的是System.arraycopy。这是一个native方法,由JVM直接调用操作系统级别的内存复制,效率比循环赋值高得多。Arrays.copyOf内部就是先创建一个新数组,然后调用System.arraycopy把元素搬过去。在日常代码里,优先用Arrays.copyOf和copyOfRange这类高级封装,除非你确实需要对拷贝的起止位置做精细控制,否则没必要直接碰System.arraycopy。但有一点要知道:这些拷贝方法对引用类型数组都是浅拷贝,拷贝的是“引用”而不是“对象本身”,后面第5章会展开讲。

3. 数组的“增删改”与遍历:动态扩容不是黑魔法

3.1 三种遍历方式与增强for的编译真相

数组遍历有三大流派:普通for循环、增强for、Java 8的Stream。先看前两种:

// 按索引遍历,可以修改元素 for (int i = 0; i < arr.length; i++) { arr[i] = arr[i] * 2; } // 增强for,适合只读遍历 for (int x : arr) { System.out.println(x); // 这里修改x不会影响arr }

很多人以为增强for是“语法糖”,底层就是用迭代器。对于数组来说,这个理解不准确。编译时,for (int x : arr)会被编译器翻译成普通的索引for循环,因为数组没有Iterator,直接下标访问反而高效。而对ArrayList这类集合,增强for才会用到迭代器。这也是为什么在数组场景下,增强for和普通for性能几乎没差别的原因。但对于集合,增强for在遍历过程中如果结构被修改,会抛ConcurrentModificationException,数组则没有这个问题,因为数组长度固定,本身不能增删。

值得注意的一个细节是:增强for里面的循环变量是“值拷贝”。如果你遍历的是对象数组,例如for (StringBuilder sb : sbArr),你拿到的是指向同一个对象的引用,所以sb.append("x")能改动原对象;但如果你写sb = new StringBuilder("y"),仅仅是把循环变量指向了新对象,原数组里的引用不受任何影响。这个“变量赋值”和“对象内容修改”的区分,是写代码时最常见的认知混乱点。

3.2 数组增加元素:扩容的本质与代价

热搜词里有个很有代表性的问题:数组增加。Java数组的长度从创建起就是固定的,所以“增加元素”本质上只有一条路:新建一个更长的新数组,把旧元素搬过去,在新位置写入值。直接用Arrays.copyOf:

int[] arr = {1, 2, 3}; arr = Arrays.copyOf(arr, arr.length + 1); // arr变长一格 arr[arr.length - 1] = 4; // 尾部追加

这段代码背后发生的事情:先申请一块新内存,长度为4,然后把1、2、3复制过去,最后在最后一个下标写入4。原来的3个元素的数组对象如果没有其他引用指向它,会被垃圾回收。

这里要理解扩容的时间代价。每次“增加”都需要复制全部旧元素,单次复杂度是O(n)。ArrayList之所以把每次扩容都干到1.5倍,而不是只加一个位置,就是为了摊销这个成本:平摊下来每次尾部追加的代价接近O(1)。面试常考的“为什么ArrayList扩容是1.5倍而不是2倍”,从内存和碎片化的角度都能聊,但核心要点是:扩容必须批量预留空间,否则频繁拷贝会拖垮性能。所以,如果你的数据规模会频繁增长,优先用ArrayList而不是裸数组;如果确定数据量不变,或者对性能极其敏感,才建议直接用数组。

3.3 删除与替换:System.arraycopy的精细操作

删除数组中间的元素,同样不能真的“删”,只能覆盖。比如删除下标index的元素,需要把index后面的元素整体向前移动一位:

int[] arr = {1, 2, 3, 4, 5}; int index = 2; System.arraycopy(arr, index + 1, arr, index, arr.length - index - 1); arr[arr.length - 1] = 0; // 把最后一个位置“清掉”

第一行是把从index+1开始的所有元素,搬到从index开始的位置,搬动数量是arr.length - index - 1。搬完之后,数组末尾还剩一个重复值,所以第二行手动把末尾重置为0。这个模式在实现自己的集合类时非常常见。

如果数组元素是引用类型,末尾那个位置重置成null还有一个作用:帮助垃圾回收。因为旧对象还挂在数组上就是一个强引用,GC没法回收它。虽然对局部变量来说,方法结束变量就销毁了,但如果这个数组本身是某个对象的成员,长期存活,里面堆积失效引用就会造成内存浪费。这也是为什么很多源码在删除元素后会把对应位置置空,最典型的就是ArrayList.remove里那一行elementData[--size] = null;。

3.4 一个真实场景:分割字符串数组并按条件筛选

热搜词里有一条特别接地气的需求:数组分割并显示包含某一字符。实际场景可能是拿到一串逗号分隔的配置,要筛出包含特定关键词的项。完整示例:

String raw = "apple,banana,grape,avocado"; String[] fruits = raw.split(","); // Java 8+ 流式筛选 List<String> matched = Arrays.stream(fruits) .filter(s -> s.contains("an")) .collect(Collectors.toList()); System.out.println(matched); // [banana] // 传统for循环版 for (String fruit : fruits) { if (fruit.contains("an")) { System.out.println(fruit); } }

这里有几个会实际踩到的坑。第一,split默认会丢弃尾部的空字符串。比如"a,,b,".split(",")的结果是["a", "", "b"],最后一个空串被去掉了。如果业务要求保留,必须用重载方法split(",", -1),这样末尾的空串才会保留。第二,split的参数是正则表达式,如果分隔符是.、|、*这种元字符,直接写会得到意想不到的结果,必须转义成"\\."或"\\|"。第三,查出结果后要处理“没有匹配项”的情况,否则后续拿着空列表做操作容易出问题。这个小需求看起来简单,但能把字符串分割、正则转义、数组遍历、条件筛选全串起来,非常适合作为面试的手写题。

4. 数组与List/String互转:几个踩了无数次的坑

4.1 数组转List的三个坑

数组转List最常用的方法是Arrays.asList,但它有一堆反直觉的行为,我几乎每次代码评审都能看到有人踩。

第一个坑:返回的List不支持增删。Arrays.asList("a", "b")返回的其实是Arrays内部的一个私有ArrayList类,它继承自AbstractList,但add和remove没有实现,调用就会抛UnsupportedOperationException。想要真正可增删的列表,得再套一层:

List<String> realList = new ArrayList<>(Arrays.asList("a", "b"));

第二个坑:它和原数组共享数据。asList返回的List底层直接指向原数组,改动List里的元素,原数组也会跟着变;反过来改数组,List也变。对很多业务来说,这个共享行为既违背直觉,还容易埋下隐蔽bug。

第三个坑:基本类型数组转List不能直接用Arrays.asList。你写:

int[] nums = {1, 2, 3}; List<int[]> wrong = Arrays.asList(nums); // 你以为List<Integer>,实际是List<int[]>,size等于1

因为泛型只认引用类型,int[]被整个当成一个对象塞进去了。正确的做法是用Stream:

List<Integer> right = IntStream.of(nums).boxed().collect(Collectors.toList());

数组转字符串是另一个高频操作。拼接可以用String.join或者StringBuilder循环,但如果你只是想打印调试,Arrays.toString(arr)是最省事的,二维数组则用Arrays.deepToString(arr)。很多人直接System.out.println(arr),看到一串[I@cafe就懵了,其实只要记得数组没重写toString,一切就都合理了。

4.2 List转数组:new String[0]还是new String[size]

反过来,List转数组也有讲究:

List<String> list = new ArrayList<>(List.of("a", "b")); String[] arr1 = list.toArray(new String[0]); String[] arr2 = list.toArray(new String[list.size()]);

两种写法都对,但社区里普遍推荐new String[0]。原因在于:如果你传入new String[list.size()],集合内部发现数组长度刚好够,就会直接填充这个数组并返回。看起来省了一次复制,但这个“正正好好”的数组是你在外部刚创建的新数组,长度完全取决于调用瞬间的size()。如果之后List内容变化,这个数组长度就过时了。传new String[0]时,集合内部会自己判断:长度不够就创建新数组,长度够就用传入的。JDK开发者也在多个版本的方法文档里建议使用“零长度数组”这种形式。实际性能差异微乎其微,但语义上更干净,所以我建议统一写toArray(new String[0])。

4.3 可变参数与main方法:数组的隐藏身份

每个Java程序员写下的第一个方法就是public static void main(String[] args),这里的args正是一个String[]数组。也就是说,命令行参数本质上是JVM把参数列表打包成一个字符串数组传给了main方法。与此类似,可变参数本质上也是数组:

public void log(String message, Object... args) { // args就是Object[] for (Object arg : args) { System.out.println(message + ": " + arg); } }

编译之后,Object... args会被当成Object[] args处理。所以,一个方法声明为void a(String... s),调用时可以穿零个、多个参数,或者直接传一个String[]。这里有个小陷阱:重载时,固定参数方法的优先级高于可变参数。如果你同时定义了a(String s)和a(String... s),调用a("x")永远走的是固定参数那个。理解了可变参数和数组的关系,面试遇到“可变参数和数组重载问题”就不会慌。

5. 二维数组、引用数组与内存视图

5.1 二维数组是“数组的数组”,不是矩阵

二维数组在Java里的本质是一维数组的数组。int[][] matrix = new int[3][4];做的事是:先创建一个长度为3的外层数组,外层数组每个元素都是一个长度为4的int[]引用。所以:

matrix.length // 3,外层数组的长度,即行数 matrix[0].length // 4,第一行那个内层数组的长度,即列数

也正因为这个结构,Java支持“不规则数组”,也叫锯齿数组。每一行的长度可以不一样:

int[][] jagged = new int[3][]; jagged[0] = new int[2]; jagged[1] = new int[5]; jagged[2] = new int[3];

这在C语言里做不到,因为C的二维数组是连续内存块。Java这种设计换来了灵活性,也带来了更清晰的“引用”语义。遍历二维数组的标准姿势就是双层循环:外层循环取行,内层循环取列。注意matrix[0]可能为null,如果创建时只指定了外层长度、内层还没逐个初始化,必须先对每一行做判空。

热搜词里的“指针数组”其实是C语言的概念:元素本身是指针的数组,比如char *argv[]。Java没有指针,但“引用数组”要解决的问题是一样的——数组里存的是对象的引用(可以粗略理解成地址),通过这个引用能访问到堆里的实际对象。二维数组就是这种引用的典型体现:外层数组元素指向内层数组对象,而不是把一整块数据平铺开。

5.2 数组作为方法参数:引用传递还是值传递

这是个经典面试题。Java的参数传递只有一个规则:值传递。但数组是一种对象,所以传数组时传的是“引用变量的值”,也就是对象地址的拷贝。结果就是:在方法里修改数组元素,外面的数组会跟着变,因为两个变量指向同一个数组对象。但如果方法里把参数重新指向一个新数组,外面的引用不受影响。

public void change(int[] arr) { arr[0] = 99; // 外部数组第0个元素也会变成99 arr = new int[3]; // 外部引用仍然指向原数组,不受影响 }

这个“修对象内容”和“改引用指向”的区分,比单纯背“值传递/引用传递”结论重要得多。理解了它,你就知道为什么很多工具方法在内部不修改入参数组,而是返回一个新数组,比如Arrays.copyOf。也正是因为数组是对象,才有clone()、getClass()这些对象方法,这也是它和C数组最大的区别之一。

5.3 浅拷贝与深拷贝:对象数组的危险共享

数组拷贝方法,包括clone()和Arrays.copyOf,对引用类型数组都是浅拷贝。什么意思?看这个例子:

StringBuilder[] sa = {new StringBuilder("a")}; StringBuilder[] sb = sa.clone(); sb[0].append("x"); System.out.println(sa[0]); // 输出a x?不对,输出ax,原数组被影响了

clone()只复制了数组里存的引用,复制后的sb[0]和sa[0]指向同一个StringBuilder对象。你通过sb[0]修改对象内容,sa[0]自然能看到变化。对不可变对象(比如String、Integer)来说,浅拷贝无所谓;但对于可变对象,这就是一个隐蔽的共享陷阱。

二维数组的拷贝同样危险。int[][] copy = matrix.clone();只是把外层数组复制了一份,内层数组还是原来的那批。你改copy[0][0],matrix[0][0]也会变。想要真正的深拷贝,必须手动逐个拷贝内层数组:

int[][] deepCopy = new int[matrix.length][]; for (int i = 0; i < matrix.length; i++) { deepCopy[i] = matrix[i].clone(); }

搜索“数组拷贝”相关问题时,只要看到“二维数组”“对象数组”“浅拷贝”这几个关键词同时出现,基本就是在考察这一点。记住一个口诀:拷贝数组本身很容易,但拷贝数组里的对象,永远要用循环。热搜词里还有一条“树状数组维护前缀和sum(11)”类型的算法题,那个场景里数组是基础存储结构,但算法操作不走拷贝路线,而是通过索引直接更新和查询,这就更依赖于你对“数组底层是连续内存”的理解了。

5.4 跨语言视角:Java数组少了什么

这里插入一个对比,帮我看得更清楚。Java数组和C/C++数组的差异,简而言之有三条:第一条,Java数组自带长度信息,arr.length随时可查,C/C++的数组退化成指针后长度就丢了,只能自己额外记;第二条,Java数组有运行时越界检查,访问arr[5](长度为3)会抛ArrayIndexOutOfBoundsException,C/C++则是未定义行为,可能读到垃圾内存;第三条,Java不允许指针算术,你不能用arr + 1跳到下一个元素,这让数组操作安全得多,但也少了那种底层操控感。

这背后其实是Java的安全设计理念:宁可要高一点的开销(越界检查),也不要让程序员把内存搞崩。JIT编译器会优化掉多数不必要的检查,所以在循环热点里,Java数组的性能并不会有想象中那么差。理解了这些差异,再看热搜词里的“C++多维数组指针”——它和Java的引用数组是两个世界,一个是纯内存地址运算,一个是带类型的对象引用模型。

6. 高频面试题与实战避坑清单

6.1 经典题:数组有没有length()方法?new T[10]能编译吗?

这两道题几乎每次面试都会出现。第一题前面已经聊过:数组没有length()方法,只有length属性,因为数组不是普通类定义出来的对象,JVM直接创建了带length字段的数组类。

第二题更深入一点:为什么不能写new T[10]创建泛型数组?核心原因是类型擦除。Java的泛型在运行时会被擦除成它的上界或Object,JVM根本不知道T具体是谁。而数组和泛型有一个根本冲突:数组是“运行时自省”的,它在创建时就必须知道具体元素类型,并且每次赋值都会做运行时类型检查。比如Object[] objs = new String[3]; objs[0] = 1;,这行代码能编译,但运行时立刻抛ArrayStoreException,原因就是数组记住了自己“实际上是String[]”这件事。泛型构造时无法提供运行时具体类型,所以被禁掉了。妥协方案就是:

@SuppressWarnings("unchecked") T[] arr = (T[]) new Object[10];

这种写法在很多框架源码里都会出现,但需要靠@SuppressWarnings压制编译器的警告。理解“数组协变但集合不变”的差异,是Java类型系统里很有价值的一课。

6.2 数组与ArrayList:为什么底层是数组还要封装

另一道高频题:数组和ArrayList有什么区别?标准答案无非是:数组长度固定,ArrayList长度可动态增长;数组可以存基本类型,ArrayList只能存引用类型(泛型不能是基本类型);数组访问和写入更快,ArrayList多了封装和方法调用开销;数组是Java语言内置的,ArrayList是集合框架里的类。但我想补充一个更深层的理解角度:ArrayList本质上就是把数组的“扩容、越界检查、增删改细节”都封装好了。它底层维护一个Object[] elementData,当元素数量达到数组容量上限时,就执行oldCapacity + (oldCapacity >> 1)的扩容,即1.5倍,然后Arrays.copyOf一把梭。你背下这句话很容易,但我建议你顺手打开JDK源码看一眼grow方法和ensureCapacity方法,比看十篇面试题解析都有用。理解了这层关系,面试被问到“ArrayList扩容为什么是1.5倍”时,你可以从“为什么不每次扩容1个位置(性能太差)”和“为什么不直接2倍(内存浪费和碎片化风险)”两个方向回答,这就能体现出你是真的理解,而不是背答案。

6.3 手写冒泡排序与算法场景:从排序到前缀和

热搜词里“冒泡排序java”热度一直不低,说明手写排序仍然是面试常见题。虽然工程上你不会用冒泡排序——Arrays.sort快得多——但手写它能考察你对循环边界、交换逻辑和优化意识的理解。一个完整可运行的版本:

public static void bubbleSort(int[] arr) { if (arr == null || arr.length <= 1) { return; } for (int i = 0; i < arr.length - 1; i++) { boolean swapped = false; for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = true; } } if (!swapped) { break; // 这一轮没有发生交换,说明数组已经有序 } } }

这里有两个容易写错的地方:内层循环的终止条件是arr.length - 1 - i,因为每一轮都会把当前最大值冒到末尾,下一轮就不用再管末尾的元素了;优化点在于提前退出,当某一轮完整扫描后一次交换都没发生,说明数组已经有序,再继续扫描纯属浪费。这个swapped标志就是初级版和进阶版的区分点,很多面试官会专门看你会不会加这个优化。

数组在算法题里的角色远不止排序。热搜词里提到了“树状数组维护长度 n = 16 的序列。查询前缀和 sum(11) 与单点修改 add(3, x)”——这类数据结构的基础存储就是数组。但即便不做树状数组,前缀和本身就值得掌握:

int[] nums = {1, 2, 3, 4}; int[] pre = new int[nums.length + 1]; for (int i = 0; i < nums.length; i++) { pre[i + 1] = pre[i] + nums[i]; } // 求下标l到r的区间和:pre[r + 1] - pre[l]

为什么前缀和数组长度要加1?因为这样pre[i]恰好表示“前i个元素的和”,求sum(l, r)时可以直接用pre[r+1] - pre[l],避免了在边界条件上做大量判断。这个设计思路在很多用数组模拟区间场景的算法里都是标配。

6.4 一组实战避坑规则

最后我把自己写代码时反复踩过的坑整理成几条规则,不管你是新手还是老手,都建议在提交前过一遍:

  • 访问数组前先判断数组本身是不是null,尤其是从外部接口、文件、网络拿到的数组。很多人只检查下标,却在arr.length上抛了NullPointerException。
  • 能用arr.length就不要用魔法数字。数组重构后长度可能变化,写死的数字是最隐蔽的bug来源。
  • 遍历时如果需要同时用多个下标,统一用普通for循环。增强for适合只读,别在增强for里尝试写回。
  • 对象数组之间做拷贝时,先确认被拷贝对象是否可变。如果是,要评估是否需要深拷贝。
  • 不要把Arrays.asList的结果直接当成普通List传给需要增删的方法,除非你非常清楚它不支持结构性修改。
  • 基本类型数组转List时,记得用IntStream.of(arr).boxed(),别用Arrays.asList(arr),否则你会得到一个“只包含一个数组对象的列表”。
  • 二维数组打印用Arrays.deepToString,一维用Arrays.toString,直接打印数组变量只会得到哈希串。
  • 数组作为返回类型时,如果不想让外部随便改内部状态,可以返回arr.clone(),而不是直接返回原数组引用。这在设计不可变工具类时非常关键。

如果让我给新人一个学习建议,我不会让他们去死背API。数组这玩意儿,核心就一句话:它是连续内存上一段带有类型信息的对象。把这个模型焊死在脑子里,再去看ArrayList的扩容、HashMap的位运算、队列栈的实现、前缀和的推导,基本是一马平川。日常开发里,数组可能不像集合那么出镜,但它是所有数据结构的底座。我文中所有这些例子都在实际项目和面试题里出现过,照着敲一遍,比看十遍总结有用得多。

返回列表