死磕算法第三弹——排序算法(1)

本文整理来源 《轻松学算法——互联网算法面试宝典》/赵烨 编著

算法基础

对于算法性能分析来说,除了时间复杂度,还是有空间复杂度、稳定性等指标。而我们平时说的算法的复杂度可以分为两个部分:时间复杂度和空间复杂度。

时间复杂度

在科学计算中,算法的时间复杂度是一个函数,它定量地描述了一个算法的运行时间。时间复杂度通常一个大$O$符号来表示,不包括这个函数低阶项和首项系数。

时间复杂度是渐近的,考虑的是这个值趋近于无穷时的情况。比如一个算法的执行时间为$3{ n }^{ 2 } + 2n + 3$,这里我们用大$O$符号来表示时,不考虑低阶项,也就是只考虑最高阶项$3{ n }^{ 2 }$,也不考虑首项的系数,所以我们会直接将这个算法的时间复杂度表示为$O({n}^2)$

我们在计算一个算法的时间复杂度时,需要考虑算法那是否会有更多重嵌套循环(即代码中包含的循环内部还有一个循环操作),因为嵌套循环势必会使时间复杂度升阶。而对于一个个列表进行循环有限次数的操作,则无需考虑,因为我们会忽略首项的系数。

我们在计算一个算法的时间复杂度时,首先需要找到一个算法的核心部分,然后根据代码确认时间复杂度。

一般的时间复杂度按照性能从差到好有这么几种:$O({n}^3)$$O({n}^2)$$O(nlogn)$$O(n)$$O(logn)$$O(1)$。当然,性能差的情况可能还有更高的幂数,但是当算法的时间复杂度达到$O({n}^2)$以上时,性能就会相当差,我们应该寻找更优的方案。当然,对于某些特殊的宣发,可能最优的性能也不会很好。

另外,$O(nlogn)$$O(logn)$内部的内容在数学里是错误的,一般应该是$O(log_{2}{n})$等,但是这里的系数并不在我们的考虑返回范围内,所以我们一般在计算复杂度时直接将其表示成$O(nlogn)$$O(logn)$

for(int i = 0; i < n; i ++){
    //some code here
    for(int j = 0; j < n; j ++){
        //some code here
        for(int k = 0; k < n; k++){
            //some code here
        }
    }
}

这段代码是个三重嵌套循环代码,n一般指算法的规模,很容易推断出这段代码的时间复杂度是$O({n}^3)$

如果是两重的嵌套循环,那么时间复杂度是$O({n}^2)$;如果只有一重循环,那么时间复杂度是$O({n})$。什么时候会出现$O(nlogn)$呢?

for(int i = 0; i < n; i ++){
    for(int j = i; j < n; j ++){
    }
}

我们发现,在内层循环中j的起始量是i,随着每次循环i的增加,j的一层循环执行的次数将越来越少。对于这种情况,我们把时间复杂度称为$O(nlogn)$

一般我们把下面这段代码的时间复杂度称为$O(logn)$的时间复杂度,并将这种情况称为对数阶,性能要优于$O(n)$

for(int i = 0; i < n; i *= 2){
}

性能好的算法的时间复杂度为$O(1)$,也就是执行的有限次的操作之后达到目标。比如一些计算类型的代码或者交换值的代码等。

当然一个算法能不能达到$O(1)$的时间复杂度,要看具体情况,我们当然希望程序的性能能够达到最优,所有算法的时间复杂度能够低于$O({n}^2)$一般来说就已经很不错了。不要忘了,算法的性能除了考虑时间复杂度外还要考虑空间复杂度,在大多数情况下旺旺需要在时间复杂度和空间复杂度之间进行权衡。

我们在上面提到的情况都是只有一个规模参数,有时规模参数也可能有两个。比如两层循环的规模不一样,我们假设分别为m和n,这时我们一般会发时间复杂度写为$O(m*n)$,但是我们需要明确,如果m和n非常相近,则这个时间复杂度趋于$O({n}^2)$;如果m通常比较小(也就是我们能够明白m的范围是多少),则这个时间复杂度趋于$O(n)$。在这两种时间复杂度下,虽然时间复杂度都是$O(m*n)$,但是真实时间复杂度可能相差很大。

实际上,一个算法的执行时间不可能通过我们计算得出,必须到机器上真正执行才能知道,而且每次的运行时间不一样。但是我们没必要将每个算法都到机器上运行和测试,并且对于很多算法,我们通过简单的分析就能知道性能的好坏,而没有必要详细的写出阿里,所以时间复杂度的计算还是非常有用的。

