排序算法性能比较

1,434 阅读13分钟

排序作为算法最基础的一部分,但是还是有部分程序员连手写冒泡排序都比较困难,包括我 :joy:,看来在我们有空的时候还是很有必要复习一下排序算法哟, 要理解各大排序算法,一定要自己动手画一画,这样才能更好的帮助自己捋清整个排序思路

但是到底哪种排序算法更快呢,请往下面看,当然,你也可以直接看最下面的结果

由于本人水平有限,有疏漏或不正确的地方,还请指正

暴力排序

嗯,这是最简单的排序了,不需要任何解释,你也能理解,时间复杂度为O(n^2),空间复杂度O(1)

[8 4 5 7 1 3 6 2]
1 [8 4 7 5 3 6 2]
1 2 [8 7 5 4 6 3]
1 2 3 [8 7 5 6 4]
1 2 3 4 [8 7 6 5]
1 2 3 4 5 [8 7 6]
1 2 3 4 5 6 [8 7]
1 2 3 4 5 6 7 [8]
// 暴力排序
for (int i = 0; i < data.length - 1; i++) {
    for (int j = i + 1; j < data.length; j++) {
        // 比较并进行交换
        if (data[i] > data[j]) {
            ArrayUtil.swap(data, i, j);
        }
    }
}

冒泡排序

这也是最简单的排序算法之一了,其思想是通过与相邻元素的比较将较小(大)值交换到最后面,时间复杂度O(n^2),空间复杂度O(1)

数组: 8 4 5 7 1 3 6 2
第一轮:[4 5 7 1 3 6 2] 8
第二轮:[4 5 1 3 6 2] 7 8
第三轮:[4 1 3 5 2] 6 7 8
第四轮:[1 3 4 2] 5 6 7 8
第五轮:[1 3 2] 4 5 6 7 8
第六轮:[1 2] 3 4 5 6 7 8
第七轮:[1] 2 3 4 5 6 7 8
// 需要n-1趟遍历
for (int i = 1; i < data.length; i++) {
    // 将最值依次往后挪
    for (int j = 0; j < data.length - i; j++) {
        if (data[j] > data[j + 1]) {
            ArrayUtil.swap(data, j, j + 1);
        }
    }
}

插入排序

从索引位置为1的元素开始,对前2个元素进行排序,索引变为2,对前3个元素进行排序,以此类推,直至排序完成,时间复杂度O(n^2),空间复杂度O(1)

数组: 8 4 5 7 1 3 6 2
从i=1开始:[4 8] 5 7 1 3 6 2
第二轮i=2:[4 5 8] 7 1 3 6 2
第三轮i=3:[4 5 7 8] 1 3 6 2
第四轮i=4:[1 4 5 7 8] 3 6 2
第五轮i=5:[1 3 4 5 7 8] 6 2
第六轮i=6:[1 3 4 5 6 7 8] 2
第七轮i=7:[1 2 3 4 5 6 7 8]

参考:bubkoo.com/2014/01/14/…

// 依次对前i+1个元素进行排序
for (int i = 1; i < data.length; i++) {
    int curr = data[i];
    int j = i - 1;
    // 将第i个元素插入到正确的位置
    while (j >= 0 && data[j] > curr) {
        data[j + 1] = data[j--];
    }
    data[j + 1] = curr;
}

思考一下,还可以优化吗?当然,我们还可以实现一个基于二分查找的插入排序

快速排序

我们需要一个基准数(就是一个参考数,可以从数组中随便选一个)作为参考,将比基准数大的放到基准数的右侧,比基准数小的放到基准数的左侧, 为了完成这项工作,我们还需要两个哨兵ij,来对数组进行探测,哨兵jlength-1的位置最先出发,直到找到一个小于基准数的元素停止, 同理,哨兵i从位置0出发,直到遇到大于基准的元素停止,然后对ij处的元素进行交换,j又率先出发,继续探测,直到i>=j,并将i处的元素与基准数进行交换, 并终止这一轮的探测

下一轮将分别对基准数的左侧和右侧进行一次快速排序,重复上述过程,直至排序完成,时间复杂度O(nlog2n),空间复杂度O(nlog2n)

数组:5 4 6 7 3 1 2 8
以5为基准数排序之后的结果:3 4 2 1 [5] 7 6 8
5的左侧以3为基准数,右侧以7作为基准数排序之后的结果:2 1 [3] 4 [5] 6 [7] 8
继续以新的基准数排序,直到无法派生新的基准数:1 [2] [3] [4] [6] [7] [8]

