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

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

算法基础

对于算法性能分析来讲,除了时间复杂度,仍是有空间复杂度、稳定性等指标。而咱们平时说的算法的复杂度能够分为两个部分:时间复杂度和空间复杂度。面试

时间复杂度

在科学计算中,算法的时间复杂度是一个函数,它定量地描述了一个算法的运行时间。时间复杂度一般一个大$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为桶的个数。这是至关快速的排序算法,可是对于空间的消耗来讲有点太大了。

好比咱们对一、十、100、1000这四个元素排序,那么咱们须要产能高度为1001的数组用来排序,若是是对于一、1000、10000排序呢?当元素的跨度返回越大时,空间的浪费就越大,即便只有几个元素,可是这个范围才是空间的大小。因此桶排序的空间复杂度时$O(m)$,其中m为桶的个数,待排序元素分布越均匀,也就是说当元素可以很是均匀地填满全部的桶时,这个空间的利用率是最好的。不过这种状况并很少见,在多数状况下,数据并不会均匀的分布。

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

桶排序的适用场景

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

可是当数据跨度很大时,这个空间消耗就会很大;若是数值的范围特别大,那么对空间消耗的代价确定也是不切实际的,因此这个算法仍是有必定局限性。一样,因为时间复杂度为$O(n+m)$,若是m比n大太多,则从时间上来讲,性能并非很好。

可是实际上在使用桶排序的过程当中,咱们会使用相似散列表的方式去实现,这时的空间利用率会高不少,同时时间复杂度会有必定的提高,可是效率还不错。

咱们在开发过程当中,除了对一些要求特别高而且数据分布较为均匀的状况下使用桐柏徐,仍是不多使用桶排序的,因此即便桶排序很简单、很快,咱们也不多使用它。

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

冒泡排序

什么是冒泡排序

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

冒泡排序的原理

首先咱们确定有一个数组,里面存放着待排序的元素列表,咱们若是须要把比较大的元素排在前面,把小的元素排在后面,那么须要从尾到头开始进行比较操做。

  1. 从尾部开始比较相邻的两个元素,若是尾部的元素比前面的大,咱们就交换两个元素的位置。
  2. 往前对每一个相邻的元素都作这样的比较、交换操做,这样的数据组头部时,第一个元素会变成最大的元素。
  3. 从新从尾部开始第一、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)$

冒泡排序的使用场景

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

冒泡排序的改进方案

虽然咱们对冒泡排序用的很少,可是正如上面所说,由冒泡排序引发的一些其余问题仍是挺有意思的。

增长标记位

这里,咱们增长一个变量来记录每趟排序中最后一次交换位置,因为这个位子以后的元素已经不用再交换了,说明后面的元素都完成了排序,因此下次开始能够直接从尾比较到这个位置,这样就能保证前面的元素若是自己有序就不用重复比较了。

好比待排序的数列为十、八、五、一、2,那么十、八、5自己有序,实际上只须要通过一趟排序交换就能够完成这个数列的排序操做,性能有时会有必定的提升;又或者中间的一些元素相对有序,有时也可能使总排序趟数少于n-1次。

一次冒2个元素

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

快速排序

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

什么是快速排序

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

快速排序的原理

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

这是典型的分治思想,即分治法。以4七、2九、7一、9九、7八、1九、2四、47的待排序的数列为例进行排序,为了方便区分两个47,咱们对后面的47增长一个下划线,即待排序的数列为4七、2九、7一、9九、7八、1九、2四、++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这个量。

其余分区方案考虑

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

并行处理

因为快速排序对数组中每一小段范围进行排序,对其余段并无影响,因此能够采用如今计算机的多线程来提升效率,这并不算是对算法的优化,只能说是一种对于数量比较多的数据使用快速排序时的一个搞笑解决方案。

相关文章
相关标签/搜索