小肥柴慢慢手写数据结构(0-2 经典问题)
目录
0-5 讨论问题的初衷
(1)讨论问题的范围:(黑皮书第1、2章)查询、递归、动态规划案例。
(2)讨论问题的目的:通过经典问题帮助大家找回编程感觉。
(3)大致感受一下课程学习的真实难度,对黑皮书中的案例做出补充和优化。
【注】相关复杂度分析结果见黑皮书,此处就不贴出来了。
0-6 问题(1)最大公约数问题(GCD,欧几里得算法)
(1)问题描述
求解两个整数的最大公约数(GCD)
(2)原理描述(具体见参考链接,以下摘自《编程之美》)
辗转相除法(欧几里得算法): 假设用 f ( x , y ) f(x,y) f(x,y)表示 x x x, y y y的最大公约数,取 k = x / y k=x/y k=x/y, b = x % y b=x\%y b=x%y,则 x = k y + b x=ky+b x=ky+b,如果一个数能够同时整除 x x x和 y y y,则必能同时整除 b b b和 y y y;而能够同时整除 b b b和 y y y的数也必能同时整除 x x x和 y y y,即 x x x和 y y y的公约数是相同的,其最大公约数也是相同的,即:
f ( x , y ) = f ( y , x % y ) ( x ≥ y > 0 ) f(x,y)=f(y,x\%y)~(x\geq y>0) f(x,y)=f(y,x%y) (x≥y>0)
(3)解法:问题解法有很多,具体涉及到数论的知识。
解法1:(黑皮书)非递归解法
unsigned int gcd1(unsigned int M, unsigned int N){
unsigned int Rem;
while( N > 0){
Rem = M % N;
M = N;
N = Rem;
}
return M;
}
解法2:(《编程之美》),解法1的递归模式
unsigned int gcd2(unsigned int M, unsigned int N){
return (N == 0) ? M : gcd2(N, M%N);
}
解法3:(《编程之美》),“辗转相除”的字面解法,可以证明:
1) f ( x , y ) = f ( x − y , y ) f(x,y)=f(x-y, y) f(x,y)=f(x−y,y),避免取模运算,但使用减法增加了迭代次数
2)为避免出现求一个正数和一个负数的最大公约数情况,要求当 x < y x<y x<y,时交换入参位置, f ( x , y ) = f ( y , x ) f(x,y)=f(y, x) f(x,y)=f(y,x)
unsigned int gcd3(unsigned int M, unsigned int N){
if( M < N)
return gcd3(N, M);
if(N == 0)
return M;
else
return gcd3(M-N, N);
}
解法4:(《编程之美》)对 x x x和 y y y,若 x = k ∗ x 1 x=k*x_1 x=k∗x1, y = k ∗ y 1 y=k*y_1 y=k∗y1,则有 f ( x , y ) = k f ( x 1 , y 1 ) f(x,y)=kf(x_1, y_1) f(x,y)=kf(x1,y1);且若 p p p为素数, x = p ∗ x 1 x=p*x_1 x=p∗x1且 y % p ≠ 0 y\%p\neq0 y%p=0(即 y y y不能被 p p p整除),那么 f ( x , y ) = f ( p ∗ x 1 , y ) = f ( x 1 , y ) f(x,y)=f(p*x_1, y)=f(x_1, y) f(x,y)=f(p∗x1,y)=f(x1,y),由此继续改进算法。
考虑到最简单的素数是2,且利用2可以做位运算,直接取 p = 2 p=2 p=2,设计如下逻辑:
1)若 x x x, y y y均为偶数, f ( x , y ) = 2 ∗ ( x / 2 , y / 2 ) = 2 ∗ f ( x ≫ 1 , y ≫ 1 ) f(x,y)=2*(x/2,y/2)=2*f(x\gg1,y\gg1) f(x,y)=2∗(x/2,y/2)=2∗f(x≫1,y≫1)
2)若 x x x为偶数, y y y为奇数, f ( x , y ) = f ( x / 2 , y ) = f ( x ≫ 1 , y ) f(x,y)=f(x/2,y)=f(x\gg1,y) f(x,y)=f(x/2,y)=f(x≫1,y)
3)若 x x x为奇数, y y y为偶数, f ( x , y ) = f ( x , y / 2 ) = f ( x , y ≫ 1 ) f(x,y)=f(x,y/2)=f(x,y\gg1) f(x,y)=f(x,y/2)=f(x,y≫1)
4)若 x x x, y y y均为奇数, f ( x , y ) = f ( y , x − y ) f(x,y)=f(y,x-y) f(x,y)=f(y,x−y)
unsigned int gcd4(unsigned int M, unsigned int N){
if( M < N)
return gcd3(N, M);
if(N == 0)
return M;
else {
if(M & 0x1 != 1)
return (N & 0x1 != 1) ? (gcd4(M >> 1, N >> 1) << 1) : gcd4(M >> 1, N);
else
return (N & 0x1 != 1) ? gcd4(M, N >> 1) : gcd4(N, M-N);
}
}
由此可见数学的重要性(T _ T),相关参考链接如下:
[1]一篇文章搞定最大公约数与扩展欧几里得算法
[2]欧几里得算法——理解算法本质的最好例子,具有很强的实用性
[3]欧几里得算法(即辗转相除法)的时间复杂度
[4](扩展)欧几里得算法
[5]如何证明辗转相除法的长度 ≤ logɑb+1 ?
[6]直面gcd之关于gcd算法复杂度的分析(这篇博客可能是我近期智商最高点)
[6]欧几里得算法时间复杂度简单分析
[7]GCD 和 LCM
[8]欧几里得算法
[9]数论在ACM中的应用
[10]《离散数学及其应用(原书第7版)》(美)Kenneth H.Rosen
[11]《计算机程序设计艺术(第1卷)》
【注】目前还有那个“平均迭代次数”没有找到对应的资料和论文去印证;奈何数学不够看(通信/信号出身,惭愧),在不断查询资料后仍然对黑皮书中给出的一些结论比较茫然,有待后续补完吧;毕竟码农出身,够不着数学的门槛。
【LeeCode相关练习】
[1] 1979.找出数组的最大公约数
[2] 2709. 最大公约数遍历
[3] 1819. 序列中不同最大公约数的数目
0-7 问题(2)数值的整数次方(幂运算)
(1)问题描述
如何快速求出 X N X^N XN?其中 X X X、 N N N均为整数
(2)问题分析
最无脑的解法,肯定也是上不了台面的解法
for(i = 0; i < N; i++)
res *= X;
正确解决思路如下:
P o w ( x ) = { X N / 2 ∗ X N / 2 N=2k X ( N − 1 ) / 2 ∗ X ( N − 1 ) / 2 ∗ X N=2k+1 Pow(x)= \begin{cases} {X^{N/2} * X^{N/2} }& \text{N=2k} \\ {X^{(N-1)/2} * X^{(N-1)/2}* X}& \text{N=2k+1} \end{cases} Pow(x)={XN/2∗XN/2X(N−1)/2∗X(N−1)/2∗XN=2kN=2k+1
核心思想:少算一半乘积!复杂度自然和 l o g log log相关。
(3)解法
解法1:(黑皮书)递归
long int Pow1(long int X, unsigned int N){
if(N == 0)
return 1;
if(N == 1)
return X;
if(N & 0x1 == 1)
return Pow1(X * X, N / 2) * X;
else
return Pow1(X * X, N / 2);
}
这个递归解法很秒,直接传入 X 2 X^2 X2。
解法2:(《剑指offer》)递归
long int Pow2(long int X, unsigned int N){
if(N == 0)
return 1;
if(N == 1)
return X;
long int res = Pow2(X, N >> 1);
res *= res;
if(N & 0x1 == 1)
res *= X;
return res;
}
解法3:非递归解法,省去0/1判断,省去使用函数调用栈
long int Pow3(long int X, unsigned int N){
long int res = 1;
while(N > 0){
if(N & 0x1 == 1)
res *= X;
N >>= 1;
X *= X;
}
return res;
}
0-8 问题(3)斐波那契数列
(1)问题描述(注:角标看具体规定)
F(0) = 1,F(1) = 1,F(n) = F(n - 1) + F(n - 2),其中 n > 1
(2)问题分析
1)首先想到的是上不了台面的方法,直接用公式递归
long int Fib1(int N) {
if(N <= 1)
return 1;
else
return Fib1(N-1) + Fib1(N-2);
}
2) 明显不能这样做,因为这样递归会重复计算很多中间数据,是不合理的。
3) 那应该怎么办呢? ==> 可以尝试复用存储空间和递推式
(3)解决方案
解法2:既然解法1的问题在于重复计算,那么我们可以用一个数组专门存放F(n)的结果,加快递归速度。
long int doFib2(long int *mem, int n){
if(mem[n] != 0)
return mem[n];
mem[n] = doFib2(mem, n-1) + doFib2(mem, n-2);
return mem[n];
}
long int Fib2(int N) {
if(N <= 1)
return 1;
long int *mem = (long int *)malloc((N) * sizeof(long int));
memset(mem, 0 , N);
mem[0] = 1;
mem[1] = 1;
return doFib2(mem, N);
}
但反过来想:既然程序依靠数组就能实现计算,为何还要用递归呢?能非递归的必然性能要比递归强,于是有了解法3.
解法3:将解法2转化为非递归,并引入了动态规划(DP)的印象:有已知结论推导未知结论,限制边界条件:
<1> 已知结论推导未知结论 F(n) = F(n - 1) + F(n - 2),其中 n > 1
<2> 边界条件 F(0) = 1,F(1) = 1
明显地,时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( n ) O(n) O(n)
long int Fib3(int N) {
if(N <= 1)
return 1;
long int *dp = (long int *)malloc((N) * sizeof(long int));
memset(dp, 0 , N);
dp[0] = 1;
dp[1] = 1;
int i;
for(i = 2; i <= N; i++)
dp[i] = dp[i-1] + dp[i-2];
return dp[N];
}
解法4:从DP的角度思考,其实还可以继续剪枝;注意前后项关系,仅需两个变量交替迭代,非递归一次遍历实现计算。即使在大一没有接触过DP,若真的学懂了循环的本质也是能写出相似的代码的;在《剑指offer》和《编程之美》中都已经提及,易有:时间复杂度 O ( n ) O(n) O(n),空间复杂度 O ( 1 ) O(1) O(1)。
long int Fib4(int N) {
if(N <= 1)
return 1;
long int first = 1;
long int second = 1;
long int fibN = 0;
int i;
for(i = 2; i <= N; i++){
fibN = first + second;
second = first;
first = fibN;
}
return fibN;
}
解法5:解法4其实已经是最优解了;当然,还有一种不太使用的解法,利用矩阵推演,简记 F i b ( n ) = F n Fib(n)=F_n Fib(n)=Fn
F i b ( n ) = { 1 n = 0 , 1 F i b ( n − 1 ) + F i b ( n − 2 ) n ≥ 2 Fib(n)= \begin{cases} {1}& {n=0,1} \\ {Fib(n-1) + Fib(n-2)}& {n \geq 2} \end{cases} Fib(n)={1Fib(n−1)+Fib(n−2)n=0,1n≥2
F n = F n − 1 + F n − 2 , F n − 1 = F n − 1 F_n= F_{n-1} + F_{n-2},F_{n-1} = F_{n-1} Fn=Fn−1+Fn−2,Fn−1=Fn−1
易有
[ F n F n − 1 ] = [ F n − 1 F n − 2 ] ∗ [ 1 1 1 0 ] \begin{bmatrix} F_n & F_{n-1} \end{bmatrix} =\begin{bmatrix} F_{n-1} & F_{n-2} \end{bmatrix} * \begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix} [FnFn−1]=[Fn−1Fn−2]∗[1110]
令2*2矩阵 A = [ 1 1 1 0 ] A=\begin{bmatrix} 1 & 1 \\ 1 & 0 \end{bmatrix} A=[1110]
则可以推导
[ F n F n − 1 ] = [ F n − 1 F n − 2 ] ∗ A = [ F n − 2 F n − 3 ] ∗ A 2 = . . . = [ F 1 F 0 ] ∗ A n − 1 \begin{bmatrix} F_{n}& F_{n-1} \end{bmatrix} =\begin{bmatrix} F_{n-1} & F_{n-2} \end{bmatrix} * A = \begin{bmatrix} F_{n-2} & F_{n-3} \end{bmatrix} * {A^2} = ... =\begin{bmatrix} F_{1} & F_{0} \end{bmatrix} * {A^{n-1}} [FnFn−1]=[Fn−1Fn−2]∗A=[Fn−2Fn−3]∗A2=...=[F1F0]∗An−1
即:
[ F n F n − 1 ] = [ F 1 F 0 ] ∗ A n − 1 \begin{bmatrix} F_{n} & F_{n-1} \end{bmatrix} =\begin{bmatrix} F_{1} & F_{0} \end{bmatrix} * {A^{n-1}} [FnFn−1]=[F1F0]∗An−1
那么现在解决矩阵乘法问题就OK了,且注意到矩阵 A A A也能实用快速求幂方法加速:
A N = { A N / 2 ∗ A N / 2 N=2k A ( N − 1 ) / 2 ∗ A ( N − 1 ) / 2 ∗ X N=2k+1 A^{N}= \begin{cases} {A^{N/2} * A^{N/2} }& \text{N=2k} \\ {A^{(N-1)/2} * A^{(N-1)/2}* X}& \text{N=2k+1} \end{cases} AN={AN/2∗AN/2A(N−1)/2∗A(N−1)/2∗XN=2kN=2k+1只需要把 N N N替换为 n − 1 n-1 n−1即可。编码时需注意:
<1> R ∗ = A R*=A R∗=A 计算需要添加单位矩阵
<2> 同 A ∗ = A A*=A A∗=A一样都需要一个零时矩阵暂存原始状态。
<3> 矩阵 R n = A N − 1 R_n = A^{N-1} Rn=AN−1用于存储矩阵幂运算结果,
<4> 最后输出 F n = F 0 ∗ R 00 + F 1 ∗ R 01 F_n = F_{0} * R_{00} + F_{1} * R_{01} Fn=F0∗R00+F1∗R01,且 F 0 = F 1 = 1 F_0=F_1=1 F0=F1=1,则 F n = R 00 + R 01 F_n = R_{00} + R_{01} Fn=R00+R01,减少不必要的乘法运算(其实没有多大作用,已经乘法了那么多次了;当然,也可以考虑使用const封装矩阵乘法操作)
long int Fib5(int N){
long int A[2][2] = {{1,1},{1,0}};
long int R[2][2] = {{1,0},{0,1}};
N = N-1;
while(N > 0){
if(N & 0x1 == 1){
long int T[2][2];
T[0][0] = R[0][0];
T[0][1] = R[0][1];
T[1][0] = R[1][0];
T[1][1] = R[1][1];
R[0][0] = T[0][0] * A[0][0] + T[0][1] * A[1][0];
R[0][1] = T[0][0] * A[0][1] + T[0][1] * A[1][1];
R[1][0] = T[1][0] * A[0][0] + T[1][1] * A[1][0];
R[1][1] = T[1][0] * A[0][1] + T[1][1] * A[1][1];
}
N >>= 1;
long int X[2][2];
X[0][0] = A[0][0];
X[0][1] = A[0][1];
X[1][0] = A[1][0];
X[1][1] = A[1][1];
A[0][0] = X[0][0] * X[0][0] + X[0][1] * X[1][0];
A[0][1] = X[0][0] * X[0][1] + X[0][1] * X[1][1];
A[1][0] = X[1][0] * X[0][0] + X[1][1] * X[1][0];
A[1][1] = X[1][0] * X[0][1] + X[1][1] * X[1][1];
}
return R[0][0] + R[1][0];
}
类似问题:
若 F(0) = 1,F(1) = 2,F(2) = 2,F(n) = F(n - 1) + F(n - 2) + F(n - 3),其中 n > 1, 求F(n) 。
0-9 问题(4)最大子序列和(重要的切入点)
(1)问题描述
给定整数 A 1 , A 2 , . . . , A N A_1,A_2,...,A_N A1,A2,...,AN(可能是负数),求 m a x ( ∑ k = i j A k ) max(\sum_{k=i}^{j} {A_k}) max(∑k=ijAk),若 A k A_k Ak均小于0,则最大值为0。
LeeCode 53. 最大子数组和
【注】黑皮书中给出的限定是“若
A
k
A_k
Ak均小于0,则最大值为0”,而LeeCode给定的测试用例要求类似输入的输出就是负数!所以在具体代码实现上,我们在LeeCode的题解和黑皮书中有细节上的不同。
(2)问题分析与解决
解法1:老老实实的使用三层嵌套,分段遍历, O ( N 3 ) O(N^3) O(N3)的时间复杂度,铁定超时。
int maxSubArray(int* nums, int numsSize) {
int ThisSum, MaxSum, i, j, k;
MaxSum = INT_MIN;
for(i = 0; i < numsSize; i++){
for(j = i; j < numsSize; j++){
ThisSum = 0;
for(k = i; k <= j; k++) //低效循环,本身ThisSum就是要累加的
ThisSum += nums[k];
if(ThisSum > MaxSum)
MaxSum = ThisSum;
}
}
return MaxSum;
}
对应时间复杂度分析:
1)最内层循环
for(k = i; k <= j; k++)
ThisSum += nums[k];
运行次数:
T
(
j
)
=
∑
k
=
i
j
1
=
i
+
(
i
+
1
)
+
.
.
.
+
(
j
−
1
)
+
j
T(j) = \sum_{k=i}^{j}{1}=i+(i+1)+...+(j-1)+j
T(j)=∑k=ij1=i+(i+1)+...+(j−1)+j,易有
T
(
j
)
=
j
−
i
+
1
T(j)=j-i+1
T(j)=j−i+1
2) 次外层循环
for(j = i; j < numsSize; j++){
...
}
运行次数:
S
(
j
)
=
∑
j
=
i
N
−
1
T
(
j
)
=
∑
j
=
i
N
−
1
(
j
−
i
+
1
)
S(j) = \sum_{j=i}^{N-1}{T(j)}=\sum_{j=i}^{N-1}{(j-i+1)}
S(j)=∑j=iN−1T(j)=∑j=iN−1(j−i+1),等差数列求和,易有
S
(
j
)
=
(
N
−
i
+
1
)
(
N
−
i
)
2
S(j)=\frac{(N-i+1)(N-i)}{2}
S(j)=2(N−i+1)(N−i)
3)最外层循环
for(i = 0; i < numsSize; i++){
...
}
运行次数: R ( j ) = ∑ i = 0 N − 1 S ( j ) = ∑ i = 0 N − 1 ( N − i + 1 ) ( N − i ) 2 R(j) = \sum_{i=0}^{N-1}{S(j)}=\sum_{i=0}^{N-1}{\frac{(N-i+1)(N-i)}{2}} R(j)=∑i=0N−1S(j)=∑i=0N−12(N−i+1)(N−i),即总的精确运行次数: R ( j ) = ∑ i = 0 N − 1 ∑ j = i N − 1 ∑ k = i j 1 R(j) = \sum_{i=0}^{N-1}{\sum_{j=i}^{N-1}{\sum_{k=i}^{j}{1}}} R(j)=∑i=0N−1∑j=iN−1∑k=ij1,则:
R ( j ) = ∑ i = 0 N − 1 ∑ j = i N − 1 ∑ k = i j 1 = ∑ i = 0 N − 1 ( N − i + 1 ) ( N − i ) 2 = ∑ i = 1 N ( N − i + 1 ) ( N − i + 2 ) 2 = 1 2 ∑ i = 1 N i 2 − ( N + 3 2 ) ∑ i = 1 N i + 1 2 ( N 2 + 3 N + 2 ) ∑ i = 1 N 1 = 1 2 N ( N + 1 ) ( N + 2 ) 6 − ( N + 3 2 ) N ( N + 1 ) 2 + N 2 + 3 N + 2 2 N = N 3 + 3 N 2 + 2 N 6 \begin{align*} R(j) &= \sum_{i=0}^{N-1}{\sum_{j=i}^{N-1}{\sum_{k=i}^{j}{1}}} \\ & =\sum_{i=0}^{N-1}{\frac{(N-i+1)(N-i)}{2}} \\ & =\sum_{i=1}^{N}{\frac{(N-i+1)(N-i+2)}{2}} \\ & ={\frac{1}{2}{\sum_{i=1}^{N}{i^2}}}-(N+{\frac{3}{2}}){\sum_{i=1}^{N}{i}}+{\frac{1}{2}}(N^2+3N+2){\sum_{i=1}^{N}{1}} \\ & ={\frac{1}{2}}{\frac{N(N+1)(N+2)}{6}}-(N+{\frac{3}{2}}){\frac{N(N+1)}{2}}+{\frac{N^2+3N+2}{2}}N \\ & ={\frac{N^3+3N^2+2N}{6}}\end{align*} R(j)=i=0∑N−1j=i∑N−1k=i∑j1=i=0∑N−12(N−i+1)(N−i)=i=1∑N2(N−i+1)(N−i+2)=21i=1∑Ni2−(N+23)i=1∑Ni+21(N2+3N+2)i=1∑N1=216N(N+1)(N+2)−(N+23)2N(N+1)+2N2+3N+2N=6N3+3N2+2N
即:经过精准推导的时间复杂度为 O ( N 3 ) O(N^3) O(N3),与通过三层for循环估计的问题规模一致。
解法2:观察到本身ThisSum就是要累加的,即合理利用外层循环迭代累加 ∑ k = i j A k = A j + ∑ k = i j − 1 A k \sum_{k=i}^{j} {A_k}={A_j}+{\sum_{k=i}^{j-1}{A_k}} ∑k=ijAk=Aj+∑k=ij−1Ak,时间复杂度降到 O ( N 2 ) O(N^2) O(N2),依旧超时。
int maxSubArray(int* nums, int numsSize) {
int ThisSum, MaxSum, i, j, k;
MaxSum = INT_MIN;
for(i = 0; i < numsSize; i++){
ThisSum = 0;
for(j = i; j < numsSize; j++){
ThisSum += nums[j];
if(ThisSum > MaxSum)
MaxSum = ThisSum;
}
}
return MaxSum;
}
解法3:分治递归。将问题划分为前后两个对半子集去递归求解,同时注意到同时占有左右两部分的中间部分需要求出前半部分的最大和(包括前半部分的最后一个元素)和后半部分最大和(包括后半部分的第一个元素)得到。时间复杂度降到 O ( N l o g N ) O(NlogN) O(NlogN),但执行效果不是很理想。
int MaxSubSum(int *nums, int Left, int Right){
int MaxLeftSum, MaxRightSum;
int MaxLeftBorderSum, MaxRightBorderSum;
int LeftBorderSum, RightBorderSum;
int Center, i;
if(Left == Right){
return nums[Left];
}
Center = (Left + Right) / 2;
MaxLeftSum = MaxSubSum(nums, Left, Center);
MaxRightSum = MaxSubSum(nums, Center+1, Right);
MaxLeftBorderSum = INT_MIN;
LeftBorderSum = 0;
for(i = Center; i >= Left; i--){
LeftBorderSum += nums[i];
if(LeftBorderSum > MaxLeftBorderSum)
MaxLeftBorderSum = LeftBorderSum;
}
MaxRightBorderSum = INT_MIN;
RightBorderSum = 0;
for(i = Center+1; i <= Right; i++){
RightBorderSum += nums[i];
if(RightBorderSum > MaxRightBorderSum)
MaxRightBorderSum = RightBorderSum;
}
int PolarMax = (MaxLeftSum > MaxRightSum) ? MaxLeftSum : MaxRightSum;
int BorderMax = MaxLeftBorderSum + MaxRightBorderSum;
return (PolarMax > BorderMax) ? PolarMax : BorderMax;
}
int maxSubArray(int* nums, int numsSize) {
return MaxSubSum(nums, 0, numsSize -1);
}
关于这个算法时间复杂度的推导如下:
<1> 对处理中间部分代码,可以估算出其时间复杂度为 O ( N ) O(N) O(N),因为其他操作均为常数时间内完成,在大 O O O计算中可忽略。
MaxLeftBorderSum = INT_MIN;
LeftBorderSum = 0;
for(i = Center; i >= Left; i--){
LeftBorderSum += nums[i];
if(LeftBorderSum > MaxLeftBorderSum)
MaxLeftBorderSum = LeftBorderSum;
}
MaxRightBorderSum = INT_MIN;
RightBorderSum = 0;
for(i = Center+1; i <= Right; i++){
RightBorderSum += nums[i];
if(RightBorderSum > MaxRightBorderSum)
MaxRightBorderSum = RightBorderSum;
}
int PolarMax = (MaxLeftSum > MaxRightSum) ? MaxLeftSum : MaxRightSum;
int BorderMax = MaxLeftBorderSum + MaxRightBorderSum;
return (PolarMax > BorderMax) ? PolarMax : BorderMax;
<2> 设整体时间复杂度函数为 T ( N ) T(N) T(N)(即执行次数),那么左右两半子集递归所用时间为 T ( N / 2 ) T(N/2) T(N/2)
MaxLeftSum = MaxSubSum(nums, Left, Center);
MaxRightSum = MaxSubSum(nums, Center+1, Right);
<3> 于是得到一组方程
{ T ( 1 ) = 1 T ( N ) = T ( N / 2 ) + O ( N ) \begin{cases} ~{T(1) = 1 } \\ ~{T(N) = T(N/2) + O(N)} \end{cases} { T(1)=1 T(N)=T(N/2)+O(N)
这个方程的推导有两种方法,都在黑皮书的第七章“归并排序”一节介绍了,接下来我们尝试跟着推导一遍(知识的学习其实可以跳着来,不要太强调先后)。
[推导方法1] 可以将 O ( N ) O(N) O(N)的原型视为 N N N,这样做不会影响最后的结果。
T ( N ) = 2 T ( N / 2 ) + O ( N ) T ( N ) = 2 T ( N / 2 ) + N \begin{align*} T(N) & = 2T(N/2) + O(N) \\ T(N) & = 2T(N/2) + N \end{align*} T(N)T(N)=2T(N/2)+O(N)=2T(N/2)+N
两边同除 N N N,调整一下:
T ( N ) N = T ( N / 2 ) N / 2 + 1 T ( N / 2 ) N / 2 = T ( N / 4 ) N / 4 + 1 T ( N / 4 ) N / 4 = T ( N / 8 ) N / 8 + 1 . . . T ( 2 ) 2 = T ( 1 ) 1 + 1 \begin{align*} {\frac{T(N)}{N}}& ={\frac{T(N/2)}{N/2}}+ 1 \\ {\frac{T(N/2)}{N/2}}& ={\frac{T(N/4)}{N/4}}+ 1 \\ {\frac{T(N/4)}{N/4}}& ={\frac{T(N/8)}{N/8}}+ 1 \\ ... \\ {\frac{T(2)}{2}}& ={\frac{T(1)}{1}}+ 1 \end{align*} NT(N)N/2T(N/2)N/4T(N/4)...2T(2)=N/2T(N/2)+1=N/4T(N/4)+1=N/8T(N/8)+1=1T(1)+1
明显这是高中的“错项向消”or“错位相减”问题,但需要确定到底有多少个式子相加(从1到N/2),设方程个数为 k k k,易有 N = 2 k N=2^k N=2k,进而 k = l o g 2 N k=log_{2}N k=log2N,于是上面的方程累加后有:
T ( N ) N = T ( 1 ) 1 + l o g 2 N \begin{align*} {\frac{T(N)}{N}}& ={\frac{T(1)}{1}}+log_{2}N \end{align*} NT(N)=1T(1)+log2N
带入 T ( 1 ) = 1 T(1)=1 T(1)=1和 O ( l o g 2 N ) = O ( l o g N ) O(log_{2}N)=O(logN) O(log2N)=O(logN), T ( N ) = O ( N l o g N ) T(N)=O(NlogN) T(N)=O(NlogN)即为时间复杂度。
[推导方法2] 大力出奇迹,手动迭代硬算
T ( N ) = T ( N / 2 ) + O ( N ) = 2 T ( N / 2 ) + N = 2 [ 2 T ( N / 4 ) + N / 2 ] + N = 4 T ( N / 4 ) + 2 N = 4 [ 2 T ( N / 8 ) + N / 4 ] + 2 N = 8 T ( N / 4 ) + 3 N = . . . . . = 2 k T ( N / 2 k ) + k N \begin{align*} T(N) & = T(N/2) + O(N) =2T(N/2) + N \\ & =2[2T(N/4) + N/2] + N =4T(N/4)+2N \\ & =4[2T(N/8) + N/4] + 2N =8T(N/4)+3N \\ & = ..... \\ &= 2^kT(N/{2^k}) + kN \end{align*} T(N)=T(N/2)+O(N)=2T(N/2)+N=2[2T(N/4)+N/2]+N=4T(N/4)+2N=4[2T(N/8)+N/4]+2N=8T(N/4)+3N=.....=2kT(N/2k)+kN
同样 k = l o g 2 N ≅ l o g N k=log_{2}N\cong{logN} k=log2N≅logN,带入
T ( N ) = N T ( 1 ) + N l o g N = N l o g N + N T(N)=NT(1)+NlogN=NlogN+N T(N)=NT(1)+NlogN=NlogN+N
同样也能得到时间复杂度为 T ( N ) = O ( N l o g N ) T(N)=O(NlogN) T(N)=O(NlogN)。
解法4:这个解法的思想很简单,即如果已经累加的子序列之和是小于0的,那么就需要舍弃,重新的位置开始继续累加找到新的Max子序列和。理论上时间复杂度降到 O ( N ) O(N) O(N),但执行效果甚至不如解法3,需要更好的解法,譬如DP。
int maxSubArray(int* nums, int numsSize) {
int ThisSum = 0, i;
int MaxSum = INT_MIN;
for(i = 0; i < numsSize; i++){
ThisSum += nums[i];
if(ThisSum > MaxSum)
MaxSum = ThisSum;
if(ThisSum < 0)
ThisSum = 0;
}
return MaxSum;
}
接下来我们从DP视角看看此问题怎么解决(参考LeeCode官方题解和labuladong的相关资料):
解法5:定义 A [ 0 ] . . . . A [ i ] A[0]....A[i] A[0]....A[i]这段数组的最大子序列为 d p [ i ] dp[i] dp[i],开始尝试找出 d p [ i ] dp[i] dp[i]与 d p [ i − 1 ] dp[i-1] dp[i−1]的递推关系(即:状态转移方程)。
<1> 序列 A [ 0 ] . . . . A [ i − 1 ] A[0]....A[i-1] A[0]....A[i−1]与下一个元素 A [ i ] A[i] A[i]必然是相邻的;
<2> 受解法4启发, d p [ i ] dp[i] dp[i]有两种计算可能:
I. 要么 d p [ i − 1 ] ≥ 0 dp[i-1]\geq0 dp[i−1]≥0,加上 A [ i ] A[i] A[i]之后更大,所以 d p [ i ] = d p [ i − 1 ] + A [ i ] dp[i] =dp[i-1] + A[i] dp[i]=dp[i−1]+A[i]
II. 反之 d p [ i ] = A [ i ] dp[i] =A[i] dp[i]=A[i],即放弃前段序列的求和成果 d p [ i − 1 ] dp[i-1] dp[i−1]。
III. 那么 d p [ i ] = m a x ( d p [ i − 1 ] + A [ i ] , A [ i ] ) dp[i] =max(dp[i-1] + A[i], A[i]) dp[i]=max(dp[i−1]+A[i],A[i]),就是需要寻找的递推关系。
【注】此处的dp仅仅是记录从0~i下标的最大子序列和,并不是最终结果。
<3> 遍历所有dp[i],得到其中最大的元素即为所求。
<4> 边界条件有两个:数组长度n=0时,max=0;数组长度n=1时,dp[0] = A[0]。
int maxSubArray(int* nums, int numsSize) {
if(numsSize == 0)
return 0;
if(numsSize == 1)
return nums[0];
int* dp = (int *)malloc(numsSize* sizeof(int));
dp[0] = nums[0];
int i;
for(i = 1; i < numsSize; i++){
int newOne = nums[i] + dp[i-1];
dp[i] = newOne > nums[i] ? newOne : nums[i];
}
int MaxSum = INT_MIN;
for(i = 0; i < numsSize; i++){
if(dp[i] > MaxSum)
MaxSum = dp[i];
}
return MaxSum;
}
此解法时间复杂度和空间复杂度都是 O ( N ) O(N) O(N),实际执行效果也不错;等等既然是DP,且空间复杂度为 O ( N ) O(N) O(N),自然联想到可以尝试做状态压缩,于是有了解法6。
解法6:仔细分析解法5的状态转移方程, d p [ i ] = m a x ( d p [ i − 1 ] + A [ i ] , A [ i ] ) dp[i] =max(dp[i-1] + A[i], A[i]) dp[i]=max(dp[i−1]+A[i],A[i]),发现 d p [ i ] dp[i] dp[i]其实仅与 d p [ i − 1 ] dp[i-1] dp[i−1]相关,自然联想到之前处理斐波那契数列的做法,直接交换两个状态;且如果状态能够交换而不使用数组,那么空间复杂度降为 O ( 1 ) O(1) O(1),且max也能随着两个dp变量的遍历一次拿到,不必再遍历一次,再次提升性能!
int maxSubArray(int* nums, int numsSize) {
if(numsSize == 0)
return 0;
if(numsSize == 1)
return nums[0];
int dp_0 = nums[0], dp_1 = 0;
int MaxSum = dp_0, i;
for(i = 1; i < numsSize; i++){
int newOne = nums[i] + dp_0;
dp_1 = newOne > nums[i] ? newOne : nums[i];
if(dp_1 > MaxSum)
MaxSum = dp_1 ;
dp_0 = dp_1;
}
return MaxSum;
}
此外,官方题解还给出了更高级的数据结构(线段树)的解法,但实测性能不如解法6的终极DP,大家有兴趣可以学习。
0-10 小结
至此,以黑皮书为学习主线的基本数学知识和热身编程项目就告一段落了,之所以讲得那么细,就是希望大家能够静下心来好好训练编程,因为很多优秀的编码其实并不是一蹴而就的,是在反复拉扯和修改中形成的;对应的数学推理训练也需要慢慢培养和强化,无论之后大家是继续学术深造还是投身工业界,对个人思维的训练(就是把你自己的思维训练得接近计算机执行计算操作的计算语言思维)还是很有帮助的,大家加油!