【Python】第7章 计算思维

        “计算思维”这个词现在很流行,许多培训机构都在宣扬要从小培养计算思维,似乎没点计算思维,就没法应对未来的挑战。计算思维是像计算机一样思维吗?当然不是,计算机不会思维,计算机做的事,都是人的思维的结果。计算思维是人的思维方式,按照这个名词的提出者周以真的说法,就是运用计算机科学的思维方式进行问题求解、系统设计,以及人类行为理解等一系列的思维活动。按照周的说法,计算思维有四大步骤。

        (1)问题分解。将大问题分解成容易解决的小问题。

        (2)模式识别。寻找待解决问题和已解决问题之间的相似之处,以及解决某类问题的普遍规律。

        (3)抽象。提取最重要的需要关注的信息,忽略不相关的细节。

        (4)制定算法。建立解决问题的流程或者规则,并使之能够解决其他类似问题。

        在作者看来,如果用这四个步骤来描述编程解决问题的过程,那很容易理解。如果说要用这种思维方式去解决生活中的各种和编程无关的问题,那实在太牵强和费解。作为长期从事软件开发和编程教学的专业人员,作者自认为是不缺乏计算思维的,但是如何将上述计算思维运用到生活中和非计算机相关的工作中,作者基本完全没有头绪。也许期末按学号把考卷排个序,会用到一点计算思维基数排序算法。

        非计算机专业人员通过计算思维获得成功的典型例子,是ThomasPetrofi(托马斯彼得菲)。他是来自匈牙利的美国第一代移民,本专业是土木工程设计,后来又转行到证券公司炒股票、期货。Petrofi自学过编程,有点计算思维。他发现纳斯达克股票市场波动起伏很大,机会可能稍纵即逝。而交易员都是手动操作,在屏幕上读取股价,终端键盘输入买卖的股票及报价。有时交易员多啃了口汉堡,可能就失去了一次低买或高卖的机会。Petrofi设计了一个系统,接上了纳斯达克的股票终端机,自动获取股票数据,经过分析后自动下单,交易速度远超交易员,自然也就能把握更多的机会。Petrofi凭借他的系统,在1986年到1987年间,赚了5000万美元。直到今天,通过计算机系统进行高频交易,仍然是金融高科技公司在股票市场获利的重要手段。其基本思路就是,哪怕每股只赚一分钱的交易也要做,但是这样的交易一天可以做几万次,通过积少成多获取大额利润。

        由于作者并不从事非计算机专业的工作,因此对其他专业如何应用计算思维并无切身体验。在作者看来,对非计算机专业的人士来说,实实在在的计算思维,就是以下三条。

        (1)知道现在计算机都能做些什么。

        (2)能敏感地发现某个问题适合用计算机解决,并能积极主动地寻求相应的软件来解决。在找不到合适软件的情况下,如果问题很简单,那就自己写个程序来解决。

        (3)如果请专业程序员解决问题,应该对问题的复杂性和程序员解决问题的质量略有评估能力,或能装作内行的样子,让程序员不敢忽悠。

        第(1)条基本上只要多关注IT界新闻就能做到。而(2)(3)两条,倘若没有学过编程,就不可能做到。只学一点粗浅的编程,也还不够,必须对计算机科学的一些基本常识有所了解才行。这些基本常识,不是如何使用计算机、计算机都有哪些部件、怎么防计算机病毒之类的实践技巧,而是关于计算机科学基本理论的常识。掌握了这些常识,作者认为就有了计算思维的基础。作者能做到的,也就是帮读者打下基础。至于能否在这基础上发扬出计算思维,当属只可意会不可言传,得靠读者自己去悟。

