这个寒假打算学习算法导论这本书,每学完一部分我都会尽量更新,希望在寒假结束时有一些收获!
书山有路勤为径,加油!
一、基础知识
1.算法在计算中的作用
算法其实就是任何良定义的计算过程,算法把给定的输入通过规定的计算步骤运算转换为输出。
数据结构是一种存储和组织数据的方式,为了使程序访问修改更加便利。
算法应该被作为一种技术,巧妙设计与合理使用算法将可以在空间和时间上降低复杂度,将可以有效地使用计算机的计算资源与存储空间。
2.算法基础
2.1 插入排序
以插入排序开始我们的算法之路。(我只会介绍算法的使用,具体复杂度分析暂不讨论)
对于少量元素的排序,插入排序是一个有效的算法。其算法的核心思想就是,首先将待排序的数组分为两类,一类是已排好序的部分,一类是暂时未排序的部分,初始状态下,已排序部分为空,未排序部分就是整个数组。接下来,每次取未排序部分的第一个元素,并从已排序部分找到合适的位置将这个元素插入,这个位置使得元素插入后已排序部分仍然有序。重复此过程,直至未排序部分为空,那么整个数组将完成排序。
对数组A[1,..,n]进行插入排序的算法如下(后续的算法均以类似C/C++风格的伪码给出):
INSERTION-SORT(A)
for(j=2;j<=A.length;j++){
key=A[j];
i=j-1; //将A[j]插入到有序数组A[1,..,j-1]中,从小到大排列
while(i>0 && A[i]>key){
A[i+1]=A[i];
i=i-1;
}
A[i+1]=key;
}
书中使用循环不变式证明算法的正确性,我将不会给出具体的证明过程,只来简要介绍一下循环不变式。
循环不变式必须证明三条性质:
初始化:循环的第一次迭代之前,它为真,即符合输出的目标要求。
保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍未真。
终止:在循环终止时,不变式提供了一个有用的性质,该性质有助于证明算法是正确的。
其实这类似于数学归纳法,需要证明基本情况与归纳步骤的正确性,不同之处在于循环不变式必须终止,终止的时候即停止归纳并且可以证明算法的正确性。
2.2 分析算法
分析算法的过程主要集中于求最坏情况运行时间,因为最坏情况运行时间给出了任何输入的运行时间的一个上界。
更简化的抽象一下,我们真正感兴趣的是运行时间的增长率,当输入规模n很大时,我们只考虑低阶项与系数就不重要了,于是可以记插入排序的最坏情况运行时间为Θ(n^2)。
如果一个算法的最坏情况运行时间具有比另一个算法更低的增长量级,那么通常认为前者比后者更有效。
2.3 设计算法
许多有用的算法在结构上是递归的,这些算法典型地遵循分治法的思想:
将原问题分解为规模较小的但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。
分治模式在每层递归时都有三个步骤:
分解原问题为若干子问题,这些子问题是原问题的规模较小的实例。
解决这些子问题,递归的求解各子问题。然而,若子问题的规模足够小,则直接求解。
合并这些子问题的解完成原问题的解。
以归并算法为例来说明分治法的思想:
分解:将待排序的n个元素分成各具n/2个元素的两个子序列
解决:使用归并排序递归递归地排序两个子序列,当子序列的长度为1的时候,可以直接求解即该子序列已排序
合并:将两个已排序的子序列合并以产生最终排序的结果
归并排序中将已排序的子序列A[p,..,q]和A[q+1,..,r]合并为有序子序列A[p,..,r]的算法如下:
MERGE(A,p,q,r)
n1=q-p+1;
n2=r-q; //两个子序列的长度
//建立新数组L[1,..,n1+1]与R[1,..,n2+1]保存两个子序列
for(i=1;i<=n1;i++)
L[i]=A[p+i-1];
for(j=1;j<=n2;j++)
R[j]=A[q+j];
L[n1+1]=99999999;
R[n2+1]=99999999; //哨兵牌,认为所有的数不会比它大,也可以用INT_MAX,实际使用时取一个极大的数就行
i=1,j=1;
for(k=p;k<=r;k++){ //每次取最小的数合并到数组A中,合并完成后A[p,..,r]有序
if(L[i]<=R[j]){
A[k]=L[i];
i++;
}
else{
A[k]=R[j];
j++;
}
}
于是依据分治法的思想,可以写出归并排序的算法:
MERGE-SORT(A,p,r)
if(p<r){ //如果数组中元素个数大于1则分解,否则不做处理即相当于求出一个仅含一个数的有序子序列
q=(p+r)/2;
MERGE-SORT(A,p,q);
MERGE-SORT(A,q+1,r); //分解
MERGE(A,p,q,r); //合并
}
可以证明,对于足够大的输入,在最坏情况下,运行时间为Θ(nlgn)的归并排序优于运行时间为Θ(n^2)的插入排序。
对于归并排序的最坏情况运行时间T(n)的递归式为:
T(n)=c ,若n=1
T(n)=2T(n/2)+cn ,若n>1
依据此递归式或画出递归树,忽略低阶项与常系数可以得出归并排序的运行时间为Θ(nlgn)。
思考题中还提到了一种排序算法--冒泡排序,这种排序算法的思想就是反复交换相邻的未按次序排列的元素。每次将无序数列中的最大或最小值都通过交换放在无序数列的最后或最前,通过n-1次找出当前最值的过程便可以将数组排序。其算法如下:
BUBBLESORT(A) //冒泡排序将A[1,..,n]从小到大排序
for(i=1;i<A.length;i++)
for(j=A.length;j>=i+1;j--)
if(A[j]<A[j-1]){
将A[j]与A[j-1]交换位置
}
3.函数的增长
这一章中记号很多,公式也不少,我不打算全部都说,对算法运行时间想有深入研究的话,可以再去钻研钻研。
3.1 渐进记号
这里我只说明一个记号--Θ记号,之前已经说过插入排序的最坏情况运行时间为T(n)=Θ(n^2),那么Θ的含义是什么呢?对于一个给定的函数g(n),用Θ(g(n))来表示以下函数的集合:
Θ(g(n))={f(n):存在正常量c1,c2和n0,使得对所有n>=n0,有0<=c1g(n)<=f(n)<=c2g(n)}
也就是说,对所有n>=n0,函数f(n)在一个常量因子内等于g(n)。称g(n)是f(n)的一个渐进紧确界。
一般来说,对任意多项式p(n)=a0*n^0+a1*n^1+...+ad*n^d,其中ai为常量且ad>0,那么p(n)=Θ(n^d)。
也把任意常量函数表示为Θ(1)。
3.2 标准记号与常用函数
这一节基本上是数学相关的知识与公式,可以自行学习。
4.分治策略
再来复习一遍分治策略的三个步骤:
分解(Divide)步骤将问题划分为一些子问题,子问题的形式与原问题一样,只是规模更小。
解决(Conquer)步骤递归地求解出子问题。如果子问题的规模足够小,则停止递归,直接求解。
合并(Combine)步骤将子问题的解组合成原问题的解。
把需要递归求解的子问题称为递归情况,把可以直接求解的子问题称为基本情况。
4.1 最大子数组问题
问题:给定数组A,寻找数组A中和最大的非空连续子数组。
如果使用暴力法枚举的话可以得出结果,不过这样时间复杂度太高。
考虑使用分治策略求解,假定我们要求A[low,..,high]的最大子数组A[i,..,j],A[low,..,high]的中央位置为mid,那么A[i,..,j]所处的位置必为以下三种情况之一:
①完全位于子数组A[low..mid]中,此时low<=i<=j<=mid
②完全位于子数组A[mid+1..high]中,此时mid+1<=i<=j<=high
③跨越中点,此时low<=i<=mid<j<=high
所以可递归求解A[low..mid]和A[mid+1..high]的最大子数组,再寻找跨越中点的最大子数组,然后在三种情况中选取和最大者。求解第三种情况时的最大子数组的算法描述如下:
FIND-MAX-CROSSING-SUBARRAY(A,low,mid,high)
left_sum=-∞
sum=0
//跨越中点的子数组由A[i..mid]与A[mid+1..j]组成
for(i=mid;i>=low;i--){ //求最大子数组A[i..mid]
sum=sum+A[i];
if(sum>left_sum){
left_sum=sum;
max_left=i;
}
}
right_sum=-∞
sum=0
for(j=mid+1;j<=high;j++){ //求最大子数组A[mid+1..j]
sum=sum+A[j];
if(sum>right_sum){
right_sum=sum;
max_right=j;
}
}
return (max_left,max_right,left_sum+right_sum); //返回跨越中点的最大子数组的下标与值
有了一个线性时间的FIND-MAX-CROSSING-SUBARRAY在手,就可以设计出采用分治策略求
A[low..high]的最大子数组问题的算法了:
FIND-MAXIMUM-SUBARRAY(A,low,high)
if(high==low)
return (low,high,A[low]); //基本情况,返回下标及最大值
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_right,cross_sum)=FIND-MAX-CROSSING-SUBARRAY(A,low,mid,high); //情况③的最大子数组
if(left_sum>=right_sum && left_sum>=cross_sum) return(left_low,left_high,left_sum);
else if(right_sum>=left_sum && right_sum>=cross_num) return(right_low,right_high,right_sum);
else return(cross_low,cross_high.cross_sum); //返回三种情况中子数组和最大的子数组
}
可以计算得分治法求最大子数组问题的运行时间T(n)=Θ(nlgn),其渐进复杂性优于暴力求解。
实际上本题还可以采用动态规划进行求解,可以自行思考。
4.2 矩阵乘法的Strassen算法
按照线性代数中的矩阵乘法运算规则,两个n*n的矩阵进行乘法需要花费Θ(n^3)时间。
Strassen算法的核心思想是在分治求解的过程中让递归树不那么茂盛,即每次只递归求解7次而不是8次n/2*n/2矩阵的乘法。可以证明其运行时间为Θ(n^lg7),要优于普通的矩阵乘法计算方法。其算法用常数次的矩阵加减法减少了一次矩阵乘法,这个算法不是特别直观,我不做具体介绍,但通过这个算法我们可以知道通过减小递归树的宽度可以减小分治法过程的计算复杂度。
4.3 求解递归式
用递归式可以刻画分治算法的运行时间,求解递归式一般有三种方法:
①代入法
第一步,猜测解的形式。(书中说到猜测需要经验、创造力和一些启发式规则)
第二步,用数学归纳法求出解中的常数,并证明解是正确的。
②递归树方法
在递归树中,每个结点代表一个单一子问题的代价,子问题对应某次递归函数调用。我们将树中每层中的代价求和,得到每层代价,然后将所有层的代价求和,得到所有层次递归调用的总代价。根结点的代价支配了整棵树的代价。
③主方法
主方法依赖于主定理:
令a>=1和b>1是常数,f(n)是一个函数,T(n)是定义在非负整数上的递归式:
T(n)=aT(n/b)+f(n),则有以下结论:
对于上述三种方法在此没有具体介绍,如果对分治算法需要具体分析可以参考书本进一步研究。
5.概率分析和随机算法
这一章需要掌握概率论的基本知识,我在此也不会特别具体的介绍,需要深入了解的话还请参考书本。
仅列举几个概念:
①数字1到n的n!种排列构成的排列表,如果n!种可能的排列每一种以等概率出现,则称为均匀随机排列
②在概率分析中,对所有可能输入产生的运行时间取平均,称为平均情况运行时间
③把一个随机算法的运行时间称为期望运行时间
④给定一个样本空间S和一个事件A,那么事件A对应的指示器随机变量I{A}定义为:
I{A}=1,如果A发生
I{A}=0,如果A不发生
⑤介绍两种随机排列数组的算法,这两种算法均可产生一个均匀随机排列。
第一种方法是为数组的每个元素A[i]赋一个随机的优先级P[i],然后依据优先级对数组A中的元素进行排序。这个过程如下:
PERMUTE-BY-SORTING(A)
n=A.length
int *P=new int[n+1] //新建数组P[1..n]
for(i=1;i<=n;i++)
P[i]=RANDOM(1,n^3) //随机赋优先级
sort A using P as sort keys //根据优先级对A进行重排
第二种方法是进行n次迭代,进行第i次迭代时,元素A[i]从A[i]到A[n]中随机选择产生,第i次迭代之后A[i]就不会变了。这个过程如下:
RANDOMIZE-IN-PLACE(A)
n=A.length
for(i=1;i<=;i++)
swap A[i] with A[RANDOM(i,n)] //确定A[i]