笔者是刚刚转专业到计算机科学与技术专业的准大二学生,这个暑假在着手自学一些书籍,写该博客主要为了梳理知识,加深记忆。
第一章 绪论
本章将介绍计算机相关的基本概念,包括算法构成的基本要素、算法效率的衡量尺度、计算复杂度的分析方法与界定技巧、算法设计的基本框架与经典模式。
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的问题,还有多种处理方式