前言
之前学习算法都是在LeetCode或牛客上刷题,从没有系统的学习过算法的底层知识,所以准备以《算法导论》为教材,从头再把算法的相关知识总结复习一遍,真正的学习方法而不是只会做题 。
基本思想
在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)等。
分治策略:对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。
适用条件
分治法所能解决的问题一般具有以下几个特征:
- 该问题的规模缩小到一定的程度就可以容易地解决
- 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
- 利用该问题分解出的子问题的解可以合并为该问题的解;
- 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题。
基本步骤
分治法在每一层递归上都有三个步骤:
- 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
- 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
- 合并:将各个子问题的解合并为原问题的解。
复杂性分析
T(n) = aT(n/b) + f(n) 其中,a >= 1,b > 1,f(n) 是一个给定的函数
一般分治策略产生的方法都可以用这种递归式来刻画,其中a代表生成了a个子问题,每个问题的规模是原问题的1/b,分解和合并的步骤总共花费时间f(n)。
典雅题例
最大子数组:给定一个整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例:
- 输入: [-2,1,-3,4,-1,2,1,-5,4],
- 输出: 6
- 解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。
分析:
要找到nums子数组的最大和,可以将其分为两个大小相同的子数组low-mid和mid-high分别进行子问题求解,当最终的子问题只有一个数时,这个数就是当前的最大子数组;所以现在的问题是最大的子数组还可能出现在包括中间位置的子数组,最大子数组可能出现以下三种情况:
- 完全位于左子数组,即 low ≤ i ≤ j ≤ mid ;
- 完全位于右子数组,即 mid < i ≤ j ≤ high ;
- 跨越了中间点,即 low ≤ i ≤ mid < j ≤ high ;
因此最大子数组就是这三种情况的最大值,写出递归代码:
class Solution {
public int maxSubArray(int[] nums) {
if (nums.length == 1) return nums[0];
int low = 0,high = nums.length-1;
int mid = (low+high) / 2;
return helper(nums,low,high,mid);
}
int helper(int[] a,int l,int h,int m) {
if (l >= h) return a[l];
//跨越了中间点
int leftMax = a[m];
int sum = 0;
//左边最大
for (int i = m;i >= 0;i--) {
sum += a[i];
if (sum > leftMax) leftMax = sum;
}
//右边最大
int rightMax = a[m+1];
sum = 0;
for (int i = m+1;i <= h;i++) {
sum += a[i];
if (sum > rightMax) rightMax = sum;
}
//三种情况的最大值
return Math.max(leftMax+rightMax,Math.max(helper(a,l,m,(l+m)/2),helper(a,m+1,h,(m+1+h)/2)));
}
}
递归代码虽然很清晰快速的写出来,但是其缺点也是很明显的,即求解问题的效率不高,我们可以通过对此算法的时间复杂度进行分析,来基本判断一个算法的好坏。
时间复杂度分析:
根据上文的递归方法复杂度公式: T(n) = aT(n/b) + f(n) ,我们知道此问题成了2个子问题,每个问题的规模是原问题的1/2,分解和合并的步骤总共花费时间f(n) = 左(m-l+1) + 右(h-m) = h-l+1 = n。
所以 T(n) = 2T(n/2) + n;且只有一个元素时复杂度为T(1) = 1,并由此推论出:
T(n) = 2T(n/2) + n = 4T(n/4) + 2n = 8T(n/8) + 3n = .... = 2^mT(n / 2^m) + mn;
由于T(1) = 1,所以当2^m = n,m = lgn 时(以2为底的log对数写作lg),可得出 T(n) = nT(1) + nlgn ;
所以此算法的时间复杂度为O(nlgn),虽然此方法比暴力法全部遍历的O(n^2)要好,但是其计算过程还是有所重复,如果采用动态规划的做法,记住计算过的子问题解,则可以更快的求出。
对于动态规划方法,此问题可以转化为求0 ~ i 内的最大子数组,令其为 f(i),是那么此时f(i)的取值有两种情况:
- 前i个数的最大子数组就等于前i-1个数的最大子数组,即 f(i) = f(i-1);
- 前i个数的最大的子数组等于从i开始往前的某个最大子数组;
所以f(i)为两种情况的最大值,写出如下代码:
public int maxSubArray(int[] nums) {
int ans = nums[0];
int sum = 0;
for(int num: nums) {
//如果sum>0,则说明sum对结果有增益效果,则sum保留并加上当前遍历数字
if(sum > 0) {
sum += num;
//如果sum<=0,则说明sum对结果无增益效果,需要舍弃,则sum直接更新为当前遍历数字
} else {
sum = num;
}
//每次比较 sum 和 ans的大小,将最大值置为ans,遍历结束返回结果
ans = Math.max(ans, sum);
}
return ans;
}
递归式求解的通用方法
上文提到,我们通过 T(n) = aT(n/b) + f(n)这个递归式求解的通用主方法公式。此递归式描述的这样一种算法的运行时间:它将规模为n的问题分解为a个子问题,每个问题的规模是原问题的1/b,其中a和b都是正常数。a个子问题递归求解,每个子问题花费时间为T(n/b)。分解和合并问题的代价总共花费时间为 f(n)。
递归主定理:令a >= 1,b > 1,f(n) 是一个给定的函数,则算法运行时间 T(n) = aT(n/b) + f(n);
对于在非负整数上的定义,T(n)有如下的渐进界:
(1)
(2)
(3)
对于递归式主定理的理解:
由主定理的三种情况可以看出,每一种情况都要比较 f(n) 与进行比较.(求复杂度时,通常取上界)
- 第一种情况,f(n)与进行比较,较大,则解为
- 第二种情况,两种函数同样大,这是乘以对数因子lgn,即
- 第三种情况,f(n)较大,则递归式的解为
需要特别注意的是:上述f(n) 与 的大小比较是多项式意义的大小比较,即f(n) 必须渐进小于,要相差一个n^ ε因子,其中 ε 为大于0的常数。多项式意义上的大小比较!
例题示范:
例题(1).,求这个递归式的复杂度.
- 第一步.计算,可求得 = n^2,
- 第二步.与f(n)=n进行比较,因此符合第一种情况,解得
例题(2)
- 第一步.计算,可求得a=1, b=3/2,因此 =
- 第二步.与f(n)=1进行比较,符合第二种情况,解得
例题(3) : 主方法不能用于如下递归式:T(n) = 2T(n/2) + nlgn ;
虽然这个递归式看上去有恰当的形式,a = 2, b = 2, f(n) = nlgn, 以及= n ,且 f(n) = nlgn渐进大于 = n 。但问题是它并不是多项式意义上的大于,对于任意正数 ε ,比值 f(n) / = lgn 都渐进小于 n^ε ,他们并没有相差一个n^ε 因子;因此递归式落入了情况2和情况3之间的间隙,既不属于情况2也不属于情况3.
常用算法的应用: