Internal Sorting

Published: by Creative Commons Licence

基本概念

排序:重新排列列表中元素,使表中元素满足按关键字有序的过程。

算法稳定性:待排序表中的两个对应关键字相同的元素Ri和Rj,使用某一排序算法之后,Ri和Rj的相对位置保持不变。

算法是否具有稳定性不是衡量算法优劣的标准,只是对算法性质的描述。

算法的分类

分类标准:排序期间数据元素是否全部存放在内存中

  1. 内部排序 - 排序期间元素全部存放在内存中
  2. 外部排序 - 排序期间元素无法在全部同时存放在内存中(必须在排序过程中根据要求不断在内、外存之间移动

    一般,内部排序算法在执行过程中都要进行两种操作:比较移动; 但不是所有的内部排序算法都要基于比较操作(基数排序不基于比较)。

通常将排序算法分为以下五大类:

  1. 插入排序
  2. 交换排序
  3. 选择排序
  4. 归并排序
  5. 基数排序

不同的排序算法适合不同的环境,就全面性而言,很难提出一种被认为是最好的算法。

内部算法的性能取决于算法的时间复杂度和空间复杂度,而时间复杂度一般是由比较和移动的次数决定的。

插入排序

基本思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中,直到插入完成。

插入排序中的三种重要排序算法:直接插入排序折半插入算法希尔排序

直接插入算法

将元素L(i)插入到已有序的子序列L[i-1]中:

  1. 查找出L(i)L[1...i-1]中的插入位置k;
  2. L[k...i-1]中的所有元素依次往后移动一个位置;
  3. L(i)复制到L(k).

实现对L[1...n]的排序,可将L(2)~L(n)依次插入到前面已排好序的子序列中。

void InsertSort(Elemtype A[], int n) {
    int i, j;
    for(i = 2; i <= n; i++)
        if(A[i] < A[j]) {       //若A[i]关键码大小小于其前驱,将A[i]插入有序表
            A[0] = A[i];        //复制为哨兵,A[0]不存放任何元素
            for(j = i - 1; A[0] < A[j]; --j)
                A[j+1] = A[j];  //向后挪位
            A[j + 1] = A[0];    //复制到插入位置
        }
}

注意上面“哨兵”的作用

空间效率:O(1)

时间效率:O(n^2)

稳定性:是一个稳定的排序算法(每次插入元素都是从后向前先比较再移动,所以不会出现相同元素相对位置发生变化的情况)

适用性:适用于顺序存储和链式存储的线性表

大部分排序算法都仅适用于顺序存储的线性表

折半插入排序

将比较和移动操作分离:先折半查找出元素的待插入位置,然后统一地移动代插入位置之后的所有元素。

因为折半查找仅适用于顺序表,所以折半插入排序也仅适用于顺序表。

void InsertSort(Elemtype A[], int n) {
    for(int i = 0; i <= n + 1; i++) {
        A[0] = A[i];                            //A[0]暂存到A[0]
        int low = 1, high = i - 1;              //折半查找
        while(low <= high) {
            int mid = (low + high) / 2;
            if(A[mid] > A[0]) high = mid - 1;
            else low = mid + 1;
        }
        for(int j = i - 1; j >= high + 1; --j)  //插入操作
            A[j + 1] = A[j];
        A[high + 1] = A[0];
    }
}

时间复杂度O(n^2),空间复杂度O(1)。

是一种稳定的排序算法。

希尔排序

直接排序插入法的时间复杂度为O(n^2),但是如果排序列为“正序”时,其时间复杂度可提高至O(n)。

由此可见直接排序插入法更加适用于基本有序的排序表和数据量不大的排序表。

希尔排序就是根据上述两点分析对直接插入排序改进而来,又称为“缩小增量排序”。

基本思想

  1. 将待排序表分割成若干形如L[i, i+d, i+2d, …, i+kd]的“特殊”子表;
  2. 对各个子表分别进行直接插入排序;
  3. 当整个表中的元素已经“基本有序”时,再对全体记录进行一次直接插入排序。

希尔提出的增量序列:d[1] = n/2; d[i+1] = floor(d[i]/2); 并且最后一个增量为1

例子:

49 38 65 97 76 13 27 49' 55 64

d = 5
49             13
 +--------------+
   38             27
    +--------------+
      65             49'
       +--------------+
         97              55
          +---------------+
            76              64
             +---------------+

13 27 49' 55 04 49 38 65 97 76

d = 3
13        55       38       76
 +---------+--------+--------+
   27        04       65
   +----------+--------+
      49'       49       98
       +---------+--------+

13 04 49' 38 27 49 55 65 97 76

d = 1
04 13 27 38 49' 49 55 65 76 97

代码如下:

void ShellSort(Elemtype A[], int n) {
    for(dk = n / 2; dk >= 1; dk = dk / 2) {
        for(i = dk + 1; i <= n; ++i)
            if(A[i] < A[i - dk]) {  //将A[i]插入有序增量子表
                A[0] = A[i];        //A[0]只是暂存,不是哨兵
                for(j = i - dk; j > 0 && A[0] < A[j]; j -= dk)
                    A[j + dk] = A[j];
                A[j + dk] = A[0];
            } //if
    }
}

空间复杂度O(1);

时间复杂度:当n在某个特定范围内时,时间复杂度约为O(n^1.3); 在最坏情况下时间复杂度为O(n^2).

稳定性:希尔排序是一种不稳定的算法 - 比如上面例子中的4949'

适用性:仅适用于顺序存储的情况

交换排序

根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。

冒泡排序

从后往前(或从前往后)两两比较相邻元素的值,若为逆序,则交换他们,直到序列比较完。我们称之为第一趟冒泡

结果是将最小的元素交换到待排序列的第一个位置(或最大的元素交换到待排序列的最后一个位置)。

这样子n-1趟冒泡就能把所有元素排序好。

void BubbleSort(ElemType A[], int n) {
    for(int i = 0; i < n - 1; i++) {
        int flag = false;               //表示本趟冒泡是否发生交换的标志
        for(int j = n - 1; j > i; j--)  //一趟冒泡过程
            if(A[j-1] > A[j]) {         //如果为逆序
                swap(A[j - 1], A[j]);   //交换
                flag = true;
            }
        if(flag == false) return;       //本趟遍历后没有发生变化,说明表已经有序
    }
}

注意是否交换的标志flag.

空间效率:O(1);

时间效率:最好情况下时间复杂度为O(n),最坏情况下为O(n^2),平均时间复杂度为O(n^2);

稳定性:冒泡是一种稳定的排序方法

冒泡排序中所产生的有序子序列一定是全局有序的(与直接插入法不同);

有序子序列中的所有元素的关键字一定小于或大于无序子序列中所有元素的关键字,这样子每趟排序都会将一个元素放置到其最终位置上。

快速排序

快排的基本思想基于分治法。

快速排序是所有内部排序算法中平均性能最优的排序算法。

  1. 在待排序表L[1...n]中任取一个元素pivot作为枢纽(或基准,通常取首元素);
  2. 通过一趟排序将待排序表划分为独立的两部分L[1...k-1]L[k+1...n],使得L[1...k-1]中的所有元素小于pivotL[k+1...n]中的元素都大于等于pivot,则pivot放在了其最终位置L(k)上,此过程称为一趟快速排序(或一次划分);
  3. 直到每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。

    一趟快速排序的过程是一个交替搜索和交换的过程。

概述:假设每次总以当前表中第一个元素作为枢轴来对表进行划分,则将表中比枢轴大的元素向右移动,将比枢轴小的元素向左移动,使得一趟Partition()操作后,表中的元素被枢轴值一分为二。

int Partition(Elemtype A[], int low, int high) {
    Elemtype pivot = A[low];
    while(low < high) {
        while(low < high && A[high] >= pivot) --high;
        A[low] = A[high];
        while(low < high && A[low] <= pivot) ++low;
        A[high] = A[low];
    }
    A[low] = pivot;
    return low;
}

void QuickSort(Elemtype A[], int low, int high) {
    if(low < high) {
        int pivotpos = Partition(A, low, high);
        QuickSort(A, low, pivotpos - 1);
        QuickSort(A, pivotpos + 1, high);
    }
}

空间效率:因为递归所以需要借助一个递归工作栈来保存每层递归调用的必要信息,其容量与递归调用的最大深度一致。最好情况下为O(log n),最坏情况下为O(n),平均情况下栈的深度为O(log n)

时间效率:快速排序的运行时间与划分是否对称有关。最坏情况发生在两个区域分别包含n-1和0个元素时,此时最坏情况下的时间复杂度为O(n^2)

稳定性:快速排序是一种不稳定的排序方法(如果右端区间有两个关键字相同,且均小于基准值的记录,则在交换到左区间后,他们的相对位置会发生变换)。

  • 如何提高算法效率?
    • 尽量选取一个可以将数据中分的枢轴元素,这样子使得最坏情况再实际排序中几乎不会发生。
    • 方法一:从序列的头尾及中间选取三个元素,再选取这三个的中间值最为最终的枢轴元素
    • 方法二:随机地从当前表中选取枢轴元素
  • 在快速排序中,并不产生有序子序列,但每趟排序后会将枢轴(基准)元素放到其最终的位置上。

选择排序

基本思想:每一趟(比如第i趟)再后面n-i+1(i=1,2,…,n-1)个待排序元素中选取关键字最小的元素,作为有序子序列的第i个元素,直到第n-1趟作完,待排序元素只剩下一个,就不用再选了。

简单选择排序

void SelectSort(Elemtype A[], int n) {
    for(int i = 0; i < n - 1; i++) {
        int min = i;
        for(int j = i + 1; j < n; j++) {
            if(A[j] < A[min]) min = j;
        }
        if(min!=i) swap(A[i], A[min]);
    }
}

空间效率:O(1)

时间效率:O(n^2),元素间的比较次数与序列的初始状态无关

稳定性:选择排序是一种不稳定的排序方法(例子:L={2,2',1},每一趟排序之后的结果为{1,2',2},{1,2',2})

堆排序

堆的定义

n个关键字序列L[1...n]称为堆,当且仅当该序列满足下面两个条件之一:

  • 条件一:L(i)>=L(2i)L(i)>=L(2i+1)
  • 条件二:L(i)<=L(2i)L(i)<=L(2i+1)

可以将该一维数组视为一棵完全二叉树,满足条件一的堆称为大根堆(大顶堆),大根堆的最大元素存放在根结点,且在任一非根结点的值小于等于其双亲结点的值。小根堆的定义则相反。

操作

不论是大根堆或者是小根堆,都是从下往上逐步调整。

建立大根堆以及堆排序的算法如下:

void HeadAdjust(Elemtype A[], int k, int len) {
    // 将元素k为根的子树进行调整
    A[0] = A[k];                                // A[0]暂时存储子树的根结点
    for(int i = 2 * k; i <= len; i*=2) {        // 沿key较大的子结点向下筛选
        if(i < len && A[i] < A[i + 1]) i++;     // 取key较大的子结点的下标
        if(A[0] >= A[i]) break;                 // 筛选结束
        else {
            A[k] = A[i];                        // 将A[i]调整到双亲结点上
            k = i;                              // 修改k值,以便继续向下筛选
        }
    }
    A[k] = A[0];                                // 被筛选结点的值放入最终位置
}

void BuildMaxHeap(Elemtype A[], int len) {
    // 建立大根堆
    for(int i = len / 2; i > 0; i--)            // 从 i=[n/2] ~ 1, 反复调整堆
        HeadAdjust(A, i, len);
}

void HeapSort(Elemtype A[], int len) {
    // 堆排序算法
    BuildMaxHeap(A, len);
    for(int i = len; i > 1; i--) {
        swap(A[i], A[1]);                        // 输出堆顶元素(和堆底元素交换)
        HeadAdjust(A, 1, i - 1);                // 调整,把剩余的 i-1 个元素整理成堆
    }
}

调整大根堆的时间与树高有关为O(h),关键字的比较总次数不超过4n,时间复杂度为O(n).

堆的插入操作:先将新结点放在堆的末端,再对这个新结点向上执行调整操作。

适合场景:关键字较多的情况

空间效率:O(1)

时间效率:O(NlogN) - 建堆时间复杂度为O(N)n-1次调整,每次的时间复杂度为O(h)

稳定性:堆排序是一种不稳定的算法(例子:L={1,2',2},每一趟排序之后的结果为{2',1,2},{1,2,2'})

归并排序

“归并”含义:将两个或两个以上的有序表组合成一个新的有序表。

2路归并排序:假定待排序表含有n个记录,则可将其视为n个有序的子表,每个子表的长度为1,然后两两归并,得到ceil(n/2)个长度为21的有序表;继续两两归并…如此重复,直到合并成一个长度为n的有序表为止。

[49] [38] [65] [97] [76] [13] [27]
 +-----+   +-----+   +-----+    +
 [38 49]   [65 97]   [13 76]  [27]
   +----------+        +--------+
   [38 49 65 97]       [13 27 76]
         +------------------+
        [13 27 38 49 65 76 97]
Elemtype *B = (Elemtype *) malloc (sizeof(Elemtype) * (n + 1));     // 辅助数组
void Merge(Elemtype A[], int low, int mid, int high) {
    // 函数功能:将前后相邻的两个有序表归并为一个有序表
    int i, j, k;
    for(k = low; k <= high; k++) B[k] = A[k];                       // 将A中的所有数组复制到B中
    for(i = low, j = mid + 1, k = i; i <= mid && j <= high; k++) {
        if(B[i] <= B[j]) A[k]=B[i++];                               // 这里的<=为该算法稳定性的关键
        else A[k] = B[j++];
    }
    while(i <= mid) A[k++] = B[i++];                                // 这两个while仅执行其一
    while(j <= high) A[k++] = B[j++];
}

void MergeSort(Elemtype A[], int low, int high) {
    // 递归形式的2路归并排序是基于分治的
    // 过程:将含有n个元素的待排序表分为含n/2个元素的两个子表,采用2路归并排序算法对两个子表递归地进行排序
    if(low < high) {
        int mid = (low + high) / 2;     // 中间划分两个子序列
        MergeSort(A, low, mid);         // 对左侧子序列进行递归排序
        MergeSort(A, mid + 1, high);    // 对右侧子序列进行递归排序
        Merge(A, low, mid, high);       // 归并
    } // if
}

2路归并排序算法的性能分析:

  • 空间效率:Merge()中的辅助空间为O(n)
  • 时间效率:O(Nlog_{2}{N})
  • 稳定性:2路归并排序是一种稳定的排序算法

对于N个元素进行k路归并排序,排序的趟数m满足k^m=N,从而m=ceil(log_{k}{N})

基数排序

一种很特殊的排序方法。不基于比较和移动过程,而基于关键字各位的大小进行排序。

基数排序是一种借助多关键字排序的思想对单逻辑关键字进行排序的方法。

线性表表头的元素称为最主关键字,表尾元素为最次关键字。

实现多关键字排序的两种方法:

  1. 最高位优先法(MSD),按关键字位权重递减依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序链表;
  2. 最低位优先法(LSD),按关键字位权重递增依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序链表。

有点散列表的拉链法表示的感觉 表示成链表。散列一波,链接一波;散列一波,链接一波…

空间效率:O(r) - 一趟排序需要辅助存储空间为r(r个队列:r个队头指针和r个队尾指针),而辅助空间可以重复利用;

时间效率:O(d(n+r)) - 基数排序需要d趟分配和搜集(d为关键字个数),每趟分配需要O(n),搜集需要O(r);

稳定性:基数排序具有稳定性 - 对于基数排序算法而言,最重要的一点就是按位排序必须是稳定的

算法种类 时间复杂度 空间复杂度 是否稳定
最好情况 最坏情况 平均情况
直接插入排序 O(n) O(n^2) O(n^2) O(1)
冒泡排序 O(n) O(n^2) O(n^2) O(1)
简单选择排序 O(n^2) O(n^2) O(n^2) O(1)
希尔排序 O(1)
快速排序 O(NlogN) O(NlogN) O(n^2) O(logN)
堆排序 O(NlogN) O(NlogN) O(NlogN) O(1)
2路归并排序 O(NlogN) O(NlogN) O(NlogN) O(n)
基数排序 O(d(n+r)) O(d(n+r)) O(d(n+r)) O(r)