源码家族
当前位置:首页 > 资讯中心

资讯中心

【 用C语言实现常用的排序算法(一) 】

发布时间:2021-06-17 09:32:06 阅读次数:144

整理目前大家常用的排序方法,帮助大家更深刻的了解每个排序算法。

直接插入排序

插入排序,又叫直接插入排序。

基本思路:

 在待排序的元素中,假设前n-1个元素已有序,现将第n个元素插入到前面已经排好的序列中,使得前n个元素有序。按照此法对所有元素进行插入,直到整个序列有序。

 但我们并不能确定待排元素中究竟哪一部分是有序的,所以我们一开始只能认为第一个元素是有序的,依次将其后面的元素插入到这个有序序列中来,直到整个序列有序为止。

代码:


//插入排序

void InsertSort(int* a, int n)

{

int i = 0;

for (i = 0; i < n - 1; i++)

{

int end = i;//记录有序序列的最后一个元素的下标

int tmp = a[end + 1];//待插入的元素

while (end >= 0)

{

if (tmp < a[end])//还需继续比较

{

a[end + 1] = a[end];

end--;

}

else//找到应插入的位置

{

break;

}

}

a[end + 1] = tmp;

//代码执行到此位置有两种情况:

//1.待插入元素找到应插入位置(break跳出循环到此)。

//2.待插入元素比当前有序序列中的所有元素都小(while循环结束后到此)。

}

}


希尔排序

希尔排序是按其设计者希尔的名字命名的,该算法由希尔1959年公布。希尔可以说是一个脑洞非常大的人,他对普通插入排序的时间复杂度进行分析,得出了以下结论:

 1.普通插入排序的时间复杂度最坏情况下为O(N2),此时待排序列为逆序,或者说接近逆序。

 2.普通插入排序的时间复杂度最好情况下为O(N),此时待排序列为升序,或者说接近升序。


于是希尔就想:若是能先将待排序列进行一次预排序,使待排序列接近有序(接近我们想要的顺序),然后再对该序列进行一次直接插入排序。因为此时直接插入排序的时间复杂度为O(N),那么只要控制预排序阶段的时间复杂度不超过O(N2),那么整体的时间复杂度就比直接插入排序的时间复杂度低了。

希尔排序,又称缩小增量法。其基本思想是:

 1.先选定一个小于N的整数gap作为第一增量,然后将所有距离为gap的元素分在同一组,并对每一组的元素进行直接插入排序。然后再取一个比第一增量小的整数作为第二增量,重复上述操作…

 2.当增量的大小减到1时,就相当于整个序列被分到一组,进行一次直接插入排序,排序完成。


问题:为什么要让gap由大到小呢?

answer:gap越大,数据挪动得越快;gap越小,数据挪动得越慢。前期让gap较大,可以让数据更快得移动到自己对应的位置附近,减少挪动次数。


注:一般情况下,取序列的一半作为增量,然后依次减半,直到增量为1(也可自己设置)。


举个例子分析一下:

 现在我们用希尔排序对该序列进行排序。


 我们用序列长度的一半作为第一次排序时gap的值,此时相隔距离为5的元素被分为一组(共分了5组,每组有2个元素),然后分别对每一组进行直接插入排序。


 gap的值折半,此时相隔距离为2的元素被分为一组(共分了2组,每组有5个元素),然后再分别对每一组进行直接插入排序。


 gap的值再次减半,此时gap减为1,即整个序列被分为一组,进行一次直接插入排序。


 该题中,前两趟就是希尔排序的预排序,最后一趟就是希尔排序的直接插入排序。


代码:


//希尔排序

void ShellSort(int* a, int n)

{

int gap = n;

while (gap > 1)

{

gap = gap / 2;//gap折半

int i = 0;

//进行一趟排序

for (i = 0; i < n - gap; i++)

{

int end = i;

int tmp = a[end + gap];

while (end >= 0)

{

if (tmp < a[end])

{

a[end + gap] = a[end];

end -= gap;

}

else

{

break;

}

}

a[end + gap] = tmp;

}

}

}

选择排序


 选择排序,即每次从待排序列中选出一个最小值,然后放在序列的起始位置,直到全部待排数据排完即可。


代码:


//选择排序(一次选一个数)

void SelectSort(int* a, int n)

{

int i = 0;

for (i = 0; i < n; i++)//i代表参与该趟选择排序的第一个元素的下标

{

int start = i;

int min = start;//记录最小元素的下标

while (start < n)

{

if (a[start] < a[min])

min = start;//最小值的下标更新

start++;

}

Swap(&a[i], &a[min]);//最小值与参与该趟选择排序的第一个元素交换位置

}

}



 实际上,我们可以一趟选出两个值,一个最大值一个最小值,然后将其放在序列开头和末尾,这样可以使选择排序的效率快一倍。


代码:


//选择排序(一次选两个数)

void SelectSort(int* a, int n)

{

int left = 0;//记录参与该趟选择排序的第一个元素的下标

int right = n - 1;//记录参与该趟选择排序的最后一个元素的下标

while (left < right)

{

int minIndex = left;//记录最小元素的下标

int maxIndex = left;//记录最大元素的下标

int i = 0;

//找出最大值及最小值的下标

for (i = left; i <= right; i++)

{

if (a[i] < a[minIndex])

minIndex = i;

if (a[i]>a[maxIndex])

maxIndex = i;

}

//将最大值和最小值放在序列开头和末尾

Swap(&a[minIndex], &a[left]);

if (left == maxIndex)

{

maxIndex = minIndex;//防止最大值位于序列开头,被最小值交换

}

Swap(&a[maxIndex], &a[right]);

left++;

right--;

}

}



