ARTICLE DETAIL

资讯详情

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

Java数组与方法:从JVM内存到冒泡排序、递归的进阶指南

Java数组与方法:从JVM内存到冒泡排序、递归的进阶指南 学完前两天的JavaSE基础语法很多人会卡在同一个坎上变量和选择循环单独看都懂可一旦遇到存一批数据反复处理同一段逻辑就不知道怎么下手了。Day03恰恰就是从能看懂代码跨到能自己组织代码的关键一天——这一天只做两件事把多个数据装进数组把一段逻辑封装成方法。这两样东西是后面面向对象、集合框架、IO流所有内容的地基。这篇笔记是我自己复习第三天的内容时整理的除了语法细节还把我当时踩过的一些坑和调试思路一并写下来了适合正在按Day01、Day02顺序推进的学习者也适合学完语法想回头补基础的同学。1. 从变量到数组内存视角的一次升级1.1 数组在JVM内存里的真实样子前两天的变量无论是int还是double都是单个的盒子。到了数组这里情况变了一个数组变量其实是一个快递单号。真正装数据的地方在堆内存里变量名里存的只是那块连续内存的地址。int[] scores new int[5];这行代码实际做了两件事在堆内存中申请一块能放下5个int的连续空间每个int占4字节总共20字节把这块空间的起始地址赋值给栈上的变量scores所以scores本身不装数据它只是知道数据在哪。这也是数组是引用类型的真正含义。很多初学者在画内存图时容易把数组名当成数据本体导致后面学习对象引用时产生误解。为什么下标从0开始不是约定俗成是地址计算方式决定的。第i个元素的地址等于数组首地址加上i * 元素类型字节数第一个元素偏移量是0自然就用了0作为首个位置。记住这个原理写循环边界时就不容易搞混。1.2 下标越界的本质与防御ArrayIndexOutOfBoundsException应该是Java新手遇到的第一个运行时异常。它不是编译阶段能发现的而是程序跑起来访问了一个不存在的位置才暴露。数组长度为5合法的下标是0到4访问scores[5]就相当于拿着快递单号去取第6个包裹可货架上只有5个。我实际踩过的一个典型错误是这样写的for (int i 0; i scores.length; i) { System.out.println(scores[i]); }循环条件写成了i scores.length数组长度为5时i最大会取到5最后一个循环实际访问的是scores[5]直接越界。正确写法是i scores.length。这里有个记忆技巧循环条件里的比较符号可以让它遵循下标最大值是length-1的原则写成i length - 1也行但更推荐统一的i length。防御越界除了检查循环边界还有两个实践建议遍历之前先判断数组是否为null避免空指针调用方法传入数组时明确约定数组的有效范围尤其是在方法内做下标运算时要提前验证传入参数1.3 数组的初始化方式静态与动态的选择数组初始化有两种常见形式选择标准很简单已知数据就用静态程序运行时才产生数据就用动态。// 静态初始化数据写死在代码里 int[] scores {88, 92, 76, 95, 83}; // 动态初始化只指定长度元素使用默认值 int[] scores new int[5];动态初始化时不同类型的数组有对应的默认值int数组默认0double数组默认0.0boolean数组默认false引用类型数组默认null。这个特点可以用于先占位、后填充的场景比如先创建数组再用循环或用户输入逐个赋值。还有第三种不太常用的形式——匿名数组直接new int[]{1, 2, 3}它不用变量名接收通常配合方法参数使用比如直接传入一个临时数组调用某个方法。初学阶段了解即可等熟悉了再灵活使用。2. 数组实操工具箱遍历、拷贝、排序与查找2.1 增强for循环的适用边界遍历数组有三种方式普通for循环、增强for循环foreach、Arrays.toString()。foreach在只读遍历时最简洁for (int score : scores) { System.out.println(score); }但有两个限制必须清楚foreach里拿不到当前下标foreach里修改循环变量不会影响原数组。因为每次迭代score只是从数组元素中复制出来的一个副本。那什么时候必须用普通for循环需要按下标操作、需要修改数组元素、需要倒序遍历、需要在遍历过程中记录位置信息的时候。比如求最大值并记录它的位置就得用普通for循环。int max scores[0]; int maxIndex 0; for (int i 1; i scores.length; i) { if (scores[i] max) { max scores[i]; maxIndex i; } }我自己的习惯是只打印内容用foreach任何涉及位置的逻辑一律退回普通for循环。别为了一时简洁把下标信息丢掉后面改需求时反而更麻烦。2.2 数组拷贝的深浅之分很多人在复制一个数组时直接int[] copy origin;这不是复制只是让copy和origin指向了同一块内存。改copy里的元素origin也会跟着变。真正的拷贝有三种常用途径方式特点适用场景Arrays.copyOf(origin, length)返回新数组可指定长度扩容、截取System.arraycopy(src, srcPos, dest, destPos, length)需要目标数组已存在性能要求高、拼接到已有数组clone()返回Object类型需强转简单复制、快速获得副本// 用copyOf实现扩容长度是原来的2倍 int[] expand Arrays.copyOf(scores, scores.length * 2); // 用System.arraycopy实现数组拼接 int[] combined new int[scores.length other.length]; System.arraycopy(scores, 0, combined, 0, scores.length); System.arraycopy(other, 0, combined, scores.length, other.length);需要特别注意的是引用类型数组的拷贝是浅拷贝数组里装的是对象的引用copy出来的数组和原数组元素指向的还是同一个对象。如果你打算复制后修改里面的对象得考虑深拷贝的问题这在以后学习集合和对象时尤其重要。2.3 手写冒泡排序与二分查找排序和查找是数组操作里的两大经典问题。Java的Arrays类提供了sort方法但学习阶段手写一遍冒泡排序能帮助理解算法的比较和交换逻辑。冒泡排序的思路每轮从头开始比较相邻元素大的往后移一轮结束后最大的数就冒到了数组末尾。public static void bubbleSort(int[] arr) { // 外层循环控制轮数n个元素需要n-1轮 for (int i 0; i arr.length - 1; i) { // 内层循环控制每轮的比较范围末尾已经归位的元素不用再比 for (int j 0; j arr.length - 1 - i; j) { if (arr[j] arr[j 1]) { // 交换两个相邻元素 int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } }这里的关键是理解内层循环为什么是arr.length - 1 - i第0轮结束后最后的元素已经最大第1轮就不用再比较它了。减i就是为了跳过那些已经归位的参数。还可以加一个标记变量如果某轮没有任何交换说明数组已经有序提前结束循环这是冒泡排序常见的优化点。二分查找的思想是每次淘汰一半数据前提是数组必须有序。代码写起来不复杂但边界条件很容易出问题。我推荐用开区间写法low初始为0high初始为length - 1循环条件是low high。public static int binarySearch(int[] arr, int target) { int low 0; int high arr.length - 1; while (low high) { int mid low (high - low) / 2; if (arr[mid] target) { return mid; } else if (arr[mid] target) { low mid 1; } else { high mid - 1; } } return -1; }注意mid low (high - low) / 2这样写是为了防止low和high很大时(low high) / 2发生整数溢出。这个细节在刷面试题时经常碰到。2.4 二维数组存储表格数据的正确姿势二维数组本质是数组的数组。int[][] matrix new int[3][4]表示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(); }Java的二维数组还有一个特点每一行的长度可以不同叫不规则数组。比如三角矩阵第一行3列第二行4列。虽然日常开发中不规则数组用得不多但理解这一点有助于看清数组在内存中的真实结构。遍历不规则数组时内层循环的结束条件一定不能写成固定常量要用matrix[i].length。二维数组常见用途是存储成绩表、矩阵运算、棋盘类游戏数据。我自己处理这种数据时习惯行为记录、列为字段比如一行代表一个学生的多条成绩列代表语文、数学、英语这样和现实场景对应起来逻辑上不容易乱。3. 方法不是代码块参数传递与作用域的底层逻辑3.1 为什么说Java只有值传递这是一个非常经典的面试问题也是理解方法参数的核心。Java的参数传递机制只有一种值传递。基本类型传的是数值本身引用类型传的是地址的副本。两者传的都是值区别只是这个值是普通数据还是地址。先看基本类型的经典例子public static void swap(int a, int b) { int temp a; a b; b temp; } int x 3; int y 5; swap(x, y); System.out.println(x y); // 输出3 5没有变化为什么没变因为swap方法里的a和b是参数副本方法栈帧里对a、b的修改影响不到外面的x、y。方法调用时会创建独立的栈帧里面的局部变量和方法结束后一起销毁。再看引用类型public static void changeFirst(int[] arr) { arr[0] 100; } int[] nums {1, 2, 3}; changeFirst(nums); System.out.println(nums[0]); // 输出100数组作为参数传入时arr拿到的是nums地址的副本。但arr和nums指向的是同一块堆内存所以通过arr修改元素实际修改的是原数组的内容。这也是初学者最容易困惑的地方不是值传递吗为什么数组内容变了答案是传过去的值是个地址地址指向同一块内存。如果方法内部执行arr new int[...]重新分配一个新的数组对象那外面的nums并不会跟着变因为改的是地址副本本身不是原地址。搞清楚这一点方法参数的很多疑问就解开了。3.2 方法重载的判定规则方法重载指方法名相同、参数列表不同的情况。参数列表不同包括三种参数个数不同、参数类型不同、参数顺序不同。返回值类型不算方法签名的一部分也就是说仅靠返回值不同无法构成重载编译会直接报错。public static int add(int a, int b) { return a b; } public static double add(double a, double b) { return a b; } public static int add(int a, int b, int c) { return a b c; }调用时编译器会根据实参的类型、个数自动匹配最合适的方法。这里有一个需要留意的场景参数自动提升。比如同时有add(int, int)和add(double, double)调用add(3, 5)匹配int版本但调用add(3, 3.0)时由于第二个参数是doubleint版本无法匹配编译器会把3自动提升为double匹配到double版本。如果同时还有一个add(int, double)则会优先匹配这个最精确的版本。重载能够提升代码可读性让同一语义的操作只用一个方法名。但别为了重载而重载如果方法参数之间有本质的不同意图应该起不同的方法名。3.3 可变参数与实际应用可变参数是方法参数设计的便利特性语法是类型名后加三个点public static double average(double... values) { double sum 0; for (double v : values) { sum v; } return values.length 0 ? 0 : sum / values.length; }调用时既可以直接传多个值average(1, 2, 3)也可以传数组average(new double[]{1, 2, 3})。可变参数的本质就是数组编译时自动创建数组来装实参方法体里可以直接用数组的方式访问。使用可变参数有几个限制需要记住可变参数必须是方法参数的最后一个参数一个方法最多只能有一个可变参数如果同时声明了average(double...)和average(double[] a)两者签名冲突编译期直接报错可变参数适合数量不固定但类型相同的场景比如简单的求和、打印多个值。如果业务逻辑很复杂我不推荐大量使用可变参数因为调用者不容易看清哪些参数是可选、哪些是必填这种时候通常应该用对象封装或集合来承载可读性更好。4. 递归从阶乘到斐波那契的思考方式4.1 递归的两个必要条件递归是方法调用自身的行为。写递归有一个核心思维拆解问题——把一个规模大的问题拆成同样结构的子问题直到子问题小到可以直接解决。判断一个递归写得好不好就看两件事有没有终止条件每次递归是否朝着终止条件靠近。public static long factorial(int n) { if (n 0 || n 1) { return 1; } return n * factorial(n - 1); }阶乘的终止条件是n等于0或1返回值是1每次调用都让n减1逐步逼近终止条件。如果少了终止条件或者n始终不减就会无限调用自己最终抛出StackOverflowError。递归调用在内存里是栈展开的过程factorial(5)调用factorial(4)要先等4的结果返回才能乘上5。所以递归的层数等于栈帧的数量层数太深比如几万层可能把栈撑爆。递归不是万能的它适合问题天然具有递归结构的场景比如树的遍历、目录的层级结构、数学归纳类问题。4.2 递归与循环的取舍很多算法用循环也能实现那什么时候选递归我给一个判断标准如果问题本身能划分成相同结构的子问题并且子问题的规模呈递减趋势递归往往更自然、代码更少如果单纯只是重复执行若干次相同操作循环更直接、性能更好。以斐波那契数列为例public static long fib(int n) { if (n 2) { return 1; } return fib(n - 1) fib(n - 2); }这段代码能跑但效率非常低。原因在于没有做中间结果的缓存fib(5)要算fib(4)和fib(3)fib(4)又算fib(3)和fib(2)同一个子问题被重复计算了很多次时间上几乎是指数级增长。我在实际运行时试着算fib(45)等了很久才出结果。要改造的话可以让递归过程中把计算结果缓存下来记忆化递归或者干脆用循环递推。递归和循环不是非此即彼的关系很多工程实现会混合使用递归负责组织结构循环负责处理同层级的重复操作。4.3 递归调试技巧递归写错了最让人头疼的地方是一旦出错不太容易定位是哪一层调用导致的。我自己常用的调试手段有两个。第一个是在递归方法入口和出口打印当前参数与返回值public static long factorial(int n, String indent) { System.out.println(indent 进入n n); long result; if (n 0 || n 1) { result 1; } else { result n * factorial(n - 1, indent ); } System.out.println(indent 返回n n result result); return result; }用缩进来表示调用层级输出效果非常直观可以清楚看到每一层的进入顺序、返回值如何从最内层往外逐层传递。第二个技巧是有意识地限制递归深度。在方法开头加一个参数当深度超过某个值就直接返回特殊值用来验证递归方向是否合法、是否真的在逼近终止条件。比如public static int badMethod(int n, int depth) { if (depth 100) { throw new IllegalStateException(递归深度超限); } // 正常逻辑 }这个手段虽然看起来有点粗暴但在排查无限递归时比事后看堆栈高效得多。经验是不要一上来就追求一次写对递归先加逻辑跑通再删调试代码。5. 综合实战用数组和方法完成学生成绩统计5.1 需求设计与功能拆分到这一步数组和方法的语法都过了一遍但如果只是跟着示例敲一遍收获有限。我当时做了一道综合题题目本身不难关键是它把所有知识点串了起来。需求如下从键盘录入一个班级的人数n依次录入n个学生的成绩0到100之间的整数计算平均分、最高分、最低分、及格人数和及格率将所有成绩从高到低输出如果不用方法所有代码堆在main里也能写但那样逻辑混在一起出错了不好定位。我给自己加了一条要求每个独立的功能必须封装成一个方法。这样整个程序变成了程序入口负责调度各方法负责具体功能的结构。5.2 代码实现逐步拆解我把代码拆成几个部分来写。首先是输入和数组填充import java.util.Scanner; import java.util.Arrays; public class ScoreStatistics { public static void main(String[] args) { Scanner scanner new Scanner(System.in); System.out.print(请输入班级人数); int n scanner.nextInt(); int[] scores new int[n]; for (int i 0; i scores.length; i) { System.out.print(请输入第 (i 1) 个学生的成绩); int input scanner.nextInt(); // 简单的合法性校验成绩必须在0到100之间 while (input 0 || input 100) { System.out.print(成绩范围是0到100请重新输入); input scanner.nextInt(); } scores[i] input; } // 计算并输出各类统计量 double avg calculateAverage(scores); int max findMax(scores); int min findMin(scores); double passRate calculatePassRate(scores); System.out.println(平均分 avg); System.out.println(最高分 max); System.out.println(最低分 min); System.out.println(及格率 (passRate * 100) %); // 排序后输出全部成绩 int[] sortedScores Arrays.copyOf(scores, scores.length); bubbleSort(sortedScores); System.out.println(成绩从高到低 Arrays.toString(sortedScores)); } }然后是对应的三个统计方法每个方法只做一件事public static double calculateAverage(int[] arr) { if (arr.length 0) { return 0; } int sum 0; for (int score : arr) { sum score; } return (double) sum / arr.length; } public static int findMax(int[] arr) { int max arr[0]; for (int i 1; i arr.length; i) { if (arr[i] max) { max arr[i]; } } return max; } public static int findMin(int[] arr) { int min arr[0]; for (int i 1; i arr.length; i) { if (arr[i] min) { min arr[i]; } } return min; } public static double calculatePassRate(int[] arr) { int count 0; for (int score : arr) { if (score 60) { count; } } return (double) count / arr.length; }最后复用前面写过的冒泡排序方法排序前先复制一份数组避免改变原始数据的顺序。show一下排序的结果。public static void bubbleSort(int[] arr) { for (int i 0; i arr.length - 1; i) { for (int j 0; j arr.length - 1 - i; j) { if (arr[j] arr[j 1]) { int temp arr[j]; arr[j] arr[j 1]; arr[j 1] temp; } } } }这里我特意把比较符号改成arr[j] arr[j 1]实现的是从高到低排序。排序算法本身是一样的只是判断条件方向不同。5.3 这个项目里最容易翻车的几个点整个综合题做完之后我复盘了一下有几个细节确实容易出问题。第一个是平均分和及格率的整数除法问题。sum / arr.length两边都是int结果会直接截断小数正确写法是先把侧转成double也就是(double) sum / arr.length。这个坑几乎每个初学Java的人都踩过包括我自己第一次输出平均分时也只显示了一个整数。第二个是排序时不小心改变了原始数组的顺序。因为后面统计和输出都在用scores数组如果直接把原数组排序输出成绩时顺序就变了。我后来养成一个习惯凡是需要保留原数组的操作先Arrays.copyOf一份再动手。这个习惯在真实项目中能省很多事。第三个是输入校验。如果不校验成绩范围用户输入一个200或者-5程序计算出来一堆不符合常理的数据。写一个while循环重新接收输入虽然代码多几行但程序的健壮性完全不一样。我把这个项目写完第一遍之后又回头重写了一版重点不是背代码而是体会方法之间的职责划分main里没有任何复杂的业务逻辑所有统计细节都沉到了方法里。后面学习面向对象时你会发现这个职责单一的思路其实就是类和对象设计思路的雏形。Day03的内容量大但核心就是数组和方法两样把这套综合题吃透再进入后面的面向对象章节会轻松一大截。
返回列表