Python 入门指南(三)(1)

简介: Python 入门指南(三)


原文:zh.annas-archive.org/md5/97bc15629f1b51a0671040c56db61b92

译者:飞龙

协议:CC BY-NC-SA 4.0

第六章:算法设计原则

我们为什么要学习算法设计?当然有很多原因,我们学习某事的动机很大程度上取决于我们自己的情况。对于对算法设计感兴趣有重要的职业原因。算法是所有计算的基础。我们认为计算机是硬件,硬盘、内存芯片、处理器等等。然而,如果缺少的是算法,现代技术将不可能存在。

算法的理论基础是图灵机,几十年前就建立了这种机器的数学模型,而数字逻辑电路实际上能够实现这样的机器。图灵机本质上是一个数学模型,它使用预定义的一组规则,将一组输入转换为一组输出。图灵机的第一批实现是机械的,下一代可能会看到数字逻辑电路被量子电路或类似的东西所取代。无论平台如何,算法都起着中心主导的作用。

算法的另一个方面是它对技术创新的影响。显而易见的例子是页面排名搜索算法,谷歌搜索引擎就是基于它的变体。使用这种算法和类似的算法允许研究人员、科学家、技术人员和其他人能够快速地搜索大量信息。这对新研究的速度、新发现的速度以及新的创新技术的发展速度都有巨大影响。

算法的研究也很重要,因为它训练我们对某些问题进行非常具体的思考。它可以通过帮助我们分离问题的组成部分并定义这些组成部分之间的关系,来增强我们的思维和问题解决能力。总之,学习算法有四个主要原因:

  1. 它们对计算机科学智能系统至关重要。
  2. 它们在许多其他领域(计算生物学、经济学、生态学、通信、生态学、物理学等)中都很重要。
  3. 它们在技术创新中发挥作用。
  4. 它们改善问题解决和分析思维能力。

算法在最简单的形式中只是一系列操作,一系列指令。它可能只是一个线性结构,形式为做x,然后做y,然后做z,然后完成。然而,为了使事情更有用,我们添加了诸如在 Python 中的if-else语句的子句。在这里,未来的行动取决于某些条件;比如数据结构的状态。我们还添加了操作、迭代,while 和 for 语句。进一步扩展我们的算法素养,我们添加了递归。递归通常可以实现与迭代相同的结果,但它们在根本上是不同的。递归函数调用自身,将相同的函数应用于逐渐减小的输入。任何递归步骤的输入都是前一个递归步骤的输出。

基本上,我们可以说算法由以下四个元素组成:

  • 顺序操作
  • 基于数据结构状态的操作
  • 迭代,重复执行一定次数的操作
  • 递归,在一组输入上调用自身

算法设计范式

总的来说,我们可以分辨出三种广泛的算法设计方法。它们是:

  • 分而治之
  • 贪婪算法
  • 动态规划

顾名思义,分而治之范式涉及将问题分解为较小的子问题,然后以某种方式将结果组合起来以获得全局解。这是一种非常常见和自然的问题解决技术,可以说是最常用的算法设计方法。

贪婪算法通常涉及优化和组合问题;经典的例子是将其应用于旅行推销员问题,贪婪方法总是首先选择最近的目的地。这种最短路径策略涉及在希望这将导致全局解决方案的情况下找到局部问题的最佳解决方案。

动态规划方法在我们的子问题重叠时非常有用。这与分治不同。与将问题分解为独立的子问题不同,动态规划中间结果被缓存并可以在后续操作中使用。与分治一样,它使用递归;然而,动态规划允许我们在不同阶段比较结果。对于某些问题,这可能比分治具有性能优势,因为通常更快地从内存中检索先前计算的结果,而不必重新计算它。

递归和回溯

递归特别适用于分治问题;然而,要准确理解发生了什么可能有些困难,因为每次递归调用都会产生其他递归调用。递归函数的核心是两种类型的情况:基本情况,告诉递归何时终止,和递归情况,调用它们所在的函数。一个自然适合递归解决方案的简单问题是计算阶乘。递归阶乘算法定义了两种情况:当n为零时的基本情况,和当n大于零时的递归情况。一个典型的实现如下:

