第十讲:排序(下)

10.1 快速排序

10.1.1 算法概述

策略:分而治之。

下面举个例子,假如一组数为13/81/92/43/65/31/57/26/75/0,我们对其进行排序。那么首先选择出一个主元,这里我们选择为65,那么将这组数的其他成员分为了两组,一组是小于主元的13/43/31/57/26/0,一组是大于主元的81/92/75.然后将其递归处理,两边各选一个主元再进行分组……倒数第二步的时候,我们在第一步选择出来的主元左侧已经排好了顺序,右侧也排好了顺序,这样将它们放在同一个数组中,就完成了排序。

以下是上面这段话的伪码描述:

void QuickSort(ElementType A[], int N){       if(N<2)              return;       pivot = 从A[]中选一个主元;       将S={A[]\PIVOT}分成2个独立子集:       A1={a∈S|a≤piovt}和       A2={a∈S|a≥piovt}       A[]=Quic_Sort(A1,N1)∪(pivot)∪QuickSort(A2,N2);}

这个方法的关键在于主元的选取(选取不好,快速算法会很慢)和子集划分(这个过程也是耗费时间的一个地方)。

快速排序算法的最好情况就是每次正好中分T(N)=O(NlogN)

10.1.2 选主元

选取头、中、尾的中位数,也可以选取5个、7个等数字的中位数。例如8/12/3的中位数就是8。伪码描述如下:

ElementType Median3(ElementType A[], int Left, int Right){       int Center=(Left+Right)/2;       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藏到右边*/       /*只需要考虑A[left+1]到A[Right-2],这样来划分左右*/       return A[Right-1];}

10.1.3 子集划分

为了便于理解,还是举一个例子:

8

1

4

9

0

3

5

2

7

6(M)

定义两个指针i(指向第一个元素)和j(指向中位数左侧的元素)。(继续强调,这里的i和j不是实际的指针,而是存储所需要元素下标的整数。)先比较i代表的元素和主元的大小,发现8大于6,那么i这个指针不变;然后看j代表的元素和主元比较,发现7大于6,将j减一(即左移一位),然后再比较,发现2小于6,这样就不对了,j停止移动。在i和j都停止移动后,将其所指向的两个元素交换位置,变成了下面这个样子。

2

1

4

9

0

3

5

8

7

6(M)

然后i加1(右移一位),1小于6,正常,i继续加1(右移一位),4小于6,正常,i继续加1(右移一位),此时9大于6,不正常,i停止;再看j减1(左移一位),5小于6,不正常,j停止,然后交换此时i和j所代表的元素,变成下面这个样子。

2

1

4

5

0

3

9

8

7

6(M)

然后i加1(右移一位),发现正常,i继续加1,发现正常,i再加1,发现9小于6,不正常,i停止;然后j左移一位,发现3小于6,不正常,停止。

此时发现i-j<0了,子集划分结束,同时将i代表的元素和主元交换,完成了子集的划分。最后结果如下:

2

1

4

5

0

3

6(M)

8

7

9

快速算法的“快速”在于,划分完成后其主元被一次性放到了正确的位置再也不会移动;例如插入算法等都需要一步一步往后移。

如果有元素正好等于pivot怎么办?停下来处理。

对于小规模数据还不如用插入排序。当递归的数据规模充分小,则停止递归,直接调用简单排序。在程序中定义一个Cutoff的阈值。

10.1.4 算法实现

    伪码描述:

void Quicksort(ElementType A[], int Left, int Right){       if(Cutoff<=Right-Left)       {              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]);              Quicksort(A,Left,i-1);              Quicksort(A,i+1,Right);       }       else              Insertion_Sort(A+Left,Right-Left+1);            }

为了统一接口,在上段程序后面再加一个:

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

快速排序算法是不稳定算法!

以下给出另外的C语言编写的代码,它是直接调用函数库:

/* 快速排序 - 直接调用库函数 */ #include <stdlib.h> /*---------------简单整数排序--------------------*/int compare(const void *a, const void *b){ /* 比较两整数。非降序排列 */    return (*(int*)a - *(int*)b);}/* 调用接口 */qsort(A, N, sizeof(int), compare);/*---------------简单整数排序--------------------*/  /*--------------- 一般情况下,对结构体Node中的某键值key排序 ---------------*/struct Node {    int key1, key2;} A[MAXN]; int compare2keys(const void *a, const void *b){ /* 比较两种键值:按key1非升序排列;如果key1相等,则按key2非降序排列 */    int k;    if ( ((const struct Node*)a)->key1 < ((const struct Node*)b)->key1 )        k = 1;    else if ( ((const struct Node*)a)->key1 > ((const struct Node*)b)->key1 )        k = -1;    else { /* 如果key1相等 */        if ( ((const struct Node*)a)->key2 < ((const struct Node*)b)->key2 )            k = -1;        else            k = 1;    }    return k;}/* 调用接口 */qsort(A, N, sizeof(struct Node), compare2keys);/*--------------- 一般情况下,对结构体Node中的某键值key排序 ---------------*/

10.2 表排序

10.2.1 算法概述

表排序用于:元素不是简简单单的排序,每一个元素都是一个庞大的结构,例如一个结构体等,此时如果我们想要交换两个元素,就不能忽略交换所需要的时间了。表排序就是在移动的时候,并不移动原始的数据,只是移动指向它的指针。

它是一种间接排序的方法,定义一个指针数组作为“表”(table)。

用下面一个例子来举例:

A

[0]

[1]

[2]

[3]

[4]

[5]

[6]

[7]

key

f

d

c

a

g

b

h

e

table

0

1

2

3

4

5

6

7

利用插入算法,可以得出:

A

[0]

[1]

[2]

[3]

[4]

[5]

[6]

[7]

key

f

d

c

a

g

b

h

e

table

3

5

2

1

7

0

4

6

如果仅要求按顺序输出,则输出:

A[table[0]],A[table[1]],……,A[table[N-1]]

10.2.2 物理排序

所谓物理排序就是说,我们不能像前文那样利用指针来移动,而是必须实际移动结构体,那该怎么办呢?

利用这样一个原理:N个数字的排列由若干个独立的环组成

为了解释这个原理,看10.2.1中最后的结果那个表,table[0]值(3)->table[3]值(1)->table[1]的值(5)->table[5](0)返回到了table[0]。一共有三种环,这三种环互不相交,称为独立。

在排序的时候,先对一个环内进行排序。那么如何判断一个环的结束呢?

每访问一个空位i后,就令table[i]=i。当发现table[i]==i时,环就结束了。

下面分析一下复杂度的情况:

(1)最好的情况:初始即有序。

(2)最坏的情况:有N/2个环,每个环包含两个元素,元素需要移动3N/2次。

但是无论如何,复杂度都可以写为T(N)=O(mN),其中m是每个A元素复制需要的时间。

10.3 基数排序

10.3.1 桶排序

举例:假设我们有N个学生,他们的成绩是0到100之间的整数(于是有于是有M=101个不同的成绩值个不同的成绩值)。如何在线性时间内将学生按成绩排序?

我们可以为每一个成绩值构造一个“”,于是就有了101个桶,如图1所示。如果我们有一个88分的学生,那么就把学生的信息查到88分的这个链表的表头里,伪码描述如下:

void Bucket_Sort(ElementType A[], int N){       count[]初始化;       while (读入读入11个学生成绩个学生成绩graded)              将该生插入count[grade]链表;       for ( i=0; i<M; i++ )       {              if(count[i])                     输出整个count[i]链表;       }}

时间复杂度:T(N,M)=O(M+N)

插入学生的成绩,因为是N个学生,所以复杂度为O(N);输出成绩,for循环,M个成绩,自然复杂度为O(M)。故总的时间复杂度如上。

如果有N=40000个学生,由于M=101,故这就是一个线性的复杂度。

1.jpg 

图1

但是,如果M>>N怎么办?

10.3.2 基数排序

举例:假设我们有N=10个整数,每个整数的值在0到999之间之间(于是有于是有M=1000个不同的值个不同的值)。还有可能在线性时间内排序吗?

这里的基数就是跟进制有关,如果是二进制,基数就是2;如果是十进制,基数就是10。这里我们说的是十进制,所以基数就是10

