【数据结构陈越版笔记】第1章 概述【习题】

1. 碎碎念

我这答案做的可能不对,如果不对,欢迎大家指出错误

2. 答案

1.1 判断正误

(1) N ( log N ) 2 N(\text{log}N)^{2} N(logN)2是 O ( N 2 ) O(N^{2}) O(N2)的。

(2) N 2 ( log N ) 2 N^{2}(\text{log}N)^{2} N2(logN)2和 N ( log N ) 2 N(\text{log}N)^{2} N(logN)2具有相同的增长速度。

【答】(1)根据 O ( 1 ) < O ( log ⁡ 2 n ) < O ( n ) < O ( n log ⁡ 2 n ) < O ( n 2 ) < O ( n 3 ) < O ( 2 n ) < O ( n ! ) < O ( n n ) O(1)<O\left(\log _{2} n\right)<O(n)<O\left(n \log _{2} n\right)<O\left(n^{2}\right)<O\left(n^{3}\right)<O\left(2^{n}\right)<O(n!)<O\left(n^{n}\right) O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)

故当 n → ∞ n\to \infty n→∞时, N < N log N < N 2 N<N\text{log}N<N^{2} N<NlogN<N2, 1 < log N < N 1<\text{log}N<N 1<logN<N,所以 N < N ( log N ) 2 < N 3 N<N(\text{log}N)^{2}<N^{3} N<N(logN)2<N3,用不等式法没法求出它到底和 O ( N 2 ) O(N^{2}) O(N2)的关系,于是我们只能通过求二者的数列极限之比来比较大小,由广义的洛必达法则:

广义的洛必达法则

设 f ( x ) f(x) f(x)和 g ( x ) g(x) g(x)在 U o ( x 0 ) \stackrel{o}{U}\left(x_{0}\right) Uo(x0)上可导(即在 x 0 x_{0} x0的去心邻域内可导),若满足:

  1. g ′ ( x ) ≠ 0 g'(x)\ne0 g′(x)=0;
  2. lim ⁡ x → x 0 g ( x ) = ∞ \lim\limits {x \rightarrow x{0}} g(x)=\infty x→x0limg(x)=∞,且 lim ⁡ x → x 0 f ( x ) \lim\limits {x \rightarrow x{0}}f(x) x→x0limf(x)存不存在随意;
  3. lim ⁡ x → x 0 f ′ ( x ) g ′ ( x ) \lim\limits {x \rightarrow x{0}} \frac{f^{\prime}(x)}{g^{\prime}(x)} x→x0limg′(x)f′(x)存在;

则有 lim ⁡ x → x 0 f ( x ) g ( x ) = lim ⁡ x → x 0 f ′ ( x ) g ′ ( x ) \lim\limits {x \rightarrow x{0}} \frac{f(x)}{g(x)}=\lim\limits {x \rightarrow x{0}} \frac{f^{\prime}(x)}{g^{\prime}(x)} x→x0limg(x)f(x)=x→x0limg′(x)f′(x)

f ( x ) = x ( log ( x ) ) 2 f(x)=x(\text{log}(x))^{2} f(x)=x(log(x))2和 g ( x ) = x 2 g(x)=x^{2} g(x)=x2是初等函数,在它们的定义域(包括正无穷点是可导的),由海涅定理,将函数极限归结为数列极限,则 lim ⁡ N → ∞ N ( log N ) 2 N 2 = lim ⁡ N → ∞ ( log N ) 2 N = 广义洛必达法则,此处以log=ln为例子,其他底的结果是一样的 lim ⁡ N → ∞ 2 log N N 1 = lim ⁡ N → ∞ 2 log N N = 0 \lim\limits_{N \to \infty} \frac{N(\text{log}N)^{2}}{N^{2}}=\lim\limits_{N \to \infty} \frac{(\text{log}N)^{2}}{N} \stackrel{\text { 广义洛必达法则,此处以log=ln为例子,其他底的结果是一样的 }}{=}\lim\limits_{N \to \infty}\frac{\frac{2\text{log}N}{N}}{1}=\lim\limits_{N \to \infty}\frac{2\text{log}N}{N}=0 N→∞limN2N(logN)2=N→∞limN(logN)2= 广义洛必达法则,此处以log=ln为例子,其他底的结果是一样的 N→∞lim1N2logN=N→∞limN2logN=0,所以当 n → ∞ n\to \infty n→∞时, N ( log N ) 2 < N 2 N(\text{log}N)^{2}<N^{2} N(logN)2<N2,故 N ( log N ) 2 N(\text{log}N)^{2} N(logN)2不是 O ( N 2 ) O(N^{2}) O(N2)的,错误。

(2)由于 lim ⁡ N → ∞ N 2 ( log N ) 2 N ( log N ) 2 = lim ⁡ N → ∞ N = ∞ \lim\limits_{N \to \infty} \frac{N^{2}(\text{log}N)^{2}}{N(\text{log}N)^{2}}=\lim\limits_{N \to \infty}N=\infty N→∞limN(logN)2N2(logN)2=N→∞limN=∞,所以当 n → ∞ n\to \infty n→∞时, N 2 ( log N ) 2 < N ( log N ) 2 N^{2}(\text{log}N)^{2}<N(\text{log}N)^{2} N2(logN)2<N(logN)2, N 2 ( log N ) 2 N^{2}(\text{log}N)^{2} N2(logN)2和 N ( log N ) 2 N(\text{log}N)^{2} N(logN)2不具有相同的增长速度。

1.2 填空题

(1)给定 N × N N\times N N×N的二维数组 A A A,则在不改变数组的前提下,查找最大元素的时间复杂度是( )。

【答】查找最大元素的时间复杂度是 O ( N 2 ) O(N^{2}) O(N2),因为要双层for循环,第一层遍历行,规模为 N N N,第二层遍历列,规模为 N N N然后去不断比较找到最大元素

(2)斐波那契而数列 F N F_{N} FN的定义为: F 0 = 0 , F 1 = 1 , F N = F N − 1 + F N − 2 , N = 2 , 3 , . . . F_{0}=0,F_{1}=1,F_{N}=F_{N-1}+F_{N-2},N=2,3,... F0=0,F1=1,FN=FN−1+FN−2,N=2,3,...。用递归函数计算 F N F_{N} FN的空间复杂度是( );时间复杂度是( )。

【答】空间复杂度为 O ( N ) O(N) O(N),时间复杂度为 O ( ( 1 + 5 2 ) n ) O\left(\left(\frac{1+\sqrt{5}}{2}\right)^{n}\right) O((21+5 )n),具体解析过程如下:

补充个前置知识,差分方程:

(1)差分方程 :设序列 a 0 , a 1 , . . . , a n , . . . a_{0}, a_{1}, ..., a_{n}, ... a0,a1,...,an,...简记为 { a n } \{a_{n}\} {an},一个把 a n a_{n} an与某些个 a i ( i < n ) a_{i}(i<n) ai(i<n)联系起来的等式称作关于序列 { a n } \{a_{n}\} {an}的差分方程(又称为递推方程,递归方程)

(2)差分方程的阶 :差分方程 f ( n ) = f ( n − 1 ) + f ( n − 2 ) + . . . f(n)=f(n-1)+f(n-2)+... f(n)=f(n−1)+f(n−2)+...中最大下标与最小下标之差称为差分方程的阶。

