备战秋招之八大排序——O(n^2)级排序算法

1、冒泡排序

冒泡排序是入门级的算法,但也有一些有趣的玩法。一般来讲,冒泡排序有三种写法:java

  1. 一边比较一边向后两两交换,将最大值 / 最小值冒泡到最后一位;
  2. 通过优化的写法:使用一个变量记录当前轮次的比较是否发生过交换,若是没有发生交换表示已经有序,再也不继续排序;
  3. 进一步优化的写法:除了使用变量记录当前轮次是否发生交换外,再使用一个变量记录上次发生交换的位置,下一轮排序时到达上次交换的位置就中止比较

1.一、第一种写法

public static void bubbleSort(int[] arr) {
    for (int i = 0; i < arr.length - 1; i++) {
        for (int j = 0; j < arr.length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                // 若是左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, j, j + 1);
            }
        }
    }
}
// 交换元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

最外层的 for 循环每通过一轮,剩余数字中的最大值就会被移动到当前轮次的最后一位,中途也会有一些相邻的数字通过交换变得有序。总共比较次数是 (n-1)+(n-2)+(n-3)+…+1(n−1)+(n−2)+(n−3)+…+1。
这种写法至关于相邻的数字两两比较,而且规定:“谁大谁站右边”。通过 n-1n−1 轮,数字就从小到大排序完成了。整个过程看起来就像一个个气泡不断上浮,这也是“冒泡排序法”名字的由来。算法

1.二、第二种写法

在第一种的基础上改良而来数组

public static void bubbleSort(int[] arr) {
    // 初始时 swapped 为 true,不然排序过程没法启动
    boolean swapped = true;
    for (int i = 0; i < arr.length - 1; i++) {
        // 若是没有发生过交换,说明剩余部分已经有序,排序完成
        if (!swapped) break;
        // 设置 swapped 为 false,若是发生交换,则将其置为 true
        swapped = false;
        for (int j = 0; j < arr.length - 1 - i; j++) {
            if (arr[j] > arr[j + 1]) {
                // 若是左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, j, j + 1);
                // 表示发生了交换
                swapped = true;
            }
        }
    }
}
// 交换元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

最外层的 for 循环每通过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。这种写法相对于第一种写法的优势是:若是一轮比较中没有发生过交换,则当即中止排序,由于此时剩余数字必定已经有序了。app

1.三、第三种写法

比较少见,在第二种的基础上进一步优化函数

public static void bubbleSort(int[] arr) {
    boolean swapped = true;
    // 最后一个没有通过排序的元素的下标
    int indexOfLastUnsortedElement = arr.length - 1;
    // 上次发生交换的位置
    int swappedIndex = -1;
    while (swapped) {
        swapped = false;
        for (int i = 0; i < indexOfLastUnsortedElement; i++) {
            if (arr[i] > arr[i + 1]) {
                // 若是左边的数大于右边的数,则交换,保证右边的数字最大
                swap(arr, i, i + 1);
                // 表示发生了交换
                swapped = true;
                // 更新交换的位置
                swappedIndex = i;
            }
        }
        // 最后一个没有通过排序的元素的下标就是最后一次发生交换的位置
        indexOfLastUnsortedElement = swappedIndex;
    }
}
// 交换元素
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

通过再一次的优化,代码看起来就稍微有点复杂了。最外层的 while 循环每通过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。
在下一轮比较时,只需比较到上一轮比较中,最后一次发生交换的位置便可。由于后面的全部元素都没有发生过交换,必然已经有序了。
当一轮比较中从头至尾都没有发生过交换,则表示整个列表已经有序,排序完成。性能

1.四、时间复杂度&空间复杂度

空间复杂度为 O(1),时间复杂度为 O(n^2),
第二种、第三种冒泡排序因为通过优化,最好的状况下只须要 O(n)的时间复杂度。
最好状况:在数组已经有序的状况下,只需遍历一次,因为没有发生交换,排序结束。
最差状况:数组顺序为逆序,每次比较都会发生交换。
但优化后的冒泡排序平均时间复杂度仍然是 O(n^2),因此这些优化对算法的性能并无质的提高。优化

