Java进阶刷题指南:从排序到双指针,突破面试算法瓶颈
前阵子有个刚工作一年的朋友跑来问我说自己在刷题网站上刷了三百多道题结果一碰真实面试题还是没思路问我是不是刷题方式出了问题。这个问题我太熟悉了这几年带过的实习生和新同事十个里有六七个都有类似的困惑。今天正好借这个标题认真聊一聊 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[j1]时不交换相等元素的相对顺序就不会改变。这里有个进阶的理解稳定性不是排完序结果稳定而是相等元素的原始相对位置保持不变这在对象排序的场景里有实际意义。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) { MapInteger, 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 中文站题库全、题解社区活跃。想要更偏竞赛一点的话可以有道和蓝桥杯的在线题库也可以选——如果是冲着算法竞赛的方向刷蓝桥杯的真题是很好的训练素材。我自己刷题时还习惯用一个本地文档记录每道题的一句话思路比如看到区间重叠 - 先按起点排序复习时翻这个文档比翻几百道题的代码高效得多。说到这我想起踩过最深的一次坑有一段时间我疯狂刷题一天刷八道坚持了一个月看起来量很猛。但后来做题时发现遇到稍微变形的题还是不熟练。回看记录才发现那一个月刷的题几乎全是一眼能看出解法、写完就跑的舒适区题真正的难题没碰几道。后来我给自己加了一条硬规矩每天必须有一道题是自己不熟悉的题型或者难度明显偏高的只有走出舒适区进阶才会真的发生。