什么是分治算法
分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
定义看起来有点类似递归的定义,分治和递归的区别就是分治算法是一种处理问题的思想,递归是一种编程技巧。实际上,分治算法一般都比较适合用递归来实现。
遵循的步骤:
- 分解(Divide) :将原问题分解为若干个规模较小的子问题。这些子问题通常是原问题的较小实例。
- 解决(Conquer) :递归地解决各个子问题。如果子问题规模足够小,则直接解决(即达到base case的情况)。
- 合并(Combine) :将子问题的解合并为原问题的解。
如何理解分治算法:
- 递归性: 分治算法经常使用递归的方式来解决问题。递归是调用自身的一种算法结构,通过递归可以使问题不断地缩小直至可以直接解决。
- 问题规模缩小: 每一次递归调用都处理比原问题规模更小的子问题。
- 子问题独立: 子问题之间通常是相互独立的,这意味着它们可以并行处理。
- 合并结果: 最后一步是将所有子问题的结果合并起来形成最终答案。
能解决哪些类型的问题?
分治算法能解决的问题,一般需要满足下面这几个条件:
-
原问题与分解成的小问题具有相同的模式;
-
原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别;
-
具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
-
可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
应用举例
1. 求出一组数据的逆序对个数
逆序对的概念
在数组中,如果对于一对元素 (i, j) 满足 i < j 且 arr[i] > arr[j],我们称 (arr[i], arr[j]) 是一个逆序对。逆序对的数量可以用来衡量数组的无序程度。
例如,对于数组 [2, 4, 1, 3, 5],逆序对有 (2, 1) 和 (4, 1),所以逆序对的个数是 2。
如何求逆序对的个数
我们可以通过暴力的方法来求逆序对,即遍历数组的每一个元素与它后面的元素进行比较,如果发现逆序对就计数。这种方法的时间复杂度为 𝑂(𝑛2)。
然而,更高效的方式是使用 归并排序 这种分治算法,在排序的同时计算逆序对。归并排序的时间复杂度是 𝑂(𝑛log𝑛),因此计算逆序对的时间复杂度也可以达到 𝑂(𝑛log𝑛)。
归并排序方法求逆序对的思路
- 分解:将数组分成两半,分别对每一半进行递归求解。
- 解决:递归地求解每个子数组的逆序对。
- 合并:在合并两个有序子数组的过程中,计算跨子数组的逆序对数量。
即我们可以将数组分成前后两半 A1 和 A2,分别计算 A1 和 A2 的逆序对个数 K1 和 K2,然后再计算 A1 与 A2 之间的逆序对个数 K3。那数组 A 的逆序对个数就等于 K1+K2+K3。
我们需要明白,在合并左右两半数组的过程中,左右两半数组本身是有序的。这是因为在递归调用的过程中, 我们会一直把数组分成更小的子数组,直到子数组只有一个元素或为空,此时这些子数组就是有序的。然后我们再通过merge()函数将这些有序的子数组合并起来,最终得到整个数组的有序排列。 具体来说,在合并时,如果左半部分的某个元素大于右半部分的某个元素,那么这个左半部分后面的元素与右边的这个元素都形成逆序对。 这是因为我们是在合并有序的左右两半数组,所以如果 arr[i] > arr[j],说明 arr[i] 应该出现在 arr[j] 的后面,但它却出现在前面,因此 (i, j) 就构成了一个逆序对。
更进一步,如果 arr[i] > arr[j],那么 arr[i] 后面的所有元素都会与 arr[j] 构成逆序对。这是因为在左半数组中, arr[i] 及其后面的所有元素都应该出现在 arr[j] 的后面,但它们却出现在前面,因此都会与 arr[j] 构成逆序对。
所以,在合并左右两半数组的过程中,每当遇到 arr[i] > arr[j] 的情况,我们就可以计算出 mid - i + 1 个逆序对(即 arr[i] 及其后面的 mid - i 个元素都与 arr[j] 构成逆序对)。
总的来说,在分治算法的合并过程中,我们利用了左右两半数组本身是有序的这一特性,通过比较相应位置的元素大小,就可以计算出合并过程中产生的逆序对个数。这就是整个算法的核心思想。
Swift 代码实现
import Foundation
func countInversions(_ array: [Int]) -> Int {
var arrayCopy = array
return mergeSortAndCount(&arrayCopy, 0, array.count - 1)
}
func mergeSortAndCount(_ array: inout [Int], _ left: Int, _ right: Int) -> Int {
if left >= right {
return 0
}
let mid = (left + right) / 2
var count = 0
// 左半部分逆序对
count += mergeSortAndCount(&array, left, mid)
// 右半部分逆序对
count += mergeSortAndCount(&array, mid + 1, right)
// 合并并同时计算跨子数组的逆序对
count += mergeAndCount(&array, left, mid, right)
return count
}
func mergeAndCount(_ array: inout [Int], _ left: Int, _ mid: Int, _ right: Int) -> Int {
var leftArray = Array(array[left...mid])
var rightArray = Array(array[mid + 1...right])
var i = 0, j = 0
var k = left
var inversions = 0
// 合并两个有序数组并计算逆序对
while i < leftArray.count && j < rightArray.count {
if leftArray[i] <= rightArray[j] {
array[k] = leftArray[i]
i += 1
} else {
array[k] = rightArray[j]
j += 1
// 左半部分剩余的所有元素都比当前右半部分元素大,形成逆序对
inversions += leftArray.count - i
}
k += 1
}
// 复制剩余的左半部分
while i < leftArray.count {
array[k] = leftArray[i]
i += 1
k += 1
}
// 复制剩余的右半部分
while j < rightArray.count {
array[k] = rightArray[j]
j += 1
k += 1
}
return inversions
}
// 示例用法
let array = [2, 4, 1, 3, 5]
let inversionCount = countInversions(array)
print("Number of inversions: \(inversionCount)")
代码说明
countInversions函数:这是主函数,它接受一个数组并返回数组中的逆序对数量。它调用mergeSortAndCount来进行归并排序和逆序对计数。mergeSortAndCount函数:这个函数递归地对数组进行归并排序,并在排序的过程中计算逆序对的数量。它将数组分为两部分,分别计算左右子数组的逆序对,然后合并并计算跨子数组的逆序对。mergeAndCount函数:这个函数在合并两个有序子数组的过程中计算跨子数组的逆序对。如果发现左半部分的某个元素大于右半部分的某个元素,那么左半部分的剩余元素都与这个右半部分的元素形成逆序对。
2.分治思想在海量数据处理中的应用
分治算法思想的应用是非常广泛的,并不仅限于指导编程和算法设计。它还经常用在海量数据处理的场景中。
比如,给 10GB 的订单文件按照金额排序这样一个需求,看似是一个简单的排序问题,但是因为数据量大,有 10GB,而我们的机器的内存可能只有 2、3GB 这样子,无法一次性加载到内存,也就无法通过单纯地使用快排、归并等基础算法来解决了。
要解决这种数据量大到内存装不下的问题,我们就可以利用分治的思想。我们可以将海量的数据集合根据某种方法,划分为几个小的数据集合,每个小的数据集合单独加载到内存来解决,然后再将小数据集合合并成大数据集合。实际上,利用这种分治的处理思路,不仅仅能克服内存的限制,还能利用多线程或者多机处理,加快处理的速度。
比如刚刚举的那个例子,给 10GB 的订单排序,我们就可以先扫描一遍订单,根据订单的金额,将 10GB 的文件划分为几个金额区间。比如订单金额为 1 到 100 元的放到一个小文件,101 到 200 之间的放到另一个文件,以此类推。这样每个小文件都可以单独加载到内存排序,最后将这些有序的小文件合并,就是最终有序的 10GB 订单数据了。
如果订单数据存储在类似 GFS 这样的分布式系统上,当 10GB 的订单被划分成多个小文件的时候,每个文件可以并行加载到多台机器上处理,最后再将结果合并在一起,这样并行处理的速度也加快了很多。不过,这里有一个点要注意,就是数据的存储与计算所在的机器是同一个或者在网络中靠的很近(比如一个局域网内,数据存取速度很快),否则就会因为数据访问的速度,导致整个处理过程不但不会变快,反而有可能变慢。
3. 二维平面上有 n 个点,如何快速计算出两个距离最近的点对?
在二维平面上给定 n 个点,快速计算出两个距离最近的点对是一个经典的计算几何问题,称为 最近点对问题(Closest Pair of Points Problem)。使用朴素的暴力解法的时间复杂度为 𝑂(𝑛2),但可以通过 分治算法 提高效率,将时间复杂度优化到 𝑂(𝑛log𝑛)。
分治法求解思路
分治法求解最近点对问题的基本思路如下:
-
分解(Divide):
- 将点集按
x坐标排序,并将点集分为左右两部分,分别递归计算每部分的最近点对。
- 将点集按
-
解决(Conquer):
- 递归地求解左右两部分中的最近点对。
-
合并(Combine):
- 比较左右两部分的最近点对,并考虑跨越左右部分的点对是否有更小的距离。跨越部分的点对必须位于左右部分中距离分割线较近的区域内。
具体实现步骤
- 按
x坐标排序:将所有点按x坐标排序。 - 递归求解:递归地将点集分成两部分,直到每部分只有一个点或两个点。对于小规模的点集(如 2-3 个点),可以直接使用暴力法计算最近点对距离。
- 合并处理:在合并阶段,首先取左右两部分的最小距离
d,然后在分割线附近宽度为2d的区域内寻找横跨左右两部分的点对,计算这些点对的距离并更新最小距离。 - 返回最近点对:最终返回整个点集的最近点对。
Swift 实现
import Foundation
struct Point {
let x: Double
let y: Double
}
func closestPair(_ points: [Point]) -> (Point, Point, Double) {
// 先按 x 坐标排序
let sortedPoints = points.sorted { $0.x < $1.x }
return closestPairRecursive(sortedPoints)
}
func closestPairRecursive(_ points: [Point]) -> (Point, Point, Double) {
let n = points.count
if n <= 3 {
return bruteForce(points)
}
let mid = n / 2
let leftPoints = Array(points[0..<mid])
let rightPoints = Array(points[mid..<n])
// 递归求解左右部分的最近点对
let (p1, q1, d1) = closestPairRecursive(leftPoints)
let (p2, q2, d2) = closestPairRecursive(rightPoints)
// 找到左右部分的最小距离
var d = min(d1, d2)
var bestPair = d1 < d2 ? (p1, q1) : (p2, q2)
// 合并处理,考虑跨越左右部分的最近点对
var strip: [Point] = []
let midX = points[mid].x
for point in points {
if abs(point.x - midX) < d {
strip.append(point)
}
}
// 按 y 坐标排序
strip.sort { $0.y < $1.y }
// 检查 strip 中的每对点,更新最小距离
for i in 0..<strip.count {
for j in (i+1)..<strip.count {
if (strip[j].y - strip[i].y) >= d {
break
}
let distance = distanceBetween(strip[i], strip[j])
if distance < d {
d = distance
bestPair = (strip[i], strip[j])
}
}
}
return (bestPair.0, bestPair.1, d)
}
func bruteForce(_ points: [Point]) -> (Point, Point, Double) {
var minDistance = Double.greatestFiniteMagnitude
var bestPair: (Point, Point) = (points[0], points[1])
for i in 0..<points.count {
for j in i+1..<points.count {
let distance = distanceBetween(points[i], points[j])
if distance < minDistance {
minDistance = distance
bestPair = (points[i], points[j])
}
}
}
return (bestPair.0, bestPair.1, minDistance)
}
func distanceBetween(_ p1: Point, _ p2: Point) -> Double {
let dx = p1.x - p2.x
let dy = p1.y - p2.y
return sqrt(dx * dx + dy * dy)
}
// 示例用法
let points = [ Point(x: 2.0, y: 3.0), Point(x: 12.0, y: 30.0), Point(x: 40.0, y: 50.0), Point(x: 5.0, y: 1.0), Point(x: 12.0, y: 10.0), Point(x: 3.0, y: 4.0)]
let (p1, p2, distance) = closestPair(points)
print("最近点对: (\(p1.x), \(p1.y)) 和 (\(p2.x), \(p2.y))")
print("距离: \(distance)")
代码说明
Point结构体:定义了点的坐标。closestPair函数:主函数,用于计算最近点对。在这里首先按x坐标对点进行排序,然后调用递归函数closestPairRecursive。closestPairRecursive函数:递归地计算最近点对。它将点集分为左右两部分,分别计算左右部分的最近点对,然后处理跨越左右部分的点对。bruteForce函数:对于小规模的点集(如 3 个以下的点),使用暴力法计算最近点对。distanceBetween函数:计算两点之间的距离。