算法导论 第一部分 第四章-分治策略
我们知道分治策略,就是3个步骤,分解、解决、合并。
子问题足够大,需要递归解决,叫做递归情况。
子问题足够小,就进入了基本情况。
递归式
递归式可以很方便的表示算法运行时间。比如,我们用以下递归式表示了归并排序的运行时间 T(n)
;
{Θ(1) 如果 n = 1; }
T(n) = { }
{2T(n/2)+ Θ(n) 如果 n > 1}
三种解递归式的方法,
- 代入法,我们猜测一个界,然后用数学归纳法证明是对的
- 递归树法,把递归式转化为一棵树,节点表示不同层次节点的递归产生的代价,然后用边界和技术求解。
- 主方法,
T(n) = aT(n/b) + f(n)
, 就是生成a个子问题,每个子问题规模是原问题的1/n,分解和合并花费时间是f(n)
最大子数组问题
买卖股票时机,输入为
[100,113,110,85,105,102,86,63,81,101,94,106,101,79,94,90,97]
索引是第几天的股价。
暴力法求解,取出所有的组合,显然运行时间是 Ω(n2)
分治策略
把源输入,变成每日的价格变化,变为
[13, -3, -25, 20, -3, -16, -23, 18, 20, -7, 12, -5, -22, 15, -4, 7]
我们找到数组的中间位置mid, 一个子数组 A[i,j]必有3个情况,
数组的左边部分 A[low, mid]
数组的右边部分 A[mid+1, high]
包含mid点
左右部分的解,可以递归解出。
第三种情况的解法。
function find_max_cross_sub_array(arr, low, mid, high) {
// 求出左半部分的最大子数组
let left_sum = -Infinity;
let sum = 0;
let max_left = mid;
for(let i = mid; i >= low; i--) {
sum+=arr[i];
if(sum > left_sum){
left_sum = sum;
max_left = i;
}
}
// console.log(`max_left=${max_left} left_sum=${left_sum}`)
// 求出right半部分的最大子数组
let right_sum = -Infinity;
sum = 0;
let max_right = mid + 1;
for(let j = mid+1; j <= high; j++){
sum+=arr[j];
if(sum > right_sum){
right_sum = sum;
max_right = j;
}
}
// console.log(`max_right=${max_right} right_sum=${right_sum}`)
// 返回两个边界、两个部分的和
return [max_left, max_right, left_sum + right_sum]
}
合并得到结果
function find_max_sub_array(arr, low, high) {
if(low === high){
return [low, high, arr[low]];
}
let mid = Math.floor((low+high)/2);
let [left_low, left_high, left_sum] = find_max_sub_array(arr, low, mid);
let [right_low, right_high, right_sum] = find_max_sub_array(arr, mid + 1, high);
let [cross_low, cross_high, cross_sum] = find_max_cross_sub_array(arr, low, mid, high);
if(left_sum >= right_sum && left_sum >= cross_sum){
return [left_low, left_high, left_sum];
}else if (right_sum >= left_sum && right_sum >= cross_sum){
return [right_low, right_high, right_sum]
}else {
return [cross_low, cross_high, cross_sum];
}
}
补充 - 更简单的解法
var maxProfit = function(prices) {
let min = prices[0];
let max = 0;
for(let i = 1; i < prices.length; i++) {
min = Math.min(min, prices[i]);
max = Math.max(max, prices[i] - min);
}
return max;
};
补充 - 连续数组最大的和
var maxSubArray = function(nums) {
let max = nums[0];
let maxSum = max;
for(let i = 1; i < nums.length;i++){
let c = nums[i];
max = Math.max(max, c, maxSum + c);
maxSum = Math.max(c, maxSum + c)
}
return max;
};
矩阵
矩阵是矩形的数组
A = [ 1 2 3 ]
[ 4 5 6 ]
是一个 2 * 3的矩阵A = (aij) ,矩阵中的元素为aij
通过交换行和列,得到矩阵A的转置AT
[ 1 4 ]
[ 2 5 ]
[ 3 6 ]
向量是一维数组
{ 2 }
x = { 3 }
{ 5 }
是一个大小是3的向量,长度为n的向量为n向量,
xT = (2,3,5)
矩阵的乘法,就是两个相容的矩阵A和B,就是A的列数和B的行数一样
对于一个 n * n的矩阵乘法,普通的解法如下
function matrix_multiply(ma, mb) {
let l = ma.length;
let r = [];
for (let x = 0; x < l; x++) {
r[x] = [];
for (let y = 0; y < l; y++) {
let z = 0;
for (let k = 0; k < l; k++) {
z += (ma[x][k] + mb[k][y]);
}
r[x][y] = z;
}
}
return r;
}
明显需要花费Θ(n3)时间
使用分治法,分解原矩阵为子矩阵。
// ma 和 mb都是n的矩阵
function matrix_multiply_recurse(ma, mb) {
let n = ma.length;
let mc = [];
if (n === 1) {
mc[1][1] = ma[1][1] * mb[1][1];
} else {
//mc11 = matrix_multiply_recurse(ma11, mb11) + matrix_multiply_recurse(ma12, mb21)
//mc12 = matrix_multiply_recurse(ma11, mb12) + matrix_multiply_recurse(ma12, mb22)
//mc21 = matrix_multiply_recurse(ma21, mb11) + matrix_multiply_recurse(ma22, mb21)
//mc22 = matrix_multiply_recurse(ma21, mb12) + matrix_multiply_recurse(ma22, mb22)
}
return mc;
}
使用递归式来表示上述方法,对于n=1,的情况
T(1) = Θ(1)
当n > 1,把一个n问题规模分解为n/2,所以调用一次递归是T(n/2),总共8次, 每个子矩阵有n2/4个元素,每次矩阵加法需要Θ(n2) 的时间
T(n) = 8T(n/2)+ Θ(n2)
最后得到 T(n) = Θ(n3) ,并不优于上述直接的matrix_multiply方法。
以下为具体的实现,略复杂
// A 和 B 都是n的矩阵,n是2的幂
function f(A, B) {
let n = A.length;
let C = [];
if (n === 1) {
C[0] = [];
C[0][0] = A[0][0] * B[0][0];
return C;
}
function add(A, B) {
let r = [];
let n = A.length;
for (let a = 0; a < n; a++) {
r[a] = [];
for (let b = 0; b < n; b++) {
r[a][b] = A[a][b] + B[a][b];
}
}
return r;
}
const m = n / 2;
// 分割两个矩阵
let A11 = [], A12 = [], A21 = [], A22 = [];
let B11 = [], B12 = [], B21 = [], B22 = [];
A.slice(0, m).forEach(l => {
A11.push(l.slice(0, m));
A12.push(l.slice(m, n));
});
A.slice(m, n).forEach(l => {
A21.push(l.slice(0, m));
A22.push(l.slice(m, n));
});
B.slice(0, m).forEach(l => {
B11.push(l.slice(0, m));
B12.push(l.slice(m, n));
});
B.slice(m, n).forEach(l => {
B21.push(l.slice(0, m));
B22.push(l.slice(m, n));
});
let r1 = add(f(A11, B11), f(A12, B21));
let r2 = add(f(A11, B12), f(A12, B22));
let r3 = add(f(A21, B11), f(A22, B21));
let r4 = add(f(A21, B12), f(A22, B22));
C = [...r1, ...r3];
let c2 = [...r2, ...r4];
for (let i = 0; i < C.length; i++) {
C[i] = [...C[i], ...c2[i]];
}
return C;
}
strassen方法
就是用常数次矩阵乘法的代价较少了一次矩阵乘法。T(n) = Θ(nlg7)
递归树解递归式
每个节点表示单一子问题的代价,子问题对应某次递归调用。把树中每层的代价求和,就是总的代价。
由 T(n) = 3T(n/4) + cn2;把n替换为n/4得到
得到T(n/4) = 3T(n/16) + c(n/4)2;
主方法解递归式
主方法就是T(n) = a(n/b) + f(n) 就是把规模是n的子问题划分为a个规模是n/b的子问题,
主定理就是:使得a >= 1和b > 1是常数,f(n)是一个函数T(n)是定义在非负整数上的递归式 T(n) = a(n/b) + f(n)
其中是 n/b 是向上取整或者是向下取整。那么T(n)有以下渐近界。
- 如果某个常数 e > 0 有f(n) = Ο(nlogba-e),那么 T(n) = Θ(nlogba);
- 如果 f(n) = Θ(nlogba), 则 T(n) = Θ(nlogbalgn);
- 如果某个常数 e > 0 有f(n) = Ω(nlogba+e),且对某个常数 c<1 和足够大的n 有af(n/b) <= cf(n) ,则 T(n) = Θ(f(n));
就是我们对比了,f(n)和 nlogba,两个函数较大者决定了递归式的解。如果后者大,就是情况1,如果前者大,就是情况3,如果情况相当,就是情况2.
具体分析,第1种情况,不是 f(n) 小于 nlogba 就够了, 而是多项式意义的小于。也就是说f(n)需要渐近小于 nlogba,要相差一个因子 ne, 其中e是大于0的常数。
第3种情况同样也是。注意,这3种情况并未覆盖f(n)的所有情况,情况1和情况2之间有间隙,2和3之间也有。如果这种情况下,就不可以使用主方法来求递归式。
使用主方法
例子1
T(n) = 9T(n/3) + n
a = 9;
b = 3;
logba = 2;
f(n) = n;
当e=1时,适用第一种情况
那么结果是 T(n) = Θ(n2);
例子2
T(n) = T(2n/3) + 1
a = 1;
b = 1.5;
logba = 0;
f(n) = 1;
适用第二种情况
结果是T(n) = Θ(lgn)
例子3
T(n) = 3T(n/4) + nlgn
a = 3;
b = 4;
logba = log43 = 0.8(约等于)
f(n) = nlgn;
f(n)= n0.8+e,当e=0.2时,可成立情况3
当c = 3/4时,成立。
cf(n) = 3/4 * nlgn
af(n/b) = 3* f(n/4) = 3/4 * n lg4/n <= c*f(n) 所以成立