数据结构 5排序算法

排序算法

插入排序

基本插入排序(Insertion Sort)

如果要向一组已经排好了序的数中,再插入一个数,可以从头开始遍历,直到找到相应的位置,插入即可。
所以我们可以这样对一组数进行插入排序:进行N-1次 pass,在第P次pass,我们将位置P处的元素向前移动到相应的位置,前面P个元素一定是已经排好序的,后面的先不用管.(这就像将一个元素插入到一段已经排好序的数中)。例如P=1时,比较前P个元素(就是第0个),判断是否移动,做完后,保证了前2个元素是排好序的;然后P=2,将第2个元素移动到前两个元素的相应位置。
下面是插入排序的过程:

Original34 8 64 51 32 21说明
p=18 34 64 51 32 218与34比较,8向前移动
p=28 34 64 51 32 2164 与前面的8 34 比较,不用变
p=38 34 51 64 32 2151 与前面的8 34 64 比较,51移动到34后面
p=48 32 34 51 64 2132移动到34的前面
p=58 21 32 34 51 6421移动到32的前面

插入排序的例程:

void InsertionSort(int A[], int N)
{
    int j, p;  //p表示当前正移动的元素下标
    int Temp;
    for (p = 1; p < N;p++)
    {
        Temp = A[p];
        //下面的方法可以代替swap方法,更加快
        for (j = p; j > 0 && A[j - 1] > Temp;j--)
        {
            A[j] = A[j - 1];  //将前面一个数向后移一位,前面数的位置空出来
        }//不断将比位置P大的数后移
        A[j] = Temp;//最后将Temp放入最终空出来的那个位置
    }
}

循环不变式:第p次pass,定义j=p,每次比较A[j-1]A[p],如果A[j] > A[p],将A[j]后移,即A[j] = A[j-1]
循环结束的条件: j > 0 && A[j-1] > A[p]

从条件A[j-1] < Temp可以看出, 原序列越有序,时间就越小
插入排序的平均时间复杂度为O(N2).当数组已经排好序时,时间复杂度最优为 O ( N ) O(N) O(N)

定理: Any algorithm that sorts by exchanging adjacent elements requires Ω ( N 2 ) Ω(N^2) Ω(N2) time on average

希尔排序

先知道Hk-sorted是什么: 排序后,对于所有的i,A[i] <= A[i+ Hk] 都成立。就说,如果Hk=5的话,A[0]<=A[5]<=A[10]<=… ; A[1]<=A[6]<=A[11]<=…;相差5的元素一定是排好序了的。
看一个排序的例子:
Original : 81 94 11 96 12 35 17 95 28 58 41 75 15
After 5-sort: 35 17 11 28 12 41 75 15 96 58 81 94 95
After 3-sort: 28 12 11 35 15 41 58 17 94 75 81 96 95
After 1-sort: 11 12 15 17 28 35 41 58 75 81 94 95 96
定义一个increment sequence(步长序列) H1<H2<H3<…<Hk.
Shell排序就是按顺序进行Hk-sort, Hk-1-sort, …H1-sort,只要保证H1=1,任何序列都能进行shell排序。

如果只进行1-sort,那么这等价于插入排序;
之所以在1-sort之前,使用了5-sort,3-sort,这会使一列数变得大致有序,前面说了原序列越有序,插入排序的时间越少。所以等到1-sort的时候,并不需要比较太多次。
Hk-sorted相当于步长为Hk的插入排序

shell排序的时间复杂度,取决于H1<H2<H3<…<Hk.这个序列的选取,下面的代码使用Shell’s increment sequence:,对于N个数据,步长序列取:N/2,N/4,N/8,N/16,…1;

void ShellSort(int A[], int N)
{
    int i, j, Increment;
    int Tmp;
	
	//h sequence
    for (Increment = N / 2; Increment > 0;Increment/=2)
    {
    	//Increment=1时,这块代码与插入排序完全相同
        for (i = Increment; i < N;i++)
        {
            Tmp = A[i];
            for (j = i; j >= Increment;j-=Increment)
            {
                if(Tmp<A[j-Increment])
                    A[j] = A[j - Increment];
                else
                    break;
            }
            A[j] = Tmp;
        }
    }
}

