很多人一听到“大厂面试”,脑子里立马浮现出两道经典画面:要么是公司门口排长队,要么是候选人在白板前满头大汗地写代码。说实话,我也经历过那种盯着题目大脑一片空白的焦虑感。但当我真正把这些年的经验梳理清楚后,发现算法面试其实是有迹可循的。今天我不讲那些虚头巴脑的理论,就想以一个过来人的身份,跟你聊聊怎么把LeetCode的高频题和大厂真题真正吃透,尤其是用Java这门语言,怎么一步步建立起自己的算法护城河。
为什么Java是面试的“万金油”?
在开始讲路径之前,我想先说说为什么推荐你用Java来应对算法面试。首先,Java的语法相对严谨,类型系统帮你挡掉了很多低级错误;其次,Java的标准库(JDK)功能极其强大,尤其是java.util包里的集合框架,在处理链表、树、图等数据结构时非常顺手。更重要的是,大厂的面试官很多也是Java背景,你在代码中展现出的Java惯用法(Idioms),比如合理使用Stream API、HashMap的底层特性,或者对Comparable和Comparator的灵活切换,往往能带来意想不到的加分项。
当然,语言只是工具,核心逻辑是相通的。但既然选择了Java,我们就得把它的优势发挥到极致。比如,在处理字符串问题时,C++选手可能直接操作指针,而Java选手应该熟练掌握StringBuilder,避免在循环中频繁拼接字符串导致性能爆炸。这种细节,恰恰是体现你“实战经验”的地方。
第一阶段:夯实基础,别急着刷难题
很多初学者一上来就挑战“Hard”级别的题目,结果挫败感爆棚,最后放弃。我的建议是:先花两周时间,把Java基础数据结构和算法骨架搭起来。
1. Java集合框架的“底层逻辑”
面试官特别喜欢问:“HashMap底层是什么?”、“ArrayList和LinkedList有什么区别?”这些问题看似简单,实则考察你对Java源码的理解。
- HashMap:JDK 1.8之后,HashMap由数组+链表+红黑树组成。当链表长度超过8且数组长度超过64时,链表会转化为红黑树。在面试中,如果你能说出
put和get操作的平均时间复杂度是O(1),最坏是O(log n)(转化为树后),并解释为什么需要红黑树(避免链表过长导致性能退化),这就已经超过了80%的候选人。 - ArrayList:底层是动态数组。扩容机制是原来的1.5倍。要清楚它适合随机访问,不适合频繁插入删除(尤其是头部)。
- LinkedList:双向链表,插入删除O(1),但随机访问O(n)。现在实际开发中用得少,但在算法题中,它常作为辅助数据结构出现。
实战建议:不要只背概念。尝试自己用Java实现一个简单的MyHashMap,或者用LinkedList实现一个LRU Cache。当你能亲手写出代码,这些概念就真正属于你了。
2. 基础算法模板
在刷具体题目之前,你需要掌握几套核心算法模板。这些模板是解决复杂问题的基石:
- 二分查找:注意边界条件,是
left <= right还是left < right?mid的计算用(left + right) / 2还是left + (right - left) / 2以防溢出? - 滑动窗口:用于解决子数组/子串问题。核心是维护一个窗口,根据条件收缩或扩张。
- 双指针:快慢指针、左右指针,常用于链表判环、数组去重等问题。
- 深度优先搜索(DFS)和广度优先搜索(BFS):图遍历的基础。DFS适合递归实现,BFS适合队列实现。
代码示例:二分查找的标准模板
public int binarySearch(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2; // 防止溢出
if (nums[mid] == target) {
return mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1; // 未找到
}
注意,这个模板是“左闭右闭”区间。如果你习惯用“左闭右开”,逻辑会有细微差别。面试前,最好两种都写一遍,确保肌肉记忆。
第二阶段:高频题型突破,建立知识网络
这个阶段,你要开始按题型刷LeetCode高频题了。不要杂乱无章地刷,要有针对性。我把高频题分为几大类,并给出每类的代表题目和解题思路。
1. 数组与字符串
这是最基础的类别,但陷阱最多。
- 代表题:LeetCode 15. 三数之和、LeetCode 42. 接雨水、LeetCode 560. 和为K的子数组
- 核心技巧:
- 前缀和:处理子数组和的问题,如560题。用
HashMap记录前缀和出现的次数,可以在O(n)时间内解决问题。 - 双指针:三数之和需要先排序,然后固定一个数,用双指针在剩余数组中查找两数之和。注意去重!Java中可以用
while (i > start && nums[i] == nums[i-1]) i++;来跳过重复元素。 - 接雨水:这是一道经典难题。可以用动态规划(预计算左右最大值)、双指针(从两端向中间收缩)或单调栈来解决。双指针法最优,空间复杂度O(1)。
- 前缀和:处理子数组和的问题,如560题。用
Java代码片段:接雨水的双指针解法
public int trap(int[] height) {
int left = 0, right = height.length - 1;
int leftMax = 0, rightMax = 0;
int water = 0;
while (left < right) {
if (height[left] < height[right]) {
if (height[left] >= leftMax) {
leftMax = height[left];
} else {
water += leftMax - height[left];
}
left++;
} else {
if (height[right] >= rightMax) {
rightMax = height[right];
} else {
water += rightMax - height[right];
}
right--;
}
}
return water;
}
2. 链表
链表操作考验的是指针(Java中是引用)的操控能力。
- 代表题:LeetCode 2. 两数相加、LeetCode 25. K个一组翻转链表、LeetCode 143. 重排链表
- 核心技巧:
- 虚拟头节点(Dummy Node):几乎在每道链表题中都会用到,可以简化边界处理。
- 快慢指针:找中点、判环、找倒数第K个节点。
- 递归与迭代:翻转链表两种方法都要会。递归代码简洁,但可能栈溢出;迭代更稳健。
Java代码片段:两数相加(LeetCode 2)
public ListNode addTwoNumbers(ListNode l1, ListNode l2) {
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
int carry = 0;
while (l1 != null || l2 != null || carry != 0) {
int sum = carry;
if (l1 != null) {
sum += l1.val;
l1 = l1.next;
}
if (l2 != null) {
sum += l2.val;
l2 = l2.next;
}
carry = sum / 10;
curr.next = new ListNode(sum % 10);
curr = curr.next;
}
return dummy.next;
}
注意处理carry最后不为0的情况,这是容易遗漏的边界条件。
3. 树与二叉搜索树(BST)
树是面试中的重头戏,尤其是递归思维。
- 代表题:LeetCode 105. 从前序与中序遍历构造二叉树、LeetCode 236. 二叉树的最近公共祖先、LeetCode 98. 验证二叉搜索树
- 核心技巧:
- 递归三要素:明确递归函数的定义、找出终止条件、设计单层逻辑。
- BST性质:中序遍历是递增序列。利用这个性质可以高效解决问题。
- 分治思想:构造树的问题通常用分治。
Java代码片段:验证BST(LeetCode 98)
public boolean isValidBST(TreeNode root) {
return isValidBST(root, null, null);
}
private boolean isValidBST(TreeNode node, Integer min, Integer max) {
if (node == null) return true;
if (min != null && node.val <= min) return false;
if (max != null && node.val >= max) return false;
return isValidBST(node.left, min, node.val) &&
isValidBST(node.right, node.val, max);
}
这里用Integer而不是int,是为了能表达null,作为初始的无界限状态。这是一个体现Java类型选择严谨性的小细节。
4. 动态规划(DP)
DP是面试中最难的部分,也是最能拉开差距的地方。
- 代表题:LeetCode 5. 最长回文子串、LeetCode 72. 编辑距离、LeetCode 152. 乘积最大子数组
- 核心技巧:
- 状态定义:明确
dp[i]或dp[i][j]代表什么。 - 状态转移方程:这是核心。通常可以从“最后一步”思考。
- 初始化:边界条件。
- 空间优化:很多DP问题可以用滚动数组优化空间。
- 状态定义:明确
Java代码片段:最长回文子串(LeetCode 5,中心扩展法)
public String longestPalindrome(String s) {
if (s == null || s.length() < 1) return "";
int start = 0, end = 0;
for (int i = 0; i < s.length(); i++) {
int len1 = expandAroundCenter(s, i, i); // 奇数长度
int len2 = expandAroundCenter(s, i, i + 1); // 偶数长度
int len = Math.max(len1, len2);
if (len > end - start) {
start = i - (len - 1) / 2;
end = i + len / 2;
}
}
return s.substring(start, end + 1);
}
private int expandAroundCenter(String s, int left, int right) {
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
left--;
right++;
}
return right - left - 1;
}
中心扩展法比DP更直观,时间复杂度O(n²),空间复杂度O(1),在面试中更容易被接受和解释。
5. 图论
图的问题通常用DFS、BFS或并查集解决。
- 代表题:LeetCode 200. 岛屿数量、LeetCode 133. 克隆图、LeetCode 399. 除法求值
- 核心技巧:
- visited数组/集合:防止重复访问和死循环。
- 拓扑排序:处理依赖关系,如课程表问题。
- 并查集:处理连通性问题,如朋友圈、岛屿数量。
Java代码片段:岛屿数量(LeetCode 200)
public int numIslands(char[][] grid) {
if (grid == null || grid.length == 0 || grid[0].length == 0) return 0;
int count = 0;
int m = grid.length, n = grid[0].length;
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == '1') {
count++;
dfs(grid, i, j, m, n);
}
}
}
return count;
}
private void dfs(char[][] grid, int i, int j, int m, int n) {
if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] == '0') return;
grid[i][j] = '0'; // 标记为已访问
dfs(grid, i + 1, j, m, n);
dfs(grid, i - 1, j, m, n);
dfs(grid, i, j + 1, m, n);
dfs(grid, i, j - 1, m, n);
}
这里用grid[i][j] = '0'来代替额外的visited数组,节省了空间,是一种巧妙的技巧。面试时可以主动提到这一点,展示你的优化意识。
第三阶段:大厂真题实战,模拟真实场景
刷完高频题,不代表你能通过大厂面试。大厂真题往往有更复杂的业务背景,或者需要多轮沟通。这个阶段,你要做的是:
1. 理解业务背景
很多大厂题目会包装成业务场景。比如,淘宝的“商品推荐排序”可能转化为“最大K对和”问题;抖音的“视频时长统计”可能转化为“区间合并”问题。你需要训练自己从文字描述中提取核心算法模型的能力。
例子:假设题目是“设计一个支持插入、删除、获取随机元素的时间复杂度均为O(1)的数据结构”。这其实是LeetCode 380. O(1) 时间插入、删除和获取随机元素。解题关键是数组+哈希表的组合:数组保证随机访问的O(1),哈希表保证查找和删除的O(1)。
class RandomizedSet {
private Map<Integer, Integer> map;
private List<Integer> list;
private Random rand;
public RandomizedSet() {
map = new HashMap<>();
list = new ArrayList<>();
rand = new Random();
}
public boolean insert(int val) {
if (map.containsKey(val)) return false;
map.put(val, list.size());
list.add(val);
return true;
}
public boolean remove(int val) {
if (!map.containsKey(val)) return false;
int index = map.get(val);
int lastElement = list.get(list.size() - 1);
list.set(index, lastElement); // 用最后一个元素填补
map.put(lastElement, index);
list.remove(list.size() - 1);
map.remove(val);
return true;
}
public int getRandom() {
return list.get(rand.nextInt(list.size()));
}
}
注意remove操作的技巧:不是直接删除中间元素(那样会导致后续元素前移,数组操作变为O(n)),而是用最后一个元素覆盖它,然后删除最后一个元素。这是这个题的核心考点。
2. 沟通与伪代码
在面试中,不要一上来就写代码。先和面试官确认题目理解,画出例子,讨论可能的解法,然后写伪代码。这展示了你的结构化思维和沟通能力。大厂面试官更看重你“怎么想”,而不仅仅是“怎么写”。
面试话术示例:
“您好,我看到这道题了。我的初步理解是,我们需要在一个有环的链表中找到环的入口。我想到两种解法:一种是使用哈希集合记录访问过的节点,空间复杂度O(n);另一种是使用快慢指针,空间复杂度O(1)。后者更优,您觉得呢?”
3. 边界条件与测试用例
写完代码后,一定要主动测试边界条件。比如空输入、单个元素、重复元素、溢出情况等。这能体现你的严谨性。
Java代码规范建议:
- 变量命名清晰:用
left、right、slow、fast等,避免i、j满天飞。 - 添加关键注释:解释复杂逻辑,如
// 用最后一个元素填补删除位置。 - 方法拆分:把复杂逻辑拆成多个小方法,提高可读性。
第四阶段:查漏补缺,针对性强化
在模拟面试或刷题过程中,你可能会发现自己某些类型题目总是出错。这时,不要灰心,这是提升的契机。
常见弱点及对策
- DP总是想不到状态转移:多做背包问题(0/1背包、完全背包)、子序列问题(最长公共子序列、最长递增子序列)。理解“状态”和“选择”的概念。
- 树的递归容易乱:画出递归树,明确每一层的输入输出。多做二叉树的遍历、构造、路径和问题。
- 图论的BFS/DFS混淆:记住,BFS用队列,适合找最短路径;DFS用栈(或递归),适合遍历所有路径。
利用在线资源
- LeetCode:坚持刷,按标签刷,按频率刷。
- GitHub:搜索“Java算法面试”相关仓库,看看别人的解题思路和代码风格。
- 博客与论坛:阅读技术博客,了解最新面试题趋势。
结语:保持节奏,相信积累
算法面试不是短期突击能成功的,它需要持续的练习和思考。我的建议是:每天刷2-3道题,保持手感;每周做一次总结,回顾错题;每月模拟一次全真面试,锻炼抗压能力。
记住,面试官不仅仅是在考察你的代码能力,更是在考察你的逻辑思维、沟通能力和解决问题的态度。所以,保持自信,从容应对,把每一次面试都当作一次学习的机会。
最后,送你一句话:**“算法是内功
