前言
前面我们介绍了归并排序,它利用了分治策略。分治策略一般步骤:
- 分解,即将一个问题划分为一些子问题
- 解决,当子问题规模足够小,则停止递归,直接求解
- 合并,将子问题的解合成原问题的解
最大子数组问题
类似于letcode中的《53. 最大子序和》
求连续子数组的最大和
题目描述:
输入一个整形数组,数组里有正数也有负数。
数组中连续的一个或多个整数组成一个子数组,每个子数组都有一个和。
求所有子数组的和的最大值。要求时间复杂度为O(n)。例如输入的数组为1, -2, 3, 10, -4, 7, 2, -5,和最大的子数组为3, 10, -4, 7, 2,
因此输出为该子数组的和18。
暴力法
即2个for循环,将所有结果都算上一遍,比较大小。
/************************************************************************/
/* 暴力法
/************************************************************************/
void MaxSubArraySum_Force(int arr[], vector<int> &subarr, int len)
{
if (len == 0)
return;
int nMax = INT_MIN;
int low = 0, high = 0;
for (int i = 0; i < len; i ++) {
int nSum = 0;
for (int j = i; j < len; j ++) {
nSum += arr[j];
if (nSum > nMax) {
nMax = nSum;
low = i;
high = j;
}
}
}
for (int i = low; i <= high; i ++) {
subarr.push_back(arr[i]);
}
}
这种方式的时间复杂度是O(n^2),不符合题目O(n)的要求。
分治法
假设数组为A[low...high],利用分治策略
- 分解,将数组A拆分为A[low...mid]和A[mid...high] .
- 解决,当数组A长度为1时,直接返回解
- 合并,将解进行合并,主要三种情况出现
- 最大子数组为于A[low...mid]中(左边)
- 最大子数组为于A[mid...high]中(右边)
- 最大子数组包含mid(中间)
/************************************************************************/
/* 分治法
最大和子数组有三种情况:
1)A[1...mid]
2)A[mid+1...N]
3)A[i..mid..j]
/************************************************************************/
//肯定包含mid,即从mid出发向两边
int Find_Max_Crossing_Subarray(int arr[], int low, int mid, int high)
{
const int infinite = -9999;
int left_sum = infinite;
int right_sum = infinite;
int max_left = -1, max_right = -1;
int sum = 0; //from mid to left;
for (int i = mid; i >= low; i --) {
sum += arr[i];
if (sum > left_sum) {
left_sum = sum;
max_left = i;
}
}
sum = 0; //from mid to right
for (int j = mid + 1; j <= high; j ++) {
sum += arr[j];
if (sum > right_sum) {
right_sum = sum;
max_right = j;
}
}
return (left_sum + right_sum);
}
int Find_Maximum_Subarray(int arr[], int low, int high)
{
if (high == low) //only one element;
return arr[low];
else {
int mid = (low + high)/2;
int leftSum = Find_Maximum_Subarray(arr, low, mid);
int rightSum = Find_Maximum_Subarray(arr, mid+1, high);
int crossSum = Find_Max_Crossing_Subarray(arr, low, mid, high);
if (leftSum >= rightSum && leftSum >= crossSum)
return leftSum;
else if (rightSum >= leftSum && rightSum >= crossSum)
return rightSum;
else
return crossSum;
}
}
这种方式的时间复杂度是O(nlgn),不符合题目O(n)的要求。
区间法
习题4.1-5,你又有了另外一种思路:数组A[1...j+1]的最大和子数组,有两种情况:
- a) A[1...j]的最大和子数组;
- b) 某个A[i...j+1]的最大和子数组,即A[1...i] < 0
/************************************************************************/
/* 区间法
求A[1...j+1]的最大和子数组,有两种情况:
1)A[1...j]的最大和子数组
2)某个A[i...j+1]的最大和子数组
/************************************************************************/
void MaxSubArraySum_Greedy(int arr[], vector<int> &subarr, int len)
{
if (len == 0)
return;
int nMax = INT_MIN;
int low = 0, high = 0;
int cur = 0; //一个指针更新子数组的左区间
int nSum = 0;
for (int i = 0; i < len; i ++) {
nSum += arr[i];
if (nSum > nMax) {
nMax = nSum;
low = cur;
high = i;
}
if (nSum < 0) {
cur = i + 1;
nSum = 0;
}
}
for (int i = low; i <= high; i ++)
subarr.push_back(arr[i]);
}
这种方式的时间复杂度是O(n),符合题目要求。
动态规划
动态规划算法最主要的是寻找递推关系式(前面状态和后面状态的关系), 其递推式为
sum[i+1] = Max(sum[i] + A[i+1], A[i+1])
/************************************************************************/
/* 动态规划(对应着上面的贪心法看,略有不同)
求A[1...j+1]的最大和子数组,有两种情况:
1)A[1...j]+A[j+1]的最大和子数组
2)A[j+1]
dp递推式:
sum[j+1] = max(sum[j] + A[j+1], A[j+1])
/************************************************************************/
int MaxSubArraySum_dp(int arr[], int len)
{
if (len <= 0)
exit(-1);
int nMax = INT_MIN;
int sum = 0;
for (int i = 0; i < len; i ++) {
if (sum >= 0)
sum += arr[i];
else
sum = arr[i];
if (sum > nMax)
nMax = sum;
}
return nMax;
}
这种方式的时间复杂度也是O(n),相对于区间法,代码更加简洁。
练习
- 1-1 当A的所有元素均为负数时,FIND-MAXIMUM-SUBARRAY 返回什么
A中最大的负数
- 1-2 对最大子数组问题,编写暴力求解方法的伪代码, 其运行时间应该为Θ(n²)
略,已在文章讲解
-
1-3 在你的计算机上实现最大子数组问题的暴力算法和递归算法。请指出多大的问题规模n0是性能交叉点——从此之后递归算法将击败暴力算法?然后,修改递归算法的基本情况——当问题规模小于n0时采用暴力算法。修改后,性能交叉点会改变吗?
n0=47的时候,从暴力切换到递归
-
1-4 假定修改最大子数组问题的定义,允许结果为空子数组,其和为0。你应该知道如何修改现有算法,使它们能允许空子数组为最终结果?
当结果为负数的时候,返回0
矩阵乘法的Strassen算法
假设 A 为 的矩阵,B为 的矩阵,那么称 的矩阵 C为矩阵 A与 B的乘积,记作 C=AB ,称为矩阵积.
其中矩阵 C 中的第 i行第j列元素可以表示为:
假如在矩阵 A 和矩阵 B中, m=n=p=N ,那么完成 C=AB 需要多少次乘法呢?
- 对于每一个行 ,总共有 N 行;
- 对于每一个列 ,总共有 N列;
- 计算它们的内积,总共有 N 次乘法计算。
综合可以看出,矩阵乘法的算法复杂度是:O(N^3) 。
再把矩阵相乘的思维提升到方阵相乘
假设矩阵 A 和矩阵 B 都是 N * N(N=2^n) 的方矩阵,求 C=AB,如下所示:
其中
矩阵 C 可以通过下列公式求出:
从上述公式我们可以得出,计算2个 n * n 的矩阵相乘需要2个 n/2 * n/2 的矩阵8次乘法和4次加法。我们使用 T(n) 表示 n * n 矩阵乘法的时间复杂度,那么我们可以根据上面的分解得到下面的递推公式:
Strassen原理详解
那么有没有比 O(N^3) 更快的算法呢?从上述递归工式可以看出每次递归操作都需要8次矩阵相乘,而这正是瓶颈的来源。相比加法,矩阵乘法是非常慢的,于是我们想到减少矩阵相乘的次数使用加法替换。Strassen算法正是从这个角度出发,实现了降低算法复杂度!实现步骤可以分为以下4步:
- 按上述方法将矩阵 A,B,C 分解(花费时间 O(1))
- 如下创建10个 n/2 * n/2 的矩阵S1,S2....S10 (花费时间O(n^2))
- 递归地计算7个矩阵积 P1,P2,P3....P7 ,每个矩阵 P都是 n/2 * n/2的
- 通过 P 计算 C11,C12,C21,C22 ,花费时间 O(n^2)
综合可得如下递归式:
练习
- 2-1使用Strassen算法计算,给出过程
1. 拆解矩阵 A11=(1),A12=(2),A21=(7),A22=(5),B11=(6),B12=(8),B21=(4),B22=(2) 2. 创建10矩阵 S1=B12−B22=6 S2=A11+A12=4 S3=A21+A22=12 S4=B21−B11=−2 S5=A11+A22=6 S6=B11+B22=8 S7=A12−A22=−2 S8=B21+B22=6 S9=A11+A21=−6 S10=B11+B12=14 3. 递归计算7矩阵 P1=A11⋅S1=1⋅6=6 P2=S2⋅B22=4⋅2=8 P3=S3⋅B11=6⋅12=72 P4=A22⋅S4=6⋅12=72 P5=S5⋅S6=6⋅8=48 P6=S7⋅S8=(−2)⋅6=−12 P7=S9⋅S10=(−6)⋅14=−84 4. 得出结果 C11=P5+P4−P2+P6=48+(−10)−8+(−12)=18 C12=P1+P2=6+8=14 C21=P3+P4=72+(−10)=62 C22=P5+P1−P3−P7=48+6−72−(−84)=66
- 2-2写出Strassen算法伪代码
STRASSEN(A, B) n = A.rows if n == 1 return a[1, 1] * b[1, 1] let C be a new n × n matrix A[1, 1] = A[1..n / 2][1..n / 2] A[1, 2] = A[1..n / 2][n / 2 + 1..n] A[2, 1] = A[n / 2 + 1..n][1..n / 2] A[2, 2] = A[n / 2 + 1..n][n / 2 + 1..n] B[1, 1] = B[1..n / 2][1..n / 2] B[1, 2] = B[1..n / 2][n / 2 + 1..n] B[2, 1] = B[n / 2 + 1..n][1..n / 2] B[2, 2] = B[n / 2 + 1..n][n / 2 + 1..n] S[1] = B[1, 2] - B[2, 2] S[2] = A[1, 1] + A[1, 2] S[3] = A[2, 1] + A[2, 2] S[4] = B[2, 1] - B[1, 1] S[5] = A[1, 1] + A[2, 2] S[6] = B[1, 1] + B[2, 2] S[7] = A[1, 2] - A[2, 2] S[8] = B[2, 1] + B[2, 2] S[9] = A[1, 1] - A[2, 1] S[10] = B[1, 1] + B[1, 2] P[1] = STRASSEN(A[1, 1], S[1]) P[2] = STRASSEN(S[2], B[2, 2]) P[3] = STRASSEN(S[3], B[1, 1]) P[4] = STRASSEN(A[2, 2], S[4]) P[5] = STRASSEN(S[5], S[6]) P[6] = STRASSEN(S[7], S[8]) P[7] = STRASSEN(S[9], S[10]) C[1..n / 2][1..n / 2] = P[5] + P[4] - P[2] + P[6] C[1..n / 2][n / 2 + 1..n] = P[1] + P[2] C[n / 2 + 1..n][1..n / 2] = P[3] + P[4] C[n / 2 + 1..n][n / 2 + 1..n] = P[5] + P[1] - P[3] - P[7] return C
- 2-3如何修改Strassen算法,适应矩阵规模不是2的幂的情况,并证明算法运行时间O(n^lg7)
可以将矩阵填充为 n×n 的矩阵,在 Θ(nlg7) 内求解完成后,再将填充元素剥离掉。
- 2-4 如果可以用k次乘法操作(假定乘法的交换律不成立)完成两个3 × 3矩阵相乘,那么你可以在时间内完成n × n矩阵相乘,满足这一条件的最大k是多少?此算法的运行时间是怎样的?
- 2-5 V.Pan发现一种方法,可以用132464次乘法操作完成68 × 68的矩阵相乘,发现另一种方法,可以用143640次乘法操作完成70 × 70的矩阵相乘,还发现一种方法,可以用155424 次乘法操作完成72 × 72 的矩阵相乘。当用于矩阵相乘的分治算法时,上述哪种方法会得到最佳的渐近运行时间?与Strassen算法相比,性能如何?
对于采用分治法的矩阵乘法算法来说,其运行时间都为Θ(n^d) log_68{132464}≈2.795128 log_70{143640}≈2.795122 log_72{155424}≈2.795147 70×70 的矩阵乘法最快,lg7≈2.81 ,比Strassen算法好。
- 2-6用Strassen算法作为子过程来进行一个kn×n矩阵和一个n×kn矩阵相乘,最快需要花费多长时间?对两个输入矩阵规模互换的情况,回答相同的问题。
- 2-7 设计算法,仅使用三次实数乘法即可完成复数a + bi 和 c+di相乘。算法需接收a 、 b 、 c和d 为输入,分别生成实部ac−bd和虚部ad+bc。
借鉴Strassen算法的思想,该问题可以按以下步骤解决。 1.计算P1,P2和P3 P1 = ad P2 = bc P3 = (a–b)(c+d) =ac–bd+ad–bc 2.计算实部和虚部 实部:P3 – P1 + P2 = ac − bd 虚部:P1 + P2 = ad + bc 该算法只需要3次乘法即可。
用代入法求解递归式
代入法求解递归式分为两步:
- 猜测解的形式。
- 用数学归纳法求出解中的常数,并且证明解是正确的。
这种方法很强大,但是我们必须能够猜出解的形式,以便将其代入。例如我们确定下面递归式的上界:
我们猜测其解为,即存在c>0 符合
我们将其代入到递归式,只要c ≥ = 1,以下公式就会成功成立。
练习
-
3-1 证明T(n)=T(n-1)+n的解为O(n^2).
根据即证明 存在c使得T(n)<=cn^2成立 即 T(n)≤c(n−1)^2+n=cn^2−2cn+c+n 当 c=1; n^2−2n+1+n=n2−n+1≤n2 for n≥1
-
3-2 证明: 的解为O(lgn)
证明:T(n)≤clgn 我猜测: T(n)≤clg(n-2) 即 T(n)≤clg(⌈n/2⌉−2)+1≤clg(n/2+1−2)+1≤clg((n−2)/2)+1≤clg(n−2)−clg2+1 只要 c≥1 T(n)≤clg(n−2)≤clgn
-
3-3 我们看到的解为 O(nlgn). 证明: Ω(nlgn)也是这个递归式的解。从而得出结论:解为Θ(nlgn)
-
3-4 通过做出不同的归纳假设,我们不必调整归纳证明中的边界条件,即可客服递归式(4.19)中边界条件T(1)=1带来的困难。
-
3-5 证明:归并排序的严格递归式的解为Θ(nlgn)
-
3-6 证明: 的解为Θ(nlgn)
-
3-7使用4.5节中的主方法,可以证明 T(n)=4T(n/3)+n的解为. 说明基于假设的代入法不能证明这一结论。然后说明如何通过减去一个低阶项完成代入法证明
-
3-8 使用4.5节中的主方法,可以证明T(n)=4T(n/2)+n的解为 说明基于假设 的代入法不能证明这一结论。然后说明如何通过减去一个低阶项完成代入法证明
-
3-9 利用改变变量的方法求解递归式. 你的解应该是渐进紧确的。不必担心数值是否是整数
递归树法求解递归式
代换法有时很难得到一个正确的好的猜测值。递归树最适合用来生成好的猜测,然后即可以用代入法去验证猜测是否正确。 在递归树当中,每个结点表示一个单一子问题的代价,子问题对应某次递归函数的调用。我们将树中每层中的代价求和,得到每层代价,然后将所有层的代价求和,得到所有层次的递归调用的总代价。以递归式为例。我们构造出递归树,如下图所示
我们求递归树中所有层次的代价之和,则可确定整棵树的代价
这样,对于原始的递归式,我们就推导出了一个猜测。我们使用代入的方法去验证猜测是正确的,即是递归式的一个上界。
练习
- 4-1 对递归式,利用递归树确定一个好的渐进上界,用代入法进行验证。
- 4-2 对递归式,利用递归树确定一个好的渐进上界,用代入法进行验证。
- 4-3 对递归式,利用递归树确定一个好的渐进上界,用代入法进行验证。
- 4-4 对递归式,利用递归树确定一个好的渐进上界,用代入法进行验证。
- 4-5 对递归式,利用递归树确定一个好的渐进上界,用代入法进行验证。
- 4-6 对递归式利用递归树论证其解为Ω(nlgn),其中c为常数。
- 4-7 对递归式(c为常数),画出递归树,并给出其解的一个渐进紧确界。用代入法进行验证。
-
4-8 对递归式,利用递归树给出一个渐进紧确解,其中a≥1和c>0是常数。
-
4-9 对递归式,利用递归树给出一个渐进紧确解,其中0<α<1和c>0常数。(略)
主方法求解递归式
主方法主要针对形如T(n) = af(n/b) + f(n)的递归式,它可以瞬间估计一个递推式的算法复杂度。书本上以”菜谱“来描述这种方法的好用之处
粗略的总结:主要看 f(n) 和 的关系,谁大取谁,相等则两个相乘,但要注意看是否相差因子 。对于3),还要看是否满足条件 af(n/b) <= cf(n) . 就像上面所说的,该方法不能用于所有的形如上式的递归式
练习
- 5-1 对下列递归式, 使用主方法求出渐近紧确界。
- 5-2 Caesar教授想设计一个渐近快于Strassen算法的矩阵相乘算法。他的算法使用分治方法,将每个矩阵分解为的子矩阵,分解和合并共花费时间。他需要确定,他的算法需要创建多少个子问题,才能击败Strassen算法。如果他的算法创建a个子问题,则描述运行时间T(n)的递归式为。Caesar教授的算法如要要渐进快于Strassen算法,a的最大整数值应是多少?
Strassen递归式:T(n)=7T(n/2)+Θ(n2)=Θ(nlg7) 情况三可以排除(矩阵乘法 T(n)>Θ(n^2)) log_4{a}= lga/lg4= lga/2 <lg7, => lga<lg49 所以 a 最大值为48。
- 5-3 使用主方法证明: 二分查找递归式的解是
- 5-4 主方法能应用于递归式吗?请说明为什么可以或者为什么不可以。给出这个递归式的一个渐近上界。
- 5-5 考虑主定理情况3的一部分:对某个常数c<1, 正则条件是否成立。给出一个例子,其中常数a≥1,b>1且函数(n)满足主定理的情况3中除正则条件外的所有条件。