java面试必问——六大排序算法

来源:互联网 发布:java系统架构设计方案 编辑:程序博客网 时间:2024/06/04 18:32

java中有很多中排序方法,其中冒泡排序过于简单,基数排序主要用于研究我们这里不讨论。实际应用和面试中,最常问到的就是下面的六种排序方法,我们将从原理,复杂度,稳定性和实际应用几个方面来讨论他们。

选择排序


排序原理:

选择排序从整个待排序数组N中进行N次查找,将每次查找到的最小值放到数组开始,然后将数组开始位置的索引递增。形象地说,选择排序就是在数组中从前到后跑N趟,每一趟把最小的数字向数组前端一放,慢慢在数组前端维护出一个有序的子数组。最后达到排序的目的。

时间复杂度:

O(N2)

选择排序通过两个for循环对数组进行遍历。外层for标记已经排序的前端子数组的索引,内层for循环对从这个索引开始至数组尾部进行最小元素的查找。理论上访问数组的次数是N2/2(或者通过优化更短),交换的次数大概为N。

空间复杂度:

O(1)

选择排序不需要额外的空间,在O(1)常量空间内完成一些局部变量的定义。

稳定性:

不稳定

选择排序是将数组中较小的元素以此放到最前面的排序方法,这种方法按照次序将元素前置,如果序列 2 2 1,那么我们需要将第一个2和1交换,交换之后两个2的位置发生了变化,所以是不稳定的。

选择排序实现:

private static void selectSort(int[] arr) {        int n = arr.length;        for (int i = 0; i < n; i++) {            int min = Integer.MAX_VALUE;            int min_index = -1;            for (int j = i; j < n; j++) {                if (arr[j] <= min) {                    min_index = j;                    min = arr[j];                }            }            int temp = arr[min_index];            arr[min_index] = arr[i];            arr[i] = temp;        }    }

应用场景:

总的来说,作为一种初级排序算法,选择排序并不能和其他快速的排序作比较,实践意义也比较少。但是选择排序有两个鲜明的优点:运行时间和数组排序无关,选择次数最少

插入排序(直接插入排序)


排序原理:

插入排序外层的for循环对整个数组N进行一次遍历,内层的for循环从外层索引元素开始向前比较和交换,如果该元素小于前一个元素,则交换这两个元素并继续与更前一个元素进行比较,否则结束退出内循环。插入排序类似于扑克排序,我们将手中的大牌从一个较后端的位置通过一个个的比较和交换移动到前面的位置。

时间复杂度:

O(N2)

插入排序也为两层循环体,时间复杂度在O(N2)的水平上。在没有任何优化的情况下,直接插入排序需要的交换次数和访问数组次数与数组中元素的分布有关。越是有序的数组交换次数和访问数组次数越少,越是混乱的数组交换次数和访问数组次数越多。

空间复杂度:

O(1)

与选择排序相同,插入排序也不需要和N相关的空间,只需要一些常量空间。

稳定性:

稳定

就和冒泡排序一样,插入排序采用的策略是不断的交换两个元素。交换不同的元素也意味着而从不交换相同的元素。只要不交换相同的元素,不可能是不稳定的排序。

插入排序实现:

private static void insertSort(int[] arr) {        int n = arr.length;        for (int i = 1; i < n; i += 1) {            for (int j = i; j > 0; j--) {                if (arr[j] < arr[j - 1]) {                    int temp = arr[j];                    arr[j] = arr[j - 1];                    arr[j - 1] = temp;                } else                    break;            }        }    }

应用场景:

在数组已经较为有序,或仅有几个元素的位置有错误的情况下,使用插入排序的效率非常高。因为在插入排序中,如果一个元素已经处于排好序的位置,那么这个元素可以不做任何交换。而在选择排序中,每一趟排序的结果和原数组的位置无关,无论是否排好序,选择排序都要进行同样的操作,这样就带来了不必要的时间浪费。

希尔排序


排序原理:

希尔排序将一个数组分成很多个子数组,将这些子数组插入排序。通过不断减少子数组的个数,最后将子数组个数减至一个达到将整个数组排序的目的。换句话说,直接插入排序可以理解为——子数组个数为1的希尔排序。希尔排序的通俗解释是,一个学校中有很多待排序的试卷,试卷太多不能一个个排序,那么先让每个老师把自己班级的试卷进行排序,最后放在一起排序,这样因为每个班级的试卷都是有序的,最后的排序也不会花费太多的时间。

时间复杂度:

难以估计

希尔排序的时间复杂度很难估计,因为时间与选取的子数组递减序列有关。我们可以设定整个数组分解为8,4,2,1个数组,或者分解为9,6,3,1或者其他的方式减少子数组的个数(但最后一定都要减至1)。最让人头痛的是,至今也没有人能证明哪个递减序列的效果最好。一个较为合适的递减序列是:1/2(3k-1)。

空间复杂度:

O(1)

希尔排序和插入排序是一个原理,并不需要使用与N相关的空间。

稳定性:

不稳定

希尔排序是一种比较灵活的排序,我们在分组的时候当子数组不为1时,进行的交换完全可能将不同组之间相同数字位置搞乱。

希尔排序实现:

  private static void shellSort(int[] arr) {        int n = arr.length;        for (int d = n / 2; d != 1; d = d / 2 + 1) {            insertSort(arr, d);            System.out.println(d);        }    }    private static void insertSort(int[] arr, int d) {        int n = arr.length;        for (int i = d; i < n; i += d) {            for (int j = i; j > 0; j -= d) {                if (arr[j] < arr[j - d]) {                    int temp = arr[j];                    arr[j] = arr[j - d];                    arr[j - d] = temp;                } else                    break;            }        }    }

应用场景:

希尔排序的应用场景很难说,有的时候我们更加依赖于快速排序而不是希尔排序。对于中等大小的数组,希尔排序会被一些有经验的程序员采纳,因为希尔排序实际应用中表现效果很好,适用于各种情况的排序,而且不需要使用额外的空间。在对你的数组没把握的时候先使用希尔排序,然后可以再去考虑具体用那种排序来替换希尔排序。

归并排序


排序原理:

归并排序采用了“分治”的思想。先将整个数组N个元素不断的递归分解成两个子数组,当所有的数组都足够小不能再分时,再将他们递归合并。合并采用的做法是:使用一个额外空间,将两个子数组中最小的那个元素放入额外数组中直到两个子数组中所有元素都被取出。然后将额外数组的内容覆盖在原数组上。

时间复杂度:

O(NlogN)(习惯的,我们将log2写成lg,归并的时间复杂度也可是O(NlgN) )

归并排序的时间复杂度是O(NlgN)或O(NlogN)的(之所以log和lg都可以,是因为在复杂度上我们考虑的是类型问题,log的底数作为一个常量并没有太大影响可以忽略)。怎么去计算归并的时间复杂度呢?归并排序采用的是递归的方法,在不计算最底层单个元素的情况下,这个递归函数的二叉树一共有n层,且满足2n=N。可求得n=log2N。在每一层上,元素的个数和都为N,容易看出每一层上的排序需要比较的次数和都是N。所以:每层所有元素需要排序的次数和×层数=N×log2N=NlogN。

空间复杂度:

O(N)

归并排序需要一个数组作为额外的空间来实现对两个子数组的排序。这里需要注意的是,这个额外的空间一定要在外部申请长度为N的数组,而不是在递归内部创建变量。如果在递归内部每一层都需要创建一个变量,那么很有可能创建数组的开销时间远大于排序时间,或是足够影响排序时间。而且不仅是时间的问题,在递归内部创建变量会让空间复杂度远大于N。

归并排序是一种递归排序,不仅需要创建N长的数组,而且需要在每层递归上维护一些数据,需要复杂度为O(logN)的空间,但是O(N)>O(logN),而且这两个空间是加算不是乘算的。在计算空间复杂度时我们认为它是O(N)的即可。

稳定性:

稳定

