排序算法

355 阅读8分钟

简介

本文是Algorithms-Part1的总结,从简入难介绍几种排序方法。

selection sort

在你面前有3个苹果,每次你都挑最大的吃,就是选择排序。

例子

数组排序:我们需要进行n(组数的长度)次选择,每次选择的结果和第n位元素交换:

int[] stu = [2,3,4,1];

// 2341选1,和2交换
int[] stu = [1,3,4,2];

// 342选2,和3交换
int[] stu = [1,2,4,3];

// 34选3,和4交换
int[] stu = [1,2,3,4];

// 4选4,和4交换
int[] stu = [1,2,3,4];

代码实现

public static void sort(Comparable[] a) {
  int n = a.length;
  for (int i = 0; i < n; i++) {
    int min = i;
    for (int j = i+1; j < n; j++) {
      if (less(a[j], a[min])) min = j;
    }
    // 每次循环找到最小的值,交换到i位置
    exch(a, i, min);
  }
}

容易发现,选择排序每次的比较次数就是剩余的元素个数,交换次数就是数组长度

小结

总结选择排序的特点:

  • 简单易学
  • 慢(需要n的平方级别的比较次数)
  • 即使数组有序,花费的时间仍然是不变的。

insertion sort

假设10,J,Q,A已经整理好的牌,如果要找到K的位置,只要从A的后面移动到K的后面即可,这就是插入排序的一部分。

举例

数组排序:永远保证当前元素左侧的所有元素是有序的,当进行到最后一个元素时,整个数组就是有序的。

int[] stu = [2,4,3,1];

// 保证第一个元素是有序的:不动
int[] stu = [2,4,3,1];

// 保证前2个元素是有序的:不动
int[] stu = [2,4,3,1];

// 保证前3个元素有序:3和4交换
int[] stu = [2,3,4,1];

// 保证第4个元素有序,1先后和4,3,2交换
int[] stu = [1,2,3,4];

从第2,3步我们发现,插入排序只比较了比自己大的元素,而不是全面的所有元素。

代码实现

public static void sort(Comparable[] a) {
  int n = a.length;
  for (int i = 1; i < n; i++) 
  {
    for (int j = i; j > 0 && less(a[j], a[j-1]); j--)
    {
       exch(a, j, j-1);
     }
   }
}

内层循环的less(a[j], a[j-1])判断正是上文得到的总结,也是插入排序比选择排序快的原因。

小结

总结插入排序的特点:

  • 慢(虽然比选择排序快一些)
  • 每次交换只能交换相邻的元素导致交换次数多。例子的最后一步可见:元素1必须和前面的所有元素交换才能到达自己的位置。

shell sort

希尔排序是优化版插入排序,主要改进了只能交换相邻元素的缺点。 排队时会有1、2报数,如果对所有报1的同学(隔着报2的同学)使用插入排序,就能交换不相邻的同学,提高效率。

例子

排序数组:首先保证(奇)偶数位的元素分别有序,最后使用插入排序保证全部有序。

int[] stu = [2,4,3,1];

// 【偶数位】前1位有序的:2不动
int[] stu = [2,4,3,1];

// 【偶数位】前2位有序的:2,3不动
int[] stu = [2,4,3,1];

// 【奇数位】前1位有序:4不动
int[] stu = [2,4,3,1];

// 【奇数位】前2位有序的:交换4,1
int[] stu = [2,1,3,4];

// 【所有位】前1位有序:2不动
int[] stu = [2,1,3,4];

// 【所有位】前2位有序:2,1交换
int[] stu = [1,2,3,4];

// 【所有位】前3位有序:1,2,3不动
int[] stu = [1,2,3,4];

// 【所有位】前4位有序:1,2,3,4不动
int[] stu = [1,2,3,4];

通过例子我们发现,虽然循环的次数变多,但是交换的次数大大减少(只有2次,插入排序用了4次)。

代码实现

public static void sort(Comparable[] a) {
  int n = a.length;
  // 3x+1增长序列:  1, 4, 13, 40, 121, ...
  int h = 1;
  while (h < n/3) h = 3*h + 1; 

  while (h >= 1) {
     // 对间隔h的子数组排序
     for (int i = h; i < n; i++) {
       for (
         int j = i; 
         j >= h && less(a[j], a[j-h]);
         j -= h
       ) {
         // 注意这里交换的是`j-h`
         exch(a, j, j-h);
       }
     }
     h /= 3;
  }
}

从代码中可以看出,希尔排序保证相隔h的子数组有序的,逐步减小h,当成为为1(即插入排序)时,完成排序。 实例代码使用3x+1的方法确定h的值,但还有很多其他形式的增长序列,序列不同,希尔算法的性能也各不相同,目前还没有找到“最好”的增长序列。

小结

