前阵子有个刚工作一年的朋友跑来问我,说自己在刷题网站上刷了三百多道题,结果一碰真实面试题还是没思路,问我是不是刷题方式出了问题。这个问题我太熟悉了,这几年带过的实习生和新同事,十个里有六七个都有类似的困惑。今天正好借这个标题,认真聊一聊 Java 进阶阶段刷题这件事。
这篇是系列的第一篇,定位是"搞清楚方向"加上"拿下核心基础"。我不会只给结论,而是把每一个关键选择的背后逻辑都掰开揉碎讲清楚:进阶刷题到底和入门有什么区别、排序这种基础题里藏着哪些进阶门道、高频题怎么从暴力解一步步演进到最优解、以及 Java 选手最容易忽略的性能细节。内容主要面向已经掌握了 Java 基本语法、准备系统性进阶的开发者,也适合正在准备面试的朋友。
1. 进阶刷题别急着开刷:先定位目标再动手
很多人的刷题困境不是不努力,而是把进阶刷题做成了入门刷题的续集。这个认知偏差,直接决定了后面几十个小时的投入产出比。
1.1 "进阶"和"入门"刷题的本质区别
入门阶段刷题,核心目标是巩固语法。你写循环、写数组、写集合,刷的是"这个东西怎么用"。这一类题的特征是:题面短、逻辑直、答案基本对应某个语法点。刷这类题,数量确实有用,因为重复度高的语法操作需要肌肉记忆。
但到了进阶阶段,题目的考察点完全变了。它不再问你某个 API 怎么调用,而是给你一个抽象问题,让你自己设计数据结构、推导算法、权衡时间空间。这时候刷题的本质是模型识别:看到题面,快速判断它属于哪一类问题——是二分搜索的变体,还是双指针的套壳,或者本质上是图的最短路径。
这就是为什么有人刷了三百道题还是没思路。因为他刷的是"题号",而不是"题型"。每道题在他脑子里都是孤立事件,做完了就扔,没有归纳到某个模型体系里。而会刷题的人,每做一道题,都会在脑中的模型树上挂一个新的分支。遇到新题时,他不是"见过这道题"才能做,而是"识别出这道题的模型"就能做。
1.2 面试导向还是工程导向:先想清楚再投入
进阶刷题通常有两条路线,我建议你动笔之前先想清楚自己要哪条。
面试导向很好理解:目标是短期内覆盖面试官最爱问的高频题型。这条路讲究"题型覆盖度"和"熟练度",你不用追求每个算法都从零推理一遍,但必须做到常见套路信手拈来。比如看到"最长"两个字就条件反射地想到滑动窗口或动态规划,看到"第 K 大"就想到堆或快速选择。
工程导向则是另一种玩法:结合你实际开发中遇到的性能问题、设计问题来刷。比如线上出现过一次接口超时,你排查下来发现是嵌套循环里反复做字符串拼接导致的,那你就应该去刷几道字符串处理的题,把 StringBuilder 的性能边界摸清楚。这条路见效慢,但积累下来的都是能写进简历、讲进项目里的真东西。
我见过太多人明明目标是跳槽面试,却每天做一些偏门竞赛题,难度拉满但和面试考察方向完全不对齐。反过来,也有人是为了提升工程能力,却整天背面试八股。路线和目标错配,是投入产出比低的最大原因。
1.3 一套我验证过的进阶刷题规划
以 8 到 12 周为一个周期,我是这样规划的:
| 阶段 | 周期 | 主要内容 | 产出目标 |
|---|---|---|---|
| 数据结构夯实 | 2-3 周 | 数组、链表、栈、队列、哈希表、树、图、堆 | 每种结构至少 15 道经典题 |
| 高频题型突破 | 4-6 周 | 双指针、滑动窗口、二分、DFS/BFS、动态规划、贪心 | 按题型专项练习,每类 20-30 道 |
| 综合模拟演练 | 2-3 周 | 随机抽题、限时模拟、复盘错题 | 每周 2-3 次完整模拟面试 |
| 查漏补缺 | 持续 | 错题重刷、薄弱环节专项 | 确保每类题型的核心思路能默写 |
这里有个很关键的原则:前面两个阶段,分类刷比乱序刷效率高得多。因为同一个题型连续做十几道,你才能从题目差异中提炼出不变的核心思路。这就像学打球,肯定是先练定点投篮练到肌肉记忆,再去打比赛,而不是一上来就打全场。
2. 排序这道"基础题",藏着的进阶门道
排序是很多人口中的"基础题",但进阶阶段回头看,它其实是一堆高频难题的地基。TopK 问题、逆序对、区间合并、求中位数——这些面试常客的底层,全是排序或排序思想的变形。
2.1 冒泡排序:看着简单,写对并不容易
冒泡排序是入门教科书的第一课,但真让面试者现场手写,写对的人并不多。最常见的翻车点在两个地方:内层循环的边界,以及"没有发生交换就提前结束"这个优化。
先看一个完整可用的版本:
public static void bubbleSort(int[] arr) { int n = arr.length; for (int i = 0; i < n - 1; i++) { boolean swapped = false; for (int j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; swapped = true; } } if (!swapped) { break; } } }为什么内层循环的上限是n - 1 - i?因为每一轮外层循环结束后,最大的那个元素已经被"冒泡"到了它最终的位置,也就是数组末尾,下一轮就不需要再碰它了。-i就是在去除这些已经排好的尾部元素。
swapped标记是这个实现里最值得讲的一笔:如果一整轮扫描下来没有任何交换,说明数组已经有序了,直接退出。这个优化对近乎有序的数组效果极好,能让最好情况降到 O(n)。
面试里如果问"冒泡排序为什么是稳定的",答案的关键是:当arr[j] == arr[j+1]时不交换,相等元素的相对顺序就不会改变。这里有个进阶的理解:稳定性不是"排完序结果稳定",而是相等元素的原始相对位置保持不变,这在对象排序的场景里有实际意义。
2.2 快排与归并思想在刷题中的实际应用
比冒泡更值得花时间的是快速排序和归并排序,因为它们的核心思想会在各种题目里反复出现。
快排的灵魂是 partition(分区)。一趟 partition 能把数组分成"小于基准值"和"大于等于基准值"两拨,这个操作本身就衍生出一大类题——比如找第 K 大元素、荷兰国旗问题(三色分类)、按奇偶排序。很多刷题者没意识到,与其去背"快速选择"的模板,不如先把 partition 吃透。
private static int partition(int[] nums, int left, int right) { int pivot = nums[right]; int i = left; for (int j = left; j < right; j++) { if (nums[j] < pivot) { swap(nums, i, j); i++; } } swap(nums, i, right); return i; }这段代码的思路是:用i维护一个"小于基准值的区间边界",遍历j,遇到比基准值小的就扔到i的位置,最后把基准值放到i处,这样i左边全小于基准值,右边全大于等于。面试手写快排时用这个写法最不容易出错。
归并思想的精髓则是"分而治之"和"合并有序数组"。经典的逆序对问题——统计数组中逆序对数量——就是在归并排序的合并过程中顺便数出来的。还有"合并 K 个有序链表"、"区间合并"这类题,本质上都是归并或排序的应用。
2.3 实战中真正常用的排序 API 与稳定性陷阱
刷题手写排序是一回事,真正做工程和笔试时,Java 内置的排序 API 才是主力。
Arrays.sort对基本类型数组使用双轴快排,对对象数组使用 TimSort。这里有一个非常值得注意的细节:基本类型数组排序是不稳定的,对象数组排序是稳定的。原因在于基本类型没有"相等元素的相对顺序"这个概念,而对象有。
// 对象数组按某个字段排序 Arrays.sort(points, (a, b) -> a.x - b.x); // 或者用比较器 Arrays.sort(intervals, Comparator.comparingInt(a -> a[0]));还有一个容易踩的坑:Comparator 的写法。a.x - b.x这种写法在 int 值很大时有溢出风险,比如a.x = Integer.MIN_VALUE、b.x = 1,相减会直接溢出。笔试时用没事,但工程代码里我更推荐用Integer.compare(a.x, b.x)或者Comparator.comparingInt。
刷题时常用的还有Arrays.binarySearch、Arrays.copyOfRange、Collections.sort、PriorityQueue(本质是一个堆),这些工具类能在关键时刻省写大量代码。但你要注意一个原则:能用 API 解决就不用自己造轮子,但你得能回答出 API 底层是什么算法,这是面试官的常见追问路径。
3. 高频题详解:两数之和从暴力到优化的完整演进
LeetCode 的"两数之和"是刷题人的第一道经典题,但要我说,它最大的价值不是让你记住怎么解,而是完整展示了一个"从暴力到最优"的进阶思考链条。这个链条,才是刷题的核心方法论。
3.1 第一反应写暴力解:先跑通再谈优化
题目描述很简单:给定一个整数数组nums和一个目标值target,找出数组中两个数之和等于target的那两个下标。
我见过很多进阶者不屑于写暴力解,觉得太低级。这是个大误区。你写不出来暴力解就直接想最优解,等于还没学会走路就想跑。暴力解的价值是:它强迫你确认自己完全理解了问题——包括输入输出是什么、边界情况有哪些、复杂度大概在什么量级。
public int[] twoSum(int[] nums, int target) { int n = nums.length; for (int i = 0; i < n; i++) { for (int j = i + 1; j < n; j++) { if (nums[i] + nums[j] == target) { return new int[]{i, j}; } } } return new int[0]; }复杂度分析:外层循环 n 次,内层循环平均 n/2 次,总时间复杂度 O(n^2)。空间复杂度 O(1),因为没开额外容器。当 n 到十万量级,n^2 就是百亿次操作,这在竞赛和笔试环境下都会超时。所以暴力解只能作为思维的起点。
3.2 哈希表解法:空间换时间最经典的例子
暴力解慢在哪?慢在"找补数"这一步要线性扫描。如果用哈希表把已经见过的元素存起来,那"找补数"就能从 O(n) 降到 O(1)。
public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int complement = target - nums[i]; if (map.containsKey(complement)) { return new int[]{map.get(complement), i}; } map.put(nums[i], i); } return new int[0]; }这段代码里最容易被忽视的是先查再放的顺序。为什么不是先把当前元素放进 map 再去查?因为题目要求两个下标不能相同。如果先把当前元素放进去,万一target - nums[i]恰好等于nums[i]本身,就会错误地把同一个下标返回两次。先查再放,保证查到的 complement 一定是之前遍历过的、下标不等于 i 的元素。
哈希表解法的时间复杂度是 O(n),空间复杂度 O(n)。这是典型的空间换时间:为了省掉内层循环的线性扫描,多付出一个哈希表的空间。我刷题时常跟人说,遇到"查找配对"类的问题,先想想哈希表,因为它把查找从"遍历"变成了"直接定位"。
这道题的变体在面试里特别多:"两数之和"的输入如果是有序数组,可以用双指针做到 O(n) 时间和 O(1) 空间;"三数之和"要求三元组不重复,排序加双指针是标准解法;还有在 BST 中找两数之和,本质上可以转换成中序遍历加双指针。
3.3 数组有序时:双指针为什么更优
如果题目明确说数组已经有序,那哈希表解法就"浪费"了这个条件。有序带来一个非常有用的性质:移动指针的方向可以直接告诉我们 sum 变大还是变小。
public int[] twoSumSorted(int[] nums, int target) { int left = 0, right = nums.length - 1; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { return new int[]{left, right}; } else if (sum < target) { left++; } else { right--; } } return new int[0]; }这个解法的直觉是:两个指针一开始指向数组两端,和是最小加最大。如果和小于 target,说明需要更大的数参与,只能把左指针右移;如果和大于 target,说明需要更小的数参与,只能把右指针左移。每次移动都排除了一组不可能的组合,所以最多移动 n 步就能找到答案。
我统计过,双指针类问题在知名刷题平台的高频题里能占到两成以上,从两数之和、三数之和、盛最多水的容器,到接雨水、最长无重复字符子串,全是双指针或其变体的天下。识别这类题的开关就是:数组有序或者问题涉及"从两端逼近"的结构。
三种解法放在一起看,演进逻辑非常清晰:暴力 O(n^2) 是基线,哈希表用 O(n) 空间换 O(n) 时间,而双指针在有序条件下做到最优。刷题时每一次优化,目标都是"分析当前方案浪费了哪些已知条件,然后补上它"。
4. Java 刷题中那些"写出来了但不完美"的细节
算法思路对了、代码也能跑通,但性能总比别人差一截,这个问题在 Java 选手身上特别常见。原因往往不在算法复杂度,而在语言层面的实现细节。
4.1 字符串拼接的隐形代价
我在 review 代码时最常看到的低级问题之一,就是循环里用+拼字符串。Java 的 String 是不可变对象,每次拼接都会创建新的字符串对象,然后把旧内容整体拷贝一遍。循环里拼 n 次,代价就是 O(n^2) 的字符拷贝。
看这个例子:
String result = ""; for (int i = 0; i < 10000; i++) { result += i; // 每次循环都在创建新对象,拷贝全部历史字符 }这段代码如果能跑完,性能会差到令你怀疑人生。用StringBuilder之后的逻辑没变,性能却从 O(n^2) 变成了 O(n) 的均摊成本:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();刷题时怎么判断该用哪个?一个简单的经验法则:循环外拼接、次数固定,用字符串拼接没问题;循环内拼接、次数不确定,一律用 StringBuilder。还有一个容易忽略的细节:StringBuilder扩容也有拷贝开销,如果能预估长度,最好在构造时指定初始容量,比如new StringBuilder(1024)。
4.2 HashMap 与 TreeMap 的选型
Java 刷题时HashMap是出现频率最高的容器之一,原因是它提供 O(1) 均摊的查找和插入。但很多人对它的底层机制理解停留在"用哈希函数定位"这个层面,一问HashMap的初始容量和扩容时机就露怯。
HashMap默认初始容量是 16,负载因子是 0.75。也就是说,当元素数量超过容量 * 0.75 = 12时,会触发扩容,容量翻倍并 rehash 所有元素。如果题目数据量巨大,频繁扩容会影响性能。一个实用的做法是:确定数据规模后直接指定初始容量,比如知道最多要放一百万条数据,new HashMap<>(1000000)就能避免中途多次扩容。
这里有个细节:HashMap的容量总是 2 的幂次,你传的初始容量会被向上取整到最近的 2 的幂。所以传 100 万,实际容量是 1048576。
什么时候用TreeMap?当你有"按键有序遍历"或"快速找最小/最大键"的需求时。TreeMap底层是红黑树,查找和插入是 O(log n),比HashMap慢,但它维护了键的顺序。刷题中有几类场景我经常会用到TreeMap:滑动窗口中维护有序集合、需要按区间端点排序的区间类问题。
4.3 输入输出的处理
竞赛场景和笔试场景的输入输出处理,是很多习惯只在 IDE 里跑测试用例的开发者容易忽略的。力扣这类 OJ 平台帮你封装好了参数输入,但要是参加一些需要自己写完整 IO 的在线评测,或者公司内部的笔试系统,Scanner和System.out.print的性能问题就会暴露出来。
Scanner号称"慢吞吞之王",是因为它内部做了大量的正则匹配和缓冲处理来做类型转换。数据量小完全没问题,但当输入规模到上百万行,Scanner的耗时可能比BufferedReader慢一个数量级。
BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); String line; while ((line = br.readLine()) != null) { // 处理每行输入 }输出端同理,尽量用StringBuilder攒一批再一次性输出,而不是每条结果都调用System.out.println。System.out是一个缓冲输出流,频繁调用会有大量系统调用开销。攒成一个大字符串再输出,通常能快好几倍。
这些细节在大厂笔试里真的能拉开差距。同样的算法思路,读写快的人能节约几分钟时间,这几分钟可能决定你能否完成后面的题。
5. 踩坑记录:刷了那么多题,我最后悔的几件事
一路刷题下来,我犯过错,也见过别人反复掉进同一个坑。这里挑几类最常见的,当作给后来者的提醒。
5.1 三类反复出现的低级错误
第一类是边界条件。二分查找的left <= right还是left < right,数组的length - 1有没有写,滑动窗口的左右指针谁先动。这些细节看似简单,但高压环境下特别容易出错。我的经验是:每道题写完,先跑三个纯手工的边界用例——空数组、单元素数组、最大值或最小值附近的用例,跑完再提交。
第二类是不读题。题目要求返回下标还是值,是否允许重复,数组是否有序,这些信息全在题面里。很多人上来就做,做到一半发现理解错了,浪费时间不说,还把思路带偏。
第三类是一上来就看题解。这应该是刷题人的大忌。我做题时给自己定过一个规矩:一道题至少独立思考 30 分钟,没有思路才允许看题解,看完题解必须自己独立重写一遍。直接看题解刷的量,很多都是虚假努力,看着刷了一百道,实际上一道都没进脑子。
5.2 我的错题复盘方法
刷题不复盘等于白刷。我目前用的复盘体系很简单,但非常管用,核心是维护一个错题表:
| 日期 | 题号/题目 | 错误原因 | 正确思路 | 同类题 |
|---|---|---|---|---|
| 12.01 | 两数之和 II | 没利用有序条件,写了哈希 | 双指针从两端逼近 | 三数之和、盛最多水的容器 |
| 12.02 | 接雨水 | 左右边界意识弱 | 双指针或单调栈,维护左右最大值 | 柱状图最大矩形 |
每周日晚固定做一次复盘:把本周的错题拉出来,先看"错误原因"那一列,找出自己最高频的犯错类型。比如发现连续三次都是边界条件出错,下周就专门找边界条件刁钻的题来练。这种做法针对性极强,比盲目刷新题效率高得多。
5.3 我推荐的刷题节奏与工具
节奏方面,我的建议是细水长流,不要突击。工作日每天保证一道新题加一道旧题重做,周末集中做 2 到 3 道同类题型的专项训练,外加一次错题复盘。突击式刷题的问题是:隔几天不练,手感衰减特别快,而且很难形成长期记忆。
工具方面,国内选手一般用力扣(LeetCode 中文站),题库全、题解社区活跃。想要更偏竞赛一点的话,可以有道和蓝桥杯的在线题库也可以选——如果是冲着算法竞赛的方向刷,蓝桥杯的真题是很好的训练素材。我自己刷题时还习惯用一个本地文档记录每道题的"一句话思路",比如"看到区间重叠 -> 先按起点排序",复习时翻这个文档,比翻几百道题的代码高效得多。
说到这,我想起踩过最深的一次坑:有一段时间我疯狂刷题,一天刷八道,坚持了一个月,看起来量很猛。但后来做题时发现,遇到稍微变形的题还是不熟练。回看记录才发现,那一个月刷的题几乎全是一眼能看出解法、写完就跑的"舒适区题",真正的难题没碰几道。后来我给自己加了一条硬规矩:每天必须有一道题是自己不熟悉的题型或者难度明显偏高的,只有走出舒适区,进阶才会真的发生。