def factorial(n):
        #test for a base case
        if n==0:
            return 1
            # make a calculation and a recursive call
            f= n*factorial(n-1)
        print(f)
        return(f)
        factorial(4)

这段代码打印出数字 1、2、4、24. 要计算 4 需要进行四次递归调用加上初始的父调用。在每次递归中,方法的变量副本都存储在内存中。一旦方法返回,它就会从内存中移除。以下是我们可以将这个过程可视化的一种方式:


可能并不清楚递归还是迭代对于特定问题更好的解决方案;毕竟它们都重复一系列操作,并且都非常适合分治算法设计。迭代一直运行直到问题完成。递归将问题分解为越来越小的块,然后将结果组合起来。迭代对程序员来说通常更容易,因为控制保持在循环内部,而递归可以更接近数学概念,比如阶乘。递归调用存储在内存中,而迭代不会。这在处理器周期和内存使用之间产生了一种权衡,因此选择使用哪种可能取决于任务是处理器密集型还是内存密集型。以下表格概述了递归和迭代之间的主要区别:

递归 迭代
当达到基本情况时终止 当满足定义的条件时终止
每次递归调用都需要内存空间 每次迭代都不会存储在内存中
无限递归会导致堆栈溢出错误 无限迭代将在硬件通电时运行
有些问题自然更适合递归解决方案 迭代解决方案可能并不总是显而易见

回溯

回溯是一种特别适用于遍历树结构等问题类型的递归形式,在每个节点我们都有多个选项可供选择。随后我们会面临不同的选项,并根据所做的选择系列达到目标状态或死胡同。如果是后者,我们必须回溯到上一个节点并遍历不同的分支。回溯是一种穷举搜索的分治方法。重要的是,回溯会剪枝不能给出结果的分支。

在下面的示例中给出了回溯的一个例子。在这里,我们使用了递归方法来生成给定长度n的给定字符串s的所有可能的排列:

def bitStr(n, s):            
         if n == 1: return s 
         return [ digit + bits for digit in bitStr(1,s)for bits in bitStr(n - 1,s)] 
    print (bitStr(3,'abc'))    

这产生了以下输出:


注意这个双列表压缩和这个理解中的两个递归调用。这个递归地连接了初始序列的每个元素,当*n* = 1时返回,与前一个递归调用中生成的字符串的每个元素。在这个意义上,它是回溯,以揭示先前未生成的组合。返回的最终字符串是初始字符串的所有n个字母组合。

分而治之 - 长乘法

为了使递归不仅仅是一个聪明的技巧,我们需要了解如何将其与其他方法进行比较,比如迭代,以及了解何时使用它将导致更快的算法。我们都熟悉的迭代算法是我们在小学数学课上学到的程序,用于相乘两个大数。也就是说,长乘法。如果你记得的话,长乘法涉及迭代相乘和进位操作,然后是移位和加法操作。

我们的目标是在这里检查如何测量这个过程的效率,并尝试回答这个问题:这是我们用来相乘两个大数的最有效的程序吗?

在下图中,我们可以看到将两个 4 位数相乘需要 16 次乘法运算,我们可以推广说,一个n位数需要大约次乘法运算:


以计算原语的数量,如乘法和加法,来分析算法的方法很重要,因为它为我们提供了一种理解完成某个计算所需的时间与该计算的输入大小之间的关系的方法。特别是,我们想知道当输入,即数字的位数n非常大时会发生什么。这个主题,称为渐近分析,或时间复杂度,对我们研究算法至关重要,我们将在本章和本书的其余部分经常回顾它。

我们能做得更好吗?递归方法

事实证明,在长乘法的情况下,答案是肯定的,实际上有几种算法可以用于乘法大数,需要更少的操作。最著名的长乘法替代方案之一是Karatsuba 算法,首次发表于 1962 年。这采用了一种基本不同的方法:而不是迭代地相乘单个数字,它以递归的方式对逐渐变小的输入进行乘法运算。递归程序在输入的较小子集上调用自己。构建递归算法的第一步是将一个大数分解为几个较小的数。这样做的最自然的方式是将数字简单地分成两半,前半部分是最重要的数字,后半部分是最不重要的数字。例如,我们的四位数 2345 变成了一对两位数 23 和 45。我们可以使用以下更一般的分解来分解任何 2 n位数xy,其中m是小于n的任何正整数:


现在我们可以将我们的乘法问题xy重写如下:


当我们展开并收集同类项时,我们得到以下结果:


更方便的是,我们可以这样写:


其中:


应该指出,这表明了一种递归方法来计算两个数字的乘法,因为这个过程本身涉及乘法。具体来说,乘积acadbcbd都涉及比输入数字小的数字,因此我们可以将相同的操作作为整体问题的部分解决方案。到目前为止,这个算法包括四个递归乘法步骤,目前还不清楚它是否比经典的长乘法方法更快。

到目前为止,关于递归乘法的讨论对数学家来说自 19 世纪末就已经很熟悉了。卡拉茨巴算法改进了这一点,方法是做出以下观察。我们实际上只需要知道三个量:z[2]= acz[1]=ad +bc,和z[0]= bd 来解方程 3.1。我们只需要知道a, b, c, d的值,因为它们对计算z[2], z[1], 和*z[0]*所涉及的总和和乘积有贡献。这表明也许我们可以减少递归步骤的数量。事实证明的确是这种情况。

由于乘积acbd已经处于最简形式,似乎我们不太可能消除这些计算。然而,我们可以做出以下观察:


当我们减去我们在上一个递归步骤中计算的acbd时,我们得到我们需要的数量,即(ad + bc):


这表明我们确实可以计算ad + bc的和,而无需单独计算每个单独的数量。总之,我们可以通过将递归步骤从四步减少到三步来改进方程 3.1。这三个步骤如下:

  1. 递归计算ac
  2. 递归计算bd
  3. 递归计算(a +b)(c + d)并减去acbd

以下示例展示了卡拉茨巴算法的 Python 实现:

from math import log10  
    def karatsuba(x,y): 
        # The base case for recursion 
        if x < 10 or y < 10: 
            return x*y     
        #sets n, the number of digits in the highest input number 
        n = max(int(log10(x)+1), int(log10(y)+1)) 
        # rounds up n/2     
        n_2 = int(math.ceil(n / 2.0)) 
        #adds 1 if n is uneven 
        n = n if n % 2 == 0 else n + 1 
        #splits the input numbers      
        a, b = divmod(x, 10**n_2) 
        c, d = divmod(y, 10**n_2) 
        #applies the three recursive steps 
        ac = karatsuba(a,c) 
        bd = karatsuba(b,d) 
        ad_bc = karatsuba((a+b),(c+d)) - ac - bd 
        #performs the multiplication     
        return (((10**n)*ac) + bd + ((10**n_2)*(ad_bc))) 

为了确保这确实有效,我们可以运行以下测试函数:

import random 
    def test(): 
            for i in range(1000): 
                x = random.randint(1,10**5) 
                y = random.randint(1,10**5) 
                expected = x * y 
                result = karatsuba(x, y) 
                if result != expected: 
                    return("failed")                 
            return('ok')   

运行时间分析

很明显,算法设计的一个重要方面是评估效率,无论是在空间(内存)还是时间(操作次数)方面。第二个度量,称为运行性能,是本节的主题。值得一提的是,用于衡量算法内存性能的度量标准与此相同。我们可以以多种方式衡量运行时间,最明显的可能是简单地测量算法完成所需的时间。这种方法的主要问题在于算法运行所需的时间很大程度上取决于其运行的硬件。衡量算法运行时间的一个与平台无关的方法是计算所涉及的操作次数。然而,这也存在问题,因为没有明确的方法来量化一个操作。这取决于编程语言、编码风格以及我们决定如何计算操作。然而,如果我们将这种计算操作的想法与一个期望相结合,即随着输入规模的增加,运行时间将以特定方式增加,那么我们就可以使用这个想法。也就是说,输入规模n与算法运行时间之间存在数学关系。

接下来的讨论大部分将围绕以下三个指导原则展开。随着我们的进行,这些原则的合理性和重要性将变得更加清晰。这些原则如下:

  • 最坏情况分析。不对输入数据做任何假设。
  • 忽略或抑制常数因子和低阶项。在大输入中,高阶项占主导地位。
  • 关注输入规模较大的问题。

