☰
算法题打卡第9天:剪枝、二分、KMP、BFS四题精讲
2026/10/2 0:01:02 网站建设 项目流程

我不是那种一上来就列一堆题号的人,但今天是《算法题打卡》的第 9 天,我确实想先聊两句“选题逻辑”。坚持到第九天,最大的变化不是手速快了,而是看到热搜词里“暴力枚举算法”“剪枝算法”“kmp算法”“贪心算法”这些词的时候,已经能下意识把它映射到具体题型上:枚举配剪枝、贪心配二分、字符串配前缀函数。这一天我特意挑了四道题,对应几个高频考点:组合总和、在 D 天内送达包裹、KMP 手写匹配、二叉树层序遍历。它们都不是偏题怪题,但每一道都能把“会背模板”和“真懂原理”的人区分开。

如果你正在刷题初期,这篇就当一份带思路的打卡记录看;如果你已经刷了一阵子,里面有几处边界处理和复杂度判断的经验,是我踩过坑之后才学到的。

1. 第9天选题思路:从热搜词里筛出四道题

1.1 四道题到底对应哪些算法热词

我每次打卡前会先看一眼当天的热词列表,不是为了追热点,而是看大家对哪类问题讨论得最多。这一天的高频词里,算法相关的基本都集中在枚举、剪枝、贪心、字符串匹配、排序、数据结构这些基础方向上。我从中挑了四道题,一一对应:

题目考点对应热词为什么选它
LeetCode 39 组合总和回溯 + 剪枝暴力枚举算法、剪枝算法回溯模板人人会背,剪枝细节决定能不能过
LeetCode 1011 在 D 天内送达包裹的能力二分答案 + 贪心模拟枚举算法、贪心算法非典型二分的代表,check 函数是灵魂
LeetCode 28 实现 strStrKMP 字符串匹配kmp 算法面试高频,背模板容易讲原理难
LeetCode 102 二叉树的层序遍历BFS + 分层数据结构与算法队列快照 size 的技巧很实用

选择的标准很简单:这四道题不是我随机翻的,而是针对我最近一周暴露出的薄弱点刻意安排的。回溯我经常写完就跑不过大样例,二分我只会写有序数组查找,字符串匹配停留在理论,树的层序遍历以前我习惯用 null 分隔符,思路不够通用。一天集中打这四个补丁,效率比漫无目的地刷二十道要高得多。

1.2 我给自己定的选题标准

刷题打卡这事,最难的不是做题,是坚持。而坚持的前提是“每天的任务可完成”。我给自己定过三条选题规则,现在已经变成固定动作:

  1. 每类题先做最经典的模板题,再做变种,不直接挑战高难度综合题。
  2. 当天选的题必须能在 30 分钟内写出完整代码,超过 30 分钟就看题解,不硬耗。
  3. 每道题必须记录一处“之前会忽略的细节”,哪怕只是一句话,也要写进当天的笔记。

这三条规则帮我避免了两个常见陷阱:一是好高骛远,打开困难题死磕一晚上,第二天就放弃;二是刷数量不刷质量,一天过十道简单题,回头全忘了。第九天回头看,真正有用的恰恰是那些被记录下来的细节,比如回溯里传 i 还是传 i+1,二分里取中位数是左偏还是右偏,KMP 失配时到底回退到哪。

2. 第一题 组合总和:回溯模板好写,剪枝技巧才决定成败

2.1 题目与一个容易被忽略的前提

LeetCode 39 的原题是这样的:给一个无重复元素的整数数组 candidates 和一个目标数 target,找出所有可以使数字和为 target 的组合,candidates 里的数字可以无限制重复被选取。

这题第一眼确实像暴力枚举:把所有可能的组合都试一遍,等于 target 就记录下来。但直接枚举会遇到两个问题:一是组合的“顺序不同算同一种”,如果不加控制,会出现 [2,3] 和 [3,2] 都进答案;二是组合数量可能爆炸,如果数组里有 1,那 target 多大就要递归多少层。

第一个问题靠 dfs 的 startIndex 参数解决,第二个问题靠排序剪枝解决,但前提是——先把数组排序。我见过有人不排序也把题 AC 了,靠的是在结果里去重,或者提前判断剩余 target 是否小于当前数来返回,但那样代码会绕,而且容易漏。

