转自http://blog.csdn.net/xiazdong/article/details/8462393
stable sort:插入排序、冒泡排序、归并排序、计数排序、基数排序、桶排序。
unstable sort:选择排序、快速排序、堆排序。
插入排序,冒泡排序和快速排序的排序趟数与序列的初始状态有关!!!
堆排序和选择排序的排序次数与初始状态无关,即最好情况和最坏情况都一样!!!
一、插入排序
特点:stable sort、In-place sort
最优复杂度:当输入数组就是排好序的时候,复杂度为O(n),而快速排序在这种情况下会产生O(n^2)的复杂度。
最差复杂度:当输入数组为倒序时,复杂度为O(n^2)
插入排序比较适合用于“少量元素的数组”。
其实插入排序的复杂度和逆序对的个数一样,当数组倒序时,逆序对的个数为n(n-1)/2,因此插入排序复杂度为O(n^2)。
在算法导论2-4中有关于逆序对的介绍。
伪代码:
证明算法正确性:
循环不变式:在每次循环开始前,A[1…i-1]包含了原来的A[1…i-1]的元素,并且已排序。
初始:i=2,A[1…1]已排序,成立。
保持:在迭代开始前,A[1…i-1]已排序,而循环体的目的是将A[i]插入A[1…i-1]中,使得A[1…i]排序,因此在下一轮迭代开 始前,i++,因此现在A[1…i-1]排好序了,因此保持循环不变式。
终止:最后i=n+1,并且A[1…n]已排序,而A[1…n]就是整个数组,因此证毕。
而在算法导论2.3-6中还问是否能将伪代码第6-8行用二分法实现?
实际上是不能的。因为第6-8行并不是单纯的线性查找,而是还要移出一个空位让A[i]插入,因此就算二分查找用O(lgn)查到了插入的位置,但是还是要用O(n)的时间移出一个空位。
问:快速排序(不使用随机化)是否一定比插入排序快?
答:不一定,当输入数组已经排好序时,插入排序需要O(n)时间,而快速排序需要O(n^2)时间。
递归版插入排序
二、冒泡排序
特点:stable sort、In-place sort
思想:通过两两交换,像水中的泡泡一样,小的先冒出来,大的后冒出来。
最坏运行时间:O(n^2)
最佳运行时间:O(n^2)(当然,也可以进行改进使得最佳运行时间为O(n))
算法导论思考题2-2中介绍了冒泡排序。
伪代码:
证明算法正确性:
运用两次循环不变式,先证明第4-6行的内循环,再证明外循环。
内循环不变式:在每次循环开始前,A[j]是A[j…n]中最小的元素。
初始:j=n,因此A[n]是A[n…n]的最小元素。
保持:当循环开始时,已知A[j]是A[j…n]的最小元素,将A[j]与A[j-1]比较,并将较小者放在j-1位置,因此能够说明A[j-1]是A[j-1…n]的最小元素,因此循环不变式保持。
终止:j=i,已知A[i]是A[i…n]中最小的元素,证毕。
接下来证明外循环不变式:在每次循环之前,A[1…i-1]包含了A中最小的i-1个元素,且已排序:A[1]<=A[2]<=…<=A[i-1]。
初始:i=1,因此A[1..0]=空,因此成立。
保持:当循环开始时,已知A[1…i-1]是A中最小的i-1个元素,且A[1]<=A[2]<=…<=A[i-1],根据内循环不变式,终止时A[i]是A[i…n]中最小的元素,因此A[1…i]包含了A中最小的i个元素,且A[1]<=A[2]<=…<=A[i-1]<=A[i]
终止:i=n+1,已知A[1…n]是A中最小的n个元素,且A[1]<=A[2]<=…<=A[n],得证。
在算法导论思考题2-2中又问了”冒泡排序和插入排序哪个更快“呢?
一般的人回答:“差不多吧,因为渐近时间都是O(n^2)”。
但是事实上不是这样的,插入排序的速度直接是逆序对的个数,而冒泡排序中执行“交换“的次数是逆序对的个数,因此冒泡排序执行的时间至少是逆序对的个数,因此插入排序的执行时间至少比冒泡排序快。
递归版冒泡排序
改进版冒泡排序
最佳运行时间:O(n)
最坏运行时间:O(n^2)
三、选择排序
特性:In-place sort,unstable sort。
思想:每次找一个最小值。
最好情况时间:O(n^2)。
最坏情况时间:O(n^2)。
定义:首先,选出数组中最小的元素,将它与数组中第一个元素交换。然后找出次小的元素,并将它与数组中第二个元素交换。按照这种方法一直进行下去,直到整个数组排完序。
交换次数:N-1
伪代码:
证明算法正确性:
循环不变式: A[1…i-1]包含了A中最小的i-1个元素,且已排序。
初始: i=1,A[1…0]=空,因此成立。
保持:在某次迭代开始之前,保持循环不变式,即A[1…i-1]包含了A中最小的i-1个元素,且已排序,则进入循环体后,程序从A[i…n]中找出最小值放在A[i]处,因此A[1…i]包含了A中最小的i个元素,且已排序,而i++,因此下一次循环之前,保持循环不变式:A[1..i-1]包含了A中最小的i-1个元素,且已排序。
终止: i=n,已知A[1…n-1]包含了A中最小的i-1个元素,且已排序,因此A[n]中的元素是最大的,因此A[1…n]已排序,证毕。
算法导论2.2-2中问了”为什么伪代码中第3行只有循环n-1次而不是n次”?
在循环不变式证明中也提到了,如果A[1…n-1]已排序,且包含了A中最小的n-1个元素,则A[n]肯定是最大的,因此肯定是已排序的。
递归版选择排序
四、归并排序
特点:stable sort、Out-place sort
思想:运用分治法思想解决排序问题。
最坏情况运行时间:O(nlgn)
最佳运行时间:O(nlgn)
分治法介绍:分治法就是将原问题分解为多个独立的子问题,且这些子问题的形式和原问题相似,只是规模上减少了,求解完子问题后合并结果构成原问题的解。
分治法通常有3步:Divide(分解子问题的步骤) 、 Conquer(递归解决子问题的步骤)、 Combine(子问题解求出来后合并成原问题解的步骤)。
假设Divide需要f(n)时间,Conquer分解为b个子问题,且子问题大小为a,Combine需要g(n)时间,则递归式为:
T(n)=bT(n/a)+f(n)+g(n)
算法导论思考题4-3(参数传递)能够很好的考察对于分治法的理解。
就如归并排序,Divide的步骤为m=(p+q)/2,因此为O(1),Combine步骤为merge()函数,Conquer步骤为分解为2个子问题,子问题大小为n/2,因此:
归并排序的递归式:T(n)=2T(n/2)+O(n)
而求解递归式的三种方法有:
(1)替换法:主要用于验证递归式的复杂度。
(2)递归树:能够大致估算递归式的复杂度,估算完后可以用替换法验证。
(3)主定理:用于解一些常见的递归式。
伪代码:
证明算法正确性:
其实我们只要证明merge()函数的正确性即可。
merge函数的主要步骤在第25~31行,可以看出是由一个循环构成。
循环不变式:每次循环之前,A[p…k-1]已排序,且L[i]和R[j]是L和R中剩下的元素中最小的两个元素。
初始:k=p,A[p…p-1]为空,因此已排序,成立。
保持:在第k次迭代之前,A[p…k-1]已经排序,而因为L[i]和R[j]是L和R中剩下的元素中最小的两个元素,因此只需要将L[i]和R[j]中最小的元素放到A[k]即可,在第k+1次迭代之前A[p…k]已排序,且L[i]和R[j]为剩下的最小的两个元素。
终止:k=q+1,且A[p…q]已排序,这就是我们想要的,因此证毕。
归并排序的例子:
问:归并排序的缺点是什么?
答:他是Out-place sort,因此相比快排,需要很多额外的空间。
问:为什么归并排序比快速排序慢?
答:虽然渐近复杂度一样,但是归并排序的系数比快排大。
问:对于归并排序有什么改进?
答:就是在数组长度为k时,用插入排序,因为插入排序适合对小数组排序。在算法导论思考题2-1中介绍了。复杂度为O(nk+nlg(n/k)) ,当k=O(lgn)时,复杂度为O(nlgn)
五、快速排序
基本思想:在待排序的N个记录中任意取一个记录,把该记录放在最终位置后,数据序列被此记录分成两部分。所有关键字比该记录关键字小的放在前一部分,所有比它大的放置在后一部分,并把该记录排在这两部分的中间,这个过程称作一次快速排序。重复上述过程,直到每一部分内只有一个记录为止。
特性:unstable sort、In-place sort。
最坏运行时间:当输入数组已排序时,时间为O(n^2),当然可以通过随机化来改进(shuffle array 或者 randomized select pivot),使得期望运行时间为O(nlgn)。
最佳运行时间:O(nlgn)
快速排序的思想也是分治法。
当输入数组的所有元素都一样时,不管是快速排序还是随机化快速排序的复杂度都为O(n^2),而在算法导论第三版的思考题7-2中通过改变Partition函数,从而改进复杂度为O(n)。
注意:只要partition的划分比例是常数的,则快排的效率就是O(nlgn),比如当partition的划分比例为10000:1时(足够不平衡了),快排的效率还是O(nlgn)
“A killer adversary for quicksort”这篇文章很有趣的介绍了怎么样设计一个输入数组,使得quicksort运行时间为O(n^2)。
伪代码:
随机化partition的实现:
改进当所有元素相同时的效率的Partition实现:
证明算法正确性:
对partition函数证明循环不变式:A[p…i]的所有元素小于等于pivot,A[i+1…j-1]的所有元素大于pivot。
初始:i=p-1,j=p,因此A[p…p-1]=空,A[p…p-1]=空,因此成立。
保持:当循环开始前,已知A[p…i]的所有元素小于等于pivot,A[i+1…j-1]的所有元素大于pivot,在循环体中,
-如果A[j]>pivot,那么不动,j++,此时A[p…i]的所有元素小于等于pivot,A[i+1…j-1]的所有元素大于pivot。
-如果A[j]<=pivot,则i++,A[i+1]>pivot,将A[i+1]和A[j]交换后,A[P…i]保持所有元素小于等于pivot,而A[i+1…j-1]的所有元素大于pivot。
终止:j=r,因此A[p…i]的所有元素小于等于pivot,A[i+1…r-1]的所有元素大于pivot。
六、堆排序
1964年Williams提出。
特性:unstable sort、In-place sort。
最优时间:O(nlgn)
最差时间:O(nlgn)
此篇文章介绍了堆排序的最优时间和最差时间的证明:http://blog.csdn.net/xiazdong/article/details/8193625
思想:运用了最小堆、最大堆这个数据结构,而堆还能用于构建优先队列。
优先队列应用于进程间调度、任务调度等。
堆数据结构应用于Dijkstra、Prim算法。
证明算法正确性:
(1)证明build_max_heap的正确性:
循环不变式:每次循环开始前,A[i+1]、A[i+2]、…、A[n]分别为最大堆的根。
初始:i=floor(n/2),则A[i+1]、…、A[n]都是叶子,因此成立。
保持:每次迭代开始前,已知A[i+1]、A[i+2]、…、A[n]分别为最大堆的根,在循环体中,因为A[i]的孩子的子树都是最大堆,因此执行完MAX_HEAPIFY(A,i)后,A[i]也是最大堆的根,因此保持循环不变式。
终止:i=0,已知A[1]、…、A[n]都是最大堆的根,得到了A[1]是最大堆的根,因此证毕。
(2)证明heapsort的正确性:
循环不变式:每次迭代前,A[i+1]、…、A[n]包含了A中最大的n-i个元素,且A[i+1]<=A[i+2]<=…<=A[n],且A[1]是堆中最大的。
初始:i=n,A[n+1]…A[n]为空,成立。
保持:每次迭代开始前,A[i+1]、…、A[n]包含了A中最大的n-i个元素,且A[i+1]<=A[i+2]<=…<=A[n],循环体内将A[1]与A[i]交换,因为A[1]是堆中最大的,因此A[i]、…、A[n]包含了A中最大的n-i+1个元素且A[i]<=A[i+1]<=A[i+2]<=…<=A[n],因此保持循环不变式。
终止:i=1,已知A[2]、…、A[n]包含了A中最大的n-1个元素,且A[2]<=A[3]<=…<=A[n],因此A[1]<=A[2]<=A[3]<=…<=A[n],证毕。
七、计数排序
特性:stable sort、out-place sort。
最坏情况运行时间:O(n+k)
最好情况运行时间:O(n+k)
当k=O(n)时,计数排序时间为O(n)
伪代码:
B[n]存放排序结果;C[k+1]提供临时存储区
算法的步骤如下:
1、找出待排序的数组中最大和最小的元素
2、统计数组中每个值为t的元素出现的次数,存入数组C的第t项
3、对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)
4、反向填充目标数组:将每个元素t放在新数组的第C(t)项,每放一个元素就将C(t)减去1
例子:
假设数字范围在 0 到 9.
输入数据: 1, 4, 1, 2, 7, 5, 2
1) 使用一个数组记录每个数组出现的次数
Index: 0 1 2 3 4 5 6 7 8 9
Count: 0 2 2 0 1 1 0 1 0 0
2) 累加所有计数(从C中的第一个元素开始,每一项和前一项相加)
Index: 0 1 2 3 4 5 6 7 8 9
Count: 0 2 4 4 5 6 6 7 7 7
更改过的计数数组就表示 每个元素在输出数组中的位置
3) 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1
例如对于: 1, 4, 1, 2, 7, 5, 2. 1 的位置是 2.
把1放在输出数组的第2个位置.并把计数减 1,下一个1出现的时候就放在了第1个位置。(方
向可以保持稳定)
习题8.2-4
给出一个算法,使之对于给定介于0和k之间的n个整数进行预处理,并能在O(1)时间内,回答出输入的整数中有多少个落在区间[a..b]内。你给出的算法的预处理时间应为Θ(n + k)。
用一个数组C,记录小于或等于其每个下标的值的元素个数。C[b] - C[a-1]为落在区间内的元素个数。
#include <stdio.h>
#include <stdlib.h>
int count(int A[], int length, int k, int a, int b);
int main(){
int num, i, k, a, b, cnt;
printf("Input the k:\n");
scanf("%d", &k);
printf("Input the a and b:\n");
scanf("%d %d", &a, &b);
printf("Input the number of the elements:\n");
scanf("%d", &num);
int *array = malloc((num + 1) * sizeof(int));
printf("Input the element:");
for(i = 1; i <= num; i++){
scanf("%d", &array[i]);
}
cnt = count(array, num, k, a, b);
printf("The number of the elements which are in the [a..b] is %d\n", cnt);
return 0;
}
int count(int A[], int length, int k, int a, int b){
int *C = malloc((k + 1) * sizeof(int));
for(int i = 0; i <= k; i++)
C[i] = 0;
for(int j = 1; j <= length; j++)
C[A[j]]++;
for(int i = 1; i <= k; i++)
C[i] += C[i-1];
return C[b] - C[a-1] ;
}
八、基数排序
本文假定每位的排序是计数排序。
特性:stable sort、Out-place sort。
最坏情况运行时间:O((n+k)d)
最好情况运行时间:O((n+k)d)
当d为常数、k=O(n)时,效率为O(n)
我们也不一定要一位一位排序,我们可以多位多位排序,比如一共10位,我们可以先对低5位排序,再对高5位排序。
引理:假设n个b位数,将b位数分为多个单元,且每个单元为r位,那么基数排序的效率为O[(b/r)(n+2^r)]。
当b=O(nlgn),r=lgn时,基数排序效率O(n)
比如算法导论习题8.3-4:说明如何在O(n)时间内,对0~n^3-1之间的n个整数排序?
答案:考虑n个三位数(即d=3)的基数排序,每个数字在0~n-1区间(即k=n)。总共有三次调用计数排序,每次花费O(n+k)=O(n+n)=O(n)时间,因此总时间为O(n)。
九、桶排序
假设输入数组的元素都在[0,1)之间。
特性:out-place sort、stable sort。
最坏情况运行时间:当分布不均匀时,全部元素都分到一个桶中,则O(n^2),当然[算法导论8.4-2]也可以将插入排序换成堆排序、快速排序等,这样最坏情况就是O(nlgn)。
最好情况运行时间:O(n)
桶排序的例子:
伪代码:
证明算法正确性:
对于任意A[i]<=A[j],且A[i]落在B[a],A[j]落在B[b],我们可以看出a<=b,因此得证。