1. 先把数组这层窗户纸捅破:搞清楚“更新”到底在更新什么
很多人学Java的时候,数组这块都是囫囵吞枣过去的。声明数组、遍历数组、用下标取元素,考试能过,刷题能写,但一旦碰到“往数组里加一个数据”或者“把某个位置的元素换掉”,就卡住了。问题就出在“更新到数组”到底是什么意思没弄明白。
先看一段最基础的代码:
int[] scores = {85, 92, 78, 88, 90}; scores[2] = 100;这就是最简单的“更新到数组”。意思很明确:把数组下标为2的位置(也就是第三个元素)从78改成100。这种操作叫按索引覆盖,在数组场景里其实有个很硬性的前提——那个位置必须已经存在。
为什么这么说?因为Java数组是定长的,或者说它是一个固定在堆内存里的连续空间。下面这段代码就是新手最常见的翻车现场:
int[] scores = new int[5]; scores[5] = 90; // 编译不报错,运行直接抛异常数组下标是0到4,你偏要写5,运行时就给你一个ArrayIndexOutOfBoundsException。这不是语法问题,是内存越界问题。很多没接触过C语言的朋友不理解“越界”这两个字,其实你可以把数组想成一排有编号的储物柜,一共5个格子,编号从0到4,5号是不存在的,你非要把东西往不存在的格子里塞,不弹出来才怪。
再说深一层。数组变量本身存的是引用(内存地址),不是数据实体。真正存数据的是一块连续的堆内存。所以你要更新数组,本质上改的是这块堆内存里的值。这一点在你处理引用类型数组时尤其重要,后面我会专门展开。
“更新到数组”这个说法,其实包含了两类需求:
- 更新已有的元素:数组中已经占位了,你只需要把对应下标的位置换成新值。
- 往数组里添加新数据:数组已经满了或者还没满,你需要把新数据“塞进去”。
后者比前者麻烦得多。麻烦的根源在于数组长度固定。如果你一开始声明new int[5],那这个数组一辈子都是5个格子,你想塞第6个数据,唯一的办法是另外开一个更大的数组,把旧数据搬过去。不少新人死活理解不了这一步,总觉得数组应该和列表一样能自动扩容——这是把数组和集合的机制搞混了。
数组为什么不全自动扩容?因为自动扩容是有代价的,需要重新分配内存,再把旧数据复制过去。Java的设计思路是:频繁的扩容会带来性能损耗,数组更应该承载“大小已知、内容反复修改”的场景。所以ArrayList这种动态容器才应运而生,它的底层其实也是一个数组,只是在帮你自动完成“扩容+复制”的操作。
我对初学者的建议是:把数组当成“定长的批量变量”来看。你需要的是一个盒子,里面固定放5个整数,那数组完美;如果你需要的是“不断往里面加东西的袋子”,直接用ArrayList,别为难自己。但即便如此,理解数组的更新机制依然很重要,因为ArrayList的很多底层问题,追根究底都是数组问题。
2. 四种更新操作拆开揉碎:覆盖、追加、插入、删除
2.1 按索引覆盖:最简单的“更新”
覆盖是最直白的更新,不需要搬家,不需要扩容,就是把某个位置的老数据换成新数据。
String[] names = {"张三", "李四", "王五"}; names[1] = "赵六"; // 遍历输出:张三 赵六 王五 for (String name : names) { System.out.println(name); }这里要注意几个容易被忽略的点:
下标是从0开始的。这是无数新人掉过的坑。names[1]在人类眼里是第二个,在数组眼里是第二个,但程序里你写1确实能取到第二个元素——因为下标0是第一个。如果有人说“把第一个元素改成XX”,你写了names[1],那你改的就是第二个了。
越界不报编译错,运行才炸。Java编译器不会勤奋到帮你检查数组下标是否越界,它把这个问题抛给了运行时。这意味着你把代码交出去之前,一定要自己心理默数一下边界。
覆盖操作是最便宜的更新操作,时间复杂度是O(1),因为它不需要移动任何数据,指定位置直接赋值。这一点在后续学习数据结构时很重要,数组的随机访问能力就是靠这个。
2.2 追加元素:注定要“搬家”的操作
往数组末尾追加一个元素,听起来要简单,实际上分两种情况。
第一种,数组还没满。这种情况不需要新的数组,你只需要维护一个计数器,记住已经用到了哪个下标:
int[] data = new int[10]; int size = 0; data[size] = 100; size++; data[size] = 200; size++;这种情况下data数组容量是10,但你只用了2个。这种方式在底层开发里非常常见,因为它避免了无意义的扩容操作,性能不错。但要注意,新手很容易搞混“数组声明长度”和“有效元素个数”,所以通常我们会单独用一个size变量来记录。
第二种,数组满了,你要硬塞进去。这时候就得扩容:
int[] data = {1, 2, 3}; int[] newData = new int[4]; System.arraycopy(data, 0, newData, 0, data.length); newData[3] = 4; data = newData;System.arraycopy是JDK提供的高性价比数组复制方法,它是移植的左膀右臂,写起来略繁琐,但性能和可靠性都有保证。另外一个更直白的写法是:
int[] data = {1, 2, 3}; data = Arrays.copyOf(data, data.length + 1); data[data.length - 1] = 4;Arrays.copyOf其实底层也调用了System.arraycopy,只是帮你把新数组的创建也一并处理了,代码看起来更清爽。我更推荐后一种写法,因为思路清晰:扩容 -> 最后一个位置赋值。新手不用纠结那么多细节,先理解拷贝的原理,后面再逐步优化。
追加元素的本质是数组长度不可变这个先天约束带来的连锁反应。你想往满了的数组里加东西,就得重新分配一块更大内存,然后复制旧数据,再丢弃旧数组(让GC回收掉)。这个操作的代价是O(n),n是原数组中的数据个数,数据多了就很慢。
2.3 删除元素:“删”不是“没了”,而是“补位”
数组删除并不是把那个位置清空就完事了,尤其是对于基本类型的数组,你把它清成0,它还在那里占位。真正的“删除”要分两种情况考虑:
如果删除后允许数组长度变短:
int[] data = {1, 2, 3, 4, 5}; int[] newData = Arrays.copyOf(data, data.length - 1);这样做的结果是newData变成了{1, 2, 3, 4},最后一位被切掉了。但请注意,这只删除了最后一个元素,如果你想删除的是数组中间某个元素,copyOf就直接废了。
如果删除中间某个元素:
// 原数组: {1, 2, 3, 4, 5},删除下标2 int[] data = {1, 2, 3, 4, 5}; System.arraycopy(data, 3, data, 2, 2); data[data.length - 1] = 0; // 末尾补0,只是为了语义清晰这段代码的意思是把下标3、4的两个元素搬到下标2、3的位置,把老数组的最后一个位置空出来。你会发现数组长度没变,还是5个格子,但逻辑上你要访问的数据变成了下标0到3,最后一个是脏数据。
所以在真实的业务代码里,单纯用数组删除中间元素是很别扭的。我们要么用一个size变量把有效范围框定住,要么干脆走List的一系列解决路线。当年我用数组实现一个队列算法时,删除头部元素的方式是记录head指针位置,而不是真正去搬数据——这就是经典的空间换时间思路。
2.4 指定位置插入:从后往前挪,千万别从前往后
插入比删除复杂一点点,因为它要“开个新槽位”,然后把后面的元素整体向后搬。
// 原数组: {1, 2, 3, 4, 5},在下标2的位置插入99 int[] data = {1, 2, 3, 4, 5}; int[] newData = new int[data.length + 1]; System.arraycopy(data, 0, newData, 0, 2); // 把[0,2)部分拷过去 newData[2] = 99; // 放入新元素 System.arraycopy(data, 2, newData, 3, data.length - 2); // 把剩余部分拷过去看到没有,核心操作就是从后往前搬运。千万不要从前往后搬,因为在同一个数组上操作时,从前往后搬会把还没搬走的信息覆盖掉。我见过不少朋友自己写插入逻辑,拿着同一个数组做循环,从前往后data[i + 1] = data[i],结果整个数组变成一团浆糊。
如果把插入和删除的复杂度放在一起看:它们都是O(n)级别的操作,因为牵涉到整体搬移。无论搬移的方向是前还是后,都改变不了这个复杂度量级。所以如果你频繁需要在中间位置插入或删除,数组不是好选择,链表那类数据结构会更合适。
3. 实操一把:用数组维护一个学生成绩系统
理论说再多都不如直接写几行能跑的代码。我模拟一个教学用的场景:有一个小组的成绩单,长度固定为5,现在要完成几个操作——显示所有成绩、更新某个学生的成绩、在末尾追加一个成绩(但容量有限要扩容)、删除一个学生的成绩。
先说设计思路。这是一个典型的“数组操作综合练习”,我把功能拆成几个方法,每个方法做一件清晰的事,这也是我平时写代码的习惯:一个方法只做一件事,出了问题也容易定位。
public class ScoreBoard { private int[] scores; private int size; // 有效元素个数 public ScoreBoard(int capacity) { scores = new int[capacity]; size = 0; } public void add(int score) { if (size == scores.length) { // 扩容:翻倍/按需增量 scores = Arrays.copyOf(scores, scores.length + 1); } scores[size] = score; size++; } public void update(int index, int newScore) { checkIndex(index); scores[index] = newScore; } public void delete(int index) { checkIndex(index); // 把 index+1 及之后的元素整体向前移动 System.arraycopy(scores, index + 1, scores, index, size - index - 1); size--; } public void print() { for (int i = 0; i < size; i++) { System.out.printf("位置%d: %d%n", i, scores[i]); } } private void checkIndex(int index) { if (index < 0 || index >= size) { throw new IndexOutOfBoundsException("下标越界: " + index); } } }这个类里我用了一个size字段来标记实际使用的元素个数。这很关键,因为没有它,你无法区分“数组容量”和“有效元素”。很多人学数组只学了“怎么填”,没学“怎么用”,于是写业务代码时总得遍历整个数组包括那些空位。
再说说增删的细节。add方法里,如果检测到数组满了,就直接扩容一个格子。虽然每次只加一格的效率不怎么样,但在教学场景里逻辑最直观。真实开发时一般会做双倍扩容,减少扩容次数。
delete方法里,我用System.arraycopy把被删除元素后面的整体向前挪一位,然后把size减一。注意我这里并没有把最末尾的元素清掉,因为只要size控制了边界,那个残留数据就永远不会被读取到。这就是“逻辑删除”的思路,通过控制有效范围来忽略脏数据。
写到这里,你可以发现一个有意思的点:一旦我们用size配合数组,它就有了一点点“动态列表”的味道。当年我刚学到这里时恍然大悟,原来ArrayList不过如此——只不过ArrayList把size和扩容逻辑封装得更完善了。
在实操过程中,有几个细节容易被忽视,我单独提醒一下:
扩容阈值。扩容前一定要判断size == scores.length,只有满了才扩,否则就变成了无意义扩容,白白浪费性能。
增量选择。每次+1的扩容方式在数据量大时很慢。如果预先知道大概要装多少数据,直接在构造函数里开好容量;如果真的不确定,建议按当前容量的1.5倍或2倍扩容,空间换时间。
边界检查。所有对下标做操作的公共方法,都应该做边界检查。这不是过度设计,而是保护你的程序不因为越界而在某个角落里崩溃。
4. 更新数组时最常见的五个坑和经验排查法
我一直觉得,基础类内容讲再多原理,都不如实实在在把坑讲清楚。下面这些坑,有些是我早期踩过的,有些是帮别人看代码时遇到的,基本每个都在实际项目中出现过。
4.1 增强for循环里直接赋值,结果发现数组没变
这个坑真的太经典了。很多初学者用for (int score : scores)改值,改完一打印发现还是老值,一脸疑惑。
int[] scores = {1, 2, 3}; for (int score : scores) { score = 100; // 这行代码实际在改一个全新的局部变量,改不到数组里 } System.out.println(Arrays.toString(scores)); // 输出 [1, 2, 3]原因很简单:增强for循环里的score只是从数组里复制出来的一个副本,你修改这个副本,不会回写到数组。这就好比你把文件内容复制到剪贴板,在剪贴板里改了,原文件内容不会有任何变化。
解决方案是:要么使用普通for循环按下标改,要么用一个引用类型数组的某个字段来修改(那又是另一种机制了)。
4.2 区分“引用替换”和“元素更新”
如果是基本类型数组,直接赋值当然没问题。但如果你是引用类型数组,事情就微妙了:
Student[] students = new Student[2]; Student s1 = new Student("张三", 18); students[0] = s1; students[0].setAge(20); // 这修改的是原对象内部的字段 s1.getAge(); // 20 —— 因为students[0]和s1是同一个对象但如果你写students[0] = new Student("李四", 19);,那只是把数组里存的引用换掉了,原来的张三对象还在内存里待着(如果没有其他引用指向它,就会被GC回收)。
引用类型数组有一个隐蔽的副作用:多个变量指向同一个对象时,任何一边改动都会影响另一边。我之前帮某课程的一位同学排查问题,他改了一个对象数组里某个元素的价格,结果其他地方引用同一个对象的地方全变了,找了半天才定位。这个不算bug,是Java引用传递的自然行为,但你要有这个意识。
4.3 数组扩容后忘了把新数组赋回去
int[] data = {1, 2, 3}; Arrays.copyOf(data, data.length + 1); // 这么写,结果赋给了null?没把返回值接收,原来的data还是3个元素。Arrays.copyOf是复制并生成新数组,不是“原地扩容”。你说你不想多一个变量,直接写:
data = Arrays.copyOf(data, data.length + 1);才行。这个“临时变量更新”的坑太常见了,尤其在追求代码简短的时候,最容易漏掉赋值动作。
4.4 多维数组的“更新”误区
二维数组本质上是“数组的数组”。你更新matrix[0][1]是更新里层数组的元素;但如果你写matrix[0] = new int[5],那是在替换一整行。很多人在处理二维数组时会把这两者搞混。
int[][] matrix = new int[3][]; matrix[0] = new int[]{1, 2, 3}; matrix[1] = new int[]{4, 5, 6}; matrix[2] = new int[]{7, 8, 9}; matrix[0][0] = 100; // 更新某个元素:{{100,2,3},...} matrix[1] = new int[]{10, 20}; // 替换整行,这行新数组长度是2这里要特别注意,第二行替换之后这一整行的长度都不一样了,交叉遍历的时候容易踩ArrayIndexOutOfBoundsException的坑。我处理二维数组更新时,一般会先判断“是更新单个元素,还是重建整行”,然后分开处理,不让两种操作混在一起。
4.5 空指针隐藏在数组更新里
String[] data = new String[3]; data[0] = null; if (data[0].equals("hello")) { // NullPointerException ... }数组里可以存null(引用类型数组默认就是null)。更新操作使用null作为删除语义时,后续访问一定要注意判空。
常见的排查思路我发现很有用:
- 先确定数组是“满了”还是“被清空了”,区分
null和“无有效元素”。 - 出现
NullPointerException时,顺着调用链找,是哪一行访问了null引用的属性或方法。 - 如果信息不够,打印数组内容:
Arrays.toString(data),看看空值分布。
说真的,这些坑看着小,但在真实业务里往往能把人折磨疯。我记得有一次排查一个数据错乱的问题,最后发现是数组扩容后忘记重新赋值给原引用,导致“读到的数据永远是最早一圈存下来的旧数据”。这种问题靠看代码很难发现,装上断点一步步看才找到。
5. 给新手的三个练习方向:把“更新”玩明白
基础技能的巩固,靠看是看不会的,一定要动手写。我推荐三个贴近“更新到数组”主题的练习,难度依次递增,每个练习都能覆盖上面提到的核心知识点。
5.1 练习一:数组去重
给你一个有序数组,要求把重复元素去掉,不允许新建数组,就地完成。思路是用一个慢指针指向“已去重区域”的末尾,一个快指针去扫描原始数据,发现新值就往前搬。这个练习能把“赋值更新”和“逻辑覆盖”的机制摸得透透的。
int[] nums = {1, 1, 2, 2, 3, 4, 4}; int slow = 0; for (int fast = 1; fast < nums.length; fast++) { if (nums[fast] != nums[slow]) { slow++; nums[slow] = nums[fast]; } } // 有效区域是 nums[0..slow],后面的都是残留数据你没看错,这就是传说中的“覆盖式更新”。它没有申请新数组,只是把数组里的有效数据往前压缩。做完这个练习,你会对“数组的物理长度”和“逻辑长度”产生深刻的体会。
5.2 练习二:数组元素循环右移
给一个数组和一个k,把数组循环右移k位。最直观的做法是k次整体搬运,但更高效的做法是三次反转:反转全部、反转前k个、反转后k个。这个练习能锻炼你对数组更新范围的敏感度,尤其要注意边界条件,极容易写成一团糊。
5.3 练习三:合并两个有序数组
给你两个有序数组A和B,A有足够的容量容纳两者(预先是n+m长度),要你把B合并进A,保持有序。这里最稳妥的思路是从后往前遍历,把较大的元素放在A数组尾部。这个练习完美结合了“追加更新”“原地覆盖”和“下标边界”三个难点,属于综合题,做完基本对数组更新有肌肉记忆了。
这三个练习都不长,代码量在20到40行之间,适合当作每天的一个小热身。做完之后你再去学ArrayList、HashMap,会发现底层的数据管理逻辑其实都是这些技能的组合延展。
最后我再说一个我个人的经验:学数组更新,不要死记“要建新数组”“要移动元素”这些结论,而是要去想背后的原因——长度固定、连续内存、随机访问。理解了这几个特征,你就知道什么时候用数组顺手、什么时候该换List、为什么扩容要复制、为什么中间插入那么慢。
数组是Java基础里的“老熟人”,但熟悉不等于理解。把更新到数组这几种操作真正做到心中有数,后续学集合、学内存模型、学算法都会顺不少。希望这篇文章能帮你把这块基础彻底夯实。