选择、冒泡、插入、归并……

简单排序算法大杂烩

这次总结一下一些简单排序算法的特点。选择、冒泡、插入排序是 O(n2) 的算法,希尔排序时间复杂度低于 O(n2)(精确分析很难),归并排序的时间复杂度为 O(n lg n)。

首先引入一个新的概念:稳定性 (stability)。如果排序后具有相同关键字的元素的相对位置没有改变,就称这种排序方式是稳定的。

例如,现在我们有某班的学生姓名和成绩,且预先已按姓名顺序排好序,要再以成绩为关键字排序。如果使用稳定的排序方法,那么得到的名单中,相同分数的学生姓名仍是有序的。而不稳定的排序方法则不是这样。

原数据 稳定的排序结果 不稳定的排序结果
Adam 100 Adam 100 Adam 100
Black 90 Smith 100 Smith 100
Brown 70 Black 90 Washington 90
Jackson 90 Jackson 90 Jackson 90
Jones 70 Washington 90 Black 90
Smith 100 White 80 Wilson 80
Thompson 70 Wilson 80 White 80
Washington 90 Brown 70 Thompson 70
White 80 Jones 70 Brown 70
Wilson 80 Thompson 70 Jones 70

根据这个性质,如果我们要按照关键字 A 排序,若 A 相同则按关键字 B 排序,只需使用稳定的排序算法,先按 B 排序,再按 A 排序,就可以达到目的了。

需要说明的是,本文的讨论均以数组表示为基础。若数据由链表表示,则很多内容都需要调整。

本文将给出几种排序算法的函数实现。调用函数的具体约定如下:

#include <stdio.h>
#include <stdlib.h>
#define min(X, Y) (((X) < (Y)) ? (X) : (Y))
#define exch(X, Y) { int T = X; X = Y; Y = T; }
#define compexch(X, Y) if ((X) > (Y)) exch(X, Y)
typedef int Item;

void sort(Item a[], int l, int r); /* 区间左闭右开 */
void merge(Item a[], int l, int m, int r); /* 适用于归并排序 */
Item *aux; /* 适用于归并排序 */

int main()
{
  int n, i;
  Item *a;
  
  scanf("%d", &n);
  a = (int *)malloc(n * sizeof(int));
  aux = (int *)malloc(n * sizeof(int)); /* 适用于归并排序 */
  for (i = 0; i < n; i++) {
    scanf("%d", a + i);
  }
  
  sort(a, 0, n);
  
  for (i = 0; i < n; i++) {
    printf("%d", a[i]);
    if (i != n - 1) putchar(' ');
  }
  
  return 0;
}

选择排序

选择排序 (selection sort) 是实现起来最简单的排序方法。首先,找到数组中最小的一个元素,把它和第一个元素交换。然后找到次小的元素,和第二个元素交换……以此类推,每一步都从未处理的元素中找到最小的,和首个未处理的元素交换,直到整个数组排完序。由于每个外循环只选一个元素进行交换,故名选择排序。

paragraph

选择排序的动态演示。来源:Sorting Algorithms Animations

C 实现如下:

void sort(Item a[], int l, int r) /* selection sort */
{
  int i, j, imin;
  
  for (i = l; i < r; i++) {
    imin = i;
    for (j = i + 1; j < r; j++) {
      if (a[j] < a[imin])
        imin = j;
    }
    exch(a[imin], a[i]);
  }
}

我在我们学校的 OJ 上测试了这一段代码。有 5 个测试点,分别是:104 个随机整数、105 个随机整数、105 个顺序整数、105 个逆序整数和 105 个基本有序的整数。时间如下:

1 2 3 4 5
54 ms 3631 ms 3739 ms 3210 ms 4157 ms

我测试了多次,发现给出的运行时间波动很大,所以这里只作大概的参考。

选择排序是否稳定?答案为。举一个简单的例子:(7) 5 [7] 1。第一次 (7) 与 1 交换,得到 1 5 [7] (7),此时数组已经有序。可以看到两个 7 被调换了位置,所以是不稳定的。

选择排序有一个缺点,即它的运行时间对已排序部分的依赖很少。在上面的例子中,即使第一次外循环后已经有序,后面的外循环仍将继续,且总的比较和交换次数不变。而且,每次交换并没有使其余部分「更加有序」,很多情况下较小的元素会被交换到后面。而它的优点是交换次数少,因此对于元素较大、关键字较小的数据,会比其它排序方法快很多(例如刚才的成绩表,成绩作为关键字很容易比大小,但名字是字符串,交换起来较麻烦,这时选择排序效率较高)。

