排序算法大乱斗

排序是算法当中非常基础又关键的算法,也是很多数据操作都需要在排序的基础上进行。这篇文章把常见的排序算法进行了整理,介绍了每一种算法的实现思路、具体程序、复杂度和效率分析,基本通过这篇文章你就可以学习到所有你需要学习的排序算法了,哈哈,是不是有种收获满满的感觉!

本文将介绍以下六种排序:
– 选择排序
– 插入排序
– 希尔排序
– 并归排序
– 快速排序
– 优先队列-堆排序

一 选择排序

1 原理

选择排序的实现原理就像他的名字一样,首先在数组中找到最小的那个元素,其次让他和数组第一个元素交换位置,然后在剩下的元素中找到最小的元素跟数组第二个元素交换位置,如此反复直到数组最后一个元素。

简单地说就是:不停寻找最小的那个元素。

2 实现

    public void sort(int[] n) {
        for(int i=0; i<n.length; i++) {
            int min = i;
            for(int j=i+1; j<n.length; j++) {
                min = n[i] > n[j] ? j : i;
            }
            int tmp = n[min];
            n[min] = n[i];
            n[i] = tmp;
        }
    }

    // 交换数组元素,后面几个算法也用到这个函数,就不重复了
    public void exch(int[] n, int a, int b) {
        int tmp = n[a];
        n[a] = n[b];
        n[b] = tmp;
    }

这些算法的实现都是我自己写的,并且运行可用。可能小的细节上会跟相同算法其他的代码有一点出入,但是原理都是不变的。

3 复杂度分析

空间复杂度:选择排序在运行过程中只生成了两个临时变量min和tmp,所以空间复杂度 o(1)

时间复杂度:对于第一个元素,他需要和剩下n-1个元素进行比较,然后进行1次交换。第二个元素需要进行n-2次比较和1次交换,以此类推,递减数列求和一共需要(n-1)+(n-2)+(n-3)+···+1 = (n-1+1)(n-1)/2 次比较与n-1次交换,所以整个的时间复杂度就是两个相加:(n+2)(n-1)/2也就是O(n^2)

二 插入排序

1 原理

插入排序的实现方式和他的名字也很一致。首先取出数组中的第一个元素并把他放入数组第一个位置(其实就是什么操作都没做= =。),把这一个的元素组成的子数组作为有序子数组。然后依次从后续数组中取出一个元素,把他插入前面的有序子数组中的有序位置。

《排序算法大乱斗》

很容易理解,插入之前要进行多次比较,插入后需要将有序子数组后面的元素依次移动。

2 实现

    public void sort(int[] n) {
        for(int i=0; i<n.length; i++) {
            for(int j=0; j<i; j++) {
                if(n[i] > n[j]) {
                    exch(n, i ,j);
                }
            }
        }
    }

3 复杂度分析

空间复杂度:插入排序除了进行交换时会生成一个临时变量以外不需要其他空间,所以空间复杂度就是o(1)

时间复杂度:根据插入排序的原理,我们可以感受到当原数组中部分有序数组的多少会非常影响大,举个极限的例子,对于完全递增排列的数组只需要进行n-1次比较就能完成排序,对于完全倒序的数组则需要(n-1+1)(n-1)/2次比较和交换。对于概率上随机分布的数组,整体的时间复杂度是O(n^2)

插入排序对于部分有序的数组十分高效,也很适合小规模数组。

三 希尔排序

1 原理

前面两种排序方式都属于初级排序,他们方法简单暴力直接,付出的代价就是n^2级别的时间复杂度。初级排序进行排序时,每个元素只能跟自己的邻居进行比较交换,也就只能一点一点从自己的位置移动到正确的位置,这是初级排序效率慢的主要原因。

希尔排序就是在插入排序基础上设计的快速排序方法。他的思想是使数组中任意间隔为h的元素都是有序的。这样的数组被称为h有序数组,也就是说,一个h有序数组就是h个互相独立的有序数组叠加在一起组成的大数组。

下图就是一个h=4的例子。以他为例,先分成h=4个子数组,分别对子数组进行插入排序;对于整个大数组,就产生了部分有序的特质;然后将h=1,也就是整个数组,再进行插入排序。所以他的性能要优于插入排序

《排序算法大乱斗》

2 实现


    public void sort3(int[] n) {
        int h = 1;
        int len = n.length;
        // 确定最大的h
        while (h < len/3)
            h = h*3 + 1;

        while(h >= 1) {
            // 通过插入排序,将数组变为h有序
            for(int i=0; i<h; i++) {
                for(int j=i; j<len; j += h) {
                    if(n[i] > n[j]) {
                        exch(n, i, j);
                    }
                }
            }
        }
    }

