图解排序算法:选择排序,插入排序,希尔排序

典型排序问题

我们在实际生活中可能会遇到各种各样的排序问题,比如:对学生信息进行排序,信息可能有学号,成绩,电话等

number name score phone
30901 Nora 90 13299009122
30902 Juli 87 13998009001
30903 Jack 89 15628900876
30904 Backer 92 13608901209
30905 Tessy 76 15610003405

这里可能需要针对某些关键字(key)进行排序,比如按 key = name 排序

number name score phone
30904 Backer 92 13608901209
30903 Jack 89 15628900876
30902 Juli 87 13998009001
30901 Nora 90 13299009122
30905 Tessy 76 15610003405

我们在排序时除了按名称也可能按学号,name 是 String 类型,学号是 Int 类型,也可能是 Long,我们还可能在真实的场景中遇到其他的类型,如日期,文件等,所以我们应该设计一个可以满足各种数据类型的排序算法 sort()

我们的目标是:可以对任何类型数据进行排序
问:在无法预知需要排序的 key 是什么类型时,比如 String, Integer, Long 甚至是 java.io.File 类型,sort()方法该如何对数据进行比较呢?

我们可以使用回调方法来解决这个问题

  • 客户端将要排序的数组传给 sort() 方法
  • sort() 方法按需调用对象的 compareTo() 方法来进行真正的比较

如何实现这个回调方法呢,不同的语言都有类似的解决方案,如下:

  • Java: interfaces
  • C: function pointers.
  • C++: class-type functors.
  • C#: delegates.
  • Python, Perl, ML, Javascript: first-class functions.

本文以 Java 为例

Java Comparable Interface

public interface Comparable<T> {
   public int compareTo(T that);
}

需要比较的类型都实现这个接口,并实现 compareTo 方法就可以了

public class File implements Comparable <File> {
    ...
    public int compareTo(File b) {
        ...
        return -1;
        ...
        return +1;
        ...
        return 0;
    }
}

如果是自定义的类要进行比较也是同样的实现,自己定义比较的规则

对于两个值的比较有三种情况 > = <compareTo 对应的是 1, 0, -1

v > w : v.compareTo(w) > 0
v = w : v.compareTo(w) = 0
v < w : v.compareTo(w) < 0

接下来我们来讨论几种经典的排序算法,并对其性能进行逐一分析
在讨论这些算法之前,我们先抽象几个排序方法都会用到的工具方法,我们定义一个基类 Sort

包含以下几个方法

  • Less: 判断 v <w ?
  • Exchange: 交换目标排序数组
  • isSort: 测试数组是否是有序的
public class Sort {
    public static boolean less(Comparable v, Comparable w) {
         return v.compareTo(w) < 0;
    }

    public static void exch(Comparable[] a, int i, int j) {
        Comparable t = a[i];
        a[i] = a[j];
        a[j] = t;
    }

    public static void show(Comparable[] a) {
        for (int i = 0; i < a.length; i++) {
            System.out.println(a[i] + "");
        }
        System.out.println();
    }

    public static boolean isSorted(Comparable[] a) {
        for (int i = 1; i < a.length; i++) {
            if (less(a[i], a[i-1])) return false;
        }
        return true;
    }
}

选择排序

这是一种非常简单的基本排序方法,核心思想如下:

  1. 找到数组中最小的元素 A1
  2. 将 A1 与数组的第一个元素交换位置
  3. 在剩下的数组中找到最小的元素 A2
  4. 与数组的第二个元素交换位置
  5. 如此循环直至将整个数组排好序

图解过程如下


选择排序图解
代码实现:
public class SelectionSort extends Sort {
    public void sort(Comparable[] a) {
        int n = a.length;
        for (int i = 0; i < n; i++) {
            int minIndex = i;
            for (int j = i+1; j < n; j++) {
                if (less(a[j], a[minIndex])) minIndex = j;
           }
            exch(a, i, minIndex);
        }
    }
}

性能分析

  • 比较次数:(N-1) + (N-2) + ... + 1 = \frac{N^2} {2}
  • 交换次数:N

我们可以画一下排序过程来验证上面的结论


选择排序轨迹图