(3)k k k阶常系数线性差分方程 :设差分方程满足:
{ H ( n ) − a 1 H ( n − 1 ) − a 2 H ( n − 2 ) − ⋯ − a k H ( n − k ) = f ( n ) H ( 0 ) = b 0 , H ( 1 ) = b 1 , H ( 2 ) = b 2 , ⋯   , H ( k − 1 ) = b k − 1 \left\{\begin{array}{l} H(n)-a_{1} H(n-1)-a_{2} H(n-2)-\cdots-a_{k} H(n-k)=f(n) \\ H(0)=b_{0}, H(1)=b_{1}, H(2)=b_{2}, \cdots, H(k-1)=b_{k-1} \end{array}\right. {H(n)−a1H(n−1)−a2H(n−2)−⋯−akH(n−k)=f(n)H(0)=b0,H(1)=b1,H(2)=b2,⋯,H(k−1)=bk−1

其中 a 1 , a 2 , . . . , a k a_{1},a_{2},...,a_{k} a1,a2,...,ak为常数, a k ≠ 0 a_{k}\ne0 ak=0,这个方程称作 k k k阶常系数线性差分方程, b 0 , b 1 , . . . b k − 1 b_{0},b_{1},...b_{k-1} b0,b1,...bk−1为 k k k个初值,当 f ( n ) = 0 f(n)=0 f(n)=0时称这个差分方程为齐次方程

(4)常系数线性齐次差分方程的特征根
{ H ( n ) − a 1 H ( n − 1 ) − a 2 H ( n − 2 ) − ⋯ − a k H ( n − k ) = 0 H ( 0 ) = b 0 , H ( 1 ) = b 1 , H ( 2 ) = b 2 , ⋯   , H ( k − 1 ) = b k − 1 \left\{\begin{array}{l} H(n)-a_{1} H(n-1)-a_{2} H(n-2)-\cdots-a_{k} H(n-k)=0 \\ H(0)=b_{0}, H(1)=b_{1}, H(2)=b_{2}, \cdots, H(k-1)=b_{k-1} \end{array}\right. {H(n)−a1H(n−1)−a2H(n−2)−⋯−akH(n−k)=0H(0)=b0,H(1)=b1,H(2)=b2,⋯,H(k−1)=bk−1

方程 x k − a 1 x k − 1 − ⋯ − a k = 0 x^{k}-a_{1} x^{k-1}-\cdots-a_{k}=0 xk−a1xk−1−⋯−ak=0称作该差分方程的特征方程,特征方程的根

(5)一阶常系数线性差分方程
H ( n ) − a H ( n − 1 ) = f ( n ) H(n)-a H(n-1)=f(n) H(n)−aH(n−1)=f(n)

其中 a ≠ 0 a\ne0 a=0,

当 f ( n ) ≠ 0 f(n)\ne0 f(n)=0,则称此方程为一阶常系数非齐次差分方程;

当 f ( n ) = 0 f(n)=0 f(n)=0,则称此方程为一阶常系数齐次差分方程

(5.1)一阶线性齐次差分方程通解的求法 (特征根法):

对于差分方程 H ( n ) − a H ( n − 1 ) = 0 H(n)-aH(n-1)=0 H(n)−aH(n−1)=0,特征方程为 x − a = 0 x-a=0 x−a=0,特征根为 x = a x=a x=a,故一阶线性齐次差分方程的 H ( n ) − a H ( n − 1 ) = 0 H(n)-aH(n-1)=0 H(n)−aH(n−1)=0的通解为 H ˉ ( n ) = C ⋅ a n ( C 为任意常数 ) \bar{H}(n)=C \cdot a^{n}(C为任意常数) Hˉ(n)=C⋅an(C为任意常数)

(5.2)一阶线性非齐次差分方程通解的求法 (齐次通接+非齐次特解):

H(n)-aH(n-1)=f(n)的通解=齐次方程H(n)-aH(n-1)=0的通解+非齐次的一个特解,其主要有下面两种类型:

(5.2.1)f ( n ) = P t ( n ) f(n)=Pt(n) f(n)=Pt(n)型

方程 H ( n ) − a H ( n − 1 ) = f ( n ) H(n)-aH(n-1)=f(n) H(n)−aH(n−1)=f(n)

特解: H ∗ ( n ) = n k Q t ( n ) H^{*}(n)=n^{k} Q t(n) H∗(n)=nkQt(n)( Q t ( n ) Qt(n) Qt(n)是与 P t ( n ) Pt(n) Pt(n)通次的待定多项式)

其中 k = { 0 , 若 1 不是特征方程的特征根 1 , 若 1 是特征方程的特征根 k=\left\{\begin{array}{l} 0, \text { 若 } 1 \text { 不是特征方程的特征根 } \\ 1, \text { 若 } 1 \text { 是特征方程的特征根 } \end{array}\right. k={0, 若 1 不是特征方程的特征根 1, 若 1 是特征方程的特征根

(5.2.2) f ( n ) = A β n f(n)=A \beta^{n} f(n)=Aβn型( A A A是某个常数, β ≠ 1 \beta\ne1 β=1):

方程的特解为: H ∗ ( n ) = { p ⋅ β n , β 不是特征方程的特征根 p ⋅ n e ⋅ β n , β 是特征方程的特征根 e , 其中 p 为待定常数 H^{*}(n)=\left\{\begin{array}{l} p \cdot \beta n, \beta \text { 不是特征方程的特征根 } \\ p \cdot n^{e} \cdot \beta^{n}, \beta \text { 是特征方程的特征根 } e \end{array} \text {, 其中 } p\right. \text { 为待定常数 } H∗(n)={p⋅βn,β 不是特征方程的特征根 p⋅ne⋅βn,β 是特征方程的特征根 e, 其中 p 为待定常数

(6)二阶常系数线性差分方程:
H ( n ) − a H ( n − 1 ) + b H ( n − 2 ) = f ( n ) H(n)-a H(n-1)+bH(n-2)=f(n) H(n)−aH(n−1)+bH(n−2)=f(n)

其中 a a a为任意常数, b ≠ 0 b\ne0 b=0,

当 f ( n ) ≠ 0 f(n)\ne0 f(n)=0,则称此方程为二阶常系数非齐次差分方程;

当 f ( n ) = 0 f(n)=0 f(n)=0,则称此方程为二阶常系数齐次差分方程

特征方程为 x 2 + a x + b = 0 x^{2}+ax+b=0 x2+ax+b=0

特征根为 x 1 , 2 = − a ± a 2 − 4 b 2 x_{1,2}=\frac{-a \pm \sqrt{a^{2}-4b}}{2} x1,2=2−a±a2−4b

(6.1)二阶常系数线性齐次差分方程通解的求法(特征根法)

通解为 H ˉ ( n ) = { C 1 x 1 n + C 2 x 2 n 且 C 1 , C 2 为任意常数, x 1 ≠ x 2 且均为实根 ( C 1 + C 2 n ) x n 且 C 1 , C 2 为任意常数, x 1 = x 2 且均为实根 r n ( C 1 cos ⁡ θ n + C 2 sin ⁡ θ n ) 且 r = α 2 + β 2 又 tan ⁡ θ = β α , x 1 , 2 = α ± β i 且为一对共轭复根 \bar{H}(n)=\left\{\begin{array}{l} C_{1} x_{1}^{n}+C_{2} x_{2}^{n} \text { 且 } C_{1}, C_{2} \text { 为任意常数, } x_{1} \neq x_{2} \text { 且均为实根 } \\ \left(C_{1}+C_{2} n\right) x^{n} \text { 且 } C_{1}, C_{2} \text { 为任意常数, } x_{1}=x_{2} \text { 且均为实根 } \\ r^{n}\left(C_{1} \cos \theta n+C_{2} \sin \theta n\right) \text { 且 } r=\sqrt{\alpha^{2}+\beta^{2}} \text { 又 } \tan \theta=\frac{\beta}{\alpha}, x_{1,2}=\alpha \pm \beta i \text { 且为一对共轭复根 } \end{array}\right. Hˉ(n)=⎩ ⎨ ⎧C1x1n+C2x2n 且 C1,C2 为任意常数, x1=x2 且均为实根 (C1+C2n)xn 且 C1,C2 为任意常数, x1=x2 且均为实根 rn(C1cosθn+C2sinθn) 且 r=α2+β2 又 tanθ=αβ,x1,2=α±βi 且为一对共轭复根

(6.2)二阶线性非齐次差分方程通解的求法**(齐次通接+非齐次特解)**:

(6.2.1)f ( n ) = P t ( n ) f(n)=Pt(n) f(n)=Pt(n)型

特解为 H ∗ ( n ) = n k Q t ( n ) H^{*}(n)=n^{k} Q t(n) H∗(n)=nkQt(n),其中 k = { 0 , 若 1 不是特征根 1 , 若 1 是特征单根 2 , 若 1 是特征重根 k=\left\{\begin{array}{l} 0, \text { 若 } 1 \text { 不是特征根 } \\ 1, \text { 若 } 1 \text { 是特征单根 } \\ 2, \text { 若 } 1 \text { 是特征重根 } \end{array}\right. k=⎩ ⎨ ⎧0, 若 1 不是特征根 1, 若 1 是特征单根 2, 若 1 是特征重根

(6.2.2) f ( n ) = A β n f(n)=A \beta^{n} f(n)=Aβn型( A A A是某个常数, β ≠ 1 \beta\ne1 β=1):

特解为 H ∗ ( n ) = { p ⋅ β n , β 不是特征根 p ⋅ n x ⋅ β n , β 是特征重根 x H^{*}(n)=\left\{\begin{array}{l} p \cdot \beta^{n}, \beta \text { 不是特征根 } \\ p \cdot n^{x} \cdot \beta^{n}, \beta \text { 是特征重根 } x \end{array}\right. H∗(n)={p⋅βn,β 不是特征根 p⋅nx⋅βn,β 是特征重根 x

(6.2.3) f ( n ) = p ( p 为常数 ) f(n)=p(p为常数) f(n)=p(p为常数):

当特征根不为1时,将 p p p带入原方程求解特解,当特征根为1时,则特解 H ∗ ( n ) = p n H^{*}(n)=pn H∗(n)=pn

根据题目,斐波那契数列的递归函数应该写成如下C语言代码:

c 复制代码
int fib(int n){
    if(n==0)
    {
        return 0;
    }
    else if(n==1)
    {
        return 1;
    }
    else
    {
        return fib(n-1) + fib(n-2);
    }
}

题目中要求求的就是递归函数版本,则先分析空间复杂度:

由于函数递归调用的时候必定涉及到函数调用栈,所以得提前说一下,栈是一种线性的先进后出的数据结构,调用函数的时候,其底层是维护了一个函数调用栈,具体怎么做,请看下面的解答:假设问题规模的 n = 5 n=5 n=5

首先,fib(5)进栈:

fib(4)进栈:

fib(3)进栈:

fib(2)进栈:

fib(2)出栈:

fib(3)出栈:

fib(2)进栈:

fib(2)出栈:

fib(4)出栈:

fib(3)进栈:

fib(2)进栈:

fib(2)出栈:

fib(3)出栈:

fib(5)出栈:

到此,我们来观察一下,我们发现,占用栈内存最多的是这种情况:

输入规模为 N = 5 N=5 N=5占用了 O ( 4 ) O(4) O(4)的空间复杂度,经过数学归纳法推理得知,输入规模为 N N N时,空间复杂度为 O ( N − 1 ) = O ( N ) O(N-1)=O(N) O(N−1)=O(N)

接下来我们探究一下时间复杂度,其实递归函数完全对应着时间复杂度函数 T ( N ) T(N) T(N)的递推过程,即 T ( N ) = T ( N − 1 ) + T ( N − 2 ) , T ( 0 ) = 0 , T ( 1 ) = 1 T(N)=T(N-1)+T(N-2), T(0)=0, T(1)=1 T(N)=T(N−1)+T(N−2),T(0)=0,T(1)=1,亦即 T ( N ) − T ( N − 1 ) − T ( N − 2 ) = 0 T(N)-T(N-1)-T(N-2)=0 T(N)−T(N−1)−T(N−2)=0,这个方程恰好是二阶齐次线性差分方程,其特征方程为 x 2 − x − 1 = 0 x^{2}-x-1=0 x2−x−1=0,特征根为 x 1 = 1 + 1 + 4 2 = 1 + 5 2 x_{1}=\frac{1+ \sqrt{1+4}}{2}=\frac{1+\sqrt{5}}{2} x1=21+1+4 =21+5 , x 2 = 1 − 1 + 4 2 = 1 − 5 2 x_{2}=\frac{1- \sqrt{1+4}}{2}=\frac{1-\sqrt{5}}{2} x2=21−1+4 =21−5 ,即有两个不等的特征实根,则方程通解为 T ˉ ( n ) = C 1 ( 1 + 5 2 ) n + C 2 ( 1 − 5 2 ) n , C 1 , C 2 为任意常数 \bar{T}(n)=C_{1}\left(\frac{1+\sqrt{5}}{2}\right)^{n}+C_{2}\left(\frac{1-\sqrt{5}}{2}\right)^{n}, C_{1}, C_{2} \text { 为任意常数 } Tˉ(n)=C1(21+5 )n+C2(21−5 )n,C1,C2 为任意常数 ,由于 T ( 0 ) = 0 , T ( 1 ) = 1 T(0)=0,T(1)=1 T(0)=0,T(1)=1,即
{ T ˉ ( 0 ) = C 1 + C 2 = 0 T ˉ ( 1 ) = C 1 ( 1 + 5 2 ) + C 2 ( 1 − 5 2 ) = 1 \left\{\begin{array}{l} \bar{T}(0)=C_{1}+C_{2}=0 \\ \bar{T}(1)=C_{1}\left(\frac{1+\sqrt{5}}{2}\right)+C_{2}\left(\frac{1-\sqrt{5}}{2}\right)=1 \end{array}\right. {Tˉ(0)=C1+C2=0Tˉ(1)=C1(21+5 )+C2(21−5 )=1

求得 C 1 = 5 5 , C 2 = − 5 5 C_{1}=\frac{\sqrt{5}}{5},C_{2}=-\frac{\sqrt{5}}{5} C1=55 ,C2=−55

所以原时间复杂度函数为 T ( n ) = 5 5 ( ( 1 + 5 2 ) n − ( 1 − 5 2 ) n ) {T}(n)=\frac{\sqrt{5}}{5}\left(\left(\frac{1+\sqrt{5}}{2}\right)^{n}-\left(\frac{1-\sqrt{5}}{2}\right)^{n}\right) T(n)=55 ((21+5 )n−(21−5 )n),所以时间复杂度为 O ( ( 1 + 5 2 ) n − ( 1 − 5 2 ) n ) O\left(\left(\frac{1+\sqrt{5}}{2}\right)^{n}-\left(\frac{1-\sqrt{5}}{2}\right)^{n}\right) O((21+5 )n−(21−5 )n)

当 n → ∞ n\to \infty n→∞时,由于 ∣ 1 + 5 2 ∣ > 1 , ∣ 1 − 5 2 ∣ < 1 |\frac{1+\sqrt{5}}{2}|>1,|\frac{1-\sqrt{5}}{2}|<1 ∣21+5 ∣>1,∣21−5 ∣<1,

所以 lim ⁡ n → ∞ ( 1 + 5 2 ) n = ∞ , lim ⁡ n → ∞ ( 1 − 5 2 ) n = 0 \lim\limits_{n \to \infty} \left(\frac{1+\sqrt{5}}{2}\right)^{n}=\infty ,\lim\limits_{n \to \infty} \left(\frac{1-\sqrt{5}}{2}\right)^{n}=0 n→∞lim(21+5 )n=∞,n→∞lim(21−5 )n=0

所以,最终的时间复杂度为 O ( ( 1 + 5 2 ) n ) O\left(\left(\frac{1+\sqrt{5}}{2}\right)^{n}\right) O((21+5 )n)

这玩意的时间复杂度是指数爆炸级别的,远大于 O ( n k ) , k > 0 O(n^{k}),k>0 O(nk),k>0

【注】关于 lim ⁡ n → ∞ ( 1 − 5 2 ) n = 0 \lim\limits_{n \to \infty} \left(\frac{1-\sqrt{5}}{2}\right)^{n}=0 n→∞lim(21−5 )n=0的证明,这里引用一下苏德矿高等数学第三版中的证明:

【拓展】这个斐波那契数列还有一个循环的版本,我们来分析一下循环版本的复杂度:

循环版本应该写成如下C语言代码:

c 复制代码
int fib(int n){
    //当n=0时
    if(n==0)
    {
        return 0;
    }
    //当n=1时
    if(n==1)
    {
        return 1;
    }
    //当n=2时
    int fn_1=1; //n-1为1
    int fn_2=0; //n-2为0
    int fn_1_2_sum = fn_1 + fn_2; //f(n-1)+f(n-2)为结果
    //当n>2即n>=3时
    for(int i = 3; i <= n ; i++){
        //f(n-1)与f(n-2)都各自往前移动一项
        fn_2 = fn_1;
        fn_1 = fn_1_2_sum ;
        fn_1_2_sum  = fn_1 + fn_2;
    }
    return fn_1_2_sum ;
}

全程就用了3个变量,相当于空间复杂度是 O ( 3 ) O(3) O(3),也就是常数级的空间复杂度,最终空间复杂度为 O ( 1 ) O(1) O(1),一个for循环,循环变量i从3遍历到n,时间复杂度为 O ( n − 3 ) = O ( n ) O(n-3)=O(n) O(n−3)=O(n)

1.3 试分析下面一段代码的时间复杂度:

c 复制代码
if(A>B){
	for(i=0;i<N;i++)
		for(j=N*N;j>i;j--)
			A+=B;
}
else{
	for(i=0;i<N*2;i++)
		for(j=N*2;j>i;j--)
			A+=B;
}

【答】如果进入if条件,那么最外层循环最多执行N次,内层循环中,当i=0时,j自减的最多,j从 N 2 N^{2} N2开始自减,一直自减到j为1时,就停止自减了,因为j减少到0时,j>i这个条件不满足,无法进入内层循环,则内层循环最多执行 N 2 − 1 N^{2}-1 N2−1,则if条件下的时间复杂度为 O if ( N ( N 2 − 1 ) ) = O if ( N 3 − N ) = O if ( N 3 ) O_{\text{if}}(N(N^{2}-1))=O_{\text{if}}(N^{3}-N)=O_{\text{if}}(N^{3}) Oif(N(N2−1))=Oif(N3−N)=Oif(N3)

再看else条件下,外层循环,i从0开始自增到2N-1的时候执行次数最多,即外层循环最多执行 2 N − 1 2N-1 2N−1次,内层循环,j从 2 N 2N 2N开始自减,当i=0时自减次数最多,自减到1,则内层循环最多执行 2 N − 1 2N-1 2N−1次,则else条件下的时间复杂度为 O else ( ( 2 N − 1 ) ( 2 N − 1 ) ) = O else ( 4 N 2 − 4 N + 1 ) = O else ( N 2 ) O_{\text{else}}((2N-1)(2N-1))=O_{\text{else}}(4N^{2}-4N+1)=O_{\text{else}}(N^{2}) Oelse((2N−1)(2N−1))=Oelse(4N2−4N+1)=Oelse(N2)

所以总的时间复杂度为 T ( n ) = max { O if ( N 3 ) , O else ( N 2 ) } = O ( N 3 ) T(n)=\text{max}\{O_{\text{if}}(N^{3}),O_{\text{else}}(N^{2})\}=O(N^{3}) T(n)=max{Oif(N3),Oelse(N2)}=O(N3)

1.4 分析例1.2中两个版本的PrintN函数的时间、空间复杂度,并测试它们的实际运行效率。对N=100, 1000, 10000, 100000运行程序,将两版本的N-时间曲线绘在一张图里进行比较分析。

c 复制代码
#include <stdio.h>

// 循环打印1到N的全部整数
void CirPrintN(int N)
{
    int i = 0;
    for(i = 1; i<=N; i++)
    {
        printf("%d\n", i);
    }
}
// 递归打印1到N的全部整数
void RecPrintN(int N)
{
    if(N>0)
    {
        RecPrintN(N-1);
        printf("%d\n", N);
    }
}

int main()
{
    int N = 0;
    scanf("%d", &N);
    CirPrintN(N);
    return 0;
}

【答】第一个循环版本PrintN只有一层for循环,从1打印到N,循环版本PrintN的时间复杂度为 O ( N ) O(N) O(N),递归版本的PrintN也是打印N次,可以直接看出递归了N次,则递归版本PrintN的时间复杂度为 O ( N ) O(N) O(N),对于循环版本PrintN,我们看到,只用了一个变量i存储,空间复杂度为 O ( 1 ) O(1) O(1),对于递归版本的PrintN,我们能想象到函数调用栈这样一个情况,等到RecPrint(1)入栈后(此时函数调用栈中有RecPrint(N)到RecPrint(2)所有的递归调用过程)才会依次出栈,此时可以看出,空间复杂度为 O ( n ) O(n) O(n),这个也可以取一个具体的N然后画图说明,类似1.2题(2)。

然后我们用之前提到的【数据结构陈越版笔记】第1章 概论C语言中的计时工具对两种方法进行计时,代码如下:

c 复制代码
#include <stdio.h>
#include <time.h>
#include <math.h>

clock_t start = 0;     //开始时间
clock_t stop = 0;      //结束时间
double duration = 0.0; //算法一共运行了多长时间

#define MAXN 100  // 打印的最大整数N
#define MAXK 1 // 被测函数最大重复调用次数

// 循环打印1到N的全部整数
void CirPrintN(int N)
{
    int i = 0;
    for (i = 1; i <= N; i++)
    {
        printf("%d\n", i);
    }
}
// 递归打印1到N的全部整数
void RecPrintN(int N)
{
    if (N > 0)
    {
        RecPrintN(N - 1);
        printf("%d\n", N);
    }
}

// 此函数用于测试被测函数*f,并且根据case_n输出相应的结果
// case_n是输出的函数编号:1代表函数f1;2代表函数f2
void run(void (*f)(int), int case_n)
{
    int i = 0;
    start = clock();
    //重复调用函数以获得充分多的时钟打点数
    for (i = 0; i < MAXK; i++) // 调用MAXK次
    {
        (*f)(MAXN);
    }
    stop = clock();
    duration = ((double)(stop - start)) / CLK_TCK; // 转换为秒数
    printf("ticks%d= %f \n", case_n, (double)(stop - start));
    printf("duration%d = % 6.2e \n", case_n, duration);
}

int main()
{
    run(CirPrintN,1);
    run(RecPrintN,2);
    return 0;
}

经过运行代码,得知(每个人机器跑出的结果应该是不太一样的)

当 N = 100 N=100 N=100时,循环法运行了 2 × 1 0 − 3 s 2\times10^{-3}\text{s} 2×10−3s,递归法运行了了 3 × 1 0 − 3 s 3\times10^{-3}\text{s} 3×10−3s;

当 N = 1000 N=1000 N=1000时,循环法运行了 2.4 × 1 0 − 2 s 2.4\times10^{-2}\text{s} 2.4×10−2s,递归法运行了了 2.4 × 1 0 − 2 s 2.4\times10^{-2}\text{s} 2.4×10−2s;

当 N = 10000 N=10000 N=10000时,循环法运行了 2.7 × 1 0 − 1 s 2.7\times10^{-1}\text{s} 2.7×10−1s,递归法出现了函数调用栈溢出错误;

当 N = 10000 = N=10000= N=10000=时,循环法运行了 2.42 s 2.42\text{s} 2.42s,递归法出现了函数调用栈溢出错误;

我们用N从1到3500,步长为1,对两种算法进行N和运行时间的取值,然后结果保存成csv文件(1.csv是循环法的数据,2.csv是递归法的数据,均保存在根目录中),再用Python matplotlib画图(我目前只会用matplotlib画图),取N最大为3500是,再取大一些,会出现函数调用栈溢出情况,这样修改后的C语言代码如下:

c 复制代码
#include <stdio.h>
#include <time.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>

//N与运行时间的数据写入CSV文件,id为1指代循环法,id为2指代递归法
void WriteToCsv(int id, int N, long double duration)
{
	FILE* fp = NULL;
	if (id == 1)
	{
		fp = fopen("1.csv", "a+"); //在文件末尾继续写入新数据,而不是覆盖
	}
	else
	{
		fp = fopen("2.csv", "a+"); 
	}
	if (fp == NULL) {
		fprintf(stderr, "fopen() failed.\n");
		exit(EXIT_FAILURE);
	}
	fprintf(fp, "%d,%.18Lf\n", N, duration); //保存18位小数,具体情况视机器的情况而定
	fclose(fp);
}

clock_t start = 0;     //开始时间
clock_t stop = 0;      //结束时间
long double duration = 0.0; //算法一共运行了多长时间

#define MAXN 3500  // N从1测试到3500,实测我电脑3993开始递归的函数调用栈溢出,为了保险测试到3500
#define MAXK 1 // 被测函数最大重复调用次数

// 循环打印1到N的全部整数
void CirPrintN(int N)
{
	int i = 0;
	for (i = 1; i <= N; i++)
	{
		printf("%d\n", i);
	}
}
// 递归打印1到N的全部整数
void RecPrintN(int N)
{
	if (N > 0)
	{
		RecPrintN(N - 1);
		printf("%d\n", N);
	}
}

// 此函数用于测试被测函数*f,并且根据case_n输出相应的结果
// case_n是输出的函数编号:1代表函数f1;2代表函数f2
void run(void (*f)(int), int case_n)
{
	//从1到MAXN传参
	for (int i = 1; i <= MAXN; i++)
	{
		start = clock();
		(*f)(i);
		stop = clock();
		duration = ((long double)(stop - start)) / CLK_TCK; // 转换为秒数
		printf("ticks%d= %Lf \n", case_n, (long double)(stop - start));
		printf("duration%d = % 6.2e \n", case_n, duration);
		WriteToCsv(case_n, i, duration); //将N和运行时间写入csv文件
	}
}

int main()
{
	run(CirPrintN, 1);
	run(RecPrintN, 2);
	return 0;
}

Python绘图代码如下(需要pandas和matplotlib库):

python 复制代码
import pandas as pd
import matplotlib.pyplot as plt
plt.rcParams['font.family'] = 'SimHei'  # 设置中文字体

# 读取CSV文件
df1 = pd.read_csv('1.csv')
df2 = pd.read_csv('2.csv')

# 绘制折线图
plt.figure(figsize=(10, 5))  # 设置图的大小

# 绘制1.csv的数据
plt.plot(df1['N'], df1['duration'], color='red', marker='^', label='循环法PrintN折线')  # 红色线条,三角标记

# 绘制2.csv的数据
plt.plot(df2['N'], df2['duration'], color='blue', marker='o', label='递归法PrintN折线')  # 蓝色线条,圆圈标记

plt.title('PrintN函数N-运行时间折线图')  # 设置图标题
plt.xlabel('N')  # 设置x轴标签
plt.ylabel('运行时间:s')  # 设置y轴标签
plt.grid(True)  # 显示网格
plt.legend()  # 显示图例
plt.show()  # 显示图形

最后将两个算法的N和运行时间绘制折线图到一张图上:

可以看到,时间差不多,毕竟都是O(n)复杂度的算法。

1.5 测试例1.3中秦九韶算法与直接法的效率差别。令 f ( x ) = 1 + ∑ i = 1 100 x i / i f(x)=1+\sum\limits_{i=1}^{100} x^{i} / i f(x)=1+i=1∑100xi/i,计算 f ( 1.1 ) f(1.1) f(1.1)的值。利用clock()函数得到两种算法在同一机器的运行时间。

【答】将之前的代码(详见【数据结构陈越版笔记】第1章 概论)魔改成:

c 复制代码
#include <stdio.h>
#include <time.h>
#include <math.h>

clock_t start = 0;     //开始时间
clock_t stop = 0;      //结束时间
double duration = 0.0; //算法一共运行了多长时间

#define MAXN 10  // 多项式最大项数,即多项式阶数+1
#define MAXK 1e7 // 被测函数最大重复调用次数

// n为多项式的项数,a数组存储的是多项式各系数
//普通的循环法求多项式的和
double f1(int n, double a[], double x)
{
    int i = 0;
    double p = a[0];
    for (i = 1; i <= n; i++)
    {
        p += (a[i] * pow(x, i));
    }
    return p;
}

//秦九韶法求多项式的和
double f2(int n, double a[], double x)
{
    int i = 0;
    double p = a[n];
    for (i = n; i > 0; i--)
    {
        p = a[i - 1] + x * p; //从最里面的括号开始算
    }
    return p;
}

// 此函数用于测试被测函数*f,并且根据case_n输出相应的结果
// case_n是输出的函数编号:1代表函数f1;2代表函数f2
void run(double (*f)(int, double*, double), double a[], int case_n)
{
    int i = 0;
    start = clock();
    //重复调用函数以获得充分多的时钟打点数
    for (i = 0; i < MAXK; i++) // 调用MAXK次
    {
        (*f)(MAXN - 1, a, 1.1);
    }
    stop = clock();
    duration = ((double)(stop - start)) / CLK_TCK; // 转换为秒数
    printf("ticks%d= %f \n", case_n, (double)(stop - start));
    printf("duration%d = % 6.2e \n", case_n, duration);
}

int main()
{
    int i = 0;
    double a[MAXN]; //系数数组
    for (i = 0; i < MAXN; i++)
    {
        a[i] = 1.0/(double)i;//系数这儿改动一下
    }
    run(f1, a, 1);
    run(f2, a, 2);
    return 0;
}

普通循环法求多项式是2.3s,而秦九韶法只需要 2.25 × 1 0 − 1 s 2.25\times10^{-1}\text{s} 2.25×10−1s

1.6 试分析最大子列和算法1.3的空间复杂度。

【答】最大子列和算法1.3的代码:

c 复制代码
// 比较三个数中最大数的宏定义
#define MAX3(A, B, C) (( A > B ? A : B) > C) ? ( A > B ? A : B) : C

// 分治法递归求最大子列和
int DivideAndConquer(int* List, int left, int right)
{
    int MaxLeftSum = INT_MIN; // 左子列的最大和
    int MaxRightSum = INT_MIN; // 右子列的最大和
    int MaxLeftBorderSum = INT_MIN; //跨越中点的子列的左侧的和
    int MaxRightBorderSum = INT_MIN; //跨越中点的子列的右侧的和
    int LeftBorderSum = 0; //跨越中点的子列的左侧的和(不一定是最大的)
    int RightBorderSum = 0; //跨越中点的子列的右侧的和
    int middle = 0; //分治法求分界点的变量s
    // left与right重合时,递归停止,也就是子列只有一个数字
    // 这是最小的子列,其和就是这一个元素,如果它的和
    // 也就是这一个元素为负数或者0,则应该返回0(根据题意)
    // 如果是LeetCode53,则应该直接返回List[left],不需要加判断条件
    // 因为LeetCode53是需要对比负数和的
    if(left == right)
    {
        if(List[left] > 0)
        {
            return List[left];
        }
        else
        {
            return 0;
        }
    }
    // 求解中点,向右移动一位相当于除2
    middle = (right + left)>>1;  // 此处也可以等价成(right - left)/2 + left,但是这样写会超时
    //递归求解左子列和右子列的最大和
    MaxLeftSum = DivideAndConquer(List, left, middle);
    MaxRightSum = DivideAndConquer(List, middle + 1, right);
    //求跨越中点的子列的最大和
    MaxLeftBorderSum = INT_MIN; //每次求和之前,要将最大值变为无穷小,方便比较
    LeftBorderSum = 0;
    //找跨越中点的子列的左侧的最大和(从中点向左遍历)
    for(int i = middle; i>=left; i--)
    {
        LeftBorderSum += List[i];
        if(LeftBorderSum > MaxLeftBorderSum)
        {
            MaxLeftBorderSum = LeftBorderSum;
        }
    }
    //找跨越中点的子列的右侧的最大和(从中点向右遍历)
    MaxRightBorderSum = INT_MIN; //每次求和之前,要将最大值变为无穷小,方便比较
    RightBorderSum = 0;
    for(int i = middle + 1; i<=right; i++)
    {
        RightBorderSum += List[i];
        if(RightBorderSum > MaxRightBorderSum)
        {
            MaxRightBorderSum = RightBorderSum;
        }
    }
    // 返回左子列,跨越中点的子列和右子列三者中的最大值
    return MAX3(MaxLeftSum, MaxLeftBorderSum + MaxRightBorderSum, MaxRightSum);
}
int maxSubArray(int* List, int N) {
    return DivideAndConquer(List, 0, N-1);
}

卡卡!我们还是画图吧,不画图硬推太难了:

假设存在这样一个数组[-1,-2,0],求其最大子列和,下面画出其函数调用栈的图:
第1步

第2步

第3步

第4步

第5步

第6步

第7步


第8步

第9步

第10步


第11步

第12步

第13步

第14步

第15步

第16步


第17步

可以看到,函数调用栈最深的时候有三个递归过程,恰好对应3个元素,所以其空间复杂度应为 O ( N ) O(N) O(N)

1.7 测试最大子列和4种算法的实际运行效率。简单起见,可令List中全部整数位1。当N=2, 4, 6, 8, 10, ..., 28, 30时,将各算法的N-时间曲线绘在一张图上,其中时间以毫秒为单位:当N=1000, 2000, ..., 10000时,以秒为单位绘出各算法的时间增长曲线。两幅图有什么不同?为什么?

【答】4种算法的代码如下:

时间复杂度为 O ( N 3 ) O(N^{3}) O(N3)的暴力法:

c 复制代码
#include <stdio.h>

//暴力法
int MaxSubseqSum1(int List[], int N)
{
    int ThisSum = 0; //当前子列的和
    int MaxSum = 0; //最大子列和,默认赋值为0,如果和为负数,就只能返回0
    //i是子列左端位置
    for (int i = 0; i < N; i++)
    {
        //j是子列右端位置
        for (int j = i; j < N; j++)
        {
            ThisSum = 0;
            // 把子列和(从List[i]加到List[j])加一起
            for (int k = i; k <= j; k++)
            {
                ThisSum += List[k];
            }
            // 如果当前和超过之前的最大和,则最大和赋值成这个
            if (ThisSum > MaxSum)
            {
                MaxSum = ThisSum;
            }
        }
    }
    return MaxSum;
}

时间复杂度为 O ( N 2 ) O(N^{2}) O(N2)的暴力法:

c 复制代码
#include <stdio.h>

//暴力法2
int MaxSubseqSum2(int List[], int N)
{
    int ThisSum = 0; //当前子列的和
    int MaxSum = 0; //最大子列和,默认赋值为0,如果和为负数,就只能返回0
    //i是子列左端位置
    for (int i = 0; i < N; i++)
    {
        ThisSum = 0; // ThisSum清零的工作就放到了j这个循环的外层
        //j是子列右端位置
        for (int j = i; j < N; j++)
        {
            ThisSum += List[j];
            // 如果当前和超过之前的最大和,则最大和赋值成这个
            if (ThisSum > MaxSum)
            {
                MaxSum = ThisSum;
            }
        }
    }
    return MaxSum;
}

时间复杂度为 O ( N log N ) O(N\text{log}N) O(NlogN)的分治法:

c 复制代码
// 比较三个数中最大数的宏定义
#define MAX3(A, B, C) (( A > B ? A : B) > C) ? ( A > B ? A : B) : C

// 分治法递归求最大子列和
int DivideAndConquer(int* List, int left, int right)
{
    int MaxLeftSum = INT_MIN; // 左子列的最大和
    int MaxRightSum = INT_MIN; // 右子列的最大和
    int MaxLeftBorderSum = INT_MIN; //跨越中点的子列的左侧的和
    int MaxRightBorderSum = INT_MIN; //跨越中点的子列的右侧的和
    int LeftBorderSum = 0; //跨越中点的子列的左侧的和(不一定是最大的)
    int RightBorderSum = 0; //跨越中点的子列的右侧的和
    int middle = 0; //分治法求分界点的变量s
    // left与right重合时,递归停止,也就是子列只有一个数字
    // 这是最小的子列,其和就是这一个元素,如果它的和
    // 也就是这一个元素为负数或者0,则应该返回0(根据题意)
    // 如果是LeetCode53,则应该直接返回List[left],不需要加判断条件
    // 因为LeetCode53是需要对比负数和的
    if(left == right)
    {
        if(List[left] > 0)
        {
            return List[left];
        }
        else
        {
            return 0;
        }
    }
    // 求解中点,向右移动一位相当于除2
    middle = (right + left)>>1;  // 此处也可以等价成(right - left)/2 + left,但是这样写会超时
    //递归求解左子列和右子列的最大和
    MaxLeftSum = DivideAndConquer(List, left, middle);
    MaxRightSum = DivideAndConquer(List, middle + 1, right);
    //求跨越中点的子列的最大和
    MaxLeftBorderSum = INT_MIN; //每次求和之前,要将最大值变为无穷小,方便比较
    LeftBorderSum = 0;
    //找跨越中点的子列的左侧的最大和(从中点向左遍历)
    for(int i = middle; i>=left; i--)
    {
        LeftBorderSum += List[i];
        if(LeftBorderSum > MaxLeftBorderSum)
        {
            MaxLeftBorderSum = LeftBorderSum;
        }
    }
    //找跨越中点的子列的右侧的最大和(从中点向右遍历)
    MaxRightBorderSum = INT_MIN; //每次求和之前,要将最大值变为无穷小,方便比较
    RightBorderSum = 0;
    for(int i = middle + 1; i<=right; i++)
    {
        RightBorderSum += List[i];
        if(RightBorderSum > MaxRightBorderSum)
        {
            MaxRightBorderSum = RightBorderSum;
        }
    }
    // 返回左子列,跨越中点的子列和右子列三者中的最大值
    return MAX3(MaxLeftSum, MaxLeftBorderSum + MaxRightBorderSum, MaxRightSum);
}
int maxSubArray(int* List, int N) {
    return DivideAndConquer(List, 0, N-1);
}

时间复杂度为 O ( N ) O(N) O(N)的在线处理(动态规划)法:

c 复制代码
int maxSubArray(int* nums, int numsSize){
    int result=0;//最开始假定最大值为0,因为这个题目要求的是负数和算为0,如果和LeetCode53一样需要负数和,此处应设置为INT_MIN
    int count =0;//子数组的求和结果
    for(int i=0;i<numsSize;i++)
    {
        count = count + nums[i];
        //count大于假定的最大值,就让假定的最大值等于count
        if(count > result)
        {
            result = count;
        }
        //加和小于等于0,则其不是最大连续子序列,让count从0开始加
        //如果加和变成负数,说明从nums[i]开始向前到nums[0]的数无论怎么取连续子数组都只能小于等于result
        //result记录的是nums[i]之前的数字的最大连续子数组的和
        //所以,就没必要再回到前面去找加和了,直接从nums[i]向后加和对比
        //如果从nums[i]开始到最后的加和中出现了加和大于nums[i]之前的最大连续子数组的和result
        //那就让result赋值为nums[i]后面的最大连续子数组的和的值
        //这样就找到了最大连续子数组的和
        if(count<0)
        {
            count =0;
        }
    }
    return result;
}

魔改一下计时的那个代码如下:

c 复制代码
#include <stdio.h>
#include <time.h>
#include <math.h>
#include <stdlib.h>
#include <string.h>

//N与运行时间的数据写入CSV文件
//1指的是时间复杂度为O(N^3)的暴力法
//2指的是时间复杂度为O(N^2)的暴力法
//3指的是时间复杂度为O(NlogN)的分治法
//4指的是时间复杂度为O(N)的在线处理(动态规划)法
void WriteToCsv(int id, int N, long double duration)
{
	FILE* fp = NULL;
	switch (id)
	{
		case 1:
			fp = fopen("1.csv", "a+");
			break;
		case 2:
			fp = fopen("2.csv", "a+");
			break;
		case 3:
			fp = fopen("3.csv", "a+");
			break;
		case 4:
			fp = fopen("4.csv", "a+");
			break;
		default:
			fprintf(stderr, "fopen() failed.\n");
			exit(EXIT_FAILURE);
	}
	if (fp == NULL) {
		fprintf(stderr, "fopen() failed.\n");
		exit(EXIT_FAILURE);
	}
	fprintf(fp, "%d,%.18Lf\n", N, duration); //保存18位小数,具体情况视机器的情况而定
	fclose(fp);
}

clock_t start = 0;     //开始时间
clock_t stop = 0;      //结束时间
long double duration = 0.0; //算法一共运行了多长时间

#define MAXN 30 // 最多测试到长度为30的全为1的数组

//时间复杂度为O(N^3)的暴力法
int MaxSubseqSum1(int* List, int N)
{
	int ThisSum = 0; //当前子列的和
	int MaxSum = 0; //最大子列和,默认赋值为0,如果和为负数,就只能返回0
	//i是子列左端位置
	for (int i = 0; i < N; i++)
	{
		//j是子列右端位置
		for (int j = i; j < N; j++)
		{
			ThisSum = 0;
			// 把子列和(从List[i]加到List[j])加一起
			for (int k = i; k <= j; k++)
			{
				ThisSum += List[k];
			}
			// 如果当前和超过之前的最大和,则最大和赋值成这个
			if (ThisSum > MaxSum)
			{
				MaxSum = ThisSum;
			}
		}
	}
	return MaxSum;
}

//时间复杂度为O(N^2)的暴力法
int MaxSubseqSum2(int* List, int N)
{
	int ThisSum = 0; //当前子列的和
	int MaxSum = 0; //最大子列和,默认赋值为0,如果和为负数,就只能返回0
	//i是子列左端位置
	for (int i = 0; i < N; i++)
	{
		ThisSum = 0; // ThisSum清零的工作就放到了j这个循环的外层
		//j是子列右端位置
		for (int j = i; j < N; j++)
		{
			ThisSum += List[j];
			// 如果当前和超过之前的最大和,则最大和赋值成这个
			if (ThisSum > MaxSum)
			{
				MaxSum = ThisSum;
			}
		}
	}
	return MaxSum;
}

//时间复杂度为O(NlogN)的分治法
// 比较三个数中最大数的宏定义
#define MAX3(A, B, C) (( A > B ? A : B) > C) ? ( A > B ? A : B) : C

// 分治法递归求最大子列和
int DivideAndConquer(int* List, int left, int right)
{
	int MaxLeftSum = INT_MIN; // 左子列的最大和
	int MaxRightSum = INT_MIN; // 右子列的最大和
	int MaxLeftBorderSum = INT_MIN; //跨越中点的子列的左侧的和
	int MaxRightBorderSum = INT_MIN; //跨越中点的子列的右侧的和
	int LeftBorderSum = 0; //跨越中点的子列的左侧的和(不一定是最大的)
	int RightBorderSum = 0; //跨越中点的子列的右侧的和
	int middle = 0; //分治法求分界点的变量s
	// left与right重合时,递归停止,也就是子列只有一个数字
	// 这是最小的子列,其和就是这一个元素,如果它的和
	// 也就是这一个元素为负数或者0,则应该返回0(根据题意)
	// 如果是LeetCode53,则应该直接返回List[left],不需要加判断条件
	// 因为LeetCode53是需要对比负数和的
	if (left == right)
	{
		if (List[left] > 0)
		{
			return List[left];
		}
		else
		{
			return 0;
		}
	}
	// 求解中点,向右移动一位相当于除2
	middle = (right + left) >> 1;  // 此处也可以等价成(right - left)/2 + left,但是这样写会超时
	//递归求解左子列和右子列的最大和
	MaxLeftSum = DivideAndConquer(List, left, middle);
	MaxRightSum = DivideAndConquer(List, middle + 1, right);
	//求跨越中点的子列的最大和
	MaxLeftBorderSum = INT_MIN; //每次求和之前,要将最大值变为无穷小,方便比较
	LeftBorderSum = 0;
	//找跨越中点的子列的左侧的最大和(从中点向左遍历)
	for (int i = middle; i >= left; i--)
	{
		LeftBorderSum += List[i];
		if (LeftBorderSum > MaxLeftBorderSum)
		{
			MaxLeftBorderSum = LeftBorderSum;
		}
	}
	//找跨越中点的子列的右侧的最大和(从中点向右遍历)
	MaxRightBorderSum = INT_MIN; //每次求和之前,要将最大值变为无穷小,方便比较
	RightBorderSum = 0;
	for (int i = middle + 1; i <= right; i++)
	{
		RightBorderSum += List[i];
		if (RightBorderSum > MaxRightBorderSum)
		{
			MaxRightBorderSum = RightBorderSum;
		}
	}
	// 返回左子列,跨越中点的子列和右子列三者中的最大值
	return MAX3(MaxLeftSum, MaxLeftBorderSum + MaxRightBorderSum, MaxRightSum);
}
int MaxSubseqSum3(int* List, int N) {
	return DivideAndConquer(List, 0, N - 1);
}

//时间复杂度为O(N)的在线处理(动态规划)法
int MaxSubseqSum4(int* nums, int numsSize) {
	int result = 0;//最开始假定最大值为0,因为这个题目要求的是负数和算为0,如果和LeetCode53一样需要负数和,此处应设置为INT_MIN
	int count = 0;//子数组的求和结果
	for (int i = 0; i < numsSize; i++)
	{
		count = count + nums[i];
		//count大于假定的最大值,就让假定的最大值等于count
		if (count > result)
		{
			result = count;
		}
		//加和小于等于0,则其不是最大连续子序列,让count从0开始加
		//如果加和变成负数,说明从nums[i]开始向前到nums[0]的数无论怎么取连续子数组都只能小于等于result
		//result记录的是nums[i]之前的数字的最大连续子数组的和
		//所以,就没必要再回到前面去找加和了,直接从nums[i]向后加和对比
		//如果从nums[i]开始到最后的加和中出现了加和大于nums[i]之前的最大连续子数组的和result
		//那就让result赋值为nums[i]后面的最大连续子数组的和的值
		//这样就找到了最大连续子数组的和
		if (count < 0)
		{
			count = 0;
		}
	}
	return result;
}

//创建一个长度为N的全1序列
int* createOnes(int N)
{
	int* result = (int*)malloc(sizeof(int) * N);
	for (int i = 0; i < N; i++)
	{
		result[i] = 1;
	}
	return result;
}

// 此函数用于测试被测函数*f,并且根据case_n输出相应的结果
// case_n是输出的函数编号:
//1指的是时间复杂度为O(N^3)的暴力法
//2指的是时间复杂度为O(N^2)的暴力法
//3指的是时间复杂度为O(NlogN)的分治法
//4指的是时间复杂度为O(N)的在线处理(动态规划)法
void run(int (*f)(int*, int), int case_n)
{
	//从2到MAXN,取偶数值生成全为1的数组然后计时对比
	for (int i = 2; i <= MAXN; i=i+2)
	{
		int* arr = createOnes(i);
		start = clock();
		// 运行10^6次,让时间明显一些
		for (int j = 0; j <= 10e6; j++)
		{
			(*f)(arr, i);
		}
		stop = clock();
		duration = ((long double)(stop - start)) / CLK_TCK; // 转换为秒数
		printf("ticks%d= %Lf \n", case_n, (long double)(stop - start));
		printf("duration%d = % 6.2e \n", case_n, duration);
		WriteToCsv(case_n, i, duration); //将N和运行时间写入csv文件
	}
}

int main()
{
	run(MaxSubseqSum1, 1);
	run(MaxSubseqSum2, 2);
	run(MaxSubseqSum3, 3);
	run(MaxSubseqSum4, 4);
	return 0;
}

最后在根目录生成了100000次运行的时间和N的关系的CSV文件,然后用以下Python脚本画图:

python 复制代码
import pandas as pd
import matplotlib.pyplot as plt
plt.rcParams['font.family'] = 'SimHei'  # 设置中文字体

# 读取CSV文件
df1 = pd.read_csv('1.csv')
df2 = pd.read_csv('2.csv')
df3 = pd.read_csv('3.csv')
df4 = pd.read_csv('4.csv')

# 还原真实数据,之前是用100000次取得时间,这次除回去,求得平均时间
df1['duration'] = df1['duration'] / 100000
df2['duration'] = df2['duration'] / 100000
df3['duration'] = df3['duration'] / 100000
df4['duration'] = df4['duration'] / 100000

# 时间以ms为单位,再乘1000
df1['duration'] = df1['duration'] * 1000
df2['duration'] = df2['duration'] * 1000
df3['duration'] = df3['duration'] * 1000
df4['duration'] = df4['duration'] * 1000

# 绘制折线图
plt.figure(figsize=(10, 5))  # 设置图的大小

# 绘制1.csv的数据
plt.plot(df1['N'], df1['duration'], color='red', marker='^', label=r'时间复杂度为$O(N^{3})$的暴力法')  # 红色线条,三角标记

# 绘制2.csv的数据
plt.plot(df2['N'], df2['duration'], color='blue', marker='o', label=r'时间复杂度为$O(N^{2})$的暴力法')  # 蓝色线条,圆圈标记

# 绘制3.csv的数据
plt.plot(df3['N'], df3['duration'], color='green', marker='*', label=r'时间复杂度为$O(N\text{log}N)$的分治法')  # 蓝色线条,圆圈标记

# 绘制4.csv的数据
plt.plot(df4['N'], df4['duration'], color='orange', marker='x', label=r'时间复杂度为$O(N)$的在线处理法')  # 蓝色线条,圆圈标记

plt.title('N-运行时间折线图')  # 设置图标题
plt.xlabel('N')  # 设置x轴标签
plt.ylabel('运行时间:ms')  # 设置y轴标签
plt.grid(True)  # 显示网格
plt.legend()  # 显示图例
plt.show()  # 显示图形

最后得到N-运行时间曲线图为:

要绘制时间增长曲线图(就是绘制时间复杂度函数的值),就要算一下当前四个算法在处理一个规模的问题需要多少秒,但是这样很难捕捉,题目要求是从1000取到10000,步长为1000这样写,那我们就要求规模为1000的问题处理起来需要多少秒,对于暴力法直接正常求解,对于其他两种方法,需要多次运行,运行100次再除100取平均,因为直接计算会是0s,精度不够,于是将上面的run函数改为:

c 复制代码
void run(int (*f)(int*, int), int case_n)
{
	int* arr = createOnes(1000);
	start = clock();
	if (case_n == 1 || case_n == 2)
	{
		(*f)(arr, 1000);
	}
	else
	{
		for (int i = 0; i < 100; i++)
		{
			(*f)(arr, 1000);
		}
	}
	stop = clock();
	duration = ((long double)(stop - start)) / CLK_TCK; // 转换为秒数
	printf("ticks%d= %Lf \n", case_n, (long double)(stop - start));
	printf("duration%d = % 6.2e \n", case_n, duration);
	WriteToCsv(case_n, 1000, duration); //将N和运行时间写入csv文件
}

最后,暴力法1运行规模1000需要 3.84 × 1 0 − 1 s 3.84\times10^{-1}\text{s} 3.84×10−1s,暴力法2运行规模1000需要 1 × 1 0 − 3 s 1\times10^{-3}\text{s} 1×10−3s,分治法运行规模1000需要 4 × 1 0 − 5 s 4\times10^{-5}\text{s} 4×10−5s,在线处理法运行规模1000需要 1 × 1 0 − 5 s 1\times10^{-5}\text{s} 1×10−5s,因为每个机器的运行时间不一样,所以我们分别设四种算法的真正的时间复杂度函数为 T 1 ( N ) = a N 3 , T 2 ( N ) = b N 2 , T 3 ( N ) = c N log N , T 4 ( N ) = d N , a , b , c , d 均为任意常数 T_{1}(N)=aN^{3},T_{2}(N)=bN^{2},T_{3}(N)=cN\text{log}N,T_{4}(N)=dN,a,b,c,d均为任意常数 T1(N)=aN3,T2(N)=bN2,T3(N)=cNlogN,T4(N)=dN,a,b,c,d均为任意常数,常数是为了估计真正的时间复杂度函数, T 1 ( 1000 ) = a 1 0 9 = 3.84 × 1 0 − 1 s , a = 3.84 × 1 0 − 10 T_{1}(1000)=a10^{9}=3.84\times10^{-1}\text{s},a=3.84\times10^{-10} T1(1000)=a109=3.84×10−1s,a=3.84×10−10, T 2 ( 1000 ) = b 1 0 6 = 1 × 1 0 − 3 s , b = 1 × 1 0 − 9 T_{2}(1000)=b10^{6}=1\times10^{-3}\text{s},b=1\times10^{-9} T2(1000)=b106=1×10−3s,b=1×10−9, T 3 ( 1000 ) = c 1000 log 2 1000 = 4 × 1 0 − 5 s , c = 4.012 × 1 0 − 9 T_{3}(1000)=c1000\text{log}{2}1000=4\times10^{-5}\text{s},c=4.012\times10^{-9} T3(1000)=c1000log21000=4×10−5s,c=4.012×10−9(底数选2是因为二分法), T 4 ( 1000 ) = 1000 d = 1 × 1 0 − 5 s , d = 1 × 1 0 − 8 T{4}(1000)=1000d=1\times10^{-5}\text{s},d=1\times10^{-8} T4(1000)=1000d=1×10−5s,d=1×10−8,最终得到的估计的时间复杂度函数为:
T 1 ( N ) = 3.84 × 1 0 − 10 N 3 s T_{1}(N)=3.84\times10^{-10}N^{3}\text{s} T1(N)=3.84×10−10N3s
T 2 ( N ) = 1 × 1 0 − 9 N 2 s T_{2}(N)=1\times10^{-9}N^{2}\text{s} T2(N)=1×10−9N2s
T 3 ( N ) = 4.012 × 1 0 − 9 N log 2 N s T_{3}(N)=4.012\times10^{-9}N\text{log}{2}N\text{s} T3(N)=4.012×10−9Nlog2Ns
T 4 ( N ) = 1 × 1 0 − 8 N s T
{4}(N)=1\times10^{-8}N\text{s} T4(N)=1×10−8Ns

最后使用如下Python脚本画出时间增长曲线:

python 复制代码
import matplotlib.pyplot as plt
import numpy as np

plt.rcParams['font.family'] = 'SimHei'  # 设置中文字体


# log函数
def log(base, x):
    return np.log(x) / np.log(base)


x = np.linspace(1000, 10000, 10)  # 生成1000到10000之间的10个数据点作为x轴
y1 = 3.84e-10 * x * x * x  # 暴力法1时间复杂度函数
y2 = 1e-9 * x * x  # 暴力法2时间复杂度函数
y3 = 4.012e-9 * x * log(2, x)  # 分治法时间复杂度函数
y4 = 1e-8 * x  # 在线处理法时间复杂度

# 创建一个Matplotlib图表
plt.figure(figsize=(10, 6))  # 设置图表的大小

# 绘制折线图
plt.plot(x, y1, label=r'$T_{1}(N)=3.84\times10^{-10}N^{3}\text{s}$', color='blue', linewidth=2)
plt.plot(x, y2, label=r'$T_{2}(N)=1\times10^{-9}N^{2}\text{s}$', color='red', linewidth=2)
plt.plot(x, y3, label=r'$T_{3}(N)=4.012\times10^{-9}N\text{log}_{2}N\text{s}$', color='green', linewidth=2)
plt.plot(x, y4, label=r'$T_{4}(N)=1\times10^{-8}N\text{s}$', color='orange', linewidth=2)

# 添加标题和标签
plt.title('时间增长曲线')
plt.xlabel('N')
plt.ylabel('时间:s')

# 添加图例
plt.legend()

# 自定义坐标轴范围
plt.xlim(1000, 10000)
plt.ylim(0, 400)

# 添加网格线
plt.grid(True, linestyle='--', alpha=0.6)

# 显示图像
plt.show()

两幅图增长趋势是一样的,只不过一个N-运行时间曲线是实际运行时间,另一个时间增长曲线是理论估计时间,时间增长曲线能描述当N充分大的时候的趋势。

1.8 查找算法中的"二分法"是这样定义的:给定N个从小到大排好序的整数序列List[],以及某待查找整数X,我们的目标是找到X在List中的下标,即若有List[i]=X,则返回i;否则返回-1表示没有找到。二分法是先找到序列的中点List[M],与X进行比较,若下个等则返回中点下标;否则,若List[M]>X,则在左边的子系列中查找X;若List[M]<X,则在右边的子系列中查找X。试写出算法的伪码描述,并分析最坏,最好情况下的时间、空间复杂度。

【答】二分查找,可以用循环实现也可以用递归实现,这里我给出我的文章【代码随想录刷题记录】LeetCode704二分查找

中左闭右闭情况的代码进行分析(循环实现,改成了C语言版本重新实现了一下):

cpp 复制代码
int search(int* nums, int numsSize, int target){
    int low = 0;//low指针
    int high = numsSize-1;//high指针
    int mid = 0;//折半点
    while(low<=high)
    {
        mid = (low+high)>>1;//右移1位相当于除2
        if(nums[mid]==target)
        {
            return mid;
        }
        else if(target < nums[mid])//小于在左半侧查找
        {
            high = mid-1;
        }
        else
        {
            low = mid+1;//大于在右半侧查找
        }
    }
    //没找到返回-1
    return -1;
}

先分析最坏时间复杂度,当要找的值在最左侧或者最右侧的时候,运行时间最多,假设要找的值在最右侧,low要不断地移动直到大于high,设数组长度为M,while循环的代码执行了n次, l o w ( n ) low(n) low(n)表示第n次low的取值,则 h i g h = M , l o w ( n ) = m i d + 1 = l o w ( n − 1 ) + h i g h 2 + 1 = l o w ( n − 1 ) + M + 2 2 high = M,low(n)=mid+1=\frac{low(n-1)+high}{2}+1=\frac{low(n-1)+M+2}{2} high=M,low(n)=mid+1=2low(n−1)+high+1=2low(n−1)+M+2即 l o w ( n ) − 1 2 l o w ( n − 1 ) = M + 2 2 , l o w ( 1 ) = 0 , l o w ( 2 ) = l o w ( 1 ) + M + 2 2 = M + 2 2 low(n)-\frac{1}{2}low(n-1)=\frac{M+2}{2},low(1)=0,low(2)=\frac{low(1)+M+2}{2}=\frac{M+2}{2} low(n)−21low(n−1)=2M+2,low(1)=0,low(2)=2low(1)+M+2=2M+2,这是一个一阶线性非齐次差分方程,

其特征方程为 x − 1 2 = 0 x-\frac{1}{2}=0 x−21=0

即特征根为 x = 1 2 x=\frac{1}{2} x=21

故齐次通解为 l o w ˉ ( n ) = C ( 1 2 ) n , C 为任意常数 \bar{low}(n)=C\left(\frac{1}{2}\right)^{n},C为任意常数 lowˉ(n)=C(21)n,C为任意常数

则特解为 l o w ∗ ( n ) = n 0 p = p , p 为任意常数 low^{*}(n)=n^{0}p=p,p为任意常数 low∗(n)=n0p=p,p为任意常数

则 p − 1 2 p = M + 2 2 p-\frac{1}{2}p=\frac{M+2}{2} p−21p=2M+2,所以 p = M + 2 p=M+2 p=M+2

所以非齐次通解为 l o w ( n ) = C ( 1 2 ) n + M + 2 , C 为任意常数 low(n)=C\left(\frac{1}{2}\right)^{n}+M+2,C为任意常数 low(n)=C(21)n+M+2,C为任意常数

又因为 l o w ( 1 ) = 1 2 C + M + 2 = 0 low(1)=\frac{1}{2}C+M+2=0 low(1)=21C+M+2=0,所以 C = − M + 2 2 C=-\frac{M+2}{2} C=−2M+2

所以 l o w ( n ) = − M + 2 2 ( 1 2 ) n + M + 2 low(n)=-\frac{M+2}{2}\left(\frac{1}{2}\right)^{n}+M+2 low(n)=−2M+2(21)n+M+2

当要找的值在最右侧时, l o w ( n ) = M low(n)=M low(n)=M,所以有:
M = − M + 2 2 ( 1 2 ) n + M + 2 M=-\frac{M+2}{2}\left(\frac{1}{2}\right)^{n}+M+2 M=−2M+2(21)n+M+2解得 n = log 2 M + 2 4 n=\text{log}_{2}\frac{M+2}{4} n=log24M+2

M其实就是问题规模N,n是时间复杂度函数 T ( n ) T(n) T(n),则其最坏时间复杂度为 O ( log 2 N + 2 4 ) = O ( log N ) O(\text{log}_{2}\frac{N+2}{4})=O(\text{log}N) O(log24N+2)=O(logN)

最好时间复杂度就是中点对应就是要找的元素,一下子就找到了,所以最好时间复杂度为 O ( 1 ) O(1) O(1)

我们在此算法中就用到了三个变量low,high,mid,所以空间复杂度无论好坏都是常数级的 O ( 1 ) O(1) O(1)

1.9 给定存储了N个从小到大排好序的整数数组List[],试给出算法将任一给定整数X插入数组中合适的位置,以保持结果依然有序。分析算法在最坏、最好情况下的时间、空间复杂度。

【答】

c 复制代码
#include <stdio.h>
#include <stdlib.h>

//arr是指向待插入数组的指针,n是待插入的数组的长度,m是待插入的值
//返回的是插入后的新数组的长度
//C语言函数参数默认值传递,没有引用,因此只能传入指向旧数组的指针(即指向指针的指针)
int InsertArray(int** arr, int n, int m)
{
	int* old = *arr; //旧的数组指针
	int* temp = (int*)malloc((sizeof(int)) * (n + 1)); // 开辟比原来数组长度多1的内存空间当作新的数组
	//判断开辟的空间是否成功
	if (temp == NULL)
	{
		printf("内存不足!\n");
		return -1;
	}
	//将原来数组的元素拷贝到新数组
	for (int i = 0; i < n; i++)
	{
		temp[i] = old[i];
	}
	int k = n; //记录应该插入的位置下标,数组中若没有元素,默认从n=0开始插入,若遍历到最后都没有大于等于m的元素,则正好在最后位置插入
	//因为数组有序,只需要找到首个大于等于m的元素,记录此时的下标
	for (int i = 0; i < n; i++)
	{
		if (old[i] >= m)
		{
			k = i;
			break; // 找到后就跳出循环
		}
	}
	//将k所指元素及其后面的元素都向后移动一个空间(在新的temp数组中)
	for (int i = n; i > k; i--)
	{
		temp[i] = temp[i - 1];
	}
	temp[k] = m; //插入元素位置
	free(old); //释放旧数组
	*arr = temp; //arr指针指向新数组
	return n + 1;
}

int main()
{
	int* a = (int*)malloc(sizeof(int) * 3); //开辟长度为3的动态数组
	if (a == NULL)
	{
		printf("内存不足!\n");
		return 0;
	}
	a[0] = 1;
	a[1] = 2;
	a[2] = 3;
	int **arr = &a; //指向动态数组的指针,便于直接修改值
	int N = InsertArray(arr, 3, 2);
	//打印数组结果
	for (int i = 0; i < N; i++)
	{
		printf("%d\n", a[i]);
	}
	return 0;
}

运行结果:

分析时间复杂度,函数中常数级别的复杂度不需要看,在无穷趋向下都被略掉了,现在看循环中的时间复杂度,首先将原来数组的元素拷贝到新数组的循环是 O ( N ) O(N) O(N),找到要插入的下标,有可能要插入的值比数组中的元素都大,此时遍历了整个数组,时间复杂度为 O ( n ) O(n) O(n),将下标k对应的后面的元素全部向后移动1个位置,假设要插入的值比数组中的元素都小,整个数组后移1位,时间复杂度为 O ( n ) O(n) O(n),最后,总的时间复杂度为 O ( N + N + N ) = O ( N ) O(N+N+N)=O(N) O(N+N+N)=O(N),对于空间复杂度,抛出常数级别的变量,我们创建了一个新的长度为 N + 1 N+1 N+1的数组,则空间复杂度为 O ( N + 1 ) = O ( N ) O(N+1)=O(N) O(N+1)=O(N)

1.10 试给出判断N是否为质数的 O ( N ) O(\sqrt{N}) O(N )的算法。

【答】素数一般指质数。质数是指在大于1的自然数中,除了1和它本身以外不再有其他因数的自然数。根据定义,可以从2~n-1依次试除,判断n是否有约数(约数,又称因数。整数a除以整数b(b≠0) 除得的商正好是整数而没有余数,就说a能被b整除,或b能整除a。a称为b的倍数,b称为a的约数。记作 b ∣ a b|a b∣a),若有则n不是质数,假设 d d d 可以整除 n n n( n d \frac{n}{d} dn是整数, d ∣ n d|n d∣n),那么它们的商 n d \frac{n}{d} dn 也能整除 n n n( n n d = d \frac{n}{\frac{n}{d}}=d dnn=d, n d ∣ n \frac{n}{d}|n dn∣n),若约数是成对(5×7=35,5×5=25)出现的,必定是一大一小或者相等 ,则假设 d ≤ n d d\le\frac{n}{d} d≤dn,即 d 2 ≤ n d^{2}\le n d2≤n,亦即 d ≤ n d\le\sqrt{n} d≤n (因为 d d d是自然数,必然大于0,所以不会出现开根号取负的结果),所以每次判断只需要判断到 n \sqrt{n} n 即可。

实际上,这个引理来自初等数论,引用一下陈景润版初等数论第一册第五页引理6:

【前置知识】



【注】书中给的证明看得不是太明白,尤其是c大于等于0那里,如果评论区有高人请告诉我为什么能推出 c ≥ 0 c\ge0 c≥0(我感觉像是说因为c>0,所以c大于等于0),我只推出>0,我自己证了一下,此处用的反证法,因为 ∣ a ∣ ∣ ∣ b ∣ |a|||b| ∣a∣∣∣b∣,所以有一个整数 c c c,使得 ∣ a ∣ = ∣ b ∣ c |a|=|b|c ∣a∣=∣b∣c,如果 ∣ a ∣ = 0 |a|=0 ∣a∣=0,则有 a = 0 a=0 a=0,假设 ∣ a ∣ > 0 |a|>0 ∣a∣>0,则由于 ∣ a ∣ < ∣ b ∣ |a|<|b| ∣a∣<∣b∣有 0 < ∣ a ∣ < ∣ b ∣ 0<|a|<|b| 0<∣a∣<∣b∣,又由于 ∣ a ∣ = ∣ b ∣ c > 0 , ∣ b ∣ > 0 |a|=|b|c>0,|b|>0 ∣a∣=∣b∣c>0,∣b∣>0则 c > 0 c>0 c>0,且 c = ∣ a ∣ ∣ b ∣ c=\frac{|a|}{|b|} c=∣b∣∣a∣,又因为 ∣ b ∣ > ∣ a ∣ > 0 |b|>|a|>0 ∣b∣>∣a∣>0,所以 c = ∣ a ∣ ∣ b ∣ < 1 c=\frac{|a|}{|b|}<1 c=∣b∣∣a∣<1,所以 0 < c < 1 0<c<1 0<c<1这与 c c c是一个整数矛盾,故如果 a , b a,b a,b都是整数,而 ∣ a ∣ < ∣ b ∣ , ∣ b ∣ ∣ ∣ a ∣ |a|<|b|,|b|||a| ∣a∣<∣b∣,∣b∣∣∣a∣,则有 a = 0 a=0 a=0.


【注】一个数的因数都小于等于其本身,且在证明的反证法部分,假设 b b b是复合数, b b b一定有大于1而不等于 b b b的因数 c c c,所以 1 < c < b 1<c<b 1<c<b,后来又证明 c c c是 a a a的因数,结果此处 c c c比 b b b小了,和前面 b b b是 a a a的大于1最小因数矛盾了(因为此时 c c c是最小因数了),故得证。

【引理6】这个引理6是解决本题的关键。

【注】由于 a a a被大于1而小于 a \sqrt{a} a 的整数都除不尽,且 a = b c a=bc a=bc, b b b和 c c c是整数且都为 a a a的因数,所以 b > a , c > a b>\sqrt{a},c>\sqrt{a} b>a ,c>a

由引理6可知,因为 a a a是整数, a a a要是复合数,则一定有大于1即大于等于2而小于根号a的因数,所以只要从2遍历到根号a,发现能整除,就是复合数,如果过了一遍循环遍历,没有能整除的数,就是素数,这样一看此种算法遍历到最后,最多是 O ( n ) O(\sqrt{n}) O(n )的时间复杂度,但是我们要注意,此处判断条件不能直接写i<sqrt(a),因为sqrt函数在C语言的math.h头文件定义,其用牛顿迭代法求解根号,时间复杂度更复杂,我们应该换一下等价形式,循环变量 i ≤ a i\le\sqrt{a} i≤a 等价于 i 2 < a i^{2}<a i2<a,乘法指令要比开根号迭代运行得快,思路明确后,代码如下:

c 复制代码
#include <stdio.h>
#include <stdlib.h>

//时间复杂度为$O(\sqrt{n})$的判断素数的算法
//0代表不是素数,1代表是素数
int isPrime(int a)
{
	//素数的前提是自然数,不是自然数不行
	if (a <= 0)
	{
		return 0;
	}
	for (int i = 2; i * i <= a; i++)
	{
		//这期间有一个能整除的,最后结果都是复合数
		if (a % i == 0)
		{
			return 0;
		}
	}
	//最后都没整除,则是素数
	return 1;
}

//输出是否是素数的结果
void printIsPrime(int a)
{
	if (isPrime(a))
	{
		printf("%d", a);
		printf("是素数\n");
	}
	else
	{
		printf("%d", a);
		printf("不是素数\n");
	}
}

int main()
{
	printIsPrime(-1);
	printIsPrime(0);
	printIsPrime(4);
	printIsPrime(5);
	printIsPrime(10);
	printIsPrime(23);
	printIsPrime(1523);
	printIsPrime(1524);
	return 0;
}

运行结果:

时间复杂度为 O ( N ) O(\sqrt{N}) O(N )

1.11 试给出计算 x N x^{N} xN的时间复杂度为 O ( log N ) O(\text{log}_{N}) O(logN)的算法。

【答】此题目对应LeetCode50 Pow(x,n),此处是计算整数次幂

为了能写出低于 O ( n ) O(n) O(n)时间复杂度的算法,肯定要让计算机记住中间的一些计算结果,比如 x 8 x^{8} x8,最开始是从 x x x, x 2 x^{2} x2开始计算,如果我们知道了 x 2 x^{2} x2就相当于知道了 x 4 = x 2 x 2 x^{4}=x^{2}x^{2} x4=x2x2,知道了 x 4 x^{4} x4相当于知道了 x 8 = x 4 x 4 x^{8}=x^{4}x^{4} x8=x4x4,于是我们知道,如果我们最开始通过不断地计算Pow(x,n/2),即乘 x n 2 x^{\frac{n}{2}} x2n一直递归到幂数为0,然后我们再将每次递归得到的 x n 2 x^{\frac{n}{2}} x2n相乘即 x n 2 x n 2 = x n x^{\frac{n}{2}}x^{\frac{n}{2}}=x^{n} x2nx2n=xn记作上一次递归的计算结果,这是偶数次幂的情况,奇数次幂的情况是,假如计算 x 9 x^{9} x9,最开始是从 x x x, x 2 x^{2} x2开始计算,如果我们知道了 x 2 x^{2} x2就相当于知道了 x 4 = x 2 x 2 x^{4}=x^{2}x^{2} x4=x2x2,知道了 x 4 x^{4} x4相当于知道了 x 8 = x 4 x 4 x^{8}=x^{4}x^{4} x8=x4x4,此时还差乘一个 x x x,那需要判断幂数是偶数就正常二分,是奇数就乘个 x x x再二分即可,但是我们目前只考虑了幂为自然数的情况,如果为0则返回1,如果为负数,应该按正数(取个负)计算,然后返回其倒数,所以我们只需要先考虑幂为自然数的情况,再根据幂的正负号决定返回倒数还是本身:
递归法

c 复制代码
double quickMul(double x, long long n)
{
    //为0返回其本身
    if(n == 0)
    {
        return 1.0;
    }
    double y = quickMul(x, n/2); //求x^(n/2)的情况递归,一直递归到n为0为止
    //分奇数还是偶数,奇数需要多乘个x
    if(n % 2 == 0)
    {
        //偶数直接返回两个二分的结果相乘
        return y * y;
    }
    else
    {
        //奇数需要乘个x
        return y * y * x;
    }
}
double myPow(double x, int n) {
    //大于0按幂为自然数情况考虑
    if(n > 0)
    {
        return quickMul(x, n);
    }
    //小于0取倒数(0的情况已经考虑到,直接返回1)
    else
    {
        return 1.0 / quickMul(x,-n);
    }
}

证明时间复杂度过程:递归法的时间复杂度函数为 T ( n ) T(n) T(n),则它是不断二分,根据double y = quickMul(x, n/2);的递归调用,然后每一次都有一个常数时间复杂度的判断奇偶的过程,则有 T ( n ) = T ( n 2 ) + O ( 1 ) = T ( n 4 ) + O ( 1 ) + O ( 1 ) = . . . = T ( n 2 k ) + k O ( 1 ) T(n)=T(\frac{n}{2})+O(1)=T(\frac{n}{4})+O(1)+O(1)=...=T(\frac{n}{2^{k}})+kO(1) T(n)=T(2n)+O(1)=T(4n)+O(1)+O(1)=...=T(2kn)+kO(1)

递归到最后,问题规模为1,即 n 2 k = 1 \frac{n}{2^{k}}=1 2kn=1,所以 k = log 2 n k=\text{log}{2}n k=log2n,于是
T ( n ) = T ( 1 ) + O ( 1 ) log 2 n = T ( 1 ) + O ( log 2 n ) T(n)=T(1)+O(1)\text{log}
{2}n=T(1)+O(\text{log}{2}n) T(n)=T(1)+O(1)log2n=T(1)+O(log2n),所以时间复杂度为 O ( log 2 n ) O(\text{log}{2}n) O(log2n)
循环法 :递归需要额外的函数调用栈的空间,我们尝试改成循环求解:

我们知道任何数都能用二进制表示为 b = k i − 1 2 i − 1 + k i − 2 2 i − 2 + . . . + k 0 2 0 b=k_{i-1}2^{i-1}+k_{i-2}2^{i-2}+...+k_{0}2^{0} b=ki−12i−1+ki−22i−2+...+k020

即每个整数都可以唯一表示为若干指数不重复的2的次幂和,则 a b = a k i − 1 2 i − 1 × a k i − 2 2 i − 2 × . . . × a k 0 2 0 a^{b}=a^{k_{i-1}2^{i-1}}\times a^{k_{i-2}2^{i-2}}\times ... \times a^{k_{0}2^{0}} ab=aki−12i−1×aki−22i−2×...×ak020

且 a 2 i = ( a 2 i − 1 ) 2 a^{2i}=(a^{2i-1})^{2} a2i=(a2i−1)2,以LeetCode官方举的例子为例,求 x 77 x^{77} x77,按照分治的方法,先求 x ⌊ n 2 ⌋ x^{\lfloor \frac{n}{2}\rfloor} x⌊2n⌋,即求 x ⌊ 77 2 ⌋ = x 38 x^{\lfloor \frac{77}{2}\rfloor}=x^{38} x⌊277⌋=x38,然后求 x ⌊ 38 2 ⌋ = x 19 x^{\lfloor \frac{38}{2}\rfloor}=x^{19} x⌊238⌋=x19,然后是 x ⌊ 19 2 ⌋ = x 9 x^{\lfloor \frac{19}{2}\rfloor}=x^{9} x⌊219⌋=x9......以此类推,最后得到要计算的顺序:
x → x 2 → x 4 → x 9 → x 19 → x 38 → x 77 x \rightarrow x^{2} \rightarrow x^{4} \rightarrow x^{9} \rightarrow x^{19} \rightarrow x^{38} \rightarrow x^{77} x→x2→x4→x9→x19→x38→x77

然后从左向右计算,根据幂的奇偶性判断是否需要乘 x x x

我们将额外乘 x x x的部分打一个加号:
x → x 2 → x 4 → + x 9 → + x 19 → x 38 → + x 77 x \rightarrow x^{2} \rightarrow x^{4} \rightarrow^{+} x^{9} \rightarrow^{+} x^{19} \rightarrow x^{38} \rightarrow^{+} x^{77} x→x2→x4→+x9→+x19→x38→+x77
{ x 38 → + x 77 中额外乘的 x 在 x 77 中贡献了 x 因此在 x 77 中贡献了 x 2 0 ; x 9 → + x 19 中额外乘的 x 在之后被平方了 2 次,即 ( x 2 ) 2 = x 4 ,因此在 x 77 中贡献了 x 2 2 = x 4 ; x 4 → + x 9 中额外乘的 x 在之后被平方了 3 次,即 ( ( x 2 ) 2 ) 2 = ( x 4 ) 2 = x 8 ,因此在 x 77 中贡献了 x 2 3 = x 8 ; 最初的 x 在之后被平方了 6 次,因此在 x 77 中 贡献了 x 2 6 = x 64 。 \left\{\begin{array}{l} x^{38} \rightarrow^{+} x^{77} \text { 中额外乘的 } x \text { 在 } x^{77} \text { 中贡献了 } x \text{因此在}x^{77}\text{中贡献了}x^{2^{0}}\text { ; } \\ x^{9} \rightarrow^{+} x^{19} \text { 中额外乘的 } x \text { 在之后被平方了 } 2 \text { 次},\text{即}(x^{2})^{2}=x^{4}\text{,因此在 } x^{77} \text { 中贡献了 } x^{2^{2}}=x^{4} \text { ; } \\ x^{4} \rightarrow^{+} x^{9} \text { 中额外乘的 } x \text { 在之后被平方了 } 3 \text { 次,即 }\left(\left(x^{2}\right)^{2}\right)^{2}=\left(x^{4}\right)^{2}=x^{8} \text { ,因此在 } x^{77} \text { 中贡献了 } x^{2^{3}}=x^{8} \text { ; } \\ \text { 最初的 } x \text { 在之后被平方了 } 6 \text { 次,因此在 } x^{77} \text { 中 } \text { 贡献了 } x^{2^{6}}=x^{64} \text { 。 } \end{array}\right. ⎩ ⎨ ⎧x38→+x77 中额外乘的 x 在 x77 中贡献了 x因此在x77中贡献了x20 ; x9→+x19 中额外乘的 x 在之后被平方了 2 次,即(x2)2=x4,因此在 x77 中贡献了 x22=x4 ; x4→+x9 中额外乘的 x 在之后被平方了 3 次,即 ((x2)2)2=(x4)2=x8 ,因此在 x77 中贡献了 x23=x8 ; 最初的 x 在之后被平方了 6 次,因此在 x77 中 贡献了 x26=x64 。

77的二进制表示为1001101B,二进制表示中的1的位置从右到左(从下标0开始)恰好和贡献的 x x x的幂数对应的 2 2 2的次方数相同,因此我们借助整数的二进制拆分,就可以得到迭代计算的方法,代码如下:

c 复制代码
double quickMul(double x, long long n) 
{
    // 只考虑自然数的情况
    double result = 1.0; // 初始为1,幂数为0时,它为1
    // 贡献的初始值为x
    double x_con = x;
    // 对N进行二进制拆分并计算答案
    while (n > 0) 
    {
        //如果当前数的最低位是1,则需要乘贡献的x
        if(n % 2 == 1)
        {
            result *= x_con;
        }
        // 将贡献不断地平方
        x_con *= x_con;
        // 每次除2,相当于找到新的最低位,除2相当于右移1位
        // 比如1011B除2,就变成了101,右移动1位
        // 这样每次都能通过除2取余判断新的最低位,直到右移不了位置
        // 我们是按次幂数的二进制求解
        n = n >> 1;
    }
    return result;
}
double myPow(double x, int n) 
{
    //大于0按幂为自然数情况考虑
    if(n > 0)
    {
        return quickMul(x, n);
    }
    //小于0取倒数(0的情况已经考虑到,直接返回1)
    else
    {
        return 1.0 / quickMul(x, -n);
    }
}

分析时间复杂度:假设问题规模 n n n,则 T ( n ) T(n) T(n)时, n n n通过不断除2接近变成规模位1,那么就是 T ( n ) = T ( n 2 ) T(n)=T(\frac{n}{2}) T(n)=T(2n),和上面的递推方程一致,所以时间复杂度为 O ( log N ) O(\text{log}N) O(logN)

3. 总结

想读明白陈姥姥的书,确实需要懂一些离散数学的知识,我也准备重新回顾看一看,顺带再看看数论。

相关推荐
幸运超级加倍~31 分钟前
软件设计师-上午题-15 计算机网络(5分)
笔记·计算机网络
南宫生38 分钟前
贪心算法习题其四【力扣】【算法学习day.21】
学习·算法·leetcode·链表·贪心算法
懒惰才能让科技进步1 小时前
从零学习大模型(十二)-----基于梯度的重要性剪枝(Gradient-based Pruning)
人工智能·深度学习·学习·算法·chatgpt·transformer·剪枝
Ni-Guvara2 小时前
函数对象笔记
c++·算法
芊寻(嵌入式)2 小时前
C转C++学习笔记--基础知识摘录总结
开发语言·c++·笔记·学习
泉崎2 小时前
11.7比赛总结
数据结构·算法
你好helloworld2 小时前
滑动窗口最大值
数据结构·算法·leetcode
准橙考典2 小时前
怎么能更好的通过驾考呢?
人工智能·笔记·自动驾驶·汽车·学习方法
AI街潜水的八角3 小时前
基于C++的决策树C4.5机器学习算法(不调包)
c++·算法·决策树·机器学习
白榆maple3 小时前
(蓝桥杯C/C++)——基础算法(下)
算法