冒泡排序

冒泡排序 (bubble sort) 的思路有所不同:每次从数组最右边往左边检索,如果相邻两个元素的顺序不对,就把它们调换,这样可以保证最小的元素被交换到数组最左边。然后再从右边开始检索,把次小的元素交换到第二个位置,以此类推,直到整个数组排完序。由于每次外循环中都是从右到左两两比较并交换,很像从水底上升的气泡,故名冒泡排序。冒泡排序是稳定的排序法。

paragraph

冒泡排序的动态演示。来源:Sorting Algorithms Animations

C 实现如下:

void sort(Item a[], int l, int r) /* bubble sort v1 */
{
  int i, j;
  
  for (i = l; i < r; i++) {
    for (j = r - 1; j > i; j--) {
      compexch(a[j - 1], a[j]);
    }
  }
}

其实冒泡排序是一种很慢的算法。它的目标和选择排序差不多——每一个外循环,都将一个元素放在合适的位置上,不过它也调整了一下其它元素的位置,使其更趋近有序。这中间花费了很多交换,浪费了更多时间。我的测试结果如下:

1 2 3 4 5
157 ms >10000 ms 5149 ms 9110 ms 5029 ms

不过有两个小技巧,可以减少无用的判断和比较:

  1. 如果某次外循环中没有发生交换,说明整个数组现在已经有序,可以跳出外循环返回结果了。
  2. 记录下每次外循环中,发生最后一次交换的位置,该位置之前的元素已经有序,下一次外循环处理到这个位置就可以了。(为什么之前的元素已经有序?可以用反证法说明。)

我们用 C 实现上面两点:

void sort(Item a[], int l, int r) /* bubble sort v2 */
{
  int i, j, pos, thisPos;
  pos = l;
  
  for (i = l; i < r; i++) {
    thisPos = r;
    
    for (j = r - 1; j > pos; j--) {
      if (a[j - 1] > a[j]) {
        exch(a[j - 1], a[j]);
        thisPos = j;
      }
    }
    
    if (thisPos == r) break;
    pos = thisPos;
  }
}

优化后的时间如下:

1 2 3 4 5
177 ms >10000 ms 38 ms 9259 ms 878 ms

可以看到,如果数据本来比较有序,这样的优化将节省许多时间;但对于其它数据,优化效果不太明显。

插入排序

假设我们在玩扑克牌。拿到手牌整理的时候,我们一般是一次考虑一张扑克牌,将它插入到已排序部分的适当位置,再考虑下一张扑克牌。插入排序 (insertion sort) 也是如此,为了插入新数据,将较大的元素一个一个向右移动,给当前的元素挪出位置。插入排序也是稳定的。

paragraph

插入排序的动态演示。来源:Sorting Algorithms Animations

以下是一种实现方式:

void sort(Item a[], int l, int r) /* insertion sort */
{
  int i, j;
  
  for (i = r - 1; i > l; i--) {
    compexch(a[i - 1], a[i]);
  }
  for (i = l + 2; i < r; i++) {
    int j = i;
    Item v = a[i];
    while (v < a[j - 1]) {
      a[j] = a[j - 1];
      j--;
    }
    a[j] = v;
  }
}

这段代码有三点值得分析之处:

首先,小循环使用了 while 语句,若找到了比正被插入项小的元素,就跳出循环。这让该算法成为一种适应性的排序算法。试想,若输入数据基本有序,那么小循环执行次数会很少。实际上,如果输入数据就是有序的,那么根本不会进入 while 语句,这时该算法的时间复杂度就是 O(n)。

其次,在 while 语句中 j-- 时,如何确定边界?其实有两个条件:j 位置左边的数据比 v 小,并且 j > l。但实际上 j > l 的判断条件大多数时候是无用的,它只在当所要插入的元素是最小的元素、要插入数组的最前面时才发挥作用。我们的优化方式是:先用类似冒泡排序的方法,找出数组中最小的元素,放在 a[0] 中作为观察哨关键字 (sentinel key)。这样,我们测试第一个条件时就相当于同时测试了这两个条件,节约了运行时间。

最后,注意 while 内部的细节:我们大可以像冒泡排序一样,直接使用 compexch(a[j - 1], a[j]),将 a[i] 一步步送到它的位置上,但这里我们先将 a[i] 保存在 v 中,再将它前面比它大的元素整体向右移动,腾出位置来,最后把 v 放在它的位置上。这样的好处是,交换两个元素需要三次赋值操作,而我们的方式只需要一次赋值操作,大大减少了无谓的运算。

