数据结构与算法复习——1、算法分析入门
数据结构与算法复习
第一学期的计算概论让我认识到在算法方面我已经失却了所有能力,因此寒假务必来复习一下算法和数据结构。
复习准备
复习主要使用:
[美]Mark Allen Weiss 、冯舜玺 译《数据结构与算法分析》.机械工业出版社
1、算法分析入门
高中学习算法竟然没有认真学习过算法分析,这是一个遗憾。在正式开始复习数据结构和算法之前,我准备先来学习一下简单的算法分析。
一、定义
算法分析主要是进行算法的时间复杂度分析。这是因为空间复杂度受到设备的影响较大,如果我们有一个无限内存、读取、存储的时间消耗都是单位时间的理想机,空间复杂度根本不需考虑。虽然这个理想机不太可能存在,但是我们目前可能接触到的算法在现在的计算机上基本可以认为是运行在一个比较理想的计算机上的,所以除非特别需要,我们就不考虑空间复杂度了。
一个算法的时间是受到它要处理的数据规模影响的,数据规模用几个正整数就能表示,如果是一个,我们一般用大写的$N$来表示,而它的时间复杂度函数则用$T(N)$来表示。大家请注意$T(N) > 0$是一个分析的必要条件,否则分析这个时间函数没有意义。
下面是一些常用的定义:
1、如果存在正函数$f(N)$、正常数$c$、正整数$N_0$,使得对任意的$N > N_0$,都有$T(N) \leq cf(N)$,我们就记$T(N) = O(f(N))$;
2、如果存在正函数$g(N)$、正常数$c$、正整数$N_0$,使得对任意的$N > N_0$,都有$ T(N) \geq cg(N)$,我们就记$T(N) = \Omega (g(N))$;
3、如果存在正函数$h(N)$,使得$T(N) = O(h(N))$、$T(N) = \Omega (h(N))$同时成立,我们就记$T(N) = \Theta (h(N))$;
4、如果1中的等号不可能成立,我们也可以记$T(N) = o(f(N))$。
这些和微积分中的无穷大(小)量的阶类似,但是算法分析里不太可能会遇到微积分。
这些记号主要是为了描述$T(N)$的增长率的。譬如$T_1 = 1000N$和$T_2 = N^2$,虽然在$N \leq 1000$时都有$T_1 \geq T_2$,但是当$N > 1000$时,总有$T_1 < T_2$,这样我们也就可以说,$1000N = O(N^2)$,其中$c=1$、$N_0 = 1000$。这描述了一个事实,就是幂函数的增长总是快于线性增长。
一般来说,如果我们求出了$T(N)$的一个$O$,我们就找到了它的一个上界;$\Omega$则是一个下界,$\Theta$则是一个严密估计。一般来讲,找到一个严密估计是不容易的,我们希望找到一个上界就足够了。这里需要用到一些已知结论和技巧:
1、如果$T_1(N) = O(f(N))$、$T_2(N) = O(g(N))$,则
$T_1(N)+T_2(N) = \max \{ O(f(N)), O(g(N)) \}$
$T_1(N) * T_2(N) = O(f(N) * g(N))$
2、如果$T(N)$是一个$k$次多项式,则$T(N) = \Theta(N^k)$。
3、对任意正整数$k$,$\log^k (N) = O(N)$,也就是指数的任意次幂增长慢于线性。
4、我们在数学分析中学到过的Stolz定理。
5、来自于1的代数运算法则,例如如果$T(N) = N^k * s(N)$、$f(N) = N^k * t(N)$,则比较$T(N)$与$f(N)$就是比较$s(N)$与$t(N)$。
还需要注意这些习惯:
1、省略常数。说$T(N) = O(3N)$是冗余的,应该说成$T(N) = O(N)$。
2、省略低阶项。说$T(N) = O(N^2 + N)$也是冗余的,因为$N^2$的增长比$N$快得多。应该说成$T(N) = O(N^2)$。
3、要用等号。说$T(N) \leq O(N)$是不合适的,因为定义里就包含了不等式。说$T(N) \geq O(N)$更是毫无意义。应该说成$T(N) = O(N)$。
二、简单的实例分析
我们通过考虑一个受欢迎的简单问题来介绍如何进行算法分析。它受欢迎是因为它可以有几种效率明显不同的算法。
问题是这样的:给定一个有$n$个数的整数序列(可以有负数)$\{a_n\}$,定义
$A_{i,j} = \sum_{k=i}^{j} a_k, 1 \leq i \leq j \leq n$,请求出$\max \{ A_{i,j} \}$的值。
这就是最大子序列和问题。
我们介绍四种算法:
1、枚举$i,j$,计算出$A_{i,j}$,并计算出最大值。代码实现如下:
1 int Solve3(int n) { 2 int res = 0; 3 int temp = 0; 4 for (int i = 0; i < n; i++) { 5 for (int j = i; j < n; j++) { 6 temp = 0; 7 8 for (int k = i; k <= j; k++) 9 temp += a[k]; 10 11 if (temp > res) 12 res = temp; 13 } 14 } 15 return res; 16 }
我们逐行分析:
第2、3行的声明我们默认不占用时间,但是赋值是占用1个单位时间的;
第4、5行进入了一个嵌套循环,我们从最内层开始分析:
从第6到第12行是最内层的循环,第6行的赋值占用1单位时间;
第8行进入了一个循环,从$i$到$j$,它的循环体(第9行)占用2个单位时间,我们也认为是$O(1)$,总的时间就是$O(j - i)$,为了用$N$表示,上界就是$O(N)$;
第11、12行是一个判断和赋值,总之不会超过$O(1)$,和第6行放在一起,远小于$O(N)$;
因此我们认为最内层循环的时间复杂度是$O(N)$。
外面两层循环,里面一层是$i$到$N$,外面一层是$0$到$N$,我们都认为是$O(N)$,这样三层循环加在一起,就是$O(N^3)$。
最后第15行和第2、3行加在一起也远小于$O(N^3)$,所以总体的算法就是$O(N^3)$。
上面这个过程貌似不太严谨,其实我们严谨地考虑,应该是计算$f(N) = \sum_{i=1}^{N} \sum_{j=i}^{N} \sum_{k=i}^{j} 1$这个和式的通项。计算出来应该是$\frac{N^3 + 3N^2 +2N}{6}$,符合我们说的结果。
这个分析过程透露给我们一些基本的规则:
1° 顺序结构的复杂度上界是每一部分相加(实际上就是其中阶数最大的部分);
2° 分支结构的复杂度上界是阶数最大的分支的复杂度;
3° 循环的复杂度上界是循环体内的复杂度乘以循环次数;
4° 嵌套循环的复杂度上界是从最内层向外按照3°分析。
作为练习,大家可以尝试分析最大子序列和问题的算法2:
1 int Solve2(int n) { 2 int res = 0; 3 int temp = 0; 4 for (int i = 0; i < n; i++) { 5 temp = 0; 6 7 for (int j = i; j < n; j++) { 8 temp += a[j]; 9 10 if (temp > res) 11 res = temp; 12 } 13 } 14 return res; 15 }
下面我们来分析算法3:
算法3的思想采用了分治思想。如果将一段序列平分成左右两部分,它的最大子序列无非出现在三个地方:左边、右边和跨越左右分界的情况。这样,我们就分别计算三种情况好了。算法的一种实现如下:
1 int SolveB(int l, int r) { 2 if (l >= r) { 3 if (a[l] > 0) 4 return a[l]; 5 else 6 return 0; 7 } 8 int mid = (l + r) / 2; 9 int Lres, Rres; 10 Lres = SolveB(l, mid); 11 Rres = SolveB(mid + 1, r); 12 int Lbord = 0, Rbord = 0; 13 int temp; 14 temp = 0; 15 for (int i = mid; i >= l; i--) { 16 temp += a[i]; 17 if (temp > Lbord) 18 Lbord = temp; 19 } 20 temp = 0; 21 for (int i = mid + 1; i <= r; i++) { 22 temp += a[i]; 23 if (temp > Rbord) 24 Rbord = temp; 25 } 26 return max(max(Lres, Rres), Lbord + Rbord); 27 }
我们来分析这个算法的时间复杂度,设其为$T(N)$:
2—7行是递归的边界情况处理,这里我们认为是$O(1)$。
8、9行是一些声明和赋值,也是$O(1)$的。
10、11两行是分治的核心算法,我们无法直接分析出它的复杂度,但是我们可以知道它们是$\Theta (T(N/2))$的。
15—25行就是计算横跨部分的最大子和了,这里可以明显看出是$O(N)$。
从而,整个算法的时间复杂度可以如下地表示:
$T(N) = 2\Theta(T(N/2)) + O(N)$
实际上$N/2$可能是两个相差1的正整数,$O(N)$可以简化成$N$,我们这样来简记这个问题:
$T(N) = 2T(N/2) + N$
也就是说我们遇到了一个递推式。要求这个函数的上界是不容易的,在此后分析排序算法时我们着重讲述怎么分析这个问题,但是结论是:$T(N) = O(N \log N)$。这里取$2$为底数。
到这里为止,我们已经找到了一个比较优秀的算法。很多问题到这个地步就无法优化了(甚至到不了这个地步),但是这个问题存在一个更优秀的算法:
1 int SolveD(int n) { 2 int temp = 0, res = 0; 3 for (int i = 0; i < n; i++) { 4 temp += a[i]; 5 if (temp > res) 6 res = temp; 7 8 if (temp < 0) 9 temp = 0; 10 } 11 return res; 12 }
直接分析不难得知这一算法的时间复杂度是$O(N)$。算法为什么是正确的呢?
考虑从序列的左端开始分析:如果某一个区间$[0,k]$上保证$A_{0,i} \geq 0 , 0 \leq i \leq k$,我们就可以期待它会为此后的$[0,k+p]$上的区间和做出正的贡献。但是如果加入了某个数$a_k$,使得$[0,k]$的区间和首次是负数,也就是$a_k$以一己之力把$[0,k-1]$的正贡献都抹消了,变成了负数,那么这个数就不应该包含在之后出现的最大子序列里,因为若$[i,j]$包含$k$,则一定有$[k+1,j]$是一个更优的子序列。这样我们就不考虑$k$之前的序列了,从头开始计算。这时$k+1$就是一个新的序列左端,分析如故。
这个算法除了效率极高,还有一个好处就是它不需要存储整个数组,它可以给出任何时刻输入数据的计算结果,这种算法叫“在线算法”。线性的在线算法几乎是最优秀的算法。
到这里我们已经依靠这个例子讲解了分析算法的一般步骤,可以看到我们还有一个问题没有解决,就是如何处理递归问题的算法分析。这是最复杂的问题之一,我们之后介绍。