Java算法从入门到实战手把手教你刷LeetCode大厂面试题必看的资源清单
嘿,朋友!如果你正在盯着屏幕上那道 LeetCode 题目发呆,或者一提到”大厂面试”就手心冒汗,这篇文章就是为你准备的。我见过太多人从”算法好难”到”刷完500题后拿下Offer”,今天把我踩过的坑、总结的经验,全都掏出来给你。
先别急着刷题,先把路看清楚
很多新人一上来就打开 LeetCode,随机挑一道题做,做不出来就跳过,然后陷入”越刷越焦虑”的恶性循环。这不是你的问题,是方法的问题。
算法学习就像建房子,你得先打地基,再砌墙,最后装修。跳步的后果就是——看似刷了很多题,遇到新题型还是懵。
我把它分成三个阶段:
- 第一阶段:工具掌握——学会用 Java 写出正确的代码,熟悉常用数据结构
- 第二阶段:模式识别——不是死记硬背,而是看懂题目背后的套路
- 第三阶段:实战提速——限时完成,像面试一样紧张感训练
下面我逐个阶段拆给你看。
第一阶段:工欲善其事,必先利其器
Java 标准库你必须滚瓜烂熟的几个类
在算法面试里,时间往往花在你”忘了一个API”上,而不是在想思路。所以我要求你把这些东西练到肌肉记忆。
集合框架的核心接口和实现
import java.util.*;
public class BasicCollectionDemo {
public static void main(String[] args) {
// ========== ArrayList:动态数组 ==========
// 底层是数组,随机访问O(1),尾部插入O(1),中间插入/删除O(n)
List<Integer> list = new ArrayList<>();
list.add(10);
list.add(20);
list.add(30);
System.out.println(list.get(1)); // 输出 20,根据索引快速查找
System.out.println(list.indexOf(30)); // 输出 2,查找元素位置
list.remove(Integer.valueOf(20)); // 注意:要传对象,不能传基本类型索引
System.out.println(list.size()); // 输出 2
// 遍历(面试常考,三种写法都要会)
for (int i = 0; i < list.size(); i++) { // 方式1:普通for
System.out.println(list.get(i));
}
for (Integer val : list) { // 方式2:增强for(简洁,但不能改大小)
System.out.println(val);
}
list.iterator().forEachRemaining(System.out::println); // 方式3:迭代器
// ========== LinkedList:双向链表 ==========
// 头部插入/删除O(1),随机访问O(n)
List<Integer> linked = new LinkedList<>();
linked.addFirst(1); // 头部插入
linked.addLast(2); // 尾部插入
System.out.println(linked.peekFirst()); // 1,不删除
System.out.println(linked.pollFirst()); // 1,删除并返回
// ========== HashSet:哈希表(无序、无重复)==========
Set<Integer> set = new HashSet<>();
set.add(1);
set.add(2);
set.add(1); // 重复,不会插入
System.out.println(set.contains(2)); // true,O(1) 查询
System.out.println(set.size()); // 2
// HashSet 判断两数组交集(经典面试题)
int[] a = {1, 2, 2, 1};
int[] b = {2, 2};
Set<Integer> setA = new HashSet<>();
for (int x : a) setA.add(x);
List<Integer> result = new ArrayList<>();
for (int x : b) {
if (setA.contains(x)) result.add(x);
}
System.out.println(result); // [2]
// ========== HashMap:键值对(最常用!)==========
Map<String, Integer> map = new HashMap<>();
map.put("apple", 5);
map.put("banana", 3);
map.put("apple", 10); // 覆盖旧值
System.out.println(map.get("apple")); // 10
System.out.println(map.containsKey("kiwi"));// false
System.out.println(map.getOrDefault("kiwi", 0)); // 0,安全获取
// 遍历HashMap(四种方式,面试常问)
for (String key : map.keySet()) { // 遍历key
System.out.println(key + " -> " + map.get(key));
}
for (Integer val : map.values()) { // 遍历value
System.out.println(val);
}
map.forEach((k, v) -> System.out.println(k + ":" + v)); // Lambda方式(推荐)
map.entrySet().forEach(entry -> System.out.println(entry.getKey() + " = " + entry.getValue())); // entrySet
}
}
这些基础操作,你要能在 5 分钟内 不打编译器写出正确代码。为什么?因为面试时你不是在写业务系统,每一秒都在消耗面试官的耐心。
字符串处理的几个关键技巧
public class StringTricks {
public static void main(String[] args) {
// 字符串反转(高频!)
String s = "Hello";
String reversed = new StringBuilder(s).reverse().toString();
System.out.println(reversed); // "olleH"
// 判读回文(经典入门题)
String word = "level";
String rev = new StringBuilder(word).reverse().toString();
System.out.println(word.equals(rev)); // true
// 字符串转整数数组(LeetCode很多题会给你字符串让你处理)
String digits = "12345";
int[] arr = new int[digits.length()];
for (int i = 0; i < digits.length(); i++) {
arr[i] = digits.charAt(i) - '0'; // 字符转数字的核心技巧
}
System.out.println(Arrays.toString(arr)); // [1, 2, 3, 4, 5]
// 字符串切割
String sentence = "apple,banana,orange";
String[] parts = sentence.split(",");
System.out.println(parts[1]); // "banana"
// StringBuilder 拼接(比 + 高效得多,大数据量时体现明显)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 100000; i++) {
sb.append(i).append(",");
}
System.out.println(sb.length()); // 结果长度
}
}
💡 一个小故事:我当年面试某大厂,面试官问”如何判断两个字符串是否是字母异位词”,我用了
sort()排序比较,面试官追问”能不能用 O(n) 时间复杂度”,我愣了一下……其实用 HashMap 计数就行。这件事教会我:排序是最后手段,先想线性方案。
第二阶段:掌握算法思维,不是刷题数量
这里我按题型模式来组织,而不是按题目编号。理解模式比记住答案重要一万倍。
模式一:双指针
双指针的本质是——把暴力 O(n²) 的嵌套循环,压缩成一次线性扫描。
入门题:两数之和 II(有序数组版)
输入:numbers = [2, 7, 11, 15], target = 9
输出:[1, 2](下标从1开始)
public class TwoSumII {
public int[] twoSum(int[] numbers, int target) {
// 左指针指向最小,右指针指向最大
int left = 0;
int right = numbers.length - 1;
while (left < right) {
int sum = numbers[left] + numbers[right];
if (sum == target) {
return new int[]{left + 1, right + 1}; // 转1-based
} else if (sum < target) {
left++; // 太小了,左边指针右移
} else {
right--; // 太大了,右边指针左移
}
}
return new int[]{-1, -1}; // 题目保证有解,这行走不到
}
}
为什么这样是对的? 因为数组有序。当 sum < target 时,左指针右边的任何一个数和 right 配对,和只会更大,所以必须 left++。同理,sum > target 时只能 right--。每个元素最多被访问一次,时间复杂度 O(n)。
进阶题:盛最多水的容器(LeetCode 11)
public class ContainerWithMostWater {
public int maxArea(int[] height) {
int left = 0;
int right = height.length - 1;
int maxWater = 0;
while (left < right) {
// 面积 = 底 × 高,底是 right-left,高是较短的那条边
int currentHeight = Math.min(height[left], height[right]);
int area = currentHeight * (right - left);
maxWater = Math.max(maxWater, area);
// 关键:移动较短的那一边,因为移动较长边不会让面积变大
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return maxWater;
}
}
这个题的精髓在于为什么移动短边——假设左边短,右边高,如果移动右边,底的长度变短了,高也受限于左边(更短),面积必然变小。所以只能移动左边,赌后面有更高的边。
模式二:滑动窗口
滑动窗口是双指针的变体,专门处理连续子数组/子串问题。
经典题:无重复字符的最长子串(LeetCode 3)
public class LongestSubstringWithoutRepeat {
public int lengthOfLongestSubstring(String s) {
// 用 HashMap 记录每个字符最后一次出现的位置
Map<Character, Integer> charIndex = new HashMap<>();
int maxLen = 0;
int left = 0; // 窗口的左边界
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
// 如果字符已经在窗口内出现过,收缩左边界
if (charIndex.containsKey(c) && charIndex.get(c) >= left) {
left = charIndex.get(c) + 1; // 跳到重复字符的下一位
}
// 更新窗口最大长度
maxLen = Math.max(maxLen, right - left + 1);
// 记录字符最新位置
charIndex.put(c, right);
}
return maxLen;
}
public static void main(String[] args) {
LongestSubstringWithoutRepeat solver = new LongestSubstringWithoutRepeat();
System.out.println(solver.lengthOfLongestSubstring("abcabcbb")); // 3 ("abc")
System.out.println(solver.lengthOfLongestSubstring("bbbbb")); // 1
System.out.println(solver.lengthOfLongestSubstring("pwwkew")); // 3 ("wke")
}
}
滑动窗口的模板(背下来,面试直接用):
// 通用模板:处理连续子数组问题
public int slidingWindow(String s) {
Map<Character, Integer> window = new HashMap<>();
int left = 0, right = 0;
int result = 0;
while (right < s.length()) {
char c = s.charAt(right);
right++;
// 1. 扩展窗口:把新字符加入窗口
window.put(c, window.getOrDefault(c, 0) + 1);
// 2. 检查窗口是否满足条件,不满足则收缩
while (/* 窗口不满足条件 */) {
char d = s.charAt(left);
left++;
// 3. 收缩窗口:把移除的字符从窗口中减去
window.put(d, window.getOrDefault(d, 0) - 1);
}
// 4. 更新结果
result = Math.max(result, right - left);
}
return result;
}
模式三:快慢指针(链表专属)
链表相关的题目,快慢指针几乎是必考的。
经典题:环形链表(LeetCode 141)
/**
* 链表节点定义(面试时通常不用你写,但要知道)
*/
class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; next = null; }
}
public class LinkedCycleDetection {
/**
* 快慢指针判断链表是否有环
* 慢指针每次走1步,快指针每次走2步
* 如果有环,快指针最终会追上慢指针
*/
public boolean hasCycle(ListNode head) {
if (head == null || head.next == null) return false;
ListNode slow = head; // 慢指针
ListNode fast = head; // 快指针
while (fast != null && fast.next != null) {
slow = slow.next; // 走1步
fast = fast.next.next; // 走2步
if (slow == fast) { // 相遇,说明有环
return true;
}
}
return false;
}
/**
* 进阶:找到环的入口(LeetCode 142)
* 核心结论:相遇点距离入口的距离 == 头节点距离入口的距离
*/
public ListNode detectCycle(ListNode head) {
if (head == null || head.next == null) return null;
ListNode slow = head;
ListNode fast = head;
// 第一步:判断是否有环
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) break; // 相遇
}
// 如果 fast 是 null,说明没有环
if (fast == null || fast.next == null) return null;
// 第二步:找入口
// 将慢指针放回起点,快指针从相遇点出发,都每次走1步
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow; // 相遇点就是环的入口
}
}
为什么找入口时两个指针速度一样? 这里有一个数学证明,我简化给你看:
假设链表头到环入口距离是 a,环入口到相遇点距离是 b,相遇点回到环入口距离是 c,环周长是 b + c。
慢指针走了 a + b 步,快指针走了 a + b + n(b+c) 步(多走了 n 圈)。快指针速度是慢指针的2倍:
2(a + b) = a + b + n(b + c)
a + b = n(b + c)
a = n(b + c) - b = (n-1)(b+c) + c
所以从头走 a 步的位置,等于从相遇点走 c 步的位置。它们会同时到达环入口。
模式四:前缀和
遇到”子数组和”问题,第一时间想前缀和。
public class PrefixSum {
/**
* 和为 K 的子数组(LeetCode 560)
* 核心思想:prefixSum[j] - prefixSum[i] = k
* 即:在前面找有多少个前缀和等于 prefixSum[j] - k
*/
public int subarraySum(int[] nums, int k) {
// key: 前缀和的值,value: 该前缀和出现的次数
Map<Integer, Integer> prefixCount = new HashMap<>();
prefixCount.put(0, 1); // 前缀和为0出现1次(空数组)
int count = 0;
int currentSum = 0;
for (int num : nums) {
currentSum += num; // 当前前缀和
// 如果存在前缀和等于 currentSum - k,说明中间这段和为k
if (prefixCount.containsKey(currentSum - k)) {
count += prefixCount.get(currentSum - k);
}
// 记录当前前缀和
prefixCount.put(currentSum, prefixCount.getOrDefault(currentSum, 0) + 1);
}
return count;
}
public static void main(String[] args) {
PrefixSum solver = new PrefixSum();
int[] nums = {1, 1, 1};
System.out.println(solver.subarraySum(nums, 2)); // 2,有两个子数组和为2:[1,1](前两个)和[1,1](后两个)
}
}
模式五:栈(单调栈)
栈在处理”下一个更大/更小元素”问题上无可替代。
经典题:每日温度(LeetCode 739)
public class DailyTemperatures {
/**
* 给定每天的温度,返回需要等多少天才能等到更高的温度
* 用单调栈(栈内存索引,温度单调递减)
*/
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] result = new int[n]; // 默认全0
// 栈存的是索引,而不是温度值
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++) {
// 当当前温度高于栈顶索引对应的温度时,弹出
while (!stack.isEmpty() && temperatures[i] > temperatures[stack.peek()]) {
int prevIndex = stack.pop();
result[prevIndex] = i - prevIndex; // 记录等待天数
}
stack.push(i);
}
return result;
}
}
第三阶段:大厂面试真题实战解析
下面我挑几道真正出现在大厂面试中的题,带你完整走一遍思路——怎么从”看不懂”到”能写出答案”。
真题一:LeetCode 152. 乘积最大子数组
输入: [2,3,-2,4]
输出: 6
解释: 子数组 [2,3] 有最大乘积 6。
第一步:先看暴力解法
// 暴力:枚举所有子数组,O(n^2)
public int maxProductBrute(int[] nums) {
int max = Integer.MIN_VALUE;
for (int i = 0; i < nums.length; i++) {
int product = 1;
for (int j = i; j < nums.length; j++) {
product *= nums[j];
max = Math.max(max, product);
}
}
return max;
}
这肯定超时。关键问题在哪里?
第二步:找规律
数组里有负数!这是陷阱。如果只有一个”最大乘积”,那么遇到负数时,最大可能变成最小,最小可能变成最大。
所以我们需要同时维护两个状态:
public int maxProduct(int[] nums) {
// maxSoFar: 以当前元素结尾的子数组最大乘积
// minSoFar: 以当前元素结尾的子数组最小乘积(可能被负数翻转为最大)
int maxSoFar = nums[0];
int minSoFar = nums[0];
int result = nums[0];
for (int i = 1; i < nums.length; i++) {
int num = nums[i];
// 如果当前数是负数,max和min要交换
// 因为 负数 × 最小值 = 最大正数
if (num < 0) {
int temp = maxSoFar;
maxSoFar = minSoFar;
minSoFar = temp;
}
// 更新最大和最小
maxSoFar = Math.max(num, maxSoFar * num);
minSoFar = Math.min(num, minSoFar * num);
// 更新全局结果
result = Math.max(result, maxSoFar);
}
return result;
}
第三步:理解状态转移方程
maxSoFar[i] = max(nums[i], maxSoFar[i-1] * nums[i])
minSoFar[i] = min(nums[i], minSoFar[i-1] * nums[i])
这个题目的核心洞察就是:负负得正。面试时你能说出这句话,面试官就知道你真正理解了。
真题二:LeetCode 239. 滑动窗口最大值
输入: nums = [1,3,-1,-3,5,3,6,7], k = 3
输出: [3,3,5,5,6,7]
解释: 滑动窗口的位置及最大值
这道题用普通滑动窗口会超时(O(nk)),需要用单调队列(双端队列)。
import java.util.Deque;
import java.util.LinkedList;
public class SlidingWindowMaximum {
public int[] maxSlidingWindow(int[] nums, int k) {
if (nums == null || nums.length < 2) return nums;
// 双端队列:存储的是索引,队首始终是当前窗口的最大值索引
Deque<Integer> deque = new LinkedList<>();
int[] result = new int[nums.length - k + 1];
for (int i = 0; i < nums.length; i++) {
// 1. 移除不在当前窗口的索引(队首)
while (!deque.isEmpty() && deque.peekFirst() < i - k + 1) {
deque.pollFirst();
}
// 2. 移除所有比当前值小的元素(它们不可能再成为最大值了)
while (!deque.isEmpty() && nums[deque.peekLast()] <= nums[i]) {
deque.pollLast();
}
// 3. 当前索引进队
deque.offerLast(i);
// 4. 当窗口形成后,记录最大值
if (i >= k - 1) {
result[i - k + 1] = nums[deque.peekFirst()];
}
}
return result;
}
}
为什么单调队列每个元素只入队出队一次? 因为每个元素最多被添加一次、移除一次,所以总复杂度是 O(n),而不是 O(nk)。这是这道题最精妙的地方。
真题三:二叉树层序遍历(LeetCode 102)
输入: [3,9,20,null,null,15,7]
3
/ \
9 20
/ \
15 7
输出: [[3],[9,20],[15,7]]
这是 BFS(广度优先搜索)的经典应用,用队列实现:
import java.util.*;
class TreeNode {
int val;
TreeNode left;
TreeNode right;
TreeNode(int x) { val = x; }
}
public class BinaryTreeLevelOrder {
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) return result;
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int levelSize = queue.size(); // 当前层的节点数
List<Integer> currentLevel = new ArrayList<>();
// 遍历当前层的所有节点
for (int i = 0; i < levelSize; i++) {
TreeNode node = queue.poll();
currentLevel.add(node.val);
if (node.left != null) queue.offer(node.left);
if (node.right != null) queue.offer(node.right);
}
result.add(currentLevel);
}
return result;
}
}
关键点:每次循环开始时记录队列大小,这决定了当前层有多少个节点。如果不记录,就会把下一层的节点也一起处理掉。
刷题资源清单(亲测有效)
光看理论不够,我按优先级给你排好了资源:
免费题库(按使用频率排序)
| 平台 | 推荐理由 | 适合阶段 |
|---|---|---|
| LeetCode | 大厂原题最多,题解质量高 | 全程 |
| 牛客网 | 国内面试题汇总,有真题讨论区 | 面国内大厂 |
| Codeforces | 算法竞赛,提升思维深度 | 进阶挑战 |
| HackerRank | 分类清晰,有专项练习 | 入门阶段 |
LeetCode 刷题路线(按阶段)
入门期(第1-30天):只做简单题,建立信心
高频简单题清单(必须全A):
两数之和 → 反转字符串 → 有效括号 → 合并两个有序数组
→ 爬楼梯 → 删除有序数组中的重复项 → 移动零
→ 二叉树的最大深度 → 反转二叉树 → 相同的树
进期(第30-90天):按模式刷,每个模式20题
推荐刷题库(LeetCode Hot 100):
双指针:盛水容器、三数之和、颜色分类
滑动窗口:无重复最长子串、最小覆盖子串
快慢指针:环形链表II、反转链表
栈:最小栈、有效括号、柱状图最大矩形
二叉树:层序遍历、最近公共祖先、验证BST
哈希表:两数之和、字母异位词、最长和谐子序列
冲刺期(第90-180天):模拟面试,限时完成
重点攻克(大厂高频):
Hard 题(每类至少5道):
- 动态规划:编辑距离、最长递增子序列、零钱兑换
- 图论:岛屿数量、课程表(拓扑排序)
- 回溯:全排列、N皇后、单词搜索
- 高级数据结构:LRU缓存、字典树
书籍推荐
- 《剑指Offer》 —— 国内面试圣经,经典题目全覆盖
- 《算法导论》 —— 理论基础,当字典查就行,不要从头读
- 《Cracking the Coding Interview》 —— 外企面试必备,有英文原版
面试当天的实战技巧
刷题归刷题,面试是另一套技能。分享几个我观察到的真实情况:
1. 拿到题先问清楚,不要急着写
面试官说”你来写个二叉树遍历”,你不要直接写代码。先问:
- 树可能为空吗?
- 是递归还是迭代?
- 需要处理异常输入吗?
这一步展示的是工程思维,很多候选人一上来就写,反而显得毛躁。
2. 边写边说思路
// 不要默默写代码,边写边解释
public TreeNode invertTree(TreeNode root) {
// 递归终止条件:空节点或者叶子节点,直接返回
if (root == null) return null;
// 交换左右子树
TreeNode temp = root.left;
root.left = root.right;
root.right = temp;
// 递归处理左右子树
invertTree(root.left);
invertTree(root.right);
return root;
}
3. 写完代码主动分析复杂度
// 分析:
// 时间复杂度:O(n),每个节点访问一次
// 空间复杂度:O(h),h是树的高度,最坏O(n),平均O(log n)
这是面试官最想听到的。很多候选人写完了就等着,其实这时候你已经领先了。
给初学者的一句真心话
我见过太多人把”刷了500题”当目标,然后焦虑自己为什么还没找到工作。事实是:面试不看你刷了多少题,看你理解了多少模式。
我认识的一个朋友,只认真搞懂了50道题的每一种变体,面试时遇到新题都能套出来,最后拿了三个大厂Offer。另一个人刷了800题,但都是看题解抄的,面试现场还是不会。
所以你的策略应该是:
第一阶段:每天1题,但必须100%独立做出来
第二阶段:按模式整理,把相似题目放在一起对比
第三阶段:限时模拟,给自己定闹钟
算法是一场马拉松,不是百米冲刺。你不需要在最短时间内刷最多题,你需要的是真正理解每一个模式。当你看到新题能下意识地说出”哦,这题可以用双指针”的时候,你就准备好了。
加油,未来的大厂人!有问题随时来找我聊聊。