时间复杂度其实还分为平均时间复杂度、最好时间复杂度和最坏时间复杂度。对于一个算法来说,旺旺有很多特殊情况,一般而言,我们所说的时间复杂度是指最坏时间复杂度,因为在最坏的情况下,我们才能评估一个算法的性能最差会到什么地步,这样我们才能更好地选择应对算法去解决问题。

空间复杂度

其实我们在算法分析时,往往会忽略空间发咋读,可能因为现在计算机的空间已经越来越便宜了,成本很低,而一台计算机的CPU性能始终很难得到太大的提升。但是空间复杂度作为一个算法性能指标,也是我们需要掌握的,这样能够让程序在时间和空间上得到优化,成为一个好算法。

空间复杂度的表示其实和时间复杂度是一样的,都用大O符号表示。空间复杂度是一个算法在运行过程中所消耗的临时空间的一个度量。

空间复杂度的计算方式和时间复杂度一样,也不包括这个函数的低阶项和首项系数。

我们一般认为对于一个算法,本身的数据会消耗一定的空间,可能还需要一些其他空间,如果需要的其他空间有限,那么这个时间复杂度为$O(1)$。相对地,也有$O(n)$$O(nlogn)$$O({n}^2)$

稳定性

算法性能分析一般分为时间复杂度分析和空间复杂度分析。另外,在排序算法中会有另一个指标——稳定性。

在排序算法中,可能在一个列表中存在多个相等的元素,而经过排序之后,这些元素的相对次序保持不变,这是我们称这个算法是稳定的。若经过排序之后次序变了,那么就是不稳定的。

如果算法稳定的,那么第1个元素排序的结果就可以被第2个相同的元素排序所使用,也就是说如果算法是稳定的,那么可能避免多余的比较。

在某些情况下,若是值一样的元素也要保持与原有的相对次序不变,那么这时就必须用哪个一个稳定的算法。

快而简单的排序——桶排序

排序充斥着我们的生活,比如站队、排队买票、考试排名、公司业绩排名、将电子邮件按时间排序、QQ好友列表中的会员红名靠前等等。

什么是桶排序

桶排序,也叫做箱排序,是一个排序算法,也是所有算法中最快、最简单的排序算法。其中的思想是我们首先需要知道所有待排序元素的范围,然后需要有在这个范围内的同样数量的桶,接着把元素放入对应的桶中,最后按顺序输出。

实际的情况下,一个桶并不总是放同一个元素,很多时候提个桶里可能会放多个元素,这和散列表有一样的原理。

除了对一个桶内的元素做链表存储,我们也可能对桶内的元素继续使用其他排序算法进行排序,所以更多时候,桶排序会结合其他排序算法一起使用。

桶排序的简单实现

使用数组可以完成桶排序的实现。然后没把一个元素往桶中放时,就把数组指定位置的值加1,最终倒序输出数组的下标,数组每个位置的值为几就输出几次下标,这样就实现桶排序了。

public class BucketSort {

    private int[] buckets;
    private int[] array;

    public BucketSort(int range, int[] array) {
        this.buckets = new int[range];
        this.array = array;
    }

    /**
     * 排序
     */
    public void sort() {
        if (array != null && array.length > 1) {
            for (int anArray : array) {
                buckets[anArray]++;
            }
        }
    }

    /**
     * 从大到小排序
     */
    public void print(){
        //倒叙输出数组
        for (int i = buckets.length -1 ; i >= 0; i--){
            //元素中的值为几,就说明有多少个相同元素,就输出几遍
            for (int j = 0; j < buckets[i]; j++){
                System.out.println(i);
            }
        }
    }
}

测试代码

public class BucketSortTest {

    @Test
    public void main(){
        int[] arrays = {5,9,1,9,5,3,7,6,1};
        BucketSort bucketSort = new BucketSort(11,arrays);
        bucketSort.sort();
        bucketSort.print();
    }

}

桶排序的性能及特点

通便徐实际上只需要遍历一遍所有待排序元素,然后依次放入指定的位置。比如加上输出排序的时间,那么需要遍历所有的桶,时间复杂度就是$O(n+m)$,其中,n为待排序的元素的个数,m为桶的个数。这是相当快速的排序算法,但是对于空间的消耗来说有点太大了。

比如我们对1、10、100、1000这四个元素排序,那么我们需要产能高度为1001的数组用来排序,如果是对于1、1000、10000排序呢?当元素的跨度返回越大时,空间的浪费就越大,即使只有几个元素,但是这个范围才是空间的大小。所以桶排序的空间复杂度时$O(m)$,其中m为桶的个数,待排序元素分布越均匀,也就是说当元素能够非常均匀地填满所有的桶时,这个空间的利用率是最好的。不过这种情况并不多见,在多数情况下,数据并不会均匀的分布。