参考:wiki.jikexueyuan.com/project/eas…

private void quickSort() {
    quickSortHelper(data, 0, data.length - 1);
}

private void quickSortHelper(int[] data, int start, int end) {
    if (start < end) {
        // 从左边开始还探测的哨兵
        int i = start;
        // 从右边开始探测的哨兵
        int j = end;
        // 基准数
        int base = data[i];
        while (i < j) {
            // 找到小于基准数的索引
            while (j > i && data[j] >= base) {
                j--;
            }
            // 找到大于基准数的索引
            while (j > i && data[i] <= base) {
                i++;
            }
            if (i < j) {
                // 交换两个哨兵处的元素
                ArrayUtil.swap(data, i, j);
            } else if (i == j) {
                // 交换基准数与哨兵处的元素(两个哨兵一定会相遇)
                ArrayUtil.swap(data, start, i);
            }
        }
        // 对基准数左侧的序列进行快速排序
        quickSortHelper(data, start, j - 1);
        // 对基准数右侧的序列进行快速排序
        quickSortHelper(data, i + 1, end);
    }
}

选择排序

这是一种非常直观的排序算法,其工作原理是在整个未排序序列中找到最小(大)值,并与这个未排序序列的第一元素进行交换,这样第一个元素就已经排序了, 接下来对索引位置1开始的未排序序列进行排序,以此类推

其主要优点是数据移动次数较少,时间复杂度O(n^2),空间复杂度O(1)

有数组:[5 4 6 7 3 1 2 8]

排序过程如下:
1 [4 6 7 3 5 2 8]
1 2 [6 7 3 5 4 8]
1 2 3 [7 6 5 4 8]
1 2 3 4 [6 5 7 8]
1 2 3 4 5 [6 7 8]
1 2 3 4 5 6 [7 8]
1 2 3 4 5 6 7 [8]

参考:bubkoo.com/2014/01/13/…

// 从未排序序列中找到最值并交换到序列中的最前面
for (int i = 0; i < data.length - 1; i++) {
    // 未排序序列的起始索引
    int lowIdx = i;
    // 在当前序列中找到最小值索引
    for (int j = i + 1; j < data.length; j++) {
        if (data[j] < data[lowIdx]) {
            lowIdx = j;
        }
    }
    if (lowIdx != i) {
        // 将最小值交换当前序列的最前面
        ArrayUtil.swap(data, i, lowIdx);
    }
}

希尔排序

希尔排序是一个名叫希尔的人发明的一种排序算法,其实质就是一个分组的插入排序,是插入排序的高效率实现,其思想是按数组下标的一定增量gap进行分组, 对每组进行插入排序,随着增量的减少,直到增量等于零,整个排序完成,又称缩小增量排序,时间复杂度O(n^1.3),空间复杂度O(1)

数组:5 4 6 7 3 1 2 8

相同符号的表示一组,对同一组进行插入排序:
gap=4: (5) [4] {6} <7> (3) [1] {2} <8>   ==>   (3) [1] {2} <7> (5) [4] {6} <8>

gap=3: (3) [1] {2} (7) [5] {4} (6) [8]   ==>   (3) [1] {2} (6) [5] {4} (7) [8]

gap=2: {3} [1] {2} [6] {5} [4] {7} [8]   ==>   {2} [1] {3} [4] {5} [6] {7} [8]

gap=1: [2] [1] [3] [4] [5] [6] [7] [8]   ==>   [1] [2] [3] [4] [5] [6] [7] [8]

参考:www.cnblogs.com/chengxiao/p…

// 按数组下标增量分组
for (int gap = data.length / 2; gap > 0; gap /= 2) {
    // 从增量的索引位置开始进行插入排序
    for (int i = gap; i < data.length; i++) {
        int curr = data[i];
        int j = i - gap;
        // 将i处的元素插入到正确的位置
        while (j >= 0 && data[j] > curr) {
            data[j + gap] = data[j];
            j -= gap;
        }
        data[j + gap] = curr;
    }
}

归并排序

归并排序采用了经典的分治策略,将大问题拆分成多个小问题逐个求解,比如这里的归并排序,将一个数组拆分两个序列,再分别将这两个序列拆分成两个序列, 直到序列长度为1,然后依次向上对这两个序列进行合并排序,这样每次我们合并的都是两个有序的序列,时间复杂度O(nlog2n), 空间复杂度O(n)