我们找到最小元素后就需要进行一次交换 ,所以是 N 次交换,而每次交换后,左边部分的元素已经是有序了,无需再参与比较,所以是 \frac{N^2} {2} 次比较,我们也可以直接观察图,图中黑色部分就是每次遍历比较的元素,网格是 N^2 大小,黑色和灰色部分各占了一半

时间复杂度
我们可以观察到,这个排序算法无论如何都需要进行 \frac{N^2} {2} 次比较,也就是说不管这个数组输入时是什么样的,即便是排好序的一个数组,也需要这么多次比较,意味着在最好的情况下这个算法并不会减少开销
结论:最好时间复杂度=最坏时间复杂度=平均时间复杂度= \frac{N^2} {2}

hmm,一看就不是什么好的排序算法

空间复杂度

因为我们在算法执行过程中只是在交换时申请了一个临时常量空间,下一次执行前这个空间就会释放掉,所以相当于只有 1 个空间开销,空间复杂度为 O(1),注意 O(1) 是指常量级,并不是特指 1 个,如果这里 的空间开销是 2,3,4 这样的可数常量,也是 O(1)

我们在分析排序算法时,还有一个重要的指标:是否是稳定排序
稳定排序是指如果我有两个值相等的元素,但是在 不同的位置上,比如有两个 E,一个在索引 1 上,一个在索引 4 上,排完序是否能保证,1 上的 E 是否仍排在 4 上的 E 之前,这个非常重要,因为在实际的场景中我们排序的元素一般都是含有多个字段的对象,比如前面说的学生信息,如果需要先对学号排序,再对分数进行排序,如果排序是不稳定的,那我们怎么排都无法达到我们想要的效果了。

这里的选择排序明显是非稳定排序,因为每次交换都是选一个最小值与未排序的第一个进行交换,破坏了原来元素的相对顺序,大家可以打印元素地址来进行验证

插入排序

  1. 数组分为两部分,左边是排好序的,右边是未排序的
  2. 每次取未排序的第一个元素与排好序的部分进行比较
  3. 如果比排好序的元素小,则交换位置直至左边元素全部升序排列

我们看下图解过程


插入排序

根据以上步骤实现代码

public class InsertionSort extends Sort {
    public void sort(Comparable[] a) {
        int n = a.length;
        for (int i = 0; i < n; i++) {
            for (int j = i; j > 0; j--) {
            if (less(a[j], a[j - 1])) {
                exch(a, j, j - 1);
            } else break;
        }
    }
}

性能分析

比较次数:\frac{N^2}{4}
交换次数:\frac{N^2}{4}

同样我们画一下算法轨迹来验证


插入排序轨迹图

右边有一半的数据没有被比较,左边有序的部分大概也是比较交换一半的数据,所以是 \frac{N^2} {4} 左右次比较和交换

前面的选择排序在最好和最坏情况下的时间复杂度都是一样的,无论输入数据是否有序都不会有任何区别,而插入排序在最好和最差情况下是不同的

时间复杂度
  • 最好情况:数据已经全部升序排列,这种情况就只需要 N-1 次比较,0 次交换
  • 最差情况:数据降序排序,则需要 N^2 次比较和交换
  • 空间复杂度:与选择排序一样
  • 稳定排序:是,我们在交换数据时,只在前面的数据比后面大的情况下进行交换,所以如果相同数值的数据已经在前则不会被交换了,所以是稳定的

逆序对 INVERSION

这里我们引入一个定义 逆序对(inversion) 来表示初始数据的有序情况,即在一组数据中有多少对数据是不符合我们预期顺序的

比如下面这组数据,我们希望升序排列
2 3 4 9 8 6

将它们按初始的顺序两两组合,不是升序的对如下:
9 - 8 9 - 6 8 - 6
共3个逆序对

当一个数组的逆序对数量 <= cNc 为远小于 N 的常量)时,我们称这个数组是部分有序的

由此我们可以得出这样的结论:对于部分有序的数组而言,插入排序的时间复杂度是线性的,因为交换次数就等于逆序对数,cN 就是线性时间复杂度

希尔排序

希尔排序的思想是使数组中任意间隔为 h 的元素都是有序的,这样的数组被称为 h 有序数组。

希尔排序是在插入排序的基础上实现的,是对插入排序的一种优化,也称缩小增量排序,由DL.Shell于1959年提出.

