【算法与数据结构】算法分析 一
当我们写程序时,我们通常会测试它们以确保其正确性,但测试一个程序只能证明它是否错误。为了证明一个程序总是有效的,我们需要使用更加数学化的方法。算法分析的两大重点是证明算法是正确的,以及确定算法使用的资源量 (时间和空间)。
1 程序验证
程序验证取决于两个主要原则。一方面,我们必须证明算法,如果它终止,那么它总能产生正确的结果。另一方面,我们必须证明它确实总能终止。
本文将通过分析基本搜索算法之一 — 二分查找来探讨这些问题。
二分查找是一个容易出错的的算法。最常见的错误是“差一错误”,即是在计数时由于边界条件判断失误导致结果多了一或少了一的错误。我们来看这样一个二分查找的实现。
function BINARY_SEARCH(array[1..n], key)
lo = 1 and hi = n + 1
while lo < hi - 1 do
mid = floor((lo + hi) / 2)
if key >= array[mid] then lo = mid
else hi = mid
if array[lo] = key then return lo // key is located at array[lo]
else return null // key is not found
如果第 5 行将判断条件改为 key > array[mid] 会怎样呢?这将是一个 bug,但考虑到它与正确代码的相似度,这样的错误很容易产生。
那么我们如何推理出这个算法其实是正确的呢?我们可以通过在算法中建立一个不变量 (Invariant) 来推理其正确性。对于上述二分查找来说,有用的不变量可以是:
如果 key ∈ array, 那么对于每一次迭代:
1. array[lo] <= key,
2. 如果 hi != n + 1, 那么 array[hi] > key.
结合数组的排序性,这意味着 key (如果存在的话) 位于范围[lo...hi).
我们现在可以重新解释二分查找算法,使它所采取的每一个行动都只是为了维持这些不变量。这一观察可以使我们证明该算法是正确的,并且它能够终止。
1.1 正确性讨论
当我们希望使用不变量来证明算法的正确性时,我们需要证明三件事:
- 初始化 (Initialisation): 我们必须证明不变量在开始时是成立的
- 维持 (Maintenance): 我们必须证明,在整个算法中,不变量始终正确
- 终止 (Termination): 我们必须证明终止时的不变量意味着算法的正确性
我们将通过讨论当 key 在数组中与 key 不在数组中时证明二分查找的正确性。
Case 1:key ∈ array
首先,我们需要论证当算法开始时上述的不变量是正确的。一旦成立,我们就能论证不变量在随后的算法中保持不变。算法开始时,初始化 lo = 1, hi = n + 1, 所以:
- 如果 key ∈ array, 因为数组是有序的,lo = 1 意味着 array[lo] 是最小的元素,所以 array[lo] <= key
- 初始 hi = n + 1, 所以第 2 点满足
所以,在二分查找开始时不变量为真。当我们执行二分查找的主循环的迭代时,该不变量会发生什么呢?在每一步,我们计算 mid = floor((lo + hi) / 2) 并且将 key 与 array[mid] 比较。循环中的条件语句将强制执行不变式。
- 如果 key >= array[mid], 则在设置 lo = mid 之后,array[lo] <= key 依旧成立
- 如果 key < array[mid], 则在设置 hi = mid 之后,array[hi] > key 依旧成立
因此,在循环的整个迭代过程中,这个不变量都是成立的。所以,如果循环终止,array[lo] <= key < array[hi] (或者 hi = n + 1) 成立。由于循环终止条件为 lo >= hi - 1,加上先前的不等式,lo = hi - 1 一定是正确的。因此,如果 key 存在与数组之中,它一定位于下标 lo,所以二分查找算法可以正确识别 key 是否为数组的元素。
Case 2: key ∉ array
由于 key 不存在于数组之中,算法终止,不管 lo 的值为多少,array[lo] != key,因此该算法正确识别 key 不是为数组的元素。
1.2 终止论证
我们已经论证了二分查找如果终止是正确的,但我们还未证明该算法不会无限循环下去,否则是不正确的。我们再来看看循环条件,lo < hi - 1,当算法还在循环时,此不等式成立。
如果 lo < hi - 1, 那么 lo < mid < hi。
因为 mid 是 (lo + hi) / 2 向下取整,那么
l
o
+
h
i
2
−
1
<
m
i
d
≤
l
o
+
h
i
2
\frac{lo + hi}{2} - 1 < mid \leq \frac{lo + hi}{2}
2lo+hi−1<mid≤2lo+hi
同乘 2,
l
o
+
h
i
−
2
<
2
×
m
i
d
≤
l
o
+
h
i
lo + hi - 2 < 2 \times mid \leq lo + hi
lo+hi−2<2×mid≤lo+hi
又因为 lo < hi - 1, 那么 lo <= hi - 2. 将不等式左边 hi - 2 替换为 lo
2
×
l
o
<
2
×
m
i
d
≤
l
o
+
h
i
2 \times lo < 2 \times mid \leq lo + hi
2×lo<2×mid≤lo+hi
接下来给不等式右边项加 1
2
×
l
o
<
2
×
m
i
d
≤
l
o
+
h
i
+
1
2 \times lo < 2 \times mid \leq lo + hi + 1
2×lo<2×mid≤lo+hi+1
因为 lo < hi - 1, 那么 lo + 1 < hi,将不等式右边 lo + 1 替换为 hi
2
×
l
o
<
2
×
m
i
d
≤
2
×
h
i
2 \times lo < 2 \times mid \leq 2 \times hi
2×lo<2×mid≤2×hi
所以当 lo < hi - 1, 那么 lo < mid < hi。
因为上述条件成立,并且我们总是将 lo 或 hi 设置为等于 mid,所以每一次迭代,区间 [lo…hi) 的大小至少减一。区间 [lo…hi) 的大小是有限的,因此在一定数目的迭代之后 lo >= hi - 1 成立。所以,循环一定以有限的迭代次数退出,因此二分查找算法会在有限的时间内终止。
2 复杂度分析
至此,我们证明了二分查找算法可以给出正确的答案并且能够终止,我们接下来只要证明它能够快速终止就行了。我们可以使用输入数据大小为 n 的函数 T(n) 来表示二分查找算法所执行的操作次数,
T
(
n
)
=
{
T
(
n
2
)
+
a
if
n
>
1
,
b
if
n
>
1
,
T(n) = \left\{\begin{matrix} T(\frac{n}{2}) + a \space \space \space \space \text{if} \space \space n > 1, \\ b \space \space \space \space \space \space \space \space \space \space \space \space \space \space \space \space \space \space \text{if} \space \space n > 1, \end{matrix}\right.
T(n)={T(2n)+a if n>1,b if n>1,
其中 a 每一步执行的一些常数操作,b 为算法结束时需要的一些常数操作。
T
(
n
)
=
a
log
2
n
+
b
T(n) = a\log_{2}{n}+b
T(n)=alog2n+b
2.1 复杂度表示
虽然递归关系式能很准确的表示一个算法所消耗的资源量,但随着算法越来越复杂,这个关系式也会变得非常难以理解。所以一般在我们分析算法时,我们更关心算法运行时间的数量级。
大 O 表示法就是一个很好的选择,它也是算法分析中最常使用的表示法。简单来说,大 O 表示法给出了函数大小的上限,公式可以表示为 f(n) = O(g(n))。意思就是算法复杂度的递归关系式 f(n) 不会大于 g(n) 的数量级。
但要注意的一点是,大 O 表示法可能会高估实际的复杂度。比如我们可以说 2n + 1 = O(n³), 也可以说 2n + 1 = O(n)。
除过大 O 表示法还有一些其他不太常用的方法。比如大 Ω (Omega) 表示法与大 O 表示法相反,它给出了函数的下限。与大 O 表示法可能会高估实际复杂度相同,大 Ω 表示法可能会低估算法实际复杂度。比如 n^5 = Ω(n^2), 也可以是 n^5 = Ω(n^5)。
2.2 复杂度的衡量
最佳,最坏和平均情况复杂度
在测量算法的复杂度时,我们通常分三种情况:最佳情况,平均情况和最坏情况。
最佳情况复杂度:算法的最佳复杂度是指算法在任何可能的输入上执行的最少指令。
最坏情况复杂度:最坏情况下的复杂度同样是指算法在任何可能的输入上可能执行的最多指令。
平均情况复杂度:平均情况下的复杂度并没有一个普遍认同的定义。最简单的,也是我们最常使用的定义是指在平均输入大小上可能执行的平均操作次数。
随机算法的复杂度
对于算法设计者来说,随机化的使用是一个强有力的工具。当一个算法所花费的时间受到随机决策的影响时,我们通常会用它的预期复杂度来分析它的时间复杂度,或者说我们给出的分析结果大概率是成立的。
预期复杂度:预期复杂度为一个随机算法在所有可能的随机决策上所花费的平均时间,这与平均情况复杂度不同,平均情况复杂度是所有可能的输入的平均值。一般情况下,我们会分析预期最坏情况下的性能,也就是说我们将分析所有可能的随机选择对最坏可能的输入的预期性能。当然我们也可以定义最佳情况,或者平均情况下的预期时间复杂度,但一般用不到。
高概率分析:高概率分析在随机算法分析中经常会用到。一般我们认为一个算法高概率耗费 O(f(n)) 的时间,如果它在概率至少为
1
−
1
n
c
,
for all
c
≥
1
1-\frac{1}{n^c}, \text{for all} \space c \geq 1
1−nc1,for all c≥1
要注意的是,如果一个算法大概率会消耗 O(f(n)) 时间,那么它的预期复杂度最高也会是 O(f(n)),也可能更低。