刷题最崩溃的瞬间是什么?不是思路想不出来,而是思路全对、代码却编译不过。List 转int[]写错了,Arrays.asList把整个int[]当成了一个元素,Integer用==比较在两数之和里莫名其妙返回false……这些坑我在刚开始刷题的时候几乎每场笔试都要踩一遍。Java集合框架和类型转换,看起来是八股文里的老生常谈,但在OJ环境里,它们直接决定你的代码能否通过。
这篇文章只聊刷题场景下的集合与类型转换实战:哪些容器该无脑选,数组和List互转有哪些写法,高频题里怎么用HashMap和优先队列。适合正在刷力扣、准备笔试面试的Java选手,也适合那些“API都知道、一到OJ就编译错误”的朋友。后面所有代码都用最贴近笔试的写法,不整花活,拿去就能用。
1. 为什么刷题翻车,十次有八次死在类型转换上
先接受一个事实:刷题和你平时写业务代码,完全是两种思维模式。业务代码里类型错了,IDE飘红、编译器提示、代码评审也可能拦住你;OJ环境里只有一次Submit,编译错误直接判罚时,没有解释机会。而且判题系统的方法签名是定死的,比如题目要求返回int[][],你就必须返回int[][],返回一个ArrayList<int[]>哪怕逻辑全对,结果也是零分。
1.1 OJ环境与业务代码的三个关键差异
第一,刷题基本都是“方法级”编程。你只需要实现一个方法,输入由系统解析好了传进来,输出靠返回值或标准输出。方法签名一旦写错,整个类编译不过。第二,OJ的测试往往是大规模数据,集合选错实现类会产生肉眼可见的超时差异。比如用一个LinkedList做随机访问,数据规模到10^5级别可能还勉强能撑,到10^6就等着TLE。第三,判题系统对输出类型极严格,题目要int[]你返回List ,就算内容一模一样也是编译错误,没有类型自动转换的空间。
所以刷题这件事,“会用集合”还不够,得“在限时高压下不用查资料就能写出正确类型转换”才算过关。这也是为什么很多人把集合和类型转换归到Java基础里,但面试官依然爱问、笔试依然爱考——它们直接决定了代码能不能跑。
1.2 一次典型的翻车现场复盘
我拿自己以前踩过的三个真实坑当例子,给大家看看到底错在哪。
第一个坑:int[]和Arrays.asList。很多人写List list = Arrays.asList(new int[]{1, 2, 3});,结果编译通过但运行起来完全不是想要的。因为泛型只能是引用类型,int[]作为一个整体对象被放进List里,得到的是List<int[]>,长度是1不是3。想快速转,得先把int[]变成Integer[],或者用Stream的boxed()方法。
第二个坑:toArray参数。List 转int[],很多人写list.toArray(new Integer[0]),看着没问题,但题目要int[]不是Integer[]。两个类型在OJ里完全不能互相赋值,必须再手动循环拆箱,或者用stream的mapToInt。反过来,List<int[]>转int[][]写res.toArray(new int[0][]),这里的new int[0][]不是“创建一个空二维数组塞进去”,而是给toArray一个“类型提示”,实际结果由虚拟机按list.size()生成。
第三个坑:包装类的==比较。两数之和里if (map.get(complement) != i)这种写法,在值大于127时可能因为Integer缓存问题得到错误结果。-128到127之间Integer会复用缓存对象,==比较碰巧成立,超出范围就不成立了。刷题里遇到包装类型比较,无脑用equals或直接用intValue(),别赌缓存。
这三个坑有个共同特点:它们不是算法题,是语言基础题。而语言基础恰恰是临场最容易崩的地方。
1.3 类型转换为什么在刷题时特别容易翻车
根本原因还是Java的类型体系严谨得让人头大。数组是一种特殊的引用类型,但不是集合,不能调用集合方法;List是接口,ArrayList是实现类;int[]和Integer[]不是一回事;String内部是char[]但不是char[]。这些“看似相关、实则不同”的类型在刷题时高频交错出现,稍不注意就错。
再加上泛型擦除这个特性。泛型只是在编译期做类型检查,运行时List 和List 在JVM眼里都是List,取出的元素永远被当作Object处理。于是你写完List raw = new ArrayList();,编译器会报unchecked警告,运行时还可能ClassCastException。刷题时为了省事不写泛型,无异于给自己埋雷。
知道自己会在哪摔倒,后面就好办了。接下来先把集合框架的选型一次性理清。
2. 集合框架:刷题前必须理清的继承树
很多人一提Java集合就头大,觉得体系庞大。其实刷题根本用不着背全景,只需要抓住一条主线:Collection是根接口,List、Set、Queue三类接口从它extends出来;Map是独立的另一个体系。代码里99%的操作都发生在ArrayList、HashMap、HashSet、PriorityQueue这几个实现类上。
2.1 四个核心接口,半分钟认清体系
记忆方法:Collection只管一堆元素,不管是重复还是不重复;List是有序、可重复的“队列”;Set是逻辑上无重复的“篮子”;Queue是带存取规则的“队”,常配合BFS使用。Map是键值对的“字典”,不属于Collection,因为它存的是Pair,不是一个一个孤立的元素。判断接口用什么类型,就看题目需要什么语义:要保序可重复就List,要去重就Set,要查表就Map,要先进先出就Queue。
2.2 高频实现类选型对照表
我整理了一张刷题版选型表:
| 场景 | 推荐实现类 | 时间复杂度 | 刷题理由 |
|---|---|---|---|
| 有序列表、随机访问 | ArrayList | get是O(1) | 遍历最快,几乎无脑选 |
| 频繁头尾插入,当栈/队列用 | ArrayDeque | 均摊O(1) | 比LinkedList的队列实现更省内存更快 |
| 去重、判重 | HashSet | 均摊O(1) | 布尔语义最强,“这个值见过吗” |
| 保持插入顺序的去重 | LinkedHashSet | 均摊O(1) | 需要“去重且按首次出现顺序输出”时用 |
| 键值查表、计数 | HashMap | 均摊O(1) | 刷题第一神器,没有之一 |
| 保持插入顺序的键值对 | LinkedHashMap | 均摊O(1) | 需要按插入顺序输出时用 |
| 按key自然排序的键值对 | TreeMap | O(log n) | 红黑树实现,刷题低频但面试爱问 |
| TopK、堆 | PriorityQueue | 插入/删除O(log n) | 默认小顶堆,求前K大直接开堆 |
在大多数笔试里,这套选型表能覆盖九成题目。我个人的习惯是:没有特殊需求就不用LinkedList、TreeMap、TreeSet这些“特种部队”,不是因为它们不好,而是ArrayList+HashMap+HashSet已经能从最简单的问题撑到最复杂的题,减少临场思考成本。
2.3 泛型:刷题中怎么用才能少些编译错误
泛型的核心作用是编译期类型检查。写了Map<String, List > map = new HashMap<>()之后,编译器能在你put错类型的时候立刻报错,而不是运行到一半才抛ClassCastException。刷题时这种“把错误挡在编译期”的能力特别宝贵,因为OJ只会告诉你编译错误,不会告诉你哪行错了。
两点实操建议。第一,等号右边可以只写菱形语法<>,省事且语义清楚。第二,返回集合时尽量返回接口类型,比如List 而不是ArrayList ,这样方法签名更通用,方便复用。但变量声明处如果要用到ArrayList特有方法,才写具体类。
有一个细节容易被忽略:数组可以作为泛型实参。List<int[]>完全合法,因为int[]是引用类型,在泛型里它和Integer地位一样。合并区间题目里,List<int[]> + int[][]互转是标准打法,后面会专门演示。
3. 类型转换:刷题高频基础操作全解
类型转换不是“会转就行”,而是要能选出最短、最不易错的那条路。我按刷题出现频率,把必背操作拆成四块讲。
3.1 数组与List互转的四种写法
先说明一个大前提:基本类型数组(int[])和包装类型数组(Integer[])在转换时的待遇完全不同。Arrays.asList只认引用类型数组。
写法一:String[] / Integer[]转List。List list = Arrays.asList(strArr);。返回的List由固定大小数组支撑,不能add/remove,但可以set替换元素。如果想真正可变,再包一层new ArrayList<>(Arrays.asList(strArr))。
写法二:int[]转List 。一条流式写法是Arrays.stream(nums).boxed().collect(Collectors.toList())。boxed()的作用是把IntStream里的每个int打包成Integer再收集进List,这也是我推荐的“一行流”。如果嫌stream有点绕,最朴素的for循环加list.add(nums[i])也完全没问题,笔试能用就行。
写法三:List 转int[]。for循环手动拆箱是最稳的,兼容任何Java版本。int[] res = new int[list.size()]; for (int i = 0; i < list.size(); i++) { res[i] = list.get(i); }。一行流则是list.stream().mapToInt(Integer::intValue).toArray()。这两种我都实测过,后者写起来快,但笔试紧张时方法引用容易写错,求稳直接for循环。
写法四:List 转String[]。list.toArray(new String[0])是标准写法。传入new String[0]不是要“塞进空数组”,而是告诉JVM最终要生成的数组类型。Effective Java里也推荐用0长度数组,因为简单且能避免预先分配错误大小。这个写法在Java 8和11下表现都很好。
3.2 数组/List与Set互转
去重是刷题里的高频需求。最简单的是new HashSet<>(list),一条语句完成“去重并转Set”。数组转Set时,包装类型数组可以直接HashSet<>(Arrays.asList(arr));int[]就需要Stream先boxed再collect到Set。反过来Set转List就是new ArrayList<>(set),Set转数组可以先转List再toArray,或者直接set.toArray(new Integer[0]),不过拿到的还是Integer[],要int[]还得循环。
有一道经典题是“数组中重复的数字”,用HashSet边遍历边查,第一次遇到的数加进去,第二次遇到直接返回,这就是HashSet最常见的刷题姿势。
3.3 String与char[]、int[]的转换
字符串操作在刷题里非常高频,至少要会这几组:
- s.toCharArray():String转char[],几乎每个字符串题第一步。
- String.valueOf(chars)或new String(chars):char[]转String,字母异位词分组题里当key用。
- s.split(" "):按空格分割成String[],注意split参数是正则,目的字符如果是点号要转义。
- int[] cnt = new int[26]; char c = s.charAt(i); cnt[c - 'a']++:统计字母出现次数的惯用法,基于字符编码连续。
- char到int:int n = c - '0',char '5'减'0'得到5。
- 整数字符串到数字:Integer.parseInt("123")。
这里有个小技巧:如果要把字符串“abc”变成字符集计数,不需要转成List,直接cnt[s.charAt(i) - 'a']++就行。刷题时能用数组别用集合,数组更快,代码也更短。
3.4 基本类型与包装类型的自动装箱拆箱陷阱
Java编译器会在基本类型和包装类型之间自动转换,这是语法糖。但封装在背后的开销和语义陷阱,刷题时经常爆雷。
第一个雷:性能。大量装箱拆箱在循环里会有额外对象创建开销,不过刷题的数据规模一般没到必须抠这个的程度,但如果你在10^6级别的循环里反复new Integer,GC压力还是肉眼可见的。我的习惯是循环内尽量用基本类型int、long,需要入集合时才装箱。
第二个雷:==比较。包装类型的==比较的是对象引用,而Integer从-128到127有缓存池。if (Integer.valueOf(127) == Integer.valueOf(127))为true,换成128就是false。这不是bug,是设计,但刷题时就是坑。两个Integer要比较值,统一用equals()或intValue()。
第三个雷:自动装箱发生在赋值和方法传参时。比如map.put(key, i)里的i自动装箱了,map.get(key)返回的是Integer,把它赋给int j时又自动拆箱。如果中间出现null,拆箱会抛NullPointerException。刷题时getOrDefault能避免大部分空指针,但还是要时刻记得返回值可能是null。
4. 集合实战:三道高频题带你打通类型转换
光说不练假把式。我挑了三道非常典型的力扣题,按“从简单到组合”的顺序演示集合与类型转换怎么落地。
4.1 两数之和:HashMap消除嵌套循环
题目:给定int[] nums和一个目标值target,返回两个下标,使两数之和等于target。暴力法是双重循环O(n^2),用HashMap能压到O(n):
public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int diff = target - nums[i]; if (map.containsKey(diff)) { return new int[]{map.get(diff), i}; } map.put(nums[i], i); } return new int[0]; }关键点:map的key存“数组元素值”,value存“下标”。因为题目只要求一组答案,所以遍历到某个数时,只要之前存在target - nums[i],直接返回。这里用containsKey而不是get后判null,是更清晰的做法。最后一行return new int[0]不是摆设,Java要求方法必须有返回值,返回null在OJ里可能被接受,但返回空数组更安全,调用方不会踩空指针。
4.2 字母异位词分组:List与Map的组合拳
题目:字符串数组分组,字母异位词归为同一组。经典思路是“排序后字符串作为key”:
public List<List<String>> groupAnagrams(String[] strs) { Map<String, List<String>> map = new HashMap<>(); for (String s : strs) { char[] chars = s.toCharArray(); Arrays.sort(chars); String key = String.valueOf(chars); map.getOrDefault(key, new ArrayList<>()).add(s); } return new ArrayList<>(map.values()); }等等,上面这个写法有个坑:getOrDefault返回的是List,如果key不存在,getOrDefault返回一个新建的空白List,并add了s,但这个新List没有放回map里,下一次相同key的字符串进来,map里还是没有这个key,又会新建一个List,导致分组丢失。正确写法是:
public List<List<String>> groupAnagrams(String[] strs) { Map<String, List<String>> map = new HashMap<>(); for (String s : strs) { char[] chars = s.toCharArray(); Arrays.sort(chars); String key = String.valueOf(chars); List<String> list = map.getOrDefault(key, new ArrayList<>()); list.add(s); map.put(key, list); } return new ArrayList<>(map.values()); }必须先取出List,add之后再put回去。或者用map.computeIfAbsent(key, k -> new ArrayList<>()).add(s),更简洁,但JDK 8+才行。这道题把String转char[]、排序、再转回String、用Map<String, List >聚合、最后map.values()转List<List >,类型转换和集合组合的考点全占齐了,非常值得反复做。
4.3 合并区间:List<int[]>和int[][]的往返
题目:给定若干区间,合并有重叠的。这道题的输出类型是int[][],中间过程最舒服的容器是List<int[]>:
public int[][] merge(int[][] intervals) { if (intervals.length == 0) return new int[0][0]; Arrays.sort(intervals, (a, b) -> a[0] - b[0]); List<int[]> res = new ArrayList<>(); int start = intervals[0][0]; int end = intervals[0][1]; for (int i = 1; i < intervals.length; i++) { if (intervals[i][0] <= end) { end = Math.max(end, intervals[i][1]); } else { res.add(new int[]{start, end}); start = intervals[i][0]; end = intervals[i][1]; } } res.add(new int[]{start, end}); return res.toArray(new int[0][]); }第一个看点:Arrays.sort(intervals, (a, b) -> a[0] - b[0])对二维数组按第一列升序排列。第二个看点:res.toArray(new int[0][]),这个写法在前面的toArray参数问题里出现过,List<int[]>转int[][]只能用这种“给类型提示”的方式。你直接写res.toArray()会得到Object[],根本无法返回int[][]。很多人在这个转换上卡住,其实记住“new T[0]作为参数传给toArray”这个套路就够了。
这道题还有一个常见变体:求区间交集。同样是用List<int[]>存结果,最后toArray(new int[0][])收尾,框架完全一样,改判断条件即可。
5. 刷题高频场景:排序、去重、TopK一把梭
刷题有三大“模板化场景”:排序、去重、TopK。这三个场景几乎每轮笔试都出现,我总结成一套可以直接抄的姿势。
5.1 排序的三种姿势与比较器写法
Java里排序入口就两个:Arrays.sort用于数组,Collections.sort用于List。写比较器有四个要点:
第一,数组排序可以直接Arrays.sort(arr),int[]、String[]都行,String[]默认字典序。第二,List排序用Collections.sort(list)或list.sort(null)(按自然序)都行。第三,复杂对象排序要自己写Comparator,推荐Lambda写法:(a, b) -> a[1] - b[1]表示按第二元素升序;想降序就反过来b[1] - a[1]。第四,比较int值用Integer.compare(a, b)更安全,用减法在极端值会溢出。例如(a, b) -> a[1] - b[1]当a[1]很大b[1]很小时会溢出,虽然刷题数据一般碰不到,但面试官会盯着这个点问。
优先级队列的排序规则同理:new PriorityQueue<>((a, b) -> a[1] - b[1])就是按第二元素的小顶堆。这里建议用Comparator.comparingInt(a -> a[1]),更可读也避免溢出。
5.2 去重:从HashSet到Stream distinct
去重最常用的三招。第一招是new HashSet<>(list),顺手完成去重;如果还需要保持原顺序,用LinkedHashSet。第二招是边遍历边判断:Set seen = new HashSet<>(); if (!seen.add(num)),add方法返回boolean,重复元素add会返回false,这个写法在“找第一个重复数字”的题里特别好用。第三招是Java 8的stream:list.stream().distinct().collect(Collectors.toList()),代码最短,但笔试环境里理论上可用,实际我很少用它,因为distinct背后还是LinkedHashSet,性能和可读性都不如直接用Set。
5.3 TopK问题:PriorityQueue的默认小顶堆
TopK是个高频考点。核心结论:求前K大用小顶堆,求前K小用大顶堆。PriorityQueue默认是小顶堆,堆顶是最小元素。求前K大时,遍历元素,堆没满就加;堆满了,如果新元素比堆顶大,就poll出堆顶再add新元素,这样堆里始终保留当前看到的最大K个。求前K小就用大顶堆,构造时写new PriorityQueue<>(Comparator.reverseOrder())。
前K个高频元素的经典流程是:先用HashMap统计次数,再把entry扔进按value排序的size=K的小顶堆,最后poll出来。这条路把HashMap、PriorityQueue、Comparator全串起来了,建议用手写一遍。有个细节:PriorityQueue比较的是“元素本身”,所以存Map.Entry时写成(a, b) -> Integer.compare(a.getValue(), b.getValue())。或者用Comparator.comparingInt(Map.Entry::getValue),一行搞定。
6. 常见坑位与排查技巧实录
最后一部分上干货中的干货:我在刷题和真实笔试里撞过的坑,以及排查思路,整理成速查手册。
6.1 ConcurrentModificationException:边遍历边删除
在foreach循环里调用list.remove(),运行时会抛ConcurrentModificationException。原因是foreach展开成Iterator,每次next()都会校验modCount,remove会修改modCount,两者不一致直接爆发。解决有三个方向:第一,改用Iterator,用it.remove(),这是Iterator接口自带的安全删除方法;第二,用list.removeIf(predicate),JDK 8+一条语句搞定;第三,先把要删的对象收集到一个List,遍历完了统一removeAll。刷题里最省事的是removeIf,但面试八股问到原理,你得能讲出modCount这层原因。
6.2 equals与hashCode:HashMap的key为何总出问题
HashMap查key时先算hashCode定位桶,再用equals找具体元素。如果一个自定义类只重写equals不重写hashCode,两个对象明明逻辑相等,却可能哈希到不同桶,get直接返回null。刷题里自定义对象当key的情况不多,但一旦出现,比如坐标点、状态封装,必须两个方法一起重写。偷懒技巧是Objects.hash(x, y)生成hashCode。另外,如果不想破坏可变性,尽量用不可变对象当key,比如String、Integer,否则put之后又改key内容,后面get会找不到。
6.3 Integer缓存:128这个数字有毒
前面多次提到,这里集中说一下。Integer.valueOf()会缓存-128到127的实例,自动装箱底层调用的就是valueOf。所以Integer a = 127; Integer b = 127; a == b是true;Integer a = 128; Integer b = 128; a == b是false。同时new Integer(127)特意new了两个不同对象,==也是false。缓存机制只对valueOf和自动装箱生效。刷题写==比较包装类型时,建议一律用equals,至少别在这个细节上丢分。
6.4 刷题前必会API清单
我整理了一个“考前30秒扫一眼”清单:
| 操作 | 推荐写法 | 易错点 |
|---|---|---|
| int[]转List | Arrays.stream(nums).boxed().collect(Collectors.toList()) | 不能用Arrays.asList(int[]) |
| List 转int[] | list.stream().mapToInt(Integer::intValue).toArray() | 别忘了toArray |
| List 转T[] | list.toArray(new T[0]) | 参数是类型提示不是容量 |
| List<int[]>转int[][] | res.toArray(new int[0][]) | 不能直接res.toArray() |
| String转char[] | s.toCharArray() | 结果不能直接用字符串拼接 |
| char[]转String | String.valueOf(chars) | new String(chars)也可以 |
| Map取默认值 | map.getOrDefault(key, defaultValue) | 注意defaultValue别每次新建list导致分组丢失 |
| TopK用小顶堆 | PriorityQueue pq = new PriorityQueue<>() | 默认就是小顶堆 |
| 字母计数 | cnt[s.charAt(i) - 'a']++ | 前提是连续小写字母 |
每次笔试前,把这张表过一遍,能减少不少低级错误。
实战里还有一个特别容易忽略的心态问题:遇到类型转换卡住,别慌,先回头看看方法签名,再看看题目给的输入输出类型,往往答案就在那里。代码写错不可怕,可怕的是不知道错在哪、不知道怎么通过系统性的API记忆去规避。
我个人刷下来的最大体会是,集合与类型转换就是Java刷题的“肌肉记忆”。算法思路可以慢慢想,但API调用必须变成条件反射,看到“返回int[][]”就想到List<int[]>和toArray,看到“去重”就想到HashSet,看到“找前K”就想到小顶堆。这些反射建立起来之后,笔试的时间会宽裕很多,真正留给思考的空间也更多。
最后再分享一个小技巧:刷完每道题,不管AC没AC,花十秒钟把题里用到的集合和类型转换写进自己的错题本。比如“合并区间=List<int[]> + toArray(new int[0][])”“两数之和=HashMap + containsKey”。下次遇到类似题,你根本不需要现场回忆API,抄作业就行。这个小习惯让我的笔试速度提升了一个档次,强烈建议你也试试。