先把话说在前面:如果你刷力扣还在“在线编辑器里手写代码、提交、看报错、再改”的死循环里打转,那我强烈建议你停下来,花一个晚上搭一套自己的力扣Hot100 Java本地模板。这套东西做完之后,刷题的速度、对代码的掌控感,完全不是一个级别。
我用这个模板带过好几轮新人,也帮不少同事做过本地环境抢救,这里把整个搭建思路、模板代码、刷题顺序、常见坑一次性讲透。整套方案以免费工具为主,JDK、IDEA Community、Maven、JUnit 5都够用,不花一分钱。
1. 为什么要做一套本地模板
1.1 在线OJ的痛点
在线刷题平台的体验,说实话一直没什么质的提升。最烦人的就是边界用例。力扣上很多题目,你提交之后报错,它只给你看失败的输入和输出,但看不到完整的用例数据,尤其像“字符串拼接超时”“整数溢出”这类问题,在线编辑器里很难复现。还有一点,平台自带的编辑器功能太弱了,没有断点调试、没有代码折叠、没有多文件组织,遇到一道需要自定义数据结构的题(比如LRU缓存、并查集),写起来相当憋屈。
更关键的问题在于:很多人刷题是在“背题”,不是在“练题”。在线编辑器打开一道题,看完题解,照着敲一遍,提交通过,下一题。整个过程没有沉淀,没有自己的测试用例,没有对代码逐步演进的记录。三个月后回头看,啥也没留下。
1.2 本地化的回报
本地模板解决的就是上面这些问题。断点调试可以直接看到每一步变量变化,自定义边界用例可以随便造,更重要的是可以建立一个属于自己的题解工程,把Hot100里的每道题按数据结构、算法类型归类,每个类里放一个独立的解法类,配套测试用例,随时可以回归。
本地环境还有一个隐形好处:刷题的同时顺便练了Java工程化。Maven怎么配、JUnit怎么用、依赖怎么管理、包怎么划分,这些东西面试时也会问到。我把这套模板搭好之后,很多同事的反馈是“好像突然会写工程代码了”,其实就是因为天天在这个结构里写算法,自然就熟了。
本地模板还能并行复习Java基础。Hot100里的题目涉及HashMap、PriorityQueue、Deque、List、数组拷贝、字符串处理、泛型、Lambda,这些都是Java面试最喜欢问的点。用本地模板刷题时,顺手写几个Demo类验证底层实现逻辑,比单独背八股文有效得多。
2. 环境准备与工程结构
2.1 JDK、IDEA与Maven的基础安装
JDK版本我建议直接用JDK 17 LTS,不要再用JDK 8了。原因很简单:Hot100题目本身对Java版本要求不高,但JDK 17有更好的语言特性(record、switch表达式、文本块),也接近当前主流公司的生产环境。用太久远的JDK 8,本地跑得欢,面试时问“你用的什么版本”反而尴尬。
安装时记住两个核心点。第一,JAVA_HOME环境变量必须指向JDK安装目录,不是bin目录,不是JRE目录。第二,PATH里追加%JAVA_HOME%\bin(Windows)或$JAVA_HOME/bin(macOS/Linux)。装完后命令行执行java -version确认,看到类似openjdk version "17.0.x"就算成功。
IDEA直接用Community版就足够了,不用折腾破解和激活,Hot100刷题不需要企业版功能。Maven 3.8以上即可,配置阿里云镜像这个步骤不要省,不然第一次拉依赖能让你怀疑人生。至于IDEA里怎么关联JDK和Maven,网上的教程很多,这里不展开,只提醒一句:IDEA的Settings里要确认Project SDK选的是JDK 17,Maven的Runner里JRE也要选对,否则编译时经常报“invalid source release”。
2.2 本地模板的目录划分与pom配置
工程名我建议叫algorithm-hot100,包名用com.leetcode之类都可以。目录结构直接按刷题维度来分,不要按力扣题号分。按题号分的缺点是不同类型的题混在一起,复习时没有主线。按算法类型分,训练时目标感更强,也方便日后快速定位。
algorithm-hot100 |- pom.xml |- src/main/java/com/leetcode |- template (模板类,新题直接复制) |- datastructure (自定数据结构:ListNode、TreeNode等) |- arrayhash (数组与哈希表) |- twopointer (双指针与滑动窗口) |- dp (动态规划) |- backtrack (回溯) |- binarytree (二叉树) |- graph (图论与并查集) |- stackqueue (栈、队列、堆) |- sort (排序算法专项) |- concurrent (并发编程Demo,面试准备用) |- javabase (Java基础验证Demo,面试八股配套) |- src/test/java/com/leetcode |- arrayhash (对应主代码的测试类) |- template |- ...pom.xml没什么花哨的,核心就是引入JUnit 5和Lombok(可选)。Lombok能省掉手写getter/setter的功夫,但有的人用不惯,也可以不用。我建议加上,因为面试手写代码时往往需要快速构造测试数据,@Data和@Builder挺好用的。
<dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>5.10.2</version> <scope>test</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <version>1.18.32</version> <scope>provided</scope> </dependency> </dependencies>如果你没有安装Lombok插件,IDEA里会看到注解爆红。这个是小问题,装上插件、开启Annotation Processing就好,后面常见问题里会细说。
3. 模板代码设计
3.1 主类模板
每道题我建议写成一个独立的类,不要堆在一个类里写几十个方法。类名直接用题目关键词的英文缩写,比如两数之和就是TwoSum。每个类里包含三部分:空实现的方法骨架、几个重要测试用例(写在main方法里或单独测试类里)、以及解题思路的注释。注释写清楚为什么这么做,而不是写“定义了一个变量”。
我是这样设计的:
package com.leetcode.arrayhash; import java.util.HashMap; import java.util.Map; /** * 1. 两数之和 * * 思路:遍历数组,用 Map 记录“当前值需要的另一半 -> 当前值的下标”。 * 每次遍历先查 Map,命中直接返回,没命中就把当前值放进 Map。 * 时间复杂度 O(n),空间复杂度 O(n)。 * * 进阶思考:如果数组有序,能不能用双指针做?如果要求原地,怎么办? */ public class TwoSum { public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int need = target - nums[i]; if (map.containsKey(need)) { return new int[]{map.get(need), i}; } map.put(nums[i], i); } return new int[0]; } public static void main(String[] args) { TwoSum solution = new TwoSum(); // 基础用例 int[] nums1 = {2, 7, 11, 15}; int[] result1 = solution.twoSum(nums1, 9); System.out.println(print(result1)); // 期望输出 [0, 1] // 重复元素用例 int[] nums2 = {3, 3}; int[] result2 = solution.twoSum(nums2, 6); System.out.println(print(result2)); // 期望输出 [0, 1] // 负数与零 int[] nums3 = {-3, 0, 4, 3}; int[] result3 = solution.twoSum(nums3, 0); System.out.println(print(result3)); // 期望输出 [0, 3] } private static String print(int[] arr) { return Arrays.toString(arr); } }main方法里直接写测试用例,对刷题来说是最快的。不要只用一个用例,至少覆盖基础场景、重复元素、负数零、大数溢出(如果涉及)。本地调试时用IDEA的Debug模式跑main方法,想看哪一步看哪一步。
3.2 高频数据结构模板
Hot100里很多题需要自建数据结构,最典型的就是链表和二叉树。每次手写ListNode和TreeNode很烦,而且不同题里定义还不一样,容易出错。我在datastructure包里统一放了一份定义。
package com.leetcode.datastructure; public class ListNode { public int val; public ListNode next; public ListNode() {} public ListNode(int val) { this.val = val; } public ListNode(int val, ListNode next) { this.val = val; this.next = next; } // 根据数组快速构造链表,方便本地测试 public static ListNode of(int... vals) { ListNode dummy = new ListNode(); ListNode cur = dummy; for (int val : vals) { cur.next = new ListNode(val); cur = cur.next; } return dummy.next; } @Override public String toString() { StringBuilder sb = new StringBuilder(); ListNode cur = this; while (cur != null) { sb.append(cur.val); if (cur.next != null) { sb.append(" -> "); } cur = cur.next; } return sb.toString(); } }TreeNode同理,加一个根据层级数组构造树的方法(注意处理null占位),以及一个中序遍历/层序输出方法。这样在做翻转二叉树、验证二叉搜索树、二叉树最大路径这些题时,验证数据特别方便。这里不贴完整代码了,核心就是public static TreeNode of(Integer... vals),用队列按层构造,遇到null就跳过左右子树指针。
3.3 算法套路的本地模板示例
Hot100里有些算法的套路非常固定,我建议把模板先写好在template包里,刷题时直接复制改参数。比如单调栈模板(每日温度、下一个更大元素)、回溯模板(全排列、子集、组合总和)、动态规划模板(最长递增子序列、编辑距离)、并查集模板(岛屿数量、除法求值)。
以并查集为例,Hot100里至少有4-5道题可以直接套用:
package com.leetcode.template; public class UnionFind { private final int[] parent; private final int[] rank; public UnionFind(int n) { parent = new int[n]; rank = new int[n]; for (int i = 0; i < n; i++) { parent[i] = i; } } public int find(int x) { // 路径压缩:把节点直接挂到根上 if (parent[x] != x) { parent[x] = find(parent[x]); } return parent[x]; } public boolean union(int x, int y) { int rootX = find(x); int rootY = find(y); if (rootX == rootY) { return false; } // 按秩合并:矮树挂到高树上 if (rank[rootX] < rank[rootY]) { parent[rootX] = rootY; } else if (rank[rootX] > rank[rootY]) { parent[rootY] = rootX; } else { parent[rootY] = rootX; rank[rootX]++; } return true; } public boolean connected(int x, int y) { return find(x) == find(y); } }写模板的时候要把注释留好,解释路径压缩和按秩合并的作用。面试时如果让手写并查集,能直接想起这套模板会顺畅很多。
回溯模板也值得单独写,Hot100里全排列、子集、组合总和、括号生成都可以共用一套模板:
public class BacktrackTemplate { private final List<List<Integer>> result = new ArrayList<>(); public List<List<Integer>> permute(int[] nums) { dfs(nums, new boolean[nums.length], new ArrayList<>()); return result; } private void dfs(int[] nums, boolean[] used, List<Integer> path) { if (path.size() == nums.length) { result.add(new ArrayList<>(path)); return; } for (int i = 0; i < nums.length; i++) { if (used[i]) { continue; } used[i] = true; path.add(nums[i]); dfs(nums, used, path); path.remove(path.size() - 1); used[i] = false; } } }关键点就是“选择-递归-撤销”三步。很多新手写回溯时忘记撤销,或者直接把path对象加进result导致最终结果全是空列表,这些都是本地调试一眼就能发现的问题。
4. Hot100分类刷题策略
4.1 按数据结构和算法类型分组
Hot100题单网上一搜一大把,但打开题单直接顺着刷的人,大部分撑不过30题。更合理的做法是先按类型分组,再按组攻坚。我自己整理过一个分组表,这里按热点程度排个序参考:
| 题型分组 | 代表题目 | 本地模板重点 |
|---|---|---|
| 数组与哈希 | 两数之和、字母异位词分组、最长连续序列 | HashMap的get/put时机、排序技巧 |
| 双指针与滑动窗口 | 三数之和、盛最多水的容器、无重复字符的最长子串 | 窗口扩展与收缩的边界条件 |
| 链表 | 反转链表、合并两个有序链表、环形链表、LRU缓存 | dummy节点技巧、快慢指针、双向链表 |
| 二叉树 | 二叉树中序遍历、最大深度、翻转二叉树、最近公共祖先 | 递归终止条件、DFS/BFS选择 |
| 回溯与递归 | 全排列、子集、组合总和、括号生成 | 撤销选择的时机、剪枝条件 |
| 动态规划 | 爬楼梯、打家劫舍、最长递增子序列、编辑距离 | dp数组含义、状态转移方程、初始值 |
| 栈与堆 | 有效括号、每日温度、数组中的第K个最大元素 | 单调栈维护、PriorityQueue大小 |
| 图与并查集 | 岛屿数量、腐烂的橘子、课程表、除法求值 | 坐标方向数组、拓扑排序、find/union |
| 贪心与区间 | 合并区间、跳跃游戏、加油站 | 排序后按边界维护 |
“接雨水”这个系列可以单独拉出来说一下,因为热搜词里也出现了“三维接雨水”。力扣的接雨水家族有两道高频题:42. 接雨水(一维,双指针/单调栈)和407. 接雨水 II(三维,优先队列+BFS)。本地模板里建议把这两题放一起刷,边界的处理逻辑完全是两个难度级别,对理解“水能从哪个方向流出去”这个问题非常有帮助。刷42时多花点时间弄懂单调栈的入栈出栈时机,到407就会轻松很多。
4.2 推荐的刷题顺序与时间安排
第一轮我建议按标签刷,每天只做同一类题。比如这周只做二叉树,下周只做动态规划。这样做的目的是建立“肌肉记忆”,看到一个题就能联想到对应的套路。第二轮打乱顺序随机刷,模拟真实面试的状态。第三轮只刷错题和让你卡壳超过30分钟的题。
每道题的时间分配也很重要。我给自己定的规矩是:10分钟独立思考,没有思路就看题解;题解读懂后合上,自己从头写;写完用本地模板跑3-5个测试用例。这个方法看起来慢,但效果非常好。有些题今天能做出来,一周后可能完全想不起来,这时候本地模板里的注释和测试用例就能帮你快速回忆。
时间安排上,每天2题比周末突击14题强。刷题这事讲究连续性和手感,断一周基本就废了。我的习惯是工作日每天2题,周末复盘本周所有题的代码,重新跑一遍测试,看有没有可以优化时间的。
5. 把本地模板延伸到Java面试八股
5.1 并发编程与线程池
刷题刷到一定阶段后,本地模板工程里的代码结构其实可以直接拿来当Java面试准备基地。我在concurrent包里放了一些高频面试点的可运行Demo,比如线程池参数验证、线程等待机制的对比、死锁复现等。
线程池是Java面试的重灾区。面试官经常问ThreadPoolExecutor核心参数,光背参数没用,最好现场演示一遍。我在本地写过一个Demo,用饱和策略为AbortPolicy的线程池提交超出最大线程数的任务,然后观察异常输出,这样可以很直观地理解corePoolSize、maxPoolSize、workQueue三者之间的联动关系。
package com.leetcode.concurrent; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; public class ThreadPoolDemo { public static void main(String[] args) { ThreadPoolExecutor pool = new ThreadPoolExecutor( 2, // corePoolSize 4, // maximumPoolSize 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2), Thread::new, new ThreadPoolExecutor.AbortPolicy() ); for (int i = 1; i <= 7; i++) { int taskId = i; try { pool.execute(() -> { System.out.println("任务 " + taskId + " 被线程 " + Thread.currentThread().getName() + " 执行"); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } catch (Exception e) { System.out.println("任务 " + taskId + " 提交被拒绝: " + e.getClass().getSimpleName()); } } pool.shutdown(); } }跑一遍这个Demo,就很容易回答“线程池的任务提交顺序是什么”这个问题:先核心线程,再任务队列,再非核心线程,最后拒绝策略。线程池相关的还有个高频题是“线程等待都完成怎么做”,CountDownLatch、Future.get()、CompletableFuture.allOf()这三个方案最好都在本地写一遍。
5.2 Redis、集合源码与常用框架
Redis在Java面试里的出镜率极高,热搜词里就有一条“java使用redistemplate将redis的数减一”。这一类问题看起来简单,但真的有很多人踩坑。典型场景:
stringRedisTemplate.opsForValue().increment("counter", -1);如果你用的是RedisTemplate<String, Object>并且没有指定序列化器,那increment()大概率会报错ERR value is not an integer or out of range。原因在于value在Redis里存的是经过JDK序列化后的二进制数据,不是字符串,Redis服务端执行INCR时当然会失败。本地模板里建议放一个Redis操作验证类,把StringRedisTemplate和RedisTemplate序列化器的区别跑清楚,这块理解了,很多生产问题都能解释通。
HashMap也是必问项。Hot100里大量题用HashMap,面试官基本都会延伸到“HashMap的put流程”“为什么线程不安全”“扩容机制”。我建议在javabase包里写一个很小的测试类,用反射打印HashMap底层数组的容量变化,直观观察扩容时机。这个在面试时讲出来,绝对比干背源码有说服力。
5.3 排序算法与Lambda等基础细节
快速排序和冒泡排序是热搜词里的常客,Hot100有些题虽然不直接考排序,但Arrays.sort的底层原理、手写快排这些都是基础中的基础。我建议在sort包里把快排、归并、堆排各写一遍,附加测试和注释。快排的partition写法和双指针思想其实在Hot100里很多地方都有呼应,尤其TopK问题。
多行文本块(Java 15+的文本块语法""")也是容易被问到的Java基础点。很多人在IDEA里写字符串拼接,遇到复杂的JSON或多行SQL,转义字符写到头大。本地模板里放一个多行字符串Demo,一边刷题一边把这些语言细节过掉。
还有Lambda和Stream,Hot100里虽然没有直接考,但很多题解用Stream可以写得非常简洁,比如统计单词频率、对Map按值排序。面试时如果能顺手用Stream处理集合,观感会好很多。本地模板里可以准备几个常用的Stream操作,分组计数、过滤、排序、flatMap,全部写成可运行的Demo。
6. 本地实战常见问题与排查
6.1 环境与依赖类问题
本地模板搭好之后,最常遇到的第一类问题就是环境和依赖相关的。这里列几个我实际处理过的案例。
JDK版本不匹配。IDEA里项目SDK选的JDK 17,但Maven Runner里JRE却指向了JDK 8,编译时会出现invalid source release: 17。排查时先看IDEA的Project Structure,再看Settings的Maven配置,确保两处一致。
Maven依赖下载慢或者根本拉不下来。解决方案是在~/.m2/settings.xml里配置阿里云镜像,配置好之后,第一次reimport项目会快很多。如果你用了Lombok 1.18.32,建议JDK 17配1.18.30以上版本,否则会有兼容警告。
IDEA启动报java was started but returned exit code=-1,这个多半是IDEA自带的JVM和系统JDK冲突。处理办法是修改IDEA安装目录下idea64.exe.vmoptions里的JVM参数,把-Xmx调小一点,或者确认一下系统PATH里的JDK没有指向损坏的JRE。
还有一条冷门的:Uncaught exception java.lang.NoClassDefFoundError: java/applet/Applet。这个一般是项目依赖了非常老的工具包(比如某些旧版CGLIB),它们还引用了已经被JDK 9+移除的applet API。解决思路就是升级依赖版本,或者在pom里排除掉老的不需要的传递依赖。
6.2 代码与配置类问题
第二类常见问题出在代码本身和框架配置上。我见过的几个高频槽点:
RedisTemplate的increment报错,前面已经讲过,核心就是key对应的value在服务端的类型不是数字字符串。还有一个坑是key本身带了序列化前缀,比如\xac\xed\x00\x05t\x00\x03foo这种,肉眼看不到但实际存在。排查办法是用Redis Desktop Manager或者redis-cli看一下实际存的key。
泛型强转报ClassCastException。这个问题多发于把JSON反序列化结果直接赋给带泛型的类型,比如List<User>转成了List<Map>。本地写代码时如果遇到这种,建议直接用Java的List<User> users = objectMapper.readValue(json, new TypeReference<>() {})这种写法,不要连环强转。
JUnit测试里用@SpringBootTest却启动失败,大多数情况是自动装配的Redis、DataSource等服务没有可用实例。本地刷题项目其实不需要Spring Boot那套,直接用main方法跑JUnit5单元测试就行。如果实在想用Spring Boot做Demo,记得给测试类加@SpringBootTest(properties = "spring.autoconfigure.exclude=...")把不需要的自动配置排除掉。
6.3 常用排查命令
最后分享几个我在本地排查问题时的常用命令,都很基础但非常管用:
# 查看Java版本和编译级别 java -version javac -version # 查看JAVA_HOME指向是否正确(macOS/Linux) echo $JAVA_HOME # Windows echo %JAVA_HOME% # 查看Maven有效配置,确认镜像是否生效 mvn -version mvn help:effective-settings # 查看某个进程的JVM参数(排查OOM时用) jps -l jcmd <pid> VM.flags # 强制刷新Maven依赖并重新编译(遇到依赖缓存问题) mvn clean compile -U这些命令都不复杂,但能解决本地模板百分之八十的环境问题。真正难的问题往往不是环境,而是代码逻辑本身,这时候老老实实用Debug断点,看每一步变量的变化,比猜要快得多。
关于这套力扣Hot100 Java本地模板,我在实际使用中最大的感受是:它让刷题从“一件要完成的任务”变成了“一件可以积累的事”。当你把每道题的解法、测试用例、踩坑记录都留在本地工程里,三个月后回头看,那份代码就是自己的算法题库,比任何刷题记录App都直观。最后建议你从今天开始,哪怕先只搭一个空工程、写好pom.xml和一个模板类,后面再慢慢填充,也比一直想着“等有时间了再搞”强。