7.1   计算机的本质

        计算机科学的奠基人之一,英国数学家阿兰·图灵在1936年提出了一种称为“图灵机”的计算机器模型,所有现代计算机的计算过程本质上都和图灵机类似,且计算能力不超过图灵机。即:现代计算机能解决的问题,图灵机都能解决。实际上,以目前的物理学理论来看,不可能造出能解决图灵机无法解决的问题的机器—因为解决那样的问题,需要无限多的能量。用图灵机解决问题的严格描述过于抽象,可以通俗地用图8.1.1表示。

         图灵机有一个无限长的纸带,纸带上有一个个格子,每个格子可以写一个符号。还有一个可以在纸带上左右移动的控制器,控制器带读写头,可以读取或者改写纸带上格子里的符号。控制器里面还有一个状态存储器,记录图灵机当前的状态。有一个特殊状态,叫“停机”,计算任务完成,就进入“停机”状态。可以用卡片给控制器输入一段程序,程序会根据当前状态以及当前读写头所在的格子上的符号来确定读写头下一步的动作(左移、右移、不动、读取、改写),并令机器进入一个新的状态或保持状态不变。后来,图灵又改进了图灵机,指出程序不需要通过另外的卡片输入给控制器,可以放在纸带上由控制器读取,这样图灵机模型就和现代电子计算机一样了纸带相当于内存,控制器相当于CPU,计算机处理问题时,程序和程序要处理的数据,都存储在内存。数据从硬盘等外存读取到内存,算是处理问题前的准备,相当于往纸带上事先写好符号。

        现代计算机本质上和图灵机一样,是信息处理装置。计算机所解决的一切问题,不论是文字编辑、视频播放、网络游戏、语音识别、下棋还是自动驾驶,本质上都是一台图灵机读入纸带上面代表问题的一些符号,然后把代表解的符号写到纸带上而已。我们看到的计算机呈现出来的纷繁复杂的视听效果,自动驾驶、无人机舞蹈等各种机器人的炫酷动作,只不过是代表解的符号的外在表现形式。

        图灵机的控制器完全可以由齿轮等机械装置构成,纸带上的符号可以通过打孔来表示。如果有人给这个机械上发条,或者不停地摇动手柄提供动力,这个机器就能解决目前计算机所能解决的一切问题。那么,读者觉得这样的一个由纸带和齿轮构成的机械,会产生意识吗?

        我们所说的“信息”,都是可以用0,1串表示的。计算机只能处理信息,计算机所能解决的问题,必须可以被表示成一个0,1串;计算机寻求的那个解,也是一个0,1串。比如下围棋,问题就是能表示当前棋局的0,1串,解就是代表最佳落子位置的0,1串。人类的意识和情感,是否是信息,没有人知道。所以,我们不知道从理论上来讲,人类自身能解决的问题,是否一定都能交给计算机解决,因为有些问题也许无法用信息表示,解也不是信息。

7.2   计算机解决问题的基本方法——穷举

        用数学的方法解决问题,就是要找定理,推公式。有了定理和公式,就可以计算出答案。然而许多问题是没有定理和公式的,比如给定正整数n,求小于n的最大质数这个问题找不到可以算出解的公式。

        非常常见的情况是:一个问题,直接找它的解很困难,然而验证一个可能的解,是否的确是该问题的解,却比较容易。这种情况下,就可以用“穷举”,也叫“枚举”的方法来求解。所谓穷举,就是一个不漏地试,即对每个可能的解X,都去判断X是否真的是问题的解。当然如果已经试出问题的解,那么还没判断的可能解就不必再去判断。以求小于n的最大质数为例,验证任何一个小于n的整数是否是质数,可以从n−1,n−2,到2依次判断每个整数是否是质数,找到的第一个质数,就是问题的解。

        再举一个奥数题的例子:已知ABCD+ABCD=BCAD,问ABCD各代表什么数字(0~9)。解法就是对ABCD4个字母代表数字的所有可能情况都试一遍,看哪种组合能满足等式。

        计算机下围棋,用的也是穷举法,即判断一下如果下在这里,后面会怎样,下在那里,后面会怎样……然后挑一个看起来最好的点落子。

        回顾一下前面用穷举法解决的例题:

        例题:输入正整数n和m,在1至n这n个整数中,取出两个不同的数,使得其和是m的因子,问有多少种不同的取法。解法:穷举取两个数的所有不同的取法,对每个取法判断其和是否是m的因子。

        例题:输入3个整数,求它们的最小公倍数。解法:从小到大试每个整数,看是不是3个数的公倍数。

        例题:八皇后问题。解法:八重循环穷举所有摆法,对每种摆法判断是否符合要求。

        计算机的特点就是不知疲倦,不惧重复。穷举是用计算机解决问题的基本方法之一,也许是最重要的基本方法。

        穷举法,从字面上看是要验证所有可能的解。但是,验证一个可能解是否正确,是需要花时间的。因此改进穷举法的一个重要思路,就是不去验证显然不可能是答案的可能解。以求3个数的最小公倍数为例,不需要验证每个数,只需要验证最大数的倍数即可;发现了最大数和另一个数的公倍数以后,就只需要验证该公倍数的倍数即可。。以八皇后问题为例,如果一个摆放方案的前两行已经造成了冲突,那么前两行和该方案相同的所有摆放方案,都不必考查。

        一个问题的所有可能解构成了一个“解空间”,解决问题就是要在这个解空间中通过验证可能解,来寻找真正的解。这个过程也称为“搜索”。减少需要验证的可能解的数量,称为“剪枝”,是提高搜索效率的关键。不进行剪枝的盲目搜索,即盲目的穷举,俗称“暴力”算法。如果计算机的运算速度无限快,即一秒钟能执行无穷多条指令,那么算法这门学科就基本没有研究的价值了,因为几乎任何问题都可以用暴力来解决反正要枚举的可能解就算再多,也不是问题。

        有的情况下,我们不但要找一个问题的解,还要找最优解,这常常也需要通过“搜索”来完成。比如将一个打乱的魔方用最少的步骤复原,就是一个通过搜索求最优解的问题。求最优解的基本思想是找到所有解,在里面挑最优的。具体到魔方问题,假定用不超过100步转动一定可以复原魔方,那么这个问题的解空间就是所有步数不超过100的转动的序列。这个解空间中可能解的数量无比巨大,是不可能验证完的,必须剪枝。一种重要的剪枝的技巧是记录到目前为止发现的最优解,在寻求一个可能的新解的过程中,如果发现该可能新解花费的代价已经大于等于目前最优解的代价,则该可能的新解就不用考虑了。以魔方问题为例,如果已经找到了一个n步复原的方案,那么所有步数大于等于n的其他方案,就都不需要去验证了。