通过上线的性能分析,我们可以知道桶排序的特点就是速度快、简单,但是也有相应的弱点,那就是空间利用率低,如果数据的跨度过大,则空间可能无法承受,或者说这些元素并不合适使用桶排序算法。

桶排序的适用场景

桶排序的适用场景非常名了,那就是在数据分布相对比较均匀或者数据跨度范围并不是很大时,桶排序的速度还是相当快且简单的。

但是当数据跨度很大时,这个空间消耗就会很大;如果数值的范围特别大,那么对空间消耗的代价肯定也是不切实际的,所以这个算法还是有一定局限性。同样,由于时间复杂度为$O(n+m)$,如果m比n大太多,则从时间上来说,性能并不是很好。

但是实际上在使用桶排序的过程中,我们会使用类似散列表的方式去实现,这时的空间利用率会高很多,同时时间复杂度会有一定的提升,但是效率还不错。

我们在开发过程中,除了对一些要求特别高并且数据分布较为均匀的情况下使用桐柏徐,还是很少使用桶排序的,所以即使桶排序很简单、很快,我们也很少使用它。

桶排序更多地用于一些特定的环境下,比如数据范围比较局限或者有一些特定要求,必须通过哈希映射快速获取某些值、需要统计没歌词的数量。但是这一切都需要确认数据的范围,如果范围太大,就需要巧妙的解决这个问题或者使用其他算法了。

冒泡排序

什么是冒泡排序

冒泡排序(Bubble Sort)是排序算法里面比较简单的排序。它重复地走访要排序的数列,一次比较两个数据元素,如果顺序不对则进行交换,并且一直重复这样的走访操作,直到没有要交换的数据元素为止。

冒泡排序的原理

首先我们肯定有一个数组,里面存放着待排序的元素列表,我们如果需要把比较大的元素排在前面,把小的元素排在后面,那么需要从尾到头开始进行比较操作。

  1. 从尾部开始比较相邻的两个元素,如果尾部的元素比前面的大,我们就交换两个元素的位置。
  2. 往前对每个相邻的元素都做这样的比较、交换操作,这样的数据组头部时,第一个元素会变成最大的元素。
  3. 重新从尾部开始第1、2步操作,除了在这之前头部已经排好的元素。
  4. 继续对越来越少的数据进行比较、交换,知道没有可比较的数据为止,排序完成。

这个算法和相识后在操场排队跑步很像,老师总是说:“高个站在前面,低的站后面”。我们一开始并不一定会站到准确的位置,接着老师说:“你比前面的高,和前面的换换,还高。再和前面换换”,这样就找到自己的位置。

冒泡排序的实现算法

首先我们需要从后往前遍历待排序的数组,然后重复这个步骤,继续遍历剩下的待排序的数列,这样我们就需要一个双重循环去完成这个算法。

public class BubbleSort {
    private int[] array;

    public BubbleSort(int[] array) {
        this.array = array;
    }

    /**
     * 从小到大
     */
    public void sort() {
        int length = array.length;
        if (length > 0) {
            for (int i = 1; i < length; i++) {
                for (int j = 0; j < length - i; j++) {
                    if (array[j] > array[j + 1]) {
                        int temp = array[j];
                        array[j] = array[j + 1];
                        array[j + 1] = temp;
                    }
                }
            }
        }
    }

    /**
     * 从大到小
     */
    public void sort2() {
        int length = array.length;
        if (length > 0) {
            for (int i = length - 1; i > 0; i--) {
                for (int j = length - 1; j > length - 1 - i; j--) {
                    if (array[j] > array[j - 1]) {
                        int temp = array[j];
                        array[j] = array[j - 1];
                        array[j - 1] = temp;
                    }
                }
            }
        }
    }

    public void print(){
        for (int anArray : array) {
            System.out.println(anArray);
        }
    }
}

测试代码

public class BubbleSortTest {

    @Test
    public void main(){
        int[] arrays = {5,9,1,9,5,3,7,6,1};
        BubbleSort bubbleSort = new BubbleSort(arrays);
        bubbleSort.sort2();
        bubbleSort.print();
    }

}

冒泡排序的特点及性能