最坏情况分析是有用的,因为它给出了我们算法保证不会超过的严格上界。忽略小的常数因子和低阶项实际上就是忽略那些在输入大小n的大值时并不对总运行时间有很大贡献的事物。这不仅使我们的工作在数学上更容易,也使我们能够专注于对性能影响最大的事物。

我们在 Karatsuba 算法中看到,乘法操作的数量增加到了输入大小n的平方。如果我们有一个四位数,乘法操作的数量是 16;一个八位数需要 64 次操作。通常,我们并不真正关心算法在n的小值上的行为,所以我们通常忽略那些随着n线性增长的因素。这是因为在较大的n值上,随着n的增加,增长最快的操作将占主导地位。

我们将通过一个例子,归并排序算法,更详细地解释这一点。排序是第十三章的主题,排序,然而,作为一个前导和了解运行时性能的有用方式,我们将在这里介绍归并排序。

归并排序算法是一个经典的算法,已经发展了 60 多年。它仍然广泛应用于许多最流行的排序库中。它相对简单而高效。它是一个使用分治法的递归算法。这涉及将问题分解为更小的子问题,递归地解决它们,然后以某种方式将结果合并。归并排序是分治范式的最明显的演示之一。

归并排序算法由三个简单的步骤组成:

  1. 递归地对输入数组的左半部分进行排序。
  2. 递归地对输入数组的右半部分进行排序。
  3. 将两个排序好的子数组合并成一个。

一个典型的问题是将一组数字按数字顺序排序。归并排序通过将输入分成两半并同时处理每一半来工作。我们可以用以下图表来形象地说明这个过程:


以下是归并排序算法的 Python 代码:

def mergeSort(A): 
        #base case if the input array is one or zero just return. 
        if len(A) > 1: 
            # splitting input array 
            print('splitting ', A ) 
            mid = len(A)//2 
            left = A[:mid] 
            right = A[mid:] 
            #recursive calls to mergeSort for left and right sub arrays                 
            mergeSort(left) 
            mergeSort(right) 
            #initalizes pointers for left (i) right (j) and output array (k)  
    # 3 initalization operations 
            i = j = k = 0         
            #Traverse and merges the sorted arrays 
            while i <len(left) and j<len(right): 
    # if left < right comparison operation  
                if left[i] < right[j]: 
    # if left < right Assignment operation 
                    A[k]=left[i] 
                    i=i+1 
                else: 
    #if right <= left assignment 
                    A[k]= right[j] 
                    j=j+1 
                k=k+1 
            while i<len(left): 
    #Assignment operation 
                A[k]=left[i] 
                i=i+1 
                k=k+1 
            while j<len(right): 
    #Assignment operation 
                A[k]=right[j] 
                j=j+1 
                k=k+1 
        print('merging ', A) 
        return(A)   

我们运行这个程序得到以下结果:


我们感兴趣的问题是如何确定运行时间性能,也就是说,算法完成所需的时间与n的大小相关的增长率是多少。为了更好地理解这一点,我们可以将每个递归调用映射到一个树结构上。

树中的每个节点都是一个递归调用,处理逐渐变小的子问题:


每次调用归并排序都会随后创建两个递归调用,因此我们可以用二叉树表示这一点。每个子节点都接收输入的一个子集。最终,我们想知道算法完成所需的总时间与n的大小相关。首先,我们可以计算树的每一层的工作量和操作数量。

关注运行时分析,在第 1 层,问题被分成两个n/2 的子问题,在第 2 层,有四个n/4 的子问题,依此类推。问题是递归何时结束,也就是说,何时达到基本情况。这只是当数组要么是零要么是一时。

递归级别的数量正好是你需要将n除以 2 的次数,直到得到最多为 1 的数字。这恰好是 log2 的定义。由于我们将初始递归调用计为级别 0,总级别数是 log[2]n + 1。

让我们暂停一下,重新定义一下。到目前为止,我们一直用字母n来描述输入中的元素数量。这指的是递归的第一级中元素的数量,也就是初始输入的长度。我们需要区分后续递归级别的输入大小。为此,我们将使用字母m或者特定的m[j]来表示递归级别j的输入长度。

