Chapter 1 复杂度分析和递归分析

本文探讨了算法的关键要素,如计算机与算法的定义,输入输出及确定性等,并深入剖析了复杂度分析,包括常数、对数、线性、多项式和指数级。重点讲解了递归分析方法、递归跟踪与递推方程,以及递归优化策略如记忆法和动态规划在Fibonacci序列和LCS问题中的应用。此外,还介绍了ADT在数据结构中的作用。
摘要由CSDN通过智能技术生成

第1章 绪论

1.1 计算机与算法

算法需要具备的要素

  • 输入、输出
  • 正确性
  • 确定性
  • 可行性
  • 有穷性
有穷性反例:Hailstone Sequence

H a i l s t o n e ( n ) = { { 1 } ,  if n equal  1 { n } ∪  Hailstone  ( n / 2 ) ,  if  n  is even  { n } ∪  Hailstone  ( 3 n + 1 ) ,  if is odd  Hailstone (n)=\left\{\begin{array}{ll}\{1\}, & \text { if n equal } 1 \\ \{n\} \cup \text { Hailstone }(n / 2), & \text { if } \mathrm{n} \text { is even } \\ \{n\} \cup \text { Hailstone }(3 n+1), & \text { if is odd }\end{array}\right. Hailstone(n)={1},{n} Hailstone (n/2),{n} Hailstone (3n+1), if n equal 1 if n is even  if is odd 

因为至今尚不确定该序列是否具有有穷性,所以下面计算该序列长度的程序还不能被称之为算法。

int hailstone(int n)
{
    int length = 1;
    while(n > 1)
    {
        n % 2 ? n = 3 * n + 1 : n /= 2;
        length++;
    }
    return length;
}
符合有穷性的算法示例:Bubble Sort

将n个整数按照通常的大小次序排成一个非降序列,以下是一种排列算法Bubble Sort

void bubbleSort(int A[], int n)
{
    bool sorted = false; //整体排序标志,初始化为未排序
    while (!sorted)      // 无法确定完成排序,则逐趟扫描
    {
        sorted = true;
        for (int i = 1; i < n; i++)
        {
            if (A[i - 1] > A[i]) // 如果有两个相邻的数构成逆序
            {
                swap(A[i - 1], A[i]); //交换
                sorted = false;       // 无法确定完成排序
            }
        }
        n--; // 每扫描一趟必有扫描范围内的最大值来到序列尾,扫描范围减1
    }
}

我们发现,每次扫描必然会将扫描范围内最大的数放置到扫描范围的尾部,所以排序的过程一定是有穷的。

排序过程中,所有元素朝各自最终位置亦步亦趋的移动过程如同气泡在水中浮动,这个算法也因此得名。

image-20200805104054431

1.2 复杂度度量

image-20200804165044999

三条虚线由上到下分别表示:最坏情况下的复杂度(最基本最常用)、较为准确的复杂度(始终与程序运行时间同阶)、最好情况下的复杂度

1.3 复杂度分析

1.3.1 常数O(1)

运算时间可表示为T(n) = O(1)的算法,统称作常数时间复杂度算法,是最为理想的算法,一般仅含一次或常数次基本操作,通常不含循环、分支、子程序调用等,但是不能根据形式一概而论。

1.3.2 对数O(logn)

示例算法:统计某非负整数二进制展开后数位1的总数
int countOnes(unsigned int n)
{
    int ones = 0; // 负责计数
    while (n > 0)
    {
        ones += (1 & n); //最低位若为1则计数
        n >>= 1;         //右移一位
    }
}
复杂度分析

n的二进制展开总位数是(log2n) +1, 那么循环次数自然也是((log2n) +1,而无论在循环体之内还是之外,均只有常数次操作,因此该算法的执行时间主要由循环次数决定,又
∀ a , b > 1 , log ⁡ a n = log ⁡ a b ⋅ log ⁡ b n = Θ ( log ⁡ b n ) \forall a, b>1, \log _{a} n=\log _{a} b \cdot \log _{b} n=\Theta\left(\log _{b} n\right) a,b>1,logan=logablogbn=Θ(logbn)
可知,常底数的具体取值无所谓,故不再标出而记作logn,此类算法称作具有对数复杂度
∀ c > 0 , log ⁡ n c = c ⋅ log ⁡ n = Θ ( log ⁡ n ) \forall c>0, \log n^{c}=c \cdot \log n=\Theta(\log n) c>0,lognc=clogn=Θ(logn)
可知,常数次幂的具体取值也无所谓
123 ⋅ log ⁡ 321 n + log ⁡ 205 ( 7 ⋅ n 2 − 15 ⋅ n + 31 ) = Θ ( log ⁡ 321 n ) 123 \cdot \log ^{321} n+\log ^{205}\left(7 \cdot n^{2}-15 \cdot n+31\right)=\Theta\left(\log ^{321} n\right) 123log321n+log205(7n215n+31)=Θ(log321n)

O ( log ⁡ c n ) O\left(\log ^{c} n\right) O(logcn)

更一般地,复杂度如上式的算法,均可称作对数多项式复杂度(O(logn)是c=1的特殊情况),这种算法的效率不如常数O(1)但无限接近与它。

1.3.3 线性O(n)

示例算法:计算n个整数的和
int sumI(int A[], int n)
{
    int sum = 0;	//O(1)
    for (int i = 0; i < n; i++)	//O(n)
        sum += A[i];	//O(1)
    return sum;	//O(1)
}
复杂度分析

O(1) + O(n) * O(1) + O(1) = O(n + 2) = O(n)

运算时间可表示为T(n) = O(n)的算法,统称作线性时间复杂度算法

1.3.4 多项式O( polynomial ( n ) )

示例算法:Bubble Sort
void bubbleSort(int A[], int n)
{
    bool sorted = false; //整体排序标志,初始化为未排序
    while (!sorted)      // 无法确定完成排序,则逐趟扫描	O(n-1)
    {
        sorted = true;
        for (int i = 1; i < n; i++)	// O(n-1)
        {
            if (A[i - 1] > A[i]) // 如果有两个相邻的数构成逆序	
            {
                swap(A[i - 1], A[i]); //交换	O(1)
                sorted = false;       // 无法确定完成排序
            }
        }
        n--; // 每扫描一趟必有扫描范围内的最大值来到序列尾,扫描范围减1
    }
}
复杂度分析

该算法由内外两层循环构成

  • 内循环依次比较个相邻元素(n - 1次),如有必要则进行交换(至多n - 1 次),一轮内循环至多需要执行2 * (n - 1)次基本操作
  • 前面分析过,外循环至多执行(n - 1)轮,则总共执行的基本操作至多 2 * (n - 1)2
  • 进一步简化整理可得:T(n) = O(n2)

若运行时间可以表示和度量为T(n) = O( f(n) )的形式,而且f(x)为多项式,则对应算法的称为多项式时间复杂度算法,理论上,复杂度为O(n2020)和O(n1)均属于该类(线性时间复杂度是多项式时间复杂度的特例)

1.3.5 指数O(2n)

从O(nc)到O(2n)往往被视为有效算法到无效算法的分水岭

示例算法:对任意非负数n,计算幂2n(蛮力迭代版)
_int64 power2BF(int n)
{
    _int64 pow = 1; //初始化累积器O(1)
    while (n--)     // 循环 O(n) 递减 O(1)
        pow <<= 1;  // ×2 O(1)
    return pow;     // O(1)
}
复杂度分析

O(1) + O(n) * 2O(1) + O(1) = O(n),以n本身作为输入规模,运行时间为O(n)

如果按照输入指数n的二进制位数r = 1 + log2n作为输入规模,则运行时间为O(2r)

对算法复杂度的界定,都是相对于问题输入规模而言的,输入规模可定义为用以描述输入所需的空间规模,因此,对于该算法,n的二进制展开的宽度r作为输入规模更加合理,countOnes算法也是如此,以输入参数本身的数值作为基准得到的复杂度称作伪××复杂度

一般地,凡运行时间可以表示和度量为T(n) = O(an) 形式的算法(a>1),均属于指数时间复杂度算法。

1.3.6 复杂度层次

image-20200804233725478

image-20200804233847828

补充:级数

算术级数:与末项平方同阶

T ( n ) = 1 + 2 + … + n = ( n + 1 2 ) = n ( n + 1 ) 2 = O ( n 2 ) T(n)=1+2+\ldots+n=\left(\begin{array}{c} n+1 \\ 2 \end{array}\right)=\frac{n(n+1)}{2}=\mathcal{O}\left(n^{2}\right) T(n)=1+2++n=(n+12)=2n(n+1)=O(n2)

幂方级数:比幂次高出一阶

∑ k = 0 n k d ≈ ∫ 0 n x d d x = x d + 1 d + 1 ∣ 0 n = n d + 1 d + 1 = O ( n d + 1 ) \sum_{k=0}^{n} k^{d} \approx \int_{0}^{n} x^{d} d x=\left.\frac{x^{d+1}}{d+1}\right|_{0} ^{n}=\frac{n^{d+1}}{d+1}=\mathcal{O}\left(n^{d+1}\right) k=0nkd0nxddx=d+1xd+10n=d+1nd+1=O(nd+1)

例如:
T 3 ( n ) = ∑ k = 1 n k 3 = 1 3 + 2 3 + 3 3 + … + n 3 = n 2 ( n + 1 ) 2 / 4 = O ( n 4 ) T_{3}(n)=\sum_{k=1}^{n} k^{3}=1^{3}+2^{3}+3^{3}+\ldots+n^{3}=n^{2}(n+1)^{2} / 4=\mathcal{O}\left(n^{4}\right) T3(n)=k=1nk3=13+23+33++n3=n2(n+1)2/4=O(n4)

几何级数:与末项同阶

T a ( n ) = ∑ k = 0 n a k = a 0 + a 1 + a 2 + a 3 + … + a n = a n + 1 − 1 a − 1 = O ( a n ) , 1 < a T_{a}(n)=\sum_{k=0}^{n} a^{k}=a^{0}+a^{1}+a^{2}+a^{3}+\ldots+a^{n}=\frac{a^{n+1}-1}{a-1}=\mathcal{O}\left(a^{n}\right), \quad 1<a Ta(n)=k=0nak=a0+a1+a2+a3++an=a1an+11=O(an),1<a

收敛级数:全部为O(1)
不收敛但有限:

调和级数:
h ( n ) = ∑ k = 1 n 1 k = 1 + 1 2 + 1 3 + 1 4 + … + 1 n = ln ⁡ n + γ + O ( 1 2 n ) = Θ ( log ⁡ n ) h(n)=\sum_{k=1}^{n} \frac{1}{k}=1+\frac{1}{2}+\frac{1}{3}+\frac{1}{4}+\ldots+\frac{1}{n}=\ln n+\gamma+\mathcal{O}\left(\frac{1}{2 n}\right)=\Theta(\log n) h(n)=k=1nk1=1+21+31+41++n1=lnn+γ+O(2n1)=Θ(logn)
对数级数:
∑ k = 1 n ln ⁡ k = ln ⁡ ∏ k = 1 n k = ln ⁡ n ! ≈ ( n + 0.5 ) ⋅ ln ⁡ n − n = Θ ( n ⋅ log ⁡ n ) \sum_{k=1}^{n} \ln k=\ln \prod_{k=1}^{n} k=\ln n ! \approx(n+0.5) \cdot \ln n-n=\Theta(n \cdot \log n) k=1nlnk=lnk=1nk=lnn!(n+0.5)lnnn=Θ(nlogn)

1.4 递归

1.4.2 两种递归分析方法

递归跟踪(recursion trace)

一种直观且可视的方法,按照下面的规则将递归算法的执行过程整理为图的形式:

  • 算法的每一递归实例都表示为一个方框,其中注明了该实例调用的参数
  • 若实例M调用实例N,则在M与N对应的方框之间添加一条有向联线
递推方程(recursion equation)

通过对递归模式的数学归纳,导出复杂度定界函数的递推方程(组)及其边界条件(分析递归基),从而将复杂度的分析,转化为递归方程(组)的求解。

算法分析1: 数组求和

1. Decrease-and-conquer

线性递归的模式往往对于减而治之的算法策略,递归每深入一层,待求解问题的规模都缩减一个常数,直至最终蜕化为平凡的小(简单)问题。

image-20200805110001180
int sum(int A[], int n)
{
    if (n < 1) // 平凡情况,递归基	判断O(1)
        return 0;
    else // 一般情况
        return sum(A, n - 1) + A[n - 1];	// 相加O(1),返回O(1)
}

递归跟踪:

image-20200805110310992

由此可见,递归深度(即任一时刻的活跃递归实例的总数)最大为n+1,T(n) = (n+1)*O(n) = O(n),而且空间复杂度线性正比于递归深度,亦为O(n)

递推方程

解决问题sum(A, n) = 解决问题sum(A, n - 1) + A[n - 1] = …

可以得到一般性递推关系:T(n) =T(n - 1) + O(1) =T(n - 1) + c1 = … = T(0) + nO(1),其中c1为常数

另一方面,当递归过程抵达递归基时,求解平凡问题sum(A, 0)只需(用于直接返回0的)常数时间。如此,即可获得如下边界条件:

T(0) = O(1) = c2,其中c2为常数

由上述两个方程可以得到T(n) = c1n + c2 = O(n)

2. Divide-and-conquer

为了求解大规模问题,可以将其划分为若干个子问题(通常为大小相同的两个)分别求解子问题,然后合并子问题的解得到原问题的解,这就是分而治之的策略。

image-20200805112521743
int sum(int A[], int lo, int hi) //区间范围是[lo,hi)
{
    if (hi - lo < 2)	//O(1)
        return A[lo];
    int mi = (lo + hi) >> 1;                //以屁中单元为界,将原匙间一分为二	O(1)
    return sum(A, lo, mi) + sum(A, mi, hi); //递归对各子数组求和,然后合计 2*O(n/2)
}

递归跟踪:

image-20200805113404519

T(n) = 各层递归实例所需时间之和 = O(1) * (20 + 21 + 22 + … + 2log2n)

这符合我们前面所说的几何级数,故T(n) = O(n)

相对于前面线性递归算法,二分递归算法的递归深度不会超过log2n+1,即空间复杂度为O(logn)小于线性递归的O(n).

递推公式

T(1) = O(1)

T(n) = 2 * T(n/2) + O(1) = 4 * T(n/4) + O(3) = … = n * T(1) + O(n - 1) = O(2n - 1) = O(n)

算法分析2:Fibonacci Sequence

f i b ( n ) = { n ( n ≤ 1 ) f i b ( n − 1 ) + f i b ( n − 2 ) ( n ≥ 2 ) f i b(n)=\left\{\begin{array}{ll} n & \left( n \leq 1\right) \\ f i b(n-1)+f i b(n-2) & \left( n \geq 2\right) \end{array}\right. fib(n)={nfib(n1)+fib(n2)(n1)(n2)

1. Divide-and-conquer
_int64 fib1(int n)
{
    return (n < 2) ? n : fib1(n - 1) + fib1(n - 2);	//O(n-1)+O(n-2)+O(1)
}

递归跟踪:

image-20200805165541957

T(n) = 各层递归实例所需时间之和 = O(1) * (20 + 21 + 22 + … + 2n)

故,T(n) = 2n,这个时间复杂度让人无法接受,原因在于:计算过程中所出现的递归实例重复度极高,同一个Fibonacci数计算了多次,使得效率很低

递推公式:

见习题解析P13,教材P24

2. 优化策略之记忆:Decrease-and-conquer

为了消除递归算法中重复的递归实例,一种自然而然的思路可以概括为:

借助一定量的辅助空间,在各子问题求解之后,及时记录下其对应的解答

其中,和分而治之一样自顶向下,但每次遇到一个子问题都先查验它是否已经计算过,以期通过直接调阅记录获得解答,从而避免重新计算,称之为制表或记忆策略

//prev = fib(n-1)	prevPrev = fib(n-2)
//令fib(-1) = 1,有fib(1) = fib(-1) + fib(0) = 1之意
_int64 fib2(int n, _int64 &prev)
{
    if (n == 0) //若到达了递归基即fib(0)
    {
        prev = 1;	//fib(-1) = 1
        return 0;	//fib(0) = 0
    }
    else
    {
        _int64 prevPrev;
        prev = fib2(n - 1, prevPrev); //递归计算前两项
        return prev + prevPrev;       //其和即位正解
    }
}

这里将二分递归中的fib(n-2)省略,而是通过变量prevPrev被记忆)调阅此前记录来直接获得(个人想法:递归由下至上返回时,下一层的prev传递到上一层作为prevPrev,下一层的返回值在上一层中赋给prev,这里所说的上一层的返回值就是prev + prevPrev。每返回至一个上层递归,都用下层的prev更新上层的prevPrev,用下层的返回值更新上层的prev

image-20200805183044448

**该算法呈线性递归模式,递归的深度线性正比于输入n,前后共计仅出现O(n)个递归实例,累计耗时不超过O(n)。**遗憾的是,该算法共需使用O(n)规模的附加空间,下面的动态规划策略可以更好解决这个问题。

3. 优化策略之动态规划:迭代

从递归基出发,自底而上递推地得出各子问题的解,直至最终原问题的解,为动态规划策略。

_int64 fib3(int n)
{
    _int64 f = 0, g = 1;	//fib(0) = 0	fib(1) = 1
    if (n == 0)
        return 0;

    while (n-- > 0)	//O(n)
    {
        g = g + f;
        f = g - f;
    }
    return g;
}
image-20200805225132393

T(n) = O(n) 且仅需O(1)的空间

算法分析3:最长公共子序列(LCS)

怎样解决问题:递归

对于序列A[0, n)和B[0, m),LCS(n , m)有以下三种情况:

  1. 若n =-1或m = -1,则取空序列("") 递归基
  2. 若A[n] = ‘X’ = B[m],则取作:LCS(n - 1 , m -1) + ‘X’ Decrease-and-conquer
  3. 若A[n] != B[m], 则考虑两种情况,LCS(n, m - 1)与LCS(n - 1, m),然后在这两者中取更长者 Divide-and-conquer

image-20200806095113094image-20200806095334069

image-20200806100922371

**个人对上图的理解:**从右下角开始,遇到分而治之的情况(即比较的两个字符不相等),就向←和↑衍生出两个新的递归实例;遇到减而治之的情况(两个字符相等),就沿左上方对角线,衍生出一个递归实例,直到递归到左上角的递归基。

到达递归基后开始返回调用,具有返回调用关系的递归实例之间相连,遇到一个对角线(即两字符相等)则LCS长度加一(对角线左上方数字加一得到右下方),对于分治情况,需要从该递归实例的上方和左边选择长度更大(即数字更大)的返回。按上述规则对各递归实例进行连接后得到上图,而这些连线形成的每个单调通路就对应该问题的一个解。

分析复杂度:

选取一个局部递归过程观察,图中大量递归实例发生雷同(同时被两个箭头所指)

image-20200806111016544image-20200806112227961

由(n,m)唤醒(a,b)的数量等于(n,m)到(a,b)通路的数量,特别地,当(a,b)取(0,0),n=m时,LCS(0,0)的次数可达O(2n)

怎样提高效率:动态规划

去除重复的大量递归实例后,我们发现,所有子问题不过O(n * m)个,我们可以采用动态规划,颠倒顺序,从左上角开始,花费O(n * m)时间,计算出所有子问题。计算的规则是:碰到两个字母相同的情况(减而治之),则对应位置的数字为它的左上角数字加1;碰到两个字母不相同的情况(分而治之),则对应位置的数字为它的上方和左边的数字的最大者。得到下图:

image-20200806114033769

其中通过加1得到的1,2,3,4任意一种组合即为LCS的一个解。

1.5 ADT

image-20200806120058705

在数据结构的具体实现与实际应用之间,ADT就分工和接口制定了统一标准。

其中的

  • 实现:高效兑现数据结构的ADT接口操作
  • 应用:便捷地通过操作接口使用数据结构
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值