首页 > 代码库 > 【算法】 算法和数据结构绪论
【算法】 算法和数据结构绪论
算法和算法分析
先说点无关紧要的。初中的时候,知道有CS这门专门的学科存在的时候最开始的概念中CS就是等同于算法。这有可能是因为当时的前桌是后来一代CS传奇WJMZBMR。。因为当时看起来十分高端,再加上后来努力的方向完全和CS不搭边,所以对于算法二字一直心中抱着一种敬畏之情,觉得是整个CS中最干的干货部分。后来决定入这行之后,我的领导对我说算法这东西虽然很高大上,但是在日常工作中我们用的并不多(我们部门主要做运维和DevOps,确实对这方面的需求不大)所以也就一直耽搁着。但是随着深入,以及在网上和各种场合查阅到越来越多的资料中接触算法和一些算法术语越来越频繁。我觉得是时候学习一下了。所以我就买了一本北大一位先生著的数据结构与算法~Python语言描述~,一方面想了解一点算法和数据结构的知识,另一方面也可以学习一下Python。这本书不是很厚,作者也说了没有涉及很高深的知识,虽然我也不一定能学会学好,但是我想,努力一下试试看把,什么都不学总比在床上刷刷B站,玩玩游戏要好一点。
■ 问题,问题实例和算法
要搞清楚算法的一些概念,区分这三个概念十分重要。问题对应的是一种需求,对应一种需求,人们可以通过分析和推断来抽象出一个计算机需要解决的问题。问题具有通用的特点,比如判断一个正整数是否为素数这就是一个问题。问题实例通俗点来说就是一个具体的问题,它很明确的指出一个问题的很具体的描述,一般具有正确解。比如1013这个正整数是否为素数,相对于上面的那个问题,就是一个问题实例。显然,一个问题反映出所有相关问题实例的共性。算法是对解决问题过程的一个严格的描述。因为算法是和问题对应的,所以这个问题的所有实例都可以用算法来求得解。比如设计一个判断某个正整数是否为素数的算法A,这个算法A对应的是上面的问题,当然把这个算法A套用在问题实例中,我们就可以得出1013以及其他各种各样的正整数是不是素数了。
● 算法具有的性质
算法是一种对问题解决过程的具体描述,为了使其严格有效,算法通常具有以下性质
有穷性(算法描述的有穷性):算法应该可以用有限的语言,尤其是语言中有限的祈使(或者计算机语言中就是指令)进行描述。
可行性:算法中的的指令必须清晰明确,所描述的过程完全可以通过机器来机械地执行。
确定性:根据某个问题(通常也是以问题实例的形式给出,通过对问题实例的分析以及配合算法的测试来抽象出一个问题),算法将产生一个唯一确定的动作序列,任何一个相关问题的实例通过这个确定的动作序列之后,就可以得到相关解
终止性(算法行为的有穷性):对于任何实例,算法产生的动作序列都是有限的
输入/输出:算法有明确的输入和输出
● 算法的描述形式
算法可以用自然语言描述,这样对不了解计算机语言的人比较友好,但是通常比较啰嗦而且容易出现歧义
如果用计算机语言描述算法,可以做到很精确(因为最终算法就是以这种形式呈现到程序中的。)但是对于一般阅读者,即使是懂计算机语言的人而言阅读起来也需要一些力气,可以说对阅读者不是很友好
折中一下,用伪代码的形式描述。伪代码结合了计算机语言(通常用于逻辑结构的表示)和自然语言(对于具体的内容操作表示)。伪代码描述的形式结合了自然语言的表达友好和机器语言的简洁清晰。
■ 算法设计与分析
所谓算法设计,就是从一个问题出发,通过分析和思考来得到一个能够解决问题的算法。算法设计中有一些常见的设计模式:
枚举法:列举出问题所有可能的解并从中筛选出合适的解。这种方法可以说是利用了计算机强大的计算性能。电脑比人脑高明之处就在于它可以快速地重复大量相似或者相同的工作,以快速得到结果。
贪心发:根据问题的信息得到部分解,认为部分解可以作为解的一种或者把部分解逐步扩充得到完整解。在问题比较复杂时适用が,通常找到的解并不是最好的解
分治法:把问题分解成一个个小问题,然后逐步解决这些小问题,组合每个小问题的解得到整个问题的解
回溯法:当问题解决没有清晰的路径时,程序需要逐步试错,当发现一种方法走不通的时候就要回溯到之前的路径点来尝试新的路径
动态规划:当问题很难直接了当地求解,需要更多信息时,可以在解决问题的过程中逐渐积累信息,这些信息可以为后面问题解决过程所用,而后面的这些过程又可以进一步地积累到更多的信息
分支限界法:可以看做是回溯法的一种优化,在搜索过程中可能会得到一些没有用的信息,就把这些信息给删除以减小求解成本
以上算法设计模式,并不是严格推导出来的,而是前人在无数的实践中的一些总结,当然这些描述也是非常抽象的,光看下来可能没法知道任何有用的信息。但是需要记住的是,真正的算法设计过程通常需要综合考虑多种设计模式。
另,算法实现为一个程序之后就需要开始运算,而运算作为处理信息的一种过程肯定是要有运算消耗的。比如时间上的消耗和空间上的消耗。这种消耗除了跟算法有关,跟硬件情况,运行环境,实现方式(哪种语言)等等。在以上条件都相同的情况下,算法就确定一个程序的消耗。消耗越小的算法,其运行效率当然也就越高了。
当我们设计出一个算法之后,我们就需要分析这个算法是不是够高效。在有些情况下,因为计算机的高效的特性,算法是否高效可能显得不是那么有意义,但是在更多时候,很可能决定了算法有没有存在的价值。比如一个算法要花三天算出明天的天气预报和三小时算出明天的天气预报意义完全不一样。为了衡量算法是不是高效,我们还需要一种度量。
● 算法度量的单位和方法
在计算过程中,硬件每执行算法中的一个操作所带来的时间上和空间上的消耗都是不同的,而为了算法度量能够有一定通用性(比较不同操作算法的效率),在制定算法度量的时候就需要一定抽象,比如下面的两条假设:
1. 所用的计算设备准备了一组储存单元,每个单元都能保存固定的一点有限数据(以此标准化空间上的消耗)
2. 机器能够执行的一次基本操作都是消耗一个单位时间(以此标准化时间上的消耗)
假设中提到的储存单元的大小,以及单位时间的长短,可能根据硬件,环境等条件不同而不同,但是这不是算法度量需要考虑的。在算法比较中,通常默认是比较除了算法之外,其他条件都完全相同的两个程序的执行效率。所以可以借助上面两条假设把算法度量抽象化,标准化。
虽然算法是针对地解决问题的,但是机器不可能看得懂一个问题的描述,所以通常算法度量还是得以具体的问题实例来进行。这就带出一个概念,问题规模。比如解1013是否为素数和10331310131是否为素数这两个问题,显然两者可以套用同一套算法,但是两者的消耗完全不同。对于这样一种算法,到底算高效还是不高效,并不是通过一个问题实例的具体消耗能决定的。所以,算法的度量通常是一种计算资源消耗和问题规模相关的函数关系。如果问题规模很小,不论用哪种算法的消耗都差不多,且在可以接受的范围内,那么算法度量就显得不是那么有意义。而当问题规模越来越大时,如果计算消耗增长得越来越快,那么就可以说算法的效率不是太好,应该避免。问题规模在上面求素数那两个实例中,可以认为是数的大小,或者数的位数等等,一般来说只要有问题实例的一个统一的度量,具体这个度量是什么并不是很重要。总之能看出来哪些问题规模较大哪些较小即可。
另外还需要注意的一点,即使是规模相同的问题实例,在有些算法中消耗也是不同的。比如判断1013和1012是否是素数的话,比如在算法中最开始添加一个判断:如果是偶数就直接返回否,这样两者消耗就相差很多了。对于这种情况,其实对于规模相同的问题实例,我们通常关注的是最坏的情况下算法的消耗(有时候也会关注平均消耗),但是不太会关注比较乐观的情况下的消耗。
● 算法复杂度
算法复杂度就是一种算法的度量方法。如上所说,对于抽象的算法通常无法给出精确地度量,所以要做的是估计算法的复杂性,而算法复杂性量化一点说就是算法的消耗处在的量级(因为不论在何种外部条件下,算法度量中的单位时间和空间都是很小的,所以多一个少一个不是很有所谓)。在估算量级的过程中,常量因子可以认为没有什么价值,比如100n**2和3n**2都是n**2量级的(n是问题规模的描述)。这里借用了微积分中常用的无穷小的概念,并采取了无穷小的记法f(n) = O(g(n))。f(n)就是算法复杂度这个算法度量(一个消耗关于问题规模的函数),而g(n)是类似于n**2,logn,n,1(常量函数)的一个关于问题规模的n的函数。把g(n)记入大O表明算法复杂度f(n)随着n的增长,其增长速度受到g(n)的限制。两个算法,只要其g(n)相同,就可以认为两个算法的量级相同,就认为两者的复杂度基本一样。
常用的g(n)有1,logn,n,nlogn,n**2,n**3,2**n。这几个函数从前到后其增长速率逐渐变快。具有这些g(n)的算法复杂度也被称为常量复杂度,对数复杂度,平方复杂度,指数复杂度等等。假如一个算法A1是对数复杂度而A2是平方复杂度,通常而言同样规模的问题实例用A1算法进行运算的消耗要远小于用A2算法计算的消耗(当然这只是通常,上面也说了算法度量只是关注最坏情况,假如某个实例刚好是A2的乐观情况那么可能A2很快就能算出来了)
● 算法分析
算法分析就是通过一个已知的算法来得出其复杂度的过程。以考虑时间开销的时间复杂度为例,从算法层面看,一个普通的程序通常包含了基本操作,顺序结构,循环结构和选择结构这几种结构。
基本操作的复杂度通常认为是常量复杂度,比如赋值,四则运算,以及这些的组合都是基本操作。
顺序结构是指多个操作按顺序复合的情况。通常其复杂性是每一步操作复杂性的总和。
循环结构的复杂度是循环头的复杂度乘以循环体的复杂度。
选择结构的复杂度是各个选择子句中最大复杂度(这里又体现出考虑最坏情况)
比如这样一个Python程序:
#把n阶矩阵m1和m2的乘积存入矩阵mfor i in range(n): #O(n) for j in range(n): #O(n) x = 0.0 #O(1) for k in range(n): #O(n) x = x + m1[i][k] * m2[k][j] #O(1) m[i][j] = k #O(1)
其复杂度T(n)是:
T(n) = O(n)*O(n)*(O(1)+O(n)*O(1)+O(1))= O(n)*O(n)*O(n)= O(n**3)
可以看到python中的for i in range(n)这样的语句,因为是遍历一个长度为n的列表,其复杂度n个O(1)相加,即O(n)。循环头的O(n)再拿去乘以循环体的复杂度,嵌套循环则用括号在算式中也嵌套。在得到算式后面的化简过程遵循无穷小之间的运算规律。比如括号中只考虑阶最高的无穷小,相加的低阶无穷小被忽略了,相乘的无穷小则其参数互相相加。
最终可以得到这条算法的复杂度是立方复杂度。
■ Python的复杂度
上面所说的算法复杂度是泛泛而谈,具体到Python中又有一些特殊的情况。比如Python作为一门比较高级(相对底层而言)的语言,它已经提供了很多包装好了的“基本操作”。在用这些操作的时候,有时候我们会以为我们做的是一个基本操作但是实际上有可能是一个复杂度并非为O(1)的操作。下面是一些简单的说明,具体的分析留到后面具体的章节中
基本运算和赋值是基本操作,复杂度是O(1)
序列的复制和切片操作是O(n),跟序列的长度有关
list,tuple的元素访问、赋值和修改都是O(1)
构造一个空的对象是O(1),如果像是list,str这种类型构造时如果指定了长度为n的内容那么就是O(n)
dict加入新键值对最坏情况下是O(n)但是平均情况下的复杂度是O(1)
以上复杂度都是针对时间消耗而言。对于空间消耗需要注意的是
Python中对于各种组合元素(通常是指str,list,tuple,dict等python自带的高级一点的数据类型)都没有预设最大元素个数。但在实际使用中,从内存角度看元素个数长度只会增不会减。比如li = [1,2,3]之后li中确实是有3个元素的长度。如果li.append(4)之后就是4个长度。这很好理解。但是如果此时del(li[3])之后,虽然len(li)变成了3但是内存中的li对象依然保持4个元素 的长度,这是需要注意的
Python中的数据结构
■ 什么是数据结构
书上有很大一堆比较学术性的解释。在我的体验中,我认为所谓数据结构就是人为地规定一些数据格式来方便对问题的抽象和编程。从集合论来看,一般而言,一个数据结构D = (E,R)。其中E表示一个数量有穷的数据集合而R代表E中这些数据之间的某种关系。换言之,一个具体的数据结构就是要有具体的数据和这些数据之间的逻辑关系。
一些典型的数据结构有:
集合结构:其数据元素之间没有明确的关系指定,即R是一个空集,这样的数据结构就是把元素包装成一个整体,是最简单的一类数据结构
序列结构:数据元素之间有明确的先后关系,存在一个排位在最前的元素。除了最后的元素之外每个元素都有唯一的后元素。序列结构还可以细分成简单线性结构,环形结构和ρ型结构
层次结构:其数据元素分属于一些不同的层次,一个上层元素可以关联着一个或者多个下层元素,关系R形成一种层次性。
树形结构:属于层次结构的一种。
图结构:数据元素之间可以有任意的互相关联,其R十分复杂且灵活多变,是一类复杂的数据结构。其实前面所有的数据结构都可以认为是图结构的一种简化或限制的情况
根据数据结构的不同特点,还可以细分数据结构结构性数据结构和功能性数据结构。结构性数据结构(Python中如list,str,tuple)等,结构性数据结构指出的是一种有具体结构要求的数据结构。功能性数据结构没有结构上死的规定,可以看做是容器一样支持存放数据,然后利用其特性进行一些运算,功能性数据结构的例子有栈,队列,优先队列等等。
■ 内存单元和地址
(不知道为什么这部分内容要放在数据结构中。。)
内存的基本结构是一批线性排列的数据单元,每个单元有唯一的编号被称为单元地址,对内存中的数据进行访问必须要知道相关单元的地址。在许多计算机中,可以一次性存取多个单元的内容,在现在常见的64位计算机中,CPU一次可以存取8个字节的数据,也就是说可以一次性访问8个数据单元。
正如上面提到过的大多组合数据类型存取值是一个O(1)的操作,这也就说明了,基于单元地址的对内存中一个存储单元的访问是一个O(1)的操作,这和单元所在位置,内存整体大小等无关。
■ Python对象和数据结构
● python中的变量和对象
对于初学Python的人来说这两个概念经常容易搞混,其实Python在数据存储的本质上来说和C,Java等语言是不同的。在Python中,给变量约束一个值看似和C中差不多,但是实际上,python首先把这个值构造成一个对象存储在内存中,然后把这个内存中对象的地址约束给相应的变量。所以在Python中,我们不需要指出某个变量的类型和它应该有的长度,因为无论是什么变量,都存的是一个地址,所有变量所需要的空间大小是一样的。而那个地址指向的内存储存单元(或者以该单元为开始的一片内存空间中)储存的才是真的数据。这种变量的实现方式被称为变量的引用语义。而像C一样把值直接存在变量的储存区的做法被称为变量的值语义。
在Python中,通过变量来取得一些具体数据的操作也是O(1)的所以这方面的消耗并不比低级语言大很多。
● Python中对象的表示
表示是指为了让电脑能够更好的理解逻辑数据的构造的数据结构。Python中的对象的表示其实是已经设计完成,不需要我们太多关心的,但是了解一下有利于我们更好地进行工作。
Python语言的实现基于一套精心设计的链接结构,变量与其值对象的关系通过链接的方式实现,对象之间的联系同样也通过链接。一个复杂的对象内部也可能包含了几个子部分。相互之间通过链接建立联系,例如一个list中包含了10个字符串的话,在实现中,这个list在内存中其实保存了这10个字符串各自的链接关系。
Python中的组合对象可以是任意大规模的,每个对象需要的储存单元数量不同,还可以有内部的复杂结构。对于这样一种复杂的情况,要有效地安排,管理内存是比较麻烦的。不过好在Python自带了一套存储管理系统,负责管理可用内存,释放不再使用的内存,安排各种对象的存储以实现灵活有效的内存管理。程序中要求建立对象时,管理系统会为它安排存储;当某些对象不再使用时,就回收其占有的内存。存储管理系统屏蔽了具体内存使用的细节,减轻了编程人员的负担。
【算法】 算法和数据结构绪论