资讯详情

数组从入门到精通:定义、遍历、查找、排序与边界避坑指南

📅 2026/10/18 11:18:07 | 华诺云谱 👁 阅读
数组从入门到精通:定义、遍历、查找、排序与边界避坑指南
说实话数组是编程里最不起眼的东西但我在带新人、看代码审查时发现很多人写了几个月甚至一年的代码数组依然停留在会用的水平一碰到边界就翻车排序和查找只会调库函数连手写一个二分都可能写出死循环。这个A06的标题之所以把定义、遍历、查找、排序、边界坑、练习都串在一起其实就是在告诉你数组不是语法点而是一整套需要建立肌肉记忆的基础功。这篇我就按这个主线把数组从里到外拆一遍该讲原理的地方讲原理该给经验的给经验最后配一套练习题你可以拿来检验自己是不是真学会了。1. 数组的本质为什么说连续内存这块地皮决定了后续一切1.1 数组不只是存放数据的容器这么简单教科书上对数组的经典定义是一组相同类型元素的集合在内存中占用一段连续的空间。这个定义里连续两个字才是重点。我经常用一个类比数组就像一条街道上连续的门牌号门牌1号和门牌2号是紧挨着的邻居你想找第N号铺面不需要从1号开始一间一间数只要知道第一个门牌的位置再加上每间铺面宽度都一样这个前提就能直接算出第N号在哪里地址 起始地址 N × 每个元素占用的字节数。这就是数组能在O(1)时间内随机访问任何一个元素的根本原因也是后续所有查找、排序算法复杂度分析的地基。很多人学数组时只背住了下标从0开始这个规定却不知道为什么从0开始。你把上面的公式代进去就明白了如果数组有n个元素下标从0开始时第k个元素的地址就是base k × size从1开始的话就得写成base (k-1) × size每次访问都多一次减法运算。早期的C语言在设计时本着能省则省的哲学选了下标从0开始Java、JavaScript、Python等语言也都沿用了这个习惯。所以下次面试被问为什么数组下标从0开始别只回答这是规定——你可以从地址计算的角度讲这个回答会显得你有体系化的理解。数组在内存中的这种连续布局还带来一个实际问题声明数组时必须指定大小。因为操作系统要给你分配一段连续的、固定大小的空间你不可能在运行过程中随意把这段空间拉长。这一点是数组和后面你会学到的List、ArrayList等动态容器的本质区别。动态容器通过重新申请一块更大的空间、把元素复制过去、再释放旧空间的策略来模拟变长底层其实还是数组。1.2 数组的声明和初始化几种写法之间的细微差别以Java为例数组的声明和初始化主要有下面几种形式// 方式一声明并指定长度之后逐个赋值 int[] arr1 new int[5]; arr1[0] 10; arr1[1] 20; // 方式二声明的同时用花括号初始化 int[] arr2 {1, 2, 3, 4, 5}; // 方式三用new 花括号的形式 int[] arr3 new int[]{1, 2, 3, 4, 5}; // 方式四先声明引用再初始化 int[] arr4; arr4 new int[]{6, 7, 8};这里有一个新手非常容易踩的坑方式二的花括号语法只能在声明的同时使用。下面的写法是编译不通过的int[] arr5; arr5 {1, 2, 3}; // 编译错误要想在声明之后再赋值必须写成arr5 new int[]{1, 2, 3};。原因在于{1, 2, 3}这种字面量形式本身没有类型信息编译器只有在和声明语句放在一起时才能从左侧的int[]推断出它的类型。还有一个细节数组一旦创建每个位置都会被赋上默认值。整型数组默认全是0浮点型数组默认是0.0布尔型数组默认是false引用类型数组比如字符串数组默认全是null。这个特性有时会被忽略比如你new int[100]之后不逐个赋值直接拿去用程序不会报错结果全是0。这在某些场景下比如统计频次、计数反而是好事省去了手动初始化的步骤但在另一些场景下默认值可能会掩盖你没赋值的错误。我的建议是凡是逻辑上要求每个元素都必须有意义的数组初始化后要遍历一遍确认赋值完整不要依赖默认值。1.3 length到底是属性还是方法为什么它这么容易和String搞混在Java里数组的length是一个属性直接访问即可arr.length。但String的length()是一个方法后面必须带括号。初学者经常把arr.length()和str.length搞混编译器会报错报错信息还比较绕。这种不一致有历史原因数组的length是在JVM层面就定义好的内部属性不是普通的类方法而String是普通的类求长度要调用方法。你写代码时要是分不清就记一个口诀——数组无括号字符串有括号。在Python里就统一多了len(arr)和len(str)都是调用同一个函数在C语言里数组压根没有length这个概念你得自己用sizeof(arr) / sizeof(arr[0])去算。不同的语言有不同的设计哲学学习时不要只背语法要理解每一种设计背后的出发点。还有一个重要的坑length指的是数组的容量而不是实际存了几个有效元素。这句话在Java、C/C里都必须深刻理解——声明了int[10]即使你只用了前3个位置length依然是10。如果你习惯用Python的列表或者Java的ArrayList它们的size()返回的是元素个数和数组的length完全不是一回事。很多从Python转到Java的新人在这里栽了跟头写循环时遍历了全部capacity而不是有效元素后面就会出现把空位当有效值处理的bug。2. 遍历数组三种核心方式与两个必须记住的坑2.1 标准for循环一切遍历的基础也是写错率最高的地方int[] arr {1, 2, 3, 4, 5}; for (int i 0; i arr.length; i) { System.out.println(arr[i]); }这段代码看起来毫无技术含量但我要说它就是数组遍历的母版。你后面遇到的双指针、滑动窗口、二分查找本质上都是在控制i这个下标的变化方式。写标准for循环时最经典的错误就是边界写错——把i arr.length写成i arr.length导致最后一次循环访问arr[arr.length]直接抛出数组越界异常。这种错误有个专门的英文叫法Off-by-one error差一错误。我教学时总告诉学生写循环边界时要养成一个条件反射数组最后一个合法下标是length - 1。所以只要你的判断条件里有小于等于号就要停下来想一想是不是多跑了一次。For循环里还有一个隐含的性能小点i arr.length中的arr.length每次循环都会重新读取虽然编译器/JIT在绝大多数情况下会把它优化掉但在某些特殊写法下比如循环体内可能修改了数组引用就未必了。一般的业务代码不用纠结这个但写底层的、性能敏感代码时可以提前把长度存到一个局部变量里int len arr.length; for (int i 0; i len; i)。2.2 for-each简洁但有限制什么时候不能用for (int num : arr) { System.out.println(num); }For-each增强for循环是遍历数组最优雅的写法不需要管理下标天然不会越界代码可读性也高。但它有两个限制理解透了才能用好第一拿不到当前元素的下标。如果你需要在遍历的同时知道我正在处理第几个元素就得自己维护一个计数器那样反而不如标准for循环干脆。第二遍历过程中不能修改数组结构。对数组来说修改结构是指重新分配、扩容之类。实际上数组的长度固定for-each遍历时修改元素内容arr[i] xxx是允许的但因为for-each给你的是元素的副本你没法直接通过遍历变量改原数组。如果你想在遍历的同时替换每个位置的值比如把所有元素都翻倍用for-each会写成下面这样——这是无效的for (int num : arr) { num num * 2; // 只是改了个副本arr没有变化 }正确做法是标准for循环通过下标操作arr[i] arr[i] * 2;。如果是对象类型的数组for-each拿到的虽然也是引用副本但通过引用去修改对象的内部状态比如student.setName(新名字)是有效的因为引用副本和原引用指向的是同一个对象。这个引用副本和对象本身的关系在后面的数组拷贝里还会遇到建议这里就建立清晰的认识。还有一个比较进阶的点for-each不能修改集合长度。如果你遍历的是ArrayList等集合在遍历过程中调用add或remove会触发ConcurrentModificationException并发修改异常。数组没有这个问题但如果你把数组和集合混着写这个特性要记牢。2.3 倒序遍历从对半搜索到原地算法都在用它倒序遍历的写法如下for (int i arr.length - 1; i 0; i--) { System.out.println(arr[i]); }这里最隐蔽的坑在于循环结束条件i 0。很多人在倒序遍历时写着写着就把条件写成了i 0导致下标为0的元素也就是第一个元素被漏掉。这种错误在笔试手写代码时特别容易发生因为正着写i length时逻辑很顺一倒过来就容易把 0记成 0。倒序遍历看起来只是个换个方向实际应用场景非常多从数组末尾开始合并比如后面练习里合并两个有序数组时从后往前填可以避免覆盖、单调栈问题里从右侧扫描、删除指定元素时从后往前防止跳过连续目标等。所以不要小看这一小节建议你把正序遍历、倒序遍历、手动控制步长遍历都练成条件反射。2.4 二维数组的遍历每一行其实是独立的一维数组二维数组在Java里本质是数组的数组int[][] matrix new int[3][4];表示有3行每行是一个长度为4的int数组。for (int i 0; i matrix.length; i) { for (int j 0; j matrix[i].length; j) { System.out.print(matrix[i][j] ); } System.out.println(); }注意matrix[i].length这个写法——每一行是独立的数组可以有自己独立的长度。Java允许所谓的锯齿数组ragged array比如int[][] arr new int[3][];然后为每一行分配不同的长度。这在表示三角矩阵、稀疏结构时很实用。很多教材只教矩阵不讲每一行可不等长导致初学者以为二维数组必须是规整的矩形。其实这个概念搞明白后你在处理很多实际问题时思路会被打开。遍历时用matrix[i].length而不是某个固定的列数正是因为不同行长度可能不同。3. 查找元素线性查找很简单二分查找才是真正的分水岭3.1 线性查找无序数组的唯一选择别嫌它低级public static int linearSearch(int[] arr, int target) { for (int i 0; i arr.length; i) { if (arr[i] target) { return i; } } return -1; }线性查找的思路一句话就能讲完从头到尾一个个比找到了返回下标找不到返回 -1。它的时间复杂度是O(n)数据量小或者数组本身无序时这是最靠谱的方案。为什么有序变成了一个关键前提因为只有有序你才能利用元素之间的顺序关系跳着找无序的情况下任何一个位置的元素都可能是目标除了全部看一遍没有任何更优策略这是一个信息论层面的下限。我在实际带项目时经常看到有人在小数组比如长度几十上用花哨的二分查找或哈希表其实完全没必要。复杂度是看增长趋势的数据规模小的时候常数因子更重要。线性查找代码简单、不容易错、对缓存友好在小数组上往往比二分还快。3.2 二分查找让新手翻车率最高的简单算法二分查找的适用前提是数组已经有序。核心逻辑每次取中间位置的值和目标比较根据大小关系排除一半的搜索空间直到找到目标或搜索空间为空。public static int binarySearch(int[] arr, int target) { int left 0; int right arr.length - 1; while (left right) { int mid left (right - left) / 2; if (arr[mid] target) { return mid; } else if (arr[mid] target) { left mid 1; } else { right mid - 1; } } return -1; }上面这段代码是全篇最值得反复揣摩的代码块之一。每次循环搜索区间缩小一半所以时间复杂度是O(log n)。你可能觉得这不难但我面试过的候选人里能一次写对二分查找的连一半都不到。为什么因为它有三个地方环环相扣改一处其他几处全都得跟着变。第一是循环条件left right还是left right用时循环终止条件是left 和 right 交错即left right此时区间为空可以安全返回 -1。用时循环终止条件是left right此时还有一个元素没检查过。这两种风格对应不同的边界处理习惯没有绝对的对错但你必须保持一致。第二是mid的计算left (right - left) / 2是一种防溢出写法。数学上它等价于(left right) / 2但当 left 和 right 都很大时left right可能超过整型上限。在绝大多数日常业务中不会遇到这么大的数组但写算法题、写底层库时这个细节是业内约定俗成的规范写法建议你直接养成习惯。第三是收缩方式left mid 1和right mid - 1。为什么不是left mid因为当arr[mid] ! target时mid这个位置已经确认不是目标把它排除在区间之外是合理的。如果写成left mid配合left right的循环条件当搜索区间缩小到两个元素且目标较大时mid会一直等于left区间永远不缩小就死循环了。配套的二分查找变式也非常重要查找第一个等于目标的位置、最后一个等于目标的位置、第一个大于等于目标的位置lower bound、最后一个小于等于目标的位置upper bound这些变式在LeetCode等平台上是高频题。不同变式的本质就是调整收缩条件与返回值要找最左那当arr[mid] target时不要急着返回而是继续收缩右边界要找最右就继续收缩左边界。把上面这个基础版理解到背下来也不丢人因为它是所有变式的地基。3.3 查找失败时返回什么这个约定俗成并不简单线性查找和二分查找在找不到目标时都返回 -1。这几乎是所有语言和算法教科书里的惯例为什么不是返回 null因为数组的下标是整型返回 null 在一些语言里根本不可能——一个 int 类型你怎么返回 null所以 -1 被选为哨兵值因为它不可能是合法下标最小合法下标是0语义明确不会混淆。不过也有另一套约定把第一个大于等于目标的位置作为返回值。Java 的Arrays.binarySearch就是这个思路如果找到了返回目标下标如果没找到返回-(插入点) - 1。这个目的是让调用方既能知道没找到又能顺手推算出如果插入这个元素应该插在哪里。理解了这套约定你就能看懂官方库函数返回的奇怪负数了。4. 排序算法从冒泡到选择先把交换和轮次刻进脑子4.1 冒泡排序理解相邻交换如何把最大值推到末尾冒泡排序的思路每一轮从头到尾依次比较相邻元素如果前面的比后面的大就交换它们。这样一轮过后最大的元素就像气泡一样浮到了数组末尾。重复这个过程n-1轮数组就有序了。public static void bubbleSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { // 轮数n-1轮 for (int j 0; j n - 1 - i; j) { // 每轮比较的次数递减 if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } }理解冒泡排序最关键的是搞清楚两个循环的边界为什么是n - 1轮因为如果有n个元素最多只需要n-1轮剩下那个元素自然就在正确位置了。极端情况下如果只剩两个元素没排好一轮交换就能搞定。为什么内层循环是j n - 1 - i因为每完成一轮末尾i1个元素已经是全局最大的那部分已经排定了不需要再参与比较。如果不减i虽然也能跑出正确结果但多了很多无效比较。冒泡排序还有一个经典优化如果某一轮从头到尾一次交换都没发生说明数组已经有序可以直接break。这个优化在近乎有序的数组上效果极佳能让时间复杂度从O(n²)降为O(n)。但注意最坏情况逆序数组依然是O(n²)这个优化改变不了最坏复杂度只能提升常量和最优情况。4.2 选择排序和冒泡的区别在于找最小值而不是一路上都在交换选择排序的思路每轮从未排序部分中找出最小值放到未排序部分的开头。等所有轮次走完数组就有序了。public static void selectionSort(int[] arr) { int n arr.length; for (int i 0; i n - 1; i) { // i当前要确定的位置 int minIndex i; for (int j i 1; j n; j) { // 在 [i, n-1] 中找最小值下标 if (arr[j] arr[minIndex]) { minIndex j; } } // 交换 i 和 minIndex int temp arr[i]; arr[i] arr[minIndex]; arr[minIndex] temp; } }选择排序和冒泡排序的核心区别在于冒泡每遇到一次逆序就交换一次选择排序每轮只交换一次找到最小值后其余时间只是在记录下标。所以选择排序的一轮只有一个动作顶点比较次数上两者差不多都是O(n²)级别但交换次数上选择排序要少得多。我常跟学生说冒泡是一路走一路换选择是先逛一圈找到最便宜再回来买。到这里可以给你一张对比表帮助建立算法直觉排序算法最优时间复杂度平均时间复杂度最差时间复杂度空间复杂度稳定性冒泡排序O(n)已优化O(n²)O(n²)O(1)稳定选择排序O(n²)O(n²)O(n²)O(1)不稳定Arrays.sort()O(n log n)O(n log n)O(n log n)O(n)依实现而定稳定性的意思是相等的元素在排序后是否保持原有相对顺序。什么时候需要关心稳定性比如先按姓名排序再按成绩排序稳定的排序算法能保证成绩相同的人仍然按姓名的顺序排列。冒泡排序是稳定的——相邻元素只有严格大于才交换相等的值不会跨过彼此选择排序是不稳定的——比如数组[5, 8, 5, 2]第一轮找到最小值2和第一个5交换两个5的相对顺序就变了。4.3 实际开发中的排序优先用库函数但要清楚它在干什么我在看代码评审时经常看到有人自己写排序而且写得还有bug。实际上业务代码里永远优先使用语言的排序库Java 的Arrays.sort(int[])针对基本类型用的是快速排序的双轴快排变体时间复杂度O(n log n)针对对象数组用的则是归并排序的变体保证稳定。你不需要完全理解它的内部实现但需要知道几个关键点第一Arrays.sort默认是按升序排的。要降序排列基本类型数组Java没有现成的装包函数直接调用需要把int[]转成Integer[]然后传入ComparatorInteger[] arr {3, 1, 4, 1, 5, 9, 2, 6}; Arrays.sort(arr, (a, b) - b - a); // 降序第二Arrays.sort是对整个数组排序。如果你只想排数组中间的一段区间Java有Arrays.sort(arr, fromIndex, toIndex)重载注意toIndex是开区间不包含。第三排序的数组元素如果是对象需要它们实现Comparable接口或者你传入单独的Comparator否则会抛ClassCastException。学到这里你可能会问既然库函数这么方便为什么还要手写冒泡和选择排序因为手写排序的价值不在于排序本身而在于训练你理解循环嵌套、下标控制、交换逻辑、复杂度分析——这些是数组操作的基本功也是后续学习更高级算法的基础。理解库函数背后大概的时间复杂度遇到性能瓶颈的时候你才知道该怀疑什么、从哪里优化。5. 常见边界坑不能踩的坑和踩了之后的排查思路5.1 最经典的越界访问当索引等于数组长度时发生了什么直接看一个最常见的错误int[] arr {1, 2, 3}; for (int i 0; i arr.length; i) { System.out.println(arr[i]); }这个循环执行到i 3时会访问arr[3]但合法下标是0到2于是Java抛出ArrayIndexOutOfBoundsException: Index 3 out of bounds for length 3。为什么下标3是非法的回想第一节说过的地址公式数组从起始地址连续分配了3个元素的空间第4个元素的位置已经不属于这块空间了访问它本质上是越界读写。C语言里这个错误更隐蔽——你不会收到异常而是直接读写了一段未知内存可能拿到垃圾数据也可能导致程序崩溃段错误这就是著名的缓冲区溢出漏洞的来源。Java用异常机制把这个错误显式暴露出来对初学者反而是一种保护。看到这种异常时第一反应应该是去看循环条件或者边界计算重点检查是不是在某个位置多加了1或者漏减了1。排查越界异常的套路把异常信息里的下标值比如Index 3和数组长度for length 3同时盯住如果下标恰好等于数组长度那你多半犯的是off-by-one错误如果下标很小比如 -1、-2却还越界多半是某个计算下标的方法返回了负数比如二分查找返回了-(插入点)-1你没判断就直接用它访问数组。5.2 数组为null和数组长度为0是两回事int[] arr1 null; // 引用不指向任何对象 int[] arr2 new int[0]; // 引用指向一个长度为0的数组arr1.length会抛NullPointerExceptionarr2.length返回0。搞清楚这个区别很多报错就不会再一头雾水。访问数组元素前最稳妥的做法是统一检查if (arr ! null arr.length 0) { // 可以安全访问 }用的原因如果arr是null第一个条件为false短路求值让第二个条件不会执行也就不会抛空指针异常。这个写法在项目中极其常见应该形成肌肉记忆。new int[0]这种空数组在某些场景下有特殊价值作为返回值时它表示没有结果但是操作是合法的。我建议你排查空指针时先区分是引用本身为空还是内部元素为空这两种修复方式完全不同。5.3 数组拷贝引用拷贝还是内容拷贝改一个会不会影响另一个这是新手最容易糊涂的问题。看代码int[] arr {1, 2, 3}; int[] copy arr; // 这只是复制了引用arr和copy指向同一个数组 copy[0] 99; System.out.println(arr[0]); // 输出99因为arr[0]和copy[0]是同一个位置copy arr之后arr和copy就像一个人两个名字改其中一个对另一个同样生效。如果想真正复制一份内容用int[] copy Arrays.copyOf(arr, arr.length); // 复制整数组 int[] copyRange Arrays.copyOfRange(arr, 1, 3); // 复制区间 [1,3)copyOfRange是左闭右开 int[] copy3 arr.clone(); // clone也可以 System.arraycopy(arr, 0, copy, 0, arr.length); // 底层最灵活的方式这里要再深入一层如果是对象数组Arrays.copyOf只复制引用不复制对象本身。也就是说两个数组里的对应元素还是指向同一个对象你通过copy数组里元素的引用修改了对象内部状态原数组对应元素也会看到修改。这就是浅拷贝和深拷贝的区别——如果对象内部还有可变结构要真正的深拷贝得自己写循环逐个new新对象并复制字段。项目中很多改了A数组的数据结果B数组也变了的bug根源就是浅拷贝。5.4 数组作为方法参数传的是引用函数里改东西外面会看到public static void modify(int[] arr) { arr[0] 100; } int[] data {1, 2, 3}; modify(data); System.out.println(data[0]); // 输出100在Java里数组参数传递的是引用更准确地说是引用的值拷贝所以在方法里修改数组元素外面的数组也会变。但如果你在方法里执行arr new int[10]让arr指向了一个新数组外面的data引用不会变——因为传进来的只是个引用副本重新赋值只改变了副本的指向。这个引用副本本身也不能改但引用指向的对象内容可以改的规则是理解Java传参行为的关键。C语言里数组作为参数时会退化为指针传的也是指向首元素的地址同样能通过指针修改调用方的数据。Python里列表作为参数传的也是引用。这个特性在很多算法题里是默认项——比如排序算法直接修改原数组不需要返回新数组。5.5 数组元素的自动装箱与比较相等陷阱如果你是Java初学者这个坑几乎必踩。int[]和Integer[]的元素比较时可能有自动装箱。尤其当你用Arrays.equals比较两个数组时int[] a {1, 2, 3}; int[] b {1, 2, 3}; System.out.println(a b); // false因为是两个不同的对象引用 System.out.println(Arrays.equals(a, b)); // true逐个比较元素值在比较数组引用时比较的是引用是否指向同一个对象而不是内容是否相等。要比较数组内容用Arrays.equals。如果你偷懒用a.equals(b)对于int[]这种基本类型数组equals实际上用的是Object的默认实现依然比较引用返回false这也是一大坑。只有Integer[]数组能通过Arrays.equals做内容比较因为它是对象数组可以用元素自己的equals方法。想比较多维数组内容要用Arrays.deepEquals。这个坑让我想到另一个相关陷阱两个数组要合并时千万别用循环里一次次System.arraycopy而把边界写错。刚学的时候合并数组不如直接用现成APIint[] a {1, 2, 3}; int[] b {4, 5, 6}; int[] merged new int[a.length b.length]; System.arraycopy(a, 0, merged, 0, a.length); System.arraycopy(b, 0, merged, a.length, b.length);这里的注意点System.arraycopy的参数依次是源数组、源起始位置、目标数组、目标起始位置、要复制的长度最后一个参数是长度不是结束位置很多人一开始会把b.length误写成merged.length然后越界。这个API参数顺序值得死记硬背src, srcPos, dest, destPos, length。6. 配套练习题六道题由易到难检验你对数组的掌握标题里说要配练习我按由易到难给你设计了六道题。每道题都给思路提示和验证方法建议先自己动手写卡住超过15分钟再看思路再卡住才看参考写法。尤其是最后两道很多工作了几年的人也未必能一次写对。6.1 练习1原地翻转数组要求给定一个int数组不使用额外数组原地将数组顺序反转例如{1, 2, 3, 4, 5}变成{5, 4, 3, 2, 1}。思路双指针。left从0开始right从length - 1开始交换这两个位置的值然后left、right--直到left right时停止。注意循环条件是left right两个指针穿过时就不需要再交换了否则会换回去。参考答案public static void reverse(int[] arr) { int left 0, 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}输入{1,2,3}输出{3,2,1}。这道题考察的是双指针和原地操作不需要额外空间时间复杂度O(n)。6.2 练习2找出数组中第二大的数要求给定一个int数组找出其中第二大的数。假设数组长度至少为2且不考虑重复值合并的情况如果最大值出现多次取另找一个次大值例如{5, 5, 4}的第二大是4。思路维护两个变量max和secondMax。循环过程中如果当前元素大于max就把旧的max赋给secondMax再更新max如果当前元素小于max但大于secondMax就只更新secondMax。这个滚动更新的思路在很多数组问题里都会用到。参考答案片段int max Integer.MIN_VALUE; int secondMax Integer.MIN_VALUE; for (int num : arr) { if (num max) { secondMax max; max num; } else if (num secondMax) { secondMax num; } }这个写法要注意的两点第一初始值用Integer.MIN_VALUE而不是0否则处理全负数数组时会出错第二else if部分没有排除num max的情况所以如果{5, 5, 4}遍历到第二个5时会走num secondMax分支secondMax变成5这需要你在读需求时明确最大值的重复值算不算。6.3 练习3实现一个二分查找要求在升序数组中查找目标值找到返回下标找不到返回 -1。要求手写不能调Arrays.binarySearch。思路直接套用前面3.2小节的代码但要确保你能自己把边界条件推导出来而不是死记硬背。重点回答三个问题循环条件为什么是left rightmid为什么用left (right - left) / 2来计算为什么收缩时一个加1一个减1验证方法用{1, 3, 5, 7, 9}分别查找1、5、9应返回对应下标、2应返回-1、0应返回-1、11应返回-1。提交前再检查一次left right写对了没有。6.4 练习4原地移除指定元素要求给定数组arr和一个值val原地移除所有等于val的元素返回移除后数组的新长度。不需要考虑新长度之后的元素是什么这是很多算法题的标准表述削减了实现难度。思路双指针一个慢指针slow负责记录下一个有效位置一个快指针fast负责遍历原数组。当arr[fast] ! val时执行arr[slow] arr[fast]; slow;。最终slow就是新长度。public static int removeElement(int[] arr, int val) { int slow 0; for (int fast 0; fast arr.length; fast) { if (arr[fast] ! val) { arr[slow] arr[fast]; slow; } } return slow; }这道题考察的核心是快慢指针——它和双指针一样是数组问题中最常出现的套路。没想明白时拿{3, 2, 3, 4, 3}移除3来模拟一遍fast0时arr[0]3跳过fast1时arr[1]2赋值给arr[0]……最后输出长度2前两个位置是2和4完全正确。6.5 练习5旋转数组进阶要求给定数组将数组中的元素向右移动k个位置其中k是非负数。例如{1, 2, 3, 4, 5, 6, 7}右移3位变成{5, 6, 7, 1, 2, 3, 4}。思路一个经典的三次翻转法。先把整个数组反转再反转前k个元素再反转后n-k个元素。例如原数组反转后是{7,6,5,4,3,2,1}反转前3个变成{5,6,7,4,3,2,1}反转后4个变成{5,6,7,1,2,3,4}正好得到结果。这里有一个必须先处理的坑如果 k 大于数组长度实际移动的效果应该等于k % n。比如n7k10右移10位等价于右移3位。如果不先取模反转时会出现下标越界。public static void rotate(int[] nums, int k) { int n nums.length; k k % n; reverse(nums, 0, n - 1); reverse(nums, 0, k - 1); reverse(nums, k, n - 1); } public static void reverse(int[] nums, int left, int right) { while (left right) { int temp nums[left]; nums[left] nums[right]; nums[right] temp; left; right--; } }注意这里reverse(nums, k, n-1)的k是开区间左边界也就是说从下标k开始到n-1的元素被反转。这是容易错的地方你可能会写成reverse(nums, k 1, n - 1)或者reverse(nums, k - 1, n - 1)逻辑完全乱了。验证时直接用上面的例子手工模拟三步反转比空想可靠得多。6.6 练习6合并两个有序数组进阶要求两个升序数组nums1和nums2其中nums1的长度是m n前m个是有效元素后n个位置空着用0占位需要把nums2合并进nums1结果依然升序。要求原地操作不额外申请数组。思路从后往前填充。标准双指针是从前往后但那样会覆盖nums1还没处理的元素从后往前则天然避免了覆盖问题。用三个指针p1 m - 1指向 nums1 末尾有效元素p2 n - 1指向 nums2 末尾p m n - 1指向合并后的末尾。每次比较nums1[p1]和nums2[p2]的大小把较大的放到nums1[p]同时相应指针前移。如果nums2还有剩余继续把剩余元素填进去。public static void merge(int[] nums1, int m, int[] nums2, int n) { int p1 m - 1; int p2 n - 2 1; // 即 n - 1这里写清晰一点 int p m n - 1; while (p1 0 p2 0) { if (nums1[p1] nums2[p2]) { nums1[p] nums1[p1]; p1--; } else { nums1[p] nums2[p2]; p2--; } p--; } while (p2 0) { nums1[p] nums2[p2]; p--; p2--; } }上面代码中我特意留了一个看着别扭的int p2 n - 2 1;其实它就等于n - 1写这么绕是想提醒你手写代码时变量的含义比形式更重要。p2是nums2最后一个有效元素的下标那自然就是n - 1别被各种占位写法晃了。如果nums1还有剩余而nums2已空不需要额外处理因为剩下的有效元素本来就在最终位置上。这道题就是LeetCode第88题的经典原型很多面试官会用它来考察候选人对从后往前这个反向思维的敏感度以及在原地操作下避免覆盖的工程意识。练习做完之后建议你把六道题串在一起回顾一遍翻转数组练双指针第二大数练滚动变量二分查找练区间收缩移除元素练快慢指针旋转数组练取模和三次反转合并有序数组练从后往前覆盖。你会发现数组的所有核心操作——定义、遍历、查找、排序——都在这些练习题中以不同的组合方式出现了。我个人的体会是数组阶段不怕写得慢就怕只背不练。把这六道题的边界条件每一处都问一遍如果这里换成别的写法会不会出错你收获的就不只是数组知识而是排查一切边界问题的方法论。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