Fork me on GitHub

《数据结构与算法分析》学习笔记-第七章-排序


插入排序

  • 插入排序由N-1趟排序组成,对于P=1趟到P=N-1趟,插入排序保证从位置0到位置P上的元素为已排序状态
  • 基本有序或者规模较小时十分高效
void
InsertSort(int inputArray[], int arrayNum)
{
	int index, value;
	int cnt;
	for (cnt = 1; cnt < arrayNum; cnt++) {
		value = inputArray[cnt];
		index = cnt - 1;
		while (index >= 0 && value < inputArray[index]) {
			inputArray[index + 1] = inputArray[index];
			index--;
		}
		inputArray[index + 1] = value;
	}
}

由于嵌套循环,每一个都话费N次迭代,因此插入排序为O(N2),这个界是精确的,因为以反序输入可以达到该界。如果输入数据已预先排序,那么运行时间为O(N)。插入排序的平均情形也是Θ(N2)

  1. N个互异数的数组的平均逆序数是N(N-1)/4
  2. 通过交换相邻元素进行排序的任何算法平均需要Ω(N^2)时间

希尔排序

又叫做缩小增量排序。冲破二次时间屏障的第一批算法之一。通过比较相距一定间隔的元素来工作,各趟比较所用的距离随着算法的进行而减小,直到只比较相邻元素的最后一趟排序为止。适度的大量输入,即使是数以万计的N仍然表现出很好的性能。因此是常用算法

void
ShellSort(int inputArray[], int arrayNum)
{
	int jump;
	for (jump = arrayNum / 2; jump > 0; jump /= 2) {
		int index, value;
		int cnt;
		for (cnt = jump; cnt < arrayNum; cnt += jump) {
			index = cnt - jump;
			value = inputArray[cnt];
			while (index >= 0 && value < inputArray[index]) {
				inputArray[index + jump] = inputArray[index];
				index -= jump;
			}
			inputArray[index + jump] = value;
		}		
	}
}
  • 使用希尔增量时,希尔排序的最坏情形运行时间为Θ(N^2)
  • 使用Hibbard增量的希尔排序的最坏情形运行时间为Θ(N^1.5)。Hibbard增量为1,3,7,..., 2^k - 1
  • 使用Sedgewick增量的希尔排序的最坏情形运行时间为O(N(4/3)),对于这些增量序列的平均运行时间猜测为O(N(7/6)),该序列中的项是94^i - 92^i + 1或者是4^i- 3*2^i + 1

堆排序

建立N个元素的二叉堆的基本方法,花费O(N)时间。执行N次DeleteMin操作,每次DeleteMin操作花费时间O(logN),总的运行时间是O(NlogN).为了避免使用第二个数组,聪明的做法是每删除一个元素,将其放到堆的最后一个元素的位置。堆排序的第一步是以线性时间建一个堆,然后通过将堆中最后一个元素第一个元素交换缩减堆的大小并进行下滤,来执行N-1次DeleteMax操作,当算法终止时,数组则以所排的顺序包含这些元素。

#define LeftChild(i) (2 * (i) + 1)
void
PercDown(ElementType A[], int i, int N)
{
    int Child;
    ElementType Tmp;
    
    for (Tmp = A[i]; LeftChild(i) < N; i = Child)
    {
        Child = LeftChild(i);
        if (Child != N - 1 && A[Child + 1] > A[Child])
        {
            Child++;
        }
        if (Tmp < A[Child])
        {
            A[i] = A[Child];
        }
        else
        {
            break;
        }
    }
    A[i] = Tmp;
}

void
Heapsort(ElementType A[], int N)
{
    int i;
    for (i = N / 2; i >= 0; i--) 
    {
        // BuildHeap
        PercDown(A, i, N);
    }
    for (i = N - 1; i > 0; i--)
    {
        // DeleteMax
        Swap(&A[0], &A[i]);
        PercDown(A, 0, i);
    }
}

定理:对N个互异项的随机排列进行堆排序,所用的比较平均次数为2NlogN - O(N)

归并排序