通过冒泡排序的算法思想,我们发现冒泡排序算法在每轮排序中会使一个元素排到一端,也就是最终需要n-1轮这样的排序(n为待排序的数列的长度),而在每轮排序中都需要对相邻的每个元素进行比较,在最坏的情况下,每次比较之后都需要交换位置,所以这里的时间复杂度时$O({n}^2)$。其实冒泡排序在最好的情况下,时间复杂度可以达到$O(n)$,这当然是在待排序的顺序有序的情况下。在待排序的数列本身就是我们想到的排序结果时,时间复杂度就是O(n),因为只需要一轮排序并且不用交换。但是实际上这种情况很少,所以冒泡排序的平均时间复杂度是$O({n}^2)$

冒泡排序的使用场景

对于冒泡排序,我们应该对它的思想进行理解,作为排序算法学习的引导,让我们思维更加开阔。虽然冒泡排序在我们的实际工作中并不会用到,其他排序算法多多少少比冒泡排序算法的性能更高,其实我们还是要掌握冒泡排序的思想及实现,并且面试时还是有可能会用到。

冒泡排序的改进方案

虽然我们对冒泡排序用的不多,但是正如上面所说,由冒泡排序引起的一些其他问题还是挺有意思的。

增加标记位

这里,我们增加一个变量来记录每趟排序中最后一次交换位置,由于这个位子之后的元素已经不用再交换了,说明后面的元素都完成了排序,所以下次开始可以直接从尾比较到这个位置,这样就能保证前面的元素如果本身有序就不用重复比较了。

比如待排序的数列为10、8、5、1、2,那么10、8、5本身有序,实际上只需要经过一趟排序交换就可以完成这个数列的排序操作,性能有时会有一定的提高;又或者中间的一些元素相对有序,有时也可能使总排序趟数少于n-1次。

一次冒2个元素

每趟排序都是交换最大的元素冒到上面去,那么可以不可以在每趟排序中进行正向和反向的两次冒泡。对于每一趟,在倒着比较出最大的元素之后,在正着比较出较小的元素并使其沉下去,可以使排序趟数几乎减少一半。

快速排序

冒泡排序的时间复杂度时$O({n}^2)$,如果计算机每秒运算10亿次,排序1亿个数字,那么桶排序只需要1秒,冒牌排序则需要1千万秒(也就是115天),那么没有一种排序即省时间又省空间。

什么是快速排序

其实快速排序是对冒泡排序的一种改进,由C.A.R.Hoare(Charles Antony Richard Hoare,东尼·霍尔)在1962年提出。它的基本思想是:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另一部分的所有数据要小,在按照这种方法对两部分数据分别进行快速排序,整个排序过程可以递归排序,使整个数据变成有序序列。

快速排序的原理

排序算法的思想非常简单,在待排序的数列中,我们首先要找一个数字作为基准数(这只是个专用名词)。为了方便,我们一般选第一个数字作为基准数(其实选择第几个并没有关系)。接下来我们需要把这个待排序的数列中小于基准数的元素移动到待排序的数列的左边,把大于基准数的元素移动到待排序的右边。这时,左右两个分区的元素怒就相对有序了;接着把两个分区的元素按照上面两个方法继续对每个分区找到基准数,然后移动,直到每个分析只有一个数时为止。

这是典型的分治思想,即分治法。以47、29、71、99、78、19、24、47的待排序的数列为例进行排序,为了方便区分两个47,我们对后面的47增加一个下划线,即待排序的数列为47、29、71、99、78、19、24、++47++。

首先我们需要在数列中选择一个基准数,我们一般选择中间的一个数或者头尾的数,这里直接选择第一个数47作为基准书,接着把47小的数字移动到左边,把比47大的数字移动到右边,对于相等的数字不做移动。所以实际上我们需要找到中间的某个位置k,这样k左边的值全部比k上的值小,k右边的值全部比k上的值大。

接下来开始移动元素,其实冒泡排序也涉及到元素的移动,但是那样移动起来很累,比如把最后一个元素移动到第一个,就需要比较n-1次,同时交换n-1次,效率低,其实,只需要把第一个元素和最后一个元素交换一下就好了,这种思想是不是在排序时可以借鉴。

快速排序的操作就是这样:首先从数列的右边开始往左边找,我们设下这个下标为i,也就是进行行减减操作(i--),找到第1个比基准数小的值,让它与基准值交换;接着从左边开始往右找,设这个下标为j,然后执行行加加(j++),找到第一个比基准数大的值,让它与基准数交换;然后继续寻找,知道i与j相遇时结束,最后基准值所在的位置即是k的位置,也就是说k左边的值比k上的值小,而k右边的值都比k上的值大。

快速排序的实现

其实快速排序时一种比较简单的思想,就是递归。对于每一趟排序都是一种的思想,只不过需要进行排序的数组范围越来越小,使用递归实现这种排序最好不过。