只要我们在归并的时候记得将前面的子数组放在前面合成,就不会存在不稳定的情况。

归并排序实现:

private static void mergeSort(int[] arr) {        int n = arr.length;        ret = new int[n];        sort(arr, 0, n);    }private static void sort(int[] arr, int i, int n) {    if (n == 1)    sort(arr, i, n / 2);    sort(arr, i + n / 2, n - n / 2);    int p1 = i;    int p2 = i + n / 2;    for (int j = p1; j < p1 + n; j++)        ret[j] = arr[j];    for (int j = i; j < n + i; j++) {        if (p1 == n / 2 + i)            arr[j] = ret[p2++];        else if (p2 == n + i)            arr[j] = ret[p1++];        else if (ret[p1] > ret[p2])            arr[j] = ret[p2++];        else            arr[j] = ret[p1++];    }}

应用场景:

我们经常将归并排序和快速排序进行比较,归并排序在空间复杂度上劣于快速排序,但是较为稳定。在实际的jdk中,复杂的复合类型数组的排序是使用归并排序来处理的,因为对于一个hashcode计算复杂的对象来说,移动的成本远低于比较的成本。没有差的排序方法,只是在每一种场合下最优解不同。

快速排序


排序原理:

快速排序可以理解成归并排序的一种反义。归并排序是先将数组按照无序的方式二分 ,然后在递归到底时再进行排序合成。快速排序是先取出数组中的一个元素(这个元素是任意的,通常我们会取出开始的元素,中间的元素,结尾的元素三者中的一个,下面的程序是按照取开始的元素进行编写的)。按照这个元素进行对数组的切分——将比这个元素小的放在该元素左端,比这个元素大的放在该元素右端。

我们以开始的元素作为切分元素举例:在开始的元素下一个元素处放置一个索引i,在该数组末尾放置一个索引j。让i递增,如果遇到一个比切分元素大的元素则停止;让 j递减,如果遇到一个比切分元素小的元素则停止。交换i和j索引指示的元素内容,继续重复上一步操作,直到j在i的左边。这个时候,交换切分元素和j索引指向的元素。这个切分点是j,将j之前的子数组调用该方法递归下去,j之后的也调用该方法递归。

具体一些临界条件、为什么是j是切分点而i不是、为什么直到j在i的左边而不是i在j的右边、为什么j可以取到0而i不能取到N.length这些问题都要在代码中自己体会,这里讲不完而且也不一定能讲明白,只有自己真正练过程序才能牢记。

时间复杂度:

O(NlogN)

快速排序的时间复杂度和归并排序的相同,证明方法也基本类似。在不计算最底层单个元素的情况下,快排的递归函数的二叉树一共有n层,且满足2n=N。可求得n=log2N。每个数组都从头和尾反向遍历,直到两个索引错位。所以总共遍历数组一次,一层上所有数组都会被遍历一次,所以每层访问数组N次。每层访问数组次数和×层数=N×log2N=NlogN。

空间复杂度:

O(logN)

快速排序需要在每层递归上维护一些数据,这些数据都是常量空间的数据,但是因为递归的层数与N相关,为logN层,所以在最坏的情况下,快速排序的空间复杂度为O(logN)。

稳定性:

不稳定

快速排序是不稳定的。原因是快速排序需要维护前后两个索引,在索引发生对换的情况时,很有可能改变了某两个相同元素的位置,这种跳跃性的交换是肯定会导致不稳定的。

快速排序实现:

  private static void quickSort(int[] arr, int lo, int he) {        if (lo >= he)            return;        int left = lo + 1;        int right = he;        int pos = lo;        while (true) {            while (arr[left] <= arr[pos] && left < he)                left++;            while (arr[right] >= arr[pos] && right > lo)                right--;            if (left < right) {                int temp = arr[left];                arr[left] = arr[right];                arr[right] = temp;            } else                break;        }        int temp = arr[pos];        arr[pos] = arr[right];        arr[right] = temp;        pos = right;        quickSort(arr, lo, pos - 1);        quickSort(arr, pos + 1, he);    }

