刷牛客刷到第五个记录,手感和状态已经跟前几篇完全不一样了。这一阶段刷题我开始有意识地控制节奏,不再追求每天吞掉多少道题,而是把重点放到“吃透”上——尤其盯着高频面试题型的底层逻辑和边界条件反复琢磨。牛客这个平台的好处是题目场景很贴近真实笔试,而且评论区总有各路大神把更优解、踩坑点、面经套路都整理出来,对处于刷题中期的人来说特别解渴。
这篇记录主要想聊聊我这段时间集中处理的几类问题:动态规划的状态设计、字符串处理里的双指针技巧、二分答案的应用场景、以及链表题里那些容易让人蒙圈的细节。如果你正在准备暑期实习或者秋招提前批,正处于“刷了不少但总觉得不稳”的阶段,这篇里的思路和踩坑经历,应该能帮你省下不少自我怀疑的时间。
1. 内容整体设计与思路拆解
先说我这轮的刷题策略。前四篇记录基本是按数据结构和算法标签硬刷,一天五道、每道尽量独立 AC,做完简单看看别人的题解就过去了。到了第五轮我发现自己陷入一个典型瓶颈:看见题目有思路,但跑测试用例就是有地方不对,尤其是一些边界条件极其刁钻的题,经常“思路对但答案错”。复盘之后我确定问题不在题量,而在题型覆盖和模型总结上。
所以这一轮我改了打法。我把刷题重心从“广度优先”调整为“套路复盘 + 高频模型拆解”,具体操作分三块:
- 按专题集中刷,不再混杂。比如这周主攻动态规划,就只刷 DP,哪怕是简单题也把状态定义、转移方程、初始化、滚动优化全部写清楚。
- 每道题 AC 之后强制写“一页纸笔记”,包含题目模型、我的第一反应思路、最优解的核心思想、卡住的点在哪、有没有同类题可以归并。这招极其费时间,但效果非常扎实。
- 回看牛客讨论区的高赞评论,尤其是那些讲“面试官期待你怎么回答”的帖子。很多题不是你会写就够,你得能讲清楚为什么这么写,复杂度为什么是这个量级,边界为什么那样处理。牛客上的高赞面经经常把这类“软实力”讲得特别透。
再说说选题思路。程序员笔试面试考来考去,核心就那几个模型:排序与查找、哈希表、双指针、二叉树、动态规划、贪心、图论基础、字符串处理。这轮我重点强化的是动态规划和字符串处理,原因很简单——这两块在牛客的题库里占比最高,也是面试时区分度最大的地方。像“最长公共子序列”“编辑距离”“回文子串”这些问题,几乎是每家大厂笔试的常客,而且变化极多,光背模板根本应付不来。
这轮我还给自己定了个规矩:每类题至少做十道,做完必须能脱离题解,从头推导一遍核心解法。不是默写,而是真的理解每一步为什么那么走。这个要求听起来基础,但做起来非常考验人。很多题你看题解觉得“哦,挺简单”,合上屏幕让自己写,立刻卡壳。能白板写出来和能看懂之间,差距比想象中大得多。
2. 核心细节解析与实操要点
2.1 动态规划:状态定义是灵魂
动态规划题的核心永远在状态定义,而不是转移方程。这是我刷完这轮的DP专题后最深的一点体会。很多人卡在转移方程上,本质是因为状态没定义清楚。牛客上有一道很经典的“最长递增子序列”题,我第一遍是用 O(n²) 的常规DP过的,后来看题解发现可以用二分贪心优化到 O(n log n),这里面的状态设计思路就完全不一样了。
我们拿“最长递增子序列”(Longest Increasing Subsequence,LIS)展开说。常规做法的状态是 dp[i],表示以第 i 个元素结尾的最长递增子序列长度。转移的时候,对每个 i,往前找所有比 nums[i] 小的 nums[j],取 dp[j] 的最大值再加一。这道题我踩过的坑有两个:
- 边界条件是 dp 数组初始化为 1,因为单个元素本身就是一个长度为 1 的递增子序列。漏掉这个初始化,结果会全部错乱。
- 最后的结果不是 dp[n-1],而是整个 dp 数组的最大值。因为最长递增子序列不一定以最后一个元素结尾。
而优化版本用的是“贪心 + 二分”,维护一个 tails 数组,tails[i] 表示长度为 i+1 的递增子序列的最小末尾值。遍历原数组时,对每个元素在 tails 里二分查找第一个不小于它的位置,替换掉。这个方案的精髓在于:tails 数组保持单调递增,所以可以用二分。我一开始没想明白为什么 tails 必然有序,后来自己推了一遍才懂——每次替换都比原值小,分位越高的位置值必然越大,否则就能用更小的值替换更靠前的位置。
这里有个非常实用的判断技巧:看到题目要求“最长/最短/方案数/可行性”,且具备“重叠子问题”和“最优子结构”特征,优先考虑DP。但如果数据范围特别大,比如 n 到 10 的 5 次方,那 O(n²) 的 DP 基本必挂,必须考虑贪心加二分、单调栈、或者状态压缩等手段。
2.2 字符串处理:双指针不是万能药
字符串题是笔试的重灾区,因为边界条件太容易翻车。我这次刷了十来道字符串相关题,最常用的方法论是双指针,但双指针也有它的适用边界。
牛客上有一道“最长无重复字符子串”的题,非常典型。这道题用滑动窗口解决,维护一个左指针和一个右指针,右指针不断向右扩展,遇到重复字符就把左指针跳到重复字符上次出现位置的下一个位置。窗口长度就是当前无重复子串的长度,全程记录最大值即可。
这个思路看起来简单,但有个关键决策点:如何记录“字符上次出现的位置”?用哈希表是最直观的,key为字符,value为它最近一次出现的下标。但要注意,更新左指针时,不能简单地取重复字符上次位置 +1,因为窗口内部可能还有更靠右的字符,左指针只能往右移,不能回退。我第一次写就栽在这个地方,忘了加Math.max保护,结果窗口左边界直接倒退了。
另一个容易忽略的点是字符集范围。如果明确说 ASCII 字符,那用固定大小的数组比哈希表更快,直接int[128]就能搞定。如果只说“字符”,那可能包含 Unicode,就得老老实实用哈希表。面试的时候主动问一句“字符集范围”是加分项,因为这体现了你在做工程判断而不是只顾着默写代码。
双指针还有一个容易混淆的场景:同向双指针和相向双指针。滑动窗口属于同向,快排里的 partition、有序数组的两数之和属于相向。这两种写法看起来都是 while 循环嵌套,但循环条件和指针更新逻辑完全不同。我建议你刷题时专门给双指针建立一张“场景-指针移动方式-终止条件”的对照表,做题前先对号入座,而不是上来就写代码。
2.3 二分答案:把最优化问题转化为判定问题
二分答案是我这轮新啃下来的题型,以前我遇到这类题基本靠直觉碰运气,这次系统刷了一遍,发现它的套路极其固定。
二分答案的适用特征是:题目要求“最大值最小”或“最小值最大”,而且存在一个单调的判定函数。典型题目包括“分割数组的最大值”“在 D 天内送达包裹的能力”“爱吃香蕉的珂珂”等。这类题直接求最优解比较难,但给定一个候选答案,判断它是否可行却很容易。
关键操作分三步:
- 确定二分范围。一般是答案的理论下界和上界,比如“在 D 天内送达包裹的能力”里,下界是包裹重量的最大值,上界是总重量。
- 写判定函数。给定一个运载能力,判断能否在 D 天内运完。这个函数通常用贪心模拟实现。
- 在二分框架里调用判定函数,不断收缩边界。
我犯过的错误主要集中在二分的边界处理上。比如while (left < right)和while (left <= right)到底用哪个,mid取中间值还是中间偏右,这些都是有讲究的。我的经验是:如果是找最小可行值,用left < right配合 mid 取左中位数,最后返回 left;如果是找最大可行值,用 mid 取右中位数。这个技巧在牛客的题解区被反复提及,但我建议你还是自己推一遍,因为死记硬背容易在变种题上翻车。
还有一个坑是溢出问题。求中间值如果写成(left + right) / 2,当 left 和 right 都很大时会溢出,应该写成left + (right - left) / 2。这个问题在 Java 和 C++ 里尤其明显,Python 因为整数无上限反而没关系。但你如果面试用的语言是 Java,这一点一定要刻进肌肉记忆。
2.4 链表题:别只在脑子里转指针
链表题的麻烦在于,指针操作一旦绕起来,逻辑在脑子里容易乱。我的经验是:链表题一定要在草稿纸上画图,哪怕是一道简单题,也先把节点关系和指针变化画清楚再动手写代码。
刷链表题有几个高频考点:反转链表、合并两个有序链表、找环入口、删除倒数第N个节点、回文链表判断。这轮我把牛客上的链表高频题都过了一遍,收获最大的是对“虚拟头节点”这个技巧的理解。
虚拟头节点的核心价值是统一处理边界情况。比如删除链表头节点时,如果不用虚拟头节点,就需要特判;用了虚拟头节点之后,所有删除操作都变成同样的逻辑,不需要对头节点做特殊处理。这个技巧在“删除倒数第N个节点”里特别明显——你加一个虚拟头节点,然后用双指针一次遍历就能找到目标节点的前驱,直接完成删除。
另一个容易出错的地方是“快慢指针找链表中点”。这个操作在链表题里极其常见,但很多人会记错快指针的移动节奏。找中点的标准写法是快指针走两步、慢指针走一步,循环条件是fast != null && fast.next != null。我建议你在这里多花一分钟想清楚:当链表长度是偶数时,慢指针停在哪个位置?当链表长度是奇数时,又停在哪个位置?这两个结果会直接影响后续代码的写法,而且牛客的测试用例里一定会覆盖偶数长度的链表,不看清楚就动手非常容易挂。
我还想单独说说链表反转。迭代法的核心是三个指针 prev、current、next,每次循环把 current.next 指向 prev 后整体前进。递归法的核心则是先深入到链表末尾,然后回溯过程中逐个改指针方向。我个人的体会是:笔试时优先写迭代法,因为它不易爆栈且好调试;面试被要求讲思路时,可以用递归法展示你对函数调用栈的理解深度。
3. 实操过程与核心环节实现
3.1 动态规划题目的完整推导示范
拿牛客上的一道经典题“编辑距离”来演示我现在的做题流程。题目要求把字符串 word1 转换成 word2,允许插入、删除、替换三种操作,求最少操作次数。
我第一次看到这道题时,直觉是用贪心,结果发现贪心根本做不了,因为每一步的最优选择会影响后续操作的成本。后来我意识到这题的模型是典型的区间 DP——两个字符串的匹配问题,多半要用二维 DP,状态是dp[i][j]表示 word1 的前 i 个字符转换成 word2 的前 j 个字符需要的最少操作数。
转移方程的推导是这题的重点。当word1[i-1] == word2[j-1]时,dp[i][j] = dp[i-1][j-1],因为最后一个字符已经匹配,不用额外操作;当两个字符不等时,我们有三种选择:
- 删除 word1 的第 i 个字符,对应
dp[i-1][j] + 1 - 在 word1 中插入一个字符去匹配 word2 的第 j 个字符,对应
dp[i][j-1] + 1 - 替换 word1 的第 i 个字符为 word2 的第 j 个字符,对应
dp[i-1][j-1] + 1
取三者最小值即可。初始化部分也要注意:dp[0][j] = j表示从空串变成 word2 的前 j 个字符只能靠插入;dp[i][0] = i表示把 word1 的前 i 个字符变成空串只能靠删除。
我写这道题时的实际代码如下:
public int minDistance(String word1, String word2) { int m = word1.length(), n = word2.length(); int[][] dp = new int[m + 1][n + 1]; for (int i = 0; i <= m; i++) dp[i][0] = i; for (int j = 0; j <= n; j++) dp[0][j] = j; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { if (word1.charAt(i - 1) == word2.charAt(j - 1)) { dp[i][j] = dp[i - 1][j - 1]; } else { dp[i][j] = Math.min(dp[i - 1][j - 1], Math.min(dp[i - 1][j], dp[i][j - 1])) + 1; } } } return dp[m][n]; }这道题我特别想强调的是:为什么状态要多开一行一列?因为dp[i-1][j-1]在 i=0 或 j=0 时会越界,多开一行一列可以统一处理空串的情况,避免大量分支判断。这是一种“用空间换代码简洁度”的思路,在 DP 题里极其常见。
写完这道题后,我还会顺便对比“最长公共子序列”和“两个字符串的删除操作”,因为它们共享同一个二维 DP 框架,只是转移方程略有不同。这样对比着一组一组地刷,记忆会牢固很多,面试时也不容易乱。
3.2 双指针场景的现场推导
拿“最长无重复字符子串”来说,我完全从零开始推一遍。
先定义一个哈希表map记录每个字符最近出现的位置,再定义 left 指针表示当前窗口的左边界。用 right 指针遍历字符串,每遇到一个字符s[right],先检查它是否在 map 中——如果是,说明窗口中可能已有重复字符,需要把 left 移动到max(left, map.get(s[right]) + 1)。然后更新map.put(s[right], right),用right - left + 1更新答案。
我实际第一次写的时候漏了max,直接用left = map.get(s[right]) + 1,结果在输入串为“abba”时正确输出为 2,我算出来却是 3。复盘后发现原因:遍历到第三个字符 b 时重复了,把 left 跳到了第二个 b 的位置加一;遍历到第四个字符 a 时,a 在 map 中的位置是 0,如果不加保护,left 会被重置为 1,但此时 left 的正确位置应该是 2(第二个 b 的下一个位置)。left 往后退了,窗口里就包含了重复的 b,结果自然错。
这个教训非常值钱。双指针的滑动窗口里,指针一旦确定的方向就只能单向移动,任何看起来像是“后退”的写法都要引起警惕。很多牛客题解里提到的“哈希表存下标 + left 取 max”这个细节,看起来不起眼,实际就是这类题的生死线。
3.3 二分答案的代码骨架
我直接把“在 D 天内送达包裹的能力”写出来,展示一下二分答案的整体代码结构。
public int shipWithinDays(int[] weights, int days) { int left = 0, right = 0; for (int w : weights) { left = Math.max(left, w); right += w; } while (left < right) { int mid = left + (right - left) / 2; if (canShip(weights, days, mid)) { right = mid; } else { left = mid + 1; } } return left; } private boolean canShip(int[] weights, int days, int capacity) { int dayCount = 1; int currentLoad = 0; for (int w : weights) { if (currentLoad + w > capacity) { dayCount++; currentLoad = 0; } currentLoad += w; if (dayCount > days) return false; } return true; }这份代码的骨架就是二分答案的标准模板:先确定答案的上下界,然后在 while 循环里用中间值调用判定函数。要注意的是canShip这个判定函数本身是贪心模拟,它如果能在 O(n) 时间内写出来,那么整体复杂度就是 O(n log(总和)),对笔试完全够用。
我在这类题里遇到的最典型问题,是把canShip写成了currentLoad + w > capacity时才开新的一天,但忘了先把当前包裹装进当天。这个顺序问题看似小,实际会让判定结果偏大,导致二分最终返回的值偏大或者偏小。具体表现就是有的测试用例能过,有的不能过——特别难排查。我现在写这类判定函数有个习惯:对着一个最小例子手算一遍再提交,比如 weights = [1,2,3,4,5,6,7,8,9,10], days = 5,这个示例用例能帮你快速验证逻辑是否完整。
3.4 链表反转的现场手写
链表题我这次重点复盘了反转链表。迭代法代码很短,但每一步都是经典:
public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode next = curr.next; curr.next = prev; prev = curr; curr = next; } return prev; }这段代码的核心就是三指针联动,需要注意的地方有两个。第一,在curr.next被修改前必须先把next保存下来,否则链表后半段就找不到了;第二,循环结束后prev指向原链表的尾节点,也就是新链表的头节点,返回值必须是 prev 而不是 curr。
递归版本其实也非常优雅,但很多人在理解上有障碍。递归的核心是“先反转后面的链表,再把当前节点接在后面”:
public ListNode reverseList(ListNode head) { if (head == null || head.next == null) return head; ListNode newHead = reverseList(head.next); head.next.next = head; head.next = null; return newHead; }我刚开始看不懂这段代码,因为head.next.next = head这句太反直觉了。后来我画图才明白:这其实就是把原来指向后面的指针掉个头,让当前节点的下一个节点反过来指向当前节点。如果你第一次学链表就卡在这句上,别慌,正常现象。我建议你只用三个节点手动模拟递归的两次调用,跑通一遍就彻底明白了。
关于链表题,我还有一个心得:写完后一定要检查是否需要断尾。比如反转后,原来的头节点,也就是新链表的尾节点,它的 next 必须置空,否则可能形成环。迭代法里因为prev初始是 null,所以第一次循环就把head.next变成了 null,这个问题自然解决了,但递归法里必须显式写head.next = null,漏掉就出大问题。
4. 常见问题与排查技巧实录
刷题这轮下来,我积累了一些很有共性的问题和排查方法。整理成一个速查表,对正在刷题的人应该有帮助。
| 题型 | 常见报错/错误结果 | 排查思路 |
|---|---|---|
| 动态规划 | 结果比预期小 | 检查 dp 初始化是否正确,尤其是边界行和列是否漏了 |
| 动态规划 | 结果比预期大 | 检查状态转移方程是否考虑了所有可能的选择分支 |
| 滑动窗口 | 窗口越过左边界 | 检查 left 指针更新是否使用了 max 保护 |
| 二分答案 | 循环死循环 | 检查 mid 取中位数的方向与边界收缩是否匹配 |
| 二分答案 | 结果偏大/偏小 | 手算一个最小用例验证判定函数 |
| 链表反转 | 链表成环 | 检查原头节点 next 是否置空 |
| 链表删除 | 删除头节点出错 | 使用虚拟头节点统一逻辑 |
| 链表找中点 | 偶数长度时位置不对 | 明确循环条件和 fast.next 的判断顺序 |
还有一个非常典型的排查场景:代码在本地 IDE 跑得好好的,贴到牛客在线编辑器里就编译不过。原因多半是你用了 Java 的var关键字或者 lambda 的特殊写法,牛客的编译器版本和本地不一致。我的建议是:提交前把代码里所有需要 Java 8+ 的新特性改成兼容写法,尤其是var、List.of、String.repeat这些,涉及到的类也要主动引用完整包名。这不是代码逻辑问题,而是环境兼容问题,踩过一次就会长记性。
另外,很多人刷题时会忽视测试用例的设计。我自己以前也是,能过示例就直接提交,然后被隐藏用例打得头破血流。后面我学乖了,每道题 AC 后都会追加几个边界用例:
- 空输入:空数组、空字符串、null 链表
- 单元素输入:只有一个字符,只有一个节点,只有一个包裹
- 全相同输入:所有字节相同,所有值一样
- 极端大小:最大值最小值的组合
把这些边界用例跑通,代码的鲁棒性会提升一大截。尤其是链表题,空链表和单节点链表是两种最容易被忽略的情况,很多看起来完美的代码转头就挂在head.next的空指针上。
排查问题还有一个很实用的技巧:加打印日志。笔试环境里没法打断点,我会在关键位置加System.out.println,比如二分答案里的判定函数入口、动态规划里的 dp 数组每个格子,输出中间值来定位逻辑错误。日志打印完再删掉提交,这个流程虽然笨,但非常有效。我见过不少同学在本地 IDE 调通了就直接复制代码提交,结果在线环境因为输入输出格式的问题白丢一次提交机会,这点一定要留意。
最后分享一个我用了很长时间的小习惯:每刷完一批题,强制自己做一次“无代码复盘”。也就是不打开编辑器,只用纸笔把核心思路写下来,包括状态定义、转移方程、边界条件。能做到这一点,才算真正消化了题目。如果这一步做不到,说明你还没从“看懂题解”变成“自己会写”,这时候回头重做比继续刷新题更有价值。
这轮牛客刷题记录就到这里。比起前几轮的赶进度,这次我更踏实地感受到,刷题不是比谁刷得多,而是比谁能在有限时间内建立更准确的题型认知和边界条件敏感度。把每道题吃透、把同类题归并、把易错点记牢,远比盲目追求数量有意义。希望这篇记录里的思路和踩坑经历,也能让你的刷题之路少绕几个弯。