☰
算法刷题核心模板:双指针、动态规划、回溯与二分实战
2026/10/14 12:39:13 网站建设 项目流程

核心关键词背后的算法思维

高频题刷多了,你会发现一个现象:真正让人卡住的往往不是题目本身,而是不知道该往哪个方向想。LeetCode 和牛客上的题虽然成千上万,但出题套路其实高度集中。我整理过自己刷过的几百道题,发现大概有这几类核心思维模型,把每个模型吃透,比题海战术管用得多。

第一个是贪心思维。这类题的特征是“每一步都选当前最优,最后就是全局最优”。听起来简单,但难点在于证明“局部最优等于全局最优”。很多同学看到贪心题就想用动态规划硬刚,反而把问题搞复杂。典型如“跳跃游戏”“任务调度器”“分发饼干”,这类题的关键在于找到正确的贪心策略,而不是证明它,证明的工作就交给数学直觉和测试用例。

第二个是双指针思维。排序数组找两数之和、判断回文链表、链表找环,这类题用双指针能把 O(n^2) 砍到 O(n)。双指针的精髓在于“让两个指针保持特定的距离或移动规则”,这需要你对题目条件足够敏感。比如看到“有序数组”四个字,第一反就应该想到双指针或者二分。

第三个是动态规划思维。这是大多数人的噩梦,但也是最高频的考点。动态规划的核心不是套公式,而是搞清楚两件事:状态怎么定义,转移方程怎么写。我刷动态规划题总结出来的经验是:先画表格,把状态转移的过程用表格模拟一遍,思路清晰了再写代码。后面的章节我会用一个具体的例子拆解这个过程。

第四个是哈希表思维。空间换时间,看到“找出现次数”“去重”“两数之和”这类关键词,第一反应该是哈希表。在 Python 里就是字典或集合,在 Java 里就是 HashMap 和 HashSet。

明确了核心思维模型,接下来就进入实践环节。高频题不能盲目刷,需要抓重点。LeetCode 的 Top Interview Questions 列表、牛客的热题榜,都是很好的起点。我的建议是:每天 3 道题,按标签刷而不是按难度刷,先搞定双指针和哈希表这两个性价比最高的标签,再过渡到动态规划。

基础攻克:数组与字符串的高频题型与模板

数组和字符串是算法题里最常见的载体,也是很多复杂题型的基础。搞定这一块,后面刷链表、树、动态规划都会轻松很多。这一部分题目的特点是:看起来简单,但边界条件极其容易出错,需要你养成“先写注释再写代码”的习惯。

暴力解法到双指针优化的完整思路

先看一个最常见的题:三数之和。题目要求找出数组中所有和为 0 的三元组。新手第一反应是三重循环,这个写法思路没错,但时间复杂度是 O(n^3),对于数据量稍大的用例直接超时。

def three_sum(nums): nums.sort() result = [] n = len(nums) for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue left, right = i + 1, n - 1 target = -nums[i] while left < right: current_sum = nums[left] + nums[right] if current_sum == target: result.append([nums[i], nums[left], nums[right]]) while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 elif current_sum < target: left += 1 else: right -= 1 return result

排序是这道题的关键一步。排序之后,固定一个数,剩下的两个数用双指针从两端往中间逼近。这个套路非常标准,刷熟之后可以迁移到好几道题上。特别要注意去重的写法:外层的 i 跳过重复值,内层找到答案后左右指针都要跳过重复值。这个细节写到烂熟,面试手写不会卡壳。

边界条件分析的隐藏陷阱

数组题最容易在边界条件上翻车。以这道题为例,需要注意:

  • n < 3时直接返回空列表,这是最容易被忽略的判断。
  • 排序后第一个元素就已大于 0,可以直接剪枝返回,因为三个正数之和不可能为 0。
  • 固定元素 i 时,nums[i] == nums[i-1]的去重逻辑不能写成nums[i] == nums[i+1],否则会跳过正确答案的三元组组合形式。

很多人忽略排序这一步在整体方案中的价值。排序是 O(n log n),后面双指针是 O(n),对每个 i 都要跑一遍双指针,所以总复杂度是 O(n^2)。这个复杂度级别在面试中是可以接受的最优解。

字符串高频题型:滑动窗口模板直接用

