浅析分治算法
前言:
分治算法(divide and conquer)是五大常用算法(分治算法、动态规划算法、贪心算法、回溯法、分治界限法)之一,很多人在平时学习中可能只是知道分治算法,但是可能并没有系统的学习分治算法,本篇就带你较为全面的去认识和了解分治算法。
在学习分治算法之前,问你一个问题,相信大家小时候都有存钱罐的经历,父母亲人如果给钱都会往自己的宝藏中存钱,我们每隔一段时间都会清点清点钱。但是一堆钱让你处理起来你可能觉得很复杂,因为数据相对于大脑有点庞大了,并且很容易算错,你可能会将它先分成几个小份算,然后再叠加起来计算总和就获得这堆钱的总数了
当然如果你觉得各个部分钱数量还是太大,你依然可以进行划分然后合并,我们之所以这么多是因为:
计算每个小堆钱的方式和计算最大堆钱的方式是相同的(区别在于体量上)
然后大堆钱总和其实就是小堆钱结果之和。这样其实就有一种分治的思想。
当然这些钱都是想出来的……
分治算法介绍
分治算法是用了分治思想的一种算法,什么是分治?
分治,字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。在计算机科学中,分治法就是运用分治思想的一种很重要的算法。分治法是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)等等。
将父问题分解为子问题同等方式求解,这和递归的概念很吻合,所以在分治算法通常以递归的方式实现(当然也有非递归的实现方式)。分治算法的描述从字面上也很容易理解,分、治其实还有个合并的过程:
分(Divide):递归解决较小的问题(到终止层或者可以解决的时候停下)
治(Conquer):递归求解,如果问题够小直接求解。
合并(Combine):将子问题的解构建父类问题
一般分治算法在正文中分解为两个即以上的递归调用,并且子类问题一般是不想交的(互不影响)。当求解一个问题规模很大很难直接求解,但是规模较小的时候问题很容易求解并且这个问题并且问题满足分治算法的适用条件,那么就可以使用分治算法
那么采用分治算法解决的问题需要 **满足那些条件(特征)**呢?
1 . 原问题规模通常比较大,不易直接解决,但问题缩小到一定程度就能较容易的解决。
2 . 问题可以分解为若干规模较小、求解方式相同(似)的子问题。且子问题之间求解是独立的互不影响。
3 . 合并问题分解的子问题可以得到问题的解。
你可能会疑惑分治算法和递归有什么关系?其实分治重要的是一种思想,注重的是问题分、治、合并的过程。而递归是一种方式(工具),这种方式通过方法自己调用自己形成一个来回的过程,而分治可能就是利用了多次这样的来回过程。
分治算法经典问题
对于分治算法的经典问题,重要的是其思想,因为我们大部分借助递归去实现,所以在代码实现上大部分都是很简单,而本篇也重在讲述思想。
分治算法的经典问题,个人将它分成两大类:子问题完全独立和子问题不完全独立。
1 . 子问题完全独立就是原问题的答案可完全由子问题的结果推出。
2 . **子问题不完全独立,**有些区间类的问题或者跨区间问题使用分治可能结果跨区间,在考虑问题的时候需要仔细借鉴下。
分治实例:
二分搜索插入:
二分搜索是分治的一个实例,只不过二分搜索有着自己的特殊性
序列有序结果为一个值正常二分将一个完整的区间分成两个区间,两个区间本应单独找值然后确认结果,但是通过有序的区间可以直接确定结果在那个区间,所以分的两个区间只需要计算其中一个区间,然后继续进行一直到结束。实现方式有递归和非递归,但是非递归用的更多一些:
public int searchInsert(int[] nums, int target) {
if (nums[0] >= target) return 0;//剪枝
if (nums[nums.length - 1] == target) return nums.length - 1;//剪枝
if (nums[nums.length - 1] < target) return nums.length;
int left = 0, right = nums.length - 1;
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) return mid;
else if (nums[mid] > target) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
快速排序:(表示有点难于理解)
快排也是分治的一个实例,快排每一趟会选定一个数,将比这个数小的放左面,比这个数大的放右面,然后递归分治求解两个子区间,当然快排因为在分的时候就做了很多工作,当全部分到最底层的时候这个序列的值就是排序完的值。这是一种分而治之的体现。
public static void quicksort(int[] a, int left, int right) {
int low = left;
int high = right; //下面两句的顺序一定不能混,否则会产生数组越界!!!very important!!!
if (low > high)//作为判断是否截止条件
return;
int k = a[low];//额外空间k,取最左侧的一个作为衡量,最后要求左侧都比它小,右侧都比它大。
while (low < high)//这一轮要求把左侧小于a[low],右侧大于a[low]。
{
while (low < high && a[high] >= k)//右侧找到第一个小于k的停止
{
high--;
} //这样就找到第一个比它小的了
a[low] = a[high];//放到low位置
while (low < high && a[low] <= k)//在low往右找到第一个大于k的,放到右侧a[high]位置
{
low++;
}
a[high] = a[low];
}
a[low] = k;//赋值然后左右递归分治求之
quicksort(a, left, low - 1);
quicksort(a, low + 1, right);
}
归并排序(逆序数)
快排在分的时候做了很多工作,而归并就是相反,归并在分的时候按照数量均匀分,而合并时候已经是两两有序的进行合并的,因为两个有序序列O(n)级别的复杂度即可得到需要的结果。而逆序数在归并排序基础上变形同样也是分治思想求解。
private static void mergesort(int[] array, int left, int right) {
int mid = (left + right) / 2;
if (left < right) {
mergesort(array, left, mid);
mergesort(array, mid + 1, right);
merge(array, left, mid, right);
}
}
private static void merge(int[] array, int l, int mid, int r) {
//将mid两边[l,r]范围的数排序并合并
int lindex = l;
int rindex = mid + 1;
int tmp[] = new int[r - l + 1];
int tmpindex = 0;
while (lindex <= mid && rindex <= r) {//先左右比较合并
if (array[lindex] <= array[rindex]) {
tmp[tmpindex++] = array[lindex++];
} else {
tmp[tmpindex++] = array[rindex++];
}
}
while (lindex <= mid)//当一个越界后剩余按序列添加即可
{
tmp[tmpindex++] = array[lindex++];
}
while (rindex <= r) {
tmp[tmpindex++] = array[rindex++];
}
for (int i = 0; i < tmpindex; i++) {
array[l + i] = tmp[i];
}
}
LeetCode第53题最大子序列和
最大子序列和的问题我们可以使用动态规划的解法,但是也可以使用分治算法来解决问题,但是最大子序列和在合并的时候并不是简单的合并,因为子序列和涉及到一个长度的问题,所以正确结果不一定全在最左侧或者最右侧,而可能出现结果的区域为:
将原数组划分为左右两个数组后,原数组中拥有最大和的连续子数组的位置有三张情况。
情况1. 原数组中拥有最大和的连续子数组的元素都在左边的子数组中。
情况2. 原数组中拥有最大和的连续子数组的元素都在右边的子数组中。
情况3. 原数组中拥有最大和的连续子数组的元素跨越了左右数组。
分别求出,3中情况的最大和,取最大,就是原数组的连续子数组的最大和。
用一张图可以表示为:
所以在具体考虑的时候需要将无法递归得到结果的中间那个最大值串的结果也算出来参与左侧、右侧值得比较。
力扣53. 最大子序和在实现的代码为:
class Solution {
public int getMax(int[] nums, int low, int high) {
// 如果子数组只有一个元素,这个元素就是子树组的最大和。
if (low == high) {
return nums[low];
}
int mid = low + (high - low) / 2;
// 求左数组的最大和
int leftMax = getMax(nums, low, mid);
// 求右数组的最大和
int rightMax = getMax(nums, mid + 1, high);
// 求跨越情况的最大和
int crossMax = getCrossMax(nums, low, mid, high);
// 返回最大
return Math.max(Math.max(leftMax, rightMax), crossMax);
}
// 求跨越情况的最大和
public int getCrossMax(int[] nums, int low, int mid, int high) {
// 从中间向左走,一直累加,每次累计后都取最大值,最后得到的就是从中间向左累加可得到最大和
int leftSum = nums[mid];
int leftMax = nums[mid];
for (int i = mid - 1; i >= low; i--) {
leftSum += nums[i];
leftMax = Math.max(leftMax, leftSum);
}
// 从中间向右走,一直累加,每次累计后都取最大值,最后得到的就是从中间向右累加可得到最大和
int rightSum = nums[mid+1];
int rightMax = nums[mid+1];
for (int i = mid + 2; i <= high; i++) {
rightSum += nums[i];
rightMax = Math.max(rightMax, rightSum);
}
// 向左累加的最大和加上向右累加的最大和,就是跨越情况下的最大和
return leftMax + rightMax;
}
public int maxSubArray(int[] nums) {
return getMax(nums, 0 , nums.length - 1);
}
}
最近点对
最近点对是一个分治非常成功的运用之一。在二维坐标轴上有若干个点坐标,让你求出最近的两个点的距离,如果让你直接求那么枚举暴力是个非常非常大的计算量,我们通常采用分治的方法来优化这种问题。
如果直接分成两部分分治计算你肯定会发现最短的如果一个在左一个在右会出现问题。我们可以优化一下
在具体的优化方案上,按照x或者y的维度进行考虑,将数据分成两个区域,先分别计算(按照同方法)左右区域内最短的点对。然后根据这个两个中较短的距离向左和向右覆盖,计算被覆盖的左右点之间的距离,找到最小那个距离与当前最短距离比较即可。
这样你就可以发现就这个一次的操作(不考虑子情况),左侧红点就避免和右侧大部分红点进行距离计算(O(n2)的时间复杂度)。事实上,在进行左右区间内部计算的时候,它其实也这样递归的进行很多次分治计算。如图所示:
但是这种分治会存在一种问题就是二维坐标可能点都聚集某个方法某条轴那么可能效果并不明显(点都在x=2附近对x分割作用就不大),需要注意一下。
到这里,分治算法就讲这么多了,因为分治算法重要在于理解其思想,还有一些典型的分治算法解决的问题,例如大整数乘法、Strassen矩阵乘法、棋盘覆盖、线性时间选择、循环赛日程表、汉诺塔等问题你可以自己研究其分治的思想和原理。
算的时候,它其实也这样递归的进行很多次分治计算。如图所示:
[外链图片转存中…(img-gKNVfDnd-1648975335756)]
但是这种分治会存在一种问题就是二维坐标可能点都聚集某个方法某条轴那么可能效果并不明显(点都在x=2附近对x分割作用就不大),需要注意一下。
到这里,分治算法就讲这么多了,因为分治算法重要在于理解其思想,还有一些典型的分治算法解决的问题,例如大整数乘法、Strassen矩阵乘法、棋盘覆盖、线性时间选择、循环赛日程表、汉诺塔等问题你可以自己研究其分治的思想和原理。