从归并排序到大数据中的归并
归并排序在实际场景中应用广泛,很大程度上是因为它不仅仅只是“排序”。归并排序的核心价值在于其分而治之
的思想,这种思想完美贴合分布式的设计理念。正所谓“三个臭皮匠,顶个诸葛亮”,将一个复杂的任务进行拆分,每台机器都可以较小的时间和空间资源去处理单个子任务。而且在子任务可以并行处理的情况下,复杂任务最终完成的时间还要更短。
归并排序
既然由归并排序延伸而来,还是要先介绍一下归并排序的原理和性能分析。
void merge(vector<int>& a, vector<int>& aux, int lo, int mid, int hi) {
int i = lo;
int j = mid + 1;
for (int k = lo; k <= hi; ++k) {
aux[k] = a[k]; // 注意不能用assign,这里赋值是部分操作,aux大小不变
}
for (int k = lo; k <= hi; ++k) {
if (i > mid) {
a[k] = aux[j++]; // 左边已用完
} else if (j > hi) {
a[k] = aux[i++]; // 右边已用完
} else if (aux[i] <= aux[j]) {
a[k] = aux[i++]; // 左边 <= 右边(稳定排序)
} else {
a[k] = aux[j++]; // 右边 > 左边
}
}
}
/**
* @overload
*/
void sort(vector<int>& a, vector<int>& aux, int lo, int hi) {
if (lo >= hi) { // 这里只要知道lo=hi=0就知道应该带等号
return;
}
int mid = lo + (hi - lo) / 2;
sort(a, aux, lo, mid); // 注意中间元素包含在左区间
sort(a, aux, mid + 1, hi);
if (a[mid] <= a[mid + 1]) { // 子数组已经有序
return;
}
merge(a, aux, lo, mid, hi);
}
void sort(vector<int>& a) {
int len = a.size();
auto aux = a; // auxiliary 辅助数组
sort(a, aux, 0, len - 1);
}
-
空间复杂度:由于需要使用一个辅助数组aux,所以空间复杂度为 O ( N ) O(N) O(N)
-
时间复杂度:粗略理解的话,自顶向下划分时对半划分,所以划分得到子数组二叉树的高度就是 l o g 2 N log_2N log2N,子数组的比较次数又是线性的 O ( N ) O(N) O(N),所以总排序时间就是 O ( N l o g 2 N ) O(Nlog_2N) O(Nlog2N)。详细分析可以参考《算法4》2.2.4 排序算法的复杂度。
虽然归并排序的理论时间复杂度看着和快排差不多,但是比较次数比快排多,移动次数也更多。并且在大多数实际数据中,快排可以先进行随机打散来避免最坏的 O ( N 2 ) O(N^2) O(N2)。这些都来自于数学概率,你完全可以相信。
内部排序和外部排序
我们熟知的排序算法一般都属于内部排序,即快速排序、堆排序、冒泡排序、插入排序等,归并排序也属于内部排序。那么为什么还要有外部排序呢?其实内部和外部的区别在于存储的介质,内部排序只使用内存,外部排序还需使用磁盘。显然,外部排序使用磁盘的目的就是为了存储更多的数据,所以外部排序基本等同于大数据的排序。那么,外部排序的工作原理是什么呢?这就要与归并联系起来了。
大数据中的归并
既然内存放不下就放到磁盘,如果单个磁盘也放不下就拆分放到多个磁盘中,在面对大数据时我们不可避免要与磁盘打交道。大数据的排序过程(外部排序)通常如下:
-
拆分。首先我们将大数据集分割成多个小文件,每个文件的大小都适合加载到内存中。
-
内部排序。然后我们使用某种内部排序算法(如快速排序、堆排序等)对每个小文件进行排序,排序得到的有序子数组再写回磁盘。
-
归并。N个有序子数组我们就设置N个游标,一开始统一指向位置为0的元素,然后比较得到当前的最小元素,写入到最终文件中。然后我们将产生这个最小元素的有序数组的游标向后移动,读取下一个元素,再次与其他游标指向的元素进行比较。以此类推,直到所有游标都到末尾,最终文件里就是全局排好序的数据。
在大数据中的归并同样也是分而治之的思想,先拆分成子文件,方便读入内存进行内部排序,最后再进行归并。现在我们应该可以初步感受到归并排序的这种分治思想,在大数据的世界里,很多单机处理不了的问题都可以按照这个思路来解决。
归并的优化
我们刚才提到的归并是最简单直接的思路,假如拆分的子文件为N个,每次我们都要 O ( N ) O(N) O(N)的时间得到当前最小的元素,子文件很多的时候这个效率并不高。相信聪明的小伙伴看到最小的元素就已经有了优化的思路,这不就是优先队列/堆的用武之地嘛。我们在内存建立一个大小为N的最小堆,每次我们从堆顶取出最小元素,时间复杂度为 O ( l o g 2 N ) O(log_2N) O(log2N),每次我们往堆里放入元素,时间复杂度也为 O ( l o g 2 N ) O(log_2N) O(log2N)。
在大多数情况下,标准的归并已经足够高效,并且实现起来相对简单。而在需要处理大量子文件的时候,使用堆进行多路归并可能是一个更好的选择。
如今互联网上各类文章满天飞,但是大部分要不是寥寥数语,让人过目即忘;要不是过多细枝末节又没有实操,让人不知所云。我将从个人学习和工作经历出发,给大家带来深入浅出的技术解析。我的文章力求简短精悍,尽量结合实战,以便大家在碎片时间即可充分吸收,后续还能学以致用。
欢迎大家关注我的微信公众号,所有文章第一时间更新~