各种排序算法的C语言实现

《数据结构与算法分析C语言描述》-第二版

1.插入排序

插入排序由N-1趟排序组成,第P趟排序之前,前P个元素已经排好序。第P趟排序时,前P个元素中大于第P+1个元素的数全部右移一位,然后将第P+1个元素插入对应的位置。
插入排序的时间复杂度为\(O(N^2)\)

void InsertionSort(ElementType A[], int N)
{
      int j, P;
      ElementType temp;
      for (p = 1; p < N; p++)
      {
            temp = A[p];
            for (j = p; j > 0 && A[j] > temp; j--)
                  A[j] = A[j - 1];
            A[j] = temp;
      }
}

2.希尔排序

基于增量序列h1,h2,h3,...,ht进行t趟排序,第k趟排序相当于对\(h_k\)组子序列,每组\(N/h_k\)个元素进行插入排序。
希尔排序的依据是一个\(h_k\)-排序的文件会保持它的\(h_k\)排序性。
希尔排序的关键是增量序列的选择,只要\(h_1 = 1\),任何序列都是可行的,下面是以希尔序列为例的伪代码:

void ShellSort(ElementType A[], int N)
{
      int i, j, Increment;
      ElementType Tmp;

      for(Increment = N / 2; Increment > 0; Increment /= 2)
      {
            for (i = Increment; i < N; i++)
            {
                  Tmp = A[i];
                  for (j = i; j >= Increment; j -= Increment)
                  {
                        if (A[j] > Tmp)
                              A[j] = A[j - 1];
                        else
                              break;
                  }
                  A[j] = Tmp;
            }
      }
}

希尔序列的问题是这些增量不互素,Hibbard增量为\(1,3,7,...,2^k - 1\),在实践中有更好的表现。使用Hibbard增量的希尔排序平均为\(O(n^{5/4})\),最坏时间界为\(\Theta(N^{3/2})\),并且已经证明\(\Theta(N^{3/2})\)的界适用于广泛的增量序列。
Sedgewick增量在实践中比Hibbard增量要好,其中最好的是\(1,5,19,41,109,...\),该序列的项或者是\(9\cdot4^{i}-9\cdot2^{i}+1\),或者是\(4^{i}-3\cdot2^{i}+1\)。通过将序列放入一个数组中可以更容易地实现该算法。最坏情形为\(O(N^{4/3})\),平均时间猜测为\(O(N^{7/6})\)

3.堆排序

使用max堆不断deletemax并且因为每次deletemax堆的容量减一,所以可以将每次deletemax得到的值放在deletemax之前堆的最后位置,这样进行N-1次deletemax后得到的将会是递增的序列。想要得到递减序列则使用min堆。
堆排序的时间复杂度为\(O(NlogN)\),但在实践中其表现慢于使用Sedgewick增量序列的希尔排序,原因是堆排序是一个非常稳定的算法,它平均使用的比较只比最坏情形界指出的略少。堆排序的伪代码如下:

#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] < A[Child + 1])
                  ++Child;
            if (Tmp < A[Child])
                  A[i] = A[Child];
      }
      A[i] = Tmp;
}

void HeadSort(ElementType A[], int N)
{
      int i;
      // build max head;
      for (i = N / 2; i >= 0; --i)
      {
            PercDown(A, i, N);
      }
      // HeadSort
      for (i = N - 1; i > 0; --i)
      {
            Swap(&A[0], &A[i]);      // deletemax
            PercDown(A, 0, i);
      }
}

4.归并排序

归并排序是经典的分治(divide-and-conquer)策略,如果\(N = 1\),那么答案是显然的,否则算法递归的将前半部分数据和后半部分数据各自归并排序。归并排序的伪代码如下:

void Merge(ElementType A[], ElementType TmpArray[], int Lpos, int Rpos, int RightEnd)
{
      int i, LeftEnd, NumElements, TmpPos;
      
      LeftEnd = Rpos - 1;
      TmpPos = Lpos;
      NumElements = Rpos - Lpos + 1;
      
      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 - left) / 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 *TempArray;
      TmpArray = malloc(N * sizeof(ElementType));
      if (TmpArray != NULL)
      {
            Msrot(A, TmpArray, 0, N-1);
            free(TmpArray);
      }
      else
      {
            FatalError("No space for tmp array!!!");
      }
}