试验表明该方法的确是最快的。结果如下:

1 2 3 4 5
14 ms 1126 ms 19 ms 2376 ms 46 ms

插入排序的优化:希尔排序

插入排序的效率仍然不高,其中一个原因是在只能将元素移动到相邻的位置。如果靠后的元素被移动到前面,就会浪费很多时间。希尔排序 (shell sort) 是插入排序的优化,它可以很好地解决这个问题。

希尔排序的思想是:取定步长 h,先让数组每隔 h 个元素有序,再逐步缩小 h 的值直到 1,最终整个数组有序。每隔 h 个元素有序的数组可以称为 h-排序的,这意味着任意元素都比它后面的第 h 个元素小。换句话说,h-排序的数组是由 h 个有序的数组相互交叉在一起的。这样操作的好处是,当 h 较大时,每次移动的距离较大,移动的次数较少;到后面 h 较小时,由于数组已经基本有序,移动很少的次数就能将元素送到正确位置。

paragraph

希尔排序的动态演示。来源:Sorting Algorithms Animations

不过,由于 h 会从大到小递减,这就增加了外循环的数量。我们需要设计 h 如何递减(即步长序列),以平衡外循环的增加与内循环的减少。Shell 在 1959 年提出希尔排序时给出的步长序列是 1 2 4 8 16 32 64…,这其实是不太好的,因为直到最后一次排序奇数位置上的数才会与偶数位置上的数比较。在最坏的情况下,可能在最后一次排序前,奇数位置全是较小的一半数,偶数位置全是较大的一半数。

在下面的例子中,我们使用的步长序列是 1 4 13 40 121 364 1093…,即 an = 3an-1 + 1。可以证明,使用这个步长序列的希尔排序的时间复杂度在 O(n3/2) 以下。

void sort(Item a[], int l, int r) /* shell sort */
{
  int i, j, h;
  
  /* 寻找初始的 h */
  for (h = 1; h <= (r - l) / 9; h = 3 * h + 1)
    continue;
  
  for ( ; h > 0; h /= 3) { /* 通过整除找到上一个 h */
    for (i = l + h; i < r; i++) {
      int j = i;
      Item v = a[i];
      while (j >= l + h && v < a[j - h]) {
        a[j] = a[j - h];
        j -= h;
      }
      a[j] = v;
    }
  }
}

注意第二层 for 循环:我们要做的是让数组变成 h-排序的,当然可以先排序除 h 余 0 位置上的数,然后排序除 h 余 1 位置上的数,再排序除 h 余 2 位置上的数……但它们都是互不影响的,所以我们可以只用一个 for 循环就完成。

里面的 while 循环不太好使用观察哨,所以我们加入 j >= l + h 的判断条件。可以看到,希尔排序对插入排序的优化是十分可观的:

1 2 3 4 5
7 ms 56 ms 43 ms 44 ms 42 ms

需要注意,希尔排序是不稳定的。虽然直接的插入排序是稳定的,但步长不同的插入排序会打乱关键字相同元素的顺序。

归并排序

归并

假设我们有一个数组,在 [l, m) 和 [m, r) 上都是有序的。给它排序很简单:比较两段上的首元素,较小的就是新数组的首个元素;再比较两段上未被选出的首元素,较小的作为新数组的第二个元素,以此类推。在这里,我们先将 a[] 数组复制到 aux[] 数组中,再将排序得到的数组存储回 a[]

void merge(Item a[], int l, int m, int r) /* merge v1 */
{
  int i = l, j = m, k;
  
  for (k = l; k < r; k++)
    aux[k] = a[k];

  for (k = l; k < r; k++) {
    if (i == m) a[k] = aux[j++];
    else if (j == r) a[k] = aux[i++];
    else a[k] = (aux[i] > aux[j]) ? aux[j++] : aux[i++];
  }
}

以上的代码考虑到四种情况:如果左边元素耗尽,就加入右边元素;如果右边耗尽,就加入左边元素;如果左边最小元素大于右边最小元素,就加入右边元素;如果左边最小元素小于等于右边最小元素,就加入左边元素。很显然,这样的归并方式是稳定的。

还有另一种方法,可以省略判断两边元素是否耗尽:我们将 a[] 的左边顺序地复制到 aux[] 中,再将 a[] 的右边逆序地复制到 aux[] 中,这样 aux[] 成为了一个先递增再递减的数组。ij 从两边向中间靠拢,数组中间最大的元素作为观察哨,不论它在哪一边,都可以避免 ij 越界。

