尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java数组与循环入门详解:从内存模型到经典算法实战

Java数组与循环入门详解:从内存模型到经典算法实战 数组和循环是Java入门绕不开的两个核心章节。我当年写的第一个有实际意义的Java程序是一个控制台版学生成绩统计工具说白了就是数组存成绩、循环算总分。那时候我才真正意识到这一章学不扎实后面集合、排序、算法题、甚至项目里的批量数据处理全部都会卡壳。这篇内容既适合零基础的同学从头学也适合准备Java面试的朋友快速把基础概念再过一遍——因为不管面试题怎么包装底层翻来覆去考的还是循环和数组那点东西。Java第二篇循环和数组从入门到写出高质量代码这篇内容我打算换个讲法不按教科书顺序罗列语法而是从“为什么数组和循环总是一起出现”这个核心问题出发把声明初始化、内存布局、循环控制、经典算法、常见报错一次讲透。全程用我实际写代码的经验来说话该给代码的地方给代码该给结论的地方给结论。1. 数组和循环为什么总被绑在一起讲很多初学者会有个困惑数组和循环明明是两章内容怎么教材、网课、面试题全都把它们放一起我觉得这不是巧合而是这两种东西在逻辑上是“天然共生”的。先说清楚这层关系后面学起来会顺很多。1.1 数组同类型元素的连续内存盒子数组的定义一句话就能说清一组类型相同、在内存中连续排列的数据容器。你可以把它想象成一栋酒店的走廊走廊上有一排编号固定的房间每个房间里住着同样“身份”的客人比如全是int或者全是String。房间编号从0开始第一个房间编号是0第二个是1第n个是n-1。这个编号在Java里叫下标或索引index访问某个房间的客人就用数组名[下标]这种方式。数组有三个特点面试也喜欢考长度固定数组一创建长度就不能变了。这一点和后面要学的ArrayList完全不同。类型统一一个数组里只能装同一类型的数据int数组不能塞字符串String数组也不能塞整数。连续存储所有元素在内存里挨着排这是数组能支持随机访问、按下标直接取元素的基础。为什么这些特点重要因为正是“长度固定类型统一”这两点决定了我们没法像做Excel表格那样随手给数组增删行所以批量处理数组元素的最佳方式就是循环一个一个地访问、计算、输出。反过来循环如果脱离了数组、集合这样的数据结构能做的事也很有限只能做点计数器之类的简单重复劳动。1.2 循环三种写法其实是三种生活场景Java里循环主要有三种for循环、while循环、do-while循环外加一个专门用来遍历数组和集合的增强for循环for-each。很多人背语法背得很熟但一到实际写代码就不知道该用哪个。我一般这样跟人讲for循环适合“我知道要循环多少次”的场景。比如把班级50个学生的成绩挨个过一遍循环50次用for最自然。while循环适合“我不知道具体次数但是知道什么时候该停”的场景。比如从键盘读输入直到用户输入quit才结束用while。do-while循环适合“不管怎么样先执行一次再判断要不要继续”的场景。比如菜单选择至少得让用户按一次键。增强for循环适合“只是从头到尾把每个元素读一遍”的场景代码最简洁。理解这个分类方式比死背语法重要得多。因为项目里的需求不会告诉你“这里用for循环”只会告诉你“把这个列表里所有符合条件的元素找出来”。1.3 面试和实战里的“高频绑定”从实战角度看数组和循环的组合几乎是批处理数据的基本功。你可能要从Excel导入几千行数据用循环逐行校验要处理一段日志用循环统计关键词出现次数要把数据库查出来的记录批量格式化成报表。这些动作的底层就是“数组或集合循环”。从面试角度看算法题基本全是循环和数组的组合拳排序、查找、去重、反转、双指针、滑动窗口……不管题目怎么包装最后落地都是几个for循环加一个数组。所以这一章不是背过就行的而是要练到“闭着眼睛都能写对边界条件”的程度。2. 数组从声明到内存升级数组这块光会用不够还得理解它“怎么来的、在内存里长什么样”。很多玄乎的报错比如空指针、数组越界根源都在内存模型上。2.1 三种初始化的选择逻辑Java里创建数组有三种常见写法我分别说一下什么时候用哪个。// 动态初始化只指定长度元素由系统赋默认值 int[] scores new int[5]; // 5个int默认全是0 // 静态初始化直接列出全部元素长度由编译器推断 int[] scores2 {88, 95, 70, 63, 99}; // 匿名数组不赋给变量直接作为方法参数传入 printArray(new int[]{1, 2, 3, 4, 5});动态初始化的特点是“先占坑后填值”。适合你只知道要存多少个元素、但具体值要后面算出来的场景。比如先开一个能放30个数的数组然后通过循环从文件或数据库里往里填。静态初始化适合一开始就知道全部值的场景比如预定义一些常量数据。匿名数组在写测试或者临时调用方法时非常方便不用先声明变量。这里有几个必须记住的细节int[] scores new int[5];创建后int类型默认值为0double类型默认0.0boolean类型默认falseString这样的引用类型默认是null。数组长度用length属性获取注意不是方法所以不加括号。这是Java初学者高频错误之一String用length()方法数组用length属性两个完全不同。静态初始化写法int[] arr {1,2,3};只能写在声明语句里如果你想先声明变量再赋值必须写成arr new int[]{1,2,3};。下面的对比表方便记忆初始化方式写法适用场景动态初始化int[] arr new int[5];长度已知值后面填静态初始化int[] arr {1,2,3};值已知一次性写好匿名数组method(new int[]{1,2});临时传参数不保存引用2.2 JVM内存视角栈、堆、引用这一节是解决“莫名其妙报错”的关键。Java的内存可以粗略分为栈Stack和堆Heap两块。局部变量存在栈里new出来的对象存在堆里栈里的变量通过“引用”去指向堆里的对象。拿int[] arr new int[4];举例右边new int[4]在堆内存里划出4个连续格子每个格子能放一个int系统给它们初始值0同时把这块内存的首地址交出来。左边arr是栈里的一个局部变量它保存的是堆内存那块地址的引用不是数据本身。通过arr[0]赋值本质是顺着引用找到堆里的对应格子再写入。为什么理解这个很重要因为你一旦写出int[] arr2 arr;arr2和arr指向的是同一块堆内存。这时候通过arr2修改元素arr看到的数组也会变。我之前帮同事排查过一个bug他以为自己复制了数组结果改了一个数组另一个也变了其实就是引用没搞清楚。如果真想复制出一个独立副本要用System.arraycopy或者Arrays.copyOf方法而不是简单赋值。这个点面试也常问数组是引用类型不是基本类型。2.3 二维数组和不规则数组的“降维打击”二维数组在Java里不是一个真正的矩阵而是“数组的数组”。比如int[][] matrix new int[3][4];它创建了一个长度为3的数组里面每个元素又是一个长度为4的int数组。所以二维数组的行数好理解列数其实是“每行的数组长度”。还有一种不规则数组在Java里是允许的可以先只定行数每一行再单独指定不同长度。int[][] triangle new int[3][]; triangle[0] new int[2]; triangle[1] new int[4]; triangle[2] new int[3];这种写法在处理杨辉三角、锯齿形数据时很实用。遍历二维数组标准做法是嵌套循环外层循环控制行内层循环控制列。int[][] matrix {{1,2,3}, {4,5,6}, {7,8,9}}; 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不是固定写死一个数字这样即使每一行长度不同也不会越界。3. 循环控制流的使用细节和避坑写法循环这种东西看别人代码觉得简单自己一写就出各种问题。这节我把三种循环的边界写法、控制跳转都说清楚顺便把我踩过的坑标出来。3.1 for循环从边界条件说起for循环的标准结构是for(初始化; 条件判断; 步进操作)。我见过的初学者错误一半出在边界条件上。int[] arr {10, 20, 30, 40, 50}; for (int i 0; i arr.length; i) { System.out.println(arr[i]); }这段代码有两个约定俗成的写法我建议直接形成肌肉记忆循环变量从0开始因为数组下标从0开始。条件用而不是即i arr.length这样当i等于数组长度时就停止正好不会越界。写成i arr.length就会在最后访问到arr[arr.length]直接抛ArrayIndexOutOfBoundsException。还有一个容易被忽视的坑循环条件里写没写对变量。有人写i length但是循环体里不小心把i改成了0结果死循环有人把i漏了也死循环。写循环的时候循环变量只在三个地方出现初始化、条件判断、步进更新不要在循环体里随便改它尤其不要给i赋值除非你知道自己在干什么。循环也不一定每次只加1。假如你想遍历数组的偶数下标元素可以写for (int i 0; i arr.length; i 2)。假如你想从末尾往前遍历可以写for (int i arr.length - 1; i 0; i--)注意这时条件用0容易写错成0导致漏掉第一个元素。3.2 while和do-while什么时候用哪个while循环最适合“不知道次数但知道条件”的场景。比如从控制台读取输入直到用户输入exitScanner scanner new Scanner(System.in); String input; while (!(input scanner.nextLine()).equals(exit)) { System.out.println(你说的是 input); }这里要注意的是while循环体里必须有某种机制让条件最终能变成false否则就是死循环。很多新手写while (true)然后忘记在里面写break程序就卡死了。do-while的特点是“先执行一次再判断”适用于必须至少执行一次的业务场景。比如用户输入一个数字如果不在1到100之间就让他重新输入int num; do { System.out.print(请输入1-100之间的数字); num scanner.nextInt(); } while (num 1 || num 100);这个场景用do-while最自然因为不管怎么样用户都得先输入一次然后才判断要不要再来一次。如果用while就得在循环前先手动读一次代码会显得别扭。三种循环的选择我总结成一句话知道次数用for不知道次数用while至少执行一次用do-while只读遍历用增强for。3.3 break、continue、标签跳转组合拳循环体里有两把“控制钥匙”break和continue。break立刻结束当前整个循环不管条件是否还成立。continue跳过本次循环的剩余语句直接进入下一次迭代。举个具体场景在一堆订单里找第一个金额大于1000的订单找到了就结束不用继续找。这就是break的典型用法。for (int i 0; i orders.length; i) { if (orders[i] 1000) { System.out.println(找到了 orders[i]); break; } }continue的经典场景是过滤掉不符合条件的数据。比如统计数组里正数的个数遇到负数就直接跳过。多层嵌套循环里break只能跳出当前最内层循环想跳出外层怎么办Java提供了标签label功能outer: for (int i 0; i 5; i) { for (int j 0; j 5; j) { if (j 2) { break outer; } } }这个标签写在for前面后面带冒号。说实话我在实际业务代码里很少这么写因为三层以上嵌套本身就该通过提取方法优化掉了。但面试偶有考察能看懂不丢分。4. 数组循环的经典实操遍历、反转、查找、排序理论说再多不如把代码写一遍。这节我把数组和循环组合的四个经典操作完整写出来每个都可以直接在IDE里跑跑完再看讲解。4.1 最值与平均值一个for循环搞定求最大值、最小值、平均值是“数组循环”最基础的应用。我见过很多人的第一反应是排序后再取第一个虽然也能做但没必要。一次遍历足够了。int[] scores {72, 85, 90, 88, 95, 67, 80}; int max scores[0]; // 先假定第一个元素是最大 int min scores[0]; int sum 0; for (int i 0; i scores.length; i) { if (scores[i] max) { max scores[i]; } if (scores[i] min) { min scores[i]; } sum scores[i]; } double avg (double) sum / scores.length; System.out.println(最高分 max); System.out.println(最低分 min); System.out.println(平均分 avg);这里有两个细节值得注意。第一max和min的初始值我用了数组第一个元素而不是0。为什么因为如果数组全是负数初始值0会导致最大值算不对。第二算平均值时先把sum转换成double再除否则两个整数相除会丢掉小数部分这是Java整数除法的坑。我后来带新人时反复强调一句话循环里能只遍历一次就不要遍历多次。这是性能习惯问题数组小的时候无所谓数据量上来了差距就明显了。4.2 数组反转双指针的妙用反转数组这个需求在笔试题里出现频率不低比如把字符串转成字符数组然后反转。最经典的写法是双指针法一个指针从头往右走一个指针从尾往左走两个指针对应的元素交换直到两个指针相遇。int[] arr {1, 2, 3, 4, 5, 6, 7}; int left 0; int right arr.length - 1; while (left right) { int temp arr[left]; // 先把左边元素暂存 arr[left] arr[right]; // 右边赋给左边 arr[right] temp; // 暂存的左边值赋给右边 left; right--; } System.out.println(Arrays.toString(arr)); // [7, 6, 5, 4, 3, 2, 1]为什么要用双指针而不是新建一个新数组存反转结果因为双指针是原地反转不需要额外空间空间复杂度O(1)。如果新建数组空间复杂度就是O(n)。虽然这里差别不大但在算法题里空间复杂度是评分点。交换元素时那句temp arr[left]是必须的。没学过编程的人可能想当然地写arr[left] arr[right]; arr[right] arr[left];这样两边都会变成同一个值原来的值丢失了。临时变量就是用来开门关门的那把钥匙先锁住一个值再移动。4.3 二分查找循环里的边界魔法查找操作最朴素的方式是线性查找一个个遍历找到就返回下标。但数组有序的时候用二分查找效率高得多每一轮把查找范围折半。int[] arr {10, 20, 30, 40, 50, 60, 70}; int target 50; int low 0; int high arr.length - 1; int index -1; while (low high) { int mid low (high - low) / 2; // 中间位置 if (arr[mid] target) { index mid; break; } else if (arr[mid] target) { low mid 1; // 目标在右半部分 } else { high mid - 1; // 目标在左半部分 } } System.out.println(目标下标 index);边界条件是二分查找最容易被问倒的地方我写的时候给自己理了三个记忆点while条件是low high不是low high。等号保证了low和high重合时还能再检查一次那个元素否则可能漏掉目标。mid的计算用low (high - low) / 2而不是(low high) / 2。后者在极端情况下可能溢出虽然Java里int溢出也未必马上崩但这是业界推荐的稳妥写法。每次更新范围时low mid 1high mid - 1而不是直接赋mid。不加1或减1可能出现死循环因为mid已经排除了。这个难点我在面试时看很多人折在这里其实本质就是“逐步缩小搜索范围”的思路写代码前先在草稿纸上把范围画出来比闭眼硬写强得多。4.4 冒泡排序与选择排序两种思路一次讲透排序是面试高频也是数组循环的综合应用。我建议优先掌握两种基础排序冒泡排序和选择排序。它们都是O(n2) 后者比前者还稳定地少一些无谓交换。冒泡排序的思路是每一趟从头开始相邻两个元素两两比较如果顺序不对就交换。一趟结束后最大的元素就像气泡一样“冒”到了数组末尾。下一趟少比较一个元素直到整个数组有序。int[] arr {64, 34, 25, 12, 22, 11, 90}; 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; } } } System.out.println(Arrays.toString(arr));注意内层循环的边界是arr.length - 1 - i。外层循环每走一趟末尾就多一个已经排好的元素所以内层就可以少比较i个。这里减1是因为每次比较的是arr[j]和arr[j1]j最大只能到数组倒数第二个下标。选择排序的思路则是每一趟从未排序部分找到最小的元素把它放到未排序部分的开头。int[] arr {64, 34, 25, 12, 22, 11, 90}; for (int i 0; i arr.length - 1; i) { int minIndex i; for (int j i 1; j arr.length; j) { if (arr[j] arr[minIndex]) { minIndex j; } } int temp arr[i]; arr[i] arr[minIndex]; arr[minIndex] temp; } System.out.println(Arrays.toString(arr));选择排序里minIndex记录的是未排序区间里最小元素的下标找到后再和当前区间的第一个元素交换。注意这里每次交换的是一趟结束后的最终结果不像冒泡那样每比较一次就可能交换一次所以数据量大时选择排序的交换次数少很多。当然真实项目里排序一般直接用Arrays.sort(arr)底层排序算法已经很成熟了。但面试手写排序考察的是你有没有理解“循环控制 数组操作”的基本功而不是让你重新发明轮子。5. 高频报错、面试题与排查速查表最后这部分是我认为全文最值钱的内容。数组和循环的报错信息有很强的规律性把它们摸透了以后写代码能少走一个月的弯路。5.1 三种典型异常的现场还原第一个是ArrayIndexOutOfBoundsException数组越界异常。只要访问了不存在的下标就会出现。最常见的场景是循环条件写错比如i arr.length在数组长度为5时访问了arr[5]。我在日志里看到这个异常的第一反应就是去查循环边界而不是去看数组内容。第二个是NullPointerException空指针异常。数组本身是引用类型如果你声明了数组变量但没有初始化就使用编译器可能放过你运行时就抛异常。此外引用类型数组比如String数组动态初始化后每个元素默认是null如果不一个个赋值为具体对象就直接调用元素的方法也会空指针。第三个不是异常是死循环。程序卡住不退出、CPU飙高基本就是while条件永远为true或者for循环步进没生效。我见过有人把i写成了i1从语法看没错但i的值根本没变循环就永远跳不出去。写这类代码时我习惯在循环体里加一行临时日志打印计数器跑一次就能定位问题。5.2 面试小题直接写在白板上的代码面试考循环和数组通常不会只问概念而是给出几个小题目让人现场写。下面两道是我认为最值得练的第一道生成斐波那契数列前20项存入数组并倒序输出。这道题综合考了数组赋值、循环累加、倒序遍历。int[] fib new int[20]; fib[0] 1; fib[1] 1; for (int i 2; i fib.length; i) { fib[i] fib[i - 1] fib[i - 2]; } for (int i fib.length - 1; i 0; i--) { System.out.print(fib[i] ); }第二道找出数组里只出现一次的数字其余数字都出现两次。我在面试中遇到的是“LeetCode136”题的基础版最巧妙的解法是用异或。但更基础的入门做法是双重循环计数用外层循环取出一个数内层循环统计它出现几次如果计数为1就找到了。虽然效率不是最优但思路最直观小白也能理解。第三道统计[100, 999]之间有多少个水仙花数并把这些数存进数组。这道题考的是循环和取数位运算的配合。// 先数出有多少个确定数组长度 int count 0; for (int i 100; i 999; i) { int a i / 100; // 百位 int b i / 10 % 10; // 十位 int c i % 10; // 个位 if (a * a * a b * b * b c * c * c i) { count; } } int[] nums new int[count]; int index 0; for (int i 100; i 999; i) { int a i / 100; int b i / 10 % 10; int c i % 10; if (a * a * a b * b * b c * c * c i) { nums[index] i; } } System.out.println(Arrays.toString(nums));这道题有个技巧先遍历一遍统计个数再创建数组填值。因为数组长度固定不知道有多少个时就得多遍历一次这也是数组和集合相比不够灵活的地方。理解这个限制后面学ArrayList时会更有体会。5.3 五年踩坑总结循环和数组的避坑清单我把这几年在代码里看到的高频问题整理成一个速查表基本上覆盖了新人能踩的90%的坑问题现象常见原因解决思路数组越界异常循环条件写成或下标从1开始统一用i arr.length下标从0开始空指针异常数组变量没初始化或引用类型元素未赋值打印完整堆栈定位到具体行检查new和赋值死循环while条件永不false或for迭代语句失效在循环体加计数器日志检查条件变量是否更新数组元素改不动增强for里对基本类型元素赋值无效想修改元素值就用普通for下标复制了数组但改了原数组也变直接用赋值两个引用指向同一堆内存用Arrays.copyOf或System.arraycopy平均值不对整数相除丢失小数部分先转成double再除思维固化在固定长度写死数组大小数据多了放不下用List或动态扩容循环里多算或漏算一位边界没推清楚对-1和1不敏感打草稿画区间上面二分查找也是这个道理最后再说一个实际开发中很实用的小技巧。当你调试循环相关bug时与其反复看代码猜不如直接在循环体里加一句System.out.println(i i , value arr[i]);打印出一两轮结果问题基本一眼就能看出来。很多时候不是逻辑不懂而是某个中间状态的数值和你想的不一样打印出来立刻就知道错在哪了。我写代码这么多年最深的体会是数组和循环这种“基础得不能再基础”的知识恰恰决定了后面所有代码的质量。排序算法写得乱不乱、边界处理得细不细、循环会不会产生无效遍历全看这个阶段有没有打牢基本功。把这一章练扎实后面学面向对象、集合框架、IO流你会明显感觉轻松很多。
返回列表