基于比较的经典7大排序

172 阅读6分钟

八大排序可谓是数据结构与算法中的经典,也是基础中的基础。其中有七大都是基于比较的,今日就把这七个基于比较的排序复习复习。

首先,需要知道几个概念:

  • 稳定的排序:稳定的排序算法是指该算法排完序后,原数组中相同元素的相对位置不改变
  • 基于比较的排序,复杂度最优为O(nlgn)O(nlgn)。这个可以使用决策树进行证明,篇幅原因就不赘述了。

下面,基于升序排序我们把这七大一一过一遍吧。

1. 冒泡排序 Bubble Sort

算法思想

暴力法,两两交换相邻之间不符合排序条件的两个元素。而每一趟的两两交换实际上是把当前最大的元素放到了数组的最后(根据具体实现也可能是最小的放到了最前)。例如:

4 3 7 5
^ ^ 不符合排序规律,交换
3 4 7 5
  ^ ^ 符合排序规则,不交换
3 4 7 5
    ^ ^ 不符合排序规则,交换
3 4 5 7
		  ^ 这一趟下来把最大值放置到了最后

时间复杂度

每一趟将当前最大的元素放到正确的位置,时间开销为O(n)O(n);数组一共有n个元素,至少需要O(n)O(n)趟。

故总的时间复杂度为O(n2)O(n^2)

稳定性

稳定。因为冒泡排序每一次交换的是相邻的两个元素,不会出现交换的过程中跨越多个元素,导致可能跨越值相同的元素。

public static void sort(Comparable[] arr) {
  if (arr == null) return;
  int len = arr.length;
  for (int i = 1; i < len; ++i) {	
    for (int j = 0; j < len - i; ++j) {
      if (arr[j].compareTo(arr[j + 1]) > 0) { // 两两比较相邻的元素
        swap(arr, j, j + 1);
      }
    }
  }
}

2. 选择排序 Selection Sort

算法思想

选择排序可以看作是冒泡排序的一个优化。之前提到冒泡排序尽管每一趟两两比较存在多次的相邻交换,但是本质上是将最大的元素放到了最后,故既然目的在此,何不每一趟直接选择出最大的元素放到最后或者是每一趟选出最小的元素放到最前呢。这样虽然比较的次数没有改变,但是交换的次数变为了1。

时间复杂度

每一趟寻找最小的元素需要遍历O(n)O(n)个元素,一共需要找O(n)O(n)次。

总的时间复杂度为O(n2)O(n^2)

稳定性

选择排序的交换是可能跨越多个元素的,即非相邻交换,会存在交换过程中跨越相同值元素的情况,故不是稳定的排序。

public static void sort(Comparable[] arr) {
  if (arr == null) return;
  int len = arr.length, minIndex;
  for (int tail = 0; tail < len; ++tail) {
    minIndex = tail;
    for (int j = tail + 1; j < len; ++j) {
      if (isLess(arr, j, minIndex)) {
        minIndex = j;
      }
    }
    swap(arr, minIndex, tail);
  }
}

3. 插入排序 Insertion Sort

算法思想

这个排序维护一个有序子数组,这个子数组初始长度为0,并随着我们插入元素而不断增长,当原数组的所有元素插入其中后整个数组的就是有序的了。

时间复杂度

插入次数O(n)O(n),子数组的维护O(n)O(n)

故总的时间复杂度为O(n2)O(n^2)

稳定性

每一次插入一个新元素到有序子数组中时,新元素跟子数组中的元素是相邻比较并且相邻交换的,故是稳定的。

public static void sort(Comparable[] arr) {
  if (arr == null) return;
  int len = arr.length;
  for (int i = 0; i < len; ++i) {
    for (int j = i; j > 0 && isLess(arr, j, j-1); --j) {
      swap(arr, j, j - 1);
    }
  }
}

4. 希尔排序 Shell Sort

算法思想

希尔排序是建立在插入排序之上的。在数组基本有序的情况下,插入排序的时间复杂度接近O(n)O(n),根据这个优点,希尔排序将数组按照一定步长分成多个组,组内进行插入排序。接着逐渐将步长缩短,直至步长为1后,排序完成。

时间复杂度

时间复杂度比较难计算,且与步长的选取有关。可以认为复杂度仍稍低于O(n2)O(n^2)

稳定性

组内插入排序维护有序子数组是,交换的元素之间是有跨度的,非相邻交换,不稳定。

public static void sort(Comparable[] arr) {
  if (arr == null) return;
  int len = arr.length;
  for (int incr = len / 2; incr > 0; incr /= 2) {  // 组内步长
    for (int g = 0; g < incr; ++g) {  // 组号(每一组的第一个元素)
      for (int i = g; i < len; i += incr) { // 组内插入排序
        for (int j = i; j > g && isLess(arr, j, j - 1); j -= incr) {
          swap(arr, j, j - incr);
        }
      }
    }
  }
}

5. 归并排序 Merge Sort

算法思想

归并排序采用的是一种分治的思想。分治的大体过程为:

  1. 划分子问题
  2. 解决子问题
  3. 合并子问题