void merge2(Item a[], int l, int m, int r) /* merge v2 */
{
  int i = l, j = r - 1, k;
  
  for (k = l; k < m; k++)
    aux[k] = a[k];
  for (k = m; k < r; k++)
    aux[k] = a[m+r-k-1];

  for (k = l; k < r; k++)
    a[k] = (aux[i] > aux[j]) ? aux[j--] : aux[i++];
}

但是要注意,这样的归并算法是不稳定的。举个例子:若 a 数组是 (6) <6> [6] {6},复制到 aux 数组变成 (6) <6> {6} [6],若左右两边相等优先取左边,所以最后的结果就是 (6) <6> {6} [6]。

自顶向下的归并排序

既然我们知道如何将在 [l, m) 和 [m, r) 上有序的数组排序,那么对于一个普通的数组,我们只需利用递归,将 [l, m) 和 [m, r) 分别排好序,再将两部分归并即可。归并排序 (merge sort)分治法 (divide-and-conquer technique) 的一个著名应用。

paragraph

自顶向下归并排序的动态演示。来源:Sorting Algorithms Animations

代码实现如下:

void sort(Item a[], int l, int r) /* merge sort (top-down) */
{
  int len = r - l;
  
  if (len >= 16) {
    int m = (l + r) / 2;
  
    sort(a, l, m);
    sort(a, m, r);
    if (a[m - 1] > a[m]) 
      merge(a, l, m, r);
  }
    
  else { /* 若区间较短,则使用插入排序 */
    int i, j;
  
    for (i = r - 1; i > l; i--) {
      compexch(a[i - 1], a[i]);
    }
    for (i = l + 2; i < r; i++) {
      int j = i;
      Item v = a[i];
      while (v < a[j - 1]) {
        a[j] = a[j - 1];
        j--;
      }
      a[j] = v;
    }
  }
}

这里有两点要注意:首先,当区间较短(这里设定为长度小于 16)时,我们改为通过插入排序判断,因为在数据较小时,插入排序会比归并排序快。归并排序是递归算法,会遇到很多数据较小的情况,所以这个优化会显著减少运行时间。

其次,我们在归并前增加了 a[m - 1] > a[m] 的判断条件,因为若 am-1 ≤ am,那么该部分就已经有序了,不用再进行归并。若原数组的有序性较高,也将显著优化运行时间。

对于长度为 n 的区间,归并操作的时间为 O(n)。每个元素被包括进 merge() 操作区间的次数为 O(lg n),所以算法总的时间复杂度为 O(n lg n)。以下是结果:

1 2 3 4 5
7 ms 29 ms 28 ms 23 ms 20 ms

还有一种优化思路是:减少 merge() 中复制元素的次数。我们可以在每层递归中,切换 a[]aux[] 的作用,这样就不用每层都复制到 aux[] 再复制回来。(具体实现方法见于《算法:C 语言实现》程序 8.1 和 8.4。)

自底向上的归并排序

任何一个递归程序都有一个等价的非递归程序与之对应。我们可以用循环来控制,先将每个元素看成长度为 1 的有序子列,然后相邻子列归并,形成长度为 2 的有序子列,再归并成长度为 4 的子列,以此类推(最后一个字列的长度可能比其他子列少,因为可能除不尽)。代码如下:

void sort(Item a[], int l, int r) /* merge sort (bottom-up) */
{
  int i, halfLen;
  for (halfLen = 1; halfLen < r - l; halfLen *= 2)
    for (i = l; i <= r - halfLen; i += halfLen * 2)
      merge(a, i, i + halfLen, min(i + halfLen * 2, r));
}

以下是测试结果:

1 2 3 4 5
8 ms 55 ms 23 ms 38 ms 23 ms

我们也可以在区间较短时采用插入排序,这是不难实现的。

利用归纳法,我们可以验证:只要采用的归并算法稳定,那么归并排序就具有稳定性。这里我们都采用第一种归并的实现,所以这里的归并排序是有序的。


最后讨论下几种算法的空间复杂度。选择、冒泡、插入、希尔排序在交换元素时,都只需开辟 1 个空间用来存储中间量,所以它们在空间上都是 O(1) 的。而归并排序的空间复杂度就是 aux[] 和递归栈占用的空间:n + lg n,即 O(n)。

留下评论

注意 评论系统在中国大陆加载不稳定。

回到顶部 ↑