换个角度深入理解排序算法

281 阅读12分钟

本篇文章适用人群?

适用于非专业算法人员,能够协助你理解好各种排序算法的优劣,以后的应用层工作中能够知晓什么情况适用什么样的算法。 提升开发效率。也为今后阅读其他优秀源码打下一定的基础。

冒泡排序的优化算法真的很有用吗?

答: 未必。我们先来看一下网上流传很久的“优化”过的冒泡排序算法。其算法的本质是: 在冒泡的过程中,如果 发现这一次没有发生数据交换,那么整体的冒泡就结束。 我们来看一下这普通冒泡和优化冒泡的代码:

 /**
     *  普通冒泡
     */
    public static void bubbleSort(int[] a) {
        long now = System.currentTimeMillis();
        System.out.println("bubbleSort start");
        for (int i = 0; i < MAX_NUMBER; i++) {
            for (int j = 0; j < MAX_NUMBER - i - 1; j++) {
                if (a[j] > a[j + 1]) {
                    int tmp = a[j];
                    a[j] = a[j + 1];
                    a[j + 1] = tmp;
                }
            }
        }
        System.out.println("bubbleSort take time=" + (System.currentTimeMillis() - now));
    }

    /**
     * 优化过的冒泡
     * @param a
     */
    public static void smartBubbleSort(int[] a) {
        long now = System.currentTimeMillis();
        System.out.println("smartBubbleSort start");
        for (int i = 0; i < MAX_NUMBER; i++) {
            boolean changeFlag = false;
            for (int j = 0; j < MAX_NUMBER - i - 1; j++) {
                if (a[j] > a[j + 1]) {
                    int tmp = a[j];
                    a[j] = a[j + 1];
                    a[j + 1] = tmp;
                    changeFlag = true;
                }
            }
            if (!changeFlag) {
                break;
            }
        }
        System.out.println("smartBubbleSort take time=" + (System.currentTimeMillis() - now));
    }

可以想象一下,对于极端的情况下,如果给你的排序的数据 是已经排序好的,那么对于普通冒泡来说要走o(n²),而 对于我们这个优化过的冒泡来说只走一次,就结束了。

但是现实中,我想没人那么无聊给一个排好序的再用冒泡排序吧,所以我们写一段测试代码来看一下这个所谓优化冒泡 的实际效果,我们给定一个长度为100000的数组,数组里面全是无序的int值,我们用普通冒泡和优化冒泡来给这个数组排序 测试实际执行时间:

可以看出来 这个所谓的优化算法 几乎没有什么提升的效果。所以大家无视就好。虽然可能普通冒泡算法都没什么人用。

为什么选择排序几乎没人用?

答: 我们来看一下选择排序的大概算法思想:1.给定一个长度为n的数组。2.第一次从下标0开始,遍历到数组最后,把最小的 那个值,和我们的a[0]来做一次互换。3.第二次从下标1开始,第三次从下标2开始。。。以此类推。 这个选择排序为什么没人用呢?我们来看一下,如果给你一个数组 3 5 3 1 2 如果用选择排序来做,你会发现,第一次遍历,我们的第一个3 就跑到了 数组的最后一个位置, 再说的简单一点,假设 原来的数组 3 5 3 1 2,我们设第一个3为3a 第二3 为3b,当用到选择排序的时候,第一次遍历结束以后,我们的3a就跑 到了3b的后面去了,这和 初始数组 3a在3b前面是相反的 这叫不稳定排序,那么这个不稳定排序有什么危害呢? 举个例子:

假设我们现在要对南京市300w辆机动车进行排序,排序的规则是 按照价格从低到高排,如果价格一样就按照上牌时间从早到晚。

那按照这个需求,我们应该怎么做呢?先用价格排序,然后 找同样价格区间的车辆 对这些车辆做上牌时间的排序。

问题是 这个找同样价格区间的车辆貌似很麻烦啊。。。。有什么方便的方法吗?答案是有的!

如果使用 稳定排序算法 那么就可以方便的解决这个问题。