7.3   程序或算法的时间复杂度

        同样是编写程序解决问题,采用的方法(即算法)不一样,程序解决问题所花的时间,会有天壤之别。以求斐波那契数列第n项为例,理论上下面两个函数都可以解决:

        解法1:

def fib(n):
    a1 = a2 = 1
    for i in range(n-2):
        a2,a1 = a1+a2,a2
    return a2

        解法2:

def fib(n):
    if n <= 2:
        return 1
    else:
        return fib(n-1) + fib(n-2)

        用解法1求第10000项,可以瞬间出结果。读者可以试试用解法2求第100项,多长时间出结果……别真的傻傻地等,用现在的个人计算机,十万年也算不出结果,原因是解法2存在大量重复计算,例如算fib(5)时会把fib(4)从头到尾递归算一遍,算fib(6)时又要把fib(4)从头到尾算一遍……

        既然程序或者算法的时间效率有巨大区别,那么就需要用一个指标来衡量。一个程序或算法的时间效率,也称为“时间复杂度”,往往简称“复杂度”。复杂度常用大的字母O和小写字母n来表示,比如O(n),O(n^2)等。n代表问题的规模,例如要排序的成绩单里学生的人数,要模糊处理的图像的像素个数等。可以不专业地认为,O(x)就是和x成正比的意思,至于到底是x的多少倍,并不重要。时间复杂度是用程序或算法运行过程中,某种时间固定的操作需要被执行的次数和n的关系来度量的。例如,在有n个元素的无序数列a中查找某个数x,复杂度是O(n),因为查找时只能从头到尾将a看一遍—这叫顺序查找。如果x不在a中,则需要看完整个a,“查看一个元素”这样的操作就会进行n次;如果x在a中,x可能是第一个,也可能是最后一个,平均需要看n/2个元素才能找到x。不妨就按最坏的情况看,“查看一个元素”这样的操作需要进行n次,因此复杂度就是O(n)。哪怕操作需要做2n次、3n次,甚至10000n次,我们都说复杂度是O(n),不关心前面的系数。

        谈一个程序或算法的复杂度时,有“平均情况的复杂度”和“最坏情况的复杂度”这两个概念。有的时候这两种复杂度是不一致的。默认情况下,复杂度说的是前者。

        计算复杂度的时候,只统计在问题规模足够大时,执行次数最多的那种时间固定操作的次数。比如某个算法需要执行加法n^2次,除法1000n次,由于n足够大时,n^2会大于1000n,于是就记其复杂度是O(n^2)。如果操作次数是多个n的函数之和,则只关心随n的增长,增长得最快的那个函数,例如:O(n^3+n^2)等价于O(n^3),O(2^n+n^3)等价于O(2^n),O(n!+3^n)等价于O(n!)。

        如何计算程序或算法的复杂度,简单的例子有6.4.3小节所述的选择排序。上面求斐波纳契数列第n项的解法1的复杂度更是极好算,a2,a1=a1+a2,a2这条语句的执行时间是固定的,它一共执行了n-2次,因此,解法1的复杂度就是O(n)。但是复杂度的计算常常也会很困难,例如上面求斐波那契数列第n项的解法2的复杂度,就不好算。粗看会觉得是O(2^n),但更精确的答案是O(1.618^n)。

        有时候我们会提到一个问题的复杂度。一个问题的复杂度指的是能解决该问题的最快的算法的复杂度。比如排序这个问题,我们说其复杂度是O(n×log(n)),因为目前最快的排序算法的复杂度就是O(n×log(n)),而且已经证明,不存在复杂度更低的排序算法。

        常见的问题有以下几种复杂度,从低到高为:

常数复杂度O(1)时间(操作次数)和问题的规模无关
对数复杂度O(log(n))对数的底是多少不重要
线性复杂度O(n)
排序复杂度O(n×log(n))
多项式复杂度O(n^k)k是常数
指数复杂度O(a^n)a是常数阶
乘复杂度O(n!)

        在一个排好序的序列中找出最大值或最小值,复杂度是O(1)。因为只要看第一个或最后一个元素即可,所花时间和序列元素个数无关。

        在排好序的列表上查找某个元素,可以使用二分查找的办法,复杂度是O(log(n))。二分查找类似于在英文词典中查单词。假设词典有n页,那么要看几页,才能找到单词所在页面呢?高效的方法是,翻到词典正中间那页看一眼,就知道要查的单词在词典前一半还是后一半,于是半本词典就可以撕下来扔掉,查找范围缩小到原来的一半。再到剩下的半本词典正中的页面看一眼,就又能缩小一半查找范围。每看一页都能缩小查找范围一半,因此叫作二分查找。二分查找的查找范围以对数形式迅速下降,一本1024页的词典,只要看不超过log21024页,即10页,查找范围就能缩小到只有1页,于是就能找到该单词,或确定单词不在词典里。庄子所说的“一尺之棰,日取其半,万世不竭”是对“对数减少”(其反义词是“指数增长”)的惊人速度没有概念。日取其半,取个几十天,棰就只剩下一个原子了。如果算法能做到对数复杂度,那么问题规模哪怕大到全宇宙的原子数目那么多也不用担心。

        在一个从小到大排好序的列表a中二分查找元素x的函数如下:

def binarySearch(a,x):
	L,R = 0, len(a) - 1 
	while L <= R:	
		mid = (L + R)//2
		if a[mid] == x:
			return mid
		elif x < a[mid]:
			R = mid - 1
		else:
			L = mid + 1
	return -1

print(binarySearch([1,3,5,9,10,30],3))
print(binarySearch([1,3,5,9,10,30],8))
print(binarySearch([1,3,5,9,10,30],30))
print(binarySearch([1,3,5,9,10,30],1))
print(binarySearch([1,3],1))
print(binarySearch([1,3],3))
print(binarySearch([1],1))
print(binarySearch([1],2))

        在无序的列表中顺序查找元素的复杂度是O(n)。

        排序的复杂度是O(n×log(n))。

        笨拙的排序算法,如插入排序、选择排序、冒泡排序等,复杂度是O(n^2)。

        汉诺塔问题的复杂度是O(2n)。指数增长的速度是惊人的,64个盘子的汉诺塔问题,如果一秒移动一个盘子,到宇宙毁灭也做不完。

        如果一个问题的复杂度到达指数级别,那么这个问题的规模稍大,就变得无法解决。整数的质因数分解,若将问题的规模视为整数二进制表示形式的位数,则在目前看来就是一个指数复杂度的问题,虽然还没有证明的确如此。随便找两个很大的质数p,q,通过p×q可以得到整数z。然而要将z分解质因数得到p和q,目前还没找到多项式复杂度的算法,但也没有证明不存在这样的算法。在z很大,比如说其二进制表示形式有2048位的情况下,想找到p和q,目前就是不可能完成的任务。世界上最流行的加密算法—RSA公开密钥算法,其难以破解的原因,就是“大整数的质因数分解很困难”。

        计算机科学的核心,就是研究怎样才能快速地解决问题。因此,计算思维,就是用智慧换时间。

        即便是非计算机专业的人士,使用Python时也有必要知道一些Python中操作的时间复杂度。否则设计的程序在处理大规模数据时,就有可能慢得无法忍受,甚至根本算不出结果。

        Python中常见的O(1)复杂度的操作有:

        (1)根据下标访问列表、字符串、元组中的元素;

        (2)在集合、字典中增删元素;

        (3)调用列表的append函数在列表末尾添加元素以及用pop()函数删除列表末尾元素;

        (4)用in判断元素是否在集合中或某关键字是否在字典中;

        (5)以关键字为下标访问字典中的元素的值。

        O(n)复杂度的操作有:

        (1)用in判断元素是否在字符串、元组、列表中;

        (2)用insert在列表中插入元素;

        (3)用remove或del删除列表中的元素;

        (4)字符串、元组或列表的find、rfind、index等执行顺序查找的函数;

        (5)用字符串、元组或列表的count函数计算元素出现次数;

        (6)用max、min函数求列表的最大值、最小值。

        O(n×log(n))的操作有:Python自带的排序函数。