比如数组:[8 4 5 7 1 3 6 2]
拆分:[[8 4 5 7] [1 3 6 2]]
再拆分:[[[8 4] [5 7]] [[1 3] [6 2]]]
再拆分,直到长度等于一:[[[[8] [4]] [[5] [7]]] [[[1] [3]] [[6] [2]]]]

合并排序:[[[4 8] [5 7]] [[1 3] [2 6]]]
再向上合并排序:[[4 5 7 8] [1 2 3 6]]
再向上合并,直到合并后序列长度等于原数组长度:[1 2 3 4 5 6 7 8]

参考:www.cnblogs.com/chengxiao/p…

示例代码如下:

// 组大小,从1开始,以2的倍数增长
int groupSize;
// 将两个组合并后的最大大小:groupSize*2
int mergedSize = 1;
while (mergedSize <= data.length) {
    groupSize = mergedSize;
    mergedSize <<= 1;
    // 对mergedSize大小内的两个分组进行有序合并
    for (int j = 0; j < data.length; j += mergedSize) {
        // 创建一个合法的临时工作数组
        int diff = data.length - j;
        int[] temp = new int[diff < mergedSize ? diff : mergedSize];
        // 第一个组的起始位置
        int left = j;
        // 第一个组的截止位置
        int maxLeft = j + groupSize;
        // 第二个组的起始位置
        int right = maxLeft;
        // 第二个组的截止位置
        int maxRight = j + temp.length;
        // 有序的合并两个有序分组
        for (int k = 0; k < temp.length; k++) {
            if (right >= maxRight || (left < maxLeft && data[right] > data[left])) {
                temp[k] = data[left++];
            } else {
                temp[k] = data[right++];
            }
        }
        // 将工作数组拷贝到原数组
        System.arraycopy(temp, 0, data, j, temp.length);
    }
}

堆排序

这种算法稍复杂一些,首先你需要了解堆结构,它是一颗近似完全二叉树的数据结构,并且需要将它调整成大顶堆或小顶堆,也就是说父节点总是大于(小于)或等于任何一个子节点, 堆化后,将堆顶元素与堆最后一个元素进行交换,堆的最后一个元素将不再参与下一轮的堆化,重复堆化和交换的过程,直到堆的大小等于1,整个堆排序完成

所以堆排序的重点其实是如何调整最大(小)堆,如果用数组表示堆的话,父节点为i的节点,其子节点分别为2*i+12*i+2,从n/2的父节点开始, 对其子节点进行比较,并调整成最大(小)堆,再对n/2-1的父节点包括其子树进行调整,最后对0的父节点也就是整颗树进行调整,整个堆化完成,时间复杂度O(nlog2n), 空间复杂度O(1)

数组:8 4 5 7 1 3 6 2

数组堆化后:
       8
     /   \
    4    [5] (i=2)
   / \   / \
  7   1 3   6
 /
2

从i=4开始调整,发现没有子节点,i=3时是一颗合法的大顶堆,i=2,调整如下:
       8
     /   \
i=1[4]    6
   / \   / \
  7   1 3   5
 /
2

i=1时调整如下,直到i=0,大顶堆调整完成
       8
     /   \
    7     6
   / \   / \
  4   1 3   5
 /
2

此时数组变成了:[8 7 6 4 1 3 5 2]
将堆顶元素与最后一个元素交换,并将最后一个元素从堆中删除(不是真的删除,只是不参与堆化了):[2 7 6 4 1 3 5] 8

堆变成了:

     2                                 7
   /   \                             /   \
  7     6     交换后对新堆进行堆化    4     6
 / \   / \                         / \   / \
4   1 3   5                       2   1 3   5

此时数组变成了:[7 4 6 2 1 3 5] 8
将堆顶与堆最后一个元素交换:[5 4 6 2 1 3] 7 8,交换后堆变成了:

     5                                 6
   /   \                             /   \
  4     6          ===>             4     5
 / \   /                           / \   /
2   1 3                           2   1 3

堆化后数组:[6 4 5 2 1 3] 7 8,交换:[3 4 5 2 1] 6 7 8

     3                                 5
   /   \                             /   \
  4     5          ===>             4     3
 / \                               / \
2   1                             2   1

堆化后数组:[5 4 3 2 1] 6 7 8,交换:[1 4 3 2] 5 6 7 8

    1                               4
   / \                             / \
  4   3          ===>             2   3
 /                               /
2                               1

堆化后数组:[4 2 3 1] 5 6 7 8,交换:[1 2 3] 4 5 6 7 8

  1                           3
 / \                         / \
