归并排序是一种相当稳健的排序算法,无论何种输入序列,其期望时间复杂度和最坏时间复杂度都是Θ(nlogn),这已经达到了基于比较排序算法的渐进下界。
因此归并排序时常会用于对可能导致quicksort退化的序列排序。
归并排序是典型的分治算法,一个最常见的实现如下:
void mergeSort(int a[], const int low, const int high) {
if (low < high) {
const int midIndex = (low + high) >> 1;
// take apart
MergeSort(a, low, midIndex);
MergeSort(a, midIndex + 1, high);
// merge two parts
Merge(a, low, midIndex + 1, high);
}
return ;
}
void Merge(int a[], const int lowFirst, const int highFirst, const int highLast) {
int l = lowFirst;
int r = highFirst;
int len = highLast - lowFirst + 1;
int* pBuffer = new int[len];
assert(pBuffer != NULL);
int* p = pBuffer;
while (l < highFirst && r <= highLast) {
*p++ = (a[l] <= a[r]) ? a[l++] : a[r++];
}
while (l < highFirst) {
*p++ = a[l++];
}
while (r <= highLast) {
*p++ = a[r++];
}
p = pBuffer;
for (int i = lowFirst; i <= highLast;) {
a[i++] = *p++;// special case: i = 10, p[0]
}
delete [] pBuffer;
}
但是在实践中,归并排序花费的时间往往超过预期,对于普通的序列而言,所花费的时间甚至远远超过quicksort。
究其原因,和归并排序的内存策略有关(不断地分配new与释放free内存)。
归并排序不是原地排序,需要额外的存储空间。并且在每次merge过程中,需要动态分配一块内存以完成对两个数据堆的排序合并。并且排序完毕之后,我们需要将存储空间中的数据复制并覆盖原序列。
最后一步操作是由归并排序自身性质决定,无法优化,所以我们只能针对Merge操作。
经过分析很容易知道,对于长度为n的序列,要执行logn次的merge操作,这意味着需要进行logn次的内存分配和回收。内存操作开销较大。
如果能够一次性分配长度为n的存储空间,那么就省掉了大量的分配操作,可以极大提高效率。
由于归并的分治特性,我们需要在原来的函数基础之上,包装一层驱动函数(driver function)
// driver function
void _mergeSort(int a[], const int count) {
// allocation only once
int* pTmpBuf = new int[count];
assert(pTmpBuf != nullptr);
_MSort(a, pTmpBuf, 0, count - 1);
delete [] pTmpBuf;
return ;
}
// devide the sequence recuresively
void _MSort(int a[], int tmpBuffer[]/*extra space*/, const int left, const int right) {
if (left < right) {
const int midIdx = (left + right) >> 1;
_MSort(a, tmpBuffer, left, midIdx);
_MSort(a, tmpBuffer, midIdx + 1, right);
_Merge(a, tmpBuffer, left, midIdx + 1, right);
}
return ;
}
// merge two parts
void _Merge(int a[], int tmpBuffer[], const int lBegin, const int rBegin, const int rEnd) {
int l = lBegin;
int r = rBegin;
int bufPos = lBegin;
while (l < rBegin && r <= rEnd) {
tmpBuffer[bufPos++] = a[l] < a[r] ? a[l++] : a[r++];
}
while (l < rBegin) {
tmpBuffer[bufPos++] = a[l++];
}
while (r <= rEnd) {
tmpBuffer[bufPos++] = a[r++];
}
for (bufPos = lBegin; bufPos <= rEnd; ++bufPos) {
a[bufPos] = tmpBuffer[bufPos];
}
return ;
}
为了检验性能提升,笔者对100,1000,10000,100000的数据规模分别进行测试,每次测试排序运行100次,得到如下数据表和图示。
ps:为了减少干扰,以上测试均在Release下进行。
经过测试发现,改进后的算法基本上比原始的要快30~50倍。至于表格中1000测试那行,猜测和缓存命中有关。多次测试后,基本也快出30-40倍。
以上改进可以得到一个结论:对于需要频繁分配内存的算法而言,一次性分配或者采用lazy-deletion以提高复用的策略可以大幅提高算法效率。