总结插入排序的特点:

  • 比选择排序、插入排序快的多(没有达到平方级别)。
  • 对于中等规模大小的数组都可能使用希尔排序,代码量小,不需要额外的空间,且不会很慢。

merging sort

假设有两个有序的数组,则可以合并成一个有序的大数组,这就是归并排序的思想。

例子

有两个stu数组,每次比较两个数组的最小值并取出,最终就能得到有序的数组。

int[] stuA = [2,3];
int[] stuB = [1,4];
int[] rlt[];
// 1和2取1
int[] stuA = [2,3];
int[] stuB = [4];
int[] rlt[1];
// 2和4取2
int[] stuA = [3];
int[] stuB = [4];
int[] rlt[1,2];
// 3和4取3
int[] stuA = [];
int[] stuB = [4];
int[] rlt[1,2,3];
// stuA没有了,取4
int[] stuA = [];
int[] stuB = [];
int[] rlt[1,2,3,4];

代码实现

看下归并的实现:

/**
 * 这里假设`lo`到`mid`、`mid`到`hi`是有序的
 * 使用i和j分别指向当前比较的位置
 */
public static void merge(
  Comparable[] a,
  int lo,
  int mid,
  int hi
) {
  for (int k = lo; k <= hi; k++) {
    aux[k] = a[k];
  }

  int i = lo, j = mid + 1;
  for (int k = lo; k <= hi; k++) {
    if (j > hi) a[k] = aux[i++];
    else if (i > mid) a[k] = aux[j++];
    else if (Util.less(aux[j], aux[i])) {
      a[k] = aux[j++];
    } else a[k] = aux[i++];
  }
}

归并排序算法递归这个过程:将数组对半(递归)排好序,然后归并起来,看下代码

public static void sort(Comparable[] a) {
  aux = new Comparable[a.length];
  sort(a, 0, a.length - 1);
}

public static void sort(Comparable[] a, int lo, int hi) {
  if (lo >= hi) return;

  int mid = lo + (hi - lo) / 2;
  sort(a, lo, mid);
  sort(a, mid + 1, hi);
  merge(a, lo, mid, hi);
}

public static void merge() {
  // 同上文
}

最重要的是,递归算法比较的时间复杂度只有NlgN。 同时需要注意的是,归并算法需要一个同样的大小的辅助数组。

小结

  • 快(比选择排序、插入排序快一个数量级,比希尔排序略快)
  • 需要辅助数组

quick sort

如果我们将数组拆分成左右两个子数组,左边的数组都小于某个值,右边的数组反之,那面重复拆分子数组,也能得到有序的数组,这就是快速排序的思想。

例子

我们需要给几个不同地区的电话号码排序:第一步只需要保证所有北京地区的号码在上海之前就可以了,然后才是北京、上海地区内部的电话排序。典型的任务分解的思路。

010-12345678
021-56781234
021-12345678
010-56781234
// 第一次将按区号排序
010-12345678
010-56781234
021-56781234
021-12345678
// 然后北京、上海分别排序
010-12345678
010-56781234
021-12345678
021-56781234

代码实现

先看下拆分的代码:

private static int partition(
  Comparable[] a,
  int lo,
  int hi
) {
  int        i = lo, j = hi + 1;
  Comparable v = a[lo];
  while (true) {
    // 从前往后找到比v大的元素
    while (Util.less(a[++i], v)) if (i == hi) break;
    // 从后往前找比v小的元素
    while (Util.less(v, a[--j])) if (j == lo) break;
    if (i >= j) break;
    Util.exch(a, i, j);
  }

  Util.exch(a, lo, j);
  return j;
}

上述代码以第一个元素为比较对象v,将数组拆分为“比v小的数组”,v,以及“比v大的数组”3部分,最后返回拆分点。 完整的快速排序实现:

public static void sort(Comparable[] a) {
  // first must shuffle(a)
  sort(a, 0, a.length - 1);
}

private static void sort(
  Comparable[] a,
  int lo,
  int hi
) {
  if (lo >= hi) return;
  int mid = partition(a, lo, hi);
  sort(a, lo, mid - 1);
  sort(a, mid + 1, hi);
}

private static int partition(
  Comparable[] a,
  int lo,
  int hi) {
    // 省略
  }

对比归并算法

两者最大的不同在于sort内递归和排序的先后顺序:

  • 归并排序法先子数组有序,后合并排序
  • 快速排序先整体(大致)有序,后子数组排序:每次切分只保证数组的元素都大于(或小于)某个值,但不是有序的,对其排序需要下次切分来完成。 同时快速排序并不需要辅助函数。

小结

快速排序是本文唯一满足N * lgN复杂度且不需要辅助函数的算法。

  • 快(和归并算法相同的时间复杂度)

总结

对于绝大部分情况,使用快速排序即可。如果有稳定性需求且也没有空间问题的话,归并排序可能是更好的选择。