下面这张图就是上面算法的实现过程
《排序算法大乱斗》

3 复杂度分析

可能你看到这里,还是挺期待希尔排序的复杂度究竟是如何分析的。因为你可能对希尔排序有种大概明白,又有点摸不着头脑他究竟怎么样,有序的子数组对后续更大的子数组的排序到底有多少的性能帮助。那下面这一段就要好好看了。

希尔排序更高效的原因在于他权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这都有利于使用插入排序。其实在数学层面透彻理解希尔排序的性能至今仍然是一项挑战。实际上希尔排序是我们唯一无法准确描述其对于乱序数组的性能特征的排序方法。所以放心吧,不仅是你想不明白,研究排序算法的数学家也不是特别明白,你只要知道原理,会用就好啦。

如果你非要一个复杂度的数学表达,首先空间复杂度肯定是O(1),时间复杂度那就可能是在NlogN或者N的6/5次方这么个量级(算法书上写的)。

四 归并排序

1 原理

在介绍归并排序之前,我们要先介绍以下什么是归并:即将两个有序的数组归并成一个更大的有序数组。

归并排序就是先把整个数组分成两半,每一半在递归进行归并排序,然后将两个有序数组归并。

归并排序可以保证对于任意长度为N的数组进行排序,所需时间和NlogN成正比,代价就是需要和N成正比的额外空间。

2 实现


    public void sort(int[] n) {
        int[] m = new int[n.length];
        sortCombine(n, m, 0, n.length-1);
    }

    public void sortCombine(int[] n, int[] m, int begin, int end) {
        if(begin >= end) return;
        int mid = begin + (begin + end)/2 + 1; // 将mid放入到第二个数组中
        sortCombine(n, m, begin, mid-1);
        sortCombine(n, m, mid, end);
        merge(n, m, begin, mid, end);
    }

    // 合并两个子数组,n为原数组,m为额外使用的空间
    public void merge(int[] n, int[] m, int begin, int mid, int end) {
        int i = begin, j = mid; // 两个索引
        for(int k=begin; k<=end; k++)
            m[k] = n[k];

        for(int k=begin; k<=end; k++) {
            if(i >= mid)
                n[k] = m[j++];
            else if(j > end)
                n[k] = m[i++];
            else if(m[i] < m[j])
                n[k] = m[i];
            else
                n[k] = m[j];
        }
    }

上面的这个实现是通过自上而下的方式进行递归排序,同样也可以通过自下而上的方式进行归并排序。自下而上就是先把数组分成两个一对,然后进行归并,归并后得到的所有子数组都是排序数组,然后再四个一组进行归并,一直归并下去。这种自下而上方法建议自己写一写,有利于理解归并排序。

3 复杂度分析

空间复杂度:排序过程中需要辅助数组m[],他的大小跟n一样,所以空间复杂度为O(n)
时间复杂度:通过归并的原理可以知道,每次递归时都会把整个数组分成两个大小平衡(长度差不超过1)子数组,这有种二分法的感觉。而对于每次归并操作,当子数组长度为n/2时,需要进行n次比较和赋值即可。整体下来时间复杂度就是O(NlogN)

五 快速排序

1 原理

快速排序就是我们常说的“快排”,他可能是应用最广泛的排序算法。他流行的原因在于他实现简单,适用于各种不同的输入数组。同时他是原地排序,只需要很小的辅助栈和NlogN的排序时间。

他的排序原理:首先取出数组的第一个元素作为切分元素,将后续所有元素与这个切分元素比较,原数组变成两个子数组(分别小于/大于切分元素)+一个切分元素。然后递归对两个子数组进行排序。快排是通过不停的切分

2 实现


    public void sort5(int[] n) {
        quickSort(n, 0, n.length-1);
    }

    public void quickSort(int[] n, int begin, int end) {
        if(begin >= end) return;
        // 切分元素
        int tmp = n[begin];
        int i=begin+1,j=end;
        // 进行切分
        for(; i<=j; i++) {
            if(n[i] <= tmp)
                exch(n, i, i-1);
            else
                exch(n, i--, j--);
        }
        quickSort(n, begin, j-1);
        quickSort(n, j+1, end);
    }

在对数组进行切分时,可以发现,每次都是比较i,j两个位置的元素,然后决定是否进行交换。对于长度为len的数组,只需要进行len/2次比较和不多于len/2次的交换。

