ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

算法模板练习指南:二分、滑动窗口与动态规划核心解法

算法模板练习指南:二分、滑动窗口与动态规划核心解法 1. 为什么刷题容易白刷模板练习解决的核心问题1.1 刷题量上去了面试还是卡壳我见过很多准备算法面试的朋友包括几年前的我都会陷入同一个怪圈LeetCode 刷了两三百道Easy、Medium 见了不少做题时也好像见过类似的但一到面试现场面对一道从没见过的变种题思路就是出不来。更尴尬的是面试官稍微追问一句你这个解法的时间复杂度为什么是 O(n log n)很多人就开始含糊了。问题出在哪刷题的数量和题目的熟练度并不能直接转化成解题能力。你记住的是每一道具体题目的答案而不是题目背后那一套可以迁移的解题骨架。我把这个阶段叫做背题阶段它和模板练习阶段最大的区别在于背题是在记忆结果模板练习是在提炼过程。我记得特别清楚的一次是在准备一家公司的二面时出了一道寻找旋转排序数组中的最小值。这题我明明刷过也知道要二分但当时脑子里的二分模板全是在有序数组中查找某个值的那种写法遇到部分有序的情况边界条件怎么调都调不对。最后虽然磕磕绊绊写出来了但明显暴露出了我对二分查找理解不深的问题。那次之后我才下决心把基础算法的模板系统性地整理了一遍。1.2 模板的本质把变量和不变式分离开说一个我一直强调的观点算法模板不是让你背代码而是帮你把一道题目拆成不变的部分和可变的部分。以二分查找为例。不管题目是查找目标值、查找第一个大于等于 target 的位置还是查找旋转数组的最小值模板里不变的部分永远是维护一个搜索区间、计算中点、根据条件收缩区间。可变的部分只有一个——收缩条件是什么。当你把模板练熟之后做新题时你不需要从零开始推演整个算法你只需要回答一个问题这道题里我应该在什么条件下把区间往左收、什么条件下往右收。用更直白的话说模板就是给你的解题过程提供一个默认骨架。就像写作文要先用总—分—总结构打底一样骨架本身不产生内容但它能保证你的思路不散。算法题也一样排序、二分、双指针、滑动窗口、DFS、BFS、回溯、动态规划这些基础算法一共就那么十几个大类每一类你手上有一个信得过的模板遇到新题时的第一反应就不再是这题我不会而是这题属于哪个模板的变体。1.3 哪些题适合模板化哪些不适合不是说所有题都要硬套模板。我总结过一个大致的划分供你参考题目类型是否适合模板化原因排序、二分查找非常适合边界条件和循环不变量完全固定双指针、滑动窗口非常适合窗口伸缩逻辑高度套路化DFS/BFS、回溯非常适合搜索框架统一只需改状态扩展逻辑动态规划部分适合状态定义要自己想但填表流程可模板化贪心、数学技巧类不太适合证明依赖直觉和积累模板收益有限冷门数据结构题不适合出现的概率低投入产出比差我见过一些人走到另一个极端连贪心题都要强行总结模板结果总结出来的东西根本没法迁移。基础算法模板练习的核心价值是覆盖面试里出现频率最高的那批题型而不是试图用模板包裹所有题目。下面我会按我自己的整理顺序把最值得练的几套模板逐个拆开讲。2. 从零搭建第一套算法模板排序与二分查找2.1 快速排序模板分治思想的默认实现很多人的排序是从调用sort()函数开始的但面试里手写排序的概率虽然不高理解排序背后的分治思想却很重要因为快排和归并里面藏着面试题喜欢考的两个关键点分区逻辑和递归边界。先给一个我常用的快排模板def quick_sort(nums, left, right): if left right: return pivot nums[(left right) // 2] i, j left, right while i j: while nums[i] pivot: i 1 while nums[j] pivot: j - 1 if i j: nums[i], nums[j] nums[j], nums[i] i 1 j - 1 quick_sort(nums, left, j) quick_sort(nums, i, right)这个模板和网上很多版本不太一样我特别说明几个点。第一pivot 取中点而不是取第一个或最后一个元素这样可以避免在数组已经有序这种极端情况下退化成 O(n²)。第二内层两个 while 用的是和而不是和这样能保证相等的元素不会反复交换让左右两边更均衡。第三递归的边界是(left, j)和(i, right)这个区间的划分方式和i j的交换逻辑是配套的不能乱改。实际练习的时候我建议你在白纸上手动跑一遍[5, 2, 3, 1, 4]这个例子把每一轮 i、j 的移动轨迹画出来。很多人觉得快排难难就难在为什么递归边界有时是 j 有时是 i。当你手动推完一轮就会发现pivot 被交换之后i 左侧都是小于等于 pivot 的j 右侧都是大于等于 pivot 的所以两个递归区间天然就是[left, j]和[i, right]。2.2 归并排序模板除了排序还能求逆序对归并排序的模板价值在于它的合并过程这个过程不仅是排序还是很多区间统计类题目的基础最典型的就是求逆序对数量。def merge_sort(nums, left, right): if left right: return 0 mid (left right) // 2 count 0 count merge_sort(nums, left, mid) count merge_sort(nums, mid 1, right) # 合并两个有序区间 temp [] i, j left, mid 1 while i mid and j right: if nums[i] nums[j]: temp.append(nums[i]) i 1 else: temp.append(nums[j]) count mid - i 1 # 关键左区间剩余元素都大于 nums[j] j 1 while i mid: temp.append(nums[i]) i 1 while j right: temp.append(nums[j]) j 1 nums[left:right 1] temp return count这里唯一需要理解透的就是count mid - i 1这一行。当右区间的nums[j]比左区间的nums[i]小时说明从 i 到 mid 的所有元素都比nums[j]大这些元素都和nums[j]构成逆序对数量正好是mid - i 1个。这个技巧在计算右侧小于当前元素的个数这类 LeetCode 题里会直接用到你如果只会调sort()面对这类题就只能用树状数组硬写复杂度没优势代码还复杂得多。2.3 二分查找的三种模板与边界陷阱二分是面试里最容易被细节打败的基础算法。我见过太多人在循环条件上用left right还是left right之间反复横跳就是因为没有建立一套统一的循环不变量。我自己的做法是只记一套主模板然后用查找左边界和查找右边界两个小变体去覆盖所有场景。主模板如下def binary_search(nums, target): left, right 0, len(nums) - 1 while left right: mid left (right - left) // 2 if nums[mid] target: return mid elif nums[mid] target: left mid 1 else: right mid - 1 return -1这个模板的循环不变量是target 如果存在一定在闭区间[left, right]内。每次比较后left mid 1或right mid - 1都能保证区间严格缩小所以循环一定终止。但实际面试题里更常考的是查找第一个大于等于 target 的位置也就是lower_bound这就是另一个模板def lower_bound(nums, target): left, right 0, len(nums) while left right: mid left (right - left) // 2 if nums[mid] target: right mid else: left mid 1 return left注意这里的循环条件变成了left right区间变成了左闭右开的[left, right)而且找到了目标也不返回而是继续把right收到mid。这就是查找左边界和查找指定值在写法上的本质区别前者关心的是第一个满足条件的位置后者关心的是有没有这个值。你在练的时候一定要在纸上各画三个例子目标在数组中间、目标比所有元素都大、目标不存在但落在区间内把这三种情况跑通二分才算真正过关。提示二分查找里mid left (right - left) // 2避免写成(left right) // 2主要是防止两个很大的整数相加溢出。Python 里其实不容易溢出但养成这个习惯没有坏处。3. 双指针与滑动窗口两类高频模板的边界处理3.1 相向双指针有序数组的经典套路双指针分为两类一类是相向而行典型题目就是 LeetCode 的两数之和 II和盛最多水的容器另一类是同向而行就是滑动窗口。两类的模板差异很大先说相向双指针。def two_sum_sorted(nums, target): left, right 0, len(nums) - 1 while left right: s nums[left] nums[right] if s target: return [left 1, right 1] elif s target: left 1 else: right - 1 return []这个模板的前提是数组有序。当nums[left] nums[right] target时说明当前右指针指向的是数组最大值左指针再往右走一步和会变大反之亦然。每一步都排除掉一个不可能的位置所以时间复杂度是 O(n)空间 O(1)。这种根据单调性排除不可能区间的思路是双指针模板最核心的价值。我在练习中还发现一个容易忽略的点相向双指针不只适用于有序数组只要问题的单调性成立就能用。盛最多水的容器这个题里数组本身无序但移动较短的那端这个决策是有单调性保证的——移动短板可能让面积变大移动长板只会让面积变小或不变所以双指针依然成立。练这种题的时候不要只记模板要把为什么可以这样移动的理由写下来这才是模板真正内化的标志。3.2 同向双指针与滑动窗口模板滑动窗口是面试里出现频率最高的题型之一覆盖了无重复字符的最长子串、最小覆盖子串、长度最小的子数组等一系列基础题。它本质上是一个维护可变长度窗口的模板def sliding_window(s, k): n len(s) left 0 window {} # 或者用 Counter result 0 for right in range(n): # 1. 扩展窗口加入 s[right] window[s[right]] window.get(s[right], 0) 1 # 2. 收缩窗口当窗口不满足条件时移动 left while not_meet_condition(window): window[s[left]] - 1 if window[s[left]] 0: del window[s[left]] left 1 # 3. 此时窗口满足条件记录/更新结果 result max(result, right - left 1) return result这个模板的骨架就是扩展—收缩—记录三步任何滑动窗口题都逃不出这个流程。你需要变的部分只有两个window里存什么以及not_meet_condition怎么定义。以无重复字符的最长子串为例条件就是窗口内所有字符的出现次数都等于 1一旦某个字符出现次数大于 1就说明有重复需要收缩。而以长度最小的子数组为例窗口里存的是数值和条件是当前窗口的和大于等于 target 就收缩并记录长度。你会发现只要把这两处填进模板题目就解完了。3.3 窗口收缩时机模板中最容易写错的一行我在带人刷题时发现滑动窗口模板最容易写错的不是扩展而是收缩那部分的谁先谁后。这里有一个关键原则先更新窗口数据再移动 left 指针。很多人会写成先移动 left 再减去字符次数导致窗口里的数据和实际区间对不上。另外一个容易错的是收缩循环里的记录结果位置——是在收缩前记录还是收缩后记录这取决于你要求的是满足条件的最小窗口长度还是满足条件的最长窗口长度。如果题目要求最小覆盖子串这类找最短的你需要在找到满足条件的窗口时先记录长度再继续收缩因为收缩可能得到更短的结果。如果要求无重复最长子串这类找最长的你应该在收缩完成后记录因为收缩前窗口是无效的。这两个方向反了很多题就是过不了。# 找最短先记录再收缩 for right in range(n): window.add(s[right]) while is_valid(window): result min(result, right - left 1) # 记录有效状态 window.remove(s[left]) left 1 # 找最长先收缩再记录 for right in range(n): window.add(s[right]) while not is_valid(window): window.remove(s[left]) left 1 result max(result, right - left 1) # 收缩后才是有效状态这两段代码我建议你单独练熟然后在滑动窗口最大值这类进阶题里继续复用。模板的价值就体现在这里——你不需要重新想整个流程怎么设计只需要调到窗口里的单调队列结构即可。4. 图论与搜索模板DFS、BFS与回溯4.1 DFS模板遍历与路径记录深度优先搜索是所有图论题的基础。面试里考 DFS 的题目基本绕不开岛屿数量、矩阵中的路径、全排列这几类。它们的核心模板是一致的def dfs(grid, i, j, visited): # 1. 越界或非法状态返回 if i 0 or i len(grid) or j 0 or j len(grid[0]) or grid[i][j] ! target: return # 2. 标记已访问避免重复遍历 if (i, j) in visited: return visited.add((i, j)) # 3. 递归处理四个方向的邻居 for di, dj in [(1, 0), (-1, 0), (0, 1), (0, -1)]: dfs(grid, i di, j dj, visited)这个模板的核心变量只有一个grid[i][j] ! target这个条件。岛屿数量题里target是1矩阵路径题里target是当前需要的字符。你不需要改递归结构只需要改这个判断条件。我在实际练习中有一个经验DFS 在统计连通块数量和判断是否存在路径这两类问题里区别只在于你有没有在递归返回时恢复现场。统计连通块只需要标记访问而是否存在从起点到终点的路径这类题目通常需要一个返回值True/False并且在四个方向的递归中只要有一个方向返回 True就要提前返回。4.2 BFS模板最短路径的层序遍历写法BFS 和 DFS 的区别在于搜索顺序BFS 按层推进天然适合求最短路径。模板如下from collections import deque def bfs(start, target): queue deque([start]) visited set([start]) steps 0 while queue: size len(queue) for _ in range(size): node queue.popleft() if node target: return steps for neighbor in get_neighbors(node): if neighbor not in visited: visited.add(neighbor) queue.append(neighbor) steps 1 return -1这个模板里最容易出问题的点是size len(queue)这一行。很多人会直接把队列里的元素一个个 popleft然后steps 1放错位置导致步数统计错误。正确的做法是在每层开始前取一次当前队列长度这一层的节点全部处理完步数才加一。提示BFS 的 visited 数组标记的时机应该在入队时而不是出队时。如果在出队时才标记同一个节点可能被多个邻居重复加入队列极端情况下会指数级膨胀。这是我见过的 BFS 实现里最常见的一个性能隐患。BFS 模板还有一个很重要的变体当状态空间很大、每一步的状态可以表示成多个维度时visited 要用适当的数据结构。比如打开转盘锁这个题每个状态是一个四位字符串visited 用 set 存字符串就完全没问题但如果状态是二维坐标用二维数组或 set of tuple 都可以。4.3 回溯模板组合、排列、子集的统一写法回溯是 DFS 在组合优化问题里的特殊应用模板和普通 DFS 最大的区别在于多了撤销选择这一步。LeetCode 里组合总和、全排列、子集这三类题都可以用同一套回溯模板解决def backtrack(path, start): # 记录合法结果 if is_solution(path): result.append(path[:]) # 注意拷贝 return for i in range(start, len(nums)): # 剪枝条件可选 if is_pruned(i): continue path.append(nums[i]) backtrack(path, i 1) # 组合i1 表示不重复取 path.pop() # 撤销选择这套模板能覆盖的题型包括了子集不限制长度start 从 0 开始、组合限制长度达到 k 个就记录、排列不是用 start 控制而是每次从 0 开始遍历配合 used 数组去重。排列和组合在模板上差一个参数def backtrack_permute(path, used): 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_permute(path, used) path.pop() used[i] False我在练习时的体会是回溯模板非常依赖撤销选择这一步是否写完整。很多人递归写完了忘了path.pop()或者忘了改回used[i] False结果状态被污染整个搜索树上所有分支都出错。一个实用技巧是每当你发现回溯的结果里出现重复的路径先检查撤销逻辑大概率是状态没有恢复干净。另外一个容易忽略的细节是result.append(path[:])里的[:]。如果直接append(path)list 是引用传递后面path.pop()会把你已经记录的结果也改掉。这个错误几乎每个新手都会犯一次我建议你第一次练回溯模板时故意写错看上几遍输出印象会特别深刻。5. 动态规划模板状态定义、转移方程与初始化5.1 线性DP模板从定义状态到填表动态规划是基础算法里最需要悟性的部分但它的练习流程其实是有模板的。我总结的流程永远是三步定义状态、写出转移方程、确定初始化和遍历顺序。以最经典的最长递增子序列为例def length_of_LIS(nums): n len(nums) dp [1] * n for i in range(n): for j in range(i): if nums[j] nums[i]: dp[i] max(dp[i], dp[j] 1) return max(dp)这个题的状态定义是dp[i]表示以 nums[i] 结尾的最长递增子序列长度。为什么是以 i 结尾而不是前 i 个数因为递增子序列有连续性要求只有知道结尾元素才能判断下一个元素能不能接上。这个结尾状态的思考方式是线性 DP 最重要的模板化思维。对比打家劫舍这个题状态定义又不一样了def rob(nums): n len(nums) if n 1: return nums[0] dp [0] * n dp[0] nums[0] dp[1] max(nums[0], nums[1]) for i in range(2, n): dp[i] max(dp[i - 1], dp[i - 2] nums[i]) return dp[n - 1]这个题的状态定义是到第 i 个房子时能偷到的最大金额转移方程的核心是偷不偷当前这间房两个选择。你会发现线性 DP 的模板不是代码层面的而是思维层面的先把状态拆成前 i 个元素 若干附加状态结尾/是否占用再把转移关系写成上一状态到当前状态的所有合法路径取最优。只要这个套路熟练了面对新题你至少知道从哪里开始想。5.2 背包问题的模板化写法背包问题在面试里出现频率也很高尤其是 0-1 背包和完全背包。它们的模板区别只在一个地方内层循环的遍历方向。# 0-1 背包内层倒序遍历 def zero_one_knapsack(weights, values, capacity): dp [0] * (capacity 1) for i in range(len(weights)): for c in range(capacity, weights[i] - 1, -1): dp[c] max(dp[c], dp[c - weights[i]] values[i]) return dp[capacity] # 完全背包内层正序遍历 def complete_knapsack(weights, values, capacity): dp [0] * (capacity 1) for i in range(len(weights)): for c in range(weights[i], capacity 1): dp[c] max(dp[c], dp[c - weights[i]] values[i]) return dp[capacity]为什么 0-1 背包要倒序因为dp[c - weights[i]]在倒序遍历时是上一件物品的状态正序遍历时则可能是当前物品已经被取过一次的状态后者恰好就是完全背包允许重复取用的情况。这个倒序/正序的选择是整个背包问题最核心的记忆点理解了它你就不需要死记0-1 倒序、完全正序这个口诀了。5.3 区间DP和DP优化的边界考量区间 DP 是动态规划里相对进阶的一类典型题目是最长回文子序列和戳气球。模板套路是def longest_palindrome_subseq(s): n len(s) dp [[0] * n for _ in range(n)] for i in range(n - 1, -1, -1): dp[i][i] 1 for j in range(i 1, n): if s[i] s[j]: dp[i][j] dp[i 1][j - 1] 2 else: dp[i][j] max(dp[i 1][j], dp[i][j - 1]) return dp[0][n - 1]区间 DP 最关键的是遍历顺序i从大到小j从小到大保证计算dp[i][j]时它所依赖的dp[i 1][...]和dp[...][j - 1]都已经被算出来。我在练习时吃过一次亏把i也从小到大遍历结果很多状态依赖的是还没算出来的值答案自然是错的。DP 优化这块基础阶段先不用深究知道状态压缩和斜率优化这些名词就够了。面试中如果能把 O(n²) 的解法写对、讲清楚已经能覆盖绝大多数动态规划题目。至于那些需要优化到 O(n) 的状态压缩等你把基础模板都练熟了再逐个击破也不迟。6. 我的模板练习路线图与实测避坑6.1 90天练习计划怎么排很多人的模板练习坚持不下去是因为上来就对着 LeetCode 题单硬刷刷到一半发现题目之间的关联度太低很难形成体系。我自己当年重新整理模板时的路线是分阶段的这里分享给你参考。第一阶段前两周只练写模板这件事。把排序、二分、双指针、滑动窗口这四类模板每天手写一遍不刷题只默写代码。目标是把代码写得和呼吸一样自然不需要思考。第二阶段第3到6周每类模板配 10 到 15 道基础题全部用模板去套。比如二分模板配的题就是搜索旋转排序数组、寻找峰值、爱吃香蕉的珂珂这类。这一阶段刻意不碰难题目的是验证模板的覆盖面。第三阶段第7到10周开始做模板融合题。比如滑动窗口 哈希表、二分 贪心、BFS 状态压缩这些题需要对多个模板都足够熟练才能组合使用。第四阶段最后两周回归把第一阶段默写过的所有模板再默写一遍然后对照自己的错题本看哪些模板在实际使用中最容易出边界问题。我自己的实测经验是这个路线走下来大概需要 250 到 300 道题但每一道都是带着模板去套而不是看着答案去背。效果上的区别是面试时遇到从没见过的题你能很快说出这个题本质上是区间 DP 的变体我可以用区间 DP 模板来套这种判断力才是模板练习真正带来的东西。6.2 复盘方法模板卡片的维护模板练习不能只靠刷题复盘更重要。我习惯给每一类模板建一张模板卡片卡片上写五块内容模板代码、适用条件、边界陷阱、经典例题、易混淆题型。举个例子二分查找的模板卡片上适用条件我会写问题具有单调性可以将搜索空间不断缩小边界陷阱写循环条件 left right vs left right 取决于区间定义易混淆题型写最大值最小化问题二分答案和普通查找不同需要 while left right 判断条件的写法。这种卡片不需要多么精美一张纸或者一个 Markdown 文件就够。关键是每当你刷完一道题发现这个题我的模板套不上或者套上了但边界条件写错了当场就要去更新对应的卡片。我后来翻自己的卡片发现90% 的更新集中在两个地方一是窗口收缩的时机二是二分查找的边界条件。这两处更新多了自然就形成肌肉记忆了。6.3 模板练习不是终点从模板到内化的最后一公里最后我想说一点个人体会。模板练习的真正目标不是让你成为一个只会套模板的模板机器而是让你通过这些固定骨架把基础算法里的核心思想内化成自己的思维方式。我见过有人把模板背得滚瓜烂熟但面试官问你这个双指针为什么不会错过答案的时候完全答不上来。这说明他练的是形不是神。我的建议是在练每一类模板的时候都花一点时间回答三个问题为什么这个模板是对的为什么边界条件必须这样处理如果去掉某个条件模板为什么失效比如滑动窗口模板你要能解释清楚为什么 right 指针只需要向前移动而不用回退。原因在于当窗口收缩到满足条件时任何以当前 right 为右端点的、更短的合法窗口都只能在 left 继续右移时出现而 left 已经在 while 循环里推进到最远了。这个推理过程比模板本身更有价值。我在实际练习中的体会是模板练习和算法理解是互相成就的。先把模板写熟再回头去理解原理比一上来就死磕原理效率高得多但只练模板不去理解原理又会在变种题面前露怯。如果你正准备面试或者正在刷基础算法我建议你从今天开始选一个模板比如二分查找把它写到滚瓜烂熟然后挑一道你没做过的二分变种题试试。你会发现当你手上有一个信得过的模板时面对新题的底气会完全不一样。
返回列表