阅读目录
- 一、计算机求解问题
- 二、问题求解:交叉路口的红绿灯安排
- 三、算法和算法分析
- 四、数据结构
一、计算机求解问题
使用计算机解决问题的两个阶段:
- 开发阶段(只做一次)
- 使用阶段(多次使用)
1、程序开发过程
工作流程
- 分析阶段。将问题严格化。
- 设计阶段。组织好数据模型,以及设计好需要解决问题的算法。写出程序的伪代码。
- 编码阶段。使用适当的编程语言实现设计出来的模型。
- 检测测试阶段。检测员错误和类型错误,最后得到一个可以运行的程序。
- 测试调试阶段。修改程序中的逻辑错误,确定实现功能是否满足其需要。
其中设计阶段是最困难的。需要算法设计和数据模型设计。
2、一个简单的例子
牛顿迭代法实现平方根
def sqrt(x): y = 1.0 while abs(y*y - x) > 1e-6: y = (y+x/y)/2 return y
二、问题求解:交叉路口的红绿灯安排
通过一个实例问题观察解决问题的方法与步骤。
问题:只考虑红绿灯如何安排。才能保证相应的允许行使的路线互不交错,从而保证路口交通安全。
现在基本的思想是对行使方向分组,这个分组满足两个条件:
- 同属一组的各个方向行使的车辆可以同时行使(安全问题,必须满足)。
-
所做的分组尽可能大些,提高路口的效率(经济问题,尽量满足)。
1、问题分析和严格化
罗列出所有的形式方向:AB、AC、AD、BA、BC、BD、DA、DB、DC、EA、EB、EC、ED。总共13条可行的方向。
为了表示冲突,设计一种表示冲突的方式--冲突图(上图)。这样的图构成了一种数据结构。这样问题就变成了为冲突图
中的顶点确定一种分组,保证属于同一组的所有顶点互不邻接。
2、图的顶点分组和算法
这里采用贪心算法来完成这个算法设计。
贪心算法的基本思想是根据当时掌握的信息,尽可能地向得到解的方向前进,直到不能继续再更换一个方向。
伪代码实现: 输入:图G #记录图中的顶点连接关系 集合verts保存G中的所有顶点 #建立初始状态 设置集合groups为空集 #记录得到的分组,元素是顶点集合 while 存在未着色的顶点: 选一种颜色 在未着色顶点中尽连多的无连边的点着色(构建一个分组) 记录新分组的顶点组 # 算法结束时,groups里记录着一种分组方式 # 算法细节还需要进一步考虑 # 进一步整理着色问题。 new_group = 空集 for v in verts: if v与new_group中所有的顶点之间都没有边: 从verts中去掉v 把v加入new_group # 循环结束时,new_group中是可以用一种颜色着色的顶点集合。 # 用这段代码代替前面程序框架中主循环体里的一部分。
3、算法的优化和python实现
上面的伪代码其实已经将程序的思路表达的很清晰了,那么相关的python实现就不难了。
def coloring(G): #做图G的着色 color = 0 groups = set() verts = vertices(G) #取得G图的所有顶点,依赖于图的表示 while verts: new_group = set() for v in list(verts): if not_adjacent_with_set(v, newgroup, G): new_group.add(v) verts.remove(v) group.add((color, new_group)) color += 1 return groups
4、讨论
完成了工作并不是结束,任何时候完成了一个算法或者程序,都应该回头再去仔细检查做出的结果,考虑有没有问题。
在上面的程序存在几个问题:
1>、解唯一吗?
上面的程序得到的解,只是分组的一个解。除此之外完全可以找到另外一种实例解2
2>、求解结果是原来要解决的问题吗?
上面代码得到的结果是一条线路只能在一个分组中。但其实,实际情况往往不是这样,比如无害的右转弯,每个分组都可以存在。
3>、对冲突的定义
上面对冲突的定义是两个路线不交叉就可以了,但现实情况中有时往往会有特殊的需求,再比如右转弯,这里定义的是只要是右转弯就不会有冲突。
三、算法和算法分析
1、问题、问题实例和算法
概念
问题是解决一个问题的具体需求。如:一个正整数是否是素数。
问题实例是对于问题的一个具体例子。如:1013是否是素数。
算法是对一种计算过程的严格描述。如:一个判断素数的算法应该不仅能给出1013是否为素数的判断,也能判断其他正整数是否为素数。
算法的性质
- 有穷性
- 能行性
- 确定性
- 终止性
- 输入/输出
算法的描述
- 自然语言描述。通俗易懂,但是有歧义。
- 自然语言结合数学公式。
- 严格定义法描述。比如,图灵机模型。没有歧义,但是极难阅读。
算法和程序
程序是算法的实现
算法设计与分析
算法的设计模式:
- 枚举法。枚举全部,直到找出最优解。
- 贪心法。先部分后全部,得出完整解。
- 分治法。先分别求解,再组合起来。
- 回溯法。先找一个方向,如果不行,下一个,直到得出解。
- 动态规划法。不断积累信息,以供后面步骤求解使用。
- 分支限界法。回溯法的改良。
这几种算法设计模式可以共用,但是不能教条。算法分析的主要任务就是弄清楚算法的资源消耗。
2、算法的代价及其度量
研究算法,先考虑其代价,包括需要多少存储空间,以及求解需要多长时间。
度量的单位和方法
计算的实际代价通常与实例的规模有关,因此人们提出一个度量算法的方法就是把一个算法的计算开销定义为问题实例规模的函数。
这个函数描述的是一种增长趋势,在规模n趋向于无穷的过程中,算法的代价变动快慢的关系。
与实例规模有关的两个问题
问题1:
算法代价除了跟实例规模的大小有关系之外。还跟实例规模的描述有关系,对实例规模的描述尺度不同,其算法代价的结论就不同。
例:判断素数问题。有两种描述方法,一种是整数的数值,一种是字符串的长度。这两种不同的方法会造成对算法代价统计的不同差异。
问题2:
即是实例规模相同,算法代价也可能不同。
例:判断素数问题。同样的100个十进制的数,如果是偶数的话只需要检查一次,但如果是奇数,就需要花费更多的时间。
因此,对于算法代价的不同差异,人们在处理算法的研究和分析中,最主要关注的是最坏的情况(也就是最长需要多长时间,或者需要多大的空间),有时也关注算法的平均代价。但是基本不考虑最好的情况。
常量因子和算法复杂度
因为对算法代价的描述有很多影响因素,因此对于给出实例规模的精准函数通常十分困难,因此对于算法的代价通常采用估算的方法。
这里给出大"O"记法。
比较常用的就是O(1)、O(logn)、O(n)、O(nlogn)、O(n2)、O(n3)、O(2n)。
也常把O(1)称为常量复杂度,把O(logn)称为对数复杂度,把O(n)称为线性复杂度,把O(n2)称为平方复杂度,把O(2n)称为指数复杂度。
从上面的图可以看出,一个好的算法完全可以让计算机解决问题的过程具有有更快的速度,从而让许多应用具有实际意义。例如:天气预报,如果一个关于预报明天的天气的算法却需要计算3天,那么这将毫无意义。
此外,算法的复杂度也决定了算法的可用性,如果复杂度较低,算法就可能用于解决很大的实例,反之,只能用于很小的实例。可用性很有限。
解决同一问题的不同算法例子
斐波那契数列的实现
def fib(n): if n = 1 return 1 return fib(n-1) + fib(n-2)
def fib(n): a, b = 1, 1 for i in range(n): a, b = b, a+b return b
这里递归实现算法复杂度O(2n),因为涉及到很多的重复计算。在后面的动态规划法中会详细研究这个问题。还有递归自然栈帧的开销等等。因此,它会非常慢。
而使用循环实现,循环只需要n-1次,其余操作都是常量操作。因此它的复杂度是O(n)。可以看到两种不同实现的差异。
3、算法分析
算法分析的最主要的目的是推出算法的复杂度。
基本循环程序的规则
- 基本操作。复杂度为O(1)。
- 加法规则(顺序复合,和分支结构),求最大值。
- 乘法规则(循环结构),求乘积。
例子:
for i in range(n): for j in range(n): x = 0.0 for k in range(n): x = x + m1[i][k] * m2[k][j] m[j][k] = x
计算上述算法复杂度。
O(n) *O(n)*(O(1)+(O(n) +O(1))+O(1))
= O(n3)
4、python程序的计算代价(复杂度)
时间开销
- 算术运算,逻辑运算是常量操作
- 组合对象有些是常量操作,有些不是。复制和切片通常要线性时间,list和tuple元素访问和元素赋值是常量时间。dict元素访问一般是常量时间,最坏达到线性,跟他的负载因子有关。
- 创建对象也需要付出空间和时间。通常看做是线性时间和线性空间操作。
- 构造新结构,如list,set等。空结构是需要常量时间操作的,而构造一个包含n个元素的结构需要O(n)时间。
- list的操作效率。元素访问和元素修改是常量时间。一般加入和删除是O(n)时间操作。
- dict的操作效率。加入元素和元素访问,最坏复杂度是O(n),平均复杂度是O(1)。
空间开销
任何对象,都需要付出空间的代价。这里,python需要注意两点:
- 在组合数据结构中,即使元素删除之后,元素的内存并没有被释放。
- python的垃圾回收机制。
注意:python提供了很多的高级结构,如果不注意,很容易写出一些生成很多不必要的数据对象的代码。
python程序的时间复杂度实例
def test(n): lst = [] for i in range(n*10000): lst = lst + [i] return lst def test2(n): lst = [] for i in range(n*10000): lst.append(i) return lst def test3(n): return [i for i in range(n*10000)] def test4(n): return list(range(n*10000))
程序时间和效率陷阱
一个好的算法的好的实现才能发挥这个算法的优势,比如上面的test实例,同样都是枚举,但是方法1构造了大量的不必要的list对象。
那么,算法的实现和原算法复杂度之间有什么关系呢?答案是,算法的是实现应该跟原算法是一样的时间复杂度,但是如果做的不好,就会使程序的性能变得更差。
因此,在考虑程序的开发时,不仅仅要选择好的算法,还要考虑如何做出好的算法实现。
四、数据结构
计算机解决问题的结果就是数据的输入和输出。因此,怎么样组织好数据的表示就很重要,尤其是处理的信息越复杂,数据的良好组织就越重要。
1、数据结构及其分类
信息、数据、数据结构
数据就是经过编码的信息。
数据结构是数据之间的关联和组合的形式。
抽象定义与重要类别
一个数据结构包含一集数据元素和一个表示元素之间的某种关系。用一个二元组表示D = (E,R),其中E是数据机构D的元素集合,R是表示D元素之间的关系。
典型的数据结构有以下几类:
- 集合结构。 元素之间没有明确的关系。也就是说R是空集。
- 序列结构。元素之间是先后关系。例如;list,str,tuple等线性结构。
- 层次结构。元素属于一些不同层次,上层元素关联下次多个或者一个元素。
- 树形结构。层次结构的一个特例。
- 图结构。数据元素之间可以有任意复杂的相互联系。
算法和程序中的数据结构
数据结构依赖于算法的实现,例如图结构,就有很多算法实现。
用编程语言实现数据结构有两层:
- 由编程语言设计人员完成数据结构的设计
- 将编程语言的实现通过编译器映射到计算机存储器中。
结构性和功能性的数据结构
结构性数据结构:线性结构,树结构和图结构等。这些结构对数据元素之间的关系做了一些规定。
功能性结构:栈、队列、优先队列、字典等。满足元素的存储和访问等功能上需求。
2、计算机内存的表示
内存单元和地址
内存的基本结构是线性排列的一批存储单元。对内存单元的访问都通过单元的地址进行。因此,要访问一个单元,必须先掌握其地址。
对象存储和管理
一个程序在运行中将不断建立一些对象并使用它们,建立的每个对象都有一个确定的唯一标识,用于识别和使用这个对象。最简单的方式就是使用对象的内存地址作为这个唯一标识。
对象的访问
知道一个对象的唯一标识之后,就可以在O(1)时间访问到它。
如果这个对象是组合对象。分两种情况:
- 一种是元素大小相同,可以直接根据公式loc(k) = p+k*a。其中p是对象元素起始地址,k是下标,a是每个元素占用的内存大小。
- 另外一种是元素大小不同。这样可是实现算各个元素在存储区中的相对位置,也就是存储偏移量。再根据公式算出。
这两种方式都可以在O(1)时间内完成。
对象关联的表示
- 隐式表示。内存是单元的线性序列,知道了前一个元素的位置和大小,自然就能得出后一个元素的位置。线性结构关系的可以使用这种方式
- 显式表示。将数据元素之间的关系,显示保存在内存中。这种方式可以表示任意复杂结构。
第一种方式也称为元素的顺序表示。例如:字符串。但是这里有个问题,如何判断字符串的结束。因为存储在内存里的都是二进制编码。python是用一个确定的大小记录该字符串的实际长度。而c语言是在字符串结尾插入一个空字符。
第二种方式的代表是链接结构。例如:要用一种对象表示两个成分。作者和书名。这两个成员的长度显然不一样,一种表示方式是将两个成员放到一块内存中,然后用分隔符隔开。但是这样会影响效率,并且表示不便。因此,下图的方式表示比较合理,它是用一个二元组分别存放数据和作者的引用信息的。这中方式就是链接结构。
连续结构和连接结构是所有数据结构的基础。
3、python对象和数据结构
python变量和对象
高级语言中的变量是内存及其地址的抽象。
给变量约束一个对象,就是将该对象的内存地址存入到该变量。从变量出发访问其值是常量操作。
python语言中实现变量的方式成为变量的引用语义,在变量里保存的是值的引用。采用这种方式,变量所需的存储空间大小是一致的,因为其中只需要保存一个引用。
但有些语言并不是这样的,他们把变量的值直接保存在变量的存储区内,称为值语义。C语言就是这样的。
python对象的表示
python语言的实现是基于一套精心设计的链接结构。
python的几个标准数据类型
list、tuple、dict。等