使用希尔增量时,最坏情况下的运行时间为 O ( N 2 ) O(N^2) O(N2),如序列1 9 2 10 3 11 4 12 5 13 6 14 7 15 8 16。使用8-sort 4-sort 2-sort 均不会产生任何效果。最后只有1-sort相当于插入排序,起作用。
使用一些其他的增量,如Hibbard增量可以改善时间复杂度,因此希尔排序的时间复杂度取决于增量序列的选择。

堆排序(Heapsort)

算法1:假设我们对数组H[]中的元素进行从小到大排序

  1. 将数组H[]调成最小堆
  2. 新建一个TmpH[]数组
  3. 每次对堆H进行一次删除,由堆的性质可知,这个元素一定的最小的那一个,将其放入TmpH[]中;
  4. 不断重复3,知道所有的元素都删除了
  5. 将TmpH中的元素返回给H数组

建堆的时间复杂度为 O ( N ) O(N) O(N)
删除最小元素的时间复杂度为 O ( l o g N ) O(logN) O(logN)
总共进行N次删除,所以总的时间复杂度为 O ( N l o g N ) O(NlogN) O(NlogN)

这种算法会消耗额外一倍的内存空间,所以我们常采用另一种算法。

算法2

  1. 先将大小为N的数组A调成最大堆(元素A[0]一定最大)
  2. 将A[0]与A[N-1]交换,并将堆的大小减1,单独对第一个不符合的元素调整,将大小为N-1的数组A调成堆(元素A[0]又变成最大了,且先前的在A[N],不在堆中,不受影响)
  3. 不断重复步骤2,直到最后堆的大小减小为0
  4. 此时数组A就是按照从小到大排序的

注意: 从小到大排序,使用最大堆;从大到小排序,使用最小堆。

由于给入的数组从0开始记录数据,所以调成堆的时候,也从零开始计数,要格外注意,位置 i 处的,左儿子为 2i+1, 右儿子为 2i+2

void HeapSort(int a[], int N)
{
    int i;
    for (i = N/2; i >=0; i--)   
        PercDown(a, i, N);     //先将数组a调成最大堆
    for (i = N - 1; i > 0;i--)
    {
    	Swap(&a[0],&a[i]); //将最大的元素放到后面,堆大小-1
        PercDown(a, 0, i); //重新调成堆,注意这里数组变小了
    }
}
void PercDown(int a[],int index, int N)
{
        int child;    
        int tmp = a[index];
        for (i = index; i * 2 + 1 < N; i = child)
        {
            child = i * 2 + 1; 
            if (child != N-1 && a[child + 1] > a[child]) 
                child++;     
            if (tmp < a[child]) 
                a[i] = a[child];  
            else
                break;  
        }
        a[i] = tmp;
}

归并排序

归并排序就是一个典型的分治法的例子,将一个数列分成两部分,分别排序,然后合并(不断地从两个数组中的第一个元素选择较小者,直到整个数组被填满)。
算法:首先判断数组大小,若无元素或只有一个元素,则数组必然已被排序;若包含的元素多于1个,则执行下列步骤:

  1. 把数组分为大小相等的两个子数组(第一个数组大小为n/2,第二个为n-n/2);
  2. 对每个子数组递归采用归并算法进行排序;
  3. 合并两个排序后的子数组。