2   3          ===>         2   1
  
堆化后数组:[3 2 1] 4 5 6 7 8,交换:[1 2] 3 4 5 6 7 8

  1                          2
 /                          /
2             ===>         1

堆化后数组:[2 1] 4 5 6 7 8,交换:[1] 2 3 4 5 6 7 8

当堆中只有一个元素时,排序完成

参考:www.cnblogs.com/chengxiao/p…

private void heapSort() {
    // 将待排序的序列构建成一个大顶堆
    for (int i = data.length / 2; i >= 0; i--) {
        heapSortHelper(data, i, data.length);
    }
    // 逐步将堆顶元素与末尾元素交换,并且再次调整二叉树,使其成为大顶堆
    for (int i = data.length - 1; i > 0; i--) {
        // 将堆顶记录和当前未排序序列的最后一个记录交换
        ArrayUtil.swap(data, 0, i);
        // 交换之后,需要重新检查堆是否符合大顶堆,不符合则要调整
        heapSortHelper(data, 0, i);
    }
}

/**
 * 堆化节点
 */
private void heapSortHelper(int[] data, int i, int n) {
    int child;
    int father;
    for (father = data[i]; 2 * i + 1 < n; i = child) {
        child = 2 * i + 1;
        // 如果左子树小于右子树,则需要比较右子树和父节点
        if (child != n - 1 && data[child] < data[child + 1]) {
            // 指向右子树
            child++;
        }
        // 如果父节点小于孩子结点,则需要交换
        if (father < data[child]) {
            data[i] = data[child];
        } else {
            // 大顶堆结构未被破坏,不需要调整
            break;
        }
    }
    data[i] = father;
}

关于交换

一般来说我们会使用一个额外的空间来对数组两个索引位置的元素进行值交换,如下:

private void swap(int[] data, int i, int j) {
    int tmp = data[i];
    data[i] = data[j];
    data[j] = tmp;
}

但是,如果不允许使用额外的空间又如何实现呢?思考一下,不要着急看下面的代码

private void swap(int[] data, int i, int j) {
    data[i] = data[i] + data[j];
    data[j] = data[i] - data[j];
    data[i] = data[i] - data[j];
}

当然这种方案也有个缺点,当整数足够大时,它可能会导致整数溢出

知识延伸:为什么说Java只有值传递

性能比较

在学习了这些常用排序算法之后,下面我们来看看各个排序算法在不同数据量的表现吧

算法名称 一千数据量 一万数据量 十万数据量 百万数据量
暴力排序 52ms 239ms 37883ms 2639.261s
冒泡排序 11ms 177ms 18665ms 1430.342s
插入排序 5ms 30ms 1430ms 84.078s
快速排序 1ms 4ms 27ms 114ms
选择排序 11ms 74ms 5080ms 398.866s
希尔排序 1ms 9ms 22ms 191ms
归并排序 4ms 9ms 41ms 173ms
堆排序 3ms 8ms 16ms 115ms

对上述运行时长进行排序:

一千数据量:
快速排序 > 希尔排序 > 堆排序 > 归并排序 > 插入排序 > 选择排序 > 冒泡排序 > 暴力排序

一万数据量:
快速排序 > 堆排序 > 希尔排序 > 归并排序 > 插入排序 > 选择排序 > 冒泡排序 > 暴力排序

十万数据量:
堆排序 > 希尔排序 > 快速排序 > 归并排序 > 插入排序 > 选择排序 > 冒泡排序 > 暴力排序

百万数据量:
快速排序 > 堆排序 > 归并排序 > 希尔排序 > 插入排序 > 选择排序 > 冒泡排序 > 暴力排序

由于运行环境和数据的不同,运行时长可能会出现较大差异

通常来说,快速排序在数据量较小时,表现得最优秀,而在数据量较大时堆排序表现得更优秀,平均来说希尔排序会比归并排序快速排序快一点, 当然这些结论都不完全准确,因为每种算法都存在最优和最坏的情况,但是后面四种排序算法的排名应该是不会出现变动的,由此可见,这些排序算法之间性能差异还是很大的

完整源代码参考

总结

像冒泡这种简单的排序一定要能够手写出来,个人觉得除堆排序外,其他的排序算法,都还好理解,主要是要动手画一画整个排序流程,理解整个排序思想, 捋清自己的思路,尽管堆排序比较困难一些,但是最好这些排序算法都能够用自己的代码实现出来

资料参考:www.cnblogs.com/onepixel/ar…