还有一些细节我们忽略了,我相信你也开始好奇了。例如,当m/2 不是整数时会发生什么,或者当我们的输入数组中有重复元素时会发生什么。事实证明,这对我们的分析没有重要影响。

使用递归树来分析算法的优势在于我们可以计算每个递归级别的工作量。定义这个工作量就是操作的总数,这当然与输入的大小有关。以平台无关的方式来衡量和比较算法的性能是很重要的。实际运行时间当然取决于运行的硬件。计算操作次数很重要,因为它给了我们一个与算法性能直接相关的度量标准,与平台无关。

一般来说,由于归并排序的每次调用都会进行两次递归调用,调用次数在每个级别都会翻倍。同时,每次调用都会处理其父级别大小一半的输入。我们可以形式化地表达为:

对于级别j,其中j是整数 0、1、2… log[2]n,每个大小为n/2^j 的子问题有 2^j 个。

为了计算总操作次数,我们需要知道单个合并两个子数组所包含的操作次数。让我们来数一下之前 Python 代码中的操作次数。我们感兴趣的是在进行两次递归调用后的所有代码。首先,我们有三个赋值操作。然后是三个 while 循环。在第一个循环中,我们有一个 if else 语句,每个 if else 语句中有两个操作,一个比较,一个赋值。由于在 if else 语句中只有一个这样的操作集,我们可以将这段代码计为每次递归执行 2 次。接下来是两个 while 循环,每个有一个赋值操作。这使得每次归并排序递归的总操作次数为 4m + 3。

由于m至少为 1,操作次数的上限为 7m。必须指出,这并不是一个精确的数字。当然,我们可以决定以不同的方式计算操作次数。我们没有计算增量操作或任何维护操作;然而,在n的高值时,我们更关心运行时间的增长速度。

这可能看起来有点令人生畏,因为每次递归调用本身都会产生更多的递归调用,似乎呈指数级增长。使这个问题可控的关键事实是,随着递归调用次数翻倍,每个子问题的大小减半。这两股相反的力量会很好地抵消,我们可以证明这一点。

为了计算递归树每个级别的最大操作次数,我们只需将子问题的数量乘以每个子问题中的操作次数,如下所示:

(图片)

重要的是,这表明,因为 2^j 取消了每个级别上的操作数,所以每个级别上的操作数与级别无关。这给我们每个级别上执行的操作数的上限,例如,在这个例子中,是 7n。应该指出,这包括该级别上每个递归调用执行的操作数,而不是在后续级别上进行的递归调用。这表明,随着每个级别的递归调用数量翻倍,所做的工作正好被每个子问题的输入大小减半所抵消。

要找到完整归并排序的总操作数,我们只需将每个级别上的操作数乘以级别数。这给我们以下结果:


当我们展开这个式子时,我们得到以下结果:


从中要得出的关键点是,输入大小和总运行时间之间存在对数关系。如果您还记得学校数学,对数函数的显著特征是它非常快地变平。作为输入变量,x增大,输出变量y增加的幅度越来越小。例如,将对数函数与线性函数进行比较:


在前面的例子中,将nlog[2]n组件与n²进行比较。


请注意,对于非常小的n值,完成时间t实际上比运行时间为 n²的算法更短。然而,对于大约 40 以上的值,对数函数开始占主导地位,使输出变平,直到在相对适中的大小n=100 时,性能比运行时间为 n²的算法高出两倍以上。还要注意,在高n值时,常数因子+7 的消失是无关紧要的。

生成这些图表所使用的代码如下:

import matplotlib.pyplot as plt 
    import math 
    x=list(range(1,100)) 
    l =[]; l2=[]; a = 1 
    plt.plot(x , [y * y for y in x] ) 
    plt.plot(x, [(7 *y )* math.log(y, 2) for y in x]) 
    plt.show() 

如果尚未安装 matplotlib 库,您需要安装它才能运行。有关详细信息,请访问以下地址;我鼓励您尝试使用列表推导式表达式来生成图表。例如,添加以下绘图语句:

plt.plot(x, [(6 *y )* math.log(y, 2) for y in x]) 