void Merge (int array[], int arr1[], int n1, int arr2[], int n2) 
{ 
     int p, p1, p2;  
     p = p1 = p2 = 0; 
     while (p1 < n1 && p2 < n2) { 
           if (arr1[p1] < arr2[p2])  
                 array[p++] = arr1[p1++]; 
           else  
                 array[p++] = arr2[p2++]; 
    } 
    while (p1 < n1) array[p++] = arr1[p1++]; 
    while (p2 < n2) array[p++] = arr2[p2++]; 
}
void SortIntegerArray (int array[], int n) 
{ 
    int i, n1, n2, *arr1, *arr2; 
    if (n > 1) { 
         n1 = n / 2; 
         n2 = n – n1; 
         arr1 = NewArray (n1, int); 
         arr2 = NewArray (n2, int); 
         for (i = 0; i < n1; i++) arr1[i] = array[i]; 
         for (i = 0; i < n2; i++) arr2[i] = array[n1 + i]; 
         SortIntegerArray (arr1, n1); 
         SortIntegerArray (arr2, n2); 
         Merge (array, arr1, n1, arr2, n2); 
         FreeBlock (arr1); 
         FreeBlock (arr2); 
    } 
}

如果对Merge的每个递归调用均局部声明一个临时数组,那么在任意时刻,就可能有logN个数组。我们需要每次递归让它释放掉临时空间,任意时刻只需要有一个临时数组活动即可,空间复杂度为O(N)

Note:Mergesort requires linear extra memory, and copying an array is slow. It is hardly ever used for internal sorting, but is quite useful for external sorting.

时间复杂度分析:
T ( 1 ) = 1 T(1) = 1 T(1)=1
T ( N ) = 2 T ( N / 2 ) + O ( N ) = 2 k T ( N / 2 k ) + k ∗ O ( N ) T(N)=2T(N/2)+O(N)=2^kT(N/2^k)+k*O(N) T(N)=2T(N/2)+O(N)=2kT(N/2k)+kO(N)
2 k = N 2^k = N 2k=N
T ( N ) = N T ( 1 ) + l o g N ∗ O ( N ) = O ( N + N l o g N ) T(N)=NT(1)+logN*O(N)=O(N+NlogN) T(N)=NT(1)+logNO(N)=O(N+NlogN)

快速排序

快速排序是已知的最快的排序算法,平均时间复杂度为O(N logN),最坏情况为O(N2)。
基本的算法思路:
分治递归的算法,当数组中只有一个或两个元素的时候为递归出口,每次递归,选取一个标准元素pivot,将所有小于pivot的元素移到pivot左边,所有大于pivot的元素移到pivot右边,然后对左右两边的元素做相同的操作,直到只有一或两个元素。

void QuickSort(int A[], int N)
{
	if(N<2)    
		return;
	pivot = pick any element v int A[];
	//A划分成两个不相交集合,一个集合所有元素都小于pivot,另一个所有元素都大于pivot
	A1 = {x∈A-{pivot}|x <= pivot};
	A2 = {x∈A-{pivot}|x >= pivot};
	return {QuickSort(A1) ∪ pivot ∪ QuickSort(A2)};
}

如何选取pivot

我们经常用数组的第一个元素作为pivot,这样可以是可以,但必须保证输入是随机的。如果一个数组的元素已经排好了序,再进行快速排序,会消耗O(N2)的时间,而且还是做无用功。所以尽量不用这种选择办法。

我们会想到使用随机算法随机选取一个pivot,但是随机算法本身也会消耗一定的时间。

Median-of-Three Partitioning
我们采用这种方法:选择一组数据中最左边的,最右边的,和中间的,这三个元素,将三个元素中间大的元素作为pivot。
在这里插入图片描述

划分方法

如何只在原数组上进行操作,达到这样的效果:
所有比pivot小的元素在pivot的左边,所有比pivot大的元素在pivot的右边。

首先,将pivot与最后一个元素交换。令i指向第一个元素,j指向倒数第二个元素
在这里插入图片描述
然后,当 i 在 j 的左边的时候,

  • 如果 A[i] 小于pivot ,就将 i 向右移动,直到A[i] >pivot
  • 如果 A[j] 大于pivot, 就将 j 向左移动,直到A[j] <pivot
    在这里插入图片描述
    当 i,j 都停下来时,将A[i] 与 A[j] 的值交换。这样做的效果是,使比pivot大的数都在右边,比pivot小的数都在左边。
    在这里插入图片描述
    重复上面的步骤:
    在这里插入图片描述
    当 i 与 j 相交(i 跑到 j 的右边了)时,这个时候便不再将i,j对应的元素交换。
    执行最后一个步骤,将 i 对应的元素与 pivot 交换(因为此时i指向的元素比pivot大,换了以后,正好把指向的元素放到了后面)
    在这里插入图片描述
    这样就满足了划分的要求。