以O(NlogN)最坏运行时间运行,所使用的比较次数几乎是最优的。它是递归算法一个很好的实例。采用递归和分治的策略,将一个待排序的数列分成两部分,将这两部分排序后,进行归并操作,即比较每个数列第一个元素的大小,将小的放入第三数列,进行到最后,一定会有一个数列是有剩余元素的,将其全部拷贝到第三数列即可。很难用于主存排序,因其额外申请空间,并且有一些拷贝操作

void
Merge(ElementType A[], ElementType TmpArray[], int Lpos, int Rpos, int RightEnd)
{
    int i, LeftEnd, NumElements, TmpPos;
    
    LeftEnd = Rpos - 1;
    TmpPos = Lpos;
    NumElements = Right End - Lpos + 1;
    
    /* main loop */
    while (Lpos <= LeftEnd && Rpos <= RightEnd)
    {
        if (A[Lpos] <= A[Rpos])
        {
            TmpArray[TmpPos++] = A[Lpos++];
        }
        else
        {
            TmpArray[TmpPos++] = A[Rpos++];
        }
    }
    
    /* Copy rest of first half */
    while (Lpos <= LeftEnd)
    {
        TmpArray[TmpPos++] = A[Lpos++];
    }
    /* Copy rest of second half */
    while (Rpos <= RightEnd)
    {
        TmpArray[TmpPos++] = A[Rpos++];
    }
    
    /* Copy TmpArray back */
    for (i = 0; i < NumElements; i++, RightEnd--)
    {
        A[RightEnd] = TmpArray[RightEnd];
    }
}

void
MSort(ElementType A[], ElementType TmpArray[], int Left, int Right)
{
    int Center;
    
    if (Left < Right)
    {
        Center = (Left + Right) / 2;
        MSort(A, TmpArray, Left, Center);
        MSort(A, TmpArray, Center + 1, Right);
        Merge(A, TmpArray, Left, Center + 1, Right);
    }
}

void
Mergesort(ElementType A[], int N)
{
    ElementType *TmpArray;
    
    TmpArray = malloc(N * sizeof(ElementType));
    if (TmpArray != NULL)
    {
        MSort(A, TmpArray, 0, N - 1);
        free(TmpArray);
    }
    else
    {
        printf("malloc tmp array failed\n");
    }
}

证明:最坏时间复杂度为O(NlogN)

已知:
T(1) = 1
T(N) = 2 * T(N/2) + N

等式两边同时除以N得:
T(N) / N = T(N/2) / (N/2) + 1

带入N/2,N/4...得:
T(N/2) / (N/2) = T(N/4) / (N/4) + 1
T(N/4) / (N/4) = T(N/8) / (N/8) + 1
...
T(2) / 2 = T(1) / 1 + 1

将这些等式相加得到:
T(N) / N = T(1) + logN =
T(N) / N = 1 + logN
(叠缩求和)

最终得到:
T(N) = NlogN + N

快速排序

平均运行时间是O(NlogN).是实践中最快的已知排序方法。由于非常精炼和高度优化内部循环,最坏情形性能为O(N^2),但是稍加努力就可避免这种情形。快速排序和归并排序一样,也是一种分治递归算法。

实现原理

  1. 如果数组S中元素个数是0或1,则return
  2. 取数组S中任一元素v,称之为枢纽元
  3. 将S - {v} (S中其余元素)分成两个不相交的集合:
    1. 大于等于v的所有元素
    2. 小于等于v的所有元素
  4. 返回quicksort(S1)后,继随v,继而quicksort(S2)

选择枢纽元

  1. 错误的方法:选择已经过预排序或者反序的第一个元素用作枢纽元,这样时间复杂度很有可能是O(N^2);另一种是选择前两个互异的关键字中较大者作为枢纽元,同样的害处
  2. 安全的做法:随机选取枢纽元,一般来说这种策略非常安全,除非随机数生成器有问题。但是生成随机数会花费大量时间
  3. 三数中值分割法:枢纽元的最好的选择是数组的中值,很难算出,而且会花费计算时间。因此随机选取三个元素,并用他们的中值作为枢纽元得到。例如,一般的做法是取左端、右端和中心位置上的三个元素的中值未枢纽元。中心位置为(Leftpos + Rightpos)/2.通过这样的做法,还能够消除预排序输入的坏情形,并减少快速排序大约5%的运行时间

