分治法
许多算法在结构上是递归的:为解决一个给定的问题,算法一次或多次递归地调用其自身以解决紧密相关的若干子问题。这些算法典型地遵循分治法的思路:将原问题分解为几个规模较小但是类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。
分治法的三个步骤:
分解原问题为若干子问题,这些子问题是原问题的规模较小的实例。
解决这些子问题,递归地求解各子问题。如果子问题的规模足够小,则直接求解。
合并这些子问题的解成原问题的解。
归并排序算法完全遵循分治模式:
分解:分解待排序的
n
n
n个元素的序列分解为两个各包含
n
2
\frac{n}{2}
2n 个元素的子序列
解决:使用归并排序递归地排序两个子序列
合并:合并两个已经排序地子序列以产生已排序地答案。
当待排序地子序列长度为2时,递归开始回升。
归并排序算法地关键操作是合并步骤中两个已经排序序列地合并。我们通过调用一个辅助算法Merge(array, begin, mid, end)
来完成合并,其中array
是一个数组,begin
、mid
和end
是数组下标,满足
b
e
g
i
n
<
m
i
d
<
e
n
d
begin < mid < end
begin<mid<end。该过程假设
A
[
b
e
g
i
n
…
m
i
d
)
A[begin \dots mid)
A[begin…mid) 和
A
[
m
i
d
…
e
n
d
)
A[mid \dots end)
A[mid…end) 都已排序。它合并这两个子数组形成单一地已排好序地子数组并代替当前地子数组
A
[
b
e
g
i
n
…
e
n
d
)
A[begin \dots end)
A[begin…end)。
算法Merge需要 θ ( n ) \theta(n) θ(n) 的时间,其中 n = e n d − b e g i n ≥ 2 n=end-begin \ge 2 n=end−begin≥2 是待合并元素的总数。
- Merge(A, begin, mid, end)伪代码
n1 = mid - begin
n2 = end - mid
let L[0.. n1) and R[0..n2) be new arrays
for i = 0 to n1
L[i] = A[begin + i]
for j = 0 to n2
R[j] = A[mid + j]
L[n1] = NUM_MAX
R[n2] = NUM_MAX
i = 0
j = 0
for index = begin to end
if L[i] <= R[j]
A[index] = L[i]
i = i + 1
else
A[index] = R[j]
j = j + 1
- 循环不变式 :在开始第12~17行
for
循环的每次迭代时,子数组 A [ b e g i n … i n d e x ) A[begin \dots index) A[begin…index) 按从小到大的顺序包含 L [ 0 … n 1 ) L[0 \dots n_1) L[0…n1) 和 R [ 0 … n 2 ) R[0 \dots n_2) R[0…n2) 中的 i n d e x − b e g i n index-begin index−begin 个最小元素。进而, L [ i ] L[i] L[i] 和 R [ j ] R[j] R[j] 是各自所在数组中未被复制回数组 A A A 的最小元素。 - 初始化: 循环在第一次之前,有 i n d e x = b e g i n index=begin index=begin,所以子数组 A [ b e g i n … i n d e x ) A[begin \dots index) A[begin…index) 为空。这个空的子数组包含 L L L 和 R R R 的 i n d e x − b e g i n = 0 index-begin=0 index−begin=0 个最小元素, i = j = 0 i=j=0 i=j=0,所以 L [ i ] L[i] L[i] 和 R [ j ] R[j] R[j] 都是各自所在数组中未被复制回数组A的最小元素。
- 保持:假设 L [ i ] ≤ R [ j ] L[i] \le R[j] L[i]≤R[j]。这时, L [ i ] L[i] L[i] 是未被复制回数组A的最小元素。因为 A [ b e g i n … i n d e x ) A[begin \dots index) A[begin…index) 包含 i n d e x − b e g i n index-begin index−begin 个最小元素。增加 i n d e x index index 的值和 i i i 的值后,为下次迭代重新建立了该循环不变式。
- 终止:终止时 i n d e x = e n d index=end index=end。根据循环不变式,子数组 A [ b e g i n … i n d e x ) A[begin \dots index) A[begin…index) 就是 A [ b e g i n … e n d ) A[begin \dots end) A[begin…end) 且从小到大的顺序包含 L [ 1 … n 1 ) L[1 \dots n_1) L[1…n1) 和 R [ 1 … n 2 ) R[1 \dots n_2) R[1…n2) 中的 i n d e x − b e g i n = e n d − b e g i n index - begin = end - begin index−begin=end−begin 个最小元素。数组 L L L 和 R R R 一起包含 n 1 + n 2 + 2 = e n d − b e g i n + 2 n_1 + n_2 + 2 = end - begin + 2 n1+n2+2=end−begin+2 个元素,除去标志位,其他所有元素都已被复制回数组 A A A。
使用Merge-Sort(A, begin, end)
对子数组
A
[
b
e
g
i
n
,
e
n
d
)
A[begin, end)
A[begin,end) 进行排序。如果
(
e
n
d
−
b
e
g
i
n
)
<
2
(end - begin) < 2
(end−begin)<2,则该子数组中至多只有一个元素,当然是已排序得。否则,分解步骤就计算出一个下标,将
A
[
b
e
g
i
n
,
e
n
d
)
A[begin, end)
A[begin,end) 分成
A
[
b
e
g
i
n
,
m
i
d
)
A[begin, mid)
A[begin,mid) 和
A
[
m
i
d
,
e
n
d
)
A[mid, end)
A[mid,end),各含
f
l
o
o
r
(
n
/
2
)
floor(n/2)
floor(n/2)个元素。
- Merge-Sort(A, begin, end) 伪代码
if (end - begin) >= 2
mid = floor((begin + end) / 2)
Merge-Sort(A, begin, mid)
Merge-Sort(A, mid, end)
Merge(A, begin, mid, end)
对
{
5
,
2
,
4
,
7
,
1
,
3
,
2
,
6
}
\{5, 2, 4, 7, 1, 3, 2, 6\}
{5,2,4,7,1,3,2,6}序列排序:
分治法分析
当一个算法种含有对其自身的递归调用时,其运行时间可以用一个递归方程来表示。分治法种的递归式是基于基本模式种的三个步骤的。设
T
(
n
)
T(n)
T(n) 为一个规模为
n
n
n 的问题的运行时间。如果问题的规模足够小,如
n
≤
c
n \le c
n≤c(
c
c
c为一个常量),则得到它直接解的时间为常量,写作
θ
(
1
)
\theta(1)
θ(1)。把原问题分解为
a
a
a 个子问题,每个大小是原问题的
1
b
\dfrac{1}{b}
b1。对于合并排序,
a
a
a 和
b
b
b 都是2,分解问题和合并问题的时间各自为
D
(
n
)
D(n)
D(n) 和
C
(
n
)
C(n)
C(n),则得到递归式:
T
(
n
)
=
{
θ
(
1
)
n
≤
c
a
T
(
n
b
)
+
D
(
n
)
+
C
(
n
)
n
>
c
T(n) = \left\{ \begin{array}{lr} \theta(1) & n \le c \\ aT(\dfrac{n}{b}) + D(n) + C(n) & n > c \end{array} \right.
T(n)={θ(1)aT(bn)+D(n)+C(n)n≤cn>c
- 合并排序算法的分析
假定合并排序算法问题的规模式2的幂次,这样每次产生的子序列的长度就恰好为 n 2 \dfrac{n}{2} 2n。
当 T ( n ) T(n) T(n) 在最坏情况下合并排序 n n n 个数。合并排序一个元素的时间是个常量。当 n > 1 n>1 n>1 时: - 分解: 计算子数组中间位置,需要常量时间, D ( n ) = θ ( 1 ) D(n) = \theta(1) D(n)=θ(1)。
- 解决: 递归地解两个规模为 n 2 \dfrac{n}{2} 2n 的子问题,时间为 2 T ( n 2 ) 2T(\dfrac{n}{2}) 2T(2n)。
- 合并: 在一个含有
n
n
n 个元素的子数组上,合并过程的运行时间为
θ
(
n
)
\theta(n)
θ(n),则
C
(
n
)
=
θ
(
n
)
C(n) = \theta(n)
C(n)=θ(n)。
所以合并排序最坏的运行时间为:
T ( n ) = { θ ( 1 ) n = 1 2 T ( n 2 ) + θ ( n ) n > 1 T(n) = \left\{ \begin{array}{lr} \theta(1) & n = 1 \\ 2T(\dfrac{n}{2}) + \theta(n) & n > 1 \end{array} \right. T(n)={θ(1)2T(2n)+θ(n)n=1n>1
对于如何证明 T ( n ) T(n) T(n) 为 $\theta(n \log_2 n) $,将在之后讨论。
- 通过等价树理解递归式
T ( n ) = { c n = 1 2 T ( n 2 ) + c n n > 1 T(n) = \left\{ \begin{array}{lr} c & n = 1 \\ 2T(\dfrac{n}{2}) + cn & n > 1 \end{array} \right. T(n)={c2T(2n)+cnn=1n>1
其中常量
c
c
c 代表规模为1的问题所需的时间,也是在解决和合并步骤中处理每个数组元素所需的时间。
假设
n
n
n 是2的整数幂。它在下图中被扩展为递归式的一种等价树形表示。
c
n
cn
cn项时树根(即顶层递归的代价),根的两颗子树时两个更小一点的递归式
T
(
n
2
)
T(\dfrac{n}{2})
T(2n)。在第二层递归的两个子结点中,每一个结点的代价都是
c
(
n
/
2
)
c(n/2)
c(n/2)。继续在树中扩展每个结点,即将其分解成由递归式所决定的各个组成部分,直到问题的规模降到1,这是每个问题的代价为
c
c
c。
最顶层的总代价为 c n cn cn,下一层的总代价为 c ( n 2 ) + c ( n 2 ) = c n c(\dfrac{n}{2}) + c(\dfrac{n}{2}) = cn c(2n)+c(2n)=cn,再往下一层,总代价为 c ( n 4 ) + c ( n 4 ) + c ( n 4 ) + c ( n 4 ) = c n c(\dfrac{n}{4}) + c(\dfrac{n}{4}) + c(\dfrac{n}{4}) + c(\dfrac{n}{4}) = cn c(4n)+c(4n)+c(4n)+c(4n)=cn。依次类推,最顶层之下的第 i i i 层有 2 i 2^i 2i 个结点,每一个的代价都是 c ( n 2 i ) c(\dfrac{n}{2^i}) c(2in),于是,顶层之下的第 i i i 层的总代价为 2 i c ( n 2 i ) = c n 2^ic(\dfrac{n}{2^i}) = cn 2ic(2in)=cn。在最底层,共有 n n n 个结点,每一个结点的代价为 c c c,该层的总代价为 c n cn cn。
在图中“递归树”中总的层数为
log
2
n
+
1
\log_2 n + 1
log2n+1,整棵树的总代价就是
c
n
(
log
2
n
+
1
)
=
c
n
log
2
n
+
c
n
cn(\log_2 n + 1) = cn \log_2 n + cn
cn(log2n+1)=cnlog2n+cn。忽略低阶项和常量
c
c
c,即得结果
θ
(
n
log
2
n
)
\theta(n \log_2 n)
θ(nlog2n)。
练习
- 2.3-1
说明合并排序在输入数组 A = { 3 , 41 , 52 , 26 , 38 , 57 , 9 , 49 } A=\{3,41,52,26,38,57,9,49\} A={3,41,52,26,38,57,9,49}上的执行过程。
import sys
def merge(array, begin, mid, end):
a = array[begin : mid]
b = array[mid : end]
a.append(sys.maxsize)
b.append(sys.maxsize)
aIndex = 0
bIndex = 0
for i in range(begin, end):
if a[aIndex] <= b[bIndex]:
array[i] = a[aIndex]
aIndex += 1
else:
array[i] = b[bIndex]
bIndex += 1
def mergeSort(array, begin, end):
if (end - begin) >= 2:
mid = (begin + end) // 2
mergeSort(array, begin, mid)
mergeSort(array, mid, end)
merge(array, begin, mid, end)
print(array)
if __name__ == "__main__":
array = [3, 41, 52, 26, 38, 57, 9, 49]
mergeSort(array, 0, len(array))
改写Merge
过程,使之不使用哨兵元素,而是在一旦数组
L
L
L或
R
R
R中的所有元素都被复制回数组
A
A
A时,就立即停止,再将另一个数组中余下的元素复制回数组
A
A
A中。
# -*- coding:utf-8 -*-
import sys, random
def merge(array, begin, mid, end):
a = array[begin : mid]
b = array[mid : end]
aIndex = 0
bIndex = 0
for i in range(begin, end):
if aIndex == len(a):
array[i: end] = b[bIndex:]
break
if bIndex == len(b):
array[i: end] = a[aIndex:]
break
if a[aIndex] <= b[bIndex]:
array[i] = a[aIndex]
aIndex += 1
else:
array[i] = b[bIndex]
bIndex += 1
def mergeSort(array, begin, end):
if (end - begin) >= 2:
mid = (begin + end) // 2
mergeSort(array, begin, mid)
mergeSort(array, mid, end)
merge(array, begin, mid, end)
print(array)
if __name__ == "__main__":
array = random.sample([x for x in range(100)], 20)
mergeSort(array, 0, len(array))
- 2.3-3
利用数学归纳法证明:当
n
n
n时2得整数次幂时,递归式
T
(
n
)
=
{
2
n
=
2
2
T
(
n
2
)
+
n
n
=
2
k
k
>
1
T(n) = \left\{ \begin{array}{l} 2 & n = 2 \\ 2T(\dfrac{n}{2}) + n & n = 2^k k > 1 \end{array} \right.
T(n)={22T(2n)+nn=2n=2kk>1
的解为
T
(
n
)
=
n
log
2
n
T(n)= n\log_2 n
T(n)=nlog2n
解: 给出基本条件:
当
n
=
2
n = 2
n=2 时,
T
(
2
)
=
2
log
2
2
=
2
T(2) = 2 \log_2 2 = 2
T(2)=2log22=2。符合条件。
给出假设:
当
n
=
2
t
n = 2^t
n=2t 时,
T
(
2
t
)
=
2
log
2
2
t
T(2^t) = 2\log_2 2^t
T(2t)=2log22t。
那么,
当
n
=
2
t
+
1
n = 2^{t+1}
n=2t+1 时,
T ( 2 t + 1 ) = 2 T ( 2 t + 1 2 ) + 2 t + 1 = 2 T ( 2 t ) + 2 t + 1 = 2 ( 2 t log 2 2 t ) + 2 t + 1 = 2 t + 1 log 2 2 t + 1 \begin{array}{l} T(2^{t+1}) &=& 2T(\dfrac{2^{t+1}}{2}) + 2^{t+1} \\ &=& 2T(2^t) + 2^{t+1} \\ &=& 2(2^t\log_2 2^t) + 2^{t+1} \\ &=& 2^{t+1}\log_2 2^{t+1} \end{array} T(2t+1)====2T(22t+1)+2t+12T(2t)+2t+12(2tlog22t)+2t+12t+1log22t+1
所以得证。
- 2.3-4
插入排序可以改写为一个递归版本:为排序 A [ 1.. n ] A[1..n] A[1..n],先递归的排序 A [ 1.. n − 1 ] A[1..n-1] A[1..n−1],然后再将 A [ n ] A[n] A[n]插入到已排序的数组 A [ 1.. n − 1 ] A[1..n-1] A[1..n−1]中去。对于插入排序的这一递归版本,为它的运行时间写一个递归式。
T ( n ) = { θ ( 1 ) n = 1 θ ( n − 1 ) + θ ( n ) n > 1 T(n) = \left\{ \begin{array}{l} \theta(1) & n = 1 \\ \theta(n-1) + \theta(n) & n > 1 \end{array} \right. T(n)={θ(1)θ(n−1)+θ(n)n=1n>1
- 2.3-5
回顾一下练习2.1-3中提出的查找问题,注意如果序列A是已排序的,就可以将该序列的中点与 v v v 进行比较。根据比较的结果,原序列中有一半就可以不用再做进一步的考虑了。二分查找(binarysearch)就是一个不断重复这一查找过程的算法,它每次都将序列余下的部分分成两半,并只对其中的一半做进一步的查找。写出二分查找算法的伪代码,可以是迭代的,也可以是递归的。说朋二分查找算法的最坏情况运行时间为什么是 θ ( l o g 2 n ) \theta(log_2 n) θ(log2n)
解:
T
(
n
)
=
{
θ
(
1
)
n
=
2
T
(
n
2
)
n
>
2
T(n) = \left\{ \begin{array}{lr} \theta(1) & n = 2 \\ T(\dfrac{n}{2}) & n > 2 \end{array} \right.
T(n)={θ(1)T(2n)n=2n>2
分解: 分解步骤仅计算子数组的中间位置,需要常量时间,因此
D
(
n
)
=
θ
(
1
)
D(n)=\theta (1)
D(n)=θ(1)。
解决: 递归地求解1个规模为
n
/
2
n/2
n/2的子问题,使用
T
(
n
/
2
)
T(n/2)
T(n/2)地运行时间。
合并: 当找到解或无解时返回,需要常量时间
C
(
n
)
=
θ
(
1
)
C(n) = \theta(1)
C(n)=θ(1)。
假设
n
=
2
m
n=2^m
n=2m,序列中不包含key值,则二分查找面对最坏情况。
第1次:序列中包含
2
m
2^m
2m 个元素;第2次:序列中包含
2
(
m
−
1
)
2^(m-1)
2(m−1) 个元素;第3次:序列中包含
2
(
m
−
2
)
2^(m-2)
2(m−2) 个元素;依次类推,第m次:序列中包含
2
0
=
1
2^0 = 1
20=1个元素。完成二分查找。
∵
n
=
2
m
m
=
log
2
n
∴
θ
(
m
)
=
θ
(
log
2
n
)
\begin{array}{ll} &\because& n = 2^m \\ &&m = \log_2 n\\ &\therefore& \theta(m) = \theta(\log_2 n) \end{array}
∵∴n=2mm=log2nθ(m)=θ(log2n)
import random
def lower_bound(array, begin, end, key):
if(begin == end):
return begin
mid = (begin + end) // 2
if(array[mid] < key):
begin = mid + 1
else:
end = mid
return lower_bound(array, begin, end, key)
def binarySearch(array, begin, end, key):
first = lower_bound(array, begin, end, key)
if ((first == end)) or ((key != array[first])):
return None
else:
return first
if __name__ == "__main__":
array = [1, 4, 6, 7, 11, 16, 27, 34, 35, 47]
print(array)
for i in range(len(array)):
index = binarySearch(array, 0, len(array), array[i])
print("find: {0} at {1}".format(array[i], index))
index = binarySearch(array, 0, len(array), 100)
print("find: {0} at {1}".format(100, index))
index = binarySearch(array, 0, len(array), 5)
print("find: {0} at {1}".format(5, index))
# -*- coding:utf-8 -*-
import random
def lower_bound(array, first, last, value):
while(first < last):
mid = (first + last) // 2
if(array[mid] < value):
first = mid + 1
else:
last = mid
return first
def binarySearch(array, first, last, value):
first = lower_bound(array, first, last, value)
if ((first == last)) or ((value != array[first])):
return None
else:
return first
if __name__ == "__main__":
array = [1, 4, 6, 7, 11, 16, 27, 34, 35, 47]
print(array)
for i in range(len(array)):
index = binarySearch(array, 0, len(array), array[i])
print("find: {0} at {1}".format(array[i], index))
index = binarySearch(array, 0, len(array), 100)
print("find: {0} at {1}".format(100, index))
index = binarySearch(array, 0, len(array), 5)
print("find: {0} at {1}".format(5, index))
- 2.3-6
观察一下2.1节中给出的INSERTION-SORT过程,在第5一7行的while
循环中,采用了一种线性查找策略,在已排序的子数组 A [ 1.. j − 1 ] A[1..j-1] A[1..j−1]中(反向)扫描。是否可以改用二分查找策略(见练习2.3-51),来将插人排序的总体最坏情况运行时间改善至 θ ( n log 2 n ) \theta(n \log_2 n) θ(nlog2n)?
解: 查找时间可以缩短至 θ ( n log 2 n ) \theta(n \log_2 n) θ(nlog2n),但移动元素得时间为 θ ( n 2 ) \theta(n^2) θ(n2) 所以不能改进至 θ ( n log 2 n ) \theta(n \log_2 n) θ(nlog2n)。
from random import sample
from BinarySearchIterative import lower_bound
def InsertionSort(array):
for i in range(1, len(array)):
key = array[i]
index = lower_bound(array, 0, i, key)
for j in range(i, index, -1):
array[j] = array[j - 1]
array[index] = key
print(array)
if __name__ == "__main__":
array = sample([x for x in range(100)], 20)
InsertionSort(array)
- 2.3-7
请描述一个运行时间为 θ ( n log 2 n ) \theta(n \log_2 n) θ(nlog2n) 的算法,给你 n n n 个整数的集合 S S S 和另一个整数 x x x,该算法能确定 S S S 中是否存在两个其和刚好为 x x x 的的元素。
解: 先用归并排序对 S S S 排序,运行时间为 θ ( n log 2 n ) \theta(n \log_2 n) θ(nlog2n),再用二分查找对 1 → n 1 \to n 1→n范围内的数查找 y = x − n y = x - n y=x−n,运行时间为 n θ ( log 2 n ) n\theta( \log_2 n) nθ(log2n). 所以总的运行时间为 θ ( n log 2 n ) \theta(n \log_2 n) θ(nlog2n)。