排序算法
{Back to Index}

Table of Contents

1 时间复杂度递推公式

递推式 时间复杂度
\(T(n) = T(\frac{n}{2}) + O(1)\) \(O(logn)\)
\(T(n) = T(\frac{n}{2}) + O(n)\) \(O(n)\)
\(T(n) = 2* T(\frac{n}{2}) + O(1)\) \(O(n)\)
\(T(n) = 2* T(\frac{n}{2}) + O(n)\) \(O(nlogn)\)
\(T(n) = T(n - 1) + O(1)\) \(O(n)\)
\(T(n) = T(n - 1) + O(n)\) \(O(n^2)\)
\(T(n) = 2 * T(n - 1) + O(1)\) \(O(2^n)\)
\(T(n) = 2 * T(n - 1) + O(n)\) \(O(2^n)\)

2 基于比较的排序算法

2.1 冒泡排序

2.1.1 排序思路

从头部开始比较每一对 相邻元素 ,如左边大于右边,则交换位置。每执行完一轮,尾部元素即为此轮的最大值。

2.1.2 代码实现

protected void sort() {
    for (int end = array.length - 1; end >= 1; end--) {
        for (int begin = 1; begin <= end; begin++) {
            // E left = array[begin-1];
            // E right = array[begin];
            if (compare(begin-1).gt(begin)) {
                swap(begin-1, begin);
                // array[begin-1] = right;
                // array[begin] = left;
            }
        }
    }
}

2.1.3 优化方案1(提前退出)

假设某一轮冒泡操作后,发现没有执行过交换操作,表明整个序列已是有序状态,无需继续剩余轮次的冒泡操作。

此优化方案对性能提高不明显 ,因为前提是数组能在某一轮次中呈现有序性,而这种情况出现概率较小。

protected void sort() {
    for (int end = array.length - 1; end >= 1; end--) {
        boolean sorted = true;
        for (int begin = 1; begin <= end; begin++) {
            if (compare(begin - 1).gt(begin)) {
                swap(begin - 1, begin);
                sorted = false;
            }
        }
        if (sorted) {
            return; // 数组已经有序,提前结束
        }
    }
}

2.1.4 优化方案2(减少比较次数)

如果序列尾部已经局部有序,可以记录最后一次交换位置,从而减少比较次数。

protected void sort() {
    for (int end = array.length - 1; end >= 1; end--) {
        int lastSwapIndex = 1; // 初始值设为 1 的意义在于如果序列完全有序,则可以提前终止排序
        for (int begin = 1; begin <= end; begin++) {
            if (compare(begin - 1).gt(begin)) {
                swap(begin - 1, begin);
                lastSwapIndex = begin;
            }
        }
        end = lastSwapIndex;
    }
}

2.2 选择排序

2.2.1 排序思路

从序列中找出最大的元素,与最末尾的元素交换位置。

2.2.2 代码实现

protected void sort() {
    for (int end = array.length - 1; end >= 1; end--) {
        int maxIndex = 0;
        for (int begin = 0; begin <= end; begin++) {
            if (compare(begin).gt(maxIndex)) maxIndex = begin;
        }
        swap(end, maxIndex);
    }
}

2.2.3 支持稳定性的解法

protected void sort() {
    for (int end = array.length - 1; end >= 1; end--) {
        int maxIndex = 0;
        for (int begin = 0; begin <= end; begin++) {
            if (compare(begin).ge(maxIndex)) maxIndex = begin; // 改为 >= 即可
        }
        swap(end, maxIndex);
    }
}

2.3 堆排序

2.3.1 排序思路

堆排序其实是对选择排序的一种优化方案。 即把选择最大值这个操作交由堆来处理。

  • 首先对序列原地建堆(大顶堆),即将原序列变为堆序列
  • 重复一下操作,直至堆元素数量为 1
    • 交换堆顶元素与尾元素
    • 堆元素数量减1
    • 对0位置进行 siftDown 操作

heap_sort.png

Figure 1: 堆排序过程

2.3.2 代码实现

protected void sort() {
    size = array.length;
    heapify();
    for (; size > 1;) {
        swap(0, --size);
        siftDown(0);
    }
}

2.4 插入排序

coolnikhilj22a2b418fe-0d4f-4c2d-828c-09e0a74ad630.jpg