字符串当之无愧的高频区间是“子串问题”。最长不重复子串、最小覆盖子串、重复 DNA 序列,几乎都能用滑动窗口模板解决。滑动窗口本质上是一种“在线维护区间信息”的双指针变体。

def length_of_longest_substring(s): char_set = set() left, max_len = 0, 0 for right in range(len(s)): while s[right] in char_set: char_set.remove(s[left]) left += 1 char_set.add(s[right]) max_len = max(max_len, right - left + 1) return max_len

核心机制是:右指针负责扩张,左指针负责收缩。等发现当前字符已经在集合里时,就收缩左边界,直到集合里不再有重复字符。这个过程每个字符最多进集合一次、出集合一次,所以时间复杂度是 O(n),空间复杂度 O(min(m, n)),m 是字符集大小。

模板的迁移:从字符串到其他场景

这套滑动窗口模板可以迁移到很多场景。比如“长度为 k 的滑动窗口内的最大值”,换成维护双端队列;“最小覆盖子串”,换成维护窗口内各字符出现次数的字典。不要死记模板,理解每个变量的作用后,套用到新题上才不会被“变形”卡住。

常见误区:数组越界与索引逻辑

数组题最经典的报错就是 IndexError。举一个我刷题时真实踩过的坑:“寻找旋转排序数组中的最小值”这道题,用二分法时,我当时判断的是nums[mid] > nums[mid+1],当 mid 恰好是最后一个索引时直接越界。后来养成习惯:凡是要访问mid+1或mid-1,先确认 mid 不会落在边界上。二分法处理这种题时,循环条件建议写成left < right,这样退出时 left 和 right 相等,直接返回nums[left],省去一堆边界判断。

数据结构必考:链表、栈与队列的实战模板

链表题在面试中非常高频,尤其在需要考察“指针操作基本功”的场景。链表题的难点不在于算法思维,而在于指针操作的细节。你需要在纸上画出每个节点的 next 指向变化,从图上推演代码才会真的理解。

链表反转类问题的核心模板

链表反转是很多题的基础操作,回文链表判断、K 个一组翻转链表等都会用到。迭代反转是最基础也最通用的写法:

def reverse_linked_list(head): prev = None cur = head while cur: next_node = cur.next cur.next = prev prev = cur cur = next_node return prev

核心动作是保存 next、反转当前节点的指向、移动 prev 和 cur。这三步的顺序一定不能乱。先保存 next 是为了防止断链后找不到下一个节点;然后让 cur.next 指向 prev;最后把 prev 和 cur 都往后移动。这套模板在面试中大概率会用到,背熟写出只是及格水平,真正加分的是你能解释清楚每一步的作用和边界场景,比如链表只有一个节点时这套代码也能正常工作。

链表环检测:快慢指针的代码细节

链表找环用龟兔赛跑算法,也就是快慢指针。快指针每次走两步,慢指针每次走一步,如果链表有环,两个指针必然在某一刻相遇。这里一个很容易被忽略的点是:快指针必须先判断自身是否为 None,再判断fast.next是否为 None。否则快指针已经走到链尾时,访问fast.next.next会抛空指针异常。正确的写法:

def has_cycle(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False

需要留意的是初始判断。如果head为空或只有一个节点,循环不会进入,直接返回 False。这个写法不依赖链表长度,也不会无限循环,因为没环的链表 fast 终会遇到 None,有环的链表快慢指针一定会相遇。

链表题的边界思维:空节点与单节点

链表题我踩过最多的坑就是空指针:对 None 调用 .next 或 .val。所以拿到链表题,我习惯先写一个保护性的判断:

if not head or not head.next: return head # 空链表或单节点链表无需处理

这行代码能解决链表反转、删除倒数第 N 个节点、交换链表节点中“处理单节点”的全部边界问题。再一个细节是虚拟头节点的使用。凡是头节点可能被删除的题,比如“删除链表的倒数第 N 个节点”,都建议创建一个虚拟头节点指向 head。这样删除操作就不需要单独讨论“删除的是头节点”这个分支,代码更统一。

栈与队列:单调栈的思维破局

栈和队列的题集中在括号匹配、表达式求值、单调栈三类。括号匹配是栈的入门应用,左括号入栈,右括号时检查栈顶是否匹配。单调栈的思维更隐蔽,但也是高频点。

“接雨水”这道题就可以用单调递减栈解决。遇到比栈顶矮或相等的柱子就入栈;遇到更高的柱子,说明栈顶柱子右边出现了一个“右边界”,此时可以计算一次积水面积。这里的关键思维是:积水区域不是一次性计算的,而是由“左右边界之间的较小值”决定每个横向小块。这种“把大问题拆成若干横向小块”的思路,就是单调栈的精华。

def trap(height): stack = [] water = 0 for i, h in enumerate(height): while stack and h > height[stack[-1]]: top = stack.pop() if not stack: break left = stack[-1] width = i - left - 1 high = min(height[left], h) - height[top] water += width * high stack.append(i) return water

这段代码的难点在于理解“为什么 popped 之后再检查栈是否为空”。栈顶元素被弹出后,如果栈里没有元素,说明左边没有更高的柱子挡着,积水会从这里流走,所以不能计算面积。这个细节是很多初学者卡住的地方。

另外,打双端队列的两个方向都要熟练。滑动窗口最大值那道题,核心也是单调队列:维护一个队头到队尾递减的序列,每次窗口滑动时,先从队头移除已离开窗口的元素,再加入新元素时从队尾弹出所有更小的元素。理解了两个方向的弹出规则,就能解决一大类“窗口极值”问题。

动态规划:从表格法到状态压缩的全面拆解

动态规划这块,我见过太多人直接卡死在入门阶段。其实只要掌握正确的思考顺序,动态规划并没有传说中那么可怕。我认为最关键的一步是:先画表格,再写代码。

经典零一背包问题的逐步推导

以“零一背包”为例:有 n 个物品,每个物品有重量 w[i] 和价值 v[i],背包容量为 C,每个物品只能取一次,求能装下的最大价值。

二维状态的转移方程是这样定义的:dp[i][j]表示前 i 个物品中,在容量为 j 时能装下的最大价值。注意状态定义中“前 i 个物品”这个约束,这让转移方程变得清晰:要么不选第 i 个物品,价值等于dp[i-1][j];要么选第 i 个物品,价值等于dp[i-1][j - w[i]] + v[i]。

def knapsack(weights, values, capacity): n = len(weights) dp = [[0] * (capacity + 1) for _ in range(n + 1)] for i in range(1, n + 1): for j in range(capacity + 1): if weights[i-1] > j: dp[i][j] = dp[i-1][j] else: dp[i][j] = max(dp[i-1][j], dp[i-1][j-weights[i-1]] + values[i-1]) return dp[n][capacity]
空间优化的核心思路

上面二维数组的写法,空间复杂度是 O(n*C)。但观察状态转移,第 i 行只依赖第 i-1 行,所以可以用一维数组滚动更新。关键在于遍历容量时必须倒序:

def knapsack_optimized(weights, values, capacity): dp = [0] * (capacity + 1) for i in range(len(weights)): for j in range(capacity, weights[i]-1, -1): dp[j] = max(dp[j], dp[j-weights[i]] + values[i]) return dp[capacity]

为什么要倒序?因为一维 dp 复用同一个数组,如果正序遍历,dp[j - weights[i]]可能已经包含了第 i 个物品,导致同一个物品被多次选择,这就变成了完全背包而非零一背包。倒序遍历保证dp[j - weights[i]]是上一轮的状态,还没有被当前物品污染。这个细节是零一背包和完全背包的唯一区别。

完全背包只改一行:方向定乾坤

完全背包的物品可以选无限次。只需要将内层循环改为正序:

for j in range(weights[i], capacity + 1): dp[j] = max(dp[j], dp[j - weights[i]] + values[i])

因为正序保证可以用到本次迭代同一物品更新过的状态,相当于允许重复选择。与零一背包对比记忆,动态规划的空间优化就不再是死记硬背,而是一个自然推导的结果。

最长上升子序列的常见考察点

最长上升子序列是面试和笔试都极高频的题,暴力解法是 O(n^2) 的动态规划,进阶解法是用“贪心 + 二分”做到 O(n log n)。我这里先讲 O(n^2) 版本,因为它最容易理解,也是后续优化的基础。

def length_of_lis(nums): if not nums: return 0 dp = [1] * len(nums) for i in range(len(nums)): for j in range(i): if nums[i] > nums[j]: dp[i] = max(dp[i], dp[j] + 1) return max(dp)

状态定义是:dp[i]表示以第 i 个元素结尾的最长上升子序列长度。每个位置初始化 1,表示子序列长度至少为 1(该元素自身)。在 O(n^2) 版本中,dp[i]是在扫描所有前面的元素 j 后更新得来的。

O(n log n) 版本的核心是维护一个数组 tail,tail[k] 表示长度为 k+1 的上升子序列末尾元素的最小值。处理每个新元素时,在 tail 中二分查找第一个大于等于它的位置,替换掉。这个技巧的精髓在于“用后续更小的元素替换前面的较大元素,保持插入可能性最大化”。

关于状态转移的循环顺序,有个很容易犯的错:在 O(n^2) 写法里,第二层 j 循环必须从 0 到 i,不能从 i-1 到 0。否则虽然大部分情况结果相同,但在有相等元素和特殊序列时会出错。为了方便记忆,动态规划的循环顺序一概保持“从小到大、从前往后”,这也是最利于排查问题的写法。

动态规划的通用思考路径:状态、转移、边界

动态规划题目千变万化,但思考路径是固定的。我总结了四步法:

  1. 明确状态:这个问题的子问题是什么?维度有哪些?比如背包问题的状态是“物品数”和“容量”两个维度。
  2. 定义 dp 数组:dp[i][j]通项公式是什么?写清楚下标含义。
  3. 推导转移方程:当前状态可以从哪些状态转移过来?列出所有可能,取最大值或最小值。
  4. 确定边界与遍历顺序:初始值是多少?循环从小到大还是从大到小?零一背包倒序、完全背包正序就是典型的遍历顺序问题。

这套路径用得顺了,遇到新题不会慌。面试时哪怕最终没有写出最优解,只要把状态定义和转移方程讲清楚,也能拿到大部分分数。

高频考察的字符串处理与哈希技巧

字符串和哈希往往是组合题。前面讲过的滑动窗口就用到了哈希表记录窗口内元素。哈希表还有一个非常经典的用法:两数之和系列。

从两数之和到三数之和的技术演进

两数之和是大多数人的算法入门第一题。暴力法是 O(n^2),用哈希表可以做到 O(n):

def two_sum(nums, target): seen = {} for i, num in enumerate(nums): complement = target - num if complement in seen: return [seen[complement], i] seen[num] = i return []

这里的核心技巧是“边遍历边建表”。先检查补数是否已经在哈希表中,如果没有,就把当前数存入表里。这样省去了一次单独建表的循环,也让代码更简洁。这个写法在后面的“判断两数之和是否存在”类题目中可以直接复用。

三数之和的哈希版本需要先去重,处理逻辑比排序 + 双指针复杂,所以更推荐双指针方案。但从两数到三数,你看到的是哈希表思维如何在更复杂的题目中优雅地迁移。它不再适合时,往往会转成排序 + 双指针这套方案。

字符串高频哈希题:字母异位词分组

“字母异位词分组”是一道极具代表性的哈希表题。考察点是把异位词归到一组,其中“如何表示一个字符串的指纹”是设计重点。

def group_anagrams(strs): from collections import defaultdict groups = defaultdict(list) for s in strs: key = ''.join(sorted(s)) groups[key].append(s) return list(groups.values())

排序后的字符串作为哈希键。这个方案的时间复杂度是 O(n * k log k),其中 n 是字符串数量,k 是最长字符串的字母个数。空间复杂度 O(n*k)。这个思路也广泛用于判断“两个字符串是否互为异位词”的题目。

一个提升性能的变体是“用字母出现次数作为 key”,即把每个字符出现次数拼成字符串,例如a1b2c0这样的形式。这个做法把排序降到 O(k),但构建 key 也需要 O(k)。实测来看,在字符串较短时普通排序足够快,在字符串很长时计数法的收益才明显。两种写法都建议掌握,面试时可以展示你对比两者的能力。

最长不含重复字符的子串:两种解法的对比

上一节已经给过滑动窗口解法。这里补一个更贴近“面试加分”的优化版本:用字典记录每个字符最后一次出现的下标,从而把左指针直接跳到重复字符的下一个位置。

def length_of_longest_substring(s): pos = {} left = max_len = 0 for right, ch in enumerate(s): if ch in pos and pos[ch] >= left: left = pos[ch] + 1 pos[ch] = right max_len = max(max_len, right - left + 1) return max_len

如果不小心把条件写漏了pos[ch] >= left,后果很微妙:当 ch 曾经出现过但位置在 left 左边时,这个信息其实已经无效,因为我们当前窗口并不包含它。加这个条件就是为了避免被旧位置干扰。这版代码虽然不如前面 set 版直观,但熟练后代码更短、思考更精准。

哈希映射的冲突与扩容原理简述

理解了哈希表能用 O(1) 时间查找的前提,你会更敢在面试里用哈希解决看起来与“哈希”无关的问题。哈希表本质是数组加哈希函数,把 key 映射到数组下标。冲突时用链地址法或开放寻址法解决。Python 的 dict 采用开放寻址,Java 的 HashMap 在链表长度超过阈值时转化为红黑树。这些知识本身不一定直接考,但在“为什么你的算法是 O(1)”的追问环节可以自然回答。

回溯与搜索:从排列组合到递归剪枝

回溯算法在牛客和 LeetCode 中一直处于“面试高频、但代码容易写乱”的地位。高点在于题型多:全排列、子集、组合总和、岛屿数量、单词搜索,全是回溯和搜索。低点是如果递归参数设计不合理,代码会陷入混乱。

全排列问题的回溯模板

全排列题最适合讲清楚回溯的通用模板。核心思想是“已经用过哪个数、当前路径有哪些元素、是否收集到足够长度”。用 Python 写:

def permute(nums): result = [] path = [] used = [False] * len(nums) def backtrack(): if len(path) == len(nums): result.append(path[:]) return for i in range(len(nums)): if used[i]: continue used[i] = True path.append(nums[i]) backtrack() path.pop() used[i] = False backtrack() return result

path[:]拷贝是必须的。如果不拷贝直接 append(path),后续的 path 变化会直接影响已经加入结果的对象,导致结果数组里全是同一个最终状态的路径。这个问题在 Python 新手里出现的频率极高。

剪枝的两种情况:可行性剪枝与去重剪枝

剪枝是回溯的提速关键。第一种是可行性剪枝:当前选择明显不可能得到合法解时,直接跳过。比如“组合总和”里当前的数已经大于剩余目标值,不再往下走。“括号生成”里右括号数量已经大于左括号数量时,不继续递归。这类剪枝极大缩小了搜索空间。

第二种是去重剪枝:在含重复元素的序列中,排序后跳过与前一个元素相同的选择,前提是前一个元素没有被使用。比如含重复元素的全排列,若不剪枝会生成重复结果。

if i > 0 and nums[i] == nums[i-1] and not used[i-1]: continue

这个条件非常经典。它确保重复元素只有在前一个相同元素已经被使用的情况下才能继续排列下去。这样就把同值元素之间的相对顺序固定为一种,避免重复排列。很多人背不下这个条件,建议手动模拟两次含重复元素的全排列过程,内在逻辑会非常清晰。

岛屿问题:深度优先搜索的边界处理

岛屿问题是搜索类的高频代表,判断网格中陆地连成几块。解法可以是 DFS 或 BFS,核心是标记已访问的陆地,防止重复计数。DFS 写法:

def num_islands(grid): if not grid: return 0 count = 0 rows, cols = len(grid), len(grid[0]) def dfs(r, c): if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] == '0': return grid[r][c] = '0' dfs(r+1, c) dfs(r-1, c) dfs(r, c+1) dfs(r, c-1) for r in range(rows): for c in range(cols): if grid[r][c] == '1': count += 1 dfs(r, c) return count

