示意图
归并排序的核心思想还是很简单的。如果要排序一个数组,我们先把数组从中间分层前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起。这样整个数组就有序了
可以看到这种结构很像一棵完全二叉树,
归并排序使用的就是分治思想。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。
分治算法一般都是用递归实现的,分阶段可以理解为就是递归拆分子序列的过程。(分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。)
为此我们需要先找到递推公式,然后找到终止条件。
递
推
公
式
:
m
e
r
g
e
S
o
r
t
(
p
.
.
.
r
)
=
m
e
r
g
e
(
m
e
r
g
e
S
o
r
t
(
p
.
.
.
q
)
,
m
e
m
g
e
S
o
r
t
(
q
+
1...
r
)
)
递推公式:mergeSort(p...r) = merge(mergeSort(p...q), memgeSort(q+1...r))
递推公式:mergeSort(p...r)=merge(mergeSort(p...q),memgeSort(q+1...r))
终
止
条
件
:
p
>
=
r
终止条件:p >= r
终止条件:p>=r
merge_sort(p…r) 表示,给下标从 p 到 r 之间的数组排序。我们将这个排序问题转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中下标 q 等于 p 和 r 的中间位置,也就是 (p+r)/2。当下标从 p 到 q 和从 q+1 到 r 这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据就也排好序了。
// 归并排序算法, a是数组,n表示数组大小
public static void mergeSort(int[] a, int n) {
mergeSortInternally(a, 0, n-1);
}
// 递归调用函数
private static void mergeSortInternally(int[] a, int p, int r) {
// 递归终止条件
if (p >= r) return;
// 取p到r之间的中间位置q,防止(p+r)的和超过int类型最大值
int q = p + (r - p)/2;
// 分治递归
mergeSortInternally(a, p, q);
mergeSortInternally(a, q+1, r);
// 将A[p...q]和A[q+1...r]合并为A[p...r]
merge(a, p, q, r);
}
从上面可以看出,merge(A[p...r], A[p...q], A[q+1...r])
的作用就是将已经有序的A[p…q]和A[q+1…r]合并成一个有序的数组,并且放入A[p…r]。那这个过程具体应该如何做呢?
如图所示,我们申请一个临时数据tmp,大小与A[p…r]相同。我们用两个游标i和j,分别指向A[p…q]和A[q+1…r]的第一个元素。比较这两个元素A[i]和A[j],如何A[i]<=A[j],我们就把A[i]放入到临时数组tmp,并且i后移一位,否则将A[j]放入到数组tmp,j后移一位。
继续比较过程,直到其中一个子数组中的所有数组都放入临时数组中,再把另一个数组中的数据依次加入临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组tmp中的数据拷贝到原数组A[p…r]中
现在看一下治的阶段
我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将 [4,5,7,8] 和[1,2,3,6]两个已经有序的子序列,合并为最终序列 [1,2,3,4,5,6,7,8],来看下实现步骤
实现
C++
#include <iostream>
#include <list>
#include <vector>
#include <map>
#include <string>
using namespace std;
#include <vector>
template<typename T>
std::ostream& print(std::ostream &out,T const &val) {
return (out << val << " ");
}
template<typename T1,typename T2>
std::ostream& print(std::ostream &out,std::pair<T1,T2> const &val) {
return (out << "{" << val.first << " " << val.second << "} ");
}
template<template<typename,typename...> class TT,typename... Args>
std::ostream& operator<<(std::ostream &out,TT<Args...> const &cont) {
for(auto&& elem : cont) print(out,elem);
out << "\n";
return out;
}
/**
* 合并的方法
* @param arr 待排序的原始数组
* @param left 左边有序序列的初始索引
* @param mid 中间索引
* @param right 右边索引
* @param temp 做中转的数组
*/
void merge(std::vector<int>&arr, int left, int mid, int right, std::vector<int> &temp){
int i = left; // 初始化i, 左边有序序列的初始索引
int j = mid + 1; //初始化j, 右边有序序列的初始索引
int t = 0; // 指向temp数组的当前索引
//(一)
//先把左右两边(有序)的数据按照规则填充到temp数组
//直到左右两边的有序序列,有一边处理完毕为止
while (i <= mid && j <= right){
//如果左边的有序序列的当前元素,小于等于右边有序序列的当前元素
//即将左边的当前元素,填充到temp数组
//然后 t++, i++
if(arr[i] <= arr[j]){
temp[t++] = arr[i++];
}else{ //反之,将右边有序序列的当前元素,填充到temp数组
temp[t++] = arr[j++];
}
}
//(二)
//把有剩余数据的一边的数据依次全部填充到temp
while (i <= mid){ //左边的有序序列还有剩余的元素,就全部填充到temp
temp[t++] = arr[i++];
}
while (j <= right){
temp[t++] = arr[j++];
}
//(三)
//将temp数组的元素拷贝到arr
//注意,并不是每次都拷贝所有,这句话和递归有关,要理解有一定的难度
//因为这里并不是全部分完之后再合,而是分一点合一点
t = 0;
int tempLeft = left;
while (tempLeft <= right){
arr[tempLeft++] = temp[t++];
}
}
void mergeSort(std::vector<int>&arr, int left, int right, std::vector<int> &temp){
if(left < right) {
int mid = (left + right) / 2; //中间索引
//向左递归进行分解
mergeSort(arr, left, mid, temp);
//向右递归进行分解
mergeSort(arr, mid + 1, right, temp);
//合并
merge(arr, left, mid, right, temp);
}
}
int main(int argc, char* argv[]){
vector<int> arr = {8, 4, 5, 7, 1, 3, 6, 2, 2};
vector<int> temp;
temp.resize(arr.size());
mergeSort(arr, 0, arr.size() - 1, temp);
std::cout << arr;
return 0;
}
java
public class MergeSort {
public static void main(String[] args) {
int arr[] = {8, 4, 5, 7, 1, 3, 6, 2};
int temp[] = new int[arr.length]; //归并排序需要一个额外空间
mergeSort(arr, 0, arr.length - 1, temp);
System.out.println("排序后的结果为:" + Arrays.toString(arr));
}
//分 + 合方法:先递归分,再合
public static void mergeSort(int[] arr, int left, int right, int[] temp) {
if(left < right) {
int mid = (left + right) / 2; //中间索引
//向左递归进行分解
mergeSort(arr, left, mid, temp);
//向右递归进行分解
mergeSort(arr, mid + 1, right, temp);
//合并
merge(arr, left, mid, right, temp);
}
}
/**
* 合并的方法
* @param arr 待排序的原始数组
* @param left 左边有序序列的初始索引
* @param mid 中间索引
* @param right 右边索引
* @param temp 做中转的数组
*/
public static void merge(int[] arr, int left, int mid, int right, int[] temp) {
int i = left; // 初始化i, 左边有序序列的初始索引
int j = mid + 1; //初始化j, 右边有序序列的初始索引
int t = 0; // 指向temp数组的当前索引
//(一)
//先把左右两边(有序)的数据按照规则填充到temp数组
//直到左右两边的有序序列,有一边处理完毕为止
while (i <= mid && j <= right) { //继续
//如果左边的有序序列的当前元素,小于等于右边有序序列的当前元素
//即将左边的当前元素,填充到temp数组
//然后 t++, i++
if(arr[i] <= arr[j]) {
temp[t] = arr[i];
t = t + 1;
i = i + 1;
} else { //反之,将右边有序序列的当前元素,填充到temp数组
temp[t] = arr[j];
t += 1;
j += 1;
}
}
//(二)
//把有剩余数据的一边的数据依次全部填充到temp
while( i <= mid) { //左边的有序序列还有剩余的元素,就全部填充到temp
temp[t] = arr[i];
t += 1;
i += 1;
}
while( j <= right) { //右边的有序序列还有剩余的元素,就全部填充到temp
temp[t] = arr[j];
t += 1;
j += 1;
}
//(三)
//将temp数组的元素拷贝到arr
//注意,并不是每次都拷贝所有,这句话和递归有关,要理解有一定的难度
//因为这里并不是全部分完之后再合,而是分一点合一点
t = 0;
int tempLeft = left; //
//第一次合并 tempLeft = 0 , right = 1 // tempLeft = 2 right = 3 // tL=0 ri=3
//最后一次 tempLeft = 0 right = 7
while(tempLeft <= right) {
arr[tempLeft] = temp[t];
t += 1;
tempLeft += 1;
}
}
}
golang
package main
import "fmt"
func merge(low []int, high []int)[]int {
left := 0
right := 0
res := make([]int,0)
for left < len(low) && right < len(high) {
if low[left] < high[right]{
res = append(res, low[left])
left++
}else if low[left] > high[right] {
res = append(res, high[right])
right++
}else{
res = append(res, low[left], high[right])
left++
right++
}
}
if left < len(low){
res = append(res, low[left:]...)
}
if right < len(high) {
res = append(res, high[right:]...)
}
return res
}
func mergeSort(arr []int) []int {
length := len(arr)
if length <= 1{
return arr
}else{
mid := length/2;
low := mergeSort(arr[:mid])
high := mergeSort(arr[mid:])
return merge(low, high)
}
}
func main() {
arr := []int{23,19,81,79,89,83,17,48,55,26,16,1,46,95,10}
fmt.Println(mergeSort(arr))
}
优化: 我们没有必要把他切割到一个然后合并,一般我们采用小于N个的时候用插入或者其他排序方法来提高排序效率
func mergeSort(arr []int) []int {
length := len(arr)
if length<=1{
return arr //小与10改用插入排序
}else if length>1 &&length <5{
return 插入排序(arr)
} else{
mid := length/2;
low := mergeSort(arr[:mid])
high := mergeSort(arr[mid:])
return merge(low, high)
}
}
性能分析
(1)归并排序是稳定的排序算法吗?
归并排序稳不稳定关键是要看merge()函数,也就是两个有序子数组合并成一个有序数组的那部分代码。
在合并的过程中,如果A[p…q] 和A[q+1…r] 之间有相同的元素,那我们可以像伪代码中那样,先把 A[p…q] 中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法
(2)归并排序的时间复杂度是多少?
我们知道,递归的使用场景是一个问题a可以分解为多个子问题b、c,那求解问题a就可以分解为求解问题b、c。问题b、c解决之后,我们再把b、c的结果合并成a的结果。
如果我们定义了求解问题a的时间是T(a),求解问题b、c的时间分别是T(b)和T©,那我们就可以得到如下递推关系式:
T
(
a
)
=
T
(
b
)
+
T
(
c
)
+
K
T(a) = T(b) + T(c) + K
T(a)=T(b)+T(c)+K
其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。
套用这个公式,我们来分析一下归并排序的时间复杂度。
我们假设对n个元素进行归并排序需要的时间是T(n),那分解成两个子数组排序的时间都是T(n/2)。我们知道,merge()函数合并两个有序子数组的时间复杂度是O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式是:
T
(
1
)
=
C
;
n
=
1
时
,
只
需
要
常
量
级
的
执
行
时
间
,
所
以
表
示
为
C
。
T(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。
T(1)=C;n=1时,只需要常量级的执行时间,所以表示为C。
T
(
n
)
=
2
∗
T
(
n
/
2
)
+
n
;
n
>
1
T(n) = 2*T(n/2) + n; n>1
T(n)=2∗T(n/2)+n;n>1
通过这个公式,如何来求解 T(n) 呢?还不够直观?那我们再进一步分解一下计算过程。
T
(
n
)
=
2
∗
T
(
n
/
2
)
+
n
T(n) = 2*T(n/2) + n
T(n)=2∗T(n/2)+n
=
2
∗
(
2
∗
T
(
n
/
4
)
+
n
/
2
)
+
n
=
4
∗
T
(
n
/
4
)
+
2
∗
n
= 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
=2∗(2∗T(n/4)+n/2)+n=4∗T(n/4)+2∗n
=
4
∗
(
2
∗
T
(
n
/
8
)
+
n
/
4
)
+
2
∗
n
=
8
∗
T
(
n
/
8
)
+
3
∗
n
= 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
=4∗(2∗T(n/8)+n/4)+2∗n=8∗T(n/8)+3∗n
=
8
∗
(
2
∗
T
(
n
/
16
)
+
n
/
8
)
+
3
∗
n
=
16
∗
T
(
n
/
16
)
+
4
∗
n
= 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
=8∗(2∗T(n/16)+n/8)+3∗n=16∗T(n/16)+4∗n
.
.
.
.
.
.
......
......
=
2
k
∗
T
(
n
/
2
k
)
+
k
∗
n
= 2^k * T(n/2^k) + k * n
=2k∗T(n/2k)+k∗n
.
.
.
.
.
.
......
......
通过这样一步一步分解推导,我们可以得到 T ( n ) = 2 k T ( n / 2 k ) + k n T(n) = 2^kT(n/2^k)+kn T(n)=2kT(n/2k)+kn。当 T ( n / 2 k ) = T ( 1 ) T(n/2^k)=T(1) T(n/2k)=T(1)时,也就是 $n/2^k=1 $,我们得到 k = l o g 2 n k=log_2n k=log2n 。我们将 k 值代入上面的公式,得到 T ( n ) = C n + n l o g 2 n T(n)=Cn+nlog_2n T(n)=Cn+nlog2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O ( n l o g n ) O(nlogn) O(nlogn)。所以归并排序的时间复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)。
从我们的原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn)。
(3)归并排序的空间复杂度是多少?
归并排序的时间复杂度任何情况下都是O(nlogn)$,看起来非常优秀,但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的弱点,那就是归并排序不是原地排序算法。
这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。那,归并排序的空间复杂度到底是多少呢?
递归代码的空间复杂度并不能像时间复杂度那样累加。而且尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU只有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过n个数据的大小,所以空间复杂度是O(n)