分割策略

当选择好枢纽元后,将枢纽元从数组拿出来,设置两个指针i和j,指针i指向第0个元素,指针j指向最后一个元素。两者向数组中心靠近。当i指向的元素大于枢纽元时停止,当j指向的元素小于枢纽元时停止,二者互相交换元素,直到i和j交错。实践证明,当遇到等于枢纽元的元素时,i和j还是停下做交换,这样会得到两个差不多一样大的子数组,比较平衡。是正确的做法。这时总的运行时间是O(NlogN)

小数组

对于很小的数组(N<=20),快速排序不如插入排序好。使用这种策略大概可以节省约15%的运行时间。一种好的截止范围是N=10,这种做法避免了一些有害的情形,比如想取三个元素的中值,而数组总共只有1个或2个元素的情况

实际的快速排序例程

  1. 选取枢纽元
  2. 对A[Left], A[Right], A[Center]进行排序,并将元素放到对应位置上。同时将枢纽元放到A[Right - 1]的位置上
  3. i初始化在Left + 1,j初始化在Right - 2。因为A[Left] < 枢纽元,所以将它做j的境界标记,避免j越界;同理,枢纽元放到A[Right - 1],将成为i的境界标记,避免i越界。
#define CUTOFF	3

void Swap(int *a, int *b)
{
	int tmp;
	tmp = *a;
	*a = *b;
	*b = tmp;
}

void
InsertSort(int inputArray[], int arrayNum)
{
	int index, value;
	int cnt;
	for (cnt = 1; cnt < arrayNum; cnt++) {
		value = inputArray[cnt];
		index = cnt - 1;
		while (index >= 0 && value < inputArray[index]) {
			inputArray[index + 1] = inputArray[index];
			index--;
		}
		inputArray[index + 1] = value;
	}
}

int 
getsn(int A[], int Left, int Center, int Right)
{
	if (A[Right] < A[Center])
	{
		Swap(&A[Right], &A[Center]);
	}

	if (A[Right] < A[Left])
	{
		Swap(&A[Right], &A[Left]);
	}

	if (A[Center] < A[Left])
	{
		Swap(&A[Center], &A[Left]);
	}

	Swap(&A[Center], &A[Right - 1]);
	return A[Right - 1];
}

