和分治思想的第一次相遇
当问题的规模是可以划分的时候,分治的算法往往是很有效的:
不断分割问题的规模,直到子问题的规模足够小便直接求解,之后不断整合子问题的解得到更大规模的解,最后得到完整解。
归并排序就是分治算法的一个简单的例子。
可能有人觉得快速排序也是属于分治算法,但我不这么认为,因为快速排序是先得到大问题的解的一部分,再靠子问题来完成解,
并没有整合子问题这一步,所以硬要说的话,快速排序应该是
“治分”算法。
简单图示(是不是有点太简单了)
如何分解?
归并排序把问题划分成两个均匀的子问题——左半区间和右半区间,于是得到递归函数:
#define HALF(i) (i >> 1) /// i / 2
/*****************************************
函数:归并排序
说明:对区间[low, high)范围的数据排序
*****************************************/
void mergeSort(int* low, int* high)
{
int range = high - low; ///区间元素个数
if(range > 1) ///对于规模为1的子问题本身已经是解了,所以只处理规模大于1的子问题
{
int* mid = HALF(range) + low; ///求出分割点
///递归求解子问题
mergeSort(low, mid);
mergeSort(mid, high);
///再合并两个子问题,这个函数待会儿实现
merge(low, mid, high);
}
}
这里是不能应用尾递归优化的,因为节点信息需要保存,以便解决子问题之后再执行merge(合并子问题)过程。
读者可以思考下对于规模为2的问题为什么不会出现无限递归。
怎么合并?
在合并两个子问题时我们知道子问题对应的区间已经是有序的。所以合并子问题就是合并两组有序元素。
所以我们可以通过比较两区间当前的最小值,得到整个区间的最小值,不断选出最小值便可完成合并(类似选择排序,只不过能在O(1)时间确定最小值)。
整个过程的花费是线性的O(n),n为两区间元素个数和。
不过整个过程需要一个辅助数组来存放不断选出的最小值(总不能占别的元素的位置吧),并且为了效率,先一次性声明足够大的辅助数组供所有合并过程使用:
#define HALF(i) (i >> 1) /// i / 2
int* helper; ///辅助数组
/**********************************************
函数:归并函数
说明:合并有序区间[low, mid)和[mid, high)
时间复杂度:O(high - low)
**********************************************/
void merge(int* low, int* mid, int* high)
{
int* left = low, * right = mid; ///left和right分别是左右区间的遍历指针
while(true)
{
if(*left <= *right) ///相等时下标小的优先,使得算法稳定
{
*(helper++) = *(left++);
if(left >= mid) ///左区间已经空了
{
while(right < high) *(helper++) = *(right++); ///把右区间剩下的复制过去
break; ///跳出循环(外层)
}
}
else
{
*(helper++) = *(right++);
if(right >= high) ///右区间空了
{
while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去
break; ///跳出外层循环
}
}
}
while(high > low) *(--high) = *(--helper); ///再复制回来
}
/*****************************************
函数:归并排序
说明:对区间[low, high)范围的数据排序
时间复杂度:O(nlgn)
*****************************************/
void mergeSortRoutine(int* low, int* high)
{
int range = high - low; ///区间元素个数
if(range > 1) ///对于规模为1的子问题本身已经是解了,所以只处理规模大于1的子问题
{
int* mid = HALF(range) + low; ///求出分割点
///递归求解子问题
mergeSortRoutine(low, mid);
mergeSortRoutine(mid, high);
///再合并两个子问题
merge(low, mid, high);
}
}
/****************************************
函数:归并排序“外壳”
****************************************/
void mergeSort(int* low, int* high)
{
helper = new int[high - low]; ///辅助数组最多也就存输入的元素数
if(helper != nullptr)
{
mergeSortRoutine(low, high);
delete[] helper; ///释放内存
}
else return; ///空间不足,没法启动归并排序
}
时间复杂度
上面的归并排序的时间复杂度是很好分析的,最多有lgn层问题,每层均花费O(n)所以是O(nlgn),并且最坏和最好情况都是差不多的,下面是我拍的算法导论上的分析图示:
优化
方案一:使叶子“变粗”
也就是小规模的子问题直接用插入排序来解决,因为插入排序的常数优势使得它面对小规模的数据时是有优势的:
#define HALF(i) (i >> 1) ///i / 2
#define FACTOR 16 ///叶子宽度
/*************************************
函数:优化版插入排序
说明:对区间[low, high)的数据排序
时间复杂度:O(n + inverse)
*************************************/
void insertionSort(int* low , int* high)
{
for(int* cur = low; ++cur < high; ) ///实际是从第二个元素开始插入,因为第一个已经有序了
{
int tmp = *cur; ///临时保存要插入的值
int* destPos = cur; ///记录当前要插入的元素的正确安放位置,这里初始化为本来的位置
///把第一次测试单独提出来
if(*(--destPos) > tmp)
{
do
{
*(destPos + 1) = *destPos;
}while(--destPos >= low && *destPos > tmp); ///测试上一个是否是目标位置
*(destPos + 1) = tmp; ///最后一次测试失败使得destIndex比实际小1
}
}
}
/*****************************************
函数:归并排序
说明:对区间[low, high)范围的数据排序
时间复杂度:O(nlgn)
*****************************************/
void mergeSortRoutine(int* low, int* high)
{
int range = high - low; ///区间元素个数
if(range > FACTOR) ///对于规模小于FACTOR的子问题用插入排序求解
{
int* mid = HALF(range) + low; ///求出分割点
///递归求解子问题
mergeSortRoutine(low, mid);
mergeSortRoutine(mid, high);
///再合并两个子问题
merge(low, mid, high);
}
else insertionSort(low, high);
}
规模的阀值在10左右都不错。
方案二:避免不必要的复制
在原始的归并函数中左右区间的元素都会按大小全部复制到辅助数组中去,之后再一一复制回来。这一过程是没错,不过却没有考虑那些原本就处于正确位置的元素。
比如当左区间空了的时候,此刻右区间剩下的元素还需要再复制到辅助数组中吗?答案是不需要的,因为它们本来就已经在正确位置了。
同理可以应用于左区间原本就处于正确位置的元素。
还有当右区间空了的时候左区间的元素其实是可以直接移到右区间的,于是得到优化代码:
/**********************************************
函数:优化版归并函数
说明:合并有序区间[low, mid)和[mid, high)
时间复杂度:O(high - low)
**********************************************/
void merge(int* low, int* mid, int* high)
{
///收缩左边界,不再考虑左区间原本位于正确位置的元素
while(*low <= *mid)
if(++low >= mid) return; ///如果左区间的元素全部在正确位置,那么右区间也是如此,直接返回
int* left = low, * right = mid; ///left和right分别是左右区间的遍历指针
*(helper++) = *(right++); ///别浪费上面循环失败的比较结果。。。
if(right >= high) ///右区间空了
while(mid > left) *(--right) = *(--mid); ///把左区间剩下的直接复制到右区间
else while(true)
{
if(*left <= *right) ///相等时下标小的优先,使得算法稳定
{
*(helper++) = *(left++);
if(left >= mid) break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置
}
else
{
*(helper++) = *(right++);
if(right >= high) ///右区间空了
{
while(mid > left) *(--right) = *(--mid); ///把左区间剩下的直接复制到右区间
break; ///跳出外层循环
}
}
}
while(right > low) *(--right) = *(--helper); ///再复制回来,不过要跳过右区间剩下的元素
}
虽然在最坏情况下和原始的归并函数一样,但是大部分情况还是有优化的,特别是当数组原本有序时,每层只需简单遍历O(n/2)个元素,比快速排序更高效。
计算表明这项优化平均减少O(n)次多余的操作。
方案三:从自顶向下变为自底向上
上面递归版的归并排序的分解步骤是通过划分父问题才得到子问题,但其实子问题是可以被我们直接找到的,因为一个子问题的标识是一个区间,而区间是由左右端点的数字确定的。
所以我们可以直接计算出我们目前想得到的子问题的区间:
#define FACTOR 16 ///叶子宽度
#define FIRST_GAP 32 ///第一次步长,为叶子宽度的两倍
#define HALF(i) (i >> 1) ///i / 2
#define NEXT_GAP(i) (i <<= 1) ///下一个步长
/*****************************************************
函数:自底向上版归并排序
说明:对区间[low, high)范围的数据排序
时间复杂度:O(nlgn)
******************************************************/
void mergeSortRoutine(int* low, int* high)
{
int* left, * mid, * right; ///一个问题区间的标识
///先用插入排序优化
for(left = low, right = low + FACTOR; right < high; left = right, right += FACTOR)
insertionSort(left, right);
insertionSort(left, high);
///手动计算问题区间
for(int gap = FIRST_GAP; HALF(gap) < range; NEXT_GAP(gap))
{
for(left = low, mid = low + HALF(gap), right = low + gap; right < high; left = right, right += gap, mid += gap)
merge(left, mid, right);
if(mid < high) merge(left, mid, high);
}
}
最终的方案:完全不用来回复制,换一个角度看数据
为什么非得把临时放在辅助数组里的数据又一一复制回去呢,换一个视角,直接把辅助数组和原数组的角色对换一下不就好了:
int* helper; ///辅助数组
#define FACTOR 8 ///叶子的宽度
#define BIGER_FACTOR 16 ///更大的叶子宽度
#define HALF(i) (i >> 1) ///i / 2
#define NEXT_GAP(i) (i <<= 1) ///下一个步长
/**********************************************
函数:归并函数
说明:合并有序区间[low, mid)和[mid, high)
时间复杂度:O(high - low)
**********************************************/
void merge(int* low, int* mid, int* high)
{
int* left = low, * right = mid;
while(true)
{
if(*left <= *right) ///相等时下标小的优先,使得算法稳定
{
*(helper++) = *(left++);
if(left >= mid)
{
while(right < high) *(helper++) = *(right++);
break;
}
}
else
{
*(helper++) = *(right++);
if(right >= high) ///右区间空了
{
while(left < mid) *(helper++) = *(left++); ///把左区间剩下的直接复制到右区间
break; ///跳出外层循环
}
}
}
///并不复制回去
}
/*************************************
函数:优化版插入排序
说明:对区间[low, high)的数据排序
时间复杂度:O(n + inverse)
*************************************/
static void insertionSort(int* low , int* high)
{
for(int* cur = low; ++cur < high; ) ///实际是从第二个元素开始插入,因为第一个已经有序了
{
int tmp = *cur; ///临时保存要插入的值
int* destPos = cur; ///记录当前要插入的元素的正确安放位置,这里初始化为本来的位置
///把第一次测试单独提出来
if(*(--destPos) > tmp)
{
do
{
*(destPos + 1) = *destPos;
}while(--destPos >= low && *destPos > tmp); ///测试上一个是否是目标位置
*(destPos + 1) = tmp; ///最后一次测试失败使得destIndex比实际小1
}
}
}
/*****************************************************
函数:自底向上版归并排序
说明:对区间[low, high)范围的数据排序
range为数据个数
[lowOfHelper, highOfHelper)为辅助数组的范围
时间复杂度:O(nlgn)
******************************************************/
void mergeSortRoutine(int* low, int* high, int range, int* lowOfHelper, int* highOfHelper)
{
int* left, * mid, * right; ///一个问题区间的标识
int firstGap = 8;
int layer = 0; ///看步长8处于问题的第几层
while(NEXT_GAP(firstGap) < range) layer++; ///层数加1
int factor = layer % 2 ? FACTOR : BIGER_FACTOR; ///选择正确的因子
///先用插入排序优化,有两个优化步长8和16看是处于问题的奇数层还是偶数层
for(left = low, right = low + factor; right < high; left = right, right += factor)
insertionSort(left, right);
insertionSort(left, high);
///手动计算问题区间
for(int gap = NEXT_GAP(factor); HALF(gap) < range; NEXT_GAP(gap))
{
for(left = low, mid = low + HALF(gap), right = low + gap; right < high; left = right, right += gap, mid += gap)
merge(left, mid, right);
if(mid < high) merge(left, mid, high);
else while(left < high) *(helper++) = *(left++);
///交换两数组的标识,“换一个角度”看数组
swap(low, lowOfHelper);
swap(high, highOfHelper);
///重新设置辅助数组,必须放在最后面,使得最后退出循环时helper指向真正的辅助数组以便释放空间
helper = lowOfHelper;
}
}
/****************************************
函数:归并排序“外壳”
****************************************/
void mergeSort(int* low, int* high)
{
int range = high - low;
helper = new int[range]; ///辅助数组最多也就存输入的元素数
if(helper != nullptr)
{
mergeSortRoutine(low, high, range, helper, helper + range);
delete[] helper; ///释放内存
}
else return; ///空间不足,没法启动归并排序
}
这样一来效率是十分接近快速排序的,并且还是稳定排序!不过对空间需求比较大。
来看和快速排序的效率比较:
后记
若内容有误或有什么好的想法请在下面评论,谢谢。