归并排序是分析递归历程方法的经典实例:必须给出运行时间的一个递归关系。假设N是2的幂,从而我们总可以将它均分为偶数的两部分。对于N = 1,用时为常数,记为1.否则对N个数归并排序的用时等于完成两个大小为\(N/2\)的递归排序所用的时间在加上合并的时间,它是线性的,如下面方程所示:
\(T(1)=1\)
\(T(N)=2T(N)+N\)
方程两边同除以N,得到:
\(\frac{T(N)}{N}=\frac{T(N/2)}{N/2}+1\)
\(\frac{T(N/2)}{N/2}=\frac{T(N/4)}{N/4}+1\)
\(\frac{T(N/4)}{N/4}=\frac{T(N/8)}{N/8}+1\)
......
\(\frac{T(2)}{2}=\frac{T(1)}{1}+1\)
叠缩(telescoping)求和,等号左边的项可以被右的项消去,最后的结果为:
\(\frac{T(N)}{N}=\frac{T(1)}{1}+logN\)
两边同时乘以N,得到:
\(T(N)=NlogN+N=O(NlogN)\)
虽然归并排序的运行时间为O(NlogN),但是它很难用于主存排序,主要的问题合并两个排序的表需要线性附加内存,在整个算法中还要花费将数据拷贝到临时数组再拷贝回来这样一些附加的工作,严重放慢了排序的速度。但合并例程是大多数外部排序算法的基石

5.快速排序

快速排序(quicksort)实在实践中最快的已知排序方法,它的平均运行时间是O(logN),与归并排序一样,快速排序也是一种分治的递归算法。数组S的快速排序基于一下的四步:

  1. 如果S中的元素是0或1个,返回。
  2. 取S中任意元素v,称之为枢纽元(pivot).
  3. 将S-{v}(S中其余元素)分成两个不相交的集合;\(S_1=\lbrace x\in {S-\lbrace v \rbrace}\mid x\le v\rbrace\), 和\(S_2=\lbrace x\in {S-\lbrace v \rbrace}\mid x \ge v\rbrace\)
  4. 返回\(\lbrace quicksort(S_1) \rbrace\)后,继而\(\lbrace quicksort(S_2) \rbrace\)

第2步枢纽元的选择是快速排序的关键,比较好的策略是随机选取枢纽元,但是随机数的生成一般是昂贵的,根本减少不了算法其余部分的平均运行时间。
三数中值分割法(Median-of-Three Partitioning)是一个更好的策略,选择数组左,中,右三个元素中的最大值作为枢纽元,并将最小值放置在最左边left处,最大值放在right处,而枢纽元放在right-1处,这样\(S[left]\)可以作为遍历时从右开始的指针j的警戒标记,不必担心j越界,同时\(S[right-1]\)处的元素即枢纽元也可以作为从左开始的指针i的警戒标记。程序开始时将i,j分别初始化为left和right-1,而不是left+1和right-2可以将其和其他枢纽元选择策略(如随机选择)兼容,因为其他策略可能不存在这样具有警戒标记的元素。
对重复元的处理,最好的处理方法是遇到等于枢纽元的元素就交换它们,显然如果遇到与枢纽元相同的元素,只有i停止或j停止,会导致分割结果偏向其中一方。如果i,j都不停止,则需要一个条件(如i<=j)来防止越界,但这样每一次i自增或j自减时都需要检查是否满足条件,并不能实际节省时间。
快速排序的伪代码如下:

ElementType Median3(ElementType A[], int left, int right)
{
      int center = left + (right - left);
      
      if(A[left] > A[center])
            Swap(&A[left], &A[right]);      // 最好将Swap声明称内联函数
      if(A[left] > A[right])
            Swap(&A[left], &A[right]);
      if (A[center] > A[right])
            Swap(&A[center], &A[right]);
      
      /* Invariant: A[ left ] <= A[ center ] <= A[right] */

      Swap(&A[center], &A[right - 1]);      // Hide pivot
      return A[right-1];                    // Return pivot
}

#define Cutoff (3)

void Qsort(ElementType A[], int left, int right)
{
      int i, j;
      ElementType 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]);      // Restore pivot

            Qsort(A, left, i - 1);
            Qsort(A, i + 1, right);
      }
      else // Do an insertion sort on the subarray
            InsertionSort(A + left, right - left + 1);
}