void
Qsort(int A[], int Left, int Right)
{
	int i, j;
	int sn;
	int Center;
	
	if (Left + CUTOFF <= Right)
	{
		i = Left;
		j = Right - 1;
		Center = (Left + Right) / 2;
		sn = getsn(A, Left, Center, Right);
		for (;;)
		{
			while (A[++i] < sn);
			while (A[--j] > sn);
			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
	{
		InsertSort(A + Left, Right - Left + 1);
	}
}

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

选择的线性期望时间算法

寻找数组S中第k大的元素。平均运行时间O(N)

void
Qselect(int A[], int k, int Left, int Right)
{
    int i,j;
    int sn;
    int Center;
    
    if (Left + CUTOFF <= Right)
    {
        i = Left;
        j = Right - 1;
        Center = (Left + Right) / 2;
        sn = getsn(A, Left, Center, Right);
        for(;;)
        {
            while(A[++i] < sn);
            while(A[--j] > sn);
            if (i < j)
            {
                Swap(&A[i], &A[j]);
            }
            else
            {
                break;
            }
        }
        Swap(&A[i], &A[Right - 1]);
        if (k <= i)
        {
            Qselect(A, k, Left, i - 1);
        }
        else
        {
            Qselect(A, k, i + 1, Right);
        }
    }
    else
    {
        InsertSort(A + Left, Right - Left + 1);
    }
}

大型结构的排序

当要对大型结构进行排序时,如果在排序的过程中,交换两个结构,那么代价非常高昂。因此采用比较指针去指向结构,并在必要时交换指针进行排序。这代表,所有的数据运动基本上就像我们对整数排序那样进行,称之为间接排序

排序的一般下界

  • 最坏情况下只用到比较的算法需要 Ω(Nlog(N))次比较,从而Ω(Nlog(N))时间,因此归并排序堆排序在一个常数因子范围内是最优的。
  • 平均情况下只用到比较任意排序算法都需要进行Ω(Nlog(N))次比较,这意味着快速排序在相差一个常数因子的范围内平均是最优的
  • 最坏情况下只用到比较任何排序算法都需要log(N!)次比较并平均需要log(N!)次比较。

决策树

通常只使用比较进行排序的每一种算法都可以用决策树表示。只有输入数据非常少的情况画决策树才是可行的。有排序算法所使用的比较次数等于最深的树叶的深度,所使用的比较的平均次数等于树叶的平均深度

  1. 令T是深度为d的二叉树,则T最多有2^d个树叶
  2. 具有L片树叶的二叉树,深度至少是logL
  3. 只使用元素间比较的任何排序算法在最坏情况下至少需要log(N!)次比较
  4. 只使用元素间比较的任何排序算法需要进行Ω(Nlog(N))次比较

桶式排序

简单来说,就是设置将要排序的数列中的数最大不能超过X,那么就创建一个大小为X+1的数组ARRAY[X+1]。以下标进行排序。将数组输入的同时,就拍好了顺序,设当前输入的数为Y,那么ARRAY[Y] = Y。当所有数据输入完成,那么打印该数组ARRAY即可.该算法用时O(X+1 + N)。桶式排序看似平凡用处不大,但是如果输入只是一些小整数,那么桶式排序就派上了用场。这时使用像快速排序这样的排序方法就太小题大做了。

外部排序

目前为止,学习的排序算法都需要将输入数据装入内存。但是存在一些应用程序,它们输入数据量太大装不进内存,并且外部访问的速度非常慢。因此使用外部排序,它被设计用来处理很大的输入

外部排序模型

各种各样的海量存储装置使得外部排序比内部排序对设备的依赖性要严重得多。由于访问磁带上的一个元素需要把磁带转动到正确的位置,因此磁带必须要有两个方向上连续的顺序才能够被有效的访问。我们假设至少有三个磁带驱动器进行排序工作,我们需要两个驱动器执行有效的排序,第三个驱动器进行简化的工作。如果只有一个磁带驱动器可用,那么我们不得不说,任何算法都将需要Ω(N^2)

简单算法

基本的外部排序算法使用归并排序中的Merge例程,设我们有四盘磁带:Ta1, Ta2, Tb1, Tb2,他们是两盘输入磁带两盘输出磁带。根据算法的特点,磁带a和磁带b要么用作输入磁带,要么用作输出磁带。

  1. 数据最初在Ta1上,并设内存可以一次容纳和排序M个记录,在内部将这些记录排序,然后把这些排过序的记录交替的写入Tb1和Tb2上。我们将每组排过序的记录叫做一个顺串。做完这些之后,我们倒回所有的磁带。
  2. 现在Tb1和Tb2包含一组顺串。我们将每个磁带的第一个顺串取出,并将两者合并,结果写到Ta1上。该结果是一个2倍长的顺串,然后,我们再从每盘磁带取出下一个顺串,合并,并将结果写道Ta2上。继续这个过程,交替使用Ta1和Ta2,直到Tb1或Tb2为空,此时或者Tb1和Tb2为空,或者只剩下一个顺串。如果有剩就把它拷贝到相应的磁带上。将全部四盘磁带倒回,并重复相同的步骤。这一次用两盘a磁带作为输入,两盘b磁带作为输出,结果得到一些4M长的顺串,继续这个过程知道的到长为N的一个顺串。该算法将需要log(N/M)趟工作,外加一趟构造初始的顺串。

多路合并

如果我们有额外的次贷,可以减少将输入数据排序所需要的趟数,通过将基本的2-路合并扩充为k-路合并能够做到这一点。两个顺串的合并操作通过将每一个输入磁带转到每个顺串的开头来完成,然后,找到较小的元素,把它放到输出磁带上,并将相应的输入磁带带向前推进。如果有k盘输入磁带,那么这种方法一相同的方式工作,惟一的区别在于它发现k个元素中最小的元素的过程稍微有些复杂。我们通过使用优先队列找出这些元素中的最小元。为了得出下一个写到磁盘上的元素,我们进行一次DeleteMin操作,将相应的磁带向前推进,如果在输入磁带上的顺串尚未完成,那么我们将新元素插入到优先队列中。在初始顺串构造阶段之后,使用k-路合并所需要的趟数为log(k)(N/M),因为每趟这些顺串达到K倍大小。

多相合并

上一节讨论的k-路合并方法需要使用2k盘磁带,这对某些应用极为不便。只使用k+1盘磁带也有可能完成排序工作。例如2-路合并,可以用3个磁盘实现。采用斐波那契数列,将待排序的数列分成两部分,一部分顺串放入Ta1, 一部分顺串放入Ta2,将排序后的顺串放入Tb1,Ta1和Ta2中必有一个有剩余,假设为Ta1,此时再将Ta1和Tb1再次进行归并,结果放入Ta2.以此类推,直到排序结束。对于2-路合并,F(N) = F(N-1) + F(N-2),对于K-路合并,Fk(N) = FK(N-1) + FK(N-2) + ... FK(N-K)

替换选择

顺串的改造。开始,M个记录被读入内存并被放到一个优先队列中。我们执行一次DeleteMin,把最小的记录写到输出磁带上,再从输入磁带读入下一个记录。如果他比刚刚写出的记录大,那么我们可以把他加到优先队列中,否则不能把它放入当前的顺串。由于优先队列少一个元素,因此,我们可以把这个新元素存入优先队列的死区。直到顺串完成构建,而该新元素用于下一个顺串。将一个元素存入死区的做法,类似于在堆排序中的做法。我们继续这样的步骤直到优先队列的大小为零,此时该顺串构建完成。我们是用死区中的所有元素通过建立一个新的优先队列开始构建一个新的顺串。有可能替换选择做的并不比标准算法更好,然而,输入数据常常从排序或几乎从排序开始,此时替换选择仅仅产少数非常长的顺串。这种类型的输入通常要进行外部排序,这就使得替换选择具有特殊的价值。

总结

对于最一般的内部排序应用程序,选用的方法不是插入排序,希尔排序,就是快速排序。它们选用主要是根据输入的大小来决定。一个例子标明在排序元素小于等于100个的时候,希尔排序用时最少。高度优化的快速排序算法,即使对于很少的输入数据也能像希尔排序一样快。如果需要对一些大型的文件排序,那么快速排序则是应该选用的方法。但是永远不要图省事儿轻易的把第一个元素用作枢纽元,对输入排序随机的假设是不安全的,如果不想过多地考虑这个问题,那么就使用希尔排序。希尔排序的最坏情况也不过是O(N^(4/3)),这种最坏情况发生的几率也是微不足道的。堆排序比希尔排序慢,尽管他是一个带有明显紧凑内循环的O(NlogN)算法。为了移动数据,堆排序要进行两次比较。Floyd提出的改进算法移动数据基本上只需要一次比较。实现这种改进算法时的代码多少要长一些。插入排序只用在小的或是非常接近排好序的输入数据上。而归并排序,因为它的性能对于驻村排序不如快速排序那么好,而且它的编程一点也不省事,然而我们已经看到合并却是外部排序的中心思想

参考文献

  1. Mark Allen Weiss.数据结构与算法分析[M].America, 2007
  2. code随笔.详解直接插入排序算法:https://zhuanlan.zhihu.com/p/120693682
  3. 祥哥的说.希尔排序--简单易懂图解:https://blog.csdn.net/qq_39207948/article/details/80006224

本文作者: CrazyCatJack

本文链接: https://www.cnblogs.com/CrazyCatJack/p/14408185.html

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!

关注博主:如果您觉得该文章对您有帮助,可以点击文章右下角推荐一下,您的支持将成为我最大的动力!


posted @ 2021-02-20 17:26  CrazyCatJack  阅读(482)  评论(0编辑  收藏  举报