这里的核心是“访问过就置为 0”,防止后续重复搜索。这种标记手法被称为“原地标记法”。注意递归边界要先判断越界,再判断字符值,顺序反了会在越界时访问到不存在的行或列。如果你觉得网格类的递归深度可能超过 Python 默认递归限制,可以加sys.setrecursionlimit()或者改 BFS。牛客的递归限制一般能支撑常见用例,但谨慎起见知道这个风险点即可。

递归回溯的复杂度分析与状态重置

回溯算法的复杂度通常是 O(答案数量 * 构造单个答案的开销)。以全排列为例,答案是 n! 个,每个答案拷贝需要 O(n),所以时间复杂度是 O(n * n!)。空间复杂度取决于递归深度,最坏是 O(n)。这个复杂度在数据规模 n <= 8 或 9 时是可接受的。一旦 n 超过 12,全排列类型的试题基本不会出现,除非用数学公式化简。这个经验可以帮助你判断回溯题的数据范围。

状态重置指的是递归返回前把路径和标记恢复原状。很多人漏掉这一步,导致同一层的状态被污染。我在刷题初期的教训是:在递归函数里先记录状态,比如 visited 数组的修改,再在递归返回时恢复。凡是用到全局状态或可变对象的回溯,都要成对出现“修改 / 恢复”。建议在写递归前先写一个状态修改清单,然后像 checklist 一样逐步执行。