加入我们的输入序列为:64,8,216,512,27,729,0,1,343,125,用“次位优先”(Least Significant Digit,LSD)算法。主位是指的第一位,剩余的都叫次位。因此这里比较我们先从个位开始。

首先我们建立十个“桶”,然后将它们以个位数为标准放到这10个桶里面去,如表中Pass 1;然后按照十位数为标准放到10个桶里,如Pass 2;在完成之后,我们来做一个收集,收集的过程就是扫描每一个桶,然后把桶里的元素按照0,1,8,512,216……顺序用一个链表把它们串起来;串起来后,按照主位放到相应的桶里,如Pass3;最后,再对它们进行一次收集,用链表连起来,得出结果。

表3.1 基数排序算法流程表

Bucket

0

1

2

3

4

5

6

7

8

9

Pass 1

0

1

512

343

64

125

216

27

8

729

Pass2

0

512

125

343

64

1

216

27

8

729

Pass3

0

125

216

343

512

729

1

8

27

64

 

    时间复杂度:T(N,B,P)=O(P(N+B))。其中P为基数排序次数(本例子例为3),N为输入序列中数字个数,B为整数进制。

10.3.3 多关键字排序

例如,一副扑克牌是按2种关键字排序的,如图2所示,花色是它的主关键字,面值是它的次关键字。

1526218273140431.jpg

图2

在这里,我们可以用“主位优先”(Most Significant Digit, MSD)排序,为花色建立4个桶,在每个桶里分别排序,最后合并结果。

也可以使用“次位优先”(LSD)算法排序,为面值建立13个桶,就可以直接将结果合并,然后为花色建4个桶,放进去就可以了。

在这里,LSD优点是不需要排序,时间复杂度小的多。

 

基数排序是稳定的算法。

补充

(1)次位优先的C语言代码:

/* 基数排序 - 次位优先 */ /* 假设元素最多有MaxDigit个关键字,基数全是同样的Radix */#define MaxDigit 4#define Radix 10 /* 桶元素结点 */typedef struct Node *PtrToNode;struct Node {    int key;    PtrToNode next;}; /* 桶头结点 */struct HeadNode {    PtrToNode head, tail;};typedef struct HeadNode Bucket[Radix]; int GetDigit ( int X, int D ){ /* 默认次位D=1, 主位D<=MaxDigit */    int d, i;        for (i=1; i<=D; i++) {        d = X % Radix;        X /= Radix;    }    return d;} void LSDRadixSort( ElementType A[], int N ){ /* 基数排序 - 次位优先 */     int D, Di, i;     Bucket B;     PtrToNode tmp, p, List = NULL;          for (i=0; i<Radix; i++) /* 初始化每个桶为空链表 */         B[i].head = B[i].tail = NULL;     for (i=0; i<N; i++) { /* 将原始序列逆序存入初始链表List */         tmp = (PtrToNode)malloc(sizeof(struct Node));         tmp->key = A[i];         tmp->next = List;         List = tmp;     }     /* 下面开始排序 */     for (D=1; D<=MaxDigit; D++) { /* 对数据的每一位循环处理 */         /* 下面是分配的过程 */         p = List;         while (p) {             Di = GetDigit(p->key, D); /* 获得当前元素的当前位数字 */             /* 从List中摘除 */             tmp = p; p = p->next;             /* 插入B[Di]号桶尾 */             tmp->next = NULL;             if (B[Di].head == NULL)                 B[Di].head = B[Di].tail = tmp;             else {                 B[Di].tail->next = tmp;                 B[Di].tail = tmp;             }         }         /* 下面是收集的过程 */         List = NULL;         for (Di=Radix-1; Di>=0; Di--) { /* 将每个桶的元素顺序收集入List */             if (B[Di].head) { /* 如果桶不为空 */                 /* 整桶插入List表头 */                 B[Di].tail->next = List;                 List = B[Di].head;                 B[Di].head = B[Di].tail = NULL; /* 清空桶 */             }         }     }     /* 将List倒入A[]并释放空间 */     for (i=0; i<N; i++) {        tmp = List;        List = List->next;        A[i] = tmp->key;        free(tmp);     }}

(2)主位优先C语言代码:

/* 基数排序 - 主位优先 *//* 假设元素最多有MaxDigit个关键字,基数全是同样的Radix */ #define MaxDigit 4#define Radix 10 /* 桶元素结点 */typedef struct Node *PtrToNode;struct Node{    int key;    PtrToNode next;}; /* 桶头结点 */struct HeadNode {    PtrToNode head, tail;};typedef struct HeadNode Bucket[Radix]; int GetDigit ( int X, int D ){ /* 默认次位D=1, 主位D<=MaxDigit */    int d, i;        for (i=1; i<=D; i++) {        d = X%Radix;        X /= Radix;    }    return d;} void MSD( ElementType A[], int L, int R, int D ){ /* 核心递归函数: 对A[L]...A[R]的第D位数进行排序 */     int Di, i, j;     Bucket B;     PtrToNode tmp, p, List = NULL;     if (D==0) return; /* 递归终止条件 */          for (i=0; i<Radix; i++) /* 初始化每个桶为空链表 */         B[i].head = B[i].tail = NULL;     for (i=L; i<=R; i++) { /* 将原始序列逆序存入初始链表List */         tmp = (PtrToNode)malloc(sizeof(struct Node));         tmp->key = A[i];         tmp->next = List;         List = tmp;     }     /* 下面是分配的过程 */     p = List;     while (p) {         Di = GetDigit(p->key, D); /* 获得当前元素的当前位数字 */         /* 从List中摘除 */         tmp = p; p = p->next;         /* 插入B[Di]号桶 */         if (B[Di].head == NULL) B[Di].tail = tmp;         tmp->next = B[Di].head;         B[Di].head = tmp;     }     /* 下面是收集的过程 */     i = j = L; /* i, j记录当前要处理的A[]的左右端下标 */     for (Di=0; Di<Radix; Di++) { /* 对于每个桶 */         if (B[Di].head) { /* 将非空的桶整桶倒入A[], 递归排序 */             p = B[Di].head;             while (p) {                 tmp = p;                 p = p->next;                 A[j++] = tmp->key;                 free(tmp);             }             /* 递归对该桶数据排序, 位数减1 */             MSD(A, i, j-1, D-1);             i = j; /* 为下一个桶对应的A[]左端 */         }     }} void MSDRadixSort( ElementType A[], int N ){ /* 统一接口 */    MSD(A, 0, N-1, MaxDigit);}

10.4 排序算法的比较

排序方法

平均时间复杂度

最坏情况下时间复杂度

额外空间复杂度

稳定性

简单选择排序

O(N2)

O(N2)

O(1)

不稳定

冒泡排序

O(N2)

O(N2)

O(1)

稳定

直接插入排序

O(N2)

O(N2)

O(1)

稳定

希尔排序

O(Nd)

O(N2)

O(1)

不稳定

堆排序

O(NlogN)

O(NlogN)

O(1)

不稳定

快速排序

O(NlogN)

O(N2)

O(logN)

不稳定

归并排序

O(NlogN)

O(NlogN)

O(N)

稳定

基数排序

O(P(N+B))

O(P(N+B))

O(N+B)

稳定

前三种排序算法的共同优点是算法编写简单。冒泡排序和直接插入排序每次都是交换两个元素,因此是慢的,但是它们稳定,简单选择排序是跳着交换,有可能不稳定。

希尔排序算法打破了N的平方的复杂度,它的好坏取决于d(增量序列),因为是跳着排的,所以它也是不稳定的。

堆排序和归并排序的时间复杂度是最好的,无论何时都是一样的。归并排序的缺点是它需要一个额外的空间,当数据量非常大的时候,只能排一半数据,但是它的优点是它是稳定的。堆排序理论上看很美,实际情况是虽然理论上是O(NlogN),但是O这个常数会比较大,所以它到底跟快速排序哪个快,就难说了。堆排序和快速排序的共同缺点是不稳定,快速排序总可以构造一种最糟糕的情况是O(N2),而且因为是递归的,所以额外空间是需要的,时间复杂度最好时,额外空间复杂度也是O(logN)。

基数排序在某种情况下,会打破NlogN的魔咒,会更快,近乎线性。它需要的额外空间是需要B个桶,每个桶设置B个数据的位置,所以到底什么情况下合算,看情况,它的好处是它是稳定的。