数据结构 (python语言描述)
- 第1章 多项集(collection)的概述
- 1.1 多项集类型
- 1.1.1 线性多项集
- 1.1.2 分层多项集
- 1.1.3 图多项集
- 1.1.4 无序多项集
- 1.1.5 有序多项集
- 1.1.6 多项集类型的分类
- 1.2 多项集操作
- 1.2.1 所有多项集类型中的基本操作
- 1.2.2 类型转换
- 1.2.3 克隆和相等性
- 1.3 迭代器和高阶函数
- 1.4 多项集的实现
- 1.5 章节总结
- 第2章 查找、排序以及复杂度分析
- 2.1 衡量算法的效率
- 2.1.1 衡量算法的运行时
- 2.1.2 统计指令数
- 2.1.3 衡量算法使用的内存
- 2.2 复杂度分析
- 2.2.1 复杂度的阶
- 2.2.2 大O表示法
- 2.2.3 比例常数
- 2.3 查找算法
- 2.3.1 最小值查找
- 2.3.2 顺序搜索列表
- 2.3.3 最好情况、最坏情况以及平均情况下的性能
- 2.3.4 基于有序列表的二分查找
- 2.4.1 选择排序
第1章 多项集(collection)的概述
学习目标:
在完成本章的学习之后,能够:
● 定义多项集的4个通用类型,即线性多项集、分层多项集、图多项集以及无序多项集;
● 了解4个多项集类型中的特定类型;
● 了解这些多项集适合用在什么类型的应用程序里;
● 描述每种多项集类型的常用操作;
● 描述多项集的抽象类型和实现之间的区别。
定义:
多项集(collection)是指由 0 个或者多个元素组成的概念单元,也被 称为抽象数据类型(Abstract Data Type,ADT)。
基本用途:
1.帮助程序员有效地组织程序里的数据;
2.帮助程序员对现实世界里对象的结构和行为进行建模。
从两个角度看待多项集:
1.用户或者客户关心多项集在不同的应用程序里能做些什么;
2.开发者或者实现者关心如何才能让多项集成为最好的通用资源以被使用。
1.1 多项集类型
Python包括几种内置的多项集类型:字符串、列表、元组、集合以及字典。多项集的一些其他重要类型包括栈、队列、优先队列、二叉查找树、堆、图、包以及各种类型的有序多项集。
多项集可以是同质的,即多项集里的所有元素必须是同一类型的;也可以是异构的,即里面的元素可以是不同类型的。
多项集通常来说不是静态(static)的,而是动态(dynamic)的,这就意味着它们可以根据需要来扩大或者缩小。
可变多项集里的内容可以在程序的运行中被改变。不可变多项集(immutable collection)例外,比如Python里的字符串或是元组。
按照构成方式广泛使用的多项集类别有:线性多项集、分层多项集、图多项集、无序多项集以及有序多项集。
1.1.1 线性多项集
线性多项集(linear collection)里的元素——就像人们排队那样——按照位置进行排列。除了第一个元素,其他每个元素都有且只有一个前序;除了最后一个元素,其他每个元素都有且只有一个后序。如图1-1所示,D2的前序是D1,D2的后序是D3。 python中的数组、字符串、列表等。
图1-1 线性多项集
1.1.2 分层多项集
分层多项集(hierarchical collection)里的数据元素以类似于树结构进行排列。除了顶部的数据元素,其他每个数据元素都有且只有一个前序[父元素(parent)],但它们可以有许多的后序[子元素(children)]。如图1-2所示,D3的前序(父元素)是D1,D3的后序(子元素)是D4、D5和D6。如 python中的二叉树。
图1-2 分层多项集
1.1.3 图多项集
图多项集(graph collection)也被称为图(graph),它的每一个数据元素都可以有多个前序和多个后序。如图1-3所示,连接到D3的所有元素会被当作它的前序和后序,它们也因此被称为D3的邻居。(图,顶点和边)
图1-3 图多项集
1.1.4 无序多项集
无序多项集(unordered collection)里的元素没有特定的顺序,并且不会用任何明确的方式来指出元素的前序或者后序,如图1-4所示。
图1-4 无序多项集
1.1.5 有序多项集
有序多项集(sorted collection),里面的数据元素经过进行自然排序(natural ordering)。
有序多项集能够让客户端按照排序之后的顺序来访问其中的所有元素。对于某些操作(如查找)来说,在有序多项集里的效率会比在无序多项集里更高效。
1.1.6 多项集类型的分类
在了解了多项集的主要类别之后,现在可以把不同的常用多项集类型进行分类,如图1-5所示。
图1-5 多项集类型的分类
需要注意的是,这个分类里的类型名称并不是多项集的特定实现,相反,一种特定类型的多项集可以有多个实现。
1.2 多项集操作
对多项集进行的操作基于所使用的多项集类型的不同而有所不同。但通常来说,可以分为表1-1所示的几种。
表1-1 多项集操作的类型
1.2.1 所有多项集类型中的基本操作
在Python里,不同多项集类型的插入、删除、替换或者访问操作并没有统一的名称,但是会有一些标准变体。比如,方法pop()会被用来从列表里移除指定位置的元素,或者从字典里移除给定键所对应的值。方法remov()e会被用来从多项集或列表里删除指定的元素。对于新开发出的、Python尚不支持的多项集类型,应当尽可能地使用标准的运算符、函数以及方法名称对它们进行操作。
1.2.2 类型转换
可以将一种类型的多项集转换为另一种类型的多项集。比如,把Python的字符串转换为列表,然后再把列表转换为元组,如下面的代码所示:
message = "Hello world!" #被转换的字符串
lyst = list(message)
toople = tuple(lyst)
list()或tuple()函数的参数不一定是另一个多项集,也可以是任何的可迭代对象(iterable object)(此处可迭代对象是指能够使用for循环来访问遍历的一系列元素)。
1.2.3 克隆和相等性
类型转换的一种特殊情况是克隆,它的功能是返回转换函数中参数的完整副本。在这种情况下,参数的类型和转换函数是相同的。比如,下面这段代码将复制一个列表,然后使用is和== 运算符来比较这两个列表。两个列表不是同一个对象,因此is会返回False。虽然这两个列表是不同的对象,但因为它们具有相同的类型和相同的结构(每对元素在两个列表里的位置都相同),所以 ==返回True。
lst1 = [2, 4, 8]
lst2 = list(lst1)
lst1 is lst2 #False
lst1 == lst2 #True
1.3 迭代器和高阶函数
每种类型的多项集都支持一个迭代器或for循环,这个操作能够迭代这个多项集的所有元素。
迭代器(支持for循环)是多项集提供的最关键,也是最常用的操作。
迭代器或者说for循环还支持使用高阶函数map()、filter()和reduce()。每一个这样的高阶函数都使用另一个函数和一个多项集作为参数。同样地,多项集都支持for循环,因此map、filter和reduce函数可以与任何类型的多项集一起使用,而不仅仅是支持列表类型。
1.4 多项集的实现
使用多项集的程序员来说,多项集是一种以某种预定行为来存储和访问数据元素的方式,需要知道如何实例化和使用每一种多项集,而并不需要关心多项集实现的细节。
换句话说,从用户的角度来看,多项集是一种抽象。因此,多项集是抽象数据类型(Abstract Data Type,ADT)。抽象数据类型的用户只关注它的接口以及这个类型对象所提供的一组操作。
多项集的开发人员为了能够向多项集的用户提供最佳性能,会关心如何以最有效的方式来实现多项集的各项行为。
抽象概念不论在计算机科学还是其他学科,都是一项重要的原则。抽象用来忽略或隐藏一些不重要的细节,以简化问题便于解决。
在Python里,函数和方法是最小的抽象单元,类次之,模块是最大的抽象单元。
我们把多项集类型的抽象实现当作类级别来加以描述。
1.5 章节总结
● 多项集是包含0或多个其他对象的对象。对多项集可以进行访问对象、插入对象、删除对象、确定大小,以及遍历等操作。
● 多项集5个主要类型是:线性多项集、分层多项集、图多项集、无序多项集和有序多项集。
● 线性多项集是按照位置对元素进行排序,其中除第一个元素,每个都有且只有一个前序;除最后一个元素,每个都有且只有一个后序。
● 分层多项集,除了一个根元素,其他所有元素都有且只有一个前序以及0个或多个后序。根元素没有前序。
● 图多项集里的元素可以有0个或多个前序以及0个或多个后序。
● 无序多项集里的元素没有特定的顺序。
● 多项集是可迭代的,可以使用for循环遍历多项集所有元素;可以使用高阶函数map()、filte()r和reduce()简化多项集的数据处理。
● 抽象数据类型是一组对象和对这些对象的操作。因此,多项集是抽象数据类型。
第2章 查找、排序以及复杂度分析
学习目标:
在完成本章的学习之后,能够:
● 根据问题的规模确定算法工作量的增长率;
● 使用大O表示法来描述算法的运行时和内存使用情况;
● 认识常见的工作量增长率或复杂度的类别(常数、对数、线性、平方和指数);
● 描述顺序查找和二分查找的工作方式;
● 描述选择排序算法和快速排序算法的工作方式。
程序 = 算法+数据结构+编程语言
算法是一种解决问题的方法和思想,它描述了一个随着问题被解决而停止的计算过程。
算法执行过程中会消耗两个资源:处理对象所需的时间和存储数据所需的空间(也就是内存)。
对于算法来说,尽可能地追求消耗更短的时间和占用更少的空间。
2.1 衡量算法的效率
2.1.1 衡量算法的运行时
衡量算法时间成本的一种方法是:用计算机时钟得到算法实际的运行时。这个过程被称为基准测试(benchmarking)或性能分析(profiling)。
编制测试程序如下:
import time
problem_size = 10000000
print("%12s%16s" % ("问题规模", "用时(S)"))
for count in range(5):
start_time = time.time()
# The start of the algorithm
work = 1
for x in range(problem_size):
work += 1
work -= 1
# The end of the algorithm
elapsed = time.time() - start_time
print("%12d%16.3f" % (problem_size, elapsed))
problem_size *= 2
测试程序的输出结果如下:
图2-1 测试程序的输出结果
从结果中可以很轻易地看出:当问题规模翻倍时,运行时也差不多翻了一番。
再举一个例子,把测试程序算法语句改成下面这样:
for x in range(problem_size):
for x in range(problem_size):
work += 1
work -= 1
两个赋值语句被放在了嵌套循环里。用problem_size = 1000测试显示:当问题规模翻倍时,运行时差不多翻了两番。按照这种增长速度,若要处理先前那个最大的数据集,大概需要175天!
这种方法可以准确地预测很多算法的运行时,但存在两个主要问题:
●算法的运行时会因各机器软硬件及工作环境的不同而存在差异。
● 用非常大的数据集确定算法的运行时是非常不切实际的。
2.1.2 统计指令数
另一种计量算法时间成本的方法是:在不同问题规模下,统计需要执行的指令数。这样无论在什么平台上运行算法,都能够很好地预测算法执行的抽象工作量。
通过这种方式对算法进行分析时,可以把它分成两个部分:
● 无论问题的规模如何变化,指令执行的次数总是相同的;
● 执行的指令数随着问题规模的变化而变化。
修改前面的测试程序,并跟踪显示内部循环迭代次数:
problem_size = 1000
print("%12s%15s" % ("问题规模", "迭代次数"))
for count in range(5):
number = 0
work = 1
for j in range(problem_size):
for k in range(problem_size):
number += 1
work += 1
work -= 1
print("%12d%15d" % (problem_size, number))
problem_size *= 2
可见,随着问题规模的翻倍,指令数(递归调用的次数)在一开始的时候缓慢增长,随后迅速加快。
以统计执行指令数的方式进行跟踪计数问题在于,对于某些算法来说,计算机仍然无法以足够快的运行速度在一定时间内得到非常大的问题规模的结果。
2.1.3 衡量算法使用的内存
对于算法所用资源的完整分析需要包含它所需的内存量。同样地,要会关注它潜在的增长率,在后续内容会对其中的几种算法进行探讨。
2.2 复杂度分析
学习一种不用关心与平台相关的时间,也不必使用统计指令数量来评估算法效率的方法。
复杂度分析(complexity analysis)方法,所需要的就是阅读算法,进行一些简单的代数计算。
2.2.1 复杂度的阶
考虑前面讨论过的两个循环计数算法。对于问题规模为n的情况,第一个循环算法会执行n次;第二个循环算法包含一个迭代n^2次的嵌套循环。在n还比较小的时候,这两种算法完成的工作量是差不多的,但是随着n的逐渐增大,它们完成的工作量也越来越不同。图2-2和表2-1展示了这种差异。
图2-2 测试程序所完成工作量的示例图
第一个算法的工作量与问题规模成正比(问题规模为10则工作量为10;问题规模为20则工作量为20;以此类推),其复杂度的阶是线性的(linear)。
第二种算法的工作量随问题规模的平方(问题规模为10时,工作量为100)而增长,其复杂度的阶是平方(quadratic)的。
可见,这两个算法的性能在复杂度的阶(order of complexity)上是不一样的,随着问题规模的增大,具有较高复杂度阶的算法性能会更快地变差。
表2-1 测试程序所完成的工作量
在算法分析中通常还有其他几个复杂度的阶:常数(constant)阶、对数(logarithmic)阶、指数(exponential)阶。
图2-3和表2-2总结了算法分析中常见的复杂度的阶。
图2-3 常见复杂度的阶示例图
表2-2 常见复杂度的阶
2.2.2 大O表示法
在算法中用来表示算法的效率或计算复杂度的一种方法被称为大O表示法(big-O notation)。在这里,“O”代表“在……阶”,指的是算法工作的复杂度的阶。因此,线性算法复杂度记为O(n)。
2.2.3 比例常数
比例常数(constant of proportionality)包含在大O分析中被忽略的项和系数。比如,线性时间算法所执行的指令工作量:work = 2 *size,则比例常数就是work/size,也就是2。
现在,看看下面算法的代码比例常数:
work = 1
for x in range(n):
work += 1
work -= 1
这个算法执行的抽象工作量就是1+2n。尽管它会大于n,但这其工作量2n的运行时会以线性速率增加,也就是说,它的比例常数1、2,运行时是O(n)。
2.3 查找算法
2.3.1 最小值查找
先看下面代码:
def indexOfMin(lsta):
"""查找最小值的下标."""
min_index = 0
current_index = 1
while current_index < len(lsta):
if lsta[current_index] < lsta[min_index]:
min_index = current_index
current_index += 1
return min_index
要保证算法能够找到最小元素的位置,就必须要访问列表里的每个元素,这个工作是在while循环内的if语句比较后完成的。因此,这个算法是对大小为n的列表进行n-1次比较,也就是说,它的复杂度为O(n)。
2.3.2 顺序搜索列表
在任意的元素列表里,从位于第一个位置的元素开始,按顺序查找特定的目标元素,直到最后一个位置,这种搜索称为顺序搜索(sequential search)或线性搜索(linear search)。在找到目标元素时返回元素的索引,否则返回−1。
下面是顺序搜索函数的Python实现:
def sequentialSearch(target,lsty):
position = 0
while position < len(lsty):
if target == lsty[position]:
return position
position += 1
return -1
lsa = [3,5,7,0,3,1,9,6]
print(sequentialSearch(0,lsa))
要注意比较顺序搜索的分析和最小值搜索的有哪些不同。
2.3.3 最好情况、最坏情况以及平均情况下的性能
有些算法的性能取决于需要处理的数据所在列表中的位置。比如,对顺序搜索的分析需要考虑下面3种情况:
● 在最坏情况下,目标元素位于列表的末尾或者根本就不在列表里。所以,最坏情况的复杂度为O(n)。
● 在最好情况下,在第一个位置就找到目标元素,只需要O(1)的复杂度。
● 要确定平均情况,就需要把每个可能位置找到目标所需要的迭代次数相加,然后再将它们的总和除以n。因此,平均情况的复杂度是O(n)。
2.3.4 基于有序列表的二分查找
在数据有序的情况下,可以使用二分查找。先看代码:
def binarySearch(target,lsty):
left = 0
right = len(lsty) -1
while left <= right:
midpoint = (left + right) // 2
if target == lsty[midpoint]:
return midpoint
elif target < lsty[midpoint]:
right = midpoint - 1
else:
left = midpoint + 1
return -1
lsa = [0,1,3,5,6,7,9,10,12]
print(binarySearch(7,lsa))
在开始查找之前,要确保列表里的元素是有序的(此处为升序排序):
● 首先查找列表的中间位置上的元素,并把中间位置元素与目标元素进行比较,如果匹配,那么就返回当前位置。
● 如果目标元素小于当前元素,则在中间位置之前部分的中间位置继续查找;
● 如果目标元素大于当前元素,则在中间位置之后部分的中间位置继续查找。
●在找到了目标元素或者当前开始位置大于当前结束位置时,停止查找过程。
算法里只有一个循环,并且没有嵌套或隐藏的循环,如果目标不在列表里,就会得到最坏情况。最坏情况下的复杂度为O()
图2-4展示了在包含9个元素的列表里,通过二分算法查找并不在列表里的目标元素10时,对列表进行的分解。(可以看到,原始列表左半部分中的任何元素都不会被访问。)
图2-4 二分搜索10时所访问的列表元素
对目标元素10的二分查找,对于包含9个元素的列表来说,最多需要4次比较,而对于包含1000000个元素的列表最多需要20次比较就能完成查找。
当然,为了让列表能够有序,二分查找需要付出额外的排序成本。
二分查找和最小值查找都有一个假设,那就是“列表里的元素彼此之间是可以比较的”。在Python里,这也意味着这些元素属于同一个类型,并且它们可以识别比较运算符==、<和>。几种Python内置的类型对象,如数字、字符串和列表,均支持使用这些运算符进行比较。
为了能够让算法对新的类对象使用比较运算符==、<和>,程序员应在这个新的类里定义__eq__、__lt__和__gt__方法。在定义了这些方法之后,其他比较运算符的方法将自动生成。
比如,SavingsAccount对象可能包含3个数据字段:名称、PIN(密码)以及余额。假定这个账户对象应该按照名称的字母顺序对它进行排序,那么就需要按照下面的方式来实现__lt__方法。代码如下:
class SavingsAccount:
def __init__(self, name, pin, balance = 0.0):
self.name = name
self.pin = pin
self.balance = balance
def __lt__(self, other):
return self.name < other.name
# Other methods, including __eq__
s1 = SavingsAccount("Ken", "1001", 0)
s2 = SavingsAccount("Bill", "1001", 30)
print(s1 < s2)
这样,就可以把账户放在列表中,并按照名称对它进行排序了。
2.4 基本的排序算法
Python排序函数都将被编写为可以在整数列表上运行,并且都会用swap函数交换列表中两个元素的位置。该函数的代码如下:
def swap(lsty, i, j):
temp = lsty[i]
lsty[i] = lsty[j]
lsty[j] = temp
2.4.1 选择排序
选择排序(selection sort)策略是:
●首先,在未排序列表中查找到最小的元素,如果它不在第一个位置,那么将它和第一个位置上的元素交换,第一个位置就是有序的了;
●接下来,从未排序的第二个位置开始向后重复上述查找过程,找到后面最小的元素并与第二个位置上的元素进行交换;
●继续重复以上步骤,当从列表最后位置开始和结束的时候(可以抽象理解为:最后位置上的元素与自已交换位置),这个列表就已经是有序的了。
代码如下:
def selectionSort(lsty):
for i in range(len(lsty)-1):
mid_indx = i
for j in range(i,len(lsty)):
if lsty[mid_indx] > lsty[j]:
mid_indx = j
lsty[i],lsty[mid_indx] = lsty[mid_indx],lsty[i]
print(lsty)
nums = [5,3,6,7,4,1,2,9]
selectionSort(nums)
(续,见第二章内容。。。。。)