算法与数据结构——内部排序

作为俺在CSDN的第一个博客,我打算以算法与数据结构中最为经典的排序开始,笔者之后学习了cpp后将考虑使用cpp将这些算法都重新实现一遍。

若有问题,欢迎讨论!

本篇博客主要介绍一下几种内部排序方法以及相关的性能比较:

1. 插入排序(包括直接插入排序、折半插入排序、2-路插入排序、希尔排序)
2. 快速排序(包括递归以及非递归的实现)
3. 选择排序(包括简单选择排序、树形排序、堆排序)
4. 归并排序
5. 基数排序

0 准备工作

我们可以提前做好一些前置功能的定义,方便之后的复用

类型、函数名参数、变量名功能
intcnt_comp数据比较次数计数
intcnt_mov数据移动次数计数
voidSwap(ElemType *a, ElemType *b) 交换a和b
voidDispArr(ElemType arr[], int n) 数组显示
intInit(ElemType data[]) 从文件初始化数组数据并返回数组大小
# include <stdio.h>
# include <stdlib.h>
# include <time.h>
# define NL 10000
typedef int ElemType;

int cnt_comp, cnt_mov;
ElemType dequeue[NL];

void Swap(ElemType *a, ElemType *b){
    ElemType t;
    t = *a; 
    *a = *b; 
    *b = t;
}

void DispArr(ElemType arr[], int n){
    int i;
    for (i = 0; i < n; i ++)
        printf("%2d ", arr[i]);
    printf("\n");
}

int Init(ElemType data[]){
    int i, n;
    freopen("D:\\Labs\\Data Structure and Algorithm\\lab12_sort12\\randData.txt", "r", stdin);
    scanf("%d", &n);
    for (i = 0; i < n; i ++){
        scanf("%d", &data[i]);
    }
    return n;
}

1 插入排序

插入排序(Insertion Sort) 是一种最为简单直观的排序算法,无论是直接插入排序还是其变种,其根本工作原理都是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常只需要用到 O ( 1 ) O(1) O(1)的额外空间(即in-place排序),因而在从后向前扫描过程中,如果使用数组结构,则需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。

图1:插入排序的通用规律示意图
注:浅绿色为排好序的表,深绿色为待排序的数据。

1.1 直接插入排序

直接插入排序(Straight Insertion Sort) 是一种最为简单的排序方法,其基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的,记录数增加 1 1 1的有序表。

直接插入排序的C语言实现十分简单,代码如下所示:

void InsertionSort(ElemType arr[], int n){
    int i, j;
    cnt_mov = cnt_comp = 0;
    ElemType k;
    for (i = 1; i < n; i ++){
        k = arr[i];
        for (j = i - 1; j >= 0; j --){
            cnt_comp ++;
            if (arr[j] > k){
                arr[j + 1] = arr[j];
                cnt_mov ++;
            }
            else break;
        }
        arr[j + 1] = k;
        cnt_mov ++;
    }
    printf("Insertion Sort: COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

简单插入排序的数据比较次数和数据移动次数的关系如下所示:

图1.1:插入排序测试结果

其中测试了 11 11 11组数据,左边第一栏为测试数据量,第二大栏为 0   100000 0~100000 0 100000的均匀随机数据,第三大栏为数据范围相同,递减但不严格递减的测试数据,第四栏为数据范围相同,严格递减的数据。可以看到几组数据都呈现出很明显的与数据长度平方成正比的趋势,不论是比较次数还是数据移动次数都是如此。与复杂度也很贴切。

1.2 折半插入排序

由于插入排序的基本操作是在一个有序表中进行查找和插入,而对于查找,相对于顺序查找,我们有更高效的折半查找。

利用折半查找进行优化的插入排序就被称为折半插入排序(Bianry Insertion Sort)。

折半插入排序因为只影响查找,所以所需的存储空间与直接插入排序是直接相同的,但是折半插入排序可以显著减少关键字之间的比较次数。不过从复杂度来看,虽然关键字的比较次数减少了,但是记录移动的次数确实没变的,所以时间复杂度仍然是 O ( n 2 ) O(n^2) O(n2)

折半插入排序的C语言程序如下所示:

void Binary_InsertionSort(ElemType arr[], int n) {
    ElemType key;
    int i, j, low, high, mid;
    cnt_comp = cnt_mov = 0;
    for (i = 1; i < n; i++) {
        key = arr[i]; cnt_mov ++;
        low = 0;
        high = i - 1;
        while (low <= high) {
            mid = (low + high) / 2;
            if (key < arr[mid]) high = mid - 1;
            else low = mid + 1;
            cnt_comp ++;
        }
        for (j = i - 1; j >= low; j--){
            arr[j + 1] = arr[j]; cnt_mov ++;
        }
        arr[low] = key; cnt_mov ++;
    }
    printf("Binary Insertion Sort: COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

类似地,我们还可以研究其数据比较次数和数据移动次数如下所示:

图1.2:折半插入排序测试结果

可以看到,折半插入排序的比较次数相对于简单插入排序明显减小,与 n log ⁡ n n\log{n} nlogn呈线性关系,而数据移动的次数仍然没有变,因此实际上时间复杂度仍然是 O ( n 2 ) O\left(n^2\right) O(n2)的。

1.3 2-路插入排序

2-路插入排序 是在折半插入排序的基础上进一步改进的,2-路插入排序之于折半查找的关系与快速排序之于归并排序的关系是十分相似的。2-路插入排序的目的是进一步减少排序过程中移动记录的次数,当然,要做到这一点往往就需要一块单独的空间了。

为了方便,我们考虑的是开辟一个单独的双端队列。具体C语言代码如下所示:

void TwoWay_InsertionSort(ElemType arr[], int n){
    int head, rear, i, j;
    cnt_comp = cnt_mov = 0;
    head = rear = 0;
    dequeue[rear] = arr[0];
    cnt_mov ++;
    for (i = 1; i < n; i ++){
        cnt_comp ++;
        if (arr[i] < dequeue[head]){
            head = (head + n - 1) % n;
            dequeue[head] = arr[i];
            cnt_mov ++;
        }
        else if (arr[i] > dequeue[rear]){
            rear = (rear + 1) % n;
            dequeue[rear] = arr[i];
            cnt_mov ++;
        }
        else {
            j = (rear + 1) % n;
            while (dequeue[(j - 1 + n) % n] > arr[i]) {
                cnt_comp ++;
                j = (j - 1 + n) % n;
                dequeue[(j + 1) % n] = dequeue[j];
                cnt_mov ++;
            }
            dequeue[j % n] = arr[i];
            cnt_mov ++;
            rear = (rear + 1) % n;
        }
    }
    for (i = 0; i < n; i ++){
        arr[i] = dequeue[(head + i) % n];
        cnt_mov ++;
    }
    printf("2-Way Insertion Sort: COMPARISON: %d; MOVEMENT: %d(%d)\n", cnt_comp, cnt_mov, cnt_mov - n);
}

输出中括号里表示的是去除将双端队列中数据移动到原数据数组中的移动次数后的数据移动次数。当然,在海量的数据移动次数中,这数据长度次移回似乎也没那么重要了
2-路插入排序的效率与数据息息相关,如果参考数据过大或过小,都会让2-路插入排序的效率直接退化到直接插入排序。根据相关知识,三节点取中法或者表插入排序能帮助我们进一步对其进行优化。其数据比较次数和数据移动次数如下所示:

图1.3:2-路插入排序测试结果

从上面的例子中就可以看到一些前面讨论的情况了,一方面,数据移动次数显著减小,这是因为,二路插入排序相当于把原来长度为 n n n的序列分成了 r 1 r_1 r1 r 2 = n − r 1 r_2=n-r_1 r2=nr1两个长度的序列,考虑最坏的情况,则会有大致 r 1 2 + r 2 2 2 \frac{r_1^2+r_2^2}{2} 2r12+r22这么多次的比较,很显然小于 n 2 2 \frac{n^2}{2} 2n2,其中当 r 1 = r 2 r_1=r_2 r1=r2即选取的指标恰好为中位数时是最坏情况的最优解,为 n 2 4 \frac{n^2}{4} 4n2,从上面的数据中也可以看到的确如此。非严格递减的数据到最后确实比较次数和移动次数都在往上涨,这是算法边界设计的问题,而可以看到对于严格递减的序列,由于其每次都从尾部往前插,每插一次都不用移动已经排好的数据,所以比较次数和移动次数都很优秀。 至于为什么是 2 n 2n 2n,这是因为其中有一个 n n n次是将数据从辅助空间移回原数组。

1.4 希尔排序

希尔排序(Shell’s Sort)又称缩小增量排序(Diminishing Increment Sort),它相当于把数据分成了很多组,然后让这些组基本有序,最后使用一个直接插入排序来对记录进行排序。

考虑将一系列增量写到一个数组里预设好,可以写出相关代码如下所示:

void ShellSort_inner(ElemType arr[], int n, int d[], int dl){
    int i, j, k;
    ElemType tmp;
    for (k = dl; k >= 0; k --){
        for (i = 1; i < n; i ++){
            tmp = arr[i];
            cnt_mov ++;
            for (j = i - d[k]; j >= 0 && arr[j] > tmp; j -= d[k]){
                arr[j + d[k]] = arr[j];
                cnt_comp ++;
                cnt_mov ++;
            }
            arr[j + d[k]] = tmp;
            cnt_mov ++;
        }
    }
}

void ShellSort(ElemType arr[], int n){
    int d[] = {1, 3, 7, 15, 31, 63, 127, 255};
    cnt_comp = cnt_mov = 0;
    ShellSort_inner(arr, n, d, 5);
    printf("Shell Sort: COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

希尔排序的增量选择是很有讲究的,如果希尔排序的增量没选择好,那么效率甚至可能比直接插入排序要低(因为**“基本有序”**这个条件可能就被破坏了)。增量的选择有点像有限覆盖定理的证明,也有点类似于最小密铺的想法,根据调研,可以发现希尔排序的增量选择大有学问,其中Hibbard增量序列和Sedgewick增量序列是非常著名的两种

对于Hibbard增量序列,其通项为 D k = 2 k − 1 D_k=2^k-1 Dk=2k1,根据相关证明,其最坏时间复杂度为 O ( n 3 / 2 ) O\left(n^{3/2}\right) O(n3/2),而平均时间复杂度约为 O ( n 5 / 4 ) O\left(n^{5/4}\right) O(n5/4);而对于Sedgewick增量序列,其通项为 D = 9 ⋅ 4 k – 9 ⋅ 2 k + 1 D=9\cdot4^k–9⋅2k+1 D=94k–92k+1 4 k – 3 ⋅ 2 k + 1 4^k–3⋅2k+1 4k–32k+1,最坏时间复杂度相对更小 O ( n 4 / 3 ) O\left(n^{4/3}\right) O(n4/3),平均复杂度也相对更小,为 O ( n 7 / 6 ) O\left(n^{7/6}\right) O(n7/6)。本实验中我采用的是Hibbard增量序列。

图1.4:希尔排序测试结果

希尔排序数据增量的选择涉及到一些尚未解决的数学问题,但是我们仍然可以看到上面利用Hibbard增量序列实现的希尔排序确实明显比前面的几种插入排序都更加优秀。

2. 快速排序

快速排序(Quick Sort),本质上是对冒泡排序的改进,以从小到大排序为例,每趟排序将待排的数据记录分割成两个子数据记录,其中前一半的数据记录关键字比后一半的数据记录关键字小,这样递归分别对两个子数据记录继续分割排序,最终形成完整的有序数据记录。 冒泡排序相当于每次排序后,前面的数据记录关键子都比最后位置的数据记录关键字小,快速排序只要求每次排序后,以某个数据记录关键字为界,前面的一半比后面的一半小即可,这里的某个数据记录称为枢轴或支点记录。

图2:一轮快速排序简单原理图
快速排序也与归并排序有所联系(但根源仍然有所不同),它们都用到了 分治(Divide and Conquer) 的思想,从而能够达到 O ( n log ⁡ n ) O\left(n\log{n}\right) O(nlogn)的复杂度。对于快速排序,下面同时也给出了其递归实现非递归实现的方法。

快速排序的内容略微复杂一些,下面还是先列一下一些声明及其意义:

类型/函数名参数功能描述
void QuickSort_r_inner(ElemType arr[], int lb, int rb)递归版快速排序内部递归逻辑
void QuickSort_r(int arr[], int n)递归版快速排序外壳层
void Swap(ElemType *a, ElemType *b)交换a和b
void DispArr(ElemType arr[], int n)数组显示
int Init(ElemType data[])从文件初始化数组数据并返回数组大小

2.1 快速排序的递归实现

void QuickSort_r_inner(ElemType arr[], int lb, int rb){
    ElemType tmp;
    int i = lb, j = rb, pivot = (lb + rb) >> 1;
    if (i < j){
        if (arr[pivot] > arr[lb]) { Swap(arr + pivot, arr + lb); cnt_mov += 3; }
        if (arr[pivot] > arr[rb]) { Swap(arr + lb, arr + rb); cnt_mov += 3; }
        if (arr[lb] > arr[rb]) { Swap(arr + lb, arr + rb); cnt_mov += 3; }
        cnt_comp += 3;
        tmp = arr[lb]; cnt_mov ++;
        while (i < j){
            while (i < j && arr[j] >= tmp){
                cnt_comp ++;
                j --;
            }
            if (i < j){
                arr[i ++] = arr[j]; cnt_mov ++;
            }
            while (i < j && arr[i] < tmp){
                cnt_comp ++;
                i ++;
            }
            if (i < j){
                arr[j --] = arr[i]; cnt_mov ++;
            }
        }
        arr[i] = tmp; cnt_mov ++;
        QuickSort_r_inner(arr, lb, i - 1);
        QuickSort_r_inner(arr, i + 1, rb);
    }
}

这里为了防止在倒序情况下程序栈崩溃(这就是递归会导致的弊端),所以使用了三节点调平衡的方法,即找到整个序列中的头中尾三个地方的数,然后把其中的中位数放到头上去作为枢轴,这样能一定程度上减轻极端情况的发生

2.2 快速排序非递归实现

与一般的直接递归不同,我们可以利用一个栈来记录每次左边界和右边界的值,然后就可以得到非递归程序如下所示:

void QuickSort_nr(int arr[], int n){
    ElemType tmp;
    int sp = 0;
    int i, j, lb, rb;
    dequeue[sp ++] = 0;
    dequeue[sp ++] = n - 1;
    cnt_comp = cnt_mov = 0;
    while (sp){
        rb = j = dequeue[-- sp]; lb = i = dequeue[-- sp];
        if (i < j){
            tmp = arr[lb]; cnt_mov ++;
            while (i < j){
                while (i < j && arr[j] >= tmp){
                    cnt_comp ++;
                    j --;
                }
                if (i < j){
                    arr[i ++] = arr[j]; cnt_mov ++;
                }
                while (i < j && arr[i] < tmp){
                    cnt_comp ++;
                    i ++;
                }
                if (i < j){
                    arr[j --] = arr[i]; cnt_mov ++;
                }
            }
            arr[i] = tmp;
            cnt_mov ++;
            dequeue[sp ++] = i + 1; dequeue[sp ++] = rb;
            dequeue[sp ++] = lb; dequeue[sp ++] = i - 1;
        }
    }
    printf("Quick Sort (non-recursion): COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

此处非递归的快速排序为了比较并没有使用三节点调平衡的方法,因此可以从下图中看到快速排序在极端情况下受到的影响是十分夸张的:

图2.1 快速排序测试结果

最后可以看到,快速排序的效率非常之高,非递归和递归算法结果具有很好的一致性。快速排序也是一样的,其效率与数据排列也有很的关系。

3 选择排序

**选择排序(Selection Sort)**也是一种十分简单的排序方法,其基本思想很简单,以从小到大排序为例,每趟排序在当前的待排数据记录序列中选择最小的元素放在前面,这样进行 n − 1 n-1 n1 趟排序之后,整个数据记录就有序了。

选择排序的核心在于如何(快速)找到未排序序列中的最小值

算法的简单演示示意图如下所示:

图3:选择排序示意图
其实就类似于一个扑克牌抽排操作。

3.1 简单选择排序

简单选择排序(Simple Selection Sort) 是选择排序中最为简单的一种,其对于最小元素的选取是直接采用顺序搜索的。

C语言代码如下所示:

void SelectionSort(int arr[], int n){
    int i, j, mintag = 0;
    cnt_comp = cnt_mov = 0;
    for (i = 0; i < n - 1; i ++){
        mintag = i;
        for (j = i + 1; j < n; j ++){
            if (arr[mintag] > arr[j]){
                mintag = j;
            }
            cnt_comp ++;
        }
        Swap(arr + mintag, arr + i); cnt_mov += 3;
    }
    printf("Selection Sort: COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

然后可以获得简单选择排序的测试结果如下所示:

图3.1:简单选择排序测试结果

可见,简单选择排序的比较次数和移动次数与数据本身的顺序并无关系,而是单纯与数据个数(数据长度)相关,其复杂程度也主要体现在比较次数上,因为每次都要找到最小值是多少。

3.2 树形选择排序/锦标赛排序

树形选择排序(Tree Selection Sort) ,又称锦标赛排序(Tournament Sort) ,是一种按照锦标赛思想进行选择排序的方法。其首先对 n n n个记录的关键字进行两两比较,然后在其中 [ n 2 ] \left[\frac{n}{2}\right] [2n](向上取整)个较小者之间再进行两两比较,如此重复直至选出最小关键字的记录为止。 比如外部排序中的胜者树和败者树就是树形选择排序的变形。

图3.2.1:树形选择排序一趟比赛示意图

类型/函数名参数功能描述
int tree_winner(int loc1, int loc2, ElemType tree[], int n)在两个节点中选出胜利者
ElemType TreeCreate(ElemType arr[], ElemType tree[], int n)初始数据录入,即建树的过程
ElemType TreeAdjust(ElemType arr[], ElemType tree[], int n)选出并移除最终胜者
void TreeSelectionSort(int arr[], int n)树形选择排序主体
int tree_winner(int loc1, int loc2, ElemType tree[], int n){
    int u = (loc1 >= n) ? loc1 : tree[loc1];
    int v = (loc2 >= n) ? loc2 : tree[loc2];
    if (tree[u] <= tree[v]) return u;
    else return v;
}

ElemType TreeCreate(ElemType arr[], ElemType tree[], int n){
    int i;
    ElemType ret;
    for (i = 0; i < n; i ++){
        tree[n + i] = arr[i];
        cnt_mov ++;
    }
    for (i = (n << 1) - 1; i > 1; i -= 2){
        tree[i >> 1] = tree_winner(i - 1, i, tree, n);
        cnt_comp ++;
        cnt_mov ++;
    }
    ret = tree[tree[1]];
    tree[tree[1]] = INF;
    cnt_mov ++;
    return ret;
}

ElemType TreeAdjust(ElemType arr[], ElemType tree[], int n){
    ElemType i = tree[1], j;
    ElemType ret;
    while (i > 1){
        if ((i % 2 == 0) && (i < (n << 1) - 1)){ j = i + 1; }
        else { j = i - 1; }
        tree[i >> 1] = tree_winner(i, j, tree, n);
        cnt_mov ++;
        cnt_comp ++;
        i >>= 1;
    }
    ret = tree[tree[1]];
    tree[tree[1]] = INF;
    cnt_mov ++;
    return ret;
}

void TreeSelectionSort(int arr[], int n){
    ElemType wval;
    int i;
    cnt_comp = cnt_mov = 0;
    for (i = 0; i < (n << 1); i ++)
        dequeue[i] = INF;
    wval = TreeCreate(arr, dequeue, n);
    for (i = 0; i < n; i ++){
        arr[i] = wval;
        cnt_mov ++;
        wval = TreeAdjust(arr, dequeue, n);
    }
    printf("Tree Selection Sort: COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

图3.2.2 锦标赛排序测试结果

考虑含有 n n n个叶子节点的完全二叉树的深度为 ⌈ log ⁡ 2 n ⌉ + 1 \left\lceil\log_2{n}\right\rceil+1 log2n+1相对于顺序选择来说,树的结构让寻找最小值降低到树高大小的难度(这是因为树天生具有分治的特点)。因此可以知道树形选择排序的时间复杂度应该是 O ( n log ⁡ 2 n ) O\left(n\log_2{n}\right) O(nlog2n)的,上图中的结果很好地体现了这一点。但是树形排序对于辅助空间的要求十分之高,并且还有和“最大值”进行多余比较的缺点 ,不过下面的堆排序就解决了这个问题。

3.3 堆排序

堆排序(Heap Sort) 是J. Williams在1964年提出的一种新的选择排序方法,其对整个数据记录序列初始建立“大顶堆”,然后每次从堆中取出其根节点,接着进行堆调整,最后直到整个堆空为止。

示意过程如下图所示:

  1. 首先是完成堆的建立:
    图3.3.1:堆排序堆的建立

  2. 然后是逐个调整堆(下面进行一趟调整):
    图3.3.2:堆排序一趟堆的调整

  3. 以此类推,便可完成堆排序。

可以据此写出堆排序的C语言代码如下:

类型/函数名参数功能描述
void HeapAdjust(ElemType heap[], int s, int m)让第s个节点进行downheap操作(向下调整)
void HeapSort(ElemType heap[], int n)进行堆排序
void HeapAdjust(ElemType heap[], int s, int m){
    ElemType rc = heap[s];
    int j;
    for (j = (s << 1) + 1; j <= m; j = (j << 1) + 1){
        if (j < m && heap[j] < heap[j + 1]){
            j ++;
        }
        if (!(rc < heap[j])) break;
        cnt_comp ++;
        heap[s] = heap[j];
        cnt_mov ++;
        s = j;
    }
    heap[s] = rc;
    cnt_mov ++;
}

void HeapSort(ElemType heap[], int n){
    int i;
    cnt_comp = cnt_mov = 0;
    for (i = (n - 1) >> 1; i >= 0; i --){
        HeapAdjust(heap, i, n);
    }
    for (i = n - 1; i > 0; i --){
        Swap(&heap[0], &heap[i]);
        cnt_mov += 3;
        HeapAdjust(heap, 0, i - 1);
    }
    printf("Heap Sort: COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

图3.3:堆排序测试结果
可以看到,堆排序无论是比较次数还是移动次数都是十分优秀的,也符合我们前面的分析。

4 归并排序

归并排序(Merging Sort) 其实是另一类比较不同的排序算法。这里的归并指的是将两个或两个以上的有序表组合成一个新的有序表。然后对于一个n个记录的序列,我可以将其看做n个长度为1的子序列,然后再两两归并,再两两归并……以此类推,一直重复直到得到一个长度为n的有序序列为止,这种归并方法被称为二路归并排序,更推广的当然有多路归并排序。

图4:归并排序过程示意图

下面是一个用C语言实现的2-路归并排序:

类型/函数名参数功能描述
void merge(ElemType arr[], ElemType T[], int lb, int mid, int rb)对两段序列进行归并操作并写回原数组
void MergeSort_r_inner(ElemType arr[], int lb, int rb)递归归并层
void MergeSort(ElemType arr[], int n)归并排序外壳
void merge(ElemType arr[], ElemType T[], int lb, int mid, int rb){
    int i, j, k;
    for (i = lb, j = mid + 1, k = lb; i < mid + 1 && j < rb + 1;){
        if (arr[i] <= arr[j]) T[k ++] = arr[i ++];
        else T[k ++] = arr[j ++];
        cnt_comp ++;
        cnt_mov ++;
    }
    while (i < mid + 1){ T[k ++] = arr[i ++]; cnt_mov ++;}
    while (j < rb + 1){ T[k ++] = arr[j ++]; cnt_mov ++;}
    for (i = lb; i < rb + 1; i ++){
        arr[i] = T[i];
        cnt_mov ++;
    }
}

void MergeSort_r_inner(ElemType arr[], int lb, int rb){
    if (lb < rb) {
        int mid = (lb + rb) >> 1;
        MergeSort_r_inner(arr, lb, mid);
        MergeSort_r_inner(arr, mid + 1, rb);
        merge(arr, dequeue, lb, mid, rb);
    }
}

void MergeSort(ElemType arr[], int n){
    cnt_comp = cnt_mov = 0;
    MergeSort_r_inner(arr, 0, n - 1);
    printf("Merge Sort: COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

下面给出的是归并排序数据移动次数和数据比较次数及其相关分析:

图4.1:归并排序测试结果

归并排序的效能确实很高,为一个典型的 O ( n log ⁡ n ) O\left(n\log{n}\right) O(nlogn)的算法,当然其还需要一个 n n n的空间来作为辅助。 从上面的数据可以看到,数据倒序时,移动次数和比较次数都显著低于数据均匀随机的时候,这是因为 归并排序对于局部紧致(并非拓扑里面那个局部紧)且相邻块数据差异比较大的数据效率比较高 ,其归并的时候并不需要过多的比较,当然,移动次数是毋庸置疑一样的。

5. 基数排序

基数排序(Radix sort) 是一种非比较型的排序算法,世纪上最早用于解决扑克牌排序的问题。基数排序将待排序的元素拆分为k个关键字,然后逐一对各个关键字排序后完成对所有元素的排序。

基数排序一般分为两类,如果是从第1关键字到第k关键字顺序进行比较,则该称为 MSD(Most Significant Digit first,最高位优先)基数排序如果是从第k关键字到第1关键字顺序进行比较,则该基数排序称为 LSD(Least Significant Digit first,最低位优先)基数排序。另外,基数排序还是一种稳定的排序方法。

基数排序过程示意图1
基数排序过程示意图2
基数排序过程示意图3

对于基数排序,我们可以写出C语言程序如下(这里给出的是基为 5 5 5的情况,实际上可以是任意的基,直接调整程序中对BASE的宏定义即可):

类型/函数名参数、变量名功能描述
int RadixBox[BASE][M]-基数排序的辅助空间
int Pcoordinate(int key, int base, int rshamt)获取对应基下对应位置的值
void RadixSort(ElemType arr[], int n)进行基数排序的函数
# define N 100000
# define M 100000
# define Q 20
# define BASE 5

int Pcoordinate(int key, int base, int rshamt){
    if (base == 2) return (key >> rshamt) & 1;
    else{
        while (rshamt){
            key = (int)(key / base);
            rshamt --;
        }
        return key % base;
    }
}

int RadixBox[BASE][M];
void RadixSort(ElemType arr[], int n){
    int rshamt = 0, lb;
    int p[BASE];
    int i = 0, j, k;
    int tag = 1;
    cnt_comp = cnt_mov = 0;
    //while (rshamt < Q){
    while (tag < M){
        tag *= BASE;
        for (i = 0; i < BASE; i ++){
            p[i] = 0;
        }
        for (i = 0; i < n; i ++){
            lb = Pcoordinate(arr[i], BASE, rshamt);
            RadixBox[lb][p[lb] ++] = arr[i];
            cnt_mov ++;
        }
        i = 0;
        for (k = 0; k < BASE; k ++){
            for (j = 0; j < p[k]; j ++){
                arr[i ++] = RadixBox[k][j];
                cnt_mov ++;
            }
        }
        rshamt ++;
    }
    printf("Radix Sort: COMPARISON: %d; MOVEMENT: %d\n", cnt_comp, cnt_mov);
}

因为基数排序为非比较的排序,所以比较次数记为0,最后得到数据移动次数的测试结果如下图所示:

图5.1:基数排序测试结果
显然基数排序的数据移动次数与数据本身是完全无关的,事实上这里实现的基数排序的移动次数应当恰好为 2 ⋅ n ⋅ ⌈ log ⁡ BASE M ⌉ 2\cdot n\cdot\left\lceil\log_{\text{BASE}}{M}\right\rceil 2nlogBASEM

附录

产生随机数的Python代码:

from random import randint
def gen(n):
    f = open(rf".\sortdata\randData_{n}.txt", "w")
    f.write(f"{n}\n")
    for _ in range(n): f.write(f"{randint(0, 100000)}\n")
def gen_inv(n, strict = 1):
    f = open(rf"sortdata\randData_{n}_inv_{strict}.txt", "w")
    f.write(f"{n}\n")
    r = randint(n, 100000)
    gap = r // n
    for _ in range(n):
        f.write(f"{r}\n")
        r = r - randint(strict, gap)
for i in [100, 500, 1000, 3000, 5000, 7000, 10000, 20000, 50000, 70000, 100000]:
    gen(i)
    gen_inv(i, strict = 0)
    gen_inv(i)

读入测试数据的函数,这里只列出一种,另两种道理相同。

int Init_with_para(ElemType data[], int para){
    int i, n;
    switch (para)
    {
    case 100: freopen("sortdata\\randData_100.txt", "r", stdin); break;
    case 500: freopen("sortdata\\randData_500.txt", "r", stdin); break;
    case 1000: freopen("sortdata\\randData_1000.txt", "r", stdin); break;
    case 3000: freopen("sortdata\\randData_3000.txt", "r", stdin); break;
    case 5000: freopen("sortdata\\randData_5000.txt", "r", stdin); break;
    case 7000: freopen("sortdata\\randData_7000.txt", "r", stdin); break;
    case 10000: freopen("sortdata\\randData_10000.txt", "r", stdin); break;
    case 20000: freopen("sortdata\\randData_20000.txt", "r", stdin); break;
    case 50000: freopen("sortdata\\randData_50000.txt", "r", stdin); break;
    case 70000: freopen("sortdata\\randData_70000.txt", "r", stdin); break;
    case 100000: freopen("sortdata\\randData_100000.txt", "r", stdin); break;
    default: break;
    }
    scanf("%d", &n);
    if (n != para){ printf("Check your parameter!\n"); return 0; }
    for (i = 0; i < n; i ++){ scanf("%d", &data[i]); }
    return n;
}

进行数据测试的函数

ElemType data[NL];
void test(void (*SortFunc)(ElemType *, int)){
    int i, n, DLen = 11;
    int DL[] = {100, 500, 1000, 3000, 5000, 7000, 10000, 20000, 50000, 70000, 100000};
    for (i = 0; i < DLen; i ++){
        printf("%d: \n", DL[i]);
        n = Init_with_para(data, DL[i]); SortFunc(data, n);
        n = Init_with_para_inv_0(data, DL[i]); SortFunc(data, n);
        n = Init_with_para_inv_1(data, DL[i]); SortFunc(data, n);
        // DispArr(data, n);
    } 
}

若有问题,欢迎讨论!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值