分治算法详解
分治算法(Divide and Conquer)是一种重要的算法设计思想,它将一个复杂的问题分解成若干个相同或相似的子问题,递归地解决这些子问题,然后将子问题的解合并成原问题的解。本文将从概念到实践,全面讲解分治算法的思想、适用场景及常见题型。
一、什么是分治算法?
1.1 分治算法的定义
分治算法(Divide and Conquer)是一种将复杂问题分解为若干个相同或相似的子问题,递归地解决这些子问题,然后将子问题的解合并成原问题的解的算法设计思想。
核心思想:
- 分(Divide):将原问题分解为若干个规模较小的子问题
- 治(Conquer):递归地解决这些子问题
- 合(Combine):将子问题的解合并成原问题的解
通俗理解:
想象你要统计全校学生的身高平均值。
- 直接方法:一个人统计全校,工作量巨大
- 分治方法:
- 把全校分成各个班级(分)
- 每个班级统计自己的平均值(治)
- 把各班级的结果合并,得到全校平均值(合)
1.2 分治算法的三个步骤
1 | ┌─────────────────────────────────────────────────────────────────┐ |
1.3 分治算法的特点
| 特点 | 说明 |
|---|---|
| 问题分解 | 原问题可以分解为若干个相同类型的子问题 |
| 子问题独立 | 子问题之间相互独立,没有重叠 |
| 最优子结构 | 问题的最优解包含子问题的最优解 |
| 递归求解 | 通常使用递归实现 |
| 合并代价 | 子问题解的合并可能需要额外计算 |
1.4 分治 vs 其他算法
| 算法 | 策略 | 特点 |
|---|---|---|
| 分治算法 | 分解 → 解决 → 合并 | 子问题独立,可能重复计算 |
| 动态规划 | 分解 → 记忆化 → 合并 | 子问题重叠,避免重复计算 |
| 贪心算法 | 局部最优 | 不回溯,效率高 |
| 回溯算法 | 尝试所有可能 | 可回退,找到所有解 |
二、分治算法的核心思想
2.1 适用条件
一个问题能用分治算法解决,必须满足以下条件:
条件一:问题可分解
原问题可以分解为若干个规模较小的相同问题。
示例:
- 排序问题:大数组排序 → 两个小数组排序
- 查找问题:大范围查找 → 两个小范围查找
条件二:子问题独立
子问题之间相互独立,一个子问题的解不影响其他子问题。
对比:
- 分治:子问题独立(归并排序的两个子数组)
- 动态规划:子问题重叠(斐波那契数列)
条件三:子问题可合并
子问题的解可以合并成原问题的解。
示例:
- 归并排序:两个有序数组合并成一个有序数组
- 快速排序:确定枢轴位置后,左右子数组自然有序
条件四:有终止条件
子问题规模足够小时,可以直接求解。
示例:
- 数组长度为 1 时,已经是有序的
- 查找范围为 1 个元素时,直接比较即可
2.2 分治算法的解题步骤
1 | ┌─────────────────────────────────────────────────────────────────┐ |
2.3 分治算法的复杂度分析
时间复杂度
分治算法的时间复杂度通常可以用主定理(Master Theorem)计算:
1 | T(n) = aT(n/b) + f(n) |
常见情况:
| 算法 | 递推式 | 时间复杂度 |
|---|---|---|
| 二分查找 | T(n) = T(n/2) + O(1) | O(log n) |
| 归并排序 | T(n) = 2T(n/2) + O(n) | O(n log n) |
| 快速排序(平均) | T(n) = 2T(n/2) + O(n) | O(n log n) |
| 快速排序(最坏) | T(n) = T(n-1) + O(n) | O(n²) |
| 矩阵乘法(Strassen) | T(n) = 7T(n/2) + O(n²) | O(n^2.807) |
空间复杂度
| 算法 | 空间复杂度 | 说明 |
|---|---|---|
| 二分查找 | O(log n) | 递归栈深度 |
| 归并排序 | O(n) | 需要额外数组 |
| 快速排序 | O(log n) | 递归栈深度 |
三、经典分治算法
3.1 归并排序(Merge Sort)
思想:
- 将数组分成两半
- 分别对两半进行排序
- 将两个有序数组合并成一个有序数组
代码实现:
1 | function mergeSort(arr) { |
时间复杂度: O(n log n)
空间复杂度: O(n)
3.2 快速排序(Quick Sort)
思想:
- 选择一个枢轴元素
- 将数组分成两部分:小于枢轴的和大于枢轴的
- 递归排序两部分
代码实现:
1 | function quickSort(arr, left = 0, right = arr.length - 1) { |
时间复杂度:
- 平均:O(n log n)
- 最坏:O(n²) —— 数组已经有序时
空间复杂度: O(log n) —— 递归栈深度
3.3 二分查找(Binary Search)
思想:
- 在有序数组中查找目标值
- 每次将查找范围缩小一半
代码实现:
1 | // 递归实现 |
时间复杂度: O(log n)
空间复杂度:
- 递归:O(log n)
- 迭代:O(1)
四、经典例题详解
4.1 例题一: Pow(x, n)
题目:
实现 pow(x, n) ,即计算 x 的整数 n 次幂函数。
示例:
1 | 输入: x = 2.00000, n = 10 |
解法:快速幂(分治)
思想:
- x^n = x^(n/2) × x^(n/2) —— n 为偶数
- x^n = x^((n-1)/2) × x^((n-1)/2) × x —— n 为奇数
代码实现:
1 | function myPow(x, n) { |
时间复杂度: O(log n)
空间复杂度: O(log n)
4.2 例题二:多数元素
题目:
给定一个大小为 n 的数组 nums ,返回其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊n/2⌋ 的元素。
你可以假设数组是非空的,并且给定的数组总是存在多数元素。
示例:
1 | 输入: nums = [3,2,3] |
解法一:分治法
思想:
- 将数组分成两半
- 分别找出左半部分和右半部分的众数
- 合并:比较两个众数在整个数组中的出现次数
代码实现:
1 | function majorityElement(nums) { |
时间复杂度: O(n log n)
空间复杂度: O(log n)
解法二:Boyer-Moore 投票算法(更优)
1 | function majorityElement(nums) { |
时间复杂度: O(n)
空间复杂度: O(1)
4.3 例题三:计算右侧小于当前元素的个数
题目:
给你一个整数数组 nums ,按要求返回一个新数组 counts 。数组 counts 有该性质:counts[i] 的值是 nums[i] 右侧小于 nums[i] 的元素的数量。
示例:
1 | 输入: nums = [5,2,6,1] |
解法:归并排序变形
思想:
- 在归并排序的过程中统计逆序对
- 当左半部分的元素大于右半部分的元素时,产生逆序对
代码实现:
1 | function countSmaller(nums) { |
时间复杂度: O(n log n)
空间复杂度: O(n)
4.4 例题四:数组中的第 K 个最大元素
题目:
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
示例:
1 | 输入: [3,2,1,5,6,4] 和 k = 2 |
解法:快速选择(Quick Select)
思想:
- 基于快速排序的分区思想
- 每次分区后,确定枢轴的位置
- 根据枢轴位置决定继续在左半部分还是右半部分查找
代码实现:
1 | function findKthLargest(nums, k) { |
时间复杂度:
- 平均:O(n)
- 最坏:O(n²) —— 随机化后概率极低
空间复杂度: O(log n)
4.5 例题五:为运算表达式设计优先级
题目:
给你一个由数字和运算符组成的字符串 expression ,按不同优先级组合数字和运算符,计算出并返回所有可能组合的结果。你可以按任意顺序返回答案。
示例:
1 | 输入: expression = "2-1-1" |
解法:分治
思想:
- 遍历每个运算符,将其作为最后计算的运算符
- 左边部分和右边部分分别递归计算所有可能结果
- 合并左右结果,根据运算符计算最终结果
代码实现:
1 | function diffWaysToCompute(expression) { |
时间复杂度: 取决于表达式结构,最坏 O(2^n)
空间复杂度: O(n) —— 递归栈深度
4.6 例题六:合并 K 个升序链表
题目:
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
示例:
1 | 输入: lists = [[1,4,5],[1,3,4],[2,6]] |
解法:分治合并
思想:
- 将 K 个链表分成两半
- 分别合并左半部分和右半部分
- 合并两个有序链表
代码实现:
1 | function mergeKLists(lists) { |
时间复杂度: O(N log k) —— N 是总节点数,k 是链表数
空间复杂度: O(log k) —— 递归栈深度
4.7 例题七:搜索二维矩阵 II
题目:
编写一个高效的算法来搜索 m × n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性:
- 每行的元素从左到右升序排列。
- 每列的元素从上到下升序排列。
示例:
1 | 输入: matrix = [ |
解法:分治(二分查找变形)
思想:
- 利用矩阵的有序性
- 从右上角或左下角开始,每次可以排除一行或一列
代码实现(分治版本):
1 | function searchMatrix(matrix, target) { |
时间复杂度: O(m + n) —— 简单解法
空间复杂度: O(1)
五、分治算法 vs 其他算法
5.1 分治 vs 递归
| 特性 | 分治算法 | 递归 |
|---|---|---|
| 关系 | 分治使用递归实现 | 递归是一种编程技巧 |
| 结构 | 分 → 治 → 合 | 函数调用自身 |
| 适用 | 可分解的问题 | 任何可递归定义的问题 |
| 示例 | 归并排序、快速排序 | 斐波那契、阶乘 |
关系:
- 分治算法通常使用递归来实现
- 但递归不一定用于分治(如树的遍历)
5.2 分治 vs 动态规划
| 特性 | 分治算法 | 动态规划 |
|---|---|---|
| 子问题 | 相互独立 | 相互重叠 |
| 重复计算 | 可能重复计算 | 避免重复计算(记忆化) |
| 典型问题 | 归并排序、快速排序 | 背包问题、最长子序列 |
| 时间复杂度 | 通常较低 | 通常较高 |
对比示例:
斐波那契数列:
1 | // 分治(递归)—— 有大量重复计算 |
5.3 分治 vs 贪心
| 特性 | 分治算法 | 贪心算法 |
|---|---|---|
| 策略 | 分解解决所有子问题 | 每步选局部最优 |
| 正确性 | 一定能得到正确解 | 不一定得到最优解 |
| 回溯 | 不需要回溯 | 不回溯 |
| 典型问题 | 排序、查找 | 区间调度、哈夫曼编码 |
六、分治算法的题型分类
6.1 按问题类型分类
类型一:排序问题
| 算法 | 思想 | 时间复杂度 |
|---|---|---|
| 归并排序 | 分半排序,然后合并 | O(n log n) |
| 快速排序 | 分区排序 | O(n log n) |
类型二:查找问题
| 算法 | 思想 | 时间复杂度 |
|---|---|---|
| 二分查找 | 每次缩小一半范围 | O(log n) |
| 快速选择 | 分区后确定查找范围 | O(n) |
类型三:计算问题
| 问题 | 思想 | 时间复杂度 |
|---|---|---|
| 大整数乘法 | Karatsuba 算法 | O(n^1.585) |
| 矩阵乘法 | Strassen 算法 | O(n^2.807) |
| 最近点对 | 分区域计算 | O(n log n) |
类型四:树/图问题
| 问题 | 思想 | 应用 |
|---|---|---|
| 树的遍历 | 递归处理子树 | 二叉树问题 |
| 线段树 | 分区间存储 | 区间查询/更新 |
| 树状数组 | 分块存储 | 前缀和问题 |
6.2 按分治策略分类
| 策略 | 说明 | 示例 |
|---|---|---|
| 二分法 | 将问题分成两半 | 二分查找、归并排序 |
| 多分法 | 将问题分成多份 | 快速排序(多分)、Strassen |
| 递归分解 | 递归处理子问题 | 树的遍历、表达式求值 |
| 合并统计 | 合并时统计信息 | 逆序对、区间统计 |
七、常见面试题总结
7.1 必须掌握的面试题
| 题目 | 难度 | 类型 |
|---|---|---|
| Pow(x, n) | 中等 | 快速幂 |
| 多数元素 | 简单 | 分治/投票 |
| 计算右侧小于当前元素的个数 | 困难 | 归并变形 |
| 数组中的第 K 个最大元素 | 中等 | 快速选择 |
| 为运算表达式设计优先级 | 中等 | 分治递归 |
| 合并 K 个升序链表 | 困难 | 分治合并 |
| 搜索二维矩阵 II | 中等 | 二分查找 |
| 不同的二叉搜索树 II | 中等 | 分治构造 |
| 漂亮数组 | 中等 | 分治构造 |
| 最大子数组和(分治版) | 中等 | 分治统计 |
7.2 面试回答要点
Q:什么是分治算法?
分治算法是一种将复杂问题分解为若干个相同或相似的子问题,递归地解决这些子问题,然后将子问题的解合并成原问题的解的算法设计思想。核心步骤是:分(Divide)、治(Conquer)、合(Combine)。
Q:分治算法和动态规划的区别?
- 分治:子问题相互独立,可能重复计算
- 动态规划:子问题相互重叠,使用记忆化避免重复计算
Q:分治算法的适用条件?
- 问题可以分解为相同类型的子问题
- 子问题相互独立
- 子问题的解可以合并
- 有终止条件(子问题足够小)
Q:常见的时间复杂度?
- 二分查找:O(log n)
- 归并排序、快速排序:O(n log n)
- 快速选择:O(n)
八、总结
8.1 分治算法核心要点
| 要点 | 内容 |
|---|---|
| 核心思想 | 分而治之:分解 → 解决 → 合并 |
| 关键条件 | 问题可分解、子问题独立、可合并、有终止条件 |
| 经典算法 | 归并排序、快速排序、二分查找、快速选择 |
| 时间复杂度 | 通常 O(n log n) 或 O(log n) |
| 空间复杂度 | 通常 O(log n) 或 O(n) |
8.2 分治算法决策树
1 | 遇到问题 |
8.3 学习建议
- 理解核心思想:分 → 治 → 合
- 掌握经典算法:归并排序、快速排序、二分查找
- 学会分析复杂度:使用主定理
- 对比学习:与递归、动态规划对比
- 多做练习:分治题目套路明显,多练就能掌握
分治算法是算法面试中的重点,虽然实现可能较复杂,但思路清晰,掌握后能解决很多复杂问题!