ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java集合框架与异常处理实战:从库存统计到遍历删除的完整指南

Java集合框架与异常处理实战:从库存统计到遍历删除的完整指南 警告如果你正打算背几个ArrayList和HashMap的方法就去面试请先停下。我见过太多候选人把集合框架的 API 倒背如流问一句遍历时能不能直接 remove就卡壳再问自定义异常应该继承 Exception 还是 RuntimeException就直接沉默。这两个主题看着基础其实是 Java 里最典型的面试造火箭、入职拧螺丝地带而真正拧螺丝的时候你会发现异常处理和集合框架几乎是每天都要碰的东西。这篇文章我选择把两者放在一起讲不是出于拼凑篇幅而是因为它们在真实项目中天然交织你用HashMap统计数据用ArrayList拼接结果用HashSet去重而这些操作一旦遇到空指针、类型转换失败、并发修改就需要异常机制来兜底。零基础的同学容易把它们看成两个孤立章节但实际编码时它们就像左手和右手。全文我会围绕一个贯穿案例展开——库存管理系统中的商品统计与导出功能从集合选型讲到异常设计再到两者如何配合最后附上高频问题和排查思路。无论你是自学入门、准备面试还是已经写了两年代码想补基础这篇都值得收藏。1. 内容整体设计与思路拆解1.1 为什么把异常处理和集合框架放在一起学很多初学者把 Java 基础分成一块一块地学今天是数组、明天是面向对象、后天是集合异常处理被扔到最后草草看一眼 try-catch 就翻篇。这种学习方式最大的问题是——你永远不知道知识在什么场景下用。集合框架是数据怎么存、怎么取、怎么遍历异常处理是程序出错了怎么办但实际业务里这两个问题永远是同时出现的。举个例子你在写一个电商后台的库存盘点功能从数据库查出所有商品塞进ListProduct然后用MapString, Integer按分类统计数量。这个过程中至少有三个机会点会触发异常查询结果可能为 null某个商品的价格字段可能是 null 导致排序时空指针遍历集合时如果有人在另一个线程同时修改了它还会抛出ConcurrentModificationException。换句话说集合框架有多常用异常处理就有多必要。所以我设计这篇文章的思路很简单不搞语法罗列而是以一个库存统计导出小工具为贯穿案例需要什么集合就讲什么集合会遇到什么异常就讲什么异常把两个主题自然编织在一起。这样学完以后你不是记住了一堆孤立的 API而是建立了一种数据结构和异常处理是配套的编码直觉。1.2 零基础学高级特性的正确打开方式高级特性这个说法容易劝退新人但实际集合框架和异常处理都没那么玄乎。集合框架的本质就是帮你把数据组织起来异常处理的本质就是给程序装上安全网。真正的难点从来不是语法而是选型和设计。从选型上说你要能在 0.1 秒内判断这个场景该用List还是Set该用ArrayList还是LinkedList该用HashMap还是TreeMap。从设计上说你要能回答这段代码可能抛什么异常该在哪里捕获是向上抛还是自己处理自定义异常继承哪个类。这些决策背后都有明确的逻辑我会在后文逐层拆开。另外我想强调一个学习顺序上的建议不要一上来就啃源码。零基础阶段把集合框架的接口体系、常用实现类的适用场景以及时间复杂度这些宏观地图搞清楚比抠HashMap的红黑树细节有用得多。先会用、再问为什么、最后看源码这是最平滑的路径。1.3 贯穿案例库存统计与导出工具为了让内容可落地我设计了一个贴近真实业务的案例一个仓库里有多种商品每件商品有名称、分类、价格、库存数量等属性。我们需要实现以下功能用集合存储商品数据支持按分类统计库存总量找出库存低于安全线的商品并给出提示按价格排序后导出到报表处理查询过程中可能出现的各种异常并把错误信息记录到日志而不是直接让程序崩溃。这个案例规模不大但覆盖了ArrayList、HashSet、HashMap、TreeMap、Collections工具类、Comparator排序、自定义异常、try-with-resources 等核心知识点也几乎包含了一个初级工程师日常开发中会遇到的典型异常场景。后面每一章我都会回到这个案例上来讲避免空谈理论。2. 核心细节解析与实操要点2.1 集合框架总览先看懂接口体系再谈实现类Java 集合框架的顶层设计可以用一句话概括所有集合分为两大派系——Collection和Map。Collection下面又分List、Set、Queue三个子接口Map则是独立的键值对体系。搞懂这个继承关系比背十个实现类的方法重要得多因为面试时最常考的就是你说一下 List、Set、Map 的区别这个问题本质上就是在问你接口设计。List的特点是有序、可重复、可按索引访问日常开发中最常用的是ArrayList底层是动态数组查询快、增删慢LinkedList底层是双向链表增删快但随机访问慢实际项目中用得相对少。Set的特点是不可重复HashSet基于哈希表实现存取效率高但顺序不可控TreeSet基于红黑树元素自动排序代价是读写复杂度为 O(log n)。Queue是队列遵循先进先出PriorityQueue则按优先级出队。我这里用一个表格把选型逻辑快速列出来方便你直接对照场景接口实现类底层数据结构特点典型场景ListArrayList动态数组查询快尾部增删快存储列表数据、按索引访问ListLinkedList双向链表头尾增删快随机访问慢频繁增删、实现队列/栈SetHashSet哈希表去重无序O(1)去重、判存在SetTreeSet红黑树去重且排序需要有序去重集合MapHashMap哈希表红黑树键值对查询快缓存、按 key 聚合MapTreeMap红黑树按键排序范围查询、有序映射记住一句话没有任何一个实现类是万能的选型的依据永远是你的业务场景。数据变多后要排序就选TreeMap仅仅按名字查信息就选HashMap需要去重还保留插入顺序就选LinkedHashSet。先判断需求再选集合代码质量会明显提升。2.2 异常体系别再一遇到报错就 try-catchJava 的异常体系顶层是Throwable下面分两个分支Error和Exception。Error是 JVM 层面的严重问题比如OutOfMemoryError、StackOverflowError这类错误程序本身无法恢复不用捕获也不要捕获Exception才是我们日常要处理的。Exception又分为两类。受检异常checked exception如IOException、SQLException编译器强制你处理要么 try-catch 要么 throws 上抛非受检异常unchecked exception如NullPointerException、IllegalArgumentException、ClassCastException都是RuntimeException的子类编译器不强制处理。很多新手最大的误区是一看到代码报错就 try-catch 包一层这其实掩盖了真正的问题。受检异常意味着外部环境可能出问题你要有预案非受检异常意味着你自己的代码逻辑有 bug不该用 try-catch 掩盖。我个人的判断标准是如果异常是外部因素导致的比如文件不存在、网络超时、数据库连接失败用受检异常并给出降级方案如果是参数校验失败、状态非法这类编程错误直接抛IllegalArgumentException或自定义运行时异常让异常信息尽早暴露。这个原则在很多公司代码规范里都有只是没人给你讲清楚为什么。2.3 两者的交汇点fail-fast 机制与 ConcurrentModificationException集合框架和异常处理最经典的交汇点就是 fail-fast快速失败机制。用ArrayList、HashMap等非线程安全集合时如果在迭代过程中有其他线程修改了集合结构会抛出ConcurrentModificationException。这个异常的底层原理是集合内部维护了一个modCount计数器每次结构性修改add、remove、clear都会让modCount加一而迭代器创建时会记录当时的modCount每次next()都会检查当前modCount是否等于预期值不等就立刻抛异常。这个设计听起来好像很暴力但背后的动机很简单集合在迭代过程中如果结构变了迭代器无法保证下一个元素的正确性与其返回错误数据不如快速失败、尽早暴露问题。这就像你在高速上开着车仪表盘的监测系统发现问题直接报警而不是让你继续开下去酿成大祸。实操中这个异常最常出现在一个场景——遍历ArrayList时直接调用list.remove()删除符合条件的元素。下一轮迭代时modCount对不上异常就炸了。正确的做法有三个用Iterator的remove()方法用removeIf()配合 lambda或者先收集要删的元素遍历结束后统一 removeAll。我会在第三章给出完整代码示例。2.4 HashMap 与 ArrayList 的关键参数扩容和哈希的底层逻辑面试问集合框架十个里有九个会问HashMap的扩容机制。我不建议零基础同学一上来就啃红黑树但有两个参数你必须理解负载因子 0.75和容量必须是 2 的幂次方。负载因子 0.75 是时间和空间的折中。太小意味着哈希表很快就要扩容空间浪费严重太大意味着哈希冲突概率升高查询性能下降。0.75 是 JDK 作者基于泊松分布计算出的平衡点。容量是 2 的幂次方是因为计算桶位置时用(n - 1) hash代替取模运算位运算比取模快得多而且只有容量是 2 的幂时(n - 1)的低位才全是 1哈希值能均匀散列。ArrayList的扩容相对简单默认容量 10每次扩容变成原来的 1.5 倍新容量 旧容量 旧容量右移一位。这里有个细节如果addAll一次性加入大量元素扩容是按Math.max(预期容量, 1.5倍旧容量)来的所以创建ArrayList时如果能预估数据量直接指定初始容量可以省下多次扩容的拷贝开销。这些参数不用死记但理解了背后的权衡面试时就能应对追问。3. 实操过程与核心环节实现3.1 案例需求定义商品与库存的数据模型我先定义贯穿案例的商品类。为了演示集合框架的排序和去重功能我给商品类加上分类、价格、库存数量三个属性并重写equals和hashCode。这里的重写是必要操作如果不重写equals和hashCodeHashSet去重的判断依据就是对象引用地址两个内容完全相同的商品会被当成不同对象去重功能直接失效。public class Product { private String name; private String category; private double price; private int stock; public Product(String name, String category, double price, int stock) { this.name name; this.category category; this.price price; this.stock stock; } // getter / setter 省略按需要生成 Override public boolean equals(Object o) { if (this o) return true; if (!(o instanceof Product)) return false; Product p (Product) o; return Double.compare(p.price, price) 0 stock p.stock Objects.equals(name, p.name) Objects.equals(category, p.category); } Override public int hashCode() { return Objects.hash(name, category, price, stock); } }这里有个容易踩的坑equals里先判断类型再逐个比较字段顺序看似无关紧要但如果是父子类继承关系用getClass()还是instanceof会导致完全不同的结果。集合框架推荐的做法是用instanceof保证子类对象也能和父类对象比较同时要在hashCode里使用相同的字段否则两个对象 equals 相等但 hashCode 不同HashSet 就会存进两个桶里去重失败。3.2 集合实操一数据存储、去重和统计现在开始真正的实操。假设我们从某个数据源拿到了一个原始商品列表里面有很多重复项我需要去重、按分类统计库存总量ListProduct rawList new ArrayList(); rawList.add(new Product(iPhone 15, 手机, 6999, 100)); rawList.add(new Product(小米手环, 穿戴, 249, 500)); rawList.add(new Product(iPhone 15, 手机, 6999, 100)); // 重复数据 rawList.add(new Product(ThinkPad, 笔记本, 8999, 60)); rawList.add(new Product(AirPods, 穿戴, 999, 80)); rawList.add(new Product(华为Mate60, 手机, 6499, 200)); // 去重 SetProduct uniqueProducts new HashSet(rawList); System.out.println(去重后商品数量 uniqueProducts.size()); // 按分类统计库存总量 MapString, Integer stockByCategory new HashMap(); for (Product p : uniqueProducts) { stockByCategory.merge(p.getCategory(), p.getStock(), Integer::sum); } for (Map.EntryString, Integer entry : stockByCategory.entrySet()) { System.out.println(entry.getKey() 类库存总量: entry.getValue()); }HashMap.merge是 JDK8 引入的实用方法如果 key 不存在就把 value 放进去如果 key 存在就把新值和老值按第三个参数这里是Integer::sum合并。这种写法比传统的containsKey判断加put两步操作简洁得多也不容易漏掉某条分支导致空指针。实际项目中我建议你优先掌握merge、computeIfAbsent、putIfAbsent这几个方法它们能帮你省掉大量模板代码。3.3 集合实操二排序和查找接下来处理两个高频需求按价格排序、找出库存低于安全线的商品。排序有多种实现方式最灵活的是使用Comparator。Java 8 之后可以用 lambda 和链式调用写出非常清晰的排序逻辑// 按价格升序排序 ListProduct sortedByPrice new ArrayList(uniqueProducts); sortedByPrice.sort(Comparator.comparingDouble(Product::getPrice)); sortedByPrice.forEach(p - System.out.println(p.getName() - p.getPrice())); // 按分类排序价格降序 sortedByPrice.sort(Comparator .comparing(Product::getCategory) .thenComparing(Product::getPrice, Comparator.reverseOrder())); // 找出库存低于 100 的商品 ListProduct lowStockProducts uniqueProducts.stream() .filter(p - p.getStock() 100) .collect(Collectors.toList()); System.out.println(库存低于 100 的商品数量: lowStockProducts.size());这里有个新手很容易忽略的点sort方法会直接修改原集合所以排序前我复制了一份new ArrayList(uniqueProducts)。如果你不想改动原列表就必须复制或者用 stream 的sorted方法生成新列表。另外Comparator.comparingDouble对 double 和 Double 都适用但如果字段是 float 就要用comparingFloat类型不匹配编译会报错这其实是在提醒你多用基本类型的专用方法避免自动装箱带来的性能损失。3.4 异常处理实操一自定义业务异常和受检异常的取舍在库存工具里商品价格不能为负、库存不能为负这些约束如果被违反最好的做法是抛出自定义异常让上层统一处理。设计自定义异常时有两条路继承Exception受检或继承RuntimeException非受检。我的建议和 Spring 等主流框架的做法一致业务校验失败继承 RuntimeException。理由很简单受检异常强制每个调用方都要 try-catch会导致捕获了又不知道怎么处理的尴尬局面最终很多团队习惯性地 catch 后吞掉反而掩盖了问题。而运行时异常可以在顶层统一处理代码中间层不需要被 try-catch 污染。只有像文件读取、网络请求这种调用方必须知道并且有能力处理失败的情况才用受检异常。public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } public BusinessException(String message, Throwable cause) { super(message, cause); } } public class ProductValidator { public static void validate(Product p) { if (p null) { throw new IllegalArgumentException(商品不能为 null); } if (p.getPrice() 0) { throw new BusinessException(商品价格不能为负数: p.getName()); } if (p.getStock() 0) { throw new BusinessException(商品库存不能为负数: p.getName()); } } }写自定义异常时还有一个容易被忽视的细节一定要提供接收Throwable cause的构造方法。因为很多时候你捕获了一个底层异常想在抛出自定义异常时不丢失原始堆栈这样才能方便排查真正的根源。只写一个String message构造方法会让异常链断裂日志里只能看到业务异常信息看不到底层原因。3.5 异常处理实操二try-with-resources 和 finally 的注意事项库存工具需要把统计结果导出到文件这就会涉及资源关闭的问题。传统的try-catch-finally写法要在 finally 块里关闭流代码啰嗦而且如果关闭流本身抛异常可能把主异常覆盖掉。JDK7 之后引入了 try-with-resources只要资源实现了AutoCloseable就能自动关闭代码简洁很多public static void exportReport(MapString, Integer stockByCategory, String filePath) { // 前置校验路径为 null 或空白时直接抛业务异常 if (filePath null || filePath.isBlank()) { throw new BusinessException(导出路径不能为空); } try (BufferedWriter writer Files.newBufferedWriter(Paths.get(filePath))) { writer.write(分类,库存总量); writer.newLine(); for (Map.EntryString, Integer entry : stockByCategory.entrySet()) { writer.write(entry.getKey() , entry.getValue()); writer.newLine(); } System.out.println(报表导出完成: filePath); } catch (IOException e) { throw new BusinessException(导出报表失败: filePath, e); } }关于 finally 有一个所有 Java 开发者都必须知道的规则不要在 finally 里写 return也不要让 finally 里抛异常。如果 finally 里有 return它会覆盖 try 或 catch 里的 return 值这是非常隐蔽的 bug。同样如果 finally 里抛了异常也会覆盖原有的异常导致问题更难排查。我见过一次线上事故排查了两天才发现是 finally 里的日志写入抛空指针把真正的业务异常给顶掉了。3.6 组合实操遍历时安全删除元素现在把集合和异常真正组合起来。前面的低库存商品需要从集合中移除很多新手会这样写for (Product p : uniqueProducts) { if (p.getStock() 50) { uniqueProducts.remove(p); // 运行时会抛 ConcurrentModificationException } }这段代码运行 100% 会抛ConcurrentModificationException原因就是我在 2.3 节讲的 fail-fast 机制。正确的做法是下面三种中的任意一种// 方式一使用 Iterator.remove() IteratorProduct iterator uniqueProducts.iterator(); while (iterator.hasNext()) { Product p iterator.next(); if (p.getStock() 50) { iterator.remove(); } } // 方式二使用 removeIf最简洁 uniqueProducts.removeIf(p - p.getStock() 50); // 方式三收集后统一删除 ListProduct toRemove new ArrayList(); for (Product p : uniqueProducts) { if (p.getStock() 50) { toRemove.add(p); } } uniqueProducts.removeAll(toRemove);三种方式的取舍Iterator方式最传统能在删除前后做一些额外逻辑removeIf最简洁适合简单的条件删除removeAll适合需要先筛选后决策的场景。注意方式三有个前提被删除的元素必须在 uniqueProducts 集合中重写过equals和hashCode否则removeAll删不掉。这也是为什么我在 3.1 要强调重写这两个方法。3.7 完整运行与日志记录给异常留证据最后把整个流程串起来。实际项目中异常不能只打印在控制台要记录到日志里。这里我用 JDK 自带的java.util.logging做演示生产环境你可以换成 Log4j2 或 Logback但思路一致日志里必须有时间、异常类型、堆栈、业务上下文。public class InventoryTool { private static final Logger logger Logger.getLogger(InventoryTool.class.getName()); public static void main(String[] args) { try { ListProduct rawList loadRawProducts(); // 模拟加载 // 数据校验 for (Product p : rawList) { ProductValidator.validate(p); } SetProduct uniqueProducts new HashSet(rawList); MapString, Integer stockByCategory new HashMap(); for (Product p : uniqueProducts) { stockByCategory.merge(p.getCategory(), p.getStock(), Integer::sum); } uniqueProducts.removeIf(p - p.getStock() 50); exportReport(stockByCategory, stock_report.csv); } catch (BusinessException e) { logger.log(Level.SEVERE, 业务处理失败, e); } catch (Exception e) { logger.log(Level.SEVERE, 系统异常, e); } } }这样设计后任何异常都有一条清晰的传递路径底层方法抛出自定义异常或系统异常顶层 main 方法统一捕获并记录日志。BusinessException单独一个 catch方便定位业务问题其他Exception兜底保证程序不会因为单个异常直接崩溃。初学者经常会犯的一个错是每个方法都 try-catch 一遍结果到处是空的 catch 块日志里什么都看不到。正确的思路是底层只抛异常中间层尽量不捕获顶层统一处理。4. 常见问题与排查技巧实录4.1 集合框架高频报错对照表我把实际操作中最常见的几个异常整理成一张速查表每一行都是一个实战踩坑点异常类型触发场景排查思路NullPointerException集合中存在 null 元素调用其方法加非空校验或使用 Objects.requireNonNullConcurrentModificationException遍历时直接修改集合结构改用 Iterator.remove 或 removeIfIndexOutOfBoundsExceptionList 访问越界索引检查索引边界优先用迭代器或增强 forClassCastException从集合取出对象强转为错误类型使用泛型限制元素类型不要裸用集合IllegalStateExceptionIterator 未调用 next 就调用 remove调用的顺序必须 next 后才能 removeUnsupportedOperationException对 Arrays.asList 返回的固定长度列表调用 add/removeasList 返回的是定长列表要增删就 new ArrayList 包一层这里特别提一下Arrays.asList的坑它返回的确实是List但不是ArrayList而是Arrays内部的一个私有类底层是固定长度的数组调用add或remove会直接抛UnsupportedOperationException。我见过不少生产事故就是从这里来的解决方案很简单new ArrayList(Arrays.asList(...))包装一下再操作。4.2 三个容易被忽略的原理性盲区第一个盲区是异常被吞掉却没有任何日志。很多人写try { ... } catch (Exception e) { }异常信息一个字符都没留下。我调试代码时最恨这种空 catch。如果确实觉得某个异常不用处理那至少写一行注释说明原因并且用logger.log(Level.FINE, 忽略该异常..., e)记录到日志的 debug 级别保留排查线索。第二个盲区是返回值被 finally 覆盖。我在 3.5 里说过不要在 finally 里 return这里再补充一个变体如果在 finally 里修改了 try 块返回的对象的某个属性同样会影响返回结果。因为 finally 的执行顺序在 return 表达式求值之后、方法真正返回之前对象引用没变但对象内部状态可能已经变了。要避免这类问题最好让 finally 只做资源清理不要做任何业务操作。第三个盲区是空集合和 null 的混淆。业务代码里经常要做集合是否为空的判断很多人直接用list ! null但list可能不为 null 却是空集合遍历没问题取数就报错。反过来有人认为空集合和 null 是一回事拿去给hashCode之类的场景用就出问题。规范的做法是方法返回集合时如果没有数据就返回空集合而不是 null。JDK 里有Collections.emptyList()可以直接用接收方也只需要判断isEmpty()不用再判 null。4.3 面试高频题速查集合和异常一起考面试官特别喜欢把两个主题串在一起考因为能考察出你是真懂还是在背 API。下面这几道题几乎每场面试都会出现我给出回答要点HashMap 的 put 流程是怎样的先计算 key 的哈希值通过(n-1) hash定位桶位置如果桶为空直接放入新节点如果不为空判断第一个节点的 key 是否相等相等就覆盖如果不相等判断该节点是红黑树节点还是链表节点链表则遍历查找找到就覆盖找不到就尾插新节点节点数超过阈值且链表长度超过 8 时树化容量小于 64 时先扩容而不是树化。ArrayList 和 LinkedList 的区别底层数据结构不同前者是动态数组后者是双向链表查找复杂度前者 O(1)后者 O(n)插入删除前者平均 O(n)后者头尾 O(1) 但中间 O(n)内存占用上前者连续内存、后者每个节点有额外指针开销。实际项目 90% 的场景选 ArrayListLinkedList 的优势往往被随机访问需求抵消。受检异常和运行时异常的区别编译期是否强制处理、继承体系不同前者继承 Exception 但非 RuntimeException 子类后者继承 RuntimeException。设计上调用方必须处理的用受检编程错误用运行时。自定义业务异常建议继承 RuntimeException。异常处理最佳实践有哪些不要捕获Error不要空 catch不要在 finally 里 returntry-with-resources 管理资源捕获具体异常而不是笼统的 Exception记录异常堆栈而不是只记录 message。如果你想系统准备 Java 面试我建议把这些题目整理成自己的一句话回答每个知识点能用自己的话讲清楚比背十篇八股文强得多。面试官追问时你要能给出例子和相应的权衡而不是掉书袋。4.4 学习路线建议从会用到弄懂底层最后聊一下自学路线。如果你真的是零基础我不建议一上来就按本文的深度去啃可以先按照下面的顺序走一轮第一阶段约 1-2 周掌握 Collection 和 Map 的接口体系会用ArrayList、HashSet、HashMap完成增删改查遍历掌握 try-catch-finally 的基本语法能读懂异常堆栈。这个阶段的目标是会写会用。第二阶段约 2-4 周研究HashMap的哈希冲突、扩容、树化机制理解ConcurrentModificationException的触发条件学会自定义异常理解受检和非受检的适用场景开始阅读 JDK 里ArrayList、HashMap的源码不用逐行读重点看字段、构造方法、add/get/remove 的核心实现。第三阶段面试冲刺整理高频考点能讲清楚每个实现类的适用场景、时间复杂度和底层原理准备一些自己在项目中遇到的异常案例能说明你怎么排查和解决把本文 4.3 的题目逐个写一遍形成自己的表达框架。如果你已经工作了我建议你把文中的所有代码都亲手敲一遍然后改造到自己的项目里。比如把库存工具换成用户权限管理订单聚合统计过程会逼你思考选型逻辑比看十篇文章都有效。这套方法我验证过很多次带过的新人里凡是认真敲过代码的三个月内都能独立处理常见的集合与异常问题。结尾我从第一次写 Java 代码到现在最深刻的体会是异常处理和集合框架这两个主题越早建立设计思维越好。所谓设计思维就是每次写代码前先问自己——这里该用什么集合为什么这里可能抛什么异常谁来处理这两个问题想清楚了代码的健壮性、可读性和可维护性会一下子提升一个档次。不要等到被线上故障逼着去补课那代价就大了。最后分享一个小技巧每次写完一段涉及集合和异常的代码故意往里面塞一个 null、一个重复元素、一个越界索引看你的代码能不能优雅地兜住。能兜住才算真正学会了。
返回列表