2、选择排序

2.一、一元选择排序

选择排序的思想是:双重循环遍历数组,每通过一轮比较,找到最小元素的下标,将其交换至首位。code

public static void selectionSort(int[] arr) {
    int minIndex;
    for (int i = 0; i < arr.length - 1; i++) {
        minIndex = i;
        for (int j = i + 1; j < arr.length; j++) {
            if (arr[minIndex] > arr[j]) {
                // 记录最小值的下标
                minIndex = j;
            }
        }
        // 将最小元素交换至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
    }
}

冒泡排序和选择排序有什么异同?
相同点:对象

  • 都是两层循环,时间复杂度都为 O(n^2);
  • 都只使用有限个变量,空间复杂度 O(1)。

不一样点:排序

  • 冒泡排序在比较过程当中就不断交换;而选择排序增长了一个变量保存最小值 / 最大值的下标,遍历完成后才交换,减小了交换次数。
    事实上,冒泡排序和选择排序还有一个很是重要的不一样点,那就是:
    冒泡排序法是稳定的,选择排序法是不稳定的。
    想要理解这点不一样,咱们先要知道什么是排序算法的稳定性。

2.二、排序算法的稳定性

假定在待排序的记录序列中,存在多个具备相同的关键字的记录,若通过排序,这些记录的相对次序保持不变,即在原序列中,r[i] = r[j],且 r[i] 在 r[j] 以前,而在排序后的序列中,r[i] 仍在 r[j] 以前,则称这种排序算法是稳定的;不然称为不稳定的。
因此,
冒泡排序中,只有左边的数字大于右边的数字时才会发生交换,相等的数字之间不会发生交换,因此它是稳定的。
而选择排序中,最小值和首位交换的过程可能会破坏稳定性。好比数列:[2, 2, 1],在选择排序中第一次进行交换时,原数列中的两个 2 的相对顺序就被改变了,所以,咱们说选择排序是不稳定的
排序算法的稳定性有什么意义呢,其实它只在一种状况下有意义:当要排序的内容是一个对象的多个属性,且其本来的顺序存在乎义时,若是咱们须要在二次排序后保持原有排序的意义,就须要使用到稳定性的算法。
举个例子,若是咱们要对一组商品排序,商品存在两个属性:价格和销量。当咱们按照价格从高到低排序后,要再按照销量对其排序,这时,若是要保证销量相同的商品仍保持价格从高到低的顺序,就必须使用稳定性算法。
固然,算法的稳定性与具体的实现有关。在修改比较的条件后,稳定性排序算法可能会变成不稳定的。如冒泡算法中,若是将「左边的数大于右边的数,则交换」这个条件修改成「左边的数大于或等于右边的数,则交换」,冒泡算法就变得不稳定了。
一样地,不稳定排序算法也能够通过修改,达到稳定的效果。思考一下,选择排序算法如何实现稳定排序呢?
实现的方式有不少种,这里给出一种最简单的思路:新开一个数组,将每轮找出的最小值依次添加到新数组中,选择排序算法就变成稳定的了。
但若是将寻找最小值的比较条件由arr[minIndex] > arr[j]修改成arr[minIndex] >= arr[j],即便新开一个数组,选择排序算法依旧是不稳定的。因此分析算法的稳定性时,须要结合具体的实现逻辑才能得出结论,咱们一般所说的算法稳定性是基于通常实现而言的。

2.三、二元选择排序

既然每轮遍历时找出了最小值,何不把最大值也顺便找出来呢?这就是二元选择排序的思想。
使用二元选择排序,每轮选择时记录最小值和最大值,能够把数组须要遍历的范围缩小一倍。

