Chapter 1 算法在计算中的作用
1.1 算法
算法: 用于求解良说明的计算问题的工具.
拿一个排序问题作为研究算法的例子.
输入: n个数的一个序列 < a 1 , a 2 , … , a n > <a_1,a_2,\dots,a_n> <a1,a2,…,an>
输出: 输入序列的一个排列 < a 1 ′ , a 2 ′ , … , a n ′ > <a_1',a_2',\dots,a_n'> <a1′,a2′,…,an′>, 满足 a 1 ′ ≤ a 2 ′ ≤ ⋯ ≤ a n ′ a_1'\le a_2'\le \dots \le a_n' a1′≤a2′≤⋯≤an′.
例如给定输入序列 < 31 , 41 , 59 , 26 , 41 , 58 > <31,41,59,26,41,58> <31,41,59,26,41,58>, 排序算法将返回序列 < 26 , 31 , 41 , 41 , 58 > <26,31,41,41,58> <26,31,41,41,58>作为输出. 这样一个输入序列称为排序问题的一个实例(instance). 一般来说, 问题实例由计算该问题所必须的(满足问题陈述中强加的各种约束的)输入组成.
算法问题的共有的两个特征:
- 存在许多候选解, 但绝大多数候选解都没有解决手头的问题.
- 存在实际应用.
1.2 作为技术的算法
Chapter 2 算法基础
2.1 插入排序
插入排序的伪代码如下所示
INSERTION-SORT(A)
for j = 2 to A.length
key = A[j]
//Insert A[j] into the sorted sequence A[1..j - 1].
i = j - 1
while i>0 and A[i]>key
A[i+1] = A[i]
i = i - 1
A[i+1] = key
循环不变式: 也就是伪代码中所说的sequence A[1…j-1]
循环不变式需要被证明三条性质:
- 初始化: 循环的第一次迭代之前, 它为真.
- 保持: 如果循环的某次迭代之前它为真, 那么下次迭代之前它仍为真.
- 终止: 如果循环终止时, 不变式为我们提供了一个有用的性质, 该性质有助于证明算法是正确的.
练习:
实现两个二进制整数的加法问题
def bin_plus(A, B):
A = A[::-1]
B = B[::-1]
for i in range(0,len(A)):
if A[i]==1:
j = i
while B[j]==1:
B[j] = 0
j = j + 1
if j==len(B):
B.append(0)
B[j] = 1
return B[::-1]
2.2 分析算法
基于随机访问机(random-access machine, RAM)来作为探讨算法效率, 分析算法的模型, 力图较好地还原一个计算机在运行此算法时的一个过程.
我们认为一个RAM模型一般包含如下指令:
- 算术指令(加法. 减法, 乘法, 除法, 取余, 向下取整, 向上取整)
- 数据移动指令(装入, 储存, 复制)
- 控制指令(条件与无条件转移, 子程序调用与返回)
不考虑实际计算机中的多层内存, 如高速缓存或虚拟内存.
插入算法的分析
输入规模: 这一概念依赖于研究的问题. 比如排序或者计算傅立叶变换, 最自然的度量就是输入中的项数. 但如果是两个整数相乘, 输入规模的最佳量度是用通常的二进制记号表示输入所需的总位数.
运行时间: 一个算法在特定输入上的运行时间是指执行的基本操作数或步数. 我们认为执行每行伪代码需要常量时间. 但是应当区分调用子程序的过程和执行子程序的过程.
下面我们来分析插入算法:
INSERTION-SORT(A) | 代价 | 次数 |
---|---|---|
for j = 2 to A.length | c 1 c_1 c1 | n n n |
key = A[j] | c 2 c_2 c2 | n − 1 n-1 n−1 |
//Insert A[j] into the sorted sequence A[1…j - 1]. | 0 | n − 1 n-1 n−1 |
i = j - 1 | c 4 c_4 c4 | n − 1 n-1 n−1 |
while i>0 and A[i]>key | c 5 c_5 c5 | ∑ j = 2 n t j \sum^n_{j=2}t_j ∑j=2ntj |
A[i+1] = A[i] | c 6 c_6 c6 | ∑ j = 2 n ( t j − 1 ) \sum^n_{j=2}(t_j-1) ∑j=2n(tj−1) |
i = i - 1 | c 7 c_7 c7 | ∑ j = 2 n ( t j − 1 ) \sum^n_{j=2}(t_j-1) ∑j=2n(tj−1) |
A[i+1] = key | c 8 c_8 c8 | n − 1 n-1 n−1 |
由此可得. 运行的总时长为:
T
(
n
)
=
c
1
n
+
c
2
(
n
−
1
)
+
c
4
(
n
−
1
)
+
c
5
∑
j
=
2
n
t
j
+
c
6
∑
j
=
2
n
(
t
j
−
1
)
+
c
7
∑
j
=
2
n
(
t
j
−
1
)
+
c
8
(
n
−
1
)
T(n)=c_1 n+c_2(n-1)+c_4(n-1)+c_5\sum^n_{j=2}t_j+c_6\sum^n_{j=2}(t_j-1)+c_7\sum^n_{j=2}(t_j-1)+c_8(n-1)
T(n)=c1n+c2(n−1)+c4(n−1)+c5j=2∑ntj+c6j=2∑n(tj−1)+c7j=2∑n(tj−1)+c8(n−1)
对于最糟糕的情况, 运行时间形如:
T
(
n
)
=
a
n
2
+
b
n
+
c
T(n)=an^2+bn+c
T(n)=an2+bn+c
那么对于一种最理想的情况, 则是输入的数组已经经过排序了, 那么不难发现, 这是一个线性的函数.
但是对于一个逆序排序的函数, 这则是一个二次函数.
增长量级
只考虑公式中最重要的项, 如之前提到的最坏情况公式中的 a n 2 an^2 an2. 我们将这种情况记为: Θ ( n 2 ) \Theta(n^2) Θ(n2).
对于一个足够大的输入, 一个 Θ ( n 2 ) \Theta(n^2) Θ(n2)的算法在最坏情况下比一个 Θ ( n 3 ) \Theta(n^3) Θ(n3)运行得更快.
练习
SLECTION-SORT(A), python代码如下:
def selection_sort(A):
for i in range(0, len(A)):
smallest = i
for j in range(i, len(A)):
if A[smallest] > A[j]:
smallest = j
tmp = A[i]
A[i] = A[smallest]
A[smallest] = tmp
return A
伪代码如下:
n = A.length
for j = 1 to n-1
smaleest = j
for i = j + 1 to n
if A[i]<A[smallest]
smallest=i
exchange A[j] with A[smallest]
很显然, 这是一个 Θ ( n 2 ) \Theta(n^2) Θ(n2)的算法.
2.3 设计算法
我们可以选择很多方法设计算法, 比如插入算法就使用了增量方法: 在排序子数组A[1…j-1]后, 将单个元素A[j]插入子数组的适当位置, 产生排序好的子数组A[1…j]. 在这一部分我们将考察一种称为"分治法"的设计方法. 利用分治法设计的排序算法比之前提到的算法要更为优秀.
2.3.1 分治法
分治法采用了递归的思想, 分治模式在每层递归时都有三个步骤:
- 分解原问题为若干子问题, 这些子问题是原问题的规模较小的实例.
- 解决这些子问题, 递归地求解各子问题. 然而, 若子问题的规模足够小, 则直接求解.
- 合并这些子问题的解成元问题的解.
归并排序的算法完全遵循分治模式, 具体操作如下:
- 分解: 分解待排序的n个元素的序列成各具 n / 2 n/2 n/2个元素的两个子序列.
- 解决: 使用递归排序递归地排序两个子序列.
- 合并: 合并两个已排序的子序列以产生已排序的答案
我们首先实现合并这一步骤, 对于两个已经排序好的数组. 我们想象这是两幅扑克牌, 正面朝上, 看这两张牌, 一张一张抽, 谁小拿谁.
考虑到计算机里会有数组溢出的问题, 书上采用了一种名为哨兵牌的方法, 我们可以先来看一下伪代码.
merge(A, p. q. r)
n1 = q - p + 1
n2 = r -q
let L[1..n1+1] and R[1..n2+1] be new arrays
for i = 1 to n1
L[i] = A[p+i-1]
for j = 1 to n2
R[j] = A[q+j]
L[n1+1] = inf //inf就是哨兵牌
R[n2+1] = inf
i = 1
j = 1
for k = p to r
if L[i]<=R[j]
A[k] = L[i]
i = i + 1
else
A[k] = R[j]
j = j + 1
其实也未必一定需要哨兵牌, 利用如下的方式也可以, 下面是我自己写的python代码:
def merge(A, p, q, r):
n1 = q - p + 1
n2 = r - q
L = A[p:q+1]
R = A[q+1:r+1]
i = 0
j = 0
while i!=n1 or j!=n2:
if i==n1:
A[p+i+j:r+1]=R[j:n2]
break
elif j==n2:
A[p+i+j:r+1]=L[i:n1]
break
elif L[i]<R[j]:
A[p+i+j]=L[i]
i=i+1
else:
A[p+i+j]=R[j]
j=j+1
return A
有了MERGE这个工具之后, 我们就可以开始我们的归并排序算法了.
def merge_sort(A, p, r):
if p<r:
q = int((p+r)/2)
merge_sort(A,p,q)
merge_sort(A,q+1,r)
merge(A,p,q,r)
return A
伪代码如下:
if p<r
p = [(p+r)/2]
MERGE-SORT(A,p,q)
MERGE-SORT(A,q+1,r)
MERGE(A,p,q,r)
2.3.2 分析分治算法
我们首先给出分治算法时间的递归式:
T
(
n
)
=
{
Θ
(
1
)
若
n
≤
c
a
T
(
n
/
b
)
+
D
(
n
)
+
C
(
n
)
其
他
T(n)=\begin{cases} \Theta(1)&若n\le c \\aT(n/b)+D(n)+C(n)&其他 \end{cases}
T(n)={Θ(1)aT(n/b)+D(n)+C(n)若n≤c其他
我们来用言语描述一下:
如果一个问题规模足够小, 如对某个常量 c c c, n ≤ c n\le c n≤c, 则直接求解需要常量时间, 将之写作 Θ ( 1 ) \Theta(1) Θ(1).
假设把原问题分解成 a a a个子问题, 每个子问题的规模是原问题的 1 / b 1/b 1/b.(对于我们的分治算法, a, b均等于2, 不过这不必然). 为了求解一个规模为 n / b n/b n/b的子问题, 需要 T ( n / b ) T(n/b) T(n/b)的时间, 所以需要 a T ( n / b ) aT(n/b) aT(n/b)的时间来求解这 a a a个子问题. 考虑到分解这些问题需要时间 D ( n ) D(n) D(n), 合并子问题成为原问题需要 C ( n ) C(n) C(n)的时间. 因此就可以得到如上形式的递归式.
归并排序算法的分析
暂且考虑元素的数量是 2 n ( n ∈ Z ∗ ) 2^n(n\in Z^*) 2n(n∈Z∗)的情况.
分解: 分解只是计算子数组的中间位置, 只需要常量时间, 因此 D ( n ) = Θ ( 1 ) D(n)=\Theta(1) D(n)=Θ(1)
解决: 由前文所叙, 应为 2 T ( n / 2 ) 2T(n/2) 2T(n/2)的运行时间
合并: 对于一个具有n个元素的子数组, 上过程MERGE需要 Θ ( 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)= \begin{cases} \Theta(1)&若n=1 \\2T(n/2)+\Theta(n)&若n>1 \end{cases}
T(n)={Θ(1)2T(n/2)+Θ(n)若n=1若n>1
练习
利用二分查找实现插入排序, 代码如下
def insert_sort1(A):
for j in range(1, len(A)):
key = A[j]
l = 0
b = j-1
if A[l]>=key: //先判断会不会发生, key落在之前已经排序的数组之外的情况
A.insert(l, key)
del A[j+1]
continue
elif A[b]<=key:
A.insert(b+1, key)
del A[j+1]
continue
m = int((l+b)/2)
while A[m]>=key or key>=A[m+1]://开始二分查找排序
m = int((l+b)/2)
if A[m]>key:
b = m
else:
l = m
A.insert(m+1, key)
del A[j+1]
return A
思考题
2-4
利用归并排序, 计算一个数组的逆序数
def merge_in(A, p, q, r, inver = 0):
n1 = q - p + 1
n2 = r - q
L = A[p:q+1]
R = A[q+1:r+1]
i = 0
j = 0
while i!=n1 or j!=n2:
if i==n1:
A[p+i+j:r+1]=R[j:n2]
break
elif j==n2:
A[p+i+j:r+1]=L[i:n1]
break
elif L[i]<R[j]:
A[p+i+j]=L[i]
i=i+1
else:
A[p+i+j]=R[j]
j=j+1
inver = inver + n1-i
# 如果我们从右边的数组抽出数字, 放到新的数组中, 那么这个时候,
# 左边的数组还剩几个元素没有放入新的数组当中, 逆序数就会减少多少
return inver
def merge_sortin(A, p, r, inver=0):
if p<r:
q = int((p+r)/2)
inver = merge_sortin(A,p,q,inver)
inver = merge_sortin(A,q+1,r,inver)
inver=merge_in(A,p,q,r,inver)
# 把每一次迭代减少的逆序数加总在一起, 就可以获得原来数组的逆序数
return inver
Chapter 3 函数的增长
3.1 渐近记号
Θ \Theta Θ记号
Θ
\Theta
Θ记号渐近地给出一个函数的上界和下界.
Θ
(
g
(
n
)
)
=
{
f
(
n
)
:
存
在
正
常
量
c
1
,
c
2
和
n
0
,
使
得
对
所
有
n
≥
n
0
,
有
0
≤
c
1
g
(
n
)
≤
f
(
n
)
≤
c
2
g
(
n
)
}
\Theta(g(n))=\{ f(n):存在正常量c_1,c_2和n_0,使得对所有n\ge n_0, 有0 \le c_1 g(n)\le f(n)\le c_2 g(n)\}
Θ(g(n))={f(n):存在正常量c1,c2和n0,使得对所有n≥n0,有0≤c1g(n)≤f(n)≤c2g(n)}
O \mathrm{O} O记号
O
\mathrm{O}
O仅给出了渐近上界.
O
(
g
(
n
)
)
=
{
f
(
n
)
:
存
在
正
常
量
c
和
n
0
,
使
得
对
所
有
n
≥
n
0
,
有
0
≤
f
(
n
)
≤
c
g
(
n
)
}
\mathrm{O}(g(n))=\{ f(n):存在正常量c和n_0,使得对所有n\ge n_0, 有0 \le f(n)\le c g(n)\}
O(g(n))={f(n):存在正常量c和n0,使得对所有n≥n0,有0≤f(n)≤cg(n)}
Ω \Omega Ω记号
Ω
\Omega
Ω仅给出了渐近下界.
O
(
g
(
n
)
)
=
{
f
(
n
)
:
存
在
正
常
量
c
和
n
0
,
使
得
对
所
有
n
≥
n
0
,
有
0
≤
c
g
(
n
)
≤
f
(
n
)
}
\mathrm{O}(g(n))=\{ f(n):存在正常量c和n_0,使得对所有n\ge n_0, 有0 \le c g(n)\le f(n)\}
O(g(n))={f(n):存在正常量c和n0,使得对所有n≥n0,有0≤cg(n)≤f(n)}
定理3.1 对任意两个函数 f ( n ) f(n) f(n)和 g ( n ) g(n) g(n), 我们有 f ( n ) = Θ ( g ( n ) ) f(n)=\Theta(g(n)) f(n)=Θ(g(n)), 当且仅当 f ( n ) = O ( g ( n ) ) f(n)=\mathrm{O}(g(n)) f(n)=O(g(n))且 f ( n ) = Ω ( g ( n ) ) f(n)=\Omega(g(n)) f(n)=Ω(g(n)).
等式和不等式中的渐近记号
对于诸如 n = O ( n 2 ) n=\mathrm{O}(n^2) n=O(n2)的这种形式, 我们要表达的其实是 n ∈ O ( n 2 ) n\in \mathrm{O}(n^2) n∈O(n2), 书上采用如下的规则解释这种等式:
无论怎样选择等号左边的匿名函数, 总有一种办法来选择等号右边的匿名函数使等式成立.
下面来讨论以下三个和上述对应的记号.
o o o记号
O ( g ( n ) ) = { f ( n ) : 对 任 意 正 常 量 c > 0 , 存 在 常 量 n 0 > 0 , 使 得 对 所 有 n ≥ n 0 , 有 0 ≤ f ( n ) < c g ( n ) } \mathrm{O}(g(n))=\{ f(n):对任意正常量c>0, 存在常量n_0>0,使得对所有n\ge n_0, 有0 \le f(n) < c g(n)\} O(g(n))={f(n):对任意正常量c>0,存在常量n0>0,使得对所有n≥n0,有0≤f(n)<cg(n)}
例如, 2 n = o ( n 2 ) 2n=o(n^2) 2n=o(n2), 但是 2 n 2 ≠ o ( n 2 ) 2n^2 \ne o(n^2) 2n2=o(n2)
直观上, 在
o
o
o记号中, 当
n
n
n趋于无穷时, 函数
f
(
n
)
f(n)
f(n)相对于
g
(
n
)
g(n)
g(n)来变得微不足道了, 即:
lim
n
→
∞
f
(
n
)
g
(
n
)
=
∞
\lim\limits_{n\to \infty}{f(n)\over g(n)}=\infty
n→∞limg(n)f(n)=∞
ω \omega ω记号
ω
\omega
ω记号和
Ω
\Omega
Ω记号的关系类似于
o
o
o记号与
O
O
O记号的关系.
O
(
g
(
n
)
)
=
{
f
(
n
)
:
对
任
意
正
常
量
c
>
0
,
存
在
常
量
n
0
>
0
,
使
得
对
所
有
n
≥
n
0
,
有
0
≤
c
g
(
n
)
<
f
(
n
)
}
\mathrm{O}(g(n))=\{ f(n):对任意正常量c>0, 存在常量n_0>0,使得对所有n\ge n_0, 有0 \le c g(n) < f(n)\}
O(g(n))={f(n):对任意正常量c>0,存在常量n0>0,使得对所有n≥n0,有0≤cg(n)<f(n)}
例如,
n
2
/
2
=
Ω
(
n
)
n^2/2=\Omega(n)
n2/2=Ω(n), 但是
n
2
/
2
≠
ω
(
n
2
)
n^2/2\ne \omega(n^2)
n2/2=ω(n2). 关系
f
(
n
)
=
ω
(
g
(
n
)
)
f(n)=\omega(g(n))
f(n)=ω(g(n))蕴藏着:
lim
n
→
∞
f
(
n
)
g
(
n
)
=
∞
\lim\limits_{n\to \infty}{f(n)\over g(n)}=\infty
n→∞limg(n)f(n)=∞
总结一下,
O
\mathrm{O}
O和
o
o
o记号表达了一种上界,
Ω
\Omega
Ω和
ω
\omega
ω表达了一种下界.
比较各种函数
传递性:
f ( n ) = Θ ( g ( n ) ) 且 g ( n ) = Θ ( h ( n ) ) 蕴 含 着 f ( n ) = Θ ( h ( n ) ) f ( n ) = O ( g ( n ) ) 且 g ( n ) = O ( h ( n ) ) 蕴 含 着 f ( n ) = O ( h ( n ) ) f ( n ) = Ω ( g ( n ) ) 且 g ( n ) = Ω ( h ( n ) ) 蕴 含 着 f ( n ) = Ω ( h ( n ) ) f ( n ) = o ( g ( n ) ) 且 g ( n ) = o ( h ( n ) ) 蕴 含 着 f ( n ) = o ( h ( n ) ) f ( n ) = ω ( g ( n ) ) 且 g ( n ) = ω ( h ( n ) ) 蕴 含 着 f ( n ) = ω ( h ( n ) ) f(n)=\Theta(g(n))且g(n)=\Theta (h(n))蕴含着f(n)=\Theta(h(n)) \\f(n)=\mathrm{O}(g(n))且g(n)=\mathrm{O} (h(n))蕴含着f(n)=\mathrm{O}(h(n)) \\f(n)=\Omega(g(n))且g(n)=\Omega (h(n))蕴含着f(n)=\Omega(h(n)) \\f(n)=o(g(n))且g(n)=o (h(n))蕴含着f(n)=o(h(n)) \\f(n)=\omega(g(n))且g(n)=\omega (h(n))蕴含着f(n)=\omega(h(n)) f(n)=Θ(g(n))且g(n)=Θ(h(n))蕴含着f(n)=Θ(h(n))f(n)=O(g(n))且g(n)=O(h(n))蕴含着f(n)=O(h(n))f(n)=Ω(g(n))且g(n)=Ω(h(n))蕴含着f(n)=Ω(h(n))f(n)=o(g(n))且g(n)=o(h(n))蕴含着f(n)=o(h(n))f(n)=ω(g(n))且g(n)=ω(h(n))蕴含着f(n)=ω(h(n))
自反性:
f ( n ) = Θ ( f ( n ) ) f ( n ) = O ( f ( n ) ) f ( n ) = Ω ( f ( n ) ) f(n)=\Theta(f(n)) \\f(n)=O(f(n)) \\f(n)=\Omega(f(n)) f(n)=Θ(f(n))f(n)=O(f(n))f(n)=Ω(f(n))
对称性:
f ( n ) = Θ ( g ( n ) ) 当 且 仅 当 g ( n ) = Θ ( f ( n ) ) f(n)=\Theta(g(n))当且仅当g(n)=\Theta(f(n)) f(n)=Θ(g(n))当且仅当g(n)=Θ(f(n))
转置对称性:
f ( n ) = O ( g ( n ) ) 当 且 仅 当 g ( n ) = Ω ( f ( n ) ) f ( n ) = o ( g ( n ) ) 当 且 仅 当 g ( n ) = ω ( f ( n ) ) f(n)=\mathrm{O}(g(n))当且仅当g(n)=\Omega(f(n)) \\f(n)=o(g(n))当且仅当g(n)=\omega(f(n)) f(n)=O(g(n))当且仅当g(n)=Ω(f(n))f(n)=o(g(n))当且仅当g(n)=ω(f(n))
我们将之和实数进行一个比较:
f
(
n
)
=
O
(
g
(
n
)
)
类
似
于
a
≤
b
f
(
n
)
=
Ω
(
g
(
n
)
)
类
似
于
a
≥
b
f
(
n
)
=
Θ
(
g
(
n
)
)
类
似
于
a
=
b
f
(
n
)
=
o
(
g
(
n
)
)
类
似
于
a
<
b
f
(
n
)
=
ω
(
g
(
n
)
)
类
似
于
a
>
b
f(n)=\mathrm{O}(g(n))类似于a\le b \\f(n)=\Omega(g(n))类似于a\ge b \\f(n)=\Theta(g(n))类似于a= b \\f(n)=o(g(n))类似于a< b \\f(n)=\omega(g(n))类似于a> b
f(n)=O(g(n))类似于a≤bf(n)=Ω(g(n))类似于a≥bf(n)=Θ(g(n))类似于a=bf(n)=o(g(n))类似于a<bf(n)=ω(g(n))类似于a>b
三分性
对于任意两个实数 a a a和 b b b, 下列三种情况必然有一种成立: a < b , a = b 或 a > b a<b, a= b或a>b a<b,a=b或a>b. 但是对于我们的函数记号则不一定.比如, 对于两个函数 f ( n ) f(n) f(n)和 g ( n ) g(n) g(n), 也许 f ( n ) = O ( g ( n ) ) 和 f ( n ) = Ω ( g ( n ) ) f(n)=\mathrm{O}(g(n))和f(n)=\Omega(g(n)) f(n)=O(g(n))和f(n)=Ω(g(n))都不成立. 例如我们不能使用键经记号来比较函数 n n n和 n 1 + s i n n n^{1+\mathrm{sin}n} n1+sinn,因为 n 1 + s i n n n^{1+\mathrm{sin}n} n1+sinn中的幂值在0与2之间摆动, 取介于两者之间的所有值.
3.2 标准记号与常用函数
单调性
向下取整与向上取整
x − 1 < ⌊ x ⌋ ≤ x ≤ ⌈ x ⌉ < x + 1 x-1<\lfloor x \rfloor\le x\le \lceil x \rceil<x+1 x−1<⌊x⌋≤x≤⌈x⌉<x+1
对任意整数
n
n
n,
⌈
n
/
2
⌉
+
⌊
n
/
2
⌋
=
n
\lceil n/2\rceil+\lfloor n/2 \rfloor=n
⌈n/2⌉+⌊n/2⌋=n
模运算
a m o d n = a − n ⌊ a / n ⌋ a \mathrm{mod}n=a-n\lfloor a/n\rfloor amodn=a−n⌊a/n⌋
结果有:
0
≤
a
m
o
d
n
<
n
0\le a \mathrm{mod} n<n
0≤amodn<n
多项式
p ( n ) = ∑ i = 0 d a i n i p(n)=\sum^d_{i=0}a_in^i p(n)=i=0∑daini
指数
对数
阶乘
多重函数
f ( i ) ( n ) = { n 若 i = 0 f ( f ( i − 1 ) ( n ) ) 若 i > 0 f^{(i)}(n)=\begin{cases} n&若i=0 \\f(f^{(i-1)}(n))&若i>0 \end{cases} f(i)(n)={nf(f(i−1)(n))若i=0若i>0
多重对数函数
l g ∗ n = m i n { i ≥ 0 : l g ( i ) n ≤ 1 } lg^* n=\mathrm{min}\{i\ge 0:\mathrm{lg}^{(i)}n\le1\} lg∗n=min{i≥0:lg(i)n≤1}
我觉得, 用言语来描述, 就是对一个数字反复地取以2为底的对数, 一直取到, 不能取为之. 取了几次, 就是几.
l
g
∗
2
=
1
l
g
∗
4
=
2
l
g
∗
16
=
3
l
g
∗
65536
=
4
l
g
∗
(
2
65536
)
=
5
\mathrm{lg}^* 2 = 1 \\\mathrm{lg}^* 4 = 2 \\\mathrm{lg}^* 16 = 3 \\\mathrm{lg}^* 65536 = 4 \\\mathrm{lg}^* (2^{65536}) = 5
lg∗2=1lg∗4=2lg∗16=3lg∗65536=4lg∗(265536)=5
斐波那契数
Chapter 4 分治策略
4.1 最大子数组问题
我们考虑一个情况, 假设我们可以知道一个公司未来一段时间的股票走势, 我们应该如何才能获益最大呢?
很显然, 我们需要按照时间顺序, 在相对低的价格买入, 在相对高的价格卖出.
如果假设未来每日的股票价格按照时间顺序被储存在了一个数组
a
n
a_n
an中, 我们的目标就是寻找:
m
a
x
{
a
j
−
a
i
}
,
其
中
j
>
i
\mathrm{max}\{a_j-a_i\}, 其中j>i
max{aj−ai},其中j>i
暴力求解
我们固然可以暴力求解, 在n个日子中寻找2天共有 ( 2 n ) ( ^n_2) (2n)种可能, 由此我们可以发现, 暴力求解法的运行时间为 Ω ( n 2 ) \Omega(n^2) Ω(n2)
问题变换
我们先将数组变为每日股票价格的变化量, 那么我们只要找到这个数组当中的一个子数组, 和最大就好了.
使用分治策略的求解方法
假定我们要寻找子数组 A [ l o w . . h i g h ] A[low..high] A[low..high]的最大子数组. 我们将之分成两个数组 A [ l o w . . m i d ] A[low..mid] A[low..mid]和 A [ m i d + 1.. h i g h ] A[mid+1..high] A[mid+1..high]. 那么很显然, 我们要寻找的答案要不在第一个数组中, 要不在第二个数组中, 要不一半在第一个数组中, 另外一半在第二个数组中.
如下这段伪代码反映了如何寻找一个跨越中点的最大子数组的边界:
FIND-MAX-CROSSING-SUBARRAY(A, low, mid, high)
left-sum = -inf
sum = 0
for i = mid downto low
sum = sum + A[i]
if sum>left-sum
left-sum = sum
max-left = i
right-sum = -inf
sum = 0
for j = mid+1 to high
sum = sum + A[j]
if sum>right-sum
right-sum = sum
max-right = j
return(max-left, max-right, left-sum+right-sum)
不使用inf这种哨兵式的方式, 采用python, 得到如下代码:
def find_mcsa(A, low, mid, high):
left_sum = A[mid]
max_left = mid
sum = 0
i = mid
while i>=low:
sum = sum+A[i]
if sum>left_sum:
left_sum = sum
max_left = i
i = i-1
right_sum = A[mid+1]
max_right = mid
sum = 0
j = mid+1
while j<=high:
sum = sum+A[j]
if sum>right_sum:
right_sum = sum
max_right = j
j = j+1
return max_left, max_right, left_sum+right_sum
分析这个算法不难发现, 这个算法需要的时间为 Θ ( n ) \Theta(n) Θ(n)
下面我们就可以采用分治法了.
FIND-MAXIMUM-SUBARRAY(A, low, high)
if high==low
return(low, high, A[low]) // base case: only one element
else mid=⌊(low+high)/2⌋
(left-low, left-high, left-sum)=
FIND-MAXIMUM-SUBARRAY(A, low, mid)
(right-low, right-high, right-sum)=
FIND-MAXIMUM-SUBARRAY(A, mid+1, high)
(cross-low, cross-high, cross-sum)=
FIND-MAX-CROSSING-SUBARRAY(A, low, mid, high)
if left-sum>=right-sum and left-sum>=cross-sum
return(left-low, left-high, left-sum)
elseif right-sum>=left-sum and right-sum>=cross-sum
return(right-low,right-high,right-sum)
else return (cross-low, cross-high, cross-sum)
分析这个算法:
首先判断子数组是否只有一个元素, 如果只有一个的话那直接返回就好了.
其他情况下计算中间位置. 左边和右边子数组中的最大子数组继续利用自己这个函数开始计算, 跨越中间的子数组用我们之前编写的函数计算.
最后的if函数我们看谁大, 谁大返回谁, 最后这个函数返回的就是最大的.
利用Python可以这样实现:
def find_msa(A, low, high):
if high==low:
return low, high, A[low]
else:
mid = int((low+high)/2)
(ll,lh,ls)=find_msa(A,low,mid)
(rl,rh,rs)=find_msa(A,mid+1,high)
(cl,ch,cs)=find_mcsa(A,low,mid,high)
if rs>=ls and rs>=cs:
return rl, rh, rs
elif ls>=rs and ls>=cs:
return ll, lh, ls
else:
return cl, ch, cs
分治算法的分析
对于只剩一个元素的时候, 算法只需要一个常量时间. 对于其他情况, 算法需要被分解成两个时间为
T
(
n
/
2
)
T(n/2)
T(n/2)时间的子问题加上计算跨越mid位置耗时
Θ
(
n
)
\Theta(n)
Θ(n)的时间. 由此我们可以列出式子:
T
(
n
)
=
{
Θ
(
1
)
若
n
=
1
2
T
(
n
/
2
)
+
Θ
(
n
)
若
n
>
1
T(n)= \begin{cases} \Theta(1)&若n=1 \\2T(n/2)+\Theta(n)&若n>1 \end{cases}
T(n)={Θ(1)2T(n/2)+Θ(n)若n=1若n>1
根据我们之前对于递归排序算法的分析可以得出类似的结论, 这个算法的运行时间应该为
Θ
(
n
)
\Theta(n)
Θ(n)
我们可以用如下的程序测试一下暴力算法和普通算法的运行速度:
for n in range(1,100):
A=[random.random()-0.5 for x in range(0,n)]
start = time.clock()
find_msa(A,0,len(A)-1)
end = time.clock()
time1 = end - start
start = time.clock()
find_fmsa(A)
end = time.clock()
time2 = end - start
print(time1/time2)
4.2 矩阵乘法的Strassen算法
我们首先先来考察矩阵大小为2的n次幂的问题
一个简单的分治算法
如果直接考察利用简单的如下伪代码的分治算法, 他的运行效率其实和常规的计算方法运行时间相同, 都是 Θ ( n 2 ) \Theta(n^2) Θ(n2)
SQUARE-MATRIX-MULTIPLY-RECURSIVE(A, B)
n = A.rows
let C be a new n*n matrix
if n==1
c11=a11*b11
else partition A,B,and C as in equations(4,9)
C11=SQUARE-MATRIX-MULTIPLY-RECURSIVE(A11, B11)
+SQUARE-MATRIX-MULTIPLY-RECURSIVE(A12, B21)
C12=SQUARE-MATRIX-MULTIPLY-RECURSIVE(A11, B12)
+SQUARE-MATRIX-MULTIPLY-RECURSIVE(A12, B22)
C21=SQUARE-MATRIX-MULTIPLY-RECURSIVE(A21, B11)
+SQUARE-MATRIX-MULTIPLY-RECURSIVE(A22, B21)
C22=SQUARE-MATRIX-MULTIPLY-RECURSIVE(A21, B12)
+SQUARE-MATRIX-MULTIPLY-RECURSIVE(A22, B22)
return C
这个算法明显意义不大, 但是如果我们使用Strassen方法就可以降低运行速度了.
Strassen方法
Strassen算法的思路如下
- 将输入矩阵A, B和C分解成n/2*n/2的子矩阵. 采用下标计算方法, 此步骤花费 Θ ( 1 ) \Theta(1) Θ(1)时间,
- 创建10个n/2*n/2的矩阵 S 1 , S 2 , ⋯ , S S 10 S_1, S_2, \cdots, SS_{10} S1,S2,⋯,SS10, 每个矩阵保存步骤1中创建的两个子矩阵的和或差. 花费时间为 Θ ( n 2 ) \Theta(n^2) Θ(n2)
- 用步骤1中创建的子矩阵和步骤2中创建的10个矩阵, 递归地计算7个矩阵积 P 1 , P 2 , ⋯ , P 7 P_1, P_2, \cdots, P_7 P1,P2,⋯,P7, 每个矩阵都是 n / 2 × n / 2 n/2\times n/2 n/2×n/2的.
- 通过 P i P_i Pi进行不同的加减运算, 计算出所需要 C 11 , C 12 , C 21 , C 22 C_{11},C_{12},C_{21},C_{22} C11,C12,C21,C22, 花费时间为 Θ ( n 2 ) \Theta(n^2) Θ(n2).
Python代码如下:
def sm_strassen(A, B, C):
n = len(A)
C = np.zeros((n,n))
if n==1:
C[0,0]=A[0,0]*B[0,0]
else:
A11 = A[0:int(n/2), 0:int(n/2)]
A12 = A[0:int(n/2), int(n/2):n]
A21 = A[int(n/2):n, 0:int(n/2)]
A22 = A[int(n/2):n, int(n/2):n]
B11 = B[0:int(n/2), 0:int(n/2)]
B12 = B[0:int(n/2), int(n/2):n]
B21 = B[int(n/2):n, 0:int(n/2)]
B22 = B[int(n/2):n, int(n/2):n]
S1 = B12 - B22
S2 = A11 + A12
S3 = A21 + A22
S4 = B21 - B11
S5 = A11 + A22
S6 = B11 + B22
S7 = A12 - A22
S8 = B21 + B22
S9 = A11- A21
S10 = B11 + B12
P1 = sm_strassen(A11,S1)
P2 = sm_strassen(S2,B22)
P3 = sm_strassen(S3,B11)
P4 = sm_strassen(A22,S4)
P5 = sm_strassen(S5,S6)
P6 = sm_strassen(S7,S8)
P7 = sm_strassen(S9,S10)
C[0:int(n/2), 0:int(n/2)] = P5 + P4 - P2 + P6
C[0:int(n/2), int(n/2):n] = P1 + P2
C[int(n/2):n, 0:int(n/2)] = P3 + P4
C[int(n/2):n, int(n/2):n] = P5 + P1 - P3 - P7
return C
虽然我也不知道为什么我这么写出来计算的速度甚至还不如暴力求解.
根据之前的描述我们不难看出, Strassen算法的运行时间为
T
(
n
)
T(n)
T(n)的递归式:
T
(
n
)
=
{
Θ
(
1
)
若
n
=
1
7
T
(
n
/
2
)
+
Θ
(
n
2
)
若
n
>
1
T(n)= \begin{cases} \Theta(1)&若n=1 \\7T(n/2)+\Theta(n^2)&若n>1 \end{cases}
T(n)={Θ(1)7T(n/2)+Θ(n2)若n=1若n>1
Strassen算法的具体细节可以自己看书第45页, Python代码里也有所体现, 这里就不多罗嗦了.
如果面对矩阵大小不是2的整数次幂的问题, 其实只要把计算的这个矩阵在外围增添一行0, 补成偶数次就好了, 返回的时候再把0扣掉, 具体的代码如下:
def sm_strassen(A, B):
add = False
n = len(A)
if n%2 != 0 and n!=1:
A = np.c_[A, np.zeros(n)]
A = np.r_[A, np.zeros((1,n+1))]
B = np.c_[B, np.zeros(n)]
B = np.r_[B, np.zeros((1,n+1))]
n = n + 1
add = True
C = np.zeros((n,n))
if n==1:
C[0,0]=A[0,0]*B[0,0]
else:
A11 = A[0:int(n/2), 0:int(n/2)]
A12 = A[0:int(n/2), int(n/2):n]
A21 = A[int(n/2):n, 0:int(n/2)]
A22 = A[int(n/2):n, int(n/2):n]
B11 = B[0:int(n/2), 0:int(n/2)]
B12 = B[0:int(n/2), int(n/2):n]
B21 = B[int(n/2):n, 0:int(n/2)]
B22 = B[int(n/2):n, int(n/2):n]
S1 = B12 - B22
S2 = A11 + A12
S3 = A21 + A22
S4 = B21 - B11
S5 = A11 + A22
S6 = B11 + B22
S7 = A12 - A22
S8 = B21 + B22
S9 = A11- A21
S10 = B11 + B12
P1 = sm_strassen(A11,S1)
P2 = sm_strassen(S2,B22)
P3 = sm_strassen(S3,B11)
P4 = sm_strassen(A22,S4)
P5 = sm_strassen(S5,S6)
P6 = sm_strassen(S7,S8)
P7 = sm_strassen(S9,S10)
C[0:int(n/2), 0:int(n/2)] = P5 + P4 - P2 + P6
C[0:int(n/2), int(n/2):n] = P1 + P2
C[int(n/2):n, 0:int(n/2)] = P3 + P4
C[int(n/2):n, int(n/2):n] = P5 + P1 - P3 - P7
if add:
return C[0:n-1, 0:n-1]
else:
return C
4.3 用代入法求解递归式
代入法求解递归式分为两步:
- 猜测解的形式
- 用数学归纳法求出解中的常数, 并证明解是正确的.
和高中学的差别不大, 就是利用定义来证明, 这里就不多啰嗦了.
做出好的猜测
看看递归式和自己以前学过的一些递归式是不是相似, 要是相似的话, 就套过来用就好了.
微妙的细节
有时候证明一个更松的界比证明一个紧的边界更难, 因为证明的时候使用的边界也更松.
繁复的证明我已经无意了解了, 我们来看主方法:
4.5 用主方法来解递归式
主定理
定理4.1(主定理) 令 a ≥ 1 a\ge1 a≥1和 b > 1 b>1 b>1是常数, f ( n ) f(n) f(n)是一个函数, T ( n ) T(n) T(n)是定义在非负整数上的递归式:
T ( n ) = a T ( n / b ) + f ( n ) T(n)=aT(n/b)+f(n) T(n)=aT(n/b)+f(n)
其中我们将 n / b n/b n/b解释为 ⌊ n / b ⌋ \lfloor n/b\rfloor ⌊n/b⌋或 ⌈ n / b ⌉ \lceil n/b \rceil ⌈n/b⌉. 那么 T ( n ) T(n) T(n)有如下渐近界:
- 若对某个常数 ϵ > 0 \epsilon >0 ϵ>0有 f ( n ) = O ( n log b a − ϵ ) f(n)=O(n^{\log_b a-\epsilon}) f(n)=O(nlogba−ϵ), 则 T ( n ) = Θ ( n log b a ) T(n)=\Theta(n^{\log_ba}) T(n)=Θ(nlogba).
- 若 f ( n ) = Θ ( n log b a ) f(n)=\Theta(n^{\log_ba}) f(n)=Θ(nlogba), 则 T ( n ) = Θ ( n log b a lg n ) T(n)=\Theta(n^{\log_ba}\lg n) T(n)=Θ(nlogbalgn)
- 若对某个常数 ϵ > 0 \epsilon>0 ϵ>0有 f ( n ) = Ω ( n log b a + ϵ ) f(n)=\Omega(n^{\log_ba+\epsilon}) f(n)=Ω(nlogba+ϵ), 且对某个常数 c < 1 c<1 c<1和所有足够大的 n n n有 a f ( n / b ) ≤ c f ( n ) af(n/b)\le cf(n) af(n/b)≤cf(n), 则 T ( n ) = Θ ( f ( n ) ) T(n)=\Theta(f(n)) T(n)=Θ(f(n))
用语言来描述一下:
我们将函数 f ( n ) f(n) f(n)与函数 n log b a n^{\log_ba} nlogba进行比较. 直觉上, 两个函数较大者决定了递归式的解. 要是一样大, 就是 n log b a n^{\log_ba} nlogba乘以 lg n \lg n lgn.
在直觉判断之后, 我们加上一些技术细节. f ( n ) f(n) f(n)应当在多项式意义上的小于, 也就是渐近小于 n log b a n^{\log_ba} nlogba, 要相差一个因子 n ϵ n^\epsilon nϵ.
Chapter 5 概率分析和随机算法
5.1 雇佣问题
HIRE-ASSISTANT(n)
best = 0 // candidate 0 is a least-qualified dummy candidate
for i = 1 to n
interview candidate i
if candidate is better than candidate best
best = i
hire candidate i
面试的费用较低, 比如为 c i c_i ci, 雇佣的费用较高, 设为 c h c_h ch. 假设m是雇佣的人数, 那么该算法的总费用就是 O ( c i n + c h m ) O(c_i n +c_h m) O(cin+chm)
最坏情况分析
如果当应聘者的质量以严格递增次序出现时, 那么我们就会雇佣所有的应聘者, 这就是最糟糕的情况, 总的费用是 O ( c h n ) O(c_h n) O(chn).
5.2 指示器随机变量
事件
A
A
A对应的指示器随机变量
I
{
A
}
I\{A\}
I{A}定义为:
I
{
A
}
=
{
1
如
果
A
发
生
0
如
果
A
不
发
生
I\{A\}= \begin{cases} 1&如果A发生 \\0&如果A不发生 \end{cases}
I{A}={10如果A发生如果A不发生
引理5.1 给定一个样本空间 S S S和 S S S中的一个事件 A A A, 设 X A = I { A } X_A=I\{A\} XA=I{A}, 那么 E [ X A ] = Pr { A } \mathrm{E}[X_A]=\Pr\{A\} E[XA]=Pr{A}.
用指示器随机变量分析雇佣问题
我们考虑如下随机变量:
X
i
=
I
{
应
聘
者
i
被
雇
佣
}
=
{
1
如
果
应
聘
者
i
被
雇
佣
0
如
果
应
聘
者
i
不
被
雇
佣
X_i= I\{应聘者i被雇佣\}= \begin{cases} 1&如果应聘者i被雇佣 \\0&如果应聘者i不被雇佣 \end{cases}
Xi=I{应聘者i被雇佣}={10如果应聘者i被雇佣如果应聘者i不被雇佣
用随机变量
X
X
X表示应聘者被雇佣的总次数:
X
=
∑
i
=
1
n
X
i
X=\sum^n_{i=1}X_i
X=i=1∑nXi
根据引用理5.1, 可得:
E
[
X
i
]
=
Pr
{
应
聘
者
i
被
雇
佣
}
\mathrm{E}[X_i]=\Pr\{应聘者i被雇佣\}
E[Xi]=Pr{应聘者i被雇佣}
我们考虑目前应聘到第
i
i
i位应聘者, 由于考虑到每个应聘者质量都随机出现的, 所以他比前
i
−
1
i-1
i−1位应聘者都好的概率是
1
/
i
1/i
1/i由此我们不难得出总的雇佣次数的期望是:
E
[
X
]
=
E
[
∑
i
=
1
n
X
i
]
=
∑
i
=
1
n
E
[
X
i
]
=
∑
i
=
1
n
1
/
i
=
ln
n
+
O
(
1
)
\begin{aligned} \mathrm{E}[X]&=\mathrm{E}\left[\sum^n_{i=1}X_i\right] \\&=\sum^n_{i=1}\mathrm{E}[X_i] \\&=\sum^n_{i=1}1/i \\&=\ln n+O(1) \end{aligned}
E[X]=E[i=1∑nXi]=i=1∑nE[Xi]=i=1∑n1/i=lnn+O(1)
由此我们也就得出了如下引理:
引理5.2 假设应聘者以随机次序出现, 算法HIRE-ASSISTANT总的雇佣费用平均情形下为 O ( c h ln n ) O(c_h\ln n) O(chlnn)
5.3 随机算法
我们刚才讨论的情况是一种平均的分布, 但是往往情况不是这么地乐观, 因此我们先对数组进行一个随机的变换顺序, 然后再开始排序, 这样不管原来的数组输入是一个理想的情况还是一个最坏的情况, 最终程序的运行时间期望都会变为 O ( c h ln n ) O(c_h\ln n) O(chlnn)
RANDOMIZED-HIRE-ASSISTANT(n)
randomly permute the list of candidates
best = 0 //candidate 0 is a least-qualified dummy candidate
for i = 1 to n
interview candidate i
if candidate i is better than candidate best
best = i
hire candidate i
引理5.3 过程RANDOMIZED-HIRE-ASSISTANT的雇佣费用期望是 O ( c h ln n ) O(c_h\ln n) O(chlnn)
随机排列数组
PERMUTE-BY-SORTING(A)
n = A.length
let P[1..n] be a new array
for i = 1 to n
P[i] = RANDOM(1, n^3)
sort A, using P as sort keys
选用 1 ∼ n 3 1\sim n^3 1∼n3之间的随机数是为了让 P P P中所有优先级尽可能唯一. 由于设计到对P进行sort key的操作. 假定我们采用归并排序, 那么这个算法运行时间的代价为 O ( n lg n ) O(n\lg n) O(nlgn).
引理5.4 假设所有优先级都不同, 则过程PERMUTE-BY-SORTING产生输入的均匀随机排列.
当然我们还有更好的算法.
RANDOMIZE-IN-PLACE(A)
n = A.length
for i = 1 to n
swap A[i] with A[RANDOM(i, n)]
引理5.5 过程RANDOMIZE-IN-PLACE可计算出一个均匀随机排列.
我实在是讨厌概率论, 这一章后面的内容就爱咋咋地吧.