和分治思想的第一次相遇
当问题的规模是可以划分的时候,分治的算法往往是很有效的:
不断分割问题的规模,直到子问题的规模足够小便直接求解,之后不断整合子问题的解得到更大规模的解,最后得到完全解。
归并排序就是分治算法的一个简单的例子。
可能有人觉得快速排序也是属于分治算法,但我不这么觉得,因为快速排序是先得到大问题的解的一部分,再靠子问题来完成解,
并没有整合子问题这一步,所以硬要说的话,快速排序应该是“治分”算法。
简单图示(是不是有点太简单了)
如何分解?
归并排序把问题划为均匀的两个子问题,即左半区间和右半区间,于是得到递归函数:
#define MID(i) (i >> 1) /// i / 2 /***************************************** 函数:归并排序 说明:对区间[low, high)范围的数据排序 *****************************************/ void mergeSort(int* low, int* high) { int range = high - low; ///区间元素个数 if(range > 1) ///对于规模为1的子问题本身已经是解了,所以只处理规模大于1的子问题 { int* mid = MID(range) + low; ///求出分割点 ///递归求解子问题 mergeSort(low, mid); mergeSort(mid, high); merge(low, mid, high); ///再合并两个子问题,这个函数待会实现 } }
这里是不能应用尾递归优化的,因为节点信息需要保存,以便执行merge(合并)过程。
读者可以思考下对于规模为2的问题为什么不会出现无限递归。
怎么合并?
在合并两个子问题时我们知道子问题对应的区间已经是有序的。所以我们可以通过比较两区间当前的最小值,得到整个区间的最小值,不断选出最小值便可完成合并(类似选择排序)
整个过程的花费是线性的O(n),n为两区间元素个数和。
不过整个过程需要一个辅助数组来存放不断选出的最小值(总不能占别的元素的位置吧),所以为了效率,首先声明足够大的辅助数组:
#define MID(i) (i >> 1) /// i / 2 int* helper; ///辅助数组 /********************************************** 函数:归并函数 说明:合并有序区间[low, mid)和[mid, high) left为左区间的遍历指针 right为右区间的遍历指针 helper为局部变量覆盖全局声明 这样做是为了减少代码行数 时间复杂度:O(high - low) **********************************************/ void merge(int* low, int* mid, int* high, int* left, int* right, int* helper) { 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 = MID(range) + low; ///求出分割点 ///递归求解子问题 mergeSortRoutine(low, mid); mergeSortRoutine(mid, high); merge(low, mid, high, low, mid, helper); ///再合并两个子问题 } } /**************************************** 函数:归并排序“外壳” ****************************************/ 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),并且最坏和最好情况都是差不多的。
优化
关于归并排序的优化还是挺多的,这里先来讲一种很不错的优化:
在原始的归并函数中左右区间的元素都会按大小全部复制到辅助数组中去,之后再一一复制回来。这一过程没错不过却没有考虑那些原本就处于正确位置的元素。
比如当左区间空了的时候,此刻右区间剩下的元素还需要再复制到复制数组中吗?答案是不需要的,因为它们本来就已经在正确位置了:
/********************************************** 函数:优化版归并函数 说明:合并有序区间[low, mid)和[mid, high) left为左区间的遍历指针 right为右区间的遍历指针 helper为局部变量覆盖全局声明 这样做是为了减少代码行数 时间复杂度:O(high - low) **********************************************/ void merge(int* low, int* mid, int* high, int* left, int* right, int* helper) { while(true) { if(*left <= *right) ///相等时下标小的优先,使得算法稳定 { *(helper++) = *(left++); if(left >= mid) break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置 } else { *(helper++) = *(right++); if(right >= high) ///右区间空了 { while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去 break; ///跳出外层循环 } } } while(right > low) *(--right) = *(--helper); ///再复制回来,不过要跳过右区间剩下的元素 }
这样不仅使代码更加简短,并且在很多时候会使程序加速。
同理可以应用于左区间原本就处于正确位置的元素,最终得到:
/********************************************** 函数:优化版归并函数 说明:合并有序区间[low, mid)和[mid, high) right为右区间的遍历指针 helper为局部变量覆盖全局声明 这样做是为了减少代码行数 时间复杂度:O(high - low) **********************************************/ void merge(int* low, int* mid, int* high, int* right, int* helper) { ///收缩左边界,不再考虑左区间原本位于正确位置的元素 while(*low <= *right) if(++low >= mid) return; ///如果左区间的元素全部在正确位置,那么右区间也是如此,直接返回 int* left = low; ///设置左区间遍历指针 *(helper++) = *(right++); ///别浪费上面循环失败的比较结果。。。 if(right >= high) ///右区间空了 while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去 else while(true) { if(*left <= *right) ///相等时下标小的优先,使得算法稳定 { *(helper++) = *(left++); if(left >= mid) break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置 } else { *(helper++) = *(right++); if(right >= high) ///右区间空了 { while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去 break; ///跳出外层循环 } } } while(right > low) *(--right) = *(--helper); ///再复制回来,不过要跳过右区间剩下的元素 }
虽然在最坏情况下和原始的归并函数一样,但是大部分情况还是有优化的,特别是当数组原本有序时,每层只需简单遍历O(n/2)个元素,比快速排序更高效。
减少的“倒腾”次数的期望
即下面cnt的平均值:
int cnt = 0; ///计数器 void merge(int* low, int* mid, int* high, int* right, int* helper) { while(*low <= *right) { cnt++; ///左边多一个元素不用参与复制 if(++low >= mid) { cnt += high - right; ///右边都不用参加 return; } } int* left = low; *(helper++) = *(right++); if(right >= high) while(left < mid) *(helper++) = *(left++); else while(true) { if(*left <= *right) { *(helper++) = *(left++); if(left >= mid) { cnt += high - right; ///右边不用参加复制的元素个数 break; } } else { *(helper++) = *(right++); if(right >= high) { while(left < mid) *(helper++) = *(left++); break; } } } while(right > low) *(--right) = *(--helper); ///再复制回来,不过要跳过右区间剩下的元素 }
当左右区间元素个数均为 k/2时,左区间不用参与复制的元素个数为 i 的条件为前 i 小的元素都被分在了左边,并且第 i + 1大元素被分在了右边,但第k/2大元素要单独考虑。
右边不用参与复制的元素个数和左区间是一样的,因为是对称的。于是得到下面的级数:
下面是我的测试数据(元素互异且随机排列):
可以看出这项优化平均减少O(n)次多余的操作。
使叶子“变粗”
同样归并排序也可以用插入排序来优化,同样也是因为对于小规模的数据插入排序常数小的缘故。并且由于引进插入排序我们知道叶子宽度一定大于1,因此可以简化归并函数:
#define FACTOR 10 ///叶子宽度 #define MID(i) (i >> 1) /// i / 2 int* helper; ///辅助数组 /********************************************** 函数:优化版归并函数 说明:合并有序区间[low, mid)和[mid, high) right为右区间的遍历指针 helper为局部变量覆盖全局声明 这样做是为了减少代码行数 时间复杂度:O(high - low) **********************************************/ void merge(int* low, int* mid, int* high, int* right, int* helper) { ///收缩左边界,不再考虑左区间原本位于正确位置的元素 while(*low <= *right) { if(++low >= mid) return; ///如果左区间的元素全部在正确位置,那么右区间也是如此,直接返回 } int* left = low; ///设置左区间遍历指针 *(helper++) = *(right++); ///别浪费上面循环失败的比较结果。。。 ///因为叶子大于1,所以之前那两句就不用了。 while(true) { if(*left <= *right) ///相等时下标小的优先,使得算法稳定 { *(helper++) = *(left++); if(left >= mid) break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置 } else { *(helper++) = *(right++); if(right >= high) ///右区间空了 { while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去 break; ///跳出外层循环 } } } while(right > low) *(--right) = *(--helper); ///再复制回来,不过要跳过右区间剩下的元素 } /************************************* 函数:优化版插入排序 说明:对区间[low, high)的数据排序 时间复杂度:O(n + inverse) *************************************/ static void improvedInsertionSort(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) ///对于规模为1的子问题本身已经是解了,所以只处理规模大于1的子问题 { int* mid = MID(range) + low; ///求出分割点 ///递归求解子问题 mergeSortRoutine(low, mid); mergeSortRoutine(mid, high); merge(low, mid, high, mid, helper); ///再合并两个子问题 } else improvedInsertionSort(low, high); } /**************************************** 函数:归并排序“外壳” ****************************************/ void mergeSort(int* low, int* high) { helper = new int[high - low]; ///辅助数组最多也就存输入的元素数 if(helper != nullptr) { mergeSortRoutine(low, high); delete[] helper; ///释放内存 } else return; ///空间不足,没法启动归并排序 }
自底向上的归并排序
上面递归版的归并排序的分解步骤是通过划分父问题才得到子问题,但其实子问题是可以被我们直接找到的,因为一个子问题的标识是一个区间,而区间是由左右端点的数字确定的。
所以我们可以直接计算出我们目前想得到的子问题的区间:
#define MID(i) (i >> 1) /// i / 2 #define NEXT_GAP(i) (i <<= 1) ///下一个步长 int* helper; ///辅助数组 /********************************************** 函数:优化版归并函数 说明:合并有序区间[low, mid)和[mid, high) right为右区间的遍历指针 helper为局部变量覆盖全局声明 这样做是为了减少代码行数 时间复杂度:O(high - low) **********************************************/ void merge(int* low, int* mid, int* high, int* right, int* helper) { ///收缩左边界,不再考虑左区间原本位于正确位置的元素 while(*low <= *right) if(++low >= mid) return; ///如果左区间的元素全部在正确位置,那么右区间也是如此,直接返回 int* left = low; ///设置左区间遍历指针 *(helper++) = *(right++); ///别浪费上面循环失败的比较结果。。。 if(right >= high) ///右区间空了 while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去 else while(true) { if(*left <= *right) ///相等时下标小的优先,使得算法稳定 { *(helper++) = *(left++); if(left >= mid) break; ///左区间扫描完直接跳出外层循环,此时右区间剩下来的元素本来就处于正确位置 } else { *(helper++) = *(right++); if(right >= high) ///右区间空了 { while(left < mid) *(helper++) = *(left++); ///把左区间剩下的复制过去 break; ///跳出外层循环 } } } while(right > low) *(--right) = *(--helper); ///再复制回来,不过要跳过右区间剩下的元素 } /************************************************ 函数:自底向上版归并排序 说明:对区间[low, low + range)范围的数据排序 时间复杂度:O(nlgn) ************************************************/ void mergeSortRoutine(int* low, int* high, int range) { for(int gap = 2; MID(gap) < range; NEXT_GAP(gap)) for(int* right = low + gap, * mid = low + MID(gap); mid < high; right += gap, mid += gap) merge(right - gap, mid, right > high ? high : right, mid, helper); } /**************************************** 函数:归并排序“外壳” ****************************************/ void mergeSort(int* low, int* high) { helper = new int[high - low]; ///辅助数组最多也就存输入的元素数 if(helper != nullptr) { mergeSortRoutine(low, high, high - low); delete[] helper; ///释放内存 } else return; ///空间不足,没法启动归并排序 }
不过不知道为什么在我电脑上递归版反而更快,真是奇怪。
后记
如果内容有误或有什么提议请在下面评论,谢谢。