应用场景:

快速排序是我们最为常用的排序方法,上述例子是未经过优化的一种简单快排。实际上,我们通常认为在这些O(NlogN)复杂度的排序方法中,快速排序的效果是最好的。一般的sort函数排序使用的都是快速排序。

堆排序


排序原理:

堆排序是根据数据结构堆(Heap)改编而来的排序方法。这种排序方法和选择排序很类似,但是实际效率上完爆选择排序。首先我们要知道堆的概念。用一个数组模拟一个二叉树,这个二叉树具有:父节点恒大于(或恒小于)子节点的特点。如果我们用k来表示父节点,那么两个子节点就是2k+1和2k+2。如果一个子节点是k,那么他的父节点就是(k-1)/2。

堆排序首先需要建堆,我们通过代码首先使得整个数组符合一颗二叉树的特点。然后我们将第0个元素 ,也就是最大的元素与最后一个元素交换,然后调整这颗二叉树让他再满足堆的特点,再重复将第0个元素和倒数第二个元素交换。通过这样的方法,不断地取出最大的元素和末端元素交换,使得在这个数组的末端的子数组具有从小到大的顺序,当整棵二叉树都如此排序后,整个数组就是排好序的状态了。

时间复杂度:

O(NlogN)

堆排序的时间复杂度是NlogN级别的,实际上是2N+2NlogN次比较和N+NlogN次交换得来的。

在建堆的过程中需要小于2N次的比较和小于N次的交换,比如以一棵长度为7的二叉树为例,构建这个堆需要调整三个大小为3的堆和1个大小为7的堆,大小为3的堆可能需要1次交换,大小为7的堆可能需要2次交换,在嘴还的情况下,一共需要3×1+1×2=5次交换<长度7。因为比较的方法是左子树和右子树比较一次,大者和父节点比较,最多需要两次比较,共需要小于2N次比较。

在排序的过程中,从头到尾排序所有的元素需要遍历整个数组为N次,在N次中每次需要删除最大节点,删除最大节点后的重排堆需要最多logN次的交换和2logN次的比较。交换和比较的次数和二叉树的层数有关。

空间复杂度:

O(1)

堆排序不需要任何辅助数组和递归的空间,只需要固定的常量空间。

稳定性:

不稳定

堆排序类似于选择排序,所以肯定也是一种不稳定的排序方法。堆排序在数组的末端维护有序子数组,这种倒置插入的方法肯定是一种不稳定的排序方法。

堆排序实现:

private static void heapSort(int[] arr) {        int n = arr.length;        for (int i = (n - 2) / 2; i >= 0; i--)            order(arr, i, n);        for (int i = n - 1; i > 0; i--) {            int temp = arr[i];            arr[i] = arr[0];            arr[0] = temp;            order(arr, 0, i);        }    }    private static void order(int[] arr, int i, int n) {        while (2 * i + 1 < n) {            int chg = 2 * i + 1;            if (chg + 1 < n && arr[chg + 1] > arr[chg])                chg++;            if (arr[chg] <= arr[i])                break;            int temp = arr[chg];            arr[chg] = arr[i];            arr[i] = temp;            i = chg;        }    }

应用场景:

堆排序是一种空间和时间最优的排序方法。但是堆排序也有着一定的局限性,因为堆排序的交换和移动的两个元素几乎都不是相邻的元素,所以无法利用计算机中的缓存。如果使用堆排序,缓存未命中的次数要远高于大多数比较在相邻元素间的算法,在大型数组中,这种情况导致时间得不偿失,远不如快速排序,归并排序,甚至是希尔排序等。

六大排序方法总结


排序方法 时间复杂度 空间复杂度 稳定性 选择排序 O(N2) O(1) 不稳定 插入排序 O(N2) O(1) 稳定 希尔排序 小于O(N2) O(1) 不稳定 归并排序 O(NlogN) O(N) 稳定 快速排序 O(NlogN) O(logN) 不稳定 堆排序 O(NlogN) O(1) 不稳定
1 0