3 复杂度分析

就像上面说的快排的优势在于他的比较次数比较少。

空间复杂度:每次进行切分时都左右指针i,j和切分元素tmp,平均来算总共进行logN次切分,所以空间复杂度O(logN)

时间复杂度:我们每次都是把数组第一个元素作为切分元素,所以在切分后不确定得到的两个子数组是否大小均衡。最好的情况就是每次都能将数组对半分,这时候的时间复杂度就是O(NlogN),通过数学概率的保证,快排的时间复杂度也是O(NlogN)

六 优先队列-堆排序

优先队列是一种数据结构,他能够支持两种操作:删除最大元素 和 插入元素

我们这里介绍通过堆排序实现优先队列,首先要介绍下二叉堆

1 二叉堆

堆有序:一棵二叉树内每个节点的值都大于等于它的子节点

二叉堆:一组能够用堆有序的完全二叉树排序的元素,并在数组中按照层级储存(不使用数组的第一个位置)

二叉堆简单的说就是通过数组存储的堆有序的完全二叉树。

对于数组中位置k(不算数组第一个位置)的节点,他的父节点就是k/2,他的左右子节点就是2*k和2*k+1

2 堆的算法

(1)下沉-自上而下有序化

当堆中某个元素发生的改变,破坏了堆的有序性时,可以通过下沉操作恢复堆有序。下沉就是这个元素跟2*k和2*k+1这两子节点中较大的那个节点比大小,如果大于那就交换,如果不大于那就结束。

(2)上浮-自下而上有序化

上浮就是不停跟父节点k/2比大小,大于父节点就交换,直到最后。

(3)插入新元素

将数组空间加一,然后新元素插入数组最后的位置,然后对他进行上浮操作

(4)删除最大元素

将数组最后一个元素赋值给数组第一个元素(也就是最大的那个元素),然后数组大小减一,对第一个元素进行下沉操作。

(5)基于堆的优先队列

    private int[] n;          // 存储二叉堆的数组 
    private int size = 0;     // 二叉堆大小

    // 下沉
    public void sink(int k) {
        while(2*k <= size) {
            int j = 2*k;
            if(j+1 <= size && n[j] < n[j + 1])
                j++;
            if(n[k] >= n[j])
                break;;
            exch(n , k, j);
            k = j;
        }
    }

    // 上浮
    public void swim(int k) {
        while(k/2 > 0 && n[k] > n[k/2]) {
            exch(n, k, k/2);
            k /= 2;
        }
    }

    // 插入元素
    public void insert(int m) {
        n[++size] = m;
        swim(size);
    }

    // 删除最大元素
    public void deleteMax() {
        n[1] = n[size--];
        sink(1);
    }

3 堆排序

堆排序分为两个步骤: 堆的构造 + 下沉排序

(1)堆的构造

从数组的中间位置(最有一个有子结点的位置)开始,依次对其进行下沉操作,并循环减一。

(2)下沉排序

每次删除堆中的最大元素,然后将该最大元素放到堆缩小后腾出来的数组位置,直到最后。


    public void sort(int[] n) {
        int len = n.length;
        // 堆的构造
        for(int k=len/2; k>=1; k--)
            sink(n, k, len);

        // 选出最大值,并下沉排序
        while(len > 1) {
            exch(n, 1, len--);
            sink(n, 1, len);
        }
    }

    public void sink(int[] n, int k, int len) {
        while(2*k <= len) {
            int j = 2*k;
            if(j+1 <= len && n[j] < n[j+1])
                j++;
            if(n[k] >= n[j])
                break;
            exch(n, k, j);
            k = j;
        }
    }

4 复杂度分析

空间复杂度:从实现代码可以看到,只是使用两个额外的变量。所以空间复杂度O(1)

时间复杂度:每次下沉操作中交换元素的次数不超过logN,所以整体的时间复杂度为O(NlogN)

堆排序是唯一能够同时最优的利用空间和时间的方法,即使在最坏的情况下也能保持使用~2NlogN次比较和恒定额外空间。

但是堆排序的使用却不是非常广泛,这是因为他无法很好地利用缓存,他总是访问2*k或者k/2,这使得缓存未命中的次数要大于归并、快排这种相邻元素比较的算法。

七 总结

在选择使用哪种算法时,需要根据具体情况进行选择。快排是这几种排序中使用次数最多,相对最稳定的一种排序算法。

下面的表格整理了六种算法效率总结。
《排序算法大乱斗》

点赞