2.4.1 排序思路

在执行过程中,会将序列分为 2部分 : 头部是已排好序的,尾部是待排序的。
插入指的是从尾部取出一个元素,并从头部依次扫描,当找到合适位置,就插入,插入后头部仍然是有序的。

2.4.2 代码实现(交换)

protected void sort() {
    for (int i = 1; i < array.length; i++) {
        for (int j = i; j > 0 ; j--) {
            if (compare(j-1).gt(j)) {
                swap(j-1, j);
            } else {
                break; // 说明头部已经是有序的
            }
        }
    }
}

2.4.3 优化方案(挪动)

  • 先将待插入元素备份
  • 头部中所有比插入元素大的,都朝尾部移动 一个位置
  • 将待插入元素放到合适位置

insertionv2.png

Figure 3: 挪动方案示意图

protected void sort() {
    for (int i = 1; i < array.length; i++) {
        E v = array[i];
        int pos = i;
        for (int j = i; j > 0 ; j--) {
            if (compare(array[j - 1]).gt(v)) {
                pos = j - 1;
                array[pos + 1] = array[pos]; // 挪动
            } else {
                break; // 说明头部已经是有序的
            }
        }
        array[pos] = v; // 插入
    }
}

2.4.4 优化方案(使用二分搜索)

使用二分搜索法来确定新元素应该放在已排序数列的位置。该优化方案的思想是减少比较的次数,但是由于数据搬运仍然是不可避免的,因此时间负责度依旧是 \(O(n^2)\) 。

@Override
protected void sort() {
    for (int i = 1; i < array.length; i++) {
        insert(i, search(i));
    }
}

// length 既是左边已排好序的序列长度,也是右边未排序序列的第一个元素索引
// 查找 v 在有序部分中的插入位置,这个位置其实是"第一个大于 v 的元素"的索引
// 已排好序的数据范围是 [0, length)
private int search(int length) {
    E v = array[length];
    int begin = 0;
    int end = begin + length;
    while (begin != end) {
        int middle = (begin + end) / 2;
        if (compare(v).ge(array[middle])) {
            begin = middle + 1;
        } else {
            end = middle;
        }
    }
    return begin;
}


// 将 sourceIndex 位置的元素插入到 pos 位置
private void insert(int sourceIndex, int pos) {
    E v = array[sourceIndex];
    for (int j = sourceIndex; j > pos; j--) { // 整体向右移动一个位置
        array[j] = array[j - 1];
    }
    array[pos] = v;
}

2.5 归并排序 [\(O(nlogn)\)]

2.5.1 排序思路

  • 不断将序列平均 分割 成两个子序列,直至分割到只剩一个元素
  • 不断将两个子序列 合并 成一个序列

mergeSort.png

Figure 4: 算法流程

2.5.1.1 merge 细节

因为 merge 不能原地操作,可以 备份 左边数组作为中间存储区域。

2.5.2 代码实现

private E[] leftBackup; // 共用的中间存储区域

// 对 [begin, end) 范围内的元素进行排序
private void sort(int begin, int end) {
    int numberOfElements = end - begin;
    if (numberOfElements < 2) return;

    int mid = (begin + end) / 2;
    sort(begin, mid);
    sort(mid, end);
    merge(begin, mid, end);
}

// 对 [begin, mid) 和 [mid, end) 两部分数据进行归并
private void merge(int begin, int mid, int end) {
    int li = 0, le = mid - begin; // 左边数组(leftBackup)的起始索引和结束索引(不包含)
    int ri = mid, re = end; // 右边数组的起始索引和结束索引(不包含)
    int ai = begin; // 最终数据存放的起始索引

    for (int i = 0; i < le; i++) {
        leftBackup[i] = array[begin + i]; // 对左边元素进行备份
    }

    while (li < le && ri < re) {
        if (compare(leftBackup[li]).le(array[ri])) {
            array[ai++] = leftBackup[li++];
        } else {
            array[ai++] = array[ri++];
        }
    }

    while (li < le) {
        array[ai++] = leftBackup[li++];
    }

}

@Override
protected void sort() {
    leftBackup = (E[]) new Comparable[array.length / 2];
    sort(0, array.length);
}

2.6 快速排序

2.6.1 排序思路