常见错误:本该用字典或者集合进行查找的场合,却使用in或index在列表中进行查找,导致程序运行很慢。在OJ系统上做某些题目,就可能导致超时的错误。初学者还常常没有时间观念,意识不到sort、find、index等操作需要的时间并非是可以忽略的常数,从而导致恣意浪费,比如下面的代码:

lst = []
for i in range(n):
    lst.append(int(input()))
    lst.sort()
对列表lst的排序,在添加完全部元素以后sort一次即可。每添加一个元素就sort一次,是严重的浪费。即便数据规模不大不在乎效率,如果被别人看见你写了这样的程序,那也是大失面子的。

         还有初学者常写类似下面的程序:

print(max(lst)*max(lst))        #假设lst是个列表

        max函数的复杂度是O(n),这里无端地多用了一次max函数,非常铺张。如果某个费时操作的结果要多次使用,那么就应该将该操作的结果存到变量里以后再用,而不是重复做该操作。所以上面程序应该写成如下形式:

a = max(lst)
print(a*a)

         用Python编程,有时不同的写法,尽管复杂度相同,速度可能也会相差数十倍甚至一百倍。假设string是个长字符串,下面的写法1就比写法2快约一百倍:

        写法1:

n = string.count('a')    #count也要从头到尾把string看一遍,复杂度O(n)

        写法2:

n = 0
for c in string:
    if c == 'a':
        n += 1

        两种写法虽然复杂度一样,但是写法1只解释执行一条语句,count函数的内部实现是机器指令,速度很快,而写法2要反复解释执行每条语句,所以明显更慢。

7.4   有序就能找得快

        在生活中,如果东西分门别类有序摆放,要找到就会比较容易。图书馆的书,如果不分类,不按书名或书号排序,要找到一本书,就会非常困难。

        让数据变得有序,是加快解决问题速度的一个重要方法。上节的O(log(n))复杂度的二分查找对比顺序查找,就是典型的例子。所以,如果经常需要在一堆元素里进行查找,那么不妨先将这堆元素放在列表里,排好序,然后就可以使用二分查找法。但是,如果还要频繁对这堆元素做添加和删除的操作,这个办法就不好使了。因为每次添加元素时,为了保持有序,需要将新元素插入到合适位置。寻找合适的插入位置可以用二分查找法,花费时间O(log(n)),然而插入操作会导致其后面的元素都要后移,后移要花费O(n)的时间。删除元素导致后面元素前移,一样要花费O(n)的时间。有没有办法做到查找、插入、删除都很快呢?

        有。计算机科学有一个领域叫作“数据结构”,研究的就是如何有组织地存放数据,使得数据的添加、删除、更新、查找、统计都尽可能快。如果我们用“平衡二叉树”这种数据结构存放一堆元素,就能使得在这堆元素上的添加、删除、更新、查找都可以用O(log(n))的复杂度来完成。Python的集合和字典,用到了“哈希表”这种数据结构来存放元素,因此增删、更新、查找基本都能在O(1)时间内完成。

        计算机科学还有一个领域叫“数据库”,研究的就是如何将海量的数据以合理的组织结构存放在外存,使得数据的添加、删除、更新、查找、统计都尽可能快。搜索引擎、网购平台、网上银行、论坛、选课系统……任何数据略有规模的软件系统,都离不开数据库技术的支持。

        各种数据结构都体现了一种思想,即多花费存储空间,往往就能节约时间。因为各种数据结构,除了存储数据本身,还要存储许多辅助信息。例如用字典存储数据,比用列表存储数据查找速度快,但是代价就是字典比列表需要更多的存储空间。空间换时间,有时还体现在,把计算结果保存起来重复利用以避免重复的计算。

        本书中第6章例题 校门外的树(详情见【Python习题】章节)可以体现利用有序以提高解题效率的思想。那一章中的解题程序,复杂度是O(M×L)。如果将M个区域按照起点坐标从小到大排序后依次处理,则可以做到复杂度是O(M×log(M))。请自行阅读分析示例程序。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值