对于小数组(N<=20),快速排序不如插入排序好,因为快排是递归的,通常的解决方法是对小数组使用插入排序。一种好的截至范围(cutoff range)是N=10,虽然5到20之间任意截至范围都可以产生类似的结果,这种做法也避免了三数中值分割时只有一个或两个元素的情况。
快速排序的平均情形分析:
\(T(N) = \frac{2}{N}\lbrack \sum\limits_{j=0}^{N-1}{T(j)} \rbrack + cN\)
两边同乘以N
\(NT(N) = 2\lbrack \sum\limits_{j=0}^{N-1}{T(j)} \rbrack + cN^2\)
需要除去求和符号以简化计算,因此在次套用上式:
\((N-1)T(N-1) = 2\lbrack \sum\limits_{j=0}^{N-2}{T(j)} \rbrack + c(N-1)^2\)
两式相减得到:
\(NT(N)-(N-1)T(N-1) = 2T(N-1) + 2cN - c\)
移项、合并并去除无关紧要的-c,得到:
\(NT(N) = (N+1)T(N-1) + 2cN\)
两边同除以N(N+1),得到:
\(\frac{T(N)}{N+1}=\frac{T(N-1)}{N}+\frac{2c}{N+1}\)
进行叠缩求和:
\(\frac{T(N-1)}{N}=\frac{T(N-2)}{N-1}+\frac{2c}{N}\)
...
\(\frac{T(2)}{3} = \frac{T(1)}{2}+\frac{2c}{3}\)
求和的结果为:
\(\frac{T(N)}{N+1}=\frac{T(1)}{2}+2c\sum\limits_{i=3}^{N+1}{\frac{1}{i}}\)
该和大约为\(\log\nolimits_{e}{N+1}+\gamma - \frac{3}{2}\),其中\(\gamma \approx 0.577\)叫做欧拉常数,于是:
\(\frac{T(N)}{N+1} = O(logN)\),从而\(T(N) = O(NlogN)\)

快速选择

基于快速排序可以给选择问题提供一个O(N)时间复杂度的算法,选择问题的内容是给定一个数组S,找到第k个最小(大)元素。快速选择的具体步骤如下:

  1. 如果\(\mid S \mid = 1\),那么k=1,将S中的元素作为答案返回。如果用小数组的截至方法且\(\mid S \mid \le CUTOFF\),则将S排序并返回第k个最小元。
  2. 选取一个枢纽元\(v \in S\)
  3. 将集合\(S - \lbrace v \rbrace\)分割成\(S_1\)\(S_2\),就像我们在快速排序中做的那样。
  4. 如果\(k \le \mid S_1 \mid\),那么第k个最小元必然在\(S_1\)中。在这种情况下,返回\(quickselect(S_1, k)\)。如果\(k = 1 + \mid S-1 \mid\),那么枢纽元就是第k个最小元,直接返回。否则第k个最小元在\(S_2\)中,它是\(S_2\)中第\((k - \mid S_1 \mid - 1)\)个最小元。这种情况下,返回\(quickselect(S_2, k - \mid S_1 \mid - 1)\)

快速选择相比快速排序的差别是,快速选择只做了一次递归调用而不是两次,但其最坏情形与快速排序相同,即\(S_1\)\(S_2\)中有一个为空,此时时间复杂度为\(O(N^2)\),不过快速选择的平均时间是\(O(N)\)。实现快速选择的伪代码如下:

// the kth smallest element will placed in the k-1 index because array start at 0;

void Qselect(int A[], int k, int left, int right)
{
    int i, j;
    ElementType pivot;

    if (left + Cufoff <= 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]);     // Swap声明成内联
            else 
                break;
        }
        Swap(&A[i], &A[right - 1]);     // restore pivot

        if (k <= i)
            Qselect(A, k, left, i - 1);
        if (k > i + 1)
            Qselect(A, k - i - 1, i + 1, right);
    }

    else
        InsertionSort(A + left, right - left + 1);
}  

使用五分化中项的中项可以消除二次最坏情况而保证算法时O(N)的。可是这么做的额外开销很大,因此最终的算法主要在于理论的意义。

猜你喜欢

转载自www.cnblogs.com/AIxiaodi/p/13362360.html