排序、二分与边界思维

排序和二分看似基础,却是面试中容易“阴沟翻船”的点。排序在于选择哪种策略、稳定性。二分则是考察你对区间不变量的理解。

快速排序与归并排序的两种模板对比

面试手写排序,最快的写法是快速排序:

def quick_sort(nums): if len(nums) <= 1: return nums pivot = nums[len(nums) // 2] left = [x for x in nums if x < pivot] mid = [x for x in nums if x == pivot] right = [x for x in nums if x > pivot] return quick_sort(left) + mid + quick_sort(right)

这个写法简单直接但不高效,因为它会反复生成新数组,空间开销比较大。它在面试中拿来演示思路可以,真正工程里更推荐原地划分版本的快排,以避免额外空间。归并排序的优势则在于稳定性和适合链表及外部排序。时间复杂度都是 O(n log n),但快排平均性能最好,归并排序则可保证最坏 O(n log n)。

如果面试官要你手写“数组中的逆序对”,就需要归并排序思维。统计逆序对的思路是在合并前后两个有序子数组时,发现右侧元素小于左侧元素时,就可以直接累计右侧元素之前跨过一个左侧元素的逆序数。

标准二分的循环不变量

二分查找的边界问题最让人头痛。只需要牢牢抓住一个原则:保持left指向“满足条件区间的左边一个位置”,right指向“满足条件区间的右边一个位置”,循环条件用left < right,最后返回 left 或 right。不同写法会有差异,但关键是先想清楚“你要查找的到底是什么性质”。

def binary_search(nums, target): left, right = 0, len(nums) - 1 while left <= right: mid = (left + right) // 2 if nums[mid] == target: return mid elif nums[mid] < target: left = mid + 1 else: right = mid - 1 return -1

这套写法的循环不变量是“target 如果存在,一定在 [left, right] 这个闭区间里”。每次排除一半,区间不会出现遗漏。换一种写法,左闭右开的区间则对应不同的循环条件,切忌几种写法混用导致 mid 更新错误。

二分答案的实战使用场景

二分法不只能用于“在有序数组中找值”,还能用于“在可行域中找最优解”。典型的场景包括“把数组分成 m 份,求最大值最小化”“在 D 天内送达包裹的能力”。这类题的特征是:解空间是单调的,求最小值或最大值的可行判断可以用贪心快速验证。做法是 binary search over answer:对答案进行二分,然后写 check 函数判断当前答案是否可行。

def ship_within_days(weights, days): def can_ship(capacity): current_sum, count = 0, 1 for w in weights: if current_sum + w > capacity: count += 1 current_sum = 0 current_sum += w return count <= days left, right = max(weights), sum(weights) while left < right: mid = (left + right) // 2 if can_ship(mid): right = mid else: left = mid + 1 return left

这套“二分答案 + 贪心 check”的组合在高频题中反复出现。掌握了它,等于掌握一类问题的通用解法。很多看起来与二分无关的题,只要解空间单调且可快速验证,都可以套这套模板。我在牛客上看到不少公司笔试题就是这种类型,实用性非常高。

实战经验:牛客笔试与 LeetCode 刷题节奏

这一部分说说我踩过坑之后的节奏安排,包括时间分配、复盘方式和心态管理。很多同学刷题刷了很久却感觉进步缓慢,基本都是节奏问题。

笔试环境的特殊性与踩坑记录

牛客笔试和 LeetCode 有一个显著区别:牛客通常是 ACM 模式,需要自己处理输入输出。有些同学在 LeetCode 上刷得很顺,一到牛客笔试,连输入格式都读不明白。剑指 Offer 模式的题目通常已经封装好函数,但牛客的热门模拟卷经常是 ACM 模式。你需要掌握sys.stdin.read()、input().split()、map(int, ...)这些基础输入技巧。

另一个常见问题是类名和函数名。牛客的题目有些平台要求类名必须是 Main,public static void main 入口。已经编译报错过的人会懂这种无语。建议首次使用某个平台时,先做一道最简单的“A+B 问题”,把输入输出、依赖库、包名、类名这些环境因素全部踩通,再开始正式刷题。

时间分配的黄金比例

我试过两种节奏。第一种是每天刷 10 题但不总结,月底回头一看全忘了。第二种是每天 3 题加 1 道复习,月底能清晰记得每类题的模板。个人经验是后者的效率远超前者。推荐节奏比较简单:

  • 每天 2 道新题 + 1 道旧题回顾。
  • 按标签刷,一轮专门做“数组 + 双指针”,一轮“动态规划”,一轮“回溯 + 搜索”。
  • 每周挑 2 道做过的题,从零开始写一遍,模拟面试白板状态。
  • 每天复盘时间控制在 30 分钟以内,写下“这题的题型关键词”和“核心模板的链接”。
复盘模板怎么用最有效

我会用一个简单的复盘表格。字段包含:日期、题目名、核心思维、模板难度、首次 AC 耗时、是否需要重刷。如果一道题首次 AC 耗时超过 40 分钟,就标记为“需要重刷”。重刷时间间隔是 3 天后、7 天后、21 天后。这样能对抗遗忘曲线。

在“核心思维”这一栏,我不写题目描述,只写一句抽象的“这类题的做法”。比如“看到有序数组找目标 → 二分”“看到字符串子串 → 滑动窗口”“看到最优解 + 重叠子问题 → 动态规划”。这些抽象描述才是复盘的真正价值,比抄一遍题解有用得多。

面试现场的算法表达技巧

面试和笔试的考察点不太一样。笔试考察结果,面试更考察思考过程。我见过不少同学一上来就写代码,结果被追问两个细节就卡住了。正确的顺序建议是:先口述思路、说复杂度,再开始写代码。写的时候边写边讲变量作用。写完主动跑一个测试用例验证。这一套流程走完,面试官对你的工程意识和沟通能力的印象会远高于“闷头写完 bug-free 但说不出为什么”的候选人。

需要避免的现场答题误区

第一误区:为了展示自己会更多解法,开篇先说一个复杂的、不常见的解法。面试官每题可用的时间有限,建议先答稳妥的解法,再补充优化思路。第二误区:不会的时候沉默。面试最常见的考察方式就是“遇到问题时的应对方法”,如果完全没有头绪,可以先把暴力解法说出来,再分析哪里能优化。暴力解法也有效表明你在工作。哪怕你没优化出来,也比一声不吭强很多。第三误区:写完不验证。手写代码不算运行很难发现问题,但主动走一个简单的例子能帮你发现很多低级错误,同时也向面试官证明你有测试意识。

结语与限时模板分享

这套刷题体系用了大半年,我的 LeetCode AC 从 80 题涨到 300 题以上,牛客模拟卷的通过率也从 30% 提升到 70% 以上。我没有特别好的天赋,只是把刷题变成了一件有反馈、有节奏的事。最后给出一份浓缩模板清单:

题型核心方法时间复杂度
数组/字符串子串滑动窗口O(n)
两数之和类哈希表O(n)
链表反转/环检测迭代/快慢指针O(n)
多重循环优化排序 + 双指针O(n log n) + O(n)
背包问题一维 DP 滚动数组O(n*C)
最长上升子序列贪心 + 二分O(n log n)
全排列/组合回溯 + 剪枝依题意
最值可行性问题二分答案 + 贪心 checkO(log(区间) * check)

我个人在实际操作中的体会是,最笨的办法往往最稳。把算法模板理解到位之后,先用最直白的思路把题 AC 掉,再考虑如何优化。这样不仅能保证正确性,还能在优化的过程中学到更多东西。刷题不是为了炫技,而是为了在真正的面试中能稳定输出,这比什么都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询