public static void selectionSort2(int[] arr) {
    int minIndex, maxIndex;
    // i 只须要遍历一半
    for (int i = 0; i < arr.length / 2; i++) {
        minIndex = i;
        maxIndex = i;
        for (int j = i + 1; j < arr.length - i; j++) {
            if (arr[minIndex] > arr[j]) {
                // 记录最小值的下标
                minIndex = j;
            }
            if (arr[maxIndex] < arr[j]) {
                // 记录最大值的下标
                maxIndex = j;
            }
        }
        // 若是 minIndex 和 maxIndex 都相等,那么他们一定都等于 i,且后面的全部数字都与 arr[i] 相等,此时已经排序完成
        if (minIndex == maxIndex) break;
        // 将最小元素交换至首位
        int temp = arr[i];
        arr[i] = arr[minIndex];
        arr[minIndex] = temp;
        // 若是最大值的下标恰好是 i,因为 arr[i] 和 arr[minIndex] 已经交换了,因此这里要更新 maxIndex 的值。
        if (maxIndex == i) maxIndex = minIndex;
        // 将最大元素交换至末尾
        int lastIndex = arr.length - 1 - i;
        temp = arr[lastIndex];
        arr[lastIndex] = arr[maxIndex];
        arr[maxIndex] = temp;
    }
}

咱们使用 minIndex 记录最小值的下标,maxIndex 记录最大值的下标。每次遍历后,将最小值交换到首位,最大值交换到末尾,就完成了排序。
因为每一轮遍历能够排好两个数字,因此最外层的遍历只需遍历一半便可。
二元选择排序中有一句很重要的代码,它位于交换最小值和交换最大值的代码中间:
if (maxIndex == i) maxIndex = minIndex;

这行代码的做用处理了一种特殊状况:若是最大值的下标等于 i,也就是说 arr[i] 就是最大值,因为 arr[i] 是当前遍历轮次的首位,它已经和 arr[minIndex] 交换了,因此最大值的下标须要跟踪到 arr[i] 最新的下标 minIndex。

2.四、二元选择排序的效率

在二元选择排序算法中,数组须要遍历的范围缩小了一倍。那么这样可使选择排序的效率提高一倍吗?

从代码能够看出,虽然二元选择排序最外层的遍历范围缩小了,但 for 循环内作的事情翻了一倍。也就是说二元选择排序没法将选择排序的效率提高一倍。但实测会发现二元选择排序的速度确实比选择排序的速度快一点点,它的速度提高主要是由于两点:

  • 在选择排序的外层 for 循环中,i 须要加到 arr.length - 1 ,二元选择排序中 i 只须要加到 arr.length / 2
  • 在选择排序的内层 for 循环中,j 须要加到 arr.length ,二元选择排序中 j 只须要加到arr.length - i
    而且,在二元选择排序中,咱们能够作一个剪枝优化,当 minIndex == maxIndex 时,说明后续全部的元素都相等,就比如班上最高的学生和最矮的学生同样高,说明整个班上的人身高都相同了。此时已经排序完成,能够提早跳出循环。经过这个剪枝优化,对于相同元素较多的数组,二元选择排序的效率将远远超过选择排序。

2.五、时间复杂度&空间复杂度

前文已经说到,选择排序使用两层循环,时间复杂度为 O(n^2); 只使用有限个变量,空间复杂度 O(1)。二元选择排序虽然比选择排序要快,但治标不治本,二元选择排序中作的优化没法改变其时间复杂度,二元选择排序的时间复杂度仍然是 O(n^2);只使用有限个变量,空间复杂度 O(1)。

3、插入排序

插入排序的思想很是简单,生活中有一个很常见的场景:在打扑克牌时,咱们一边抓牌一边给扑克牌排序,每次摸一张牌,就将它插入手上已有的牌中合适的位置,逐渐完成整个排序。
插入排序有两种写法:

  • 交换法:在新数字插入过程当中,不断与前面的数字交换,直到找到本身合适的位置。
  • 移动法:在新数字插入过程当中,与前面的数字不断比较,前面的数字不断向后挪出位置,当新数字找到本身的位置后,插入一次便可。

3.一、交换法插入排序