堆排序

 要学习堆排序,首先要学习堆的向下调整算法,因为要用堆排序,你首先得建堆,而建堆需要执行多次堆的向下调整算法。


堆的向下调整算法(使用前提):

 若想将其调整为小堆,那么根结点的左右子树必须都为小堆。

 若想将其调整为大堆,那么根结点的左右子树必须都为大堆。


向下调整算法的基本思想(以建大堆为例):

 1.从根结点处开始,选出左右孩子中值较大的孩子。

 2.让大的孩子与其父亲进行比较。

 若大的孩子比父亲还大,则该孩子与其父亲的位置进行交换。并将原来大的孩子的位置当成父亲继续向下进行调整,直到调整到叶子结点为止。

 若大的孩子比父亲小,则不需处理了,调整完成,整个树已经是大堆了。


堆的向下调整算法代码:


//堆的向下调整算法

void AdjustDown(int* a, int n, int root)

{

int parent = root;

int child = 2 * parent + 1;//假设左孩子较大

while (child < n)

{

if (child + 1 < n&&a[child + 1] > a[child])//右孩子存在,并且比左孩子大

{

child++;//左右孩子的较大值

}

if (a[child] > a[parent])

{

Swap(&a[child], &a[parent]);

parent = child;

child = 2 * parent + 1;

}

else//已成堆

{

break;

}

}

}


 使用堆的向下调整算法,最坏的情况下(即一直需要交换结点),需要循环的次数为:h - 1次(h为树的高度)。而h = log2(N+1)(N为树的总结点数)。所以堆的向下调整算法的时间复杂度为:O(logN) 。


 上面说到,使用堆的向下调整算法需要满足其根结点的左右子树均为大堆或是小堆才行,那么如何才能将一个任意树调整为堆呢?

 答案很简单,我们只需要从倒数第一个非叶子结点开始,从后往前,按下标,依次作为根去向下调整即可。


建堆代码:


//建堆

for (int i = (n - 1 - 1) / 2; i >= 0; i--)

{

AdjustDown(php->a, php->size, i);

}


那么建堆的时间复杂度又是多少呢?

 当结点数无穷大时,完全二叉树与其层数相同的满二叉树相比较来说,它们相差的结点数可以忽略不计,所以计算时间复杂度的时候我们可以将完全二叉树看作与其层数相同的满二叉树来进行计算。


我们计算建堆过程中总共交换的次数:

T ( n ) = 1 × ( h − 1 ) + 2 × ( h − 2 ) + . . . + 2 h − 3 × 2 + 2 h − 2 × 1 T(n) = 1\times(h-1) + 2\times(h-2) + ... + 2^{h-3}\times2 + 2^{h-2}\times1 T(n)=1×(h−1)+2×(h−2)+...+2 

h−3

 ×2+2 

h−2

 ×1

两边同时乘2得:

2 T ( n ) = 2 × ( h − 1 ) + 2 2 × ( h − 2 ) + . . . + 2 h − 2 × 2 + 2 h − 1 × 1 2T(n) = 2\times(h-1) + 2^2\times(h-2) + ... + 2^{h-2}\times2 + 2^{h-1}\times1 2T(n)=2×(h−1)+2 

2

 ×(h−2)+...+2 

h−2

 ×2+2 

h−1

 ×1

两式相减得:

T ( n ) = 1 − h + 2 1 + 2 2 + . . . + 2 h − 2 + 2 h − 1 T(n)=1-h+2^1+2^2+...+2^{h-2}+2^{h-1} T(n)=1−h+2 

1

 +2 

2

 +...+2 

h−2

 +2 

h−1

 

运用等比数列求和得:

T ( n ) = 2 h − h − 1 T(n)=2^h-h-1 T(n)=2 

h

 −h−1

由二叉树的性质,有 N = 2 h − 1 N=2^h-1 N=2 

h

 −1和 h = log ⁡ 2 ( N + 1 ) h=\log_2(N+1) h=log 

2

 (N+1),于是

T ( n ) = N − log ⁡ 2 ( N + 1 ) T(n)=N-\log_2(N+1) T(n)=N−log 

2

 (N+1)

用大O的渐进表示法:

T ( n ) = O ( N ) T(n)=O(N) T(n)=O(N)


总结一下:

 堆的向下调整算法的时间复杂度: T ( n ) = O ( log ⁡ N ) T(n)=O(\log N) T(n)=O(logN)。

 建堆的时间复杂度: T ( n ) = O ( N ) T(n)=O(N) T(n)=O(N)。


那么堆建好后,如何进行堆排序呢?

步骤如下:

 1、将堆顶数据与堆的最后一个数据交换,然后对根位置进行一次堆的向下调整,但是调整时被交换到最后的那个最大的数不参与向下调整。

 2、完成步骤1后,这棵树除最后一个数之外,其余数又成一个大堆,然后又将堆顶数据与堆的最后一个数据交换,这样一来,第二大的数就被放到了倒数第二个位置上,然后该数又不参与堆的向下调整…反复执行下去,直到堆中只有一个数据时便结束。此时该序列就是一个升序。


堆排序代码:


//堆排序

void HeapSort(int* a, int n)

{

//排升序,建大堆

//从第一个非叶子结点开始向下调整,一直到根

int i = 0;

for (i = (n - 1 - 1) / 2; i >= 0; i--)

{

AdjustDown(a, n, i);

}

int end = n - 1;//记录堆的最后一个数据的下标

while (end)

{

Swap(&a[0], &a[end]);//将堆顶的数据和堆的最后一个数据交换

AdjustDown(a, end, 0);//对根进行一次向下调整

end--;//堆的最后一个数据的下标减一

}

}


下一篇:用C语言实现常用的排序算法(二)