Java数组双循环与去重逻辑:从原理到方法封装实战

发布时间:2026/10/12 13:11:55

Java数组双循环与去重逻辑:从原理到方法封装实战 不用急着去背“双循环”“去重”“多分支”“方法”这些孤立的知识点。很多Java基础明明都见过一写代码就乱说到底是因为没有把“循环怎么控制”“逻辑怎么分支”“代码怎么封装”这三件事串成一条线。这篇文章就从数组的双循环开始把去重逻辑拆开揉碎再引出多分支判断和方法封装。这套组合在面试里很常见在平时写业务代码时更是离不开。你不妨按文中的思路打开IDE一边读一边跟着敲跑通一次比看十遍都管用。1. 为什么双循环是数组操作的基本功1.1 双循环不是“循环套循环”这么简单先回答一个最基础的问题为什么操作数组时经常需要双循环数组是个线性结构一个循环就能从头扫到尾。但一旦你想比较“每一个元素”和“其他元素”的关系——比如找重复项、做排序、判断两两配对——单层循环就无能为力了。因为单层循环只能站在一个位置上看全局却没法站在每一个位置上分别和“别人”做比较。双循环的核心逻辑简单讲就是外层循环定基准内层循环做比较。外层每走一步内层就完整地扫一遍或者扫一半用这种方式穷举出所有两两组合。来一个最贴近生活的类比假设一个班有N个同学要统计每两个人之间是否认识。你不可能只问一遍就得到全部答案而是得让第1个人依次去问第2、第3、第4……个人然后第2个人再去问第3、第4……个人。这个“依次找人去问别人”的过程天然就是嵌套循环。用代码写出来就是最经典的双循环骨架for (int i 0; i arr.length; i) { for (int j i 1; j arr.length; j) { // 比较 arr[i] 与 arr[j] } }这里有个细节值得新手注意内层循环的起始位置是j i 1不是j 0。为什么因为当外层i0时内层已经把arr[0]和后面所有元素都比过了当外层i1时arr[0]与arr[1]已经比过没必要再从j0开始。从i1开始一方面避免了重复比较另一方面也天然避开了“自己和比自己”的无意义操作。如果你用j 0从头开始那不仅白白多算很多次还会让每一对元素被比较两遍。在小数组上感受不出来一旦数组规模变大有可能从百万次级别变成两百万次级别性能损耗相当可观。1.2 双循环的三个典型应用场景双循环绝不只为了去重而存在。我带过的不少初学者会把“双循环”和“去重”画等号其实双循环在数组处理里至少有三类高频场景场景一去重与查重。这个就是本篇文章的核心主题之一。逻辑很简单外层循环拿出当前元素内层循环到它前面或后面的元素中去“找相同”。找到了说明重复没找到说明是第一次出现。for (int i 0; i arr.length; i) { boolean isDuplicate false; for (int j 0; j i; j) { if (arr[i] arr[j]) { isDuplicate true; break; } } if (!isDuplicate) { // arr[i] 是不重复的元素 } }这段代码和前面的骨架略有不同内层循环是j i也就是到当前位置之前去找。这其实是从“数据流向”的角度理解去重——每次处理新元素时只需要回头看已经处理过的部分不用关心后面的元素。后面是“未来”你无法预知但可以确定之前有没有出现过同样的值。这种“回头看”的写法在去重、统计首次出现位置时都很顺手。场景二排序。冒泡排序就是双循环的教科书案例。外层循环控制“需要经过多少轮”内层循环负责“每一轮相邻元素的两两比较和交换”。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。每经过一轮最大的那个元素就会像气泡一样浮到数组末尾已经到位的元素不需要再参与比较所以每轮比较的范围都在缩小。如果不减i虽然结果可能依然正确但会做很多无效比较。场景三二维数组遍历。二维数组本质上是“数组的数组”。外层循环遍历第一维内层循环遍历第二维组合起来才能访问到每一个元素。int[][] matrix { {1, 2}, {3, 4}, {5, 6} }; 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(); }想象你把二维数组想成一个Excel表格外层循环是一行一行往下走内层循环是在当前行里一格一格往右走。行下标和列下标共同确定了唯一一个单元格这就是双循环在二维结构中的意义。这三个场景告诉我们一个道理双循环的本质是一种“组合遍历”的思维方式重点不是背代码而是看到问题能判断该不该用双循环以及决定内外层循环各自控制什么维度。2. 用双循环实现数组去重从零手写2.1 一个新手最容易接受的版本很多教材和课程会直接把LinkedHashSet或者Stream API甩给你说“一行代码去重”。但对一个Java基础还没扎牢的人来说我还是建议先把原生双循环版本写明白。原因很直接去重的本质是“判断一个元素有没有出现过”而双循环正是把这种判断过程体现在了最原始的层面上面。让我展示一个看似“笨”但逻辑每一步都说得通的写法public class ArrayDuplicateRemoval { public static void main(String[] args) { int[] arr {3, 1, 2, 3, 4, 2, 5, 1}; int[] tempArr new int[arr.length]; int newIndex 0; for (int i 0; i arr.length; i) { boolean isRepeat false; for (int j 0; j newIndex; j) { if (arr[i] tempArr[j]) { isRepeat true; break; } } if (!isRepeat) { tempArr[newIndex] arr[i]; } } int[] result new int[newIndex]; System.arraycopy(tempArr, 0, result, 0, newIndex); System.out.println(Arrays.toString(result)); } }这段代码的关键在于tempArr。我见过很多初学者在这一步卡住为什么不直接在原数组上覆盖为什么需要一个临时数组核心原因是你在遍历原数组的过程中并不知道最终去重后还剩多少个元素。一旦使用定长数组就得先给结果预留出空间。原数组长度就是最大可能的去重后长度所以tempArr的长度直接取arr.length宁可多留不能不够。另一个细节是newIndex。它有两个身份一是“目前已经放入了多少个不重复元素”二是“下一个不重复元素应该写入tempArr的哪个位置”。内层循环里用j newIndex而不是j arr.length等于只和“已经确定不重复的那部分”做比较。这比遍历整个tempArr更精确也更快因为tempArr后面那些未赋值的默认位置全是0拿它们做比较没有任何意义甚至会把0这种没实际出现的值误判成“已存在”。当isRepeat为false时执行tempArr[newIndex] arr[i]。这里newIndex是先使用当前值赋位置、再自增。第一次写入时newIndex是0写入tempArr[0]然后newIndex变成1。第二次写入时写入tempArr[1]……顺序严丝合缝。最后newIndex记录了去重后数组的真实长度用它来截取真正有效的部分。System.arraycopy是系统级方法效率比for循环手动拷贝更高也显得代码更专业。当然如果你还没学到这个API完全可以用for循环代替for (int k 0; k newIndex; k) { result[k] tempArr[k]; }2.2 双循环去重的时间复杂度与隐患上面这个版本的时间复杂度是O(n²)准确说是O(n * m)m是去重后的元素个数。最坏情况所有元素都不重复时m接近n所以就是O(n²)。对于几千几万个元素这个性能完全够用但如果你处理的是几十上百万条数据这个算法就会慢到让人抓狂。我自己曾经在一个数据清洗的练习里用双循环去重处理一个10万元素的数组肉眼可见地卡了好几秒。换成HashSet瞬间完成。这就是为什么实际工作中很少直接用双循环去重——但你依然得会写、得懂原理。因为双循环去重是理解HashSet去重的基础你只有亲手写过“判断元素是否已存在”这个过程才能真正理解为什么HashSet能那么快哈希查找接近O(1)。2.3 手写去重的三个常见错误这个版本的代码虽然简单但新手经常在三个地方写错第一isRepeat的位置放错了。有些同学会把它声明在外层循环外面也就是i循环之前。结果就是第一个元素一旦被标记为true之后所有元素就都会被当成“重复元素”去重结果直接变成空数组或者只剩下第一个元素。正确写法是每一轮外层循环都重新声明boolean isRepeat false让它的生命周期严格对应“当前元素是否重复”这一件事。第二忘记重置或在比较过程中没有break。其实不break也没错只是会多比较几次。但有一种写法是错误的在if里直接就tempArr[newIndex] arr[i]没先判断是否重复。这会导致所有元素都被写入去重完全失效。如果去重逻辑只有一个判断分支一定要保证“只有不重复才写入”。第三数组越界。如果newIndex已经到达tempArr.length继续执行tempArr[newIndex]就会抛ArrayIndexOutOfBoundsException。上面版本中newIndex最大不会超过arr.length因为不重复元素最多也就是数组长度所以不会越界。但如果你把tempArr的长度定义错了比如定义成arr.length / 2那就有可能会崩。3. 去重逻辑的缺陷与优化方案3.1 双循环去重的四个真实缺陷如果你去查资料会看到很多文章轻描淡写地说“双循环去重效率低”。但效率低只是表面往深了看它至少有四个层面的缺陷缺陷一时间复杂度过高。最坏情况O(n²)。数据量从1万涨到10万耗时不是涨10倍而是涨100倍。这种增长速度在真实业务场景中完全不可接受。缺陷二元素相对顺序可能被打乱。前面那个版本是保留首次出现顺序的。但有些变体写法比如先排序再去重会打乱原有的相对顺序。如果你的业务对顺序有要求比如保留第一个出现的重复元素的位置那排序方案就不能直接使用。缺陷三对于引用类型比较的是地址不是内容。两个内容相同的字符串对象用判断会得到false导致去重失效。必须用equals()方法。这其实是一个经典的Java基础考点下面会单独展开。缺陷四代码维护性差。把去重逻辑写在main方法的一大坨循环里一旦出现bug排查非常痛苦。而且这种逻辑很难复用——换个数组类型、换个需求代码基本就作废了。3.2 优化方案一利用有序数组去重降到O(n)如果数组本身有序或者我们先排序再去重去重逻辑就变得非常简单。因为相同的元素一定紧挨在一起只需要比较相邻元素即可public static int[] removeDuplicatesFromSortedArray(int[] arr) { if (arr null || arr.length 0) { return new int[0]; } Arrays.sort(arr); // 注意这会改变原数组顺序 int newIndex 0; for (int i 1; i arr.length; i) { if (arr[i] ! arr[newIndex]) { arr[newIndex] arr[i]; } } return Arrays.copyOf(arr, newIndex 1); }逻辑讲解newIndex是当前去重后数组的有效末尾位置。从第2个元素开始遍历如果当前元素arr[i]不和arr[newIndex]相等说明是新的值就把newIndex向前移动一位并把arr[i]放过去。一个循环O(n)。算上排序的O(n log n)整体还是比双循环快很多。但这里要提醒一点排序改变了原数组的顺序。如果业务要求保持相对顺序这个方案就不能直接用。3.3 优化方案二HashSet保持顺序且接近O(n)这是实际开发里最常见的方案。HashSet基于哈希表查找元素是否存在的平均时间复杂度是O(1)所以整个去重过程最坏情况O(n)还自带了“去重”的天然语义。public static Integer[] removeDuplicatesWithSet(int[] arr) { SetInteger set new LinkedHashSet(); for (int num : arr) { set.add(num); } return set.toArray(new Integer[0]); }这里我用的是LinkedHashSet而不是HashSet理由是前者的迭代顺序是插入顺序能保证去重后元素相对顺序不变。如果你不关心顺序换成HashSet即可性能差异很小。特别说明一下LinkedHashSet的去重底层原理和双循环本质上是同一件事——它也要判断“元素是否已存在”只是判断方式从遍历变成了哈希查找。理解了双循环你才能理解为什么Set可以去重为什么Set的元素不能重复。3.4 优化方案三HashMap统计频率不只是去重如果你不仅要“把重复的去掉”还想知道每个元素出现了几次而不是只关心去重后结果那就直接用HashMappublic static MapInteger, Integer countFrequency(int[] arr) { MapInteger, Integer freqMap new HashMap(); for (int num : arr) { freqMap.put(num, freqMap.getOrDefault(num, 0) 1); } return freqMap; }getOrDefault(num, 0)是个很好用的API如果map里还没有这个key就返回默认值0有就用已有的次数。然后加1再放回去。这样一个循环下来每个元素的出现次数都统计好了。当后续需要“去掉重复元素、保留不重复元素”直接取freqMap.keySet()就够了当需要“只保留出现一次的元素”就遍历entrySet筛选value为1的key当需要“把出现多次的元素列出来”那更是顺手拈来。这是一道非常经典的扩展题**不仅会去重还能统计频次还能按条件筛选。**面试时如果你能把这种扩展讲出来能明显看出你对容器的掌握程度。3.5 优化方案四Java 8 Stream一行去重你既然学到了方法的层次Java 8的Stream去重也值得知道。它内部封装了基于LinkedHashSet的去重逻辑代码极简int[] arr {3, 1, 2, 3, 4, 2, 5, 1}; int[] distinctArr Arrays.stream(arr).distinct().toArray();Stream的distinct()本质上也是LinkedHashSet实现的所以它能保持顺序。不过对于初学者我并不建议一上来就用这个。**先手写双循环再学HashSet最后再去了解Stream这条路最稳。**否则你只知道“去重可以调一行代码”却不知道这行代码背后发生了什么。工作中出了性能和顺序问题就完全无从排查。我把上面几种方案整理成一张对比表方便你在不同的场景里直接选方案时间复杂度是否保持原顺序代码复杂度适用场景双循环O(n²)是控制j从0开始低理解原理、小数据量排序后相邻比较O(n log n)否低不关心顺序的大数据量LinkedHashSetO(n)是中高效且需要保持顺序HashMap统计O(n)不保证中需要同时统计频次Stream distinctO(n)是极低JDK 8、快速开发4. 多分支逻辑与判断条件的分层设计4.1 多分支不是if-else堆砌数组处理的结果往往不是一个单一的答案。例如去重后你可能要根据元素值的大小进一步分类——偶数留下、奇数去掉或者某个元素出现次数大于等于2的标记为“重复”只出现一次的标记为“唯一”。这些都属于“多分支”问题。最朴素的写法就是if-else if-else一长串堆下来。代码能不能跑能跑。但可读性会越来越差。一段代码里如果出现五六个分支任何后来的维护者包括三个月后的你自己都要花大量时间一遍遍读条件才能明白这个分支到底在做什么。一个好的做法是**先理清条件的优先级再决定怎么组织分支。**比如判断“元素是否有效”先判断数组是否为null或空再判断元素是否重复最后判断元素值的区间。优先级从“边界条件”到“业务规则”从“广义”到“狭义”。这种层级感会让分支逻辑不再是一团乱麻。4.2 用多分支处理去重后的进一步分类我给你一个具体的例子把一个数组去重后把所有元素分成三类——“偶数”“奇数且大于10”“其他”。如果直接堆if-else你可能会这样写for (int num : distinctArr) { if (num % 2 0) { // 偶数 } else if (num 10) { // 奇数且大于10 } else { // 其他 } }单看这段逻辑没什么技术含量。但一旦你把“去重”“分类”“输出”三件事混在一个方法里代码就会变得很臃肿。更合理的做法是拆分职责后续结合方法封装让代码更清晰。其实多分支和去重在本质上是“组合使用”**去重解决的是“数据有哪些”多分支解决的是“数据怎么处理”。**两者结合才是一个完整的数据处理流程。4.3 switch与switch表达式的适用场景如果多分支的判断依据是一个具体的离散值比如一个类型码是1、2、3、4用if-else就不够优雅了此时优先考虑switch。Java 12之后还引入了switch表达式写法更简洁而且不需要手动breakString category switch (typeCode) { case 1 - 分类一; case 2, 3 - 分类二三; case 4 - 分类四; default - 未知分类; };但这里要提醒你**switch适合“单一变量、离散取值”的场景。**如果判断条件是范围比如num 10 num 20、或者涉及多个变量的联合判断switch就无能为力了老老实实用if-else更清晰。选对分支工具和写对分支逻辑同样重要。4.4 把分支条件抽成布尔方法分支代码可读性差的根源往往不是if本身而是条件表达式太长。比如if (arr null || arr.length 0 || arr[i] 0 || arr[i] 100) { // ... }这一行读起来比较费劲。改进方式是抽成方法if (isInvalid(arr, arr[i])) { // ... } private static boolean isInvalid(int[] arr, int value) { return arr null || arr.length 0 || value 0 || value 100; }这么一改主流程里的判断变得像是在读一句英语如果“无效”就怎么处理。具体怎么判断无效的细节被关在了方法里面。这就是“方法封装”对可读性的直观提升。判断逻辑越是复杂就越值得抽方法。5. 方法封装让双循环与多分支真正可用5.1 为什么要封装方法前面写的所有代码都集中在main方法里。main方法本身没什么问题但如果你的程序只有几十行它很清晰一旦你把去重、分类、统计这些逻辑全部塞进main你会发现方法越长越难读变量之间的依赖错综复杂调试时无从下手。程序设计的其中一个基本原则就是“单一职责”一个方法只做一件事。去重的方法只负责去重不负责打印分类的方法只负责分类不负责修改原数组。这样任何一步出了问题只需要盯住对应方法排查即可。另外一个被很多人低估的收益是复用。今天你要处理的是一个int数组的去重明天可能是另一个业务场景也需要去重。如果你把去重逻辑封装成一个方法下次直接调用如果没封装你只能复制粘贴再改改而每一次复制粘贴都在埋bug隐患。5.2 方法的参数设计修改原数组还是返回新数组封装方法时最重要的决策是方法到底应该修改原数组还是返回一个新数组这个决策会影响调用者怎么使用这个方法。返回新数组的优势是“不破坏原数据”原数组还能继续使用劣势是多了一次内存分配和拷贝。直接修改原数组的优势是节省内存劣势是原数据丢失。我在实际开发中的默认选择是**除非业务明确要求节省内存否则都返回新数组。**原因很简单副作用越小方法越安全。一个静悄悄把传入数组改掉的方法在大型程序里会造成非常隐蔽的bug。你要排查很久才能发现“明明我没改过这个数组怎么数据变了”举个例子假设我们想实现一个“去重并分类”的完整方法public static ListListInteger classifyAndDeduplicate(int[] arr) { // 去重 SetInteger set new LinkedHashSet(); for (int num : arr) { set.add(num); } // 分类 ListInteger evens new ArrayList(); ListInteger bigOdds new ArrayList(); ListInteger others new ArrayList(); for (int num : set) { if (num % 2 0) { evens.add(num); } else if (num 10) { bigOdds.add(num); } else { others.add(num); } } // 组织结果 ListListInteger result new ArrayList(); result.add(evens); result.add(bigOdds); result.add(others); return result; }调用方只需要一行ListListInteger result classifyAndDeduplicate(arr);main方法中不再需要关心里面是怎么去重、怎么分类的。这就是封装的意义把复杂留给自己把简单留给调用者。5.3 进一步拆分每个职责一个方法上面那个方法仍然不完美它把去重和分类混在了一个方法体里。如果某个分类规则变了你不得不动整体逻辑。更好的做法是拆成多个方法public static SetInteger deduplicate(int[] arr) { SetInteger set new LinkedHashSet(); for (int num : arr) { set.add(num); } return set; } public static String classify(int num) { if (num % 2 0) { return EVEN; } else if (num 10) { return BIG_ODD; } else { return OTHER; } }然后主流程就可以用“管道式”的思维组合它们SetInteger distinctSet deduplicate(arr); for (int num : distinctSet) { String category classify(num); // 按类别放入不同列表... }现在整段代码的主流程变得非常直白先去重再逐个分类。每个方法单独测试也极为方便——你完全可以在main里单独打印deduplicate(arr)看看去重对不对单独调用classify(15)看看分支判断对不对。这就是“可测试性”带来的实际好处。5.4 方法重载、可变参数和递归的这段补充Java方法的话题还包含重载、可变参数、递归。其中一个很实用的场景是“处理个数不确定的数组”。你可能要连接多个数组或者一次性处理多组数据这时可变参数Varargs就派上用场了public static int[] concatAll(int[]... arrays) { int totalLength 0; for (int[] arr : arrays) { totalLength arr.length; } int[] result new int[totalLength]; int index 0; for (int[] arr : arrays) { for (int value : arr) { result[index] value; } } return result; }这个操作本质上也是双循环——外层遍历多个数组内层遍历当前数组的每个元素。可见双循环的思维方式真的贯穿各种场景。方法重载则是“同名不同参”典型场景是让调用者可以偷懒。比如你可以同时提供“完整参数”和“默认参数”两种版本public static int[] removeDuplicates(int[] arr) { return removeDuplicates(arr, false); } public static int[] removeDuplicates(int[] arr, boolean keepOrder) { if (keepOrder) { SetInteger set new LinkedHashSet(); for (int num : arr) set.add(num); return set.stream().mapToInt(Integer::intValue).toArray(); } else { SetInteger set new HashSet(); for (int num : arr) set.add(num); return set.stream().mapToInt(Integer::intValue).toArray(); } }调用者如果只想快速去重可以直接调用一个参数版本需要保持顺序时再传第二个参数。这种“重载设计”降低了方法的使用门槛初始值由方法内部兜底处理。递归虽然能力和循环等价但某些场景下递归表达更自然。比如“多维数组求最大值”你当然可以用N层循环但维数不确定时循环层数写不写死都很别扭而递归可以优雅应对public static int maxValue(Object[] array) { int max Integer.MIN_VALUE; for (Object obj : array) { if (obj instanceof Integer) { max Math.max(max, (Integer) obj); } else if (obj instanceof Object[]) { max Math.max(max, maxValue((Object[]) obj)); } } return max; }注意这里用instanceof做类型判断本身就是一种多分支逻辑。你看多分支和方法递归又能组合到一起去。6. 一个完整的综合实战去重、分组、统计一体的成绩分析6.1 需求描述与其零散地展示代码片段不如把前面几节的内容串成一个完整案例。假设你现在有这样一个需求已知一个整数数组scoreList表示某次测验中部分学生的成绩可能重复因为不同学生可能考了同一个分数。需要做三件事去重得到有哪些“分数档位”统计每个分数出现的次数按“优秀90”“及格60~89”“不及格60”三个等级分组并输出每个等级下的分数及其出现次数。这个需求几乎把数组循环、去重、多分支、方法封装全部串起来了。6.2 面向对象式的设计思路这里我打算稍微把思路拔高一点。因为你已经在学方法了那就不如按“面向对象”的最低限度来组织代码定义学生成绩类还是直接封装静态方法为了不过度设计就直接用静态方法封装配合简单集合返回结果。第一步是一个“分数信息”的内部类用于保存分数和次数static class ScoreInfo { int score; int count; ScoreInfo(int score, int count) { this.score score; this.count count; } }第二步是核心统计分析逻辑。第一步去重也不是简单去掉重复而是同时统计频次。我们选择HashMap方案因为一次循环同时搞定“出现过的分数”和“每个分数出现几次”。public static MapInteger, Integer countScores(int[] scores) { MapInteger, Integer countMap new HashMap(); for (int score : scores) { countMap.put(score, countMap.getOrDefault(score, 0) 1); } return countMap; }第三步是多分支分组。根据分数范围分成“优秀”“及格”“不及格”三组public static String gradeLevel(int score) { if (score 90) { return 优秀; } else if (score 60) { return 及格; } else { return 不及格; } }第四步是组装结果并输出。重点体现在“每个等级下有哪些分数分别出现几次”public static void analyzeScores(int[] scores) { MapInteger, Integer countMap countScores(scores); MapString, ListScoreInfo grouped new LinkedHashMap(); grouped.put(优秀, new ArrayList()); grouped.put(及格, new ArrayList()); grouped.put(不及格, new ArrayList()); for (Map.EntryInteger, Integer entry : countMap.entrySet()) { int score entry.getKey(); int count entry.getValue(); grouped.get(gradeLevel(score)).add(new ScoreInfo(score, count)); } for (Map.EntryString, ListScoreInfo entry : grouped.entrySet()) { System.out.println(【 entry.getKey() 】); for (ScoreInfo info : entry.getValue()) { System.out.println( 分数 info.score 出现 info.count 次); } } }最后在main里调用public static void main(String[] args) { int[] scores {95, 82, 95, 60, 77, 43, 82, 90, 60, 100, 43}; analyzeScores(scores); }运行结果大致是【优秀】 分数 100出现 1 次 分数 95出现 2 次 分数 90出现 1 次 【及格】 分数 82出现 2 次 分数 77出现 1 次 分数 60出现 2 次 【不及格】 分数 43出现 2 次这个案例本身不复杂但它清楚展示了“数据去重统计”和“多分支分组”之间的联系——你用HashMap同时完成了去重和统计用gradeLevel方法把成绩归到不同分组里用最后一个双重循环来遍历分组并输出。所有操作都被拆成了独立的小方法每个方法都可以单独测试和复用。6.3 这个实战到底练了什么这个案例有意识地把前面所有知识点织在一起双循环在哪儿可能你会说这里没有双循环啊。但其实analyzeScores方法里“遍历分组 遍历组内分数”就是双循环外层是三个等级内层是每个等级下的分数列表。它和你之前写的“外层i内层j”并没有本质区别只是这次外层遍历的是Map的entrySet内层遍历的是ArrayList。去重逻辑在哪儿HashMap的key天然记录了出现过的分数value记录了次数。这就是“去重统计”的增强版。多分支在哪儿gradeLevel的if-else把分数映射到等级。方法封装在哪儿每一个独立步骤都封装成了方法甚至ScoreInfo这个类也在帮助整理输出结构。理解了这一层你会发现面试里常问的“你怎么把数组去重并统计频次并分类”这类问题本质上就是这些基础能力的组合。你不需要背答案你只需要把组件想清楚写出来自然就能通。7. 常见问题与排查技巧实录7.1 数组越界到底是哪里越界双循环代码最常见的报错就是ArrayIndexOutOfBoundsException。遇到这种问题先不要急着加一个if兜底而是要确定到底哪一次循环越界。我的排查步骤是这样先用System.out.println(i i , j j)打印循环变量的当前值立刻就能定位是哪一层循环出了问题。最常见的成因有三个内层循环起始条件写成了j i当元素和自己比较本身不越界但在某些算法里会造成误判。内层循环结束条件写成了j arr.length多循环一次最后一次访问越界。使用了j arr.length - i但i从1开始写边界计算错误。一个我在面试中经常看到的答案去重时内层循环写成j i; j arr.length; j然后判断arr[i] arr[j]如果相等就把arr[j]删掉。这个思路在“相邻元素重复”的情况下大多能跑但遇到{1, 2, 1, 1}会被坑——因为删掉元素后后面的元素会前移但循环变量j不会自动回退导致漏检。这就是为什么去重更稳妥的做法是“不直接删而是复制到新数组”。7.2 循环变量覆盖和break设置错误再看这类问题for (int i 0; i arr.length; i) { for (int j 0; j arr.length; j) { if (i j) continue; // 跳过自己 if (arr[i] arr[j]) { // 重复 } } }这段代码里内外层变量名是i和j不容易搞混。但如果有人习惯写for (int i 0; i arr.length; i) { for (int i 0; i arr.length; i) { // 编译错误变量i重复定义这会把编译器的报错直接暴露出来。解决方案也简单外层用i内层用j保持命名有区分度。break的问题更隐蔽。请看这段“错误示范”for (int i 0; i arr.length; i) { boolean flag false; for (int j i 1; j arr.length; j) { if (arr[i] arr[j]) { flag true; break; // 这个break只跳出内层循环 } } // 这里flag已经生效 }如果这个break放错了位置比如放到了if (flag)的外面就会产生“只要找到第一个重复就立刻结束所有循环”的效果。要避免这种问题记住一个原则**break只能跳出自己所在的那一层循环。**如果你需要同时跳出多层循环可以给外层循环加标签例如outer: for (int i 0; i arr.length; i) { for (int j i 1; j arr.length; j) { if (arr[i] arr[j]) { break outer; } } }标签outer:不是关键字只是一个合法标识符。但这种写法可读性一般非必要不建议用。更好的思路是让代码逻辑本身“不需要多重跳出”——比如用方法封装用return代替break。7.3 布尔标志变量的重置问题这个问题前面已经提过但因为它太典型了值得单独放进排查清单。我见过太多初学者写出类似这样的代码——boolean isDuplicate false; for (int i 0; i arr.length; i) { for (int j 0; j i; j) { if (arr[i] arr[j]) { isDuplicate true; break; } } if (!isDuplicate) { // 存入结果 } }这段代码看似“flag在内层循环之前声明”但它不在外层循环内部结果就是一旦某个元素重复过一次isDuplicate从此永远为true后面所有本来不重复的元素全部被过滤掉。这种bug在数据量小的时候很难发现因为只要第一个元素恰好不重复前几个结果还是对的出现重复元素之后才开始产生“丢数据”的现象。正确做法把boolean isDuplicate false;放在外层循环体内确保每一轮都重新初始化为false。检查时你可以打印每一轮的isDuplicate值对比预期基本一眼就能看出问题。7.4 引用类型比较问题前面提到过如果数组中存的是Integer而不是int比较的是引用地址不是值。这时候去重最好调用equals()Integer[] arr {128, 128, 129, 129}; SetInteger set new HashSet(); for (Integer num : arr) { if (!set.contains(num)) { // 这一步其实依赖equals/hashCode set.add(num); } }HashSet内部通过hashCode()和equals()来判断元素是否相同而不是。这对自定义对象尤其重要——如果你往Set里放一个自定义类的对象却没有重写equals()和hashCode()那去重基本等于没用因为每个new出来的对象地址都不同Set会认为它们都是不重复的。这里有个新手非常容易踩的坑自定义类没有重写equals和hashCode然后拿着它去Set去重发现结果里出现大量“相同”的元素。排查方法也简单检查这个类有没有正确重写两个方法用IDE的生成功能也行。理解了这一点你对Java去重这类问题的理解才算真正深入了一层。7.5 常见问题速查表现象可能原因排查/解决方法数组越界内层循环结束条件多算1次边界值和数组长度没对齐打印i、j确认最后一次合法索引是length-1把改成去重结果为空/只剩第一个元素flag位置错放导致所有元素被判为重复把flag声明移到外层循环内每轮重置去重结果包含重复元素判断条件写错或把“等于”写成“不等于”检查if (arr[i] arr[j])方向是否正确建议打印每轮结果验证顺序被打乱使用了HashSet或排序方案需要保持顺序时改用LinkedHashSet或记录首次索引引用类型去重失效使用比较自定义类没重写equals/hashCode改用equals重写equals和hashCode方法调用后原数组变了方法内部直接修改了传入数组设计方法时优先返回新数组明确是否允许副作用多层循环break只跳出一层对break作用域理解不足使用带标签的break或把逻辑封装成方法用returnHashMap统计不准确忘记使用getOrDefault每次put覆盖旧值用getOrDefault(key, 0)1再put回去main方法直接报错“找不到符号”把局部变量声明在了循环内循环外访问不到把变量声明提升到合适的代码块作用域这张表建议保存下来。不是因为它有多高深而是因为这些坑我在带人时几乎每一个都见过它们高度典型。你自己写代码时也可以对照这张表快速缩小问题范围。8. 从面试到实战这套知识还能怎么用聊到这儿估计你已经能感受到标题里的“数组双循环去重逻辑多分支方法”并不是四个孤立的考点而是一套处理数据的完整思路。最后再分享一点我的个人经验。我在面试初级Java开发时经常会让候选人现场写“数组去重”。其实我更想看的不是他能不能用Stream一行搞定而是能不能在白板上逐步推理先确定用什么数据结构再考虑是否要保留顺序最后分析时间复杂度和边界条件。能讲清楚这些的候选人哪怕双循环版本写得磕磕绊绊我也会给加分。因为这说明他是真的理解而不只是在背题。在实际工作中这套知识也远不止于“考试”。比如你从接口拿回来一批订单号要去掉重复的再按状态分类处理你解析Excel时读进来一堆可能重复的ID需要统计每个ID出现了几次来决定是否合并。这些场景里HashSet去重 HashMap统计 if-else/switch分支 方法封装就是一套可以直接套用的模板。你越早把它们变成肌肉记忆写这类业务代码就越轻松。顺手再给你一个扩展练习如果把题目的int数组换成String数组要求忽略大小写去重再按字符串长度分组你应该怎么做提示去重时用TreeMap或者自己转小写作为key分组时用HashMapInteger, ListString分支判断的复杂程度会明显增加你正好可以试试怎么把条件和逻辑分开。自己动手跑一遍比看这篇文章十遍都更能长本事。
延伸阅读

更多相关文章

2026/10/12 13:11:55

大项目手足无措?用责任域切片、价值里程碑与最小契约破局

1. 大项目恐惧不是能力问题,而是系统性认知错位“手足无措”这四个字,我第一次在某跨平台系统重构项目启动会上听到时,坐在会议室第三排的A同学突然把笔捏断了。他不是不会写代码,也不是没做过模块——他刚独立交付过一个用户量20…

2026/10/12 13:11:55

产品交付控制程序编写指南:从流程梳理到落地避坑

简介:面向核电产品交付管理场景的质量体系规范文档,核心价值在于明确从完工到客户接收全过程的控制要求,适用于直发件、见证件、档案材料、备品备件、专用工具及完工文件等各类核电产品。文档按目的、范围、编制依据、职责、程序说明、形成文…

2026/10/12 14:07:03

Beyond Compare 4 使用指南:文件对比、文件夹同步与命令行自动化

简介:Beyond Compare 4.zip是一款经典的文件及文件夹对比工具安装包,面向需要频繁处理代码差异、数据同步与版本控制的开发者和IT运维人员。核心功能包括逐行文本与源代码比对、子文件夹递归比较、二进制文件差异识别,以及直观的三向合并操作…

2026/10/12 14:07:03

机器学习驱动的Web日志异常检测:告别阈值拍脑袋

简介:一款基于机器学习的Web日志统计分析与异常检测命令行工具,面向网站运维、安全分析人员及机器学习开发学习者。该工具可对Web访问日志进行多维统计,并借助机器学习模型自动识别恶意扫描、暴力破解等异常行为,辅助快速定位威胁…

2026/10/12 14:07:03

AI服务器供电架构升级:从12V母线到50V与VRM变革

拆开一台最新 AI 训练服务器,你不会第一眼看到那颗硕大的加速芯片——你会先看到围绕它的几百颗小方块、长条形电感和密密麻麻的电容。这些元件拼在一起,就是一个完整的供电域,核心角色叫 VRM(电压调节模块)&#xff0…

2026/10/12 14:02:03

PRODAVE_V62详解:不装组态软件用DLL直连PLC做数据采集

简介:西门子PRODAVE_V62工具包面向工业自动化现场的上位机开发与系统集成场景,用于搭建个人计算机与西门子可编程逻辑控制器之间的以太网通信链路,支持数据采集、变量读写、程序启停与远程监控,是连接管理层与控制层的常用桥梁。整…

2026/10/11 0:02:13

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/12 12:01:26

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/12 12:54:07

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/12 0:04:22

绝缘子缺陷检测数据集清洗与工业级训练实战指南

简介:本资源是面向电力AI研发人员、工业视觉工程师及智能巡检系统开发者的绝缘子缺陷检测专用YOLO格式数据集,解决无人机航拍场景下绝缘子破损、污闪、积雪等9类典型缺陷的精准识别与定位难题。数据集共2139张真实巡检图像(含训练/验证/测试集…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