笔者是刚刚转专业到计算机科学与技术专业的准大二学生,这个暑假在着手自学一些书籍,写该博客主要为了梳理知识,加深记忆。
第一章 绪论
本章将介绍计算机相关的基本概念,包括算法构成的基本要素、算法效率的衡量尺度、计算复杂度的分析方法与界定技巧、算法设计的基本框架与经典模式。
1.1计算机与算法
介绍古埃及人的绳索、欧几里得尺柜和起泡排序,以此来分析算法的种种特性。
代码1.1整数数组的起泡排序:
void bubblesort1A(int A[], int n) { //起泡排序算法(版本1A):0 <= n
bool sorted = false; //整体排序标志,首先假定尚未排序
while (!sorted) { //在尚未确认已全局排序之前,逐趟进行扫描交换
sorted = true; //假定已经排序
for (int i = 1; i < n; i++) { //自左向右逐对检查当前范围A[0, n)内的各相邻元素
if (A[i - 1] > A[i]) { //一旦A[i - 1]与A[i]逆序,则
swap(A[i - 1], A[i]); //交换之,并
sorted = false; //因整体排序不能保证,需要清除排序标志
}
}
n--; //至此末元素必然就位,故可以缩短待排序序列的有效长度
}
} //借助布尔型标志位sorted,可及时提前退出,而不致总是蛮力地做n - 1趟扫描交换
1.1.4算法:
输入与输出
基本操作、确定性与可行性:一个算法满足确定性与可行性,当且仅当它可以通过程序设计语言精确地表达
有穷性与正确性:证明算法有穷性和正确性的一个重要技巧,就是从适当的角度审视整个计算过程,并找出其中的某种不变性和单调性。其中单调性是指,问题的有效规模会随着算法的推进不断递减。不变性则不仅应在算法初始条件下自然满足,而且与最终的正确性相呼应——当问题的有效规模缩减到0时,不变性应随即等于正确性。 起泡排序算法的不变性和单调性:经过k趟扫描交换之后,最大的前k个元素必然就位;经过k趟扫描交换之后,待求解问题的有效规模将缩减至n-k。
退化与鲁棒性:程序可以满足一些极端的数据
重用性:算法的整体框架能否便捷地推广至其他场合
1.1.5算法效率
可计算性、难解性、计算效率、数据结构
1.2复杂度度量
1.2.1时间复杂度
特定算法处理规模为n的问题所需的时间记作T(n)。但不同的输入规模算法的处理时间也不同。
在规模n的所有输入中选择执行时间最长的T(n),为度量该算法的时间复杂度
1.2.2渐进复杂度
时间复杂度可以判断两种算法对相同规模n的问题的计算效率的高低,但不能就此判断算法的整体性能。
渐进分析:着眼长远,更为注重时间复杂度的总体变化趋势和曾增长速度的策略与方法。
大O记号:
对于T(n)的渐进上界引入大O记号。
若存在正的常数c和函数f(n),对于任意的n>>2都有T(n)<=c*f(n),则可认为n足够大后,f(n)给出了T(n)增长速度的一个渐进上界。记之为 T(n)=O(f(n))
性质:1.对于任意常数c>0,有 O(f(n))=O(c*f(n)) 2.对于任意常数a>b,有O(n^a+n^b)=O(n^a)
性质1:在大O记号的意义下,函数各项正的常系数可忽略并等同于1 。
性质2:多项式中的低次项可忽略,只保留最高次项。
大O的性:质体现了对函数总体增长趋势的关注与刻画。
环境差异:
有必要按照超脱于具体硬件、软件平台和软件环境的某一客观标准,来度量算法的时间复杂度。
基本操作:
将T(n)定义为算法所执行基本操作的次数:组成算法所有语句各自的执行次数,以及其中所包含基本操作的数目。
eg:1.1起泡排序:T(n)=O(2*(n-1)^2)=O(n^2) 每轮循环中扫描(不算基本操作)、比较n-1对元素,至多交换n-1对元素,又外循环至多执行n-1轮。
最好最坏与平均情况:“最坏情况复杂度”关注最高
大Ω记号:渐近下界
若存在正的常数c和函数f(n),对于任意的n>>2都有T(n)>=c*g(n),则可认为n足够大后,g(n)给出了T(n)增长速度的一个渐进下界。记之为 T(n)=Ω(g(n))
乐观估计,对于规模为n的任意输入,算法运行时间都不低于Ω(g(n))。
大θ记号:准确估计(g(n)=f(n)的情况)
若存在正的常数c和函数f(n),对于任意的n>>2都有c1*h(n)<=T(n)<=c2*h(n),则可认为n足够大后,h(n)给出了T(n)的一个确界。记之为 T(n)=θ(h(n))
对于规模为n 的任何输入,算法运行时间 T(n)都与θ(h(n))同阶。
1.2.3空间复杂度
通常不计入原始输入所占的空间,而是其他(转储、中转、索引、映射、缓冲等)各个方面所消耗的空间。
时间复杂度是空间复杂度的天然上界。
1.3复杂度分析
1.3.1 常数O(1)
常数时间复杂度算法:仅含一次或常数次基本操作的算法(亦称就地算法)
1.3.2对数O(logn)
代码1.2整数二进制展开中数位1总数的统计:
int countOnes(unsigned int n) { //统计整数二进制展开中数位1的总数:O(logn)
int ones = 0; //计数器复位
while (0 < n) { //在n缩减至0之前,反复地
ones += (1 & n); //检查最低位,若为1则计数
n >>= 1; //右移一位
}
return ones; //返回计数
} //等效于glibc的内置函数int __builtin_popcount (unsigned int n)
复杂度:
根据右移的性质,每右移一位,n都至少缩减一半。至多经过 1+次循环,n必然缩减至0 。从另一角度看,1+
恰为n二进制展开的总位数。
O(1+)=O(
)=O(log2(n))
由大O记号定义,常底数r的具体取值无所谓,则通常写为logn,及记为O(logn) 。称为“对数时间复杂度”
对数多项式时间复杂度:T(n)=O()形式的算法(常数c>0)均称为“对数多项式时间复杂度的算法”。虽不如常数时间复杂度算法理想,但从多项式的角度看仍能无线趋近后者,故也是高效的一类算法。
1.3.3线性O(n)
代码1.3数组元素求和算法sumI()
int sumI(int A[], int n) { //数组求和算法(迭代版)
int sum = 0; //初始化累计器,O(1)
for (int i = 0; i < n; i++) //对全部共O(n)个元素,逐一
sum += A[i]; //累计,O(1)
return sum; //返回累计值,O(1)
} //O(1) + O(n)*O(1) + O(1) = O(n+2) = O(n)
O(1)+O(1)*n = O(n+1) = O(n)
线性时间复杂度算法:T(n)=O(n)
1.3.4多项式 O(polynomial(n))
eg: 起泡排序: T(n)=(n^2)
一般认为是可接受、可有效求解、易求解的。
仅要求多项式的次数为一个正的常数,并未对最大值范围设置上限。
1.3.5指数O(2^n)
代码1.4 幂函数算法(蛮力迭代版)
__int64 power2BF_I(int n) { //幂函数2^n算法(蛮力迭代版),n >= 0
__int64 pow = 1; //O(1):累积器初始化为2^0
while (0 < n--) //O(n):迭代n轮,每轮都
pow <<= 1; //O(1):将累积器翻倍
return pow; //O(1):返回累积器
} //O(n) = O(2^r),r为输入指数n的比特位数
复杂度:
以输入指数n计算,为O(n),以输入指数n的二进制位数 r=1+ 作为输入规模,则O(2^r)
从多项式到指数
通常指数复杂度算法无法真正适用于实际问题中,不是有效算法。相应的,不存在多项式复杂度算法的问题也叫难解的问题。
1.3.6复杂度层次
经典的复杂度层次包括 O(1) O(loglogn) O(logn) O(sqrt(n)) O(n) O(nlog*n) O(nloglogn) O(nlogn) O(n^2) O(n^3) O(n^c) O(2^n) 等。
1.3.7输入规模
7输入规模:用以描述输入所需的空间规模。
以上两个例子,将输入参数n的二进制展开的宽度r作为输入规模更加合理,即算法复杂度定义为O(r)和O(2^r)更合理。对应的,以输入参数n的本身数值作为基准而得出的O(logn) O(n) 复杂度,则分别称作伪对数的和伪线性的复杂度。
1.4递归
1.4.1线性递归
代码1.5 数组求和算法(线性递归版):
int sum(int A[], int n) { //数组求和算法(线性递归版)
if (1 > n) //平凡情况,递归基
return 0; //直接(非递归式)计算
else //一般情况
return sum(A, n - 1) + A[n - 1]; //递归:前n - 1项之和,再累计第n - 1项
} //O(1)*递归深度 = O(1)*(n + 1) = O(n)
上面是1.3.3数组求和问题的重新处理。首先判断并处理 n=0 之类的平凡情况,以免无限递归而导致系统溢出。这类平凡情况成为“递归基”,平凡情况可能有多种,但是至少要有一种,且迟早必然会出现。
线性递归
算法可能朝着更深一层进行自我调用,且每一层递归实例对自身的调用至多一次。于是,每一层次上至多只有一个实例,且它们构成一个线性的次序关系,此类递归被称为线性递归,它也是递归的最基本形式。
应用问题可分为两个独立的子问题。其一对应于单独的某个元素,故可直接求解,比如 A[n-1] ;另一个问题对应于剩余部分,且其结构与原问题相同(比如 sum(A,n-1) )。另外,子问题的解经过简单合并(比如整数的相加)之后,即可得到原问题的解。
减而治之
减而治之的算法策略:递归每深入一层,待求解的问题的规模都酸碱一个常数,直至最终蜕化为平凡的简单问题。
1.4.2 递归分析
递归跟踪
递归跟踪可用于分析递归泛的总体运行时间与空间,按照以下原则:
1.算法的每一递归实例都表示为一个方框,其中注明了该实例调用的参数。
2.若实例M调用实例N,则在M与N对应的方框之间加一条有向联线。
整个算法运行所需的计算时间,应该等于所有递归实例的创建、执行和销毁所需的和时间总和。其中创建、销毁均由操作系统完成,对应的时间成本可近似看常数,不会超过递归实例中实质计算步骤所需的时间成本,往往忽略。启动各实例每一条递归语句所需的时间,也可以计入被创建的递归实例的账上,为此我们只需要统计各递归实例中非递归调用部分所需的时间。
上面sun()函数,非递归调用部分所涉及的计算为:判断n是否为0,累加sum(n-1)与A[n-1]、返回当前总和,且至多各执行一次。故每个递归实例实际所需的计算时间都应为O(3),对于长度为n的数组,递归深度为 n+1 ,故总时间为:(n+1)*O(3)=O(n)
空间复杂度则等于所有递归实例各自所占空间量的总和。调用参数(A的起始地址和长度n)和用于累加总和的临时变量。这些数据各自只需常数规模的空间,总量也为常数。由此可知,sun()算法的空间复杂度线性正比于其递归深度,即O()。
递归方程
通过对递归模式的数学归纳,导出复杂度定界函数的递推方程(组)及其边界条件,从而将复杂度的分析,转化为递归方程(组)的求解。
1.复杂函数的微分形式往往遵循相对简洁的规律 2.最终解依靠边界条件,可有递归基分析得出
对于sun()函数,记处理长度为n的数组所需时间成本为T(n),则有 T(n)=T(n-1)+O(1)=T(n-1)+c1 (求解sun(A,n)所需的时间等于求解sun(A,n-1)所需的时间+一次整数加法运算所需的时间)
到达递归基时,可得边界条件 T(0)=O(1)=c2 (求解平凡问题sum(A,0)只需(用于直接返回0的)常数时间)
联立得 T(n)=c1*n+c2 =O(n)
由此方法也可求空间复杂度
1.4.3递归模式
多递归基
为保证有穷性,递归算法都设有递归基,且保证可执行到
代码1.6 数组倒置算法的统一入口
void reverse(int*, int, int); //重载的倒置算法原型
void reverse(int* A, int n) //数组倒置(算法的初始入口,调用的可能是reverse()的递归版或迭代版)
{ reverse(A, 0, n - 1); } //由重载的入口启动递归或迭代算法
代码1.7 数组倒置的递归算法
void reverse(int* A, int lo, int hi) { //数组倒置(多递归基递归版)
if (lo < hi) {
swap(A[lo], A[hi]); //交换A[lo]和A[hi]
reverse(A, lo + 1, hi - 1); //递归倒置A(lo, hi)
} //else隐含了两种递归基
} //O(hi - lo + 1)
实现递归
在设计算法时,往往要多个角度反复尝试,方能确定对问题的输入及其规模的最佳划分方式。有时,还可能需要从不同角度重新定义和描述原问题,使得经过分解所得的子问题与原问题有相同的语义形式。
在代码1.7中,通过引入lo和hi,使得对全数组以及其后各子数组的递归调用都统一为相同的与发行时。另外,还利用c++的函数重载机制定义了名称相同、参数有别的另一函数reverse(A,n),作为统一的初始入口。
多向递归
递归算法中不仅递归基可能有多个,递归调用也可能有多种可供选择的分支。以下简单实例中,每一递归实例随有多个可能的递归方向,但只能选择其一,故各层次上的递归实例依然构成一个线性次序关系,这种情况依然属于线性递归。
对1.3.5中计算power(2,n)=2^n的问题,还有多种处理方式
power2(n)= 1 (n=0) ; power2(n-1)*2 (else)
其算法时间复杂度和蛮力法相同
power2(n)= 1 (n=0) ; power2()^2 * 2 (n>0 and odd) ; power2(
)^2 (n>0 and even)
按照这一新的表达和理解,可按二进制展开n之后的各比特位,通过反复平方运算和加倍运算得到power2(n)
2^1=2^001=(2^2^2)^0*(2^2)^0*2*1=(((1*2^0)^2*2^0)^2*2^1)
2^2=2^010=(2^2^2)^0*(2^2)^1*2*0=(((1*2^0)^2*2^1)^2*2^0)
……
( 二进制转化为十进制:010=0*2^2+1*2^1+0*2^0 )
一般的,若n的二进制展开式为b1b2b3b4……b(k),则有
2^n=(……(((1*2^b1)^2*2^b2)^2*2^b3)^2……*2^b(k))
若n(k-1)和n(k)的二进制展开式分别为b1b2b3b4……b(k-1)和b1b2b3b4……b(k),则有
2^n(k)=(2^n(k-1))^2*2^b(k)
归纳可得下递推式: power2(n(k))= (power2(n(k-1)))^2*2 (b(k)=1) ; (power2(n(k-1)))^2 (b(k)=0)
代码1.8 优化的幂函数算法(线性递归版)
inline __int64 sqr(__int64 a) { return a * a; }
__int64 power2(int n) { //幂函数2^n算法(优化递归版),n >= 0
if (0 == n) return 1; //递归基;否则,视n的奇偶分别递归
return (n & 1) ? sqr(power2(n >> 1)) << 1 : sqr(power2(n >> 1));
} //O(logn) = O(r),r为输入指数n的比特位数
三木运算符: b = a < 10 ? 5 : 2 a=6则b=5,a=11则b=2
1.4.4递归消除
空间成本
递归算法所消耗的空间量主要取决于递归深度,较之迭代版往往耗费更多的空间,并进而影响实际的运行速度。
在对运行速度要求较高、储存空间需精打细算的场合,往往应将递归算法改写成等阶的非递归版本。
一般的转化思路是通过利用栈结构模拟操作系统的工作过程。
尾递归及其消除
递归调用在递归实例中恰好以最后一步操作的形式出现,称为尾递归。 比如代码1.7。
尾递归算法均可简捷地转化为等效的迭代版本。
代码1.9 由递归版改造而得的数组倒置算法(迭代版):
void reverse ( int* A, int lo, int hi ) { //数组倒置(直接改造而得的迭代版)
next: //算法起始位置添加跳转标志
if ( lo < hi ) {
swap ( A[lo], A[hi] ); //交换A[lo]和A[hi]
lo++; hi--; //收缩待倒置区间
goto next; //跳转至算法体的起始位置,迭代地倒置A(lo, hi)
} //else隐含了迭代的终止
} //O(hi - lo + 1)
其中的goto语句有悖于结构化程序设计的原则。可改为
代码1.10 进一步调整代码1.9的结构,消除goto语句:
void reverse ( int* A, int lo, int hi ) { //数组倒置(规范整理之后的迭代版)
while ( lo < hi ) //用while替换跳转标志和if,完全等效
swap ( A[lo++], A[hi--] ); //交换A[lo]和A[hi],收缩待倒置区间
} //O(hi - lo + 1)
只有当算法(除平凡递归基外)任一实例都终止于这一递归调用时才属于尾递归。代码1.5不属于尾递归。
1.4.5二分递归
分而治之
将大问题分解为若干规模更小的问题,在通过递归机制分别求解。(直到子问题规模缩减至平凡情况)
可以“多路递归”,一般采用“二分递归”。无论怎么分,对算法总体的渐进复杂度无实质影响。
数组求和
代码1.11 通过二分递归计算数组元素之和
int sum ( int A[], int lo, int hi ) { //数组求和算法(二分递归版,入口为sum(A, 0, n))
if ( lo < hi ) { //一般情况下(lo < hi),则
int mi = ( lo + hi ) >> 1; //以居中单元为界,将原区间均分
return sum ( A, lo, mi ) + sum ( A, mi, hi ); //递归对各子数组求和,然后合计
} else //如遇递归基(区间长度已降至1),则
return A[lo]; //直接返回该元素
} //O(hi - lo),线性正比于区间的长度
算法启动后经连续 m=log2(n)次递归调用,数组区间的长度由n首次缩减到1,并达到第一个递归基。
……P23
效率
1.子问题划分 2.子解答合并
为使分而治之真正有效,不仅能必须保证两方的计算都能高效地实现,还必须保证子问题之间相互独立——格子问题可独立求解,而无需借助其他子问题的原始数据和中间结果。
Fibonacci数:二分递归
根据 fib(n)= n (n<=1) ; fib(n-1)+fib(n-2) (n>=2) 可直接导出二分递归版fib()算法
代码1.12 通过二分递归计算Fibonacci数
__int64 fib ( int n ) { //计算Fibonacci数列的第n项(二分递归版):O(2^n)
return ( 2 > n ) ?
( __int64 ) n //若到达递归基,直接取值
: fib ( n - 1 ) + fib ( n - 2 ); //否则,递归计算前两项,其和即为正解
}
虽一目了然,但是效率低下
......P24、25计算时间复杂度的方式 O(2^n)
优化策略
为消除递归算法中重复的递归实例:
借助一定的辅助空间,在个子问题求解之后,及时记录下其对应的解答。
1.从原问题自顶而下,每遇到一个子问题先检查是否已经计算过,以期通过直接调阅记录获得解答,避免重复计算。
2。从递归基出发,自底而上递推得出个子问题的解,直至最终问题的解。
前者为制表或记忆策略,后者为动态规划策略。
Fibonacci数:线性递归
转而采用 (fib(k-1),fib(k))计算一对相邻的fib数:
代码1.13 通过线性递归计算Fibonacci数:
__int64 fib ( int n, __int64& prev ) { //计算Fibonacci数列第n项(线性递归版):入口形式fib(n, prev)
if ( 0 == n ) //若到达递归基,则
{ prev = 1; return 0; } //直接取值:fib(-1) = 1, fib(0) = 0
else { //否则
__int64 prevPrev; prev = fib ( n - 1, prevPrev ); //递归计算前两项
return prevPrev + prev; //其和即为正解
}
} //用辅助变量记录前一项,返回数列的当前项,O(n)
原版本中对fib(n-2)的另一次递归调用被省略掉,其对应的解答可借助形式参数的机制,通过变量prevPrev“调阅”此前的记录获得。
该算法时间复杂度 O(n),但所需O(n)规模的附加空间,如何改进?
Fibonacci数:迭代
线性迭代版的fib()算法中,其中所记录的每一个子问题的解答都只会用到一次。在该算法抵达递归基之后的逐层返回过程中,每向上返回一层,以下各层的解答均不必继续保留。
若将以上逐层返回的过程等效地视作从递归基出发,按规模自小而大求解各子问题的过程,即可采用动态规划的策略,将以上算法进一步改写为迭代版。
代码1.14 基于动态规划策略计算Fibonacci数:
__int64 fibI ( int n ) { //计算Fibonacci数列的第n项(迭代版):O(n)
__int64 f = 1, g = 0; //初始化:fib(-1)、fib(0)
while ( 0 < n-- ) { g += f; f = g - f; } //依据原始定义,通过n次加法和减法计算fib(n)
return g; //返回
}
只用了两个中间变量,仅需线性步的迭代,时间复杂度为O(n),更重要的是仅需常数规模的附加空间,空间效率有了极大提高。
1.5抽象数据类型
“信息隐藏”理念,通过统一的接口和内部封装,分层次从整体上加以设计、实现与使用。
至此《数据结构(c++语言版)》第一章知识整理完毕,这一章介绍了基本而经典的算法——递归,以及算法涉及到的各种常用理论基础。整理这一章内容分两次,几乎用了整整一天的时间。重新整理后的理解比第一遍看会深入很多,要坚持这样学下去。