得到以下输出:


前面的图表显示了计算六次操作或七次操作之间的差异。我们可以看到这两种情况是如何分歧的,这在谈论应用程序的具体情况时很重要。然而,我们在这里更感兴趣的是一种表征增长率的方法。我们更关心的不是绝对值,而是这些值随着n的增加而变化的方式。通过这种方式,我们可以看到这两条较低的曲线具有相似的增长率,与顶部(x²)曲线相比。我们说这两条较低的曲线具有相同的复杂度类。这是一种理解和描述不同运行时行为的方法。我们将在下一节正式化这个性能指标。

渐近分析

基本上有三个特征来表征算法的运行时间性能。它们是:

  • 最坏情况 - 使用能够获得最慢性能的输入
  • 最佳情况 - 使用能够给出最佳结果的输入
  • 平均情况 - 假设输入是随机的

为了计算这些,我们需要知道上限和下限。我们已经看到了用数学表达式来表示算法的运行时间的方法,基本上是加法和乘法运算。要使用渐近分析,我们只需创建两个表达式,一个用于最佳情况,一个用于最坏情况。

大 O 符号

大 O 符号中的字母“O”代表顺序,以承认增长速度被定义为函数的顺序。我们说一个函数T(n)是另一个函数F(n)的大 O,我们将其定义如下:


输入大小n的函数g(n)基于这样的观察:对于所有足够大的n值,g(n)都受到F(n)的常数倍的上界限制。目标是找到小于或等于F(n)的增长速度。我们只关心n的较高值发生了什么。变量n[0]表示增长速度不重要的阈值。函数 T(n)表示紧密上界F(n)。在下图中,我们看到T(n) = + 500 = O(),其中C = 2,*n[0]*约为 23:


您还会看到符号f(n) = O(g(n))。这描述了O(g(n))实际上是一个包含所有增长速度与f(n)相同或更小的函数的集合。例如,O()也包括函数O(n),O(nlogn),等等。

在下表中,我们按从最低到最高的顺序列出了最常见的增长率。我们有时称这些增长率为函数的时间复杂度,或者函数的复杂度类:

复杂度类 名称 示例操作
O(1) 常数 追加,获取项目,设置项目。
O(logn) 对数 在排序数组中查找元素。
O(n) 线性 复制,插入,删除,迭代。
nLogn 线性对数 对列表进行排序,合并 - 排序。
二次 在图中找到两个节点之间的最短路径。嵌套循环。
立方 矩阵乘法。
2*^n* 指数 '汉诺塔’问题,回溯。

组合复杂度类

通常,我们需要找到一系列基本操作的总运行时间。事实证明,我们可以将简单操作的复杂度类组合起来,以找到更复杂的组合操作的复杂度类。目标是分析函数或方法中的组合语句,以了解执行多个操作的总时间复杂度。组合两个复杂度类的最简单方法是将它们相加。这发生在我们有两个连续的操作时。例如,考虑将元素插入列表,然后对该列表进行排序的两个操作。我们可以看到插入一个项目需要 O(n)时间,排序需要 O(nlogn)时间。我们可以将总时间复杂度写为 O(n + nlogn),也就是说,我们将两个函数放在 O(…)中。我们只关心最高阶项,因此这让我们只剩下 O(nlogn)。

如果我们重复一个操作,例如在 while 循环中,那么我们将复杂度类乘以操作执行的次数。如果一个时间复杂度为 O(f(n))的操作重复执行 O(n)次,那么我们将这两个复杂度相乘:

O(f(n) * O(n)) = O(nf(n))。

例如,假设函数 f(…)的时间复杂度为 O(n²),并且在 while 循环中执行n次,如下所示:

for i n range(n): 
        f(...) 

然后,这个循环的时间复杂度变成了 O(n²) * O(n) = O(n * n²) = O()。在这里,我们只是将操作的时间复杂度乘以这个操作执行的次数。循环的运行时间最多是循环内语句的运行时间乘以迭代次数。一个单独的嵌套循环,也就是一个循环嵌套在另一个循环中,假设两个循环都运行n次,那么运行时间就是n²。例如:

for i in range(0,n):  
        for j in range(0,n) 
            #statements 

每个语句是一个常数 c,执行n**n次,因此我们可以将运行时间表示为;c**n n = cn² = O(n2)。

对于嵌套循环中的连续语句,我们将每个语句的时间复杂度相加,并乘以语句执行的次数。例如:

n = 500    #c0   
    #executes n times 
    for i in range(0,n): 
        print(i)    #c1 
    #executes n times 
    for i in range(0,n): 
        #executes n times 
        for j in range(0,n): 
        print(j)   #c2 

这可以写成c[0] +c[1]n + cn² = O(n²)。

我们可以定义(以 2 为底)对数复杂度,将问题的大小减少一半,所需的时间是常数。例如,考虑以下代码片段:

i = 1 
    while i <= n: 
        i=i * 2 
        print(i) 

注意i在每次迭代中都会加倍,如果我们以n=10 运行这个程序,我们会看到它打印出四个数字;2、4、8 和 16。如果我们将n加倍,我们会看到它打印出五个数字。随着n的每次加倍,迭代次数只增加了 1。如果我们假设k次迭代,我们可以写成如下:


由此我们可以得出总时间 = O(log(n))。

尽管大 O 是渐近分析中最常用的符号,但还有两个相关的符号应该简要提到。它们是 Omega 符号和 Theta 符号。


Python 入门指南(三)(2)https://developer.aliyun.com/article/1507384

相关文章
|
3天前
|
Linux 开发工具 Python
初学者从无到有的Python语言如何入门,这份Python学习路线赶紧带走_python 从无到(1)
初学者从无到有的Python语言如何入门,这份Python学习路线赶紧带走_python 从无到(1)
初学者从无到有的Python语言如何入门,这份Python学习路线赶紧带走_python 从无到(1)
|
3天前
|
数据采集 算法 Python
2024年Python最全python基础入门:高阶函数,小米面试编程题
2024年Python最全python基础入门:高阶函数,小米面试编程题
|
3天前
|
存储 数据采集 数据挖掘
真正零基础Python入门:手把手教你从变量和赋值语句学起
真正零基础Python入门:手把手教你从变量和赋值语句学起
|
4天前
|
数据挖掘 数据处理 Python
【Python DataFrame 专栏】Python DataFrame 入门指南:从零开始构建数据表格
【5月更文挑战第19天】本文介绍了Python数据分析中的核心概念——DataFrame,通过导入`pandas`库创建并操作DataFrame。示例展示了如何构建数据字典并转换为DataFrame,以及进行数据选择、添加修改列、计算统计量、筛选和排序等操作。DataFrame适用于处理各种规模的表格数据,是数据分析的得力工具。掌握其基础和应用是数据分析之旅的重要起点。
【Python DataFrame 专栏】Python DataFrame 入门指南:从零开始构建数据表格
|
5天前
|
网络协议 网络架构 Python
Python 网络编程基础:套接字(Sockets)入门与实践
【5月更文挑战第18天】Python网络编程中的套接字是程序间通信的基础,分为TCP和UDP。TCP套接字涉及创建服务器套接字、绑定地址和端口、监听、接受连接及数据交换。UDP套接字则无连接状态。示例展示了TCP服务器和客户端如何使用套接字通信。注意选择唯一地址和端口,处理异常以确保健壮性。学习套接字可为构建网络应用打下基础。
20 7
|
6天前
|
Python
10个python入门小游戏,零基础打通关,就能掌握编程基础_python编写的入门简单小游戏
10个python入门小游戏,零基础打通关,就能掌握编程基础_python编写的入门简单小游戏
|
8天前
|
Python 索引 C语言
Python3从零基础到入门(2)—— 运算符-3
Python3从零基础到入门(2)—— 运算符
|
8天前
|
Python
Python3从零基础到入门(2)—— 运算符-2
Python3从零基础到入门(2)—— 运算符
Python3从零基础到入门(2)—— 运算符-2
|
8天前
|
Python C语言 存储
Python3从零基础到入门(2)—— 运算符-1
Python3从零基础到入门(2)—— 运算符
Python3从零基础到入门(2)—— 运算符-1
|
8天前
|
存储 C语言 Python