一、分治算法
分治(divide and conquer)的全称为“分而治之”,从名称上看,分治算法主要由两部分构成:
- 分(divide):递归求解所有从原问题分解出来的相似子问题;
- 治(conquer):从子问题的解构建原问题的解。
也就是说,分治算法将原问题划分成若干个规模较小而结构与原问题相同或相似的子问题,然后递归求解所有子问题(如果存在子问题的规模小到可以直接解决,就直接解决它),最后合并所有子问题的解,即可得到原问题的解。
1.1 分治与减治
传统上,在函数正文中至少含有两个递归调用的例程叫做分治算法,而函数正文中只含一个递归调用的例程不是分治算法(可以称为减治算法)。对于函数中只包含一个递归调用的减治算法,在前篇博客:递推与递归中已经做过介绍,插入排序与希尔排序算法就可以称为减治算法。
减治算法一般只包含一个子问题(或者说只选取一部分子问题,而裁剪掉另一部分子问题),递归求解该子问题的解即可得到原问题的解(递推公式的作用)。
分治算法则先将原问题递归分解为多个子问题,再在递归中求解所有子问题的解,最后合并所有子问题的解即可得到原问题的解。需要指出的是,分治算法分解出的子问题应当是相互独立、没有交叉重叠的,如果存在两个子问题有交叉重叠部分,那么不应当使用分治算法解决。可以看出,分治算法是对递归算法的组合应用。
1.2 归并排序
说起分治算法,最先想到的一般是归并排序,“归并”可以理解为递归分解与合并两部分,正好对应分治算法的分与治两个过程。
递归分解数据序列,最常见的就是一分二、二分四、四分八…,直至分解到数据序列只剩下一个元素,不可再分,就到了递归边界。归并排序的递归分解过程也很清晰,递推公式就是前面说的一分二、二分四、四分八…这个过程,实际上就是左边界或右边界减半的过程,递归边界就是数据序列只剩下一个元素的情形,也即左右边界相等的情形。
递归分解过程中,跟前篇介绍希尔排序的递归分组不同的是,希尔排序对于增量序列中的每个增量只分解出一个子问题,而归并排序的递归分解,每次递归都会将原问题分解为两个子问题,所以在函数正文中需要两次递归调用,这也是前面介绍的分治算法与减治算法的区别。
归并排序的递归分解过程,主要是通过左边界或右边界减半实现的,那么参数就需要包含数据序列的首地址,左边界下标和右边界下标,下面给出递归分解过程的实现代码(数据合并部分暂略):
// algorithm\sort.c
void recursive_merge(int *data, int left, int right)
{
if(left >= right)
return;
int mid = left + (right - left) / 2;
recursive_merge(data, left, mid);
recursive_merge(data, mid + 1, right);
merge_data(data, left, mid, right);
}
接下来看被递归分解的数据序列如何合并?递归分解到只剩一个元素时,我们可以认为该数据序列是有序的。每次自顶向下递归调用时,原序列都被递归分解为两个子序列,在到达递归边界后,开始自底向上回归,每次回归都需要将两个有序子序列合并为一个有序子序列。所以,问题就转换为我们如何将两个有序子序列合并为一个有序序列?
两个有序子序列合并为一个有序序列,最简单的就是借助一个空数组,将两个有序子序列的首元素相互比较,较小的元素放入空数组,并将其所在子序列的指针移到下一个元素处,继续刚才的比较过程。直到其中一个子序列的元素比较完毕,将另一个子序列剩余的元素全部放到空数组中。最后,我们将存放在空数组中的有序序列依次放入原序列即可。将该过程举例图示如下:
按照上面的逻辑编写数据合并实现代码如下(两个子序列分别为data[left] – data[mid]与data[mid+1] – data[right]):
// algorithm\sort.c
void merge_data(int *data, int left, int mid, int right)
{
int *temp = malloc((right - left + 1) * sizeof(int));
if(temp == NULL)
return;
int i = left, j = mid + 1, k = 0;
while(i <= mid && j <= right) {
if(data[i] <= data[j])
temp[k++] = data[i++];
else
temp[k++] = data[j++];
}
while(i <= mid)
temp[k++] = data[i++];
while(j <= right)
temp[k++] = data[j++];
for(i = left, k = 0; i <= right; i++, k++)
data[i] = temp[k];
free(temp);
}
到这里递归分解与子序列合并两个过程都通过函数实现了,我们用一张图来说明归并排序的两个过程:
上面的函数还有点改进空间,内存的分配释放比较占用时间,上面子序列合并的实现函数merge_data中进行了空数组的分配与释放,该函数被多次调用就会降低归并排序的效率。我们可以先为其分配一个与原序列相同大小的空数组,在子序列合并函数merge_data中就可以省去内存的分配与释放操作过程,但需要增加一个参数用于传入临时数组的指针。优化后的归并排序实现代码如下:
// algorithm\sort.c
void merge_data(int *data, int *temp, int left, int mid, int right)
{
int i = left, j = mid + 1, k = 0;
while(i <= mid && j <= right) {
if(data[i] <= data[j])
temp[k++] = data[i++];
else
temp[k++] = data[j++];
}
while(i <= mid)
temp[k++] = data[i++];
while(j <= right)
temp[k++] = data[j++];
for(i = left, k = 0; i <= right; i++, k++