快速排序的基本思想是: 逐渐将每一个元素都转换成轴点元素

  1. 从序列中选择一个轴点元素 (pivot) 假设每次选择 0 位置的元素为轴点元素
  2. 利用 pivot 将序列分割成 2 个子序列
    • 将小于 pivot 的元素放在pivot前面(左侧)
    • 将大于 pivot 的元素放在pivot后面(右侧)
    • 等于pivot的元素放哪边都可以
  3. 对子序列递归地进行上述2步操作,直到不能再分割(子序列中只剩下1个元素)

quicksort.png

Figure 5: 快速排序过程

2.6.2 轴点位置的确定

将 0 位置的元素 备份 ,并作为轴点元素。

轴点最终位置的选择需要先从右往左找,找到以后再从左往右,不断循环这个过程, 直到把所有比轴点元素小的元素放到左半部分,把所有比轴点元素大的元素放到右半部分,假设 begin 表示序列首位置,此过程的逻辑可以描述为:

  • 从右往左扫描,寻找比轴点元素小的元素 找到以后,放到 begin 位置, \(begin++\) ,然后 反向寻找
  • 从左往右扫描,寻找比轴点元素大的元素 找到以后,放到 end 位置, \(end--\) ,然后 反向寻找
  • 当 \(begin == end\) ,说明左右都扫描完了,begin(或 end) 就是轴点的最终位置,此时可以将一开始备份的轴点元素放到此位置

quicksort_pivot.png

Figure 6: 过程的本质是用轴点元素不断的 比较与交换 ,而备份,只是为了 减少交换次数所做的优化

2.6.3 代码实现

@Override
protected void sort() {
    doSort(0, array.length);
}

// [begin, end)
private void doSort(int begin, int end) {
    if ((end - begin) < 2) return;
    int pivotIndex = quickSort(begin, end);
    doSort(begin, pivotIndex);
    doSort(pivotIndex + 1, end);
}

/**
 * search through [begin, end)
 *
 * @return the index where the pivot should be at
 */
private int quickSort(int begin, int end) {
    end--; // 先减一,不然会越界
    E pivot = array[begin];
    while (begin != end) {
        // 右到左
        while (begin != end) {
            if (compare(array[end]).lt(pivot)) {
                array[begin++] = array[end];
                break;
            } else {
                end--;
            }
        }

        // 左到右
        while (begin != end) {
            if (compare(begin).gt(pivot)) {
                array[end--] = array[begin];
                break;
            } else {
                begin++;
            }
        }
    }
    array[begin] = pivot;
    return begin;
}

代码中嵌套在外层 while 内的 2 个内层 while ,这种写法 值得玩味

2.6.4 优化方案(随机交换元素)

在轴点最终确定位置左右元素数量比较均匀的情况下,时间复杂度较好,最好情况可以是 \(O(nlogn)\) 。

但是如果左右元素数量极度不均匀,可能出现最坏情况,这时的复杂度会退化为 \(O(n^2)\) 。

为了降低最坏情况的出现概率,一般采取的做法是: 随机选择轴点元素 ,即在备份轴点元素前,将 begin 位置的元素与序列中的 随机位置元素交换一下

private int quickSort(int begin, int end) {
    ...

    swap(begin, begin + (int) (Math.random() * (end - begin))); // 随机交换

    ...
}

2.7 希尔排序

2.7.1 排序思路

希尔排序把序列看作是一个 矩阵 ,分成 m 列, 逐列 进行排序:

  • n 从某个整数逐渐减为 1
  • 当 m 为1时,整个序列将完全有序

列数取决于步长序列,例如 {1, 8, 16} 代表依次分成 16 列,8 列,1 列,并逐列排序。(不同步长序列的选择,其执行效率也不同)

2.7.1.1 看图举例 1

假设有如下序列: { 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1 } 且步长序列是 { 1, 2, 4, 8 }

shell_0.png

Figure 7: 原始序列

shell1.png

Figure 8: 首先分为 8 列,对每一列进行排序

shell2.png

Figure 9: 然后分为 4 列,对每一列进行排序

shell3.png

Figure 10: 然后分为 2 列,对每一列进行排序

shell4.png

Figure 11: 最后分为 1

2.7.1.2 与插入排序的关系

