说实话,很多程序员朋友跟我倒苦水,说学算法就是“看一遍懂,做一遍废”。手里攥着《剑指Offer》或者《算法4》,代码看得挺顺,一关上书,面对LeetCode的题干,脑子一片空白。尤其是Java出身的朋友,总觉得自己的语法熟悉,但算法逻辑一卡壳,连最简单的遍历都写得漏洞百出。
今天咱们不聊虚的,就聊聊怎么把Java算法这关真正啃下来。不是为了刷简历装样子,是真的想理解那些递归、动态规划到底在干什么。我会结合我带过很多徒弟的经验,给你梳理一条从入门到实战的路径,特别是那些容易踩的坑,咱们提前避一避。
别急着刷题,先搞清楚Java里的“数据结构武器库”
很多人一上来就打开LeetCode刷热题100,结果发现根本看不懂题目的数据规模要求,或者写完代码发现超时、内存溢出。为什么?因为你对Java自带的数据结构不够敏感。
算法的本质,是在合适的数据结构上执行合适的操作。Java里那些List、Map、Set、Queue可不是摆设,它们底层对应的数据结构决定了你的算法效率。
1. List接口:ArrayList vs LinkedList,选错就是坑
import java.util.*;
public class ListTrap {
public static void main(String[] args) {
// 场景1:频繁在末尾增删,中间插入极少
List<Integer> arrayList = new ArrayList<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 100000; i++) {
arrayList.add(i);
}
// ArrayList的add在末尾是O(1)均摊,因为内部数组扩容机制
System.out.println("ArrayList add 10万耗时: " + (System.currentTimeMillis() - start) + "ms");
// 场景2:频繁在头部或中间插入
List<Integer> linkedList = new LinkedList<>();
start = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
linkedList.add(0, i); // 头部插入
}
// LinkedList头部插入是O(1),但ArrayList头部插入是O(n),因为要搬移所有元素
System.out.println("LinkedList add head 1万耗时: " + (System.currentTimeMillis() - start) + "ms");
// 坑点:遍历!别用增强for循环遍历LinkedList做大量操作
// 因为LinkedList每次next()都要从头节点追踪指针,而ArrayList是数组直接寻址
for (Integer num : linkedList) {
// 这里看着简洁,但如果是超大列表,性能损耗比你想的大
// 推荐使用迭代器或者基于索引的遍历(但LinkedList不支持随机访问,所以索引遍历也是坑)
}
}
}
专家点拨:在LeetCode里,90%的情况用ArrayList就对了。只有当你明确需要频繁在头部插入元素(比如某些双端队列的实现)或者插入删除操作集中在中间且数据量极大时,才考虑LinkedList。但大多数时候,ArrayList的性能优势远超你的想象,因为它对CPU缓存友好。
2. Map接口:HashMap的默认初始化与扩容
import java.util.*;
public class HashMapTrap {
public static void main(String[] args) {
// 坑点:默认初始化容量是16,扩容因子0.75
// 如果你预期存1000个元素,直接new HashMap<>(),它会经历多次扩容
// 每次扩容都要rehash,把旧数组的所有元素重新计算hash放入新数组,开销巨大
Map<String, Integer> goodMap = new HashMap<>(1334); // 预估1000个,1000/0.75 ≈ 1334
// 这样能避免大部分扩容操作
// 另一个坑:HashMap key必须是不可变对象(如String, Integer)
// 如果用自定义对象作为key,必须正确重写hashCode()和equals()
Map<MyKey, String> map = new HashMap<>();
MyKey k1 = new MyKey(1);
map.put(k1, "value");
// 如果你修改了k1的属性,导致hashCode变了,就再也取不到这个值了!
k1.id = 2;
System.out.println(map.get(k1)); // 输出null,灾难!
}
}
class MyKey {
int id;
public MyKey(int id) { this.id = id; }
@Override
public int hashCode() { return id; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof MyKey)) return false;
return this.id == ((MyKey) o).id;
}
}
专家点拨:LeetCode里统计字符频率、判断重复元素,首选HashMap。但记得,如果数据量已知且较大,初始化时指定容量能节省大量时间。另外,永远不要把可变对象作为HashMap的key,这在算法题里虽然少见,但在实际项目结合算法时是致命错误。
3. Queue与PriorityQueue:优先队列是堆排序的利器
import java.util.*;
public class PriorityQueueExample {
public static void main(String[] args) {
// Java的PriorityQueue默认是最小堆
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(5);
minHeap.offer(1);
minHeap.offer(3);
// 取出最小值
System.out.println(minHeap.poll()); // 1
// 想要最大堆?自定义比较器
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
maxHeap.offer(5);
maxHeap.offer(1);
System.out.println(maxHeap.poll()); // 5
// 应用场景:Top K问题、中位数问题、合并K个有序链表
// 比如找数组中第K大的元素,用大小为K的最小堆,遍历数组,
// 如果当前元素大于堆顶,弹出堆顶,压入当前元素。最后堆顶就是第K大。
}
}
专家点拨:很多初学者不知道PriorityQueue的存在,遇到“前K个”、“第K大”这类题就想着排序,时间复杂度O(n log n)。用堆可以做到O(n log k),在K远小于n时效率提升显著。这是Java算法必备技能。
算法核心思想:图解+代码,双管齐下
光看文档没用,得看“为什么”。我推荐几个资源,但更重要的是,你要学会画图。算法题,尤其是递归、树、图,画出来就成功了一半。
1. 递归与回溯:别怕“栈溢出”,理解调用栈
很多Java程序员怕递归,觉得不直观。其实递归就是函数调用自己,每一层调用都会在调用栈上压一个帧。
经典案例:全排列
import java.util.*;
public class Permutation {
public static List<List<Integer>> permute(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
boolean[] used = new boolean[nums.length];
backtrack(nums, used, new ArrayList<>(), result);
return result;
}
private static void backtrack(int[] nums, boolean[] used, List<Integer> path, List<List<Integer>> result) {
// 终止条件:路径长度等于数组长度
if (path.size() == nums.length) {
result.add(new ArrayList<>(path)); // 注意:必须new一个新的!
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue; // 剪枝:已经用过的元素跳过
// 做选择
used[i] = true;
path.add(nums[i]);
// 进入下一层决策树
backtrack(nums, used, path, result);
// 撤销选择(回溯的关键!)
path.remove(path.size() - 1);
used[i] = false;
}
}
public static void main(String[] args) {
System.out.println(permute(new int[]{1, 2, 3}));
}
}
图解思路: 想象一棵树,根节点是空路径。第一层有3个分支(选1、选2、选3)。每个分支下,又有2个分支(剩下两个数)。直到叶子节点(路径长度为3),记录结果。回溯就是“走不通就退回上一级,尝试另一条路”。
坑点:result.add(new ArrayList<>(path)) 这行代码,如果你写result.add(path),最后得到的全是空列表。因为path是一个对象引用,回溯时会清空它,最后result里存的都是同一个被清空的对象。
2. 动态规划:别一上来就写状态转移方程
动态规划(DP)是Java算法里最难啃的骨头。很多教程上来就给公式,看得人云里雾里。我的建议是:先画图,找重叠子问题。
经典案例:爬楼梯
public class ClimbingStairs {
// 暴力递归会超时,因为有大量重复计算
// f(n) = f(n-1) + f(n-2)
// 方法1:记忆化搜索(自顶向下)
public int climbStairs(int n) {
if (n <= 2) return n;
int[] memo = new int[n + 1];
Arrays.fill(memo, -1);
return help(n, memo);
}
private int help(int n, int[] memo) {
if (n <= 2) return n;
if (memo[n] != -1) return memo[n]; // 查表,避免重复计算
memo[n] = help(n - 1, memo) + help(n - 2, memo);
return memo[n];
}
// 方法2:动态规划(自底向上,空间优化)
public int climbStairsOptimized(int n) {
if (n <= 2) return n;
int prev2 = 1; // f(1)
int prev1 = 2; // f(2)
int current = 0;
for (int i = 3; i <= n; i++) {
current = prev1 + prev2;
prev2 = prev1;
prev1 = current;
}
return current;
}
}
图解思路: 画一个表格,行是楼层n,列是到达该楼层的方法数。
- n=1: 1种
- n=2: 2种
- n=3: n=1的方法数 + n=2的方法数 = 1+2=3种
- n=4: n=2的方法数 + n=3的方法数 = 2+3=5种
你看,这就是斐波那契数列。关键点:DP的本质是把大问题拆成小问题,并且记住小问题的解,避免重复计算。空间优化那版,你只需要记住前两个状态,所以空间复杂度从O(n)降到了O(1)。
坑点:很多人分不清“贪心”和“DP”。爬楼梯可以贪心吗?可以,因为每次只能走1或2步,没有后效性。但如果是“不同的路径II”(有障碍物),就不能贪心,必须用DP,因为之前的选择会影响后续的选择。
3. 双指针:滑动窗口的艺术
public class SlidingWindow {
// 经典题:无重复字符的最长子串
public int lengthOfLongestSubstring(String s) {
if (s.isEmpty()) return 0;
// 用HashSet记录窗口内的字符
Set<Character> set = new HashSet<>();
int left = 0;
int right = 0;
int maxLen = 0;
while (right < s.length()) {
// 如果右指针指向的字符已经在窗口内,收缩左指针
while (set.contains(s.charAt(right))) {
set.remove(s.charAt(left));
left++;
}
// 扩展右指针
set.add(s.charAt(right));
maxLen = Math.max(maxLen, right - left + 1);
right++;
}
return maxLen;
}
}
图解思路:
想象一个滑动窗口在字符串上移动。left和right是两个指针。
- 右指针不断向右扩展,把字符加入窗口。
- 如果加入的字符导致窗口内有重复,左指针就向右收缩,直到窗口内无重复。
- 每次扩展或收缩后,更新最大长度。
坑点:滑动窗口通常用于“连续子数组/子串”问题。记住模板:外层循环右指针扩展,内层循环左指针收缩。如果题目不是“连续”的,可能要用其他方法(比如排序+双指针)。
实战资源推荐:别再只看书了
书是基础,但算法是练出来的。我推荐几个“避坑”资源组合:
1. 视频资源:图解+动画,直观理解
B站:labuladong的算法小抄
这位UP主的视频非常系统,从递归到DP,再到图论,每个专题都有配套的代码和动画演示。他的文章也整理成了书,但视频更适合入门。重点是,他会讲“套路”,比如回溯框架、DP五部曲,帮你建立解题思维。YouTube: NeetCode
如果你的英文还行,NeetCode的150题系列是圣经。他用白板画图讲解,非常清晰。而且每道题都有Java的解法。配合他的网站neetcode.io,可以边看边练。中国大学MOOC:浙江大学《数据结构》
陈越老师讲的,非常严谨。虽然偏理论,但能帮你打牢基础。特别是树的遍历、图的存储,书上的代码不一定能完全覆盖边界情况,视频里会有更细致的讲解。
2. LeetCode刷题策略:别盲目刷
按专题刷:不要随机刷。先学完一个专题(比如“二叉树”),再刷对应专题的题。LeetCode有“题库”功能,可以筛选专题。
先看题解,再自己写:遇到不会的题,先看高赞题解,理解思路后,关掉题解,自己手写一遍。这一步很重要,看懂≠会做。
复盘错题本:我建议你用Excel或者Notion建一个错题本。记录题目、错误原因、正确解法、时间复杂度。每周回顾一次,避免在同一个坑里摔两次。
模拟面试:LeetCode上有“模拟面试”功能,可以限时做题。真实面试的压力和平时练习不一样,提前适应很重要。
3. 代码练习平台:不仅仅是LeetCode
Codewars:题目更有趣,难度分级从8kyu到1kyu。适合碎片时间练习,提升代码简洁性。
HackerRank:算法模块也很全,而且很多公司的招聘笔试题从这里出。
LintCode:国内平台,题目分类更细,有很多中文题解,适合初学者。
常见坑总结:Java程序员特有的坑
最后,我给你总结几个Java程序员学算法最容易踩的坑,提前避雷:
数组越界:Java的数组索引从0开始,很多初学者会忘。比如在遍历数组时,循环条件写成
i <= nums.length,应该写成i < nums.length。Integer缓存陷阱:
Integer a = 127; Integer b = 127; a == b是true,但Integer c = 128; Integer d = 128; c == d是false。因为Java对-128到127的Integer有缓存。在算法题里,如果用Integer做键值,尽量用equals()而不是==。集合的并发修改异常:在遍历ArrayList或HashMap时,如果直接
remove元素,会抛ConcurrentModificationException。正确做法是用迭代器的remove()方法,或者先收集要删除的元素,再统一删除。递归深度过大导致栈溢出:Java的默认栈空间有限。如果递归深度太大(比如处理10万节点的树),可能会栈溢出。这时可以考虑用迭代代替递归,或者调整JVM参数
-Xss。但在LeetCode里,通常不会遇到这么深的递归,除非题目特别刁钻。哈希冲突导致的性能退化:Java 8之后,HashMap在链表长度超过8且数组长度超过64时会转为红黑树。但如果你的hash函数写得很烂,所有元素都哈希到同一个桶,那么HashMap就变成了链表,时间复杂度退化到O(n)。在算法题里,如果你自定义了HashMap的key,一定要确保
hashCode()和equals()一致。忽略边界条件:空数组、单个元素、全相同元素、最大/最小值等边界情况,一定要单独测试。很多题解在正常情况下面没问题,但一遇到边界就崩。
结语:算法是练出来的,不是看出来的
学Java算法,别指望看几本书、刷几道题就能一通百通。关键在于理解+实践+复盘。我建议你从今天开始,每天一道题,先自己做,做不出来再看题解,然后把题解的思路用自己的语言复述一遍,最后写成博客或者笔记。
记住,算法竞赛不是目的,目的是培养解决问题的思维。当你能够熟练运用递归、动态规划、贪心这些思想去拆解复杂问题时,你不仅会在面试中脱颖而出,在实际工作中遇到性能瓶颈,也能更快地找到优化方案。
希望这份指南能帮你避开那些常见的坑,祝你早日成为算法高手!如果有任何具体问题,