2.2 C++ 代码实现与边界细节

class Solution { public: void dfs(vector<int>& candidates, int target, int start, vector<int>& path, vector<vector<int>>& ans) { if (target == 0) { ans.push_back(path); return; } for (int i = start; i < candidates.size(); ++i) { if (candidates[i] > target) break; // 排序后的核心剪枝 path.push_back(candidates[i]); dfs(candidates, target - candidates[i], i, path, ans); path.pop_back(); } } vector<vector<int>> combinationSum(vector<int>& candidates, int target) { sort(candidates.begin(), candidates.end()); vector<vector<int>> ans; vector<int> path; dfs(candidates, target, 0, path, ans); return ans; } };

几个细节:

  • dfs里传的是i而不是i+1,因为题目允许同一个数字重复选取。如果题目改成每个数字只能用一次,那就传i+1,比如 LeetCode 40。
  • if (candidates[i] > target) break;这行只有在数组有序的情况下才成立。因为后面所有的数都更大,当前数如果已经大于剩余 target,后面不可能再有可行解,直接终止循环即可。
  • 剪枝发生在这里,但这不是唯一的剪枝方向。如果数字可以重复,另一个常见剪枝是提前记录前缀和,判断剩余位置上即使全选最小数也无法凑齐 target,就直接回溯。对于数据范围更大、更接近现实的题目,这个剪枝会更有效。

2.3 枚举、剪枝、去重:三件事的边界感

这道题给我的最大启发是,很多人把“暴力枚举”和“剪枝优化”当成两个对立面,其实它们是同一个过程。暴力枚举是先定好搜索空间,剪枝是把搜索空间中明显没希望的分支砍掉。写的时候先保证枚举部分是正确的,再考虑优化,这个顺序一定不要反。否则一旦剪枝写错,连正确性都保不住。

另一个值得讲的是去重。这道题因为数组无重复元素,所以不存在“同一层选了相同的数字导致重复组合”的问题。但 LeetCode 40 里有重复元素,就需要在 for 循环里加一行:

if (i > start && candidates[i] == candidates[i - 1]) continue;

意思是同一层递归中,如果当前数字和上一个数字相同,直接跳过。这个去重逻辑配合排序,非常经典,能延伸出一整类“组合去重”的题。面试时,面试官很喜欢在这道题后面跟一句“如果数组里有重复元素怎么办”,本质上就是在考这个。

3. 第二题 在 D 天内送达包裹:二分答案为什么比直接模拟更好写

3.1 先理解 check 函数,再谈二分

LeetCode 1011 是这样的:传送带上的包裹 weights 必须按顺序运走,不能打乱顺序,要求在第 days 天内运完,求满足条件的最小运载能力。

我见过不少人第一反应是直接模拟,一天一天地装,尝试让运载能力符合要求。但运载能力的取值区间很大,直接枚举太慢。正确的做法是二分答案,把“求最小可行值”转换成“判断某个值是否可行”。

关键在 check 函数的写法:

bool check(vector<int>& weights, int days, int cap) { int need = 1, cur = 0; for (int w : weights) { if (w > cap) return false; // 单个货物都放不下,直接不可行 if (cur + w > cap) { need++; cur = w; } else { cur += w; } } return need <= days; }

这里有一个我踩过的坑:如果单个包裹重量大于当前测试的运载能力,应该直接返回 false,而不是假装把它装进新的一天。早期我写 check 的时候没加这行判断,结果当 days 恰好很大时,容量比最大包裹小也会被判成可行,导致二分结果错误。这个问题非常隐蔽,因为输入数据一般不会让 days 大得太离谱,但如果把边界值卡死,就能测出来。

3.2 左闭右开还是左开右闭?防死循环的模板

二分答案的写法有很多流派,我自己常用的是“左开右闭 + l + 1 < r”的模板,不容易死循环:

int shipWithinDays(vector<int>& weights, int days) { int maxW = 0, sumW = 0; for (int w : weights) { maxW = max(maxW, w); sumW += w; } int l = maxW - 1; // 严格小于最小可行值 int r = sumW + 1; // 严格大于最大可行值 while (l + 1 < r) { int mid = l + (r - l) / 2; if (check(weights, days, mid)) r = mid; else l = mid; } return r; }

边界的设计逻辑是这样的:

  • 运载能力至少要能装下最重的单个包裹,所以最小可行值一定 ≥ maxW。
  • 运载能力取 sumW 时,一天就能全部运完,所以它一定可行。
  • 我把左边界取成 maxW - 1,保证左边界严格不可行;右边界取成 sumW + 1,保证右边界严格可行。

while 循环用l + 1 < r而不是l < r,这样 mid 永远不会等于 l,也就不会出现l = mid导致的死循环。最后 r 停在最小可行值,直接返回。

这种写法比常见的while (l < r)版本更容易讲清楚,也更适合面试时手撕。当然,如果习惯了l < r + r = mid / l = mid + 1的模板,也没问题,关键是明白每个边界值的含义,而不是死记。

3.3 复杂度计算与面试中的加分表达

二分答案的复杂度分两部分:每次 check 需要遍历整个数组,O(n);二分次数是 O(log(sumW)),其中 sumW 是所有包裹重量之和。总复杂度 O(n log(sumW)),在 n 是 10^4 级别的题里完全够用。

面试时如果你能主动说清这个复杂度,会让面试官觉得你有全局观。我习惯在写代码前先讲一句:“这个问题的答案是单调的,运载能力越大越容易完成任务,所以可以用二分答案。check 函数每次 O(n),总共 O(n log sumW)。”这样一开口,思路就已经清晰了。

这道题的变种很多:LeetCode 875 爱吃香蕉的珂珂、LeetCode 410 分割数组的最大值,核心都是“最大值最小化”或者“最小值最大化”这种单调性极强的问题。刷完 1011 以后,我建议把 875 和 410 连着做一遍,会把这类题目彻底打通。

4. 第三题 手撕 KMP:与其背模板,不如从失配推导 next

4.1 从暴力匹配到快速失配跳转

KMP 是那种“看起来懂,一写就废”的算法。暴力匹配是主串指针往前走,模式串每次失配都从头开始,最坏复杂度 O(n * m)。KMP 的核心优化是把模式串的“已知匹配前缀”利用起来,失配时不要从头开始,而是跳到已经匹配上的最长前缀后面继续比。

这个“已经匹配上的最长前缀”怎么求?就是 next 数组。我用的定义是前缀函数:next[i]表示模式串p[0..i]这个子串中,最长相等前后缀的长度。

4.2 C++ 代码:next 数组构建与匹配主流程

vector<int> buildNext(const string& p) { int n = p.size(); vector<int> next(n, 0); for (int i = 1, j = 0; i < n; ++i) { while (j > 0 && p[i] != p[j]) j = next[j - 1]; if (p[i] == p[j]) ++j; next[i] = j; } return next; } int strStr(string haystack, string needle) { if (needle.empty()) return 0; vector<int> next = buildNext(needle); for (int i = 0, j = 0; i < haystack.size(); ++i) { while (j > 0 && haystack[i] != needle[j]) j = next[j - 1]; if (haystack[i] == needle[j]) ++j; if (j == needle.size()) return i - j + 1; } return -1; }

构建 next 的过程很像动态规划:i 从 1 开始扫描,j 保存当前已经匹配上的前缀长度。如果当前位置的字符相等,j 加一;否则 j 不断回退到next[j-1],直到匹配或者 j 等于 0。这个“回退”和主串匹配中的失配跳转是同一个逻辑,理解了它就理解了 KMP。

匹配阶段更简单,主串指针 i 从头到尾只扫一遍。当haystack[i]和needle[j]相等时,j 加一;失配时 j 回退。当 j 等于模式串长度时,说明找到了完整匹配,返回i - j + 1。

4.3 实测中容易踩的两个坑

坑一:网上的老模板喜欢把 next 数组整体右移,next[0] = -1,失配时j = next[j]。这个模板也能 AC,但面试如果让你解释原理,你会讲不清“为什么整体右移”“为什么 next[0] 是 -1”。我建议用前缀函数版本,逻辑自洽,从定义到代码都能对得上。

坑二:拿小样例手推 next 数组时,很容易算错最长相等前后缀。我当时的解决办法是每次刷完 KMP,固定拿一个例子完整推导一遍,比如模式串ABABCABAB。它的 next 数组是[0,0,1,2,0,1,2,3,4],把“最长相等前后缀”这个定义落实到具体字符串上,手推两步之后就再也忘不掉了。

KMP 的代码量不大,但细节多。如果哪次手写时卡住了,我建议按这个顺序回忆:第一,next[i] 定义是啥;第二,构建时 i 从 1 开始,j 跟随最长前缀;第三,失配时 j 回退到哪里;第四,主串匹配时 i 永远不回溯。记牢这四条,KMP 基本不会写错。

5. 第四题 二叉树的层序遍历:BFS 快照 size 法的意外收获

5.1 为什么不用 null 分隔符

LeetCode 102 要求按层输出二叉树的节点值,比如[ [3], [9,20], [15,7] ]。

最直观的想法是 BFS 队列加 null 分隔符:每遇到一个 null 就说明一层结束。但这个方法有个尴尬的地方:如果某一层的最后一个节点恰好有右子树但左子树为空,队列里就会出现多余的 null,导致分层错误。用快照 size 法就完全不存在这个问题。

vector<vector<int>> levelOrder(TreeNode* root) { vector<vector<int>> ans; if (!root) return ans; queue<TreeNode*> q; q.push(root); while (!q.empty()) { int sz = q.size(); vector<int> level; for (int i = 0; i < sz; ++i) { TreeNode* node = q.front(); q.pop(); level.push_back(node->val); if (node->left) q.push(node->left); if (node->right) q.push(node->right); } ans.push_back(level); } return ans; }

核心就一行:进入循环时先取int sz = q.size(),这表示当前队列里恰好是上一层的节点数。然后 for 循环只处理这 sz 个节点,过程中新加入的节点是下一层的,不会干扰当前层。

5.2 DFS 也能层序,为什么面试优先选 BFS

有人可能疑惑,层序遍历是不是只能用 BFS?其实 DFS 也可以,递归时记录深度 depth,把当前节点的值写进res[depth]即可。但面试里我建议优先用 BFS 快照 size 法,原因是它天然符合“逐层扫描”的定义,代码结构清晰,而且很容易扩展到锯齿形层序遍历:LeetCode 103 只要在偶数层把结果 reverse 一下就行。

这道题还有一个隐形考点:边界的处理。很多人在if (!root) return ans;这一步漏写,导致队列入空后访问空指针。每道树的题我都会先确认 root 是否为空,这也算是肌肉记忆了。

5.3 层序遍历可以延伸出什么

层序遍历不是孤立的知识点,它可以用来做求二叉树最大宽度、判断完全二叉树、输出二叉树右视图等变种题。核心都是 BFS 分层,区别只在于每层循环里处理的数据不同。刷完 102 后,我顺手把 103 锯齿形层序、199 右视图都做了一遍,基本是一套模板改几行的事。这种“一题带一题”的刷法,比每次单打独斗效率高得多。

6. 复杂度那点事:什么时候写 O,什么时候写 Θ

6.1 热词这个问题到底在问什么

热词里有一条“计算算法复杂度时什么时候用 o 什么时候用 θ”,这是很多初学者的困惑。O 表示渐近上界,Ω 表示渐近下界,Θ 表示上下界同阶,也就是精确阶。但工程和面试里,大家几乎都写 O,而且经常把它当成“最坏情况复杂度”来用。

严格来说,这是不严谨的。举几个例子:

  • 遍历数组的次数是 n,说复杂度是 O(n) 没问题,但更精确的说法是 Θ(n),因为访问次数既是上界也是下界。
  • 二分查找的最坏情况执行 log n 次比较,说 O(log n) 是描述上界,没问题;但如果明确知道最坏就是 log n,用 Θ(log n) 更准确。
  • 快速排序平均情况比较次数大约是 n log n 量级,工程上写成 O(n log n),这里的 O 实际上承担了 Θ 的角色,因为大家默认讨论的是平均、最坏等多重情况叠加后的一个宽松结论。

所以在算法题交流中,“O”已经被泛化为“大概这个量级”,你不会因为用了 O 而被批评。但如果是写论文或者做严谨的分析报告,该用 Θ 的时候别写 O。判断标准很简单:你给出的界是否紧?如果紧,想强调准确,就用 Θ;如果只是描述“不会超过这个量级”,用 O。

6.2 一个实用判断技巧:看数据规模反推算法

刷题时比纠结 O 和 Θ 更重要的是,先通过数据范围判断预期复杂度。我列了个常用参考表:

数据规模可接受复杂度
n ≤ 10O(n!) 或 O(2^n)
n ≤ 20O(2^n) 或 O(n * 2^n)
n ≤ 10^3O(n^2)
n ≤ 10^5O(n log n)
n ≤ 10^6O(n) 或 O(n log n) 且常数小

拿到题目先看 n 的范围,再倒推自己该写什么复杂度。比如 n 是 10^5,你还写 O(n^2),大概率要超时;这时候就要考虑排序后配合二分、或者双指针、或者哈希表。这种“先算复杂度再动手”的习惯,能让你的代码在写之前就赢了一半。我在第 9 天打卡中反复用到了这个判断:二分答案的 O(n log sumW)、KMP 的 O(n + m)、层序遍历的 O(n),都属于能轻松通过中大规模数据的选择。

7. 打卡第9天的节奏管理:刷完题不等于吃透题

7.1 三遍复盘法

第 9 天最大的心得是:做完题之后,怎么复盘比怎么做题更重要。我现在的流程是标准三遍:

第一遍,闭卷写。卡住 15 分钟就果断看题解,不硬憋。看题解不是抄代码,而是看别人的思路卡在哪里、用了什么我没见过的技巧。

第二遍,隔 2 小时再写一遍。这个间隔时间不长不短,刚好能忘掉刚才的瞬时记忆。如果能独立写出来,说明真的吸收了;如果又卡住,说明刚才只是“看懂”,还没变成自己的。

第三遍,睡前用自然语言把思路讲一遍。比如这道题:“组合总和是回溯加排序剪枝,循环里传 i 是因为允许重复选取,传 i+1 就是不能用重复元素。”能把思路讲清楚,才是真正吃透了。

7.2 一个可持续的打卡模板

我整理了一个适合每天用的打卡笔记模板,你可以直接抄:

日期题目考点用时复杂度一句话心得
第9天39回溯剪枝22minO(2^n) 最坏排序后 break 比 continue 果断得多
第9天1011二分答案35minO(n log sumW)单个包裹大于容量要直接 return false
第9天28KMP40minO(n + m)前缀函数定义比整体右移模板更好讲
第9天102BFS 分层12minO(n)先取快照 size,再处理本层

表格的价值在于,它逼你每天输出一个“一句话心得”。哪怕是“今天这道题很简单”这种话,写下来也会让你对当天的状态有感知。如果连续几天都只能写“没看懂”,那就说明选题难度高了,需要回调。

7.3 最近热词给我下周的选题线索

刷完这四道题后,我翻了翻当天的热搜词,发现“排序算法”相关的讨论特别多:冒泡排序、归并排序、堆排序、快速排序、C++ STL 排序。这正好是我计划的下一块拼图。

下周我准备开一个“排序算法全家桶”专题,不只看复杂度,而是把冒泡、选择、插入、快排、归并、堆排全部手写一遍,再配合 STLsort的底层原理做对比分析。类似的主题在热词里还有“数据结构排序算法”和“c++分治算法”,感觉可以合并成一个大章节来写。如果你也刷到这里,建议别只追新算法,先把基础专题轮一遍,收获会大得多。

说实话,打卡到第 9 天,我并不觉得自己多了不起,只是慢慢地养成了一个习惯:拿到一道题,先想数据范围能不能承受,再想这道题考的是哪个专题,最后才是动笔写代码。这个顺序如果从头就立住,后面的刷题效率和心态都会好很多。下一步,我准备把排序全家桶整理出来,到时候接着聊。

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

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

立即咨询