刚刚认真学习了第二章,习题还未做。现在趁热打铁,先来凭空总结和回忆一下整个过程。
本章主要线索:通过引入两个算法,从插入排序分析和设计排序算法,引出了整本书后续各章节的算法设计和分析的框架。这个框架归纳起来即是:
引入问题并以实际情景进行思考-->抽象出精确的算法描述(这里就包含了所用的数据结构)-->伪码表示-->证明算法的正确性-->算法分析-->算法设计。
本章先引入的是插入排序算法。
1.实际情景:
插入排序可以以这个实际情景来设想:有一堆牌拿在右手上,你左手开始是空的。每次从右手拿一张牌,与左手已有的排从右到左的进行比较,并插在正确的位置,则左手在每一次插入后均是排好序的。直到右手没有牌时,则终止,此时全部排序完成。
2.算法描述:
输入:含有n个待排序的数组A。(a[1]、a[2]、...、a[n]);
输出:含有n个已排序的数组A。(a`[1]、a`[2]、...、a`[n]);
3.伪码表示:
INSERTION-SORT(A) //输入:待排序数组A
for j <-- 2 to length[A]
do key <-- A[j]
》对每一个待排序数组的元素,将其与已排序数组的元素从右向左的比较,找到合适位置时停止比较并插入
i <-- j-1
while(i > 0 and A[i] > key)//“从右向左比较,找到合适位置停止”包含两层意思:1.向左递进到起始位置则停止,2.找到合适位置则停止;所以这话不妨改为:“从右到左进行比较,到达起始位置或者找到合适位置时停止。(未到达且未找到时不停止)”
A[i+1] <-- A[i] /
i <-- i-1
A[i+1] <-- key
4.算法正确性证明:
先引入所谓"loop invariant'的概念:一般而言,用这个式子表示希望得到的结果,如果在循环的每一步,这个式子都是正确的,那么循环结束后,这个式子也正确,并得到了期望的结果.(有人建议译为循环不变性,因为这是种性质不一定是式子)
循环不变式证明遵循以下三步:1.初始化 2.保持 3.终止
先假设的循环不变式为:在每轮循环开始前,A[1]..A[j-1]是排好序的。
1.初始化:对于for循环就是在第一次测试之前,这里即是在j赋值为2并且在测试j<=length[A]之前。可以看到,这时j=2,此时A[1]是单个元素,是排好序的,满足。
2.保持:假设在某轮循环(j = k时)开始前,循环不变式成立,即A[1]..A[k-1]有序,则在下一轮循环(j = k+1)开始前,已经执行了循环部分,此时A[1]..A[k]成立,满足。
3.终止:j=n+1时,A[1]..A[j-1]即A[1]..A[n]有序,成立。
5.算法分析:
对于算法分析,需要一个模型,我们设计的是一种单处理器的随机存取机模型,指令一条条执行,没有并发。在算法分析中,实际要考虑的因素往往很多,而我们为了方便,只考虑大的因素,小的因素忽略掉。
现在开始分析。
先通过代码标注执行时间:
INSERTION-SORT(A) costtimes
for j <-- 2 to length[A]c1n
do key <-- A[j]c2n-1
》对每一个待排序数组的元素,将其与已排序数组的元素从右向左的比较,找到合适位置时停止比较并插入
i <-- j-1 c4n-1
while(i > 0 and A[i] > key)c5求和(j=2..n)*t(j)//由于while测试次数不定,故设为t(j)
A[i+1] <-- A[i] c6求和(j=2..n)*(t(j)-1)
i <-- i-1 c7求和(j=2..n)*(t(j)-1)
A[i+1] <-- keyc8n-1
所以T(n)=c1n+c2(n-1)+c4(n-1)+c5(求和(j=2..n)*t(j))+c6(求和(j=2..n)*(t(j)-1))+c7(求和(j=2..n)*(t(j)-1))+c8(n-1)
最好情况:即当数组本身就有序,则A[i] <= key,只测试一次,所以t(j)=1,化简得T(n) = (c1+c2+c4+c5+c8)n-(c2+c4+c5+c8),所以是满足@(n)的(@表示sita)
最坏情况:即循环j-1次,亦即while处测试j次,此时T(n) = (...)n^2+(...)n-(...)
在实际的算法分析中,若平均情况不好估计,一般分析的是最坏情况, 因为最坏情况一般与平均情况一样差,比如这里平均情况是:对于一个插入元素A[j],有一半元素小于它一半大于它,那么t(j) = j/2,亦得一个二次函数,故运行效率相等。
但有时我们仍然对平均情况或期望感兴趣,那么我们可以根据后面会介绍的概率分析技术来进行分析。
下面我们可对执行时间进行简化,因为考虑大规模输入时,相对于增长率来说,系数是次要的,故这里可以改写成:@(n^2)。不解释了吧。
6.设计更好的算法
我们既然分析了算法,就应该找出时间耗费的地方,根据其他的技术或思想来设计更好的算法。开始吧。
我们知道插入排序使用的思想是增量式的设计,还有一种常见的思想则是分治法。
分治法的框架是:1.分解 2.解决3.合并
根据这个框架我们设计出合并排序算法,同样遵循步骤:
1.实际情景:略
2.算法描述:略
3.伪码表示:略
4.算法正确性证明:增量式设计的算法如插入排序使用的证明方法是循环不变式;而对于这种分治法则采用递归主定理来证明即可。
5.算法分析:(可以画递归树来分析)
执行时间写成一般表达式是:
T(n)= @(1)n<=c时
aT(n/b)+D(n)+C(n)否则
解释如下:当规模小于c时,可以直接解决,执行常量时间;否则分解为a个子问题,每个子问题大小是原问题的1/b,D(n)是分解所用时间,C(n)是合并所用时间。
针对这个特定问题,c=1,a=b=2,D(n) = @(n),C(n) = @(n)
不用主定理的话,可以画出递归树来看,可以得到T(n) = cn * (lg n +1)
故T(n) = @(nlgn)
以上就是我对第二章的主线和内容总结。
下面是实现:
插入排序:
#include <iostream>
using namespace std;
void InsertionSort(int A[], int n){ //输入含有n个数的数组A
int length_A = n;
for(int j=1; j<=length_A-1; j++){ //从右边挨个取数进行迭代比较
int key = A[j];
int i = j-1;
while(i>-1 && A[i]>key){
A[i+1] = A[i];
i = i-1;
}
A[i+1] = key;
}
}
//测试
int main(){
int A[]={10,9,4,7,8,3,1};
InsertionSort(A, 7);
for(int i=0; i<7; i++)
cout<<A[i]<<" ";
return 0;
}
合并排序:
#include <iostream>
using namespace std;
#define SENTINEL 10000; //用一个比所有数字都大的值作为哨兵
void Merge(int* A, int p, int q, int r){
int n1 = q-p+1;
int n2 = r-q;
int *L = new int[n1+1];
int *R = new int[n2+1];
for(int i=0; i<=n1-1; i++)
L[i] = A[p+i-1];
for(int j=0; j<=n2-1; j++)
R[j] = A[q+j];
L[n1] = SENTINEL;
R[n2] = SENTINEL;
int i = 0;
int j = 0;
for(int k=p-1; k<=r-1; k++){
if(L[i]<=R[j]){
A[k] = L[i];
i++;
}else{
A[k] = R[j];
j++;
}
}
delete[] L;
delete[] R;
}
void MergeSort(int* A, int p,int r){
if(p<r){
int q = (p+r)/2;
MergeSort(A, p, q);
MergeSort(A, q+1, r);
Merge(A, p, q, r);
}
}
int main(){
int A[]={10,9,4,7,8,3,1,15,12};
int length_A = 9;;
MergeSort(A, 1, length_A);
for(int i=0; i<length_A; i++)
cout<<A[i]<<" ";
return 0;
}