我们先按照上牌时间对这300w辆机动车进行排序,排序结束以后 再找一个 稳定排序算法 对这个排好序的机动车 进行价格维度 上的排序 就可以解决问题。因为是 稳定排序,所以 第一遍 上牌时间排序完的数组,如果价格一样的话,他们的位置是不会 发生变动的。

由此可见,为了方便,对于多数排序算法来说,如果时间复杂度差不多,我们理应选择稳定排序算法(比如冒泡和插入排序), 而放弃不稳定排序(选择排序)

时间复杂度相同的冒泡,选择,和插入排序,真的实际用起来计算速度是一样的吗?

答:暂时不考虑选择排序,不稳定排序先不考虑了,有兴趣可以自己写一段算法来试试。我们这里仅只比对冒泡和插入排序的 性能。随便百度都知道 这三者的时间复杂度都是o(n²)。 那么实际跑一遍看一下?

public static void insertionSort(int[] a) {
        long now = System.currentTimeMillis();
        System.out.println("insertionSort start");
        for (int i = 1; i < MAX_NUMBER; i++) {
            int value = a[i];
            int j = i - 1;
            while (j >= 0) {
                if (a[j] > value) {
                    a[j + 1] = a[j];
                    --j;
                } else {
                    break;
                }
            }
            a[j + 1] = value;
        }
        System.out.println("insertionSort take time=" + (System.currentTimeMillis() - now));

    }

然后对同样一组 10w大小的数组,来进行排序看看执行时间:

诶,这个速度怎么一下快了这么多?都快了一个量级了。。。

说好的时间复杂度都是o(n²)呢?

可以看一下 对于可能执行o(n²)的语句来说,冒泡算法我们三次赋值语句,而插入排序只有一次赋值。 所以对于同样的遍历次数来说,冒泡要执行的语句是插入的三倍至多。

这就是为什么时间复杂度一致的情况,插入排序要比冒泡排序快这么多的原因!

总结:对于简单的排序算法来说,我们总是优先选择稳定排序的算法,在这基础之上,即使时间复杂度冒泡和插入算法差不多, 但是实际执行时间插入要比冒泡快很多,所以多数时候不考虑快排等高级算法,我们总是优先使用插入排序,冒泡和选择 不考虑

归并排序和快速排序到底有啥区别啊,为啥看起来都差不多?实际怎么选?

我们先来看归并排序。归并排序的算法很好理解。

给定一个数组长度为n的数组, 我们把这个数组 分成 2个数组,从中间开始分。比方说这个n为100,那么就分成

0-49,50-99 这2个数组,然后以此类推,最后我们这个数组就被分成了 2个 2个一组的数组,一直分到这样2个2个一个的数组以后

我们再反向merge,也就是说 这个递归的过程是 先把这个数组 一分为二,当分到无法在一分为二的时候,我们再合并,

注意这个合并的过程,我们就可以在合并的时候做 排序了

所以归并排序的算法简单来说就是 一个递归的过程,这个递归的过程,就是把数组一分为2,如果发现无法把数组一分为2了, 就可以把这2个数组进行合并,合并的时候进行排序。比方说 上面那个n为100的数组,最后一次merge 就是0-49 和50-99 这2个数组merge,merge的时候,可以确定的是0-49 和50-99这2个数组都是有序的,现在我们要将 0-49和50-992个有序数组 合并,合并之后 这个长度为100的数组 就完全是排序的好的了。

所以归并排序的算法核心就是如何确定这个merge的算法。 我们简单把这个merge的过程抽象成一个题目就是:

给定一个长度为n的数组,已知这个数组 (0-n/2)(n/2+1,n-1) 这2段都是有序的(其实就是这个数组的前半段和后半段分别都是有序的),现在要求将这个长度为n的数组 进行排序,排序之后,整个数组都是有序的。

这个题目看起来就清晰明了许多,实际上这个小问题就太简单了,我们只需要用一个临时的temp数组来对这2段数组进行排序, 排序完毕以后 再把这个temp数组 拷贝到 原数组内即可。

归并排序是稳定排序算法吗?

归并排序当然是稳定排序算法,我们只要保证我们merge的过程中,如果前面的数组有一个值和 后面的数组 值一样的话 让这2个相同值 不要交换顺序即可。

归并排序的缺点是什么?

算法分析我们可以看出来,归并排序还需要一个临时的tmp数组,这对空间复杂度是有影响的,且,这个临时数组除了 被赋值以外还要赋值给原数组,这中间的开销也很大。同样的时间复杂度的话这种操作实际性能表现上会相当一般。 但是归并排序优点就是稳定排序。

归并排序的时间复杂度O(nlogn),这个分析过程网上太多,我就不过多叙述了。有兴趣的自己可以看一下。

最后下面来看看和归并排序经常放在一起比较的快排序到底是什么思路?和归并相比有啥异同。

快排序算法简述:给定一个长度为n的数组,我们假设位置[N-1]的数组元素为一个锚点,比这个锚点小的就放到 左边,比这个锚点大的就放到右边。自然锚点就在中间了。(注意单次的这个过程锚点左边和右边的元素都是没有排序的哦

然后递归这个过程到最后,这个长度为n的数组就排序完毕了。(其实快排的算法简述比归并要好理解多了。。。)

那么,显然这个快排序的递归算法的核心问题 我们又可以抽象成一个小题目:

给定一个长度为n的数组,假设锚点的值是数组的最后一个位置,现在要求把这个数组进行排序,把锚点放到数组的中间位置, 比锚点小的都在这个位置的左边,比锚点大的都在位置的右边。 (锚点左边和右边的元素 当然是没有排序过的)

这个问题看起来也很简单,我们只要申请一个临时tmp数组,然后根据要求把temp数组生成完毕以后再赋值给原数组就可以了。 但是如果这样不是和归并排序差不多了么,那我还废话这么多干啥

所以快排序的递归算法的核心问题其实是:

给定一个长度为n的数组,假设锚点的值是数组的最后一个位置,现在要求把这个数组进行排序,把锚点放到数组的中间位置, 比锚点小的都在这个位置的左边,比锚点大的都在位置的右边。 (锚点左边和右边的元素 当然是没有排序过的) (要求不可以申请临时空间数组)

怎么样,这个抽象过的题目是不是有点leetcode的感觉?leetcode很多题的进阶要求都是不可以申请临时空间。

那我们来看看如何解决这个问题:

设置2个指针为i和j,初始状态分别指向这个数组的第一个位置,然后对j这个指针进行++j的for循环遍历,

如果发现数组a[j]的值 小于 这个锚点的值,那么 就把a[i]和a[j]的值 交换位置,交换位置以后 i++;

当这个循环结束的时候,我们可以想到, 指针i的位置 的左边都是比锚点小的,右边都是比锚点大的。

所以最后我们只要把锚点和a[i]的值互换 这个数组就被我们成功的一分为2了,并且还不需要额外tmp数组空间。

赋值语句也不像归并那样那么多。这里给出一个比较好的实现

public class QuickSort {

  // 快速排序,a是数组,n表示数组的大小
  public static void quickSort(int[] a, int n) {
    quickSortInternally(a, 0, n-1);
  }

  // 快速排序递归函数,p,r为下标
  private static void quickSortInternally(int[] a, int p, int r) {
    if (p >= r) return;

    int q = partition(a, p, r); // 获取分区点
    quickSortInternally(a, p, q-1);
    quickSortInternally(a, q+1, r);
  }

  private static int partition(int[] a, int p, int r) {
    int pivot = a[r];
    int i = p;
    for(int j = p; j < r; ++j) {
      if (a[j] < pivot) {
        int tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
        ++i;
      }
    }

    int tmp = a[i];
    a[i] = a[r];
    a[r] = tmp;
    return i;
  }
}

所以最后我们来总结下:

相对于归并算法来说,快排序不需要利用额外空间,空间复杂度只有o(1),赋值语句也不像归并那么多要做多次,且算法复杂度来说,快排序平均也是O(nlogn),极端情况才有O(n2)。所以速度上快排序绝大多数都比归并排序要快,但是要注意的是如果你需要的是稳定排序就不要选择快排。实际上java的sdk提供的很多sort算法都是快排序的变种,有兴趣的可以看看源码。主要是锚点的选择有更为复杂高效的算法