考虑一下当 i 或 j 指向的元素与pivot相等的时候,是否要停下。
答案是都要停下。想象一种情况,如果对一组元素都相同的数进行快排。

不停下的话,i就会一直向右边移动,最后 i 的指向与pivot相同,这样划分出来的两个集合太不平衡了(pivot在最后面),这就像前面说过的用第一个元素作为pivot,对一个已经排序好的元素进行排序一样,会消耗O(N2)时间。

而如果停下的话,虽然每次会进行交换,但最终pivot会移到中间位置,这样对两边继续进行递归会减少更多的时间,时间复杂度为O(N logN)。

元素较少的情况

当元素个数小于等于20左右时,插入排序反而比快速排序更快。解决方法是判断N的大小,当N比较小时,采用其他的排序方法。

实现代码

下面是选择pivot的代码。这里将Left,Center,Right位置的数先排好了顺序,即满足 A[Left]<=A[Center]<=A[Right],这样有很多好处。
将pivot与Right-1位置处的数交换,并将 i 设置为Left+1,j 设置为Right-2。A[Left]与A[Right]的数起到了边界的效果。因为数组两边的数一定一个小于pivot,一个大于pivot,所以i,j移动的时候,就不会越界。

int Median3(int A[], int Left, int Right)
{
    int Center = (Left + Right) / 2;

    //A[Left]<=A[Center]<=A[Right],先排好了顺序
    if(A[Left] > A[Center])
        Swap(&A[Left], &A[Center]);
    if(A[Left] > A[Right])
        Swap(&A[Left], &A[Right]);
    if(A[Center] > A[Right])
        Swap(&A[Center], &A[Right]);

    Swap(&A[Center], &A[Right - 1]);  //将pivot放到了Right-1的位置
    return A[Right - 1];
}
#define Cutoff (2)

void qsort(int A[],int Left,int Right)
{
    int i, j, pivot;

    if(Left + Cutoff<= Right)   //当序列过短时,调用其他的排序方法
    {
        pivot = Median3(A, Left, Right);
        i = Left;
        j = Right - 1;
        for (;;)
        {
            while(A[++i]<pivot){}  
            while(A[--j]>pivot){}
            if(i<j)
                Swap(&A[i], &A[j]);
            else
                break;
        }
        Swap(&A[i], &A[Right - 1]);

        qsort(A, Left, i - 1);
        qsort(A, i + 1, Right);  
    }
    else
    {
        InsertionSort(A + Left, Right - Left + 1);
    } 
}

void Quicksort(int A[], int N)
{
    qsort(A, 0, N - 1);
}

注意,当序列过短时,应该调用其他的排序方法。否则Median3会出问题。
为什么不可以这么写?
i = left +1; j = Right - 2;
for(; ; )
{
while(A[i]<pivot) i++;
while(A[j]>pivot) j++;

}
注意不能先判断A[i],是否小于pivot,再将i++;这样写的话,当A[i]=A[j]=pivot时,会无限循环,i,j再也移动不了了。所以要先移动,再判断

一些其他问题

采用递归方式对顺序表进行快速排序,下列关于递归次数的叙述中,正确的是(D)
A递归次数与初始数据的排列次序无关
B每次划分后,先处理较长的分区可以减少递归次数
C每次划分后,先处理较短的分区可以减少递归次数
D递归次数与每次划分后得到的分区处理顺序无关
https://www.nowcoder.com/questionTerminal/69fc9122a0a74b5f8e011c4f53419dd3?pos=7&mutiTagIds=591&orderByHotValue=1
递归次数,取决于递归树,而递归树取决于轴枢的选择。树越平衡,递归次数越少。而对分区的长短处理顺序,影响的是递归时对栈的使用内存,而不是递归次数