一个数组有序,其各个部分子数组也是必然有序的。故可以不断将要排序的数组折半划分,对更小的数组排序(划分子问题);当数组的长度为1时,本身完成排序(基例,解决子问题);将排好序的数组两两合并成一个更长的数组(合并子问题)。

时间复杂度

典型的递归式计算,每一次递归的开销主要在于归并操作O(n)O(n)

T(n)=2T(n/2)+O(n)O(nlgn)T(n) = 2T(n/2) + O(n) \\ \Rightarrow O(nlgn)

稳定性

归并不会出现跨元素的交换,所以是稳定的排序。

public static void sort(Comparable[] arr) {
  if (arr == null) return;
  int len = arr.length;
  Comparable[] aux = new Comparable[len];
  sort(arr, aux, 0, len - 1);
}

// 自底向上的归并排序
private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi) {
  if (lo >= hi) return;
  int mid = lo + (hi - lo) / 2;
  sort(a, aux, lo, mid);			// 对左子数组排序
  sort(a, aux, mid + 1, hi);	// 对右子数组排序
  merge(a, aux, lo, mid, hi);	// 两部分归并
}

/**
* 借助aux[lo .. hi] 归并 a[lo .. mid] 和 a[mid+1 .. hi]
*/
private static void merge(Comparable[] a, Comparable[] aux, int lo, int mid, int hi) {
  if (hi + 1 - lo >= 0)	// copy a[lo .. hi] to aux[lo .. hi]
    System.arraycopy(a, lo, aux, lo, hi + 1 - lo);
  int i = lo, j = mid + 1;
  for (int k = lo; k <= hi; ++k) {
    if      (i > mid)           a[k] = aux[j++];
    else if (j > hi)            a[k] = aux[i++];
    else if (isLess(aux, i, j)) a[k] = aux[i++];
    else                        a[k] = aux[j++];
  }
}

6. 快速排序 Quick Sort

算法思想

快排其实也是一种分治的思想。关键是每一次寻找一个轴值pivot,这个pivot就是当前要放到最终正确位置的元素。如何才能放到正确的位置呢?把比pivot小的元素放到pivot之前,反之放到pivot之后,这样无论之后如何排序pivot的位置就是最终的正确位置。

按照上述方法放置好轴值pivot之后,实际上就将原数组划分成了两大部分,一部分全部比pivot小,另一部分全部比pivot大。分别再对这两部分进行排序,实际就是两个同类型子问题。

捋一捋大致的流程:

  1. 选择一个轴值pivot。
  2. 通过pivot将数组划分成两个小大部分,划分完后pivot就放在了排序结果的正确位置。
  3. 分别继续解决pivot左右两个子区间。

时间复杂度

每一趟的开销主要在于划分,划分需要遍历当前的子数组,故递归式:

T(n)=2T(n/2)+O(n)O(nlgn)T(n) = 2T(n/2) + O(n)\\ \Rightarrow O(nlgn)

稳定性

在通过轴值划分区域的时候,会出现非相邻交换,故是不稳定的。

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

private static void sort(Comparable[] a, int lo, int hi) {
  if (lo >= hi) return;
  int mid = lo + (hi - lo) / 2;
  int k = partition(a, lo, hi, mid);
  sort(a, lo, k - 1);	// 轴值左测
  sort(a, k + 1, hi);	// 轴值右侧
}

/**
* @return 按照轴值划分完后,轴值的位置
*/
private static int partition(Comparable[] a, int lo, int hi, int p) {
  int k = lo;
  Comparable pivot = a[p];
  swap(a, p, hi);	// 先将pivot放到最后
  for (int i = lo; i < hi; ++i) {
    if (a[i].compareTo(pivot) < 0) { // 将比pivot小的元素交换到左侧
      swap(a, k++, i);
    }
  }
  swap(a, k, hi);	// pivot防止到最终的正确位置
  return k;
}

7. 堆排序 Heap Sort

算法思想

这个算法我认为是最巧妙的,充分地利用了数据结构。堆是一种局部有序的完全二叉树,既然是完全二叉树,那么就恰好可以用数组来完美存储其层次遍历序列。

堆的删除就是将根与最后一个叶子交换,之后再维护堆。由于根存储的就是整个当前树的最值,刚提到的根删除实际上就是将最值放到了当前数组的最后。若不断删除,就是不断将当前的最值放到当前最后,实际上就是一个排序的过程。

故要得到一个升序序列,就是构建一个大根堆,然后不断进行删除。

时间复杂度

堆的构建O(n)O(n),堆的删除O(lgn)O(lgn),一共要删除n次。

故总体的时间复杂度为O(nlgn)O(nlgn)

稳定性

堆的删除的一个操作就是数组的首尾交换,所以不是相邻的交换,不稳定。

// implement the heap myself 
//(篇幅问题,具体的堆实现这里就没有展示,关键是这个算法思想)
public static void sort(Comparable[] a) {
  int last;
  toHeap(a, last = a.length - 1);
  for (int i = last; i >= 0; --i) {
    pop(a, i);
  }
}

小总结

完整代码:

gitee.com/bankarian/d…