前言
一、插入排序的场景
1.1 插入排序简介
根据《算法导论》中的描述,插入排序可以由“扑克抓牌”来解释,当我们抓牌的时候会进行排序,抓到的第一张牌,放置在第一的位置,后续抓到的牌与之前的牌进行比较后,插入到相应位置。
那么插入排序的需求可以描述为:
输入:n个数 <a1, a2, …, an>
输出:对输入序列的一个排序,使得a1' ≤ a2' ≤ … ≤ an' (亦可以降序排列)
待排序的数称为关键字(key)
1.2 插入排序伪代码
根据伪代码,可以看出,insert-sort,需要两层循环,外层循环,控制待排序的数(key)的遍历,内层循环,将待排序的数插入到合适位置
二、插入排序的实现
2.1 循环不变式与插入算法的正确性
这个理解的不是很透彻,回头补充(T_T)
一个来自《算法导论》的例子,对A = < 5, 2, 4, 6, 1, 3 >进行插入排序
a)
b)
c)
d)
e)
f)
2.2 Insert-Sort 的C语言实现
- #include <stdio.h>
- void printArray ( int intArray[], int length )
- {
- int loop;
- for ( loop = 0; loop < length; loop++ )
- {
- printf( "%d ", intArray[loop] );
- }
- printf( "\n" );
- }
- /*
- char[] array 待排序数组
- int flag 升降序标志位 0 升序、 1 降序
- */
- void insertSort ( int intArray[], int length, int flag )
- {
- // outterloop 控制外层循环, innerloop控制内层循环, key 待排序数字
- int outterloop, innerloop, key;
- // 数组下标从0开始,那么 outterloop = 1 即 intArray[1],数组中第二个数字
- // 数组中第一个元素默认已排序完成
- for ( outterloop = 1; outterloop < length; outterloop++ )
- {
- key = intArray[outterloop]; //key
- //内层循环负责遍历与插入
- // eg 当outterloop = 1时,意味着数组中只有一个元素,需要与intArray[1]进行比较,
- innerloop = outterloop - 1;
- // innerloop >= 0 负责控制循环次数
- // intArray[innerloop] > key 负责判断是否符合插入条件
- while ( innerloop >= 0 && intArray[innerloop] > key )
- {
- intArray[innerloop + 1] = intArray[innerloop];
- innerloop--;
- }
- // 与插入的元素 进行赋值
- intArray[innerloop + 1] = key;
- }
- }
- int main ( void )
- {
- int array[6] = { 5, 2, 4, 6, 1, 3 };
- printArray ( array, 6 );
- insertSort ( array, 6, 0 );
- printArray ( array, 6 );
- while(1);
- }
三、插入排序的算法分析
3.1 算法分析的一些概念
- 算法运行时间:在特定的输入下,所执行的基本操作数(步数),这是假设每次原子操作所花费的时间都是常量c,那么,如果执行一条语句需要n步,每步视为一个原子操作,那么,这条语句所花费时间为t = cn,那么运行总时间,则是对每对cn的值求和。
- 算法的性能:算法的性能,取决于给定规模的输入,同时,一个算法的运行时间还依赖于给定的是该规模下的那种输入。
- 数学的一些概概念:弃低阶项,如an2+bn+c可以变为n2
3.2 插入排序算法分析
那么,算法分析通常会出现,最佳情况、平均情况(期望)、最坏情况。其中最佳情况(实际中不存在)、平均情况(需要通过数学的方法建立相应场景)、最坏情况(最具参考价值)。
插入排序的base-case(最佳情况):如果是已排序好的,将会出现最佳情况
在该情况下T(n) = an + b
插入排序的worst-case(最坏情况):待排序数组为逆序,将出现最坏情况
在该情况下T(n) = an2+bn+c
总结下,插入排序的最佳时间复杂度为T(n) = n,最坏复杂时间为T(n) =n2
worst-case的参考价值:
- 一个算法的最坏运行情况运行时间是在任何输入下运行时间的一个上界
- 对于某些算法来说,最坏情况出现得还是相当频繁的。如,在数据库中检索一条信息时,当要找到的信息不在数据库中时,检索算法的最坏情况就会经常出现
算法的设计有很多思想,之前的插入排序使用的是增量的方法,即在排好的子数组A中,将元素A[j]插入,形成新的子数组A。
这次将实现另一种排序——归并排序,归并排序采用了“分治法”(divide-and-conquer),本篇中包含
分治法,也可以称为分治策略:是将一个大规模的问题(原问题)划分成n个规模较小而结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
分治模式在每一层递归上都有三个步骤:
分解(divide):将原问题分解成一系列子问题
解决(conquer):递归地解决各子问题。若子问题足够小,则直接求解
合并(combine):将子问题的结果合并成原问题的解
合并排序(merge sort)即参照了上述的模式:
分解:将n个元素分成各含n/2个元素的子序列
解决:用合并排序法对两个子序列递归地排序
合并:合并两个已排序的子序列以得到排序结果
若对子序列排序时,其长度为1时递归结束。因为当数组中仅含有单个元素时,必定是有序的。
二、归并算法的实现
2.1 merge-sort的分析
归并排序主要分为递归与合并两个部分,下面的伪代码为合并部分,其中A是一个数组,p、q、r为下标,满足
p≤q<r,A[p...q]与A[q+1...r]都是已排序好的,并合并成一个已排序好的子数组代替当前子数组A[p...r]。
MERGE过程的时间代价为Ɵ(n)其中n = r - p + 1为待合并元素个数。
归并的递归过程,归并的临界点与数组元素为1,因为数组元素个数为1,代表数组元素已排序。否则进行数组一分为2的操作,下面为递归部分伪代码
利用merge-sort( A, p, r )对子数组A[p...r]进行排序。如果p≥r,则该子数组中至多只有一个元素,当然就是已排序的。否则,分解步骤就计算出一个下标q,将A[p...r]分解为A[p...q]和A[q+1...r],各含[n/2]个元素。
2.2 merge-sort的C语言实现
- void merge ( int intArray[], int begin, int mid, int end )
- {
- int n1 = mid - begin + 1;
- int n2 = end - mid;
- int i, j, k;
- int *L = (int *) malloc ( sizeof ( int ) * n1 );
- int *R = (int *) malloc ( sizeof ( int ) * n2 );
- /*利用goto语句实现异常处理
- 当L或R未能正常分配时
- 将直接跳转至程序末尾,后续程序不再执行
- */
- if ( L == NULL || R == NULL )
- {
- goto error;
- }
- for ( i = 0; i < n1; i++ )
- L[i] = intArray[begin + i];
- for ( j = 0; j < n2; j++ )
- R[j] = intArray[mid + 1 + j];
- i = j = 0;
- k = begin;
- while ( i < n1 && j < n2 )
- {
- if ( L[i] < R[j] )
- {
- intArray[k++] = L[i++];
- }
- else
- {
- intArray[k++] = R[j++];
- }
- }
- while ( i < n1 )
- {
- intArray[k++] = L[i++];
- }
- while ( j < n2 )
- {
- intArray[k++] = R[j++];
- }
- /*
- 程序执行完毕后,在done处,进行资源释放
- */
- goto done;
- error:
- printf ( "malloc has benn failed!\n" );
- done:
- if ( L != NULL && R != NULL )
- {
- free ( L );
- free ( R );
- }
- }
- void merge_sort ( int intArray[], int head, int tail )
- {
- int mid;
- if ( head < tail )
- {
- mid = ( head + tail ) / 2;
- printf ( "sort ( %d-%d %d-%d ) %d %d %d %d %d %d %d %d\n", head, mid, mid + 1, tail, intArray[0], intArray[1], intArray[2], intArray[3], intArray[4], intArray[5], intArray[6], intArray[7] );
- merge_sort ( intArray, head, mid );
- merge_sort ( intArray, mid + 1, tail );
- merge ( intArray, head, mid, tail );
- printf ( "merge ( %d-%d %d-%d ) %d %d %d %d %d %d %d %d\n", head, mid, mid + 1, tail, intArray[0], intArray[1], intArray[2], intArray[3], intArray[4], intArray[5], intArray[6], intArray[7] );
- }
- }
- int main ( void )
- {
- int a[8] = { 5, 2, 4, 7, 1, 3, 8, 6 };
- int i = 0;
- merge_sort ( a, 0, 7 );
- for ( i = 0; i < 8; i++ )
- {
- printf ( "%d ", a[i] );
- }
- while ( 1 );
- }
三、排序算法分析
3.1 排序算法分析
这里有一个待排序的数组A= ( 5, 2, 4, 7, 1, 3, 2, 6 )
时间复杂度分析,设T(n)为一个规模为n的问题的运行时间。如果问题的规模足够小,如n≤c(c为常量),则得到它的直接解的时间为常量,写作Ɵ(1)。假设我们把原问题分解成a个子问题,每一个的大小是原问题的1/b(对于合并排序,a和b都是2,但在许多分治法中,a≠b)。如果分解该问题和合并解的时间各为D(n)和C(n),则递归式为
worst-case
分析T(n)最坏情况下,合并排序n个数的运行时间,合并排序一个元素的时间是个常量。当n>1时,将运行时间分解:
分解:这一步仅仅是计算出子数组的中间位置,需要常量时间,因而D(n)= Ɵ(1)
解决:递归地解两个规模为n/2的子问题,时间为2T(n/2)
合并:在一个含有n个元素的子数组上,MERGE过程的运行时间为Ɵ(n),则C(n) = Ɵ (n)
当我们再合并排序算法的分析中,将D(n)和C(n)相加时,我们是在将一个Ɵ(1)函数与另一个Ɵ(n)函数进行相加。相加的和是n的一个线性函数,即Ɵ(n)。将它与“解决”步骤中所得的2T(n/2)相加,即得到合并排序的最坏情况运行时间T(n)的递归表示
根据主定理(master theorem),它可以证明T(n)为Ɵ(nlgn)。
这里的lgn可以从树的深度测出,因为是取2的n此幂,因为,树的根节点为1,其他的分割是n/2;那么可以简单得出n = 1 * 2 * 2 * ... * 2,那么树的深度就是log_2 n,树的高度为log_2 n + 1。
3.2 排序算法与插入算法比较
因为对数函数的增长速度比任何线性函数增长的都要慢,因此,当输入规模足够大时,合并排序要比插入排序要好(worst-case下合并排序为Ɵ(nlgn),插入排序为Ɵ(n2))
四、参考资料
《算法导论》
《网易公开课》——算法导论