分治算法
一、介绍
「分治divideandconquer」,全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现, 包括“分”和“治”两个步骤。
1. 分(划分阶段):递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
2. 治(合并阶段):从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。
如图所示,“归并排序”是分治策略的典型应用之一。
1. 分:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。
2. 治:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)。
二、如何判断分治问题
一个问题是否适合使用分治解决,通常可以参考以下几个判断依据。
1. 问题可以被分解:原问题可以被分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
2. 子问题是独立的:子问题之间是没有重叠的,互相没有依赖,可以被独立解决。
3. 子问题的解可以被合并:原问题的解通过合并子问题的解得来。
显然,归并排序是满足以上三条判断依据的。
1. 问题可以被分解:递归地将数组(原问题)划分为两个子数组(子问题)。
2. 子问题是独立的:每个子数组都可以独立地进行排序(子问题可以独立进行求解)。
3. 子问题的解可以被合并:两个有序子数组(子问题的解)可以被合并为一个有序数组(原问题的解)。
三、通过分治提升效率
分治不仅可以有效地解决算法问题,往往还可以带来算法效率的提升。在排序算法中,快速排序、归并排序、 堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。
那么,为什么分治可以提升算法效率,其底层逻辑是什么?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这几步的效率为什么比直接解决原问题的效率更高? 这个问题可以从操作数量和并行计算两方面来讨论。
1. 操作数量优化
以“冒泡排序”为例,其处理一个长度为𝑛的数组需要𝑂()时间。假设我们按照图所示的方式,将数组从中点分为两个子数组,则划分需要𝑂(𝑛)时间,排序每个子数组需要𝑂(
)时间,合并两个子数组需要𝑂(𝑛)时间,总体时间复杂度为:
接下来,我们计算以下不等式,其左边和右边分别为划分前和划分后的操作总数:
这意味着当𝑛>4时,划分后的操作数量更少,排序效率应该更高。请注意,划分后的时间复杂度仍然是平 方阶𝑂(),只是复杂度中的常数项变小了。
进一步想,如果我们把子数组不断地再从中点划分为两个子数组,直至子数组只剩一个元素时停止划分呢? 这种思路实际上就是“归并排序”,时间复杂度为𝑂(𝑛log𝑛)。 再思考,如果我们多设置几个划分点,将原数组平均划分为𝑘个子数组呢?这种情况与“桶排序”非常类似, 它非常适合排序海量数据,理论上时间复杂度可以达到𝑂(𝑛+𝑘)。
2. 并行计算优化
我们知道,分治生成的子问题是相互独立的,因此通常可以并行解决。也就是说,分治不仅可以降低算法的 时间复杂度,还有利于操作系统的并行优化。 并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资 源,从而显著减少总体的运行时间。 比如在图所示的“桶排序”中,我们将海量的数据平均分配到各个桶中,则可所有桶的排序任务分散到各个计算单元,完成后再进行结果合并。
四、分治常见应用
一方面,分治可以用来解决许多经典算法问题。
‧ 寻找最近点对:该算法首先将点集分成两部分,然后分别找出两部分中的最近点对,最后再找出跨越两 部分的最近点对。
‧ 大整数乘法:例如Karatsuba算法,它是将大整数乘法分解为几个较小的整数的乘法和加法。
‧ 矩阵乘法:例如Strassen算法,它是将大矩阵乘法分解为多个小矩阵的乘法和加法。
‧ 汉诺塔问题:汉诺塔问题可以视为典型的分治策略,通过递归解决。
‧ 求解逆序对:在一个序列中,如果前面的数字大于后面的数字,那么这两个数字构成一个逆序对。求解逆序对问题可以通过分治的思想,借助归并排序进行求解。
另一方面,分治在算法和数据结构的设计中应用非常广泛。
‧ 二分查找:二分查找是将有序数组从中点索引分为两部分,然后根据目标值与中间元素值比较结果,决 定排除哪一半区间,然后在剩余区间执行相同的二分操作。
‧ 归并排序:文章开头已介绍,不再赘述。
‧ 快速排序:快速排序是选取一个基准值,然后把数组分为两个子数组,一个子数组的元素比基准值小, 另一子数组的元素比基准值大,然后再对这两部分进行相同的划分操作,直至子数组只剩下一个元素。
‧ 桶排序:桶排序的基本思想是将数据分散到多个桶,然后对每个桶内的元素进行排序,最后将各个桶的 元素依次取出,从而得到一个有序数组。
‧ 树:例如二叉搜索树、AVL树、红黑树、B树、B+树等,它们的查找、插入和删除等操作都可以视为 分治的应用。
‧ 堆:堆是一种特殊的完全二叉树,其各种操作,如插入、删除和堆化,实际上都隐含了分治的思想。
‧ 哈希表:虽然哈希表来并不直接应用分治,但某些哈希冲突解决策略间接应用了分治策略,例如,链式 地址中的长链表会被转化为红黑树,以提升查询效率。
可以看出,分治是一种“润物细无声”的算法思想,隐含在各种算法与数据结构之中。
五、分治搜索策略
搜索算法分为两大类。
‧ 暴力搜索:它通过遍历数据结构实现,时间复杂度为𝑂(𝑛)。
‧ 自适应搜索:它利用特有的数据组织形式或先验信息,可达到𝑂(log𝑛)甚至𝑂(1)的时间复杂度。
实际上,时间复杂度为𝑂(log𝑛)的搜索算法通常都是基于分治策略实现的,例如二分查找和树。
‧ 二分查找的每一步都将问题(在数组中搜索目标元素)分解为一个小问题(在数组的一半中搜索目标元素),这个过程一直持续到数组为空或找到目标元素为止。
‧ 树是分治关系的代表,在二叉搜索树、AVL树、堆等数据结构中,各种操作的时间复杂度皆为𝑂(log𝑛) 。
1. 二分查找的分治策略
‧ 问题可以被分解:二分查找递归地将原问题(在数组中进行查找)分解为子问题(在数组的一半中进行查找),这是通过比较中间元素和目标元素来实现的。
‧ 子问题是独立的:在二分查找中,每轮只处理一个子问题,它不受另外子问题的影响。
‧ 子问题的解无须合并:二分查找旨在查找一个特定元素,因此不需要将子问题的解进行合并。当子问题得到解决时,原问题也会同时得到解决。
分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选项。
基于分治实现二分在之前的文章中,二分查找是基于递推(迭代)实现的。现在我们基于分治(递归)来实现它。
给定一个长度为𝑛的有序数组 nums ,数组中所有元素都是唯一的,请查找元素 target 。
从分治角度,我们将搜索区间[𝑖,𝑗]对应的子问题记为𝑓(𝑖,𝑗)。 从原问题𝑓(0,𝑛−1)为起始点,通过以下步骤进行二分查找。
1. 计算搜索区间[𝑖,𝑗]的中点𝑚,根据它排除一半搜索区间。
2. 递归求解规模减小一半的子问题,可能为𝑓(𝑖,𝑚−1)或𝑓(𝑚+1,𝑗)。
3. 循环第1.和2.步,直至找到 target 或区间为空时返回。 下图展示了在数组中二分查找元素6的分治过程。
2. 实现代码
def dfs(nums: list[int], target: int, i: int, j: int) -> int:"""二分查找:问题 f(i, j)"""# 若区间为空,代表无目标元素,则返回 -1if i > j:return -1# 计算中点索引 mm = (i + j) // 2if nums[m] < target:# 递归子问题 f(m+1, j)return dfs(nums, target, m + 1, j)elif nums[m] > target:# 递归子问题 f(i, m-1)return dfs(nums, target, i, m - 1)else:# 找到目标元素,返回其索引return mdef binary_search(nums: list[int], target: int) -> int:"""二分查找"""n = len(nums)# 求解问题 f(0, n-1)return dfs(nums, target, 0, n - 1)"""Driver Code"""
if __name__ == "__main__":target = 6nums = [1, 3, 6, 8, 12, 15, 23, 26, 31, 35]# 二分查找(双闭区间)index = binary_search(nums, target)print("目标元素 6 的索引 = ", index)