从上面的例子中可以看出,从 8 列变为 1 列的过程中, 逆序对的数量在逐渐减少

而插入排序有这样一个性质: 插入排序的时间复杂度与逆序对的数量成正比关系 ,逆序对的数量越多,插入排序的时间复杂度越高。

希尔排序底层一般 使用插入排序对每一列进行排序 ,可以将希尔排序看作是对插入排序的改进。

2.7.2 代码实现

protected void sort() {
    List<Integer> steps = shellStepSequence();
    for (Integer step : steps) {
        doSort(step);
    }
}

// 留意数组下标的转换
private void doSort(int step) {
    // 基本插入排序实现(交换法)
    for (int col = 0; col < step; col++) {
        for (int i = col + step; i < array.length; i += step) {
            for (int j = i; j > col; j -= step) {
                if (compare(j - step).gt(j)) {
                    swap(j - step, j);
                } else {
                    break;
                }
            }
        }
    }
}

/**
 * 希尔算法默认使用的步长序列
 */
public List<Integer> shellStepSequence(){
    // return Arrays.asList(500, 250, 100, 50, 10, 1);
    List<Integer> stepSequence = new ArrayList<>();
    int step = 1;
    while (step < array.length) {
        stepSequence.add(0, step);
        step = step << 1;
    }
    return stepSequence;
}

3 基于空间换时间的排序算法

3.1 计数排序

适合对 一定范围内整数 进行排序。

3.1.1 排序思路

统计每个整数在序列中出现的次数,进而推导出每个整数在有序序列中的索引。

  • 首先找出要排序数组的中的最大值 max
  • 根据最大值 max 开辟 max + 1 空间的数组 counts
  • counts 数组统计每个整数出现的次数
  • 根据整数出现的次数,对整数进行排序
    • 遍历 counts 数组,索引index即为元素值,counts[index] 为元素的出现次数
    • 将 counts[index] 个 index 依次放入数组,即为排序过的序列

3.1.2 代码实现(不支持负整数)

protected void sort() {
    Integer max = Stream.of(array).max(java.util.Comparator.naturalOrder()).orElse(Integer.MIN_VALUE);
    int[] counts = new int[max + 1];
    for (Integer i : array) {
        counts[i]++;
    }
    int arrayIndex = 0;
    for (int i = 0; i < counts.length; i++) {
        int count = counts[i];
        if (count == 0) continue;
        for (int j = 0; j < count; j++) {
            array[arrayIndex++] = i;
        }
    }
}

3.1.3 优化方案

利用一个 offset 变量,即可支持负整数,且优化了空间。

protected void sort() {
    int min = Integer.MAX_VALUE;
    int max = Integer.MIN_VALUE;
    for (Integer value : array) {
        min = Math.min(min, value);
        max = Math.max(max, value);
    }
    int offset = min < 0 ? -min : 0;
    int length = (max - min + 2);
    int[] counts = new int[length];
    for (Integer i : array) {
        counts[i + offset]++;
    }
    int arrayIndex = 0;
    for (int i = 0; i < counts.length; i++) {
        int count = counts[i];
        if (count == 0) continue;
        for (int j = 0; j < count; j++) {
            array[arrayIndex++] = i - offset;
        }
    }
}

3.1.4 优化方案(稳定排序)

利用一个累计计数数组,该数组的值即为值(索引)应该放置的位置。

protected void sort() {
    int min = Integer.MAX_VALUE;
    int max = Integer.MIN_VALUE;
    for (Integer value : array) {
        min = Math.min(min, value);
        max = Math.max(max, value);
    }
    int offset = min < 0 ? -min : 0;
    int length = (max - min + 2);
    int[] counts = new int[length];
    for (Integer i : array) {
        counts[i + offset]++;
    }
    int[] aggCounts = new int[counts.length];
    int total = 0;
    for (int i = 0; i < counts.length; i++) {
        int count = counts[i];
        total += count;
        aggCounts[i] = total;
    }
    Integer[] newArray = new Integer[array.length];
    for (Integer i : array) {
        newArray[(aggCounts[i + offset] - (counts[i + offset]--))] = i;
    }
    for (int i = 0; i < array.length; i++) {
        array[i] = newArray[i];
    }
}

3.2 基数排序