public class QuickSort {
    private int[] array;

    public QuickSort(int[] array) {
        this.array = array;
    }

    public void sort() {
        quickSort(array, 0, array.length - 1);
    }

    public void print() {
        for (int i : array) {
            System.out.println(i);
        }
    }

    private void quickSort(int[] src, int begin, int end) {
        if (begin < end) {
            int key = src[begin];
            int i = begin;
            int j = end;

            while (i < j) {
                while (i < j && src[j] > key) {
                    j--;
                }
                if (i < j) {
                    src[i] = src[j];
                    i++;
                }
                while (i < j && src[i] < key) {
                    i++;
                }
                if (i < j) {
                    src[j] = src[i];
                    j--;
                }
            }
            src[i] = key;
            quickSort(src, begin, i - 1);
            quickSort(src, i + 1, end);
        }
    }
}

测试代码

public class QuickSortTest {

    @Test
    public void main(){
        int[] arrays = {5,9,1,9,5,3,7,6,1};
        QuickSort quickSort = new QuickSort(arrays);
        quickSort.sort();
        quickSort.print();
    }

}

快速排序的特点及性能

快速排序是在冒泡排序的基础上改进而来的,冒泡排序每次只能交换相邻的两个元素,而快速排序是跳跃式的交换,交换的距离很大,因此总的比较和交换次数少了很多,速度也快了不少。

但是快速排序在最坏的情况下时间复杂度和冒泡排序一样,是$O({n}^2)$,实际上每次比较都是需要交换,但是这种情况并不常见。我们可以思考一下如果每次比较都需要交换,那么数列的平均时间复杂度时$O(nlogn)$,实际上大多数的时候,排序的速度要快于这种平均时间复杂度。这种算法实际上是一种分治思想,也就是分而治之,把问题分为一个个的小部分来分别解决,再把结果和组合起来。

快速排序只是使用数组原本的空间进行排序,所以所占空间应该是常量级的,但是由于每次划分之后是递归调用,所以递归调用在运行的过程中会消耗掉一定的空间,在一般情况下的空间复杂度时$O(nlogn)$,在最差的情况下,若每次只完成了一个元素,那么空间复杂度为$O(n)$。所以我们一般认为快速排序的空间复杂度为$O(logn)$

快速排序是一个不稳定的算法,在经过排序之后,可能会相同值的元素的相对位置造成改变。

快速排序基本上被认为相同数量级的所有排序算法中,平均性能最好的。

快速排序的使用场景

快速排序由于相对简单而且性能不错,所以我们比较常用。在需要对数列排序时,我们优先选择快速排序。

快速排序适合在需要针对给定数列进行顺序排列时使用,当然有更快的排序算法,但是由于其他的一些算法的实现没有快速排序那么简单,但是在n并不是很大的情况下,性能差异并不是很大,所以一些复杂的算法虽然在性能上会更有有事,但是在大多数的时候并不经常使用,这时有快速排序就足够了。

快速排序的优化

三者取中法

由于每次选择基准都寻则第1个,这就会产生一个问题,那就是可能造成每次都需要移动,这样会使算法的性能很差,趋向于$O({n}^2)$,所以我们要找出中间位置的值。我们希望基准值越能够更接近中间位置的值,所以这里可以每次使用待排序的数列部分的头、尾、中间数,在这三个数中取中间大小的那个数作为基准值,然后进行快速排序,这样能够对一些情况下进行优化。

根据规模大小更改算法

由于快速排序在数据量较小的情况下,排序性能并未有其他算法好,所以我们可以在待排序的数列区分小于某个值后,采用其他算法进行排序,而不是继续使用快速排序,这样也能够得到一定的性能提升。这个值一般可以是5~25,在一些编程语言中使用10或者15这个量。

其他分区方案考虑

有时,我们选择的基准数在数列中可能存在多个,这时我们可以考虑改变分区方案,那就是分为三个区间,除了小于基准数的区间、大于基准数的区间,我们还可以交换出一个等于基准数的区间,这样我们在之后每次进行递归时,救指递归小于和大于两个部分的区间,对于等于基准数的区间就不用考虑了。

并行处理

由于快速排序对数组中每一小段范围进行排序,对其他段并没有影响,所以可以采用现在计算机的多线程来提高效率,这并不算是对算法的优化,只能说是一种对于数量比较多的数据使用快速排序时的一个搞笑解决方案。

posted @ 2018-04-02 21:43  Mr-cc  阅读(208)  评论(0编辑  收藏  举报