分治算法的核心就是分而治之,也就是将原问题划分为若干个规模更小但结构与原问题相似的子问题,递归地解决这些子问题然后进行合并,就可以得到原问题的解。比如归并排序就是将原数据划分为左右两个部分,然后分别递归对左右两部分排序,排完序后再合并两个有序区间数据即可得到最终整体有序的数据。
分治算法能解决的问题,一般需要满足以下几个条件:
数据中逆序元素对的定义为:\(a[i] > a[j], 如果 \space i < j\)。比如 2,4,3,1,5,6 这组数据的逆序元素对就有 (2, 1),(4, 3),(4, 1),(3, 1) 共计 4 个。
怎么统计一组数据中的逆序对数呢?最简单的方法就是针对数组中的每一个元素,查看其后面有多少个大于其的数据。但是,这样操作的时间复杂度是 \(O(n^2)\),有没有更快的方法呢?
借助于分治算法,我们可以将数据划分为左右两部分 L 和 R,分别计算 L 和 R 中的逆序数对,然后再计算 L 和 R 之间的逆序数对,便可以得到整体数据的逆序数对了。这时候,我们可以想到归并排序的合并操作是对左右两个有序数据进行合并,那么左右两部分数据的逆序数对就为零,我们只需要在合并的时候统计左半部分和右半部分之间的逆序数对即可。
代码如下:
int num = 0; void Merge_Sort(float data[], int left, int right, float sorted_data[]) { if(left < right) { int mid = (left + right) / 2; Merge_Sort(data, left, mid, sorted_data); Merge_Sort(data, mid+1, right, sorted_data); Merge_Array(data, left, mid, right, sorted_data); } } void Merge_Array(float data[], int left, int mid, int right, float temp[]) { int i = left, j = mid + 1; int k = 0; // 从子数组的头开始比较 while(i <= mid && j <= right) { if (data[i] <= data[j]) { temp[k++] = data[i++]; } else { // [i, mid] 的元素都比 data[j] 大 num += (mid - i + 1); temp[k++] = data[j++]; } } // 判断哪个子数组还有元素,并拷贝到 temp 后面 while(i <= mid) { temp[k++] = data[i++]; } while(j <= right) { temp[k++] = data[j++]; } // 将 temp 中的数据拷贝到原数组对应位置 for(i = 0; i < k; i++) { data[left+i] = temp[i]; } }
假设我们有两个 \(n×n\) 的矩阵 A 和 B,矩阵相乘后得到的矩阵 C 也是 \(n×n\) 的,其中每个位置的元素是一行乘以一列,需要 \(n\) 次乘加操作,所以总的时间复杂度为 \(O(n^2*n)=O(n^3)\)。
借助于分治算法,我们可以先将原始矩阵分解为 4 个 \(n/2×n/2\) 的小矩阵,
然后分别计算 \(n/2×n/2\) 小矩阵的 10 个加减和 7 个乘法,
最后再计算 \(n/2×n/2\) 小矩阵的 8 个加减即可得到输出矩阵。
其中,小矩阵的加减需要 \(O(n^2)\),所以时间复杂度可以表示为:
\[T(n)=7T(n/2)+O(n^2) \]
利用 Master 定理可得 \(T(n)=O(n^{log7}) \approx O(n^{2.81})\)。
针对一维空间,我们先找到所有点坐标的中位数,然后将点对分为两部分,递归求出左右两边的最近点对 \((p_1, p_2)\) 和 \((q_1, q_2)\),然后再找出左半部分最右边的点和右半部分最左边的点组成的点对 \((p_3, q_3)\),那么最近点对一定是这三个中的一个。
其中寻找中位数、找到左半部分最右边点以及右半部分最左边点需要的时间复杂度都为 \(O(n)\),所以有:
\[T(n)=2T(n/2)+O(n) \to T(n)=O(nlogn) \]
针对二维空间,我们需要先找到所有点横坐标的中位数,然后将点对分为两部分,同样地,我们递归找到 \((p_1, p_2)\) 和 \((q_1, q_2)\),然后再找出临界区中距离最小的点对 \((p_3, q_3)\),这三者距离最小的就是所求。
重点就在于怎么找到 \((p_3, q_3)\)。如果我们直接遍历左半部分所有的点,求出它们和右半部分每一个点的距离,那么时间复杂度为 \(O(n^2)\)。我们就有:
\[T(n)=2T(n/2)+O(n^2) \to T(n)=O(n^2) \]
这显然是我们不满意的。怎么才能降低合并过程的时间复杂度呢?
假设我们递归得到的左右两边最近点对的最小距离为 d,对于左边的一个点 p,我们没必要遍历右边所有的 q。
如上图所示,如果 p 和 q 的距离小于 d,那么 q 只能位于 p-右邻域,而当点 p 位于中线上时,右邻域最大,包含的可能的点 q 才最多。在这种情况下,可能的 q 点有 6 个,分别位于 6 个长方形区域。如果点数多余 6 个,那么必然有两个点位于同一个长方形区域,那么这两个点的距离最大为 \(5d/6<d\),这是不可能的,因为 d 是左右两边的最近点对距离。
那么我们怎么找到这些点呢?我们需要维护一个按照横坐标排序的点对 \(X\) 和一个按照纵坐标排序的点对 \(Y\),排序的时间复杂度为 \(O(nlogn)\)。分治的时候,我们利用 \(X\) 来找到横坐标的中位数 \(x=m\),然后遍历 \(Y\) 通过横坐标的比较来进行划分。合并的时候,我们通过判断横坐标与分界线距离小于 \(d\) 找到位于临界区的点集 \(S\),此时它们还是纵坐标有序的。这样我们遍历 \(S\) 中的每个点,然后向后查找与其纵坐标距离小于 \(d\) 的点计算距离(这些点的个数是有限的正如我们上面的分析那样),看是否有小于 \(d\) 的点对即可。所以分治和合并的时间复杂度都可以做到 \(O(n)\),算法整体复杂度为
\[T(n)=2T(n/2)+O(n) \to T(n)=O(nlogn) \]
参考资料-极客时间专栏《数据结构与算法之美》