排序:归并排序

1 思路

假设有这样一个数组:


数组

归并排序的思路是,将这个数组先不断的拆分为二,直至只有一个子元素。然后不断的向上合并已排好序的子数组。


归并排序过程

因此,大概的流程是这样:
  • 将一个大的数组分成两部分子数组
  • 分别对两个子数组进行排序
  • 将两个已排好序的子数组进行合并
  • 大的数组也已经排好序了
    因此,假设有一个函数,实现了上面的功能:
void sort(int[] aar, int l, int r) {
    // 1.判断待排序数组空间是否合法
    if (l >= r)
      return;

    int mid = l + (r - l) / 2;
    // 2.对左边部分子数组进行排序
    sort(aar, l, mid);
    // 3.对右边数组进行排序
    sort(aar, mid+1, r);
    // 4.合并两部分数组
    merge(aar, l, mid, r);    
}

由以上分析可知,归并排序算法的核心在于实现merge函数,这也是该算法称为归并的原因。

2 实现

2.1 merge的实现

这里先实现merge。本质上,merge就是将两个已排好序的数组合并为一个排序数组,在这里只不过是将两个已排好序的子数组合并为一个更大的排序子数组。
使用k来遍历待排序的更大的子数组,用来放置合适的值。由于会发生值变换,因此需要先把数组中的元素拷贝一份。
使用i来遍历左半部分的已排序数组,使用j来遍历右半部分的已排序的数组。不断的将两部分i和j所指向的更小的元素拷贝到索引为k中。
代码如下:

// 数组data的区间[l .. mid][mid+1 .. r]分别是有序的,对这两个区间的元素进行合并排序
private static <E extends Comparable<E>> void merge(E[] data, int l, int mid, int r) {
    E[] temp = Arrays.copyOfRange(data, l, r + 1);
    int i = l;
    int j = mid + 1;
    // k轮询原数组待操作区域的索引,用于放入新元素
    for (int k = l; k <= r; k++) {
        if (j > r) {
            data[k] = temp[i - l];
            i++;
        } else if (i > mid) {
            data[k] = temp[j - l];
            j++;
        } else if (temp[i - l].compareTo(temp[j - l]) < 0) {
            data[k] = temp[i - l];
            i++;
        } else {
            data[k] = temp[j - l];
            j++;
        }
    }
}

2.2 递归的实现

根据开始的伪代码,实现对应的Java代码

public static <E extends Comparable<E>> void sort(E[] data) {
    if (data == null || data.length <= 1) {
        return;
    }

    sort(data, 0, data.length - 1);
}
// 对数组data的区间[l .. r]进行排序
private static <E extends Comparable<E>> void sort(E[] data, int l, int r) {
    // 最简单的问题
    if (l >= r) {
        return;
    }

    // 先求解更小的问题
    int mid = l + (r - l) / 2;
    sort(data, l, mid);
    sort(data, mid + 1, r);
    // 根据最小问题的解解决当前问题
    merge(data, l, mid, r);
}

3算法复杂度分析

复杂度分析

由以上图示可知,对于归并算法的每一层,归并所需要的操作的和都为8,推而广之为n,因此每一层的算法复杂度为O(n)。
接下来在看层数,由于每次会一分为二,这可以看成是一个树结构,它的层数的量级为logn。
因此归并排序算法的复杂度为O(nlogn)

4 优化

4.1 判断是否需要merge

如果两部分已排序子数组,后半部分的最小索引的元素比前半部分最大索引的元素要大,此时,不需要重新排序:

// 对数组data的区间[l .. r]进行排序
private static <E extends Comparable<E>> void sort2(E[] data, int l, int r) {
    // 最简单的问题
    if (l >= r) {
        return;
    }
    // 先求解更小的问题
    int mid = l + (r - l) / 2;
    sort2(data, l, mid);
    sort2(data, mid + 1, r);
    // 根据最小问题的解解决当前问题
    if (data[mid].compareTo(data[mid + 1]) > 0) {
        merge(data, l, mid, r);
    }
}

针对这个优化,做一个100,000规模数据的排序比较结果如下:

MergeSort: n = 100000, costTime: 0.060000S  // 未优化
MergeSort2: n = 100000, costTime: 0.022000S  // 已优化

可以看到,优化后的算法,在这个数据规模和我的电脑上,时间快了三倍。
另一方面,加入数组本来就是一个有序的,那么,将不会进入到merge过程,整个算法的复杂度将会退化到O(n)级别。

4.2 使用插入排序排序较小子数组

归并排序的merge算法相对来说操作步骤较多,当数据规模较小时,更有可能有序,插入排序算法的性能可能会更好,因此,我们假设当子数组数据量没超过16时,使用插入排序。
首先,重新定义一个插入排序方法,允许排序一个数组的子数组区间:

// 对数组data的区间[l, r]进行排序
public static <E extends Comparable<E>> void sort(E[] data, int l, int r) {
    // 第一重循环,维持循环不变量data[l ... i)中的元素都是已排好序的
    for (int i = l; i <= r; i++) {
        // 第二重循环,将data[i]中的元素插入到已排好序的正确位置,不是合适的位置的元素,都往后挪一个索引
        E t = data[i]; // 待插入元素
        int j; // 待插入位置
        for (j = i; j - 1 >= l && t.compareTo(data[j - 1]) < 0; j--) {    // 该重循环的工作是寻找待插入的正确位置j
            data[j] = data[j - 1];
        }
        data[j] = t;
    }
}

然后,再改进sort方法

// 对数组data的区间[l .. r]进行排序
    private static <E extends Comparable<E>> void sort2(E[] data, int l, int r) {
        // 最简单的问题
        if (r - l <= 15) {
            InsertionSort.sort(data, l, r);
            return;
        }
        // 先求解更小的问题
        int mid = l + (r - l) / 2;
        sort2(data, l, mid);
        sort2(data, mid + 1, r);
        // 根据最小问题的解解决当前问题
        if (data[mid].compareTo(data[mid + 1]) > 0) {
            merge(data, l, mid, r);
        }
    }

和只是进行merge判断优化对比,在我的机子上针对100,000的数据,快了3倍多。

MergeSort: n = 100000, costTime: 0.073000S    // merge判断优化
MergeSort2: n = 100000, costTime: 0.029000S  // 进一步插入排序优化

4.3 一次性分配临时数组

在上面的实现中,每一次merge都会创建一个新的临时数组,这是非常耗性能的。我们可以一开始就定义一个的数组:

public static <E extends Comparable<E>> void sort2(E[] data) {
        if (data == null || data.length <= 1) {
            return;
        }

        E[] temp = (E[]) new Comparable[data.length];
        sort2(data, 0, data.length - 1, temp);
    }

    // 对数组data的区间[l .. r]进行排序
    private static <E extends Comparable<E>> void sort2(E[] data, int l, int r, E[] temp) {
        // 最简单的问题
        if (r - l <= 15) {
            InsertionSort.sort(data, l, r);
            return;
        }
        // 先求解更小的问题
        int mid = l + (r - l) / 2;
        sort2(data, l, mid, temp);
        sort2(data, mid + 1, r, temp);
        // 根据最小问题的解解决当前问题
        if (data[mid].compareTo(data[mid + 1]) > 0) {
            merge2(data, l, mid, r, temp);
        }
    }

    // 数组data的区间[l .. mid][mid+1 .. r]分别是有序的,对这两个区间的元素进行合并排序
    private static <E extends Comparable<E>> void merge2(E[] data, int l, int mid, int r, E[] temp) {
        System.arraycopy(data, l, temp, l, r - l + 1);

        int i = l;
        int j = mid + 1;
        // k轮询愿数组待操作区域的索引,用于放入新元素
        for (int k = l; k <= r; k++) {
            if (j > r) {
                data[k] = temp[i++];
            } else if (i > mid) {
                data[k] = temp[j++];
            } else if (temp[i].compareTo(temp[j]) < 0) {
                data[k] = temp[i++];
            } else {
                data[k] = temp[j++];
            }
        }
    }

在我的机子上,针对这样的优化的性能比较如下,可以看到,也是快了点:

MergeSort: n = 100000, costTime: 0.042000S    // 已进行了前两步优化
MergeSort2: n = 100000, costTime: 0.037000S  // 增加统一数组分配优化

自底向上归并排序算法

上面实现的归并排序算法,是将一个数组不断拆分成两个更小的数组,这样一种思路是一种自顶向下的。另一种相反的思路是自底向上的。
例如对于一开始的八个元素,先对每两个元素进行合并:


每两个元素进行合并

然后在进行每四个元素进行合并:


每四个元素进行合并

然后就是接下来,对所有8个元素进行合并。
实现代码如下,需要注意的是,当数组长度不为2的n次方的时候,对于merge的右边界值,需要和数组的边界做比较:
public static <E extends Comparable<E>> void sort(E[] data) {
    if (data == null || data.length <= 1) {
        return;
    }
    E[] temp = (E[]) new Comparable[data.length];
    int n = data.length;
    // 遍历合并的区间的长度
    for (int sz = 1; sz < n; sz += sz) {
        // 合并[i, i + sz -1]和[i + sz, Math.min(i + sz + sz - 1, n -1)]
        for (int i = 0; i + sz < n; i += sz + sz) {
            if (data[i + sz - 1].compareTo(data[i + sz]) > 0) {
                merge(data, i, i + sz - 1, Math.min(n - 1, i + sz + sz - 1), temp);
            }
        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,393评论 5 467
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,790评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,391评论 0 330
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,703评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,613评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,003评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,507评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,158评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,300评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,256评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,274评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,984评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,569评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,662评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,899评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,268评论 2 345
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,840评论 2 339

推荐阅读更多精彩内容