分而治之
- 1 分治法原理
- 2 示例说明:归并排序
- 2.1 分治法的步骤
- 2.2 归并排序代码
- 3 分治法应用
- 3.1 最近点对问题
- 3.1.1 Python3代码
- 3.1.2 分治法思路说明
- 3.2 快速傅里叶变换(FFT)
- 3.2.1 Python3代码
- 3.2.1 分治法思路说明
- 3.3 最长公共子序列问题
- 3.3.1 Python3代码
- 3.3.2 分治法思路说明
- 4 总结
1 分治法原理
分治法(Divide and Conquer)是一种广泛应用于算法设计的策略。它通过将一个复杂问题分解为若干个较小的子问题,递归地解决这些子问题,然后将这些子问题的解合并为原问题的解。分治法的基本步骤可以概括为以下几个阶段:
- 分解(Divide)
将原问题分解成多个小的、相似的子问题。这些子问题的规模通常比原问题要小。 - 解决(Conquer)
递归地解决这些子问题。如果子问题的规模足够小,可以直接解决。 - 合并(Combine)
将子问题的解合并成原问题的解。
2 示例说明:归并排序
问题:对一个数组 [38, 27, 43, 3, 9, 82, 10],进行排序。
2.1 分治法的步骤
- 分解(Divide)
将数组分为两半。例如分成 [38, 27, 43] 和 [3, 9, 82, 10]。 - 解决(Conquer)
递归地对每个子数组进行排序。- 对 [38, 27, 43]:
再分为 [38] 和 [27, 43],对 [27, 43] 进一步分为 [27] 和 [43],然后合并成 [27, 43]。 - 对 [3, 9, 82, 10]:
分为 [3, 9] 和 [82, 10],分别排序后合并。
- 对 [38, 27, 43]:
- 合并(Combine)
将已排序的子数组[27, 38, 43] 和 [3, 9, 10, 82] 合并。最终合并的顺序如下:
[3, 9, 10, 27, 38, 43, 82]。
2.2 归并排序代码
def merge_sort(arr):
if len(arr) <= 1:
return arr
mid = len(arr) // 2
left_half = merge_sort(arr[:mid])
right_half = merge_sort(arr[mid:])
return merge(left_half, right_half)
def merge(left, right):
sorted_array = []
i = j = 0
# 合并两个已排序的数组
while i < len(left) and j < len(right):
if left[i] < right[j]:
sorted_array.append(left[i])
i += 1
else:
sorted_array.append(right[j])
j += 1
# 添加剩余元素
sorted_array.extend(left[i:])
sorted_array.extend(right[j:])
return sorted_array
# 示例使用
if __name__ == "__main__":
arr = [38, 27, 43, 3, 3, 9, -82, 10]
sorted_arr = merge_sort(arr)
print("排序后的数组:", sorted_arr)
3 分治法应用
3.1 最近点对问题
问题描述:平面中存在一些点,计算欧几里得距离最近的两个点。
3.1.1 Python3代码
import math
def closest_pair(points):
"""
查找最近点对
:param points: 点的列表,每个点是一个元组 (x, y)
:return: 最近点对的距离和对应的点
"""
# 将点按 x 坐标排序
points.sort(key=lambda point: point[0])
return closest_pair_rec(points)
def closest_pair_rec(points):
"""
递归查找最近点对
:param points: 排序后的点列表
:return: 最近点对的距离和对应的点
"""
n = len(points)
if n <= 3: # 小于等于3个点时,使用暴力法
return brute_force(points)
mid = n // 2
mid_point = points[mid]
# 递归查找左右两侧的最近点对
dl = closest_pair_rec(points[:mid]) # 左侧
dr = closest_pair_rec(points[mid:]) # 右侧
# 找到左侧和右侧的最小距离
d = min(dl[0], dr[0])
# 创建一个带状区域
strip = [point for point in points if abs(point[0] - mid_point[0]) < d]
# 在带状区域中查找最近点对
return min(dl, dr, closest_in_strip(strip, d), key=lambda x: x[0])
def brute_force(points):
"""
暴力法查找最近点对
:param points: 点的列表
:return: 最近点对的距离和对应的点
"""
min_dist = float('inf')
closest_points = None
for i in range(len(points)):
for j in range(i + 1, len(points)):
dist = distance(points[i], points[j])
if dist < min_dist:
min_dist = dist
closest_points = (points[i], points[j])
return min_dist, closest_points
def closest_in_strip(strip, d):
"""
在带状区域中查找最近点对
:param strip: 带状区域中的点
:param d: 当前已知的最小距离
:return: 最近点对的距离和对应的点
"""
min_dist = d
closest_points = None
strip.sort(key=lambda point: point[1]) # 按 y 坐标排序
for i in range(len(strip)):
for j in range(i + 1, len(strip)):
if strip[j][1] - strip[i][1] >= min_dist: # 超过最小距离则停止
break
dist = distance(strip[i], strip[j])
if dist < min_dist:
min_dist = dist
closest_points = (strip[i], strip[j])
return min_dist, closest_points
def distance(p1, p2):
"""
计算两点之间的欧几里得距离
:param p1: 点1
:param p2: 点2
:return: 欧几里得距离
"""
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
# 示例使用
if __name__ == "__main__":
points = [(12.3, -30.2), (-40.5, 50.5), (5.0, 1.0), (2.5, 3.1), (7.5, -1.1), (3.4, -4.2)]
min_distance, closest_points = closest_pair(points)
print("最近点对的距离:", min_distance)
print("最近点对的点:", closest_points)
3.1.2 分治法思路说明
- 分解(Divide)
- 分割点集:首先,将输入的点集按 x x x 坐标进行排序。然后将点集分为左右两个子集。这个分割的过程是通过计算中间点的索引来实现的。
- 示例:对于点集 points,通过 mid = n // 2 找到中间点 mid_point,然后递归调用 closest_pair_rec(points[:mid]) 和 closest_pair_rec(points[mid:]) 来处理左侧和右侧的点集。
- 解决(Conquer)
- 递归求解:分别递归地计算左侧和右侧子集的最近点对。调用 closest_pair_rec 函数会继续将子集进一步分解,直到子集的大小小于等于 3。
- 暴力法处理小子集:当点的数量小于等于 3 时,直接使用暴力法(brute_force 函数)计算最近点对的距离。这是因为对于小规模数据,暴力法的效率是可以接受的。
- 合并(Combine)
- 计算带状区域:在获得左右子集的最近点对距离后,取二者的最小值 d。接下来,创建一个带状区域(strip),该区域的宽度为 2d,包含可能的最近点对。
- 带状区域内查找:在带状区域的点集中,通过调用 closest_in_strip 函数查找最近点对。这个步骤是合并的关键,因为它考虑了可能跨越中间线的点对。
- 基本情况
- 递归终止条件:在 closest_pair_rec 函数中,当点的数量小于等于 3 时,直接调用暴力法进行处理。这是递归的基本情况,确保算法能够终止。
3.2 快速傅里叶变换(FFT)
快速傅里叶变换(Fast Fourier Transform, FFT)是计算离散傅里叶变换(Discrete Fourier Transform, DFT)及其逆变换的高效算法。FFT 的主要目标是将时间域信号转换为频域表示,从而分析信号的频率成分。快速傅里叶变换是现代信号处理中的基础算法之一,广泛应用于信号处理、音频和图像处理、通信、科学计算和生物医学等多个领域。
问题描述:
给定一个长度为
N
N
N的序列
x
[
n
]
x[n]
x[n],计算其离散傅里叶变换,得到频域表示
X
[
k
]
X[k]
X[k]。离散傅里叶变换的公式为:
X
[
k
]
=
∑
n
=
0
N
−
1
x
[
n
]
⋅
e
−
2
π
i
n
k
N
(
k
=
0
,
1
,
2
,
…
…
,
N
−
1
)
X[k]=\sum_{n=0}^{N-1}x[n]\cdot e^{-2\pi i\frac{nk}{N}} \;\;\; (k=0, 1, 2, ……,N-1)
X[k]=n=0∑N−1x[n]⋅e−2πiNnk(k=0,1,2,……,N−1)
其中:
- X [ k ] X[k] X[k] 是频域信号的第 k k k 个分量
- x [ n ] x[n] x[n] 是时域信号的第 n n n 个样本
- N N N 是信号的总个数
3.2.1 Python3代码
import cmath
def fft(x):
"""
快速傅里叶变换
:param x: 输入信号,必须是 2 的幂次
:return: 输入信号的傅里叶变换结果
"""
N = len(x)
if N <= 1: # 基本情况:如果只有一个元素,直接返回
return x
# 分解:将输入信号分为偶数和奇数索引的元素
even = fft(x[0::2]) # 偶数部分
odd = fft(x[1::2]) # 奇数部分
# 合并:计算傅里叶变换
T = [cmath.exp(-2j * cmath.pi * k / N) * odd[k] for k in range(N // 2)]
# 组合结果
return [even[k] + T[k] for k in range(N // 2)] + \
[even[k] - T[k] for k in range(N // 2)]
# 示例使用
if __name__ == "__main__":
# 输入信号,长度为 8(2 的幂次)
x = [0, 1, 0, 0, 0, 0, 0, 0] # 示例输入信号
print("输入信号:", x)
y = fft(x)
print("傅里叶变换结果:", y)
3.2.1 分治法思路说明
- 函数 fft(x):
- 输入信号 x 是一个列表,长度必须是 2 的幂次。
- 当 N <= 1 时,直接返回输入信号作为基本情况。
- 使用递归将信号分为偶数和奇数部分。
- 分解:
- even = fft(x[0::2]) 获取偶数索引的元素。
- odd = fft(x[1::2]) 获取奇数索引的元素。
- 合并:
- 计算合并部分 T,使用复数指数函数 cmath.exp 来计算傅里叶变换的核心公式。
- 最后将偶数部分与奇数部分的结果结合,形成最终的傅里叶变换结果。
3.3 最长公共子序列问题
最长公共子序列(Longest Common Subsequence, LCS)问题是指在给定的两个序列(通常是字符串)中,找出它们的最长公共子序列。子序列是指可以通过删除一些元素(可以不删除任何元素)而不改变其余元素顺序得到的序列。
问题描述:
给定两个序列 X 和 Y,求它们的最长公共子序列的长度,以及具体的子序列。
3.3.1 Python3代码
def lcs(X, Y):
"""
基于分治法的最长公共子序列(LCS)实现
:param X: 字符串 X
:param Y: 字符串 Y
:return: 最长公共子序列的长度和具体的子序列
"""
length, sequence = lcs_helper(X, Y, len(X), len(Y))
return length, sequence
def lcs_helper(X, Y, m, n):
"""
递归求解 LCS 的辅助函数
:param X: 字符串 X
:param Y: 字符串 Y
:param m: 字符串 X 的长度
:param n: 字符串 Y 的长度
:return: LCS 的长度和具体的子序列
"""
# 基本情况:如果任一字符串为空,LCS 长度为 0,子序列为空
if m == 0 or n == 0:
return 0, ""
# 如果最后一个字符相同,递归求解剩余部分
if X[m - 1] == Y[n - 1]:
length, sequence = lcs_helper(X, Y, m - 1, n - 1)
return length + 1, sequence + X[m - 1]
# 如果最后一个字符不同,递归求解去掉最后一个字符的两种情况
else:
left_length, left_sequence = lcs_helper(X, Y, m, n - 1)
right_length, right_sequence = lcs_helper(X, Y, m - 1, n)
# 返回较长的序列及其长度
if left_length > right_length:
return left_length, left_sequence
else:
return right_length, right_sequence
# 示例使用
if __name__ == "__main__":
X = "AGGTA088*B"
Y = "GXTXA087Y*B"
length, sequence = lcs(X, Y)
print("最长公共子序列的长度:", length)
print("最长公共子序列的值:", sequence)
3.3.2 分治法思路说明
- 分解(Divide):
- 将问题分解为更小的子问题。对于两个字符串
X
X
X 和
Y
Y
Y,考虑它们的最后一个字符:
- 如果 X [ m − 1 ] X[m-1] X[m−1] 和 Y [ n − 1 ] Y[n-1] Y[n−1] 相同,则这个字符必定属于$ LCS$。此时问题可以转化为求解 l c s ( X [ 0 : m − 1 ] , Y [ 0 : n − 1 ] ) lcs(X[0:m-1], Y[0:n-1]) lcs(X[0:m−1],Y[0:n−1]),即去掉这两个字符后的 L C S LCS LCS。
- 如果
X
[
m
−
1
]
X[m-1]
X[m−1] 和
Y
[
n
−
1
]
Y[n-1]
Y[n−1] 不同,则需要考虑两种情况:
(1)去掉 X X X 的最后一个字符,求解 l c s ( X [ 0 : m − 1 ] , Y [ 0 : n ] ) lcs(X[0:m-1], Y[0:n]) lcs(X[0:m−1],Y[0:n])。
(2)去掉 Y Y Y 的最后一个字符,求解 l c s ( X [ 0 : m ] , Y [ 0 : n − 1 ] ) lcs(X[0:m], Y[0:n-1]) lcs(X[0:m],Y[0:n−1])。
- 将问题分解为更小的子问题。对于两个字符串
X
X
X 和
Y
Y
Y,考虑它们的最后一个字符:
- 解决(Conquer):
- 对每个分解出的子问题递归调用
l
c
s
_
h
e
l
p
e
r
lcs\_helper
lcs_helper 函数。每次调用根据最后一个字符的匹配情况决定下一步的递归路径:
- 如果字符相同,继续求解去掉这两个字符后的 L C S LCS LCS。
- 如果字符不同,分别计算去掉一个字符后的两种情况。 - 合并(Combine):
- 在递归返回时,结合子问题的解以构建最终的
L
C
S
LCS
LCS:
- 当两个字符相同时, L C S LCS LCS 长度加 1,并将这个字符添加到 L C S LCS LCS 中。
- 当字符不同时,取去掉一个字符的两种情况中较长的 L C S LCS LCS 作为结果。
- 在递归返回时,结合子问题的解以构建最终的
L
C
S
LCS
LCS:
- 基本情况:
递归的基本情况是当任一字符串的长度为 0 时, L C S LCS LCS 的长度为 0,且子序列为空。这是递归终止的条件。
4 总结
分治法是一种解决问题的方法,基本思路是把一个复杂的问题拆分成几个简单的小问题,先解决这些小问题,然后把它们的结果合起来,得到原问题的答案。
(1)优点
- 节省时间:很多问题通过分治法可以把时间复杂度大幅度降低。
- 并行处理:因为小问题可以独立解决,所以适合用在并行计算中。
(2)缺点
- 递归深度:如果递归层数太多,可能会导致栈溢出,尤其是在处理超大数据时。
- 合并复杂:有些问题在合并结果时可能比较复杂,难以实现。
总结:
以下是适合使用分治法解决的问题的特点:
- 可分解性
- 问题可以拆分:这些问题能够被拆分成多个较小的子问题,且这些子问题通常具有相似的结构。
- 递归性质:每个子问题可以通过相同的方法继续拆分,直到达到基本情况。
- 独立性
- 子问题独立:子问题的解决通常不依赖于其他子问题的结果,这使得它们可以独立地被解决。
- 并行计算:因为子问题是独立的,分治法很适合并行处理,能够利用多核处理器的优势。