public static void insertSort(int[] arr) {
    // 从第二个数开始,往前插入数字
    for (int i = 1; i < arr.length; i++) {
        // j 记录当前数字下标
        int j = i;
        // 当前数字比前一个数字小,则将当前数字与前一个数字交换
        while (j >= 1 && arr[j] < arr[j - 1]) {
            swap(arr, j, j - 1);
            // 更新当前数字下标
            j--;
        }
    }
}
private static void swap(int[] arr, int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

当数字少于两个时,不存在排序问题,固然也不须要插入,因此咱们直接从第二个数字开始往前插入。
整个过程就像是已经有一些数字坐成了一排,这时一个新的数字要加入,这个新加入的数字本来坐在这一排数字的最后一位,而后它不断地与前面的数字比较,若是前面的数字比它大,它就和前面的数字交换位置。

3.二、移动法插入排序

能够发现,在交换法插入排序中,每次交换数字时,swap 函数都会进行三次赋值操做。但实际上,新插入的这个数字并不必定适合与它交换的数字所在的位置。也就是说,它刚换到新的位置上不久,下一次比较后,若是又须要交换,它立刻又会被换到前一个数字的位置。
由此,咱们能够想到一种优化方案:让新插入的数字先进行比较,前面比它大的数字不断向后移动,直到找到适合这个新数字的位置后,新数字只作一次插入操做便可。
这种方案咱们须要把新插入的数字暂存起来,代码以下:

public static void insertSort(int[] arr) {
    // 从第二个数开始,往前插入数字
    for (int i = 1; i < arr.length; i++) {
        int currentNumber = arr[i];
        int j = i - 1;
        // 寻找插入位置的过程当中,不断地将比 currentNumber 大的数字向后挪
        while (j >= 0 && currentNumber < arr[j]) {
            arr[j + 1] = arr[j];
            j--;
        }
        // 两种状况会跳出循环:1. 遇到一个小于或等于 currentNumber 的数字,跳出循环,currentNumber 就坐到它后面。
        // 2. 已经走到数列头部,仍然没有遇到小于或等于 currentNumber 的数字,也会跳出循环,此时 j 等于 -1,currentNumber 就坐到数列头部。
        arr[j + 1] = currentNumber;
    }
}

整个过程就像是已经有一些数字坐成了一排,这时一个新的数字要加入,因此这一排数字不断地向后腾出位置,当新的数字找到本身合适的位置后,就能够直接坐下了。重复此过程,直到排序结束。
分析可知,插入排序的过程不会破坏原有数组中相同关键字的相对次序,因此插入排序是一种稳定的排序算法。

3.三、时间复杂度&空间复杂度

插入排序过程须要两层循环,时间复杂度为 O(n^2);只须要常量级的临时变量,空间复杂度为 O(1)。

4、小结

本章咱们介绍了三种基础排序算法:冒泡排序、选择排序、插入排序。

冒泡排序

冒泡排序有两种优化方式:

  • 记录当前轮次是否发生过交换,没有发生过交换表示数组已经有序;
  • 记录上次发生交换的位置,下一轮排序时只比较到此位置。

选择排序

选择排序能够演变为二元选择排序:

  • 二元选择排序:一次遍历选出两个值——最大值和最小值;
  • 二元选择排序剪枝优化:当某一轮遍历出现最大值和最小值相等,表示数组中剩余元素已经所有相等。

插入排序

插入排序有两种写法:

  • 交换法:新数字经过不断交换找到本身合适的位置;
  • 移动法:旧数字不断向后移动,直到新数字找到合适的位置。

相同点

  • 时间复杂度都是 O(n^2),空间复杂度都是 O(1)。
  • 都须要采用两重循环。

不一样点

  • 选择排序是不稳定的,冒泡排序、插入排序是稳定的;
  • 在这三个排序算法中,选择排序交换的次数是最少的;
  • 在数组几乎有序的状况下,插入排序的时间复杂度接近线性级别。
相关文章
相关标签/搜索