对大的结构进行排序

我们常常想对一个结构进行排序。比如学生这个结构可能包括学号,姓名等信息。我们想根据学生的成绩进行排序。如果直接交换两个结构,额外的开销会非常大。因此我们可以添加一个指针指向结构,对指针进行排序即可。

桶排序(Bucket Sort)

Any algorithm that sorts by comparisons only must have a worst case computing time of Ω( N log N ).
通过比较进行的算法至少要NlogN时间,但在一些特殊的情况下,排序的时间复杂度可以达到线性时间。例如桶排序。

有N个学生,他们的成绩在0-100之间,现在需要对这N个学生的成绩进行排序。
从题目中可以看出,N个数字进行排序,不同的数字个数最多只有M=101个,我们可以使用一个数组指针A[101],对N个学生的成绩进行遍历,若读到学生m1的成绩为X, 便使A[X]指向学生m1;如果又读到一个学生m2的成绩也为X,将m2这个结构作为节点插入链表A[X]中。
这样排序所需的时间为O(M,N)

基数排序(Radix Sort)

给出N个范围在0-999(M=1000)的正整数,如何在线性时间内将这N个数排好序?
算法思想:
假设有编号0-9的十个桶,待排序的数170, 45, 75, 90, 02, 802, 2, 66
先看最低位,依照最低位将数放到相应的桶中。获得如下的序列;
170, 90, 02, 802, 2, 45, 75, 66
为什么170在90的前面,他们的个位都是0啊?
因为原序列中170在90的前面,所以按低位排了一遍后,170还是在90前。

再看次低位,比较后获得序列:
02, 802, 02, 45, 66, 170, 75, 90
为什么802在02的前面?因为次低位排序之前,就在前面了。不改变原来的相对顺序

最后对最高位进行一次,就排序成功了
002, 002, 045, 066, 075, 090, 170, 802

冒泡排序与选择排序

冒泡排序是相邻两个元素交换,小的元素向上冒。
选择排序是所有元素中选择最小的放第一个,剩下的元素中最小的放第二个。

排序算法的时间空间复杂度及稳定性

排序算法时间复杂性空间复杂性稳定性
Insertion sort最优 O ( N ) O(N) O(N)最坏 O ( N 2 ) O(N^2) O(N2)平均 O ( N 2 ) O(N^2) O(N2) O ( 1 ) O(1) O(1)稳定
Shell sort不同的增量的选取,时间复杂度不同 O ( 1 ) O(1) O(1)不稳定
Selection sort最优最坏平均 O ( N 2 ) O(N^2) O(N2) O ( 1 ) O(1) O(1)不稳定
Bubble sort最优 O ( N ) O(N) O(N)最坏 O ( N 2 ) O(N^2) O(N2)平均 O ( N 2 ) O(N^2) O(N2) O ( 1 ) O(1) O(1)稳定
Heap sort最优最坏平均 O ( N l o g N ) O(NlogN) O(NlogN) O ( 1 ) O(1) O(1)不稳定
Merge sort最优最坏平均 O ( N l o g N ) O(NlogN) O(NlogN) O ( N ) O(N) O(N)稳定
Quick sort最优 O ( N l o g N ) O(NlogN) O(NlogN)最坏 O ( N 2 ) O(N^2) O(N2)平均 O ( N l o g N ) O(NlogN) O(NlogN) O ( l o g 2 N ) O(log_2N) O(log2N)不稳定
  • 归并排序与快速排序相比,归并排序空间消耗更大,但它是稳定的排序,而且快速排序最坏情况的时间复杂度为 O ( N 2 ) O(N^2) O(N2)
  • 冒泡排序与选择排序相比,冒泡排序是稳定的
  • 基本的插入排序与希尔排序相比,插入排序是稳定的
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值