ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

分治算法实战:从归并排序到力扣周赛新题拆解

分治算法实战:从归并排序到力扣周赛新题拆解 当刷题进入一定阶段后很多人会有一种感觉题目见过很多模板也背了不少但一到力扣周赛这种限时场景看到新题还是容易卡住。明明觉得和某道旧题很像却说不清该往哪个方向想最后在递归、循环、回溯之间反复试错浪费大量时间。如果你也有这种感觉那这篇文章的核心观点可能会对你有帮助与其死记题号不如先把一类算法思想彻底想透再带着这个思想去套周赛里的新题。本文从“分治”这个看起来入门、实则贯穿很多中高难度题目的思想出发结合力扣周赛 514 的备战场景完整梳理分治的识别方法、代码模板、经典例题和踩坑清单。不管你是准备周赛的选手还是正在按“力扣刷题顺序”走新手路线这篇都能给你一套能直接用的思路。1. 从周赛 514 说起为什么分治值得单独训练力扣周赛每周一场题号不断更新很多人习惯把它当成“检测自己刷了多少题”的标尺。但如果你复盘过周赛题目会发现一个规律周赛的题目并不总是考偏题怪题更多是把基础算法思想包装在新的场景里。分治就是其中一个高频考点。1.1 周赛里常见的分治影子在周赛的 hard 题中分治往往不是单独出现的而是和其他知识点组合区间类问题比如统计区间内的逆序对、区间最大值、区间和常通过分治把大区间拆成小区间。数据结构类问题线段树、树状数组的底层逻辑和分治有天然联系尤其是“区间合并”这一步。归并思想涉及到有序数组合并、链表排序、计算跨区间贡献的题目几乎都是分治的变体。递归构建比如根据遍历序列构建二叉树本质上是每次确定根节点再对左右子树递归处理。也就是说分治并不是“递归”那么简单它是一套分析问题的方法把大问题拆成多个子问题分别求解再合并结果。掌握这套方法后很多周赛题在你眼里会变成“原来只是换了一层外衣的分治”。1.2 为什么很多人分治学不透分治学不透通常不是因为它难而是因为很多人学的时候只记住了“递归”这个外壳却没理解“拆分”和“合并”的设计逻辑。比如归并排序大家都会背“先排序左边再排序右边最后合并”但面试或周赛里换个问法比如“求一个数组的逆序对数量”很多人就反应不过来了。本质上分治题的难点主要在两步如何拆分子问题是按下标分成两半还是按值域分成两半还是按其他维度拆。如何合并子问题结果合并时是否要额外处理跨子问题的贡献。如果这两步想清楚了代码基本都是固定模板。接下来我们用三个从易到难的例子把这两步练透。2. 分治的本质三步套路与递归关系在写代码之前先把概念理清楚。分治Divide and Conquer的核心就三步分解Divide把原问题拆成若干个规模更小的、互相独立的子问题。解决Conquer递归地解决每个子问题。当子问题足够小时直接求解。合并Combine把子问题的解合并成原问题的解。听起来很简单但真正写代码时很多人纠结的是“我怎么知道递归到哪里停”“合并部分的代码到底写在哪”这里有一个万能的思考顺序先写递归出口什么情况下问题已经小到可以直接返回结果。再写递归调用把当前问题拆成左右两半或其他拆分方式分别递归。最后写合并逻辑思考两个子问题的结果如何拼成当前层的结果。用归并排序来套这个模板最直观。2.1 最小可用示例归并排序归并排序是理解分治的最佳入口。它把一个数组从中间拆开分别排序再合并两个有序数组。def merge_sort(nums): # 递归出口只有一个元素或空数组时天然有序 if len(nums) 1: return nums # 分解从中间拆成两个子数组 mid len(nums) // 2 left merge_sort(nums[:mid]) right merge_sort(nums[mid:]) # 合并合并两个有序数组 i j 0 merged [] while i len(left) and j len(right): if left[i] right[j]: merged.append(left[i]) i 1 else: merged.append(right[j]) j 1 merged.extend(left[i:]) merged.extend(right[j:]) return merged if __name__ __main__: nums [5, 2, 9, 1, 7, 6, 3] print(merge_sort(nums))运行结果[1, 2, 3, 5, 6, 7, 9]这个例子虽然简单但它把分治的三个步骤展示得很清楚分解mid len(nums) // 2从中间拆。解决left merge_sort(...)和right merge_sort(...)递归处理。合并双指针合并两个有序数组。时间复杂度是 O(n log n)空间复杂度是 O(n)。2.2 分治与递归、BFS/DFS 的区别刷题时经常把分治、递归、DFS 混在一起说这里做一个简单的区分概念核心特点典型场景递归一种函数调用自身的编程技巧树遍历、阶乘、斐波那契分治一种算法思想通常用递归实现关键是“拆”和“合”归并排序、快速排序、逆序对DFS一种遍历策略沿着一条路径走到底再回溯树的深度优先遍历、图的连通分量BFS一种遍历策略按层扩散腐烂的橘子、最短路径、层次遍历比如“力扣腐烂的橘子是什么题型”这个问题答案是典型的多源 BFS而不是分治。它把腐烂的橘子作为多个起点同时向外扩散按层更新“分钟数”。如果你一看到“扩散”就用分治去拆思路就偏了。所以识别题型时不能只看“递归”两个字要看问题结构是“可拆可合”还是“按层扩散”。3. 分治实战一多数元素力扣 169 题“多数元素”是一道经典题也可以用分治来做而且很适合用来理解“合并子问题结果”的逻辑。3.1 题目描述给定一个大小为 n 的数组 nums返回其中的多数元素。多数元素是指在数组中出现次数大于 n/2 的元素。例如输入nums [2,2,1,1,1,2,2] 输出23.2 分治思路如果用分治来解核心观察是如果左半部分的多数元素是 a右半部分的多数元素是 b那整段的多数元素只可能是 a 或 b不可能是别的新元素。为什么因为如果某个数 x 是整段的多数元素那它在左半段和右半段中至少有一段里是多数。否则它在两段的出现次数都不超过各自长度的一半加起来就不可能超过总长度的一半。因此合并逻辑就很清晰了递归求出左半段的多数元素 left_major。递归求出右半段的多数元素 right_major。如果两个相等直接返回。如果不相等分别统计这两个候选值在当前整段里出现的次数返回出现次数多的那个。递归出口是当区间只有一个元素时这个元素就是多数元素。3.3 完整代码from typing import List class Solution: def majorityElement(self, nums: List[int]) - int: def divide(left: int, right: int) - int: # 递归出口区间只有一个元素 if left right: return nums[left] # 分解从中间拆成两个区间 mid (left right) // 2 left_major divide(left, mid) right_major divide(mid 1, right) # 合并两个候选值相等时直接返回 if left_major right_major: return left_major # 不相等时统计两个候选值在当前区间出现的次数 left_count sum(1 for i in range(left, right 1) if nums[i] left_major) right_count sum(1 for i in range(left, right 1) if nums[i] right_major) return left_major if left_count right_count else right_major return divide(0, len(nums) - 1) if __name__ __main__: sol Solution() print(sol.majorityElement([2, 2, 1, 1, 1, 2, 2])) print(sol.majorityElement([3, 3, 4]))运行结果2 33.4 复杂度分析时间复杂度T(n) 2T(n/2) O(n)合并时统计出现次数需要遍历当前区间所以总复杂度是 O(n log n)。空间复杂度递归调用栈深度 O(log n)。这道题其实还有 O(n) 时间的 Boyer-Moore 投票算法但用分治解它的意义在于你可以看到一个具有“区间性质”的问题如何被拆成左右两个子问题再通过合并得到答案。这种“左右拆、合并算”的模式在周赛的线段树类题目里还会反复出现。3.5 和力扣热题 100 的关联如果你刷过力扣热题 100会发现里面很多题目都能用分治来解。除了多数元素还有“最大子数组和”“二叉树的中序遍历递归本身就是分治的体现”“合并 K 个升序链表”等。这些题表面上题型不同但内部分治结构类似要么是在左右结果中取最优要么是合并多个有序序列。所以当你按力扣刷题顺序推进时建议不要只把热题 100 当成“题单”而是按专题归纳。分治这一块把归并排序、快速排序、多数元素、最大子数组和、合并 K 个链表放一起刷理解的深度会比孤立刷题高很多。4. 分治实战二合并 K 个升序链表力扣 23 题“合并 K 个升序链表”是周赛和面试里的高频题最朴素的做法是每轮比较 K 个头节点但这样时间复杂度是 O(K * N)不够优雅。更好的方案有两种优先队列和分治合并。分治合并的思路同样沿用了归并排序的框架。4.1 题目描述给你一个链表数组每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中返回合并后的链表。例如输入lists [[1,4,5],[1,3,4],[2,6]] 输出[1,1,2,3,4,4,5,6]4.2 分治思路从中间把链表数组拆成两半先分别合并左半边所有链表再合并右半边所有链表最后用“合并两个有序链表”的函数把两个结果合并起来。递归出口如果链表数组为空返回 None。如果区间里只有一个链表直接返回这个链表。合并两个有序链表的过程可以单独写成一个函数利用哑节点简化处理。4.3 完整代码from typing import List, Optional # 链表节点定义 class ListNode: def __init__(self, val0, nextNone): self.val val self.next next class Solution: def mergeKLists(self, lists: List[Optional[ListNode]]) - Optional[ListNode]: if not lists: return None return self.divide_merge(lists, 0, len(lists) - 1) def divide_merge(self, lists: List[Optional[ListNode]], left: int, right: int) - Optional[ListNode]: # 递归出口区间为空或只有一个链表 if left right: return None if left right: return lists[left] # 分解从中间拆成两个区间 mid (left right) // 2 left_merged self.divide_merge(lists, left, mid) right_merged self.divide_merge(lists, mid 1, right) # 合并合并两个有序链表 return self.merge_two_lists(left_merged, right_merged) def merge_two_lists(self, l1: Optional[ListNode], l2: Optional[ListNode]) - Optional[ListNode]: dummy ListNode(0) cur dummy while l1 and l2: if l1.val l2.val: cur.next l1 l1 l1.next else: cur.next l2 l2 l2.next cur cur.next if l1: cur.next l1 if l2: cur.next l2 return dummy.next def build_list(arr): dummy ListNode(0) cur dummy for val in arr: cur.next ListNode(val) cur cur.next return dummy.next def print_list(head): result [] while head: result.append(head.val) head head.next print(result) if __name__ __main__: lists [ build_list([1, 4, 5]), build_list([1, 3, 4]), build_list([2, 6]), ] sol Solution() merged sol.mergeKLists(lists) print_list(merged)运行结果[1, 1, 2, 3, 4, 4, 5, 6]4.4 复杂度分析设有 K 个链表每个链表平均长度为 N。分治合并每一层都会把所有节点访问一遍层数是 O(log K)所以总时间复杂度是 O(KN log K)比逐轮比较的 O(K^2 N) 好很多。空间复杂度是 O(log K)来自递归调用栈。这里要理解一个关键点分治合并和优先队列合并的时间复杂度是同一量级的但分治版本没有额外的堆空间开销代码结构也更规整更容易和归并排序联系起来。在周赛紧张的环境下分治的“套路感”更强写错概率更低。4.5 变体延伸合并 K 个有序序列的分治思想还能延伸到很多题目上。比如力扣 21 题“合并两个有序链表”是基础版在一些数据库或大数据场景里多路归并排序也是分治思想在生产环境中的应用。刷题时如果只看题目本身很容易觉得“这题会了”但把 K 路的场景换一下比如改成合并 K 个有序数组你还会不会写建议把链表版的代码改成数组版练一遍这会加深对“合并”这一步骤的理解。5. 周赛中的分治识别什么题该往分治想周赛时间紧张最怕的不是不会写代码而是在错误的方向上花了 20 分钟后才推翻重来。所以快速识别题型的能力比单纯会写某种算法更重要。5.1 分治 vs 动态规划 vs 贪心分治和动态规划都需要把大问题拆成子问题但有一个关键区别分治子问题之间通常相互独立合并起来比较简单。比如归并排序的左右两半互不影响。动态规划子问题之间有重叠后面的状态依赖前面的状态通常用 dp 数组递推或记忆化搜索。贪心每一步做局部最优选择不回溯不拆分成“左右两半”。做题时可以用一个简单的判断标准如果一个问题可以拆成“左右两半分别处理最后合并”而且拆出来的子问题独立那就优先考虑分治。如果子问题有大量重叠且存在“选或不选”“状态转移”的痕迹那更可能是动态规划。5.2 分治的常见题型特征以下特征出现时分治是一个值得尝试的方向题目涉及区间、线段、数组切分。问题可以递归地定义比如“左子树的最大值 右子树的最大值”。涉及到有序序列的合并、排序、逆序对。需要对所有子区间统计某种指标且合并成本可控。二叉树的构建、序列化、遍历类问题。反过来如果题目是“求最短路径”“按层扩散”“连通块”那应该优先想 BFS 或 DFS而不是分治。比如前面提到的“腐烂的橘子”每个橘子每分钟向外感染天然是层扩散结构用多源 BFS 才是最自然的解法。5.3 周赛临场判断流程我在打周赛时会按这套流程快速判断读题后先看数据范围。如果 n 非常大O(n^2) 一定会超时那就要想 O(n log n) 或 O(n log^2 n) 的解法分治和排序类算法通常在这时候进入候选。看问题是否有“重叠子问题”。有重叠想 DP无重叠且可拆半想分治。看是否涉及有序性。需要把无序变有序、或者利用有序性合并时优先想归并分治。看是否涉及“跨区间贡献”。比如逆序对数量涉及左区间和右区间之间的大小关系这种题的合并阶段一定需要特殊处理而不是简单返回子区间答案。这一步不会花很多时间但能帮你避开“用 DFS 硬写分治题”的尴尬局面。6. 周赛代码中的常见问题与排查思路分治代码本身模板固定但真正写起来还是会遇到几种典型的坑。这里按周赛实战里最常见的报错和结果错误来整理。问题现象常见原因解决思路递归栈溢出递归深度太大比如直接递归处理长度为 10^5 的数组检查递归出口是否合理考虑改用迭代或尾递归分治层数一般是 O(log n)如果递归深度不是 log n 级说明拆分逻辑可能有问题结果差一点边界用例不过区间拆分时 left、right、mid 边界处理错误用 mid (left right) // 2递归区间用 [left, mid] 和 [mid 1, right]保持区间不重不漏超时合并阶段做了 O(n^2) 的扫描合并部分尽量控制在 O(n) 或 O(log n)如果统计候选值频率需要遍历整个区间评估数据范围是否可接受空数组/空链表报错没有处理递归出口的边界情况在函数最前面判断空数组和空链表返回 None 或空结果Python 字符串格式化报错unsupported format character y (0x59) at index 514字符串里包含%符号但后面的格式化参数没对应上检查所有字符串格式化语句尤其是包含百分号的 SQL、日志、输出模板可以用%%转义或改用 f-string、format()在周赛调试时我最常犯的就是边界问题。这里分享一个经验写完分治递归函数后先用最小用例手推一遍递归过程。比如区间 [0, 2]mid 1左区间 [0, 1]右区间 [2, 2]检查最后是否每个元素都被覆盖到。如果小用例对了再试大数据基本不会有大问题。7. Python 刷题时的一个冷门坑字符串格式化报错虽然分治考的是算法但周赛里很多人会在输出阶段踩到 Python 字符串格式化的坑尤其是遇到题面里带百分比、日志类题目时。这里单独展开讲一下。7.1 报错长什么样有时候你的程序在本地运行正常但提交到力扣后某个测试用例报错ValueError: unsupported format character y (0x59) at index 514这个报错的意思是Python 在格式化字符串时在索引 514 的位置遇到了一个无法识别的格式字符y。7.2 产生原因在 Python 中字符串里如果包含%符号后面跟着一个普通字母会被解析成格式化占位符。比如s 进度50% yes print(s % ())这里% y被 Python 当成了格式符但y不是合法的格式字符于是抛出上面的异常。7.3 复现示例try: text 完成比例: 100% year end report print(text % ()) except ValueError as e: print(Error:, e)运行输出Error: unsupported format character y (0x59) at index 514这个例子里 index 的数字会随着字符串长度变化但报错逻辑是相同的。7.4 解决方案有三种常见修复方式使用%%转义百分号text 完成比例: 100%% year end report print(text % ())使用 format() 方法百分号不再作为占位符text 完成比例: 100% year end report print(text.format())使用 f-stringpercent 100 print(f完成比例: {percent}% year end report)周赛里如果遇到这种报错优先检查代码里所有包含%的字符串尤其是在打印日志、拼接 SQL、格式化输出的时候。这个坑不涉及到分治算法本身但会浪费宝贵的比赛时间值得提前避开。8. 最佳实践从力扣刷题顺序到周赛复盘分治不是孤立的知识点它和整个刷题路线、周赛成绩提升强相关。最后给出一些工程实践层面的建议。8.1 刷题顺序建议如果你是刚开始刷力扣不建议直接跳到周赛 hard 题。推荐顺序是先掌握基础数据结构数组、链表、栈、队列、哈希表。再刷简单的算法思想二分查找、双指针、滑动窗口。然后进入递归和分治专题先做归并排序、快速排序再做多数元素、最大子数组和、合并K个升序链表。接着是树专题二叉树遍历、最近公共祖先、根据遍历序列重建二叉树。最后再进入动态规划、图论等进阶专题。这套顺序的核心逻辑是“基础数据结构 - 基础算法思想 - 复杂综合应用”。分治放的位置比较靠前因为它是后续很多高级数据结构的思维底座。8.2 力扣热题 100 的使用方法力扣热题 100 是很多人刷题的第一站但它的缺点是题目跨度较大如果只是按顺序刷很难形成知识网络。我更推荐把热题 100 里的题目按专题重新分类比如分治类多数元素、最大子数组和、合并K个升序链表。二叉树类二叉树的中序遍历、二叉树的最大深度、翻转二叉树。动态规划类爬楼梯、打家劫舍、最长回文子串。这样做的好处是同一种思想连续刷几道题后你会自然形成“识别题型”的肌肉记忆而不是每道题都从零开始。8.3 周赛复盘的正确姿势周赛结束后不要只看分数和排名。更有效的复盘方法是把每道题分类是基础数据结构题、分治题、DP 题还是 BFS/DFS 题。对照你当时的思考过程是题没读懂还是想到了思路但写不出来还是代码 bug 耽误了时间。把每道题的最优解和你的解法对比差在时间复杂度还是差在代码简洁度。每周积累一道“值得重刷”的题放进自己的错题本。这套复盘方式坚持一个月你对周赛题型的敏感度会有明显提升。刷题数量当然重要但从一道题里提炼出可复用的算法范式才是长期进步的关键。8.4 分治代码的工程建议在实际项目和竞赛代码中分治递归要注意三点控制递归深度避免在数据量很大的情况下出现栈溢出。Python 默认递归深度约 1000如果递归深度接近这个值考虑提高递归限制或改用迭代实现。合并阶段的操作要尽量简单。分治代码的复杂度瓶颈往往不在“拆分”而在“合并”合并时尽量不要做重复扫描。用辅助函数把“合并”逻辑单独抽出来。比如合并两个有序链表、合并两个有序数组单独封装后既便于测试也方便在其他题目中复用。9. 结语让分治成为你的周赛底层武器力扣周赛 514 的题目内容每场都会变但算法思想的底层逻辑是稳定的。分治、动态规划、BFS/DFS、贪心这些核心思想一旦真正掌握你会发现新题不过是旧思想换了一个新场景。本文从分治的三步套路讲起用归并排序建立了最初的认识再用多数元素和合并 K 个升序链表两个实战题演示了“拆分子问题”和“合并结果”两个关键设计点最后聊了周赛中的题型识别、常见排查思路以及刷题建议。建议你在看完文章后不只依赖题解而是把题目分类后重新独立写一遍代码尤其是自己手推一遍递归过程。毕竟分治这种思想只有在你亲自设计过“拆分逻辑”和“合并逻辑”之后才会真正变成你自己的武器。祝你在接下来的周赛中能见题拆题用稳定的算法框架战胜时间压力。如果这篇内容对你有帮助可以收藏备用也欢迎在评论区聊聊你打周赛时最常卡住的知识点。
返回列表