0.归并排序简介
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。若将k个有序表合并成一个有序表称为k路归并。
1.归并排序的思想
归并排序主要是通过分治法的思想来将一个大的无序序列逐渐二分成为多个小的无序序列,当小的无序序列只有1个元素时,这些序列毫无疑问都是有序序列。然后又从自底向上将小的有序序列合并成大的有序序列,直到将整个待排序序列合并完成,整个排序算法就结束了。
举个栗子:假设现在有一个数组,我们要将这些数据按从小到大的升序进行排序,数组中的元素为{8,6,1,2,4,7,3,5}。那么归并排序的排序过程如下所示,其中|表示划分:
划分流程
level0 8 6 1 2 4 7 3 5
level1 8 6 1 2|4 7 3 5
level2 8 6|1 2|4 7|3 5
level3 8|6|1|2|4|7|3|5
归并流程
level3 8|6|1|2|4|7|3|5
level2 6 8|1 2|4 7|3 5
level1 1 2 6 8|3 5 4 7
level0 1 2 3 4 5 6 7 8
从上面可以看出,只有当小的有序序列有序,并将小的有序序列合并成一个大的有序序列,以此类推,才能使整个序列有序。归并排序在划分子序列时的时间复杂度是O(nlogn),而合并有序序列时的时间复杂度是O(n)。
这个O(n)的合并操作是怎么实现的呢?答案是空间换时间!下面举例说明。
假设待合并的序列是a = [1 2 6 8|3 4 5 7],为了O(n)的将两个有序序列合并成一个有序序列,我们需要分配一个辅助空间aux = [1 2 6 8 3 4 5 7]。
并假设index指向待合并序列a的元素,初始值为0(下标)。i指向辅助空间aux左半部分的元素,初始值为0(下标)。j指向辅助空间aux右半部分的元素,初始值为mid+1(即右半部分的起始下标)。那么此时模型如下所示:
a 1 2 6 8 3 4 5 7
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
下面开始合并,aux[i]和aux[j]比,aux[i]更小那么就a[index]=aux[i],i++,index++。模型如下所示:
a 1 2 6 8 3 4 5 7
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
继续,aux[i]和aux[j]比,aux[i]更小那么就a[index]=aux[i],i++,index++。模型如下所示:
a 1 2 6 8 3 4 5 7
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
aux[i]和aux[j]比,aux[j]更小,那么就a[index]=aux[j],++j,++index。模型如下所示:
a 1 2 3 8 3 4 5 7
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
aux[i]和aux[j]比,aux[j]更小,那么就a[index]=aux[j],++j,++index。模型如下所示:
a 1 2 3 4 3 4 5 7
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
aux[i]和aux[j]比,aux[j]更小,那么就a[index]=aux[j],++j,++index。模型如下所示:
a 1 2 3 4 5 4 5 7
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
aux[i]和aux[j]比,aux[i]更小,那么就a[index]=aux[i],++i,++index。模型如下所示:
a 1 2 3 4 5 6 5 7
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
aux[i]和aux[j]比,aux[j]更小,那么就a[index]=aux[j],++j,++index。模型如下所示:
a 1 2 3 4 5 6 7 7
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
这个时候j已经超过了右半部分的边界,那么只需要a[index]=aux[i],++i,++index就可以了
a 1 2 3 4 5 6 7 8
^
|
index
aux 1 2 6 8 3 4 5 7
^ ^
| |
i j
到这里,一次合并有序列表的工作已经完成。
2.归并排序的实现
template<typename T>
void mergeKernel(vector<T>& data, int left, int mid, int right)
{
//分配辅助空间,空间大小为两个部分的大小
vector<T> aux(data.size());
//将两个部分的数据拷贝到辅助空间
for (int k = 0; k < aux.size(); ++k)
{
aux[k] = data[k];
}
//开始合并
int i = left;
int j = mid + 1;
for (int index = left; index <= right; ++ index)
{
if (j > right)
{
data[index] = aux[i];
i++;
}
else if (i > mid)
{
data[index] = aux[j];
j++;
}
else
{
if (aux[i] < aux[j])
{
data[index] = aux[i];
++i;
}
else
{
data[index] = aux[j];
++j;
}
}
}
}
template<typename T>
void mergeSortKernel(vector<T>& data, int left, int right)
{
//划分到只剩一个元素,没必要合并
if (left>=right)
{
return;
}
//二分进行划分
int mid = (left + right) / 2;
mergeSortKernel(data, left, mid);
mergeSortKernel(data, mid + 1, right);
//将两个部分进行合并
mergeKernel(data, left, mid, right);
}
template<typename T>
void mergeSort(vector<T>& data)
{
//[left...right]
mergeSortKernel(data,0, data.size() - 1);
}
3.分析
归并排序是O(nlogn)级别的算法而插入排序时O(n^2)级别的算法,那么港道理归并会比插入要快咯。下面做了一下实验,以随机生成的100000的数据进行实验,实验结果如图所示:
从图中看出,归并居然比插入还慢。。。我还能怎么办,我也很绝望啊。。。肯定是我撸的姿势不对。。然后仔细review代码看出,每次在合并有序序列时,都开辟了一个O(n)级别的辅助空间,也就是说不管这一次合并的序列有多大,都是开辟n=100000的辅助空间,这就是问题所在。每次合并都有很大的开辟和销毁空间的开销,这也就导致了写出来的归并比插入还慢近8倍的速度。所以上面的归并排序代码还有待优化。优化代码如下:
template<typename T>
void mergeKernel(vector<T>& data, int left, int mid, int right)
{
//分配辅助空间,空间大小为两个部分的大小
//根据待合并序列的大小来分配aux
vector<T> aux(right-left+1);
//将两个部分的数据拷贝到辅助空间
for (int k = left; k <= right; ++k)
{
aux[k - left] = data[k]; //k-lef是偏移量
}
//开始合并
int i = 0;
int j = (aux.size()-1)/2+1;
for (int index = left; index <= right; ++ index)
{
if (j > aux.size()-1)
{
data[index] = aux[i];
i++;
}
else if (i > (aux.size() - 1) / 2)
{
data[index] = aux[j];
j++;
}
else
{
if (aux[i] < aux[j])
{
data[index] = aux[i];
++i;
}
else
{
data[index] = aux[j];
++j;
}
}
}
}
通过上面的优化之后,在来看一下实验结果,实验结果如下图所示:
很明显,经过优化之后,归并排序比插入排序不知道快了多少倍。。当然,待优化的地方还不这一点,个人觉得还有两个地方可以继续优化。首先,如果两个有序序列足够小,说明这个序列有序程度会比较高,在上一篇中提到的插入排序算法正是因为若待排序序列有序程度较高的情况下效率很高的特性,在这里可以给定个阈值来判断序列是否足够小,如果足够小就不进行合并,而是进行插入排序(合并要开辟辅助空间啦。。)。其次,在合并之前,如果能判断出这两个序列a和b,ab是个有序序列那么也就没有进行合并操作(合并要开辟空间了啦)。基于这两点优化,代码如下:
template<typename T>
void insertSort(vector<T>& data, int left, int right)
{
for (int i = left+1; i <= right; ++i)
{
int k = data[i];
int j;
for (j = i; j > 0; --j)
{
if (data[j] < data[j - 1])
{
data[j] = data[j - 1];
}
else
break;
}
data[j] = k;
}
}
template<typename T>
void mergeSortKernel2(vector<T>& data, int left, int right)
{
//size在[0-30]的话就插入排序,而不合并
if (right - left + 1 <= 100)
{
insertSort(data, left, right);
}
//二分进行划分
int mid = (left + right) / 2;
mergeSortKernel(data, left, mid);
mergeSortKernel(data, mid + 1, right);
//两个部分的序列A,B都是有序的,如果A的最后一个小于等于B的第一个那么整个序列就是有序的
if (data[mid] <= data[mid + 1])
return;
//将两个部分进行合并
mergeKernel(data, left, mid, right);
}
为了验证优化的效果,以1000000的随机数据对归并排序的优化进行验证,实验结果如下:
从图可以看到,经过优化后效率提升了那么一丢丢。虽然只有一丢丢,但好多个一丢丢就是很多的提升了。
4.总结
归并排序算法主要用分治法的思想对无序序列进行二分,然后在归并成一个有序序列。值得注意的是,归并排序需要O(n)的空间,并且减少空间开辟和销毁的开销,能够提升算法的性能。