因为插入排序一次只进行一次位移,但排序过程其实需要移动很多次,所以希尔算法的思想是,我们一次移动多个位置,这种操作方式叫做 h-sorting 数组

h-sorting 数组

一个 h-sorted 数组就是以 h 为步长的数据组成的子数组是有序排列的,如下图所示

希尔排序

上面的每一组数据都是有序的,这就是一个4-sorted 数组

实现方式

实现一种排序算法,每次减少 h 的数值,比如先是实现一个 13-sorted 数组,进行数值交换,然后是 4-sorted,再进行数值交换,最后是 1-sorted ,这样整个数组就有序了

那么如何实现 h-sorted 呢,其实就是通过插入排序的方式来实现的,只是指针移动时的步长不同而已

代码如下

public static void sort(Comparable[] a) {
    int N = a.length;
    int h = 1;
    while (h < N / 3) h = 3 * h + 1;  //1, 4, 7...
        while (h >= 1) {
            for (int i = h; i < N; i++) {
            for (int j = i; j >= h && less(a[j], a[j - h]); j -= h) {
                exch(a, j, j - h);
            }
        }
        h = h / 3;
    }
}

为什么使用插入排序呢

  1. 如果 h 比较大,那么我们的子数组数据就会很小,这种情况下插入排序的性能会很好,当然其他排序的性能也不会差
  2. h 是逐渐减小的,也就是说h 越小,数组的有序度就会高,因为前面已经对 h 比较大的情况进行了部分排序,我们前面在分析插入排序时发现,在数组部分有序时插入排序性能能达到线性时间复杂度

接下来我们来看看一个数组分别使用 7,3,1 作为步长进行希尔排序的轨迹


希尔排序轨迹图

在进行希尔排序时应该如何选择 h 的值呢?

2 的次幂:1, 2, 4, 8, ...

使用 2 次幂无法正确排序,因为它不会将偶数位与基数位的值进行比较,所以不会全覆盖
那么基于这个问题,大家尝试了 2 的次幂 - 1

2 的次幂 - 1: 1,3,7,15,...

这个是可以正确排序的

knuth 提出的序列为 3x + 1,一般我们使用这个序列

3x + 1: 1, 4, 13, 40, 121, ...

这个是效果比较好的一个增量序列,因为计算起来很方便,每次我们只需要 /3 就可以了,当然我们的 h 值肯定不能大于数组的长度

选择 h 值是一个比较大的研究课题,这里不展开讨论

性能分析

最坏时间复杂度:h = 3x + 1时,最差时间复杂度为 O(N^\frac{3}{2}).
当然实际情况要比这个快很多,应该是在 N ~ NlgN 之间,目前关于希尔排序还没有特别好的模型,还有很多研究正在进行中,只能说目前的性能是这样的标准,后续可能会有更优的方案

希尔排序的优点

  1. 性能比较好,除非数据非常非常大
  2. 代码量比较少

实际上在代码量非常非常大的情况下,一般都不会使用单一的算法来完成,而在代码非常少的情况下就拥有了这种性能的算法并不多,因为一般达到这个性能的算法都会对数组进行大大小小的分区,代码量比希尔排序要大得多,所以希尔排序非常适合用在嵌入式或者硬件排序系统中。

关于希尔排序的研究和优化还远没有结束,而且据 DL.Shell 提出以来,大家已经为此努力了几十年,仍没有一个最优的解决方案

小结

算法 最好时间复杂度 最坏时间复杂度 空间复杂度 是否稳定排序
选择排序 O(\frac{N^2} {2}) O(\frac{N^2} {2}) O(1)
插入排序 O(N) O(\frac{N^2} {4}) O(1)
希尔排序 O(N) O(N^\frac{3}{2}) O(1)

本文由 coursera 上的算法第四版的公开课内容整理而成,这个公开课是算法(第四版)的作者 Robert Sedgewick 主讲,课程内容非常好,代码在这里,会持续更新课程内容上的代码实践以及课后的作业

参考资料

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,980评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,422评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,130评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,553评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,408评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,326评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,720评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,373评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,678评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,722评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,486评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,335评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,738评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,283评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,692评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,893评论 2 335

推荐阅读更多精彩内容