基数排序通常用于整数排序(尤其是非负整数),排序思路是依次对个位数、十位数、百位数…进行排序(从低位到高位)。

private int offset;

private int radix(int value, int bit) {
    int r = 1;
    bit--;
    while (bit-- > 0) {
        r *= 10;
    }
    return (value / r) % 10 ;
}


private void countingSort(int bit) {
    int length = 10;
    int[] counts = new int[length];
    for (Integer i : array) {
        counts[radix(i + offset, bit)]++;
    }
    int[] aggCounts = new int[counts.length];
    int total = 0;
    for (int i = 0; i < counts.length; i++) {
        int count = counts[i];
        total += count;
        aggCounts[i] = total;
    }
    Integer[] newArray = new Integer[array.length];
    for (Integer i : array) {
        int radix = radix(i + offset, bit);
        newArray[(aggCounts[radix] - (counts[radix]--))] = i;
    }
    for (int i = 0; i < array.length; i++) {
        array[i] = newArray[i];
    }
}

@Override
protected void sort() {
    int min = Integer.MAX_VALUE;
    int max = Integer.MIN_VALUE;
    for (Integer value : array) {
        min = Math.min(min, value);
        max = Math.max(max, value);
    }
    offset = min < 0 ? -min : 0;
    int bits = String.valueOf(max + offset).length();
    for (int bit = 1; bit <= bits; bit++)
        countingSort(bit);

}

3.3 桶排序

排序思路:

  1. 创建一定数量的桶(比如用数组、链表作为桶)
  2. 按照一定的规则(不同类型的数据,规则不同),将序列中的元素均匀分配到对应的桶
  3. 分别对每个桶进行单独排序
  4. 将所有非空桶的元素合并成有序序列

4 性能对比

【CountingSortV3】
 稳定:✓        耗时:0.002s(2ms)        比较:0        交换:0
------------------------------------------------------------------
【CountingSortV2】
 稳定:✗        耗时:0.003s(3ms)        比较:0        交换:0
------------------------------------------------------------------
【MergeSort】
 稳定:✓        耗时:0.006s(6ms)        比较:12.05万        交换:0
------------------------------------------------------------------
【QuickSortV2】
 稳定:✗        耗时:0.006s(6ms)        比较:16.73万        交换:6821
------------------------------------------------------------------
【RadixSort】
 稳定:✓        耗时:0.007s(7ms)        比较:0        交换:0
------------------------------------------------------------------
【QuickSort】
 稳定:✗        耗时:0.007s(7ms)        比较:15.60万        交换:0
------------------------------------------------------------------
【HeapSort】
 稳定:✗        耗时:0.009s(9ms)        比较:23.69万        交换:12.42万
------------------------------------------------------------------
【ShellSortV2】
 稳定:✗        耗时:0.011s(11ms)        比较:19.56万        交换:10.73万
------------------------------------------------------------------
【ShellSort】
 稳定:✗        耗时:0.02s(20ms)        比较:64.63万        交换:52.72万
------------------------------------------------------------------
【InsertionSortV3】
 稳定:✓        耗时:0.074s(74ms)        比较:11.90万        交换:0
------------------------------------------------------------------
【InsertionSortV2】
 稳定:✓        耗时:0.143s(143ms)        比较:2510.51万        交换:0
------------------------------------------------------------------
【SelectionSort】
 稳定:✓        耗时:0.147s(147ms)        比较:5000.50万        交换:9999
------------------------------------------------------------------
【InsertionSort】
 稳定:✓        耗时:0.204s(204ms)        比较:2510.51万        交换:2509.51万
------------------------------------------------------------------
【BubbleSort】
 稳定:✓        耗时:0.626s(626ms)        比较:4999.50万        交换:2509.51万
------------------------------------------------------------------
【BubbleSortV3】
 稳定:✓        耗时:0.642s(642ms)        比较:4995.97万        交换:2509.51万
------------------------------------------------------------------
【BubbleSortV2】
 稳定:✓        耗时:0.702s(702ms)        比较:4999.01万        交换:2509.51万
------------------------------------------------------------------

Footnotes:

Author: Hao Ruan (ruanhao1116@gmail.com)

Created: 2021-03-13 Sat 23:10

Updated: 2021-08-17 Tue 11:23

Emacs 27.1 (Org mode 9.3)