第10 章 一些简单算法和数据结构


我们虽然花费大量篇幅讨论程序效率,但目的不是使你成为设计高效程序的专家。关于这个
问题,有很多大部头的图书(其中一些甚至非常引人入胜)进行了专门的讨论。①第9章介绍了一
些构成复杂度分析的基本概念。在本章中,我们会使用这些概念研究几种经典算法的复杂度。本
章的目的是帮助你建立某种通用直觉,以解决算法效率方面的问题。学习本章内容之后,你应该
明白为什么有些程序眨眼之间就可以完成,有些程序却需要运行到第二天,而有些程序在你有生
之年都不会结束运行。
我们在本书中介绍的第一个算法是暴力穷举法。当时我们声称,现代计算机的速度太快了,
以至于开发那些巧妙的算法经常就是浪费时间,编写既简单又明显正确的代码才是正道。
然后我们遇到了一些问题(例如,求一个多项式的根的近似值),这时搜索空间太大,以至
于暴力算法已经失效。这促使我们考虑更有效率的算法,比如二分查找法和牛顿拉弗森法。我
们的主要观点是,程序效率的关键是好的算法,而不是靠小聪明在编码时耍些花招。
在科学领域(物理学、生命科学和社会科学)中,为了验证一个关于数据集的假设是否合理,
程序员经常先快速地编码实现一个简单算法,然后使用少量数据运行该算法。如果结果令人鼓舞,
那么接下来就要开发可以运行(可能要一次又一次地运行)在大规模数据集上的程序实现,艰苦
的工作就开始了,这种实现要在高效算法的基础上才能完成。
高效算法的实现非常困难。对于那些成功的专业计算机科学家来说,整个职业生涯中可能只
会开发出一种算法——如果他们足够幸运。多数人永远不会开发出新算法。我们要做的是,学会
在面对问题时将复杂性减到最小,并将它们转换成以前已经解决了的问题。
更具体地说,我们需要:
 理解问题的内在复杂度;
 思考如何将问题分解成多个子问题;
 将这些子问题与已经有高效算法的其他问题联系起来。
本章给出几个示例程序,目的是让你在算法设计方面具有一些直觉性知识。还有很多算法可
以在本书其他章节中找到。
请记住,你并不总是需要选择最有效率的算法,一个在各方面都最有效率的程序经常是难以理解的,我们也没有必要去理解。一般来说,比较好的策略是先用最简单直接的方式解决手头的
问题,再仔细测试找出计算上的瓶颈,然后仔细研究造成瓶颈的那部分程序,并找出改善计算复
杂度的方法。
10.1 搜索算法
搜索算法就是在一个项目集合中找出一个或一组具有某种特点的项目。我们将项目集合称为
搜索空间。它可以很具体,比如一组电子病历;也可以很抽象,比如所有整数的集合。在实际工
作中,大量问题都可以转换为搜索问题。
本书前面介绍过的很多算法都可以看作搜索算法。在第3章中,我们将“为多项式的根找出
近似值”这个问题形式化为搜索问题,并给出三种搜索可行解空间的算法:穷举法、二分查找法
和牛顿拉弗森法。
本节会研究两种搜索列表的算法,每种方法都满足以下规范:

def search(L, e):
"""假设L是列表
如果e是L中的元素,则返回True,否则返回False"""

聪明的读者可能会问,这个函数在语义上不是和Python表达式e in L完全相同吗?没错,就
是这样。如果我们不关心判断“e是否在L中”时的效率问题,那么只要简单地使用这个表达式就
可以了。

10.1.1 线性搜索与间接引用元素

Python使用以下算法确定列表中是否有某个元素:

for i in range(len(L)):
if L[i] == e:
return True
return False

如果元素e不在列表中,那么算法就会执行O(len(L))次测试。也就是说,复杂度至多与L的长
度成线性关系。为什么是“至多”成线性关系呢?只有当循环中的每个操作都可以在常数时间内
完成时,才是线性关系。这就引发一个问题:Python能否在常数时间内提取列表中的第i个元素?
因为我们的计算模型假设取出一个内存地址中的内容是一个常数时间操作,所以问题就变成能否
在常数时间内计算出列表中第i个元素的地址。
首先考虑简单情形。假设列表中的每个元素都是整数,这意味着列表中每个元素的大小都相
同,如4个内存单位(4个8位字节①)。假设列表中的元素是连续存储的,那么列表中第i个元素的
内存地址就是start+4*i,这里的start是列表起始位置的地址。因此,我们可以认为,Python
能够在常数时间内计算出整数列表中第i个元素的地址。

当然,我们知道Python列表可以包含非int类型的对象,而且同一个列表中对象的大小和类
型也可以都不同。你可能会认为这是一个问题,但实际上不是。
在Python中,列表被表示成一个长度(列表中对象的数量)和一个固定长度的对象指针①的
序列。图10-1说明了指针的用法。图中的阴影区域表示一个包含4个元素的列表,最左边的阴影
方块包含一个指向整数的指针,表示列表长度。其余每个阴影方块都包含一个指针,指向列表中
的对象。

图10-1 列表的实现

如果长度域占4个内存单元,每个指针(其实就是地址)占4个内存单元,那么地址start + 4 +
4 * i中保存的就是列表中第i个元素的地址。同样,这个地址可以在常数时间内找到,然后使用
保存在这个地址中的值就可以访问第i个元素。访问操作也可以在常数时间内完成。
这个例子演示了计算中最重要的实现技术之一:间接引用。②一般来说,间接引用就是,要
访问目标元素时,先访问另一个元素,再通过包含在这个元素中的引用来访问目标元素。我们每
次使用变量引用与变量绑定的对象时,就是这么做的。当我们使用一个变量访问列表并使用保存
在列表中的引用访问另一个对象时,实际上进行了双重间接引用。③
10.1.2 二分查找和利用假设
回到实现search(L, e)这个问题,O(len(L))是我们能做到的最好情况吗?是的,如果我们对
列表中元素值之间的关系以及元素的存储顺序一无所知。在最差情形中,我们必须遍历L中的每
一个元素才能确定L是否包含e。
但是假如我们对元素的存储顺序有所了解呢?例如,假设我们知道一个整数列表是按照升序
存储元素的,那么可以修改函数实现,搜索到一个大于目标整数的数值时,就停止搜索,如图10-2
所示。

def search(L, e):
"""假设L是列表,其中元素按升序排列。
ascending order.
如果e是L中的元素,则返回True,否则返回False"""
for i in range(len(L)):
if L[i] == e:
return True
if L[i] > e:
return False
return False

这种算法可以缩短平均运行时间,但不会改变最差情形下的算法复杂度,因为在最差情形下
还是需要检查L中的每个元素。
但是,通过使用一种称为二分查找的算法,我们可以显著改善最差情形下的复杂度,就像第
3章中求浮点数平方根近似值时所做的一样。使用二分查找时,我们依赖的是浮点数固有的全序
性。现在我们则依赖“列表有序”这个假设。
二分查找的思路非常简单:
(1) 选择一个可以将列表L大致一分为二的索引i;
(2) 检查是否有L[i] == e;
(3) 如果不是,检查L[i]大于还是小于e;
(4) 根据上一步的结果,确定在L的左半部分还是右半部分搜索e。
给定算法结构之后,很显然,实现二分查找的最简单直接的方式就是使用递归,如图10-3所示。

def search(L, e):
"""假设L是列表,其中元素按升序排列。
ascending order.
如果e是L中的元素,则返回True,否则返回False"""
def bSearch(L, e, low, high):
#Decrements high – low
if high == low:
return L[low] == e
mid = (low + high)//2
if L[mid] == e:
return True
elif L[mid] > e:
if low == mid: #nothing left to search
return False
else:
return bSearch(L, e, low, mid - 1)
else:
return bSearch(L, e, mid + 1, high)
if len(L) == 0:
return False
else:
return bSearch(L, e, 0, len(L) - 1)

图10-3 递归二分查找

图10-3中,外层函数search(L, e)与图10-2中的定义函数具有同样的参数和规范。从规范中
可知,函数会假设L中的元素是以升序排列的。search函数的调用者应该确保满足这个假设。如
果这个假设没有被满足,那么函数没有义务保证能够正确运行。它可能有效,也可能崩溃,还可
能返回一个错误的结果。我们是否应该对search函数进行修改,并检查这个假设是否被满足呢?
这样做虽然可以消除错误隐患,但会违背使用二分查找的初衷,因为检查假设这个操作本身就会
带来O(len(L))的复杂度。
像search这样的函数经常被称为包装器函数。这种函数为客户代码提供了一个非常易用的接
口,但就是一个外壳,不执行重要的计算,而使用适当的参数调用辅助函数bSearch。这就引起
一个问题:为什么不去掉search,让客户代码直接调用bSearch呢?原因就在于bSearch中的两
个参数low和high,它们与在列表中搜索一个元素这一抽象任务没有任何关系,只是具体实现中
的细节,应该对search的调用者隐藏。
下面分析bSearch的复杂度。上一节证明了访问列表需要常数时间,因此,如果先不考虑递归调
用,那么每个bSearch实例的复杂度都是O(1)。所以,bSearch的复杂度仅仅依赖于递归调用的次数。
如果这是一本关于算法的书,我就会使用所谓的递推关系来进行详细分析。但因为这不是一
本算法书,所以我会采用一种不那么正式的方法,先从一个问题开始:“我们如何知道程序会在
什么时候结束?”回忆一下,在第3章关于while循环时,我们也问过同样的问题。当时通过一个
循环中的递减函数回答了这个问题,现在我们也要做同样的事。在这个上下文环境中,递减函数
具有以下性质:
 它可以将形参绑定的值映射为一个非负整数;
 当它的值为0时,递归结束;
 对于每次递归调用,递减函数的值都会小于做出调用的函数实例中的递减函数的值。
bSearch中的递减函数是high - low。search中的if语句保证了第一次调用bSearch时递减
函数的值至少为0(递减函数性质1)。
进入bSearch时,如果high - low正好为0,那么函数就不做任何递归调用,只返回表达式
L[low] == e的值(满足递减函数性质2)。
函数bSearch中有两个递归调用。一个调用中的参数覆盖了mid左侧的所有元素,另一个调
用中的参数覆盖了mid右侧的所有元素。在任何一个调用中,high - low的值都被分为两半(满
足递减函数性质3)。
现在我们明白了为什么递归会结束。下一个问题就是,在high - low == 0之前,high - low
的值会减半多少次呢?回忆一下,logy(x)表示的是为了达到x值,y需要和自己相乘的次数。相反,
如果x被y除了logy(x)次,那么结果就是1。这说明high - low在等于0之前,至多使用整数除法减
半log2(high low)次即可。
最后,我们终于可以回答“二分查找的算法复杂度是多少?”这个问题。因为当search调用
bSearch时,high - low的值是len(L) - 1,所以search函数的复杂度为O(log(len(L)))。①

实际练习:为什么在第二个递归调用中,代码使用的不是mid,而是mid + 1?

10.2 排序算法
从上一节可以看到,如果知道列表是有序的,那么我们就可以利用这个信息大大降低搜索列
表所需的时间。这是否意味着在有列表搜索的需求时,应该先排序再执行搜索呢?
假设O(sortComplexity(L))表示列表排序的复杂度。我们已经知道搜索列表的时间在O(len(L))
之内,所以“是否应该先排序再搜索”的问题就变成:sortComplexity(L) + log(len(L))小于len(L)
吗?很遗憾,答案是否定的。如果不能对列表中的每个元素至少检查一次,我们就不可能完成列
表排序,所以排序算法的复杂度不可能小于线性复杂度。
难道二分查找只是对我们求知欲的一种满足,却没有任何实用价值吗?令人高兴的是,答案
依然是否定的。假设我们希望对同一列表进行多次搜索,那么在一次列表排序上付出的成本就有
很大意义了,这个成本可以分摊在多次搜索中。如果我们希望对列表进行k次搜索,那么问题就
变成:sortComplexity(L) + log(len(L))小于k*len(L)吗?
k越来越大时,列表排序所用的时间会变得越来越微不足道。k的大小取决于列表排序所需的
时间。例如,如果排序时间与列表大小成指数关系,k就应该非常大。
幸运的是,排序可以相当高效地完成。例如,在大多数Python版本中,标准排序算法的运行
时间大约是O(n*log(n)),这里的n是列表长度。实际上,我们几乎不用自己实现排序函数。在大
多数情况下,我们应该使用Python内置的sort方法(L.sort()可以对列表L排序),或者使用内置
函数sorted(sorted(L)会返回一个列表,其中包含与L同样的元素,但是不会修改L)。我们介
绍排序算法的基本目的是,帮助大家在算法设计和复杂度分析方面积累一些实际经验。
首先,从一个简单但是低效的算法开始:选择排序。如图10-4所示,选择排序的工作原理是
维持一个循环不变式,它会将列表分成前缀部分(L[0 : i])和后缀部分(L[i+1 : len(L)]),
前缀部分已经排好序,而且其中的每一个元素都不大于后缀部分中的最小元素。

def selSort(L):
"""假设L是列表,其中的元素可以用>进行比较。
compared using >.
对L进行升序排列"""
suffixStart = 0
while suffixStart != len(L):
#检查后缀集合中的每个元素
for i in range(suffixStart, len(L)):
if L[i] < L[suffixStart]:
#交换元素位置
L[suffixStart], L[i] = L[i], L[suffixStart]
suffixStart += 1

图10-4 选择排序

我们使用归纳法对循环不变式进行推导。
 基础情形:第一次迭代开始时,前缀集合是空的,也就是说,后缀集合是整个列表。因
此,不变式(显然)成立。
 归纳步骤:在算法的每一步中,我们都从后缀集合向前缀集合移动一个元素,移动的方
式是将后缀集合中的最小元素添加到前缀集合的末尾。因为移动元素之前,不变式是成
立的,所以添加元素之后,前缀集合依然有序。而且,因为我们从后缀集合中移走的是
最小元素,所以前缀集合中仍然没有任何一个元素大于后缀集合中的最小元素。
 结束:退出循环时,前缀集合中包括了整个列表,后缀集合是空的。因此,整个列表按
照升序排列。
很难想象还有比选择排序更加简单明了的排序算法。但非常遗憾,这个算法非常低效。①内
层循环的复杂度为O(len(L)),外层循环的复杂度也是O(len(L))。所以,整个函数的复杂度是
O(len(L)2),即列表L长度的平方。
10.2.1 归并排序
幸运的是,我们可以使用分治算法得到比平方复杂度好得多的结果。其基本思想就是先找出
初始问题的一些简单实例的解,再将这些解组合起来作为初始问题的解。一般来说,分治算法具
有以下特征:
 一个输入规模的阈值,低于这个阈值的问题不会进行分解;
 一个实例分解成子实例的规模和数量;
 合并子解的算法。
阈值有时被称为递归基。对于第二条特征,经常要考虑初始问题规模与子实例规模的比例。
在至今为止我们见过的大多数例子中,这个比例是2。
归并排序是一种典型的分治算法,它由约翰·冯·诺依曼于1945年发明,至今仍被广泛使用。
和多数分治算法一样,用递归方式描述它是最容易的:
(1) 如果列表的长度是0或1,那么它已经排好序了;
(2) 如果列表包含多于1个元素,就将其分成两个列表,并分别使用归并排序法进行排序;
(3) 合并结果。
冯·诺依曼的关键发现是,两个有序的列表可以高效地合并成一个有序列表。合并的思想
是,先看每个列表的第一个元素,然后将二者之间较小的一个移到目标列表的末尾。其中一个
列表为空时,就将另一个列表中余下的元素复制到目标列表末尾。举例来说,假设要合并列表
[1, 5, 12, 18, 19, 20]和[2, 3, 4, 17]:

列表1中剩余元素 | 列表2中剩余元素 | 目标列表 |
[1,5,12,18,19,20] [2,3,4,17] []
[5,12,18,19,20] [2,3,4,17] [1]
[5,12,18,19,20] [3,4,17] [1,2]
[5,12,18,19,20] [4,17] [1,2,3]
[5,12,18,19,20] [17] [1,2,3,4]
[12,18,19,20] [17] [1,2,3,4,5]
[18,19,20] [17] [1,2,3,4,5,12]
[18,19,20] [] [1,2,3,4,5,12,17]
[] [] [1,2,3,4,5,12,17,18,19,20]

合并过程的复杂度是多少呢?过程中有两个常数时间操作,比较元素的值和从一个列表向另
一个列表复制元素。比较的次数是O(len(L)),这里的L是两个列表中较长的那个。复制操作的次
数是O(len(L1) + len(L2)),因为每个元素都正好复制一次。(复制元素的时间依赖于元素大小,但
这并不会影响排序时间增长的速度,这个速度是列表中元素个数的函数。)因此,合并两个有序
列表的复杂度与列表的长度成线性关系。
归并排序算法的实现如图10-5所示。

def merge(left, right, compare):
"""假设left和right是两个有序列表,compare定义了一种元素排序规则。
返回一个新的有序列表(按照compare定义的顺序),其中包含与
(left+right)相同的元素。"""
result = []
i,j = 0, 0
while i < len(left) and j < len(right):
if compare(left[i], right[j]):
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
while (i < len(left)):
result.append(left[i])
i += 1
while (j < len(right)):
result.append(right[j])
j += 1
return result
def mergeSort(L, compare = lambda x, y: x < y):
"""假设L是列表,compare定义了L中元素的排序规则
on elements of L
返回一个新的具有L中相同元素的有序列表。"""
if len(L) < 2:
return L[:]
else:
middle = len(L)//2
left = mergeSort(L[:middle], compare)
right = mergeSort(L[middle:], compare)
return merge(left, right, compare)

图10-5 归并排序

请注意,我们将比较操作符作为mergeSort函数的一个参数,并编写了一个Lambda表达式作
为默认值。例如,以下代码:

L = [2,1,4,5,3]
print(mergeSort(L), mergeSort(L, lambda x, y: x > y))

会输出:

[1, 2, 3, 4, 5] [5, 4, 3, 2, 1]

分析一下mergeSort的复杂度。我们已经知道,merge的时间复杂度是O(len(L))。在每层递归
中,要合并的元素总数是len(L)。因此,mergeSort的时间复杂度是O(len(L))乘以递归的层数。因
为mergeSort每次将列表分为两半,所以我们可知递归层数是O(log(len(L)))。因此,mergeSort
的时间复杂度是O(n*log(n)),这里的n是len(L)。
这比选择排序的O(len(L)2)要好多了。举例来说,如果L中有10 000个元素,那么len(L)2
就是1亿,而len(L)*log(len(L))大约只有13万。
这种对时间复杂度的改进是有代价的。选择排序是原地排序算法的一个实例,因为它在列表
内部交换元素位置,仅使用固定数量的额外存储(在我们的具体实现中,是一个元素的大小)。
相比之下,合并排序算法需要复制列表,这意味着其空间复杂度是O(len(L))。对于大规模列表来
说,这可能是个问题。①

10.2.2 将函数用作参数
假设我们要对一个姓名列表进行排序,其中姓名的形式为“先名后姓”,如列表['Chris
Terman', 'Tom Brady', 'Eric Grimson', 'Gisele Bundchen']。图10-6中定义了两个定序函
数,然后通过这些函数使用两种不同的定序方式对一个列表进行排序。每个函数都使用了str类
型的split方法。

def lastNameFirstName(name1, name2):
arg1 = name1.split(' ')
arg2 = name2.split(' ')
if arg1[1] != arg2[1]:
return arg1[1] < arg2[1]
else: #姓相同,则按照名排序
return arg1[0] < arg2[0]
def firstNameLastName(name1, name2):
arg1 = name1.split(' ')
arg2 = name2.split(' ')
if arg1[0] != arg2[0]:
return arg1[0] < arg2[0]
else: #名相同,则按照姓排序
return arg1[1] < arg2[1]
L = ['Tom Brady', 'Eric Grimson', 'Gisele Bundchen']
newL = mergeSort(L, lastNameFirstName)
print('Sorted by last name =', newL)
newL = mergeSort(L, firstNameLastName)
print('Sorted by first name =', newL)

图10-6 姓名列表排序

运行图10-6中的代码,会输出以下结果:

Sorted by last name = ['Tom Brady', 'Gisele Bundchen', 'Eric Grimson']
Sorted by first name = ['Eric Grimson', 'Gisele Bundchen', 'Tom Brady']

10.2.3 Python 中的排序
多数Python版本中使用的排序算法被称为timsort①。这种算法的核心思想是利用这样一个事
实,即在很多数据集中,数据已经部分有序。timsort在最差情形下的性能与归并排序一样,但平
均性能要远远超过归并排序。
正如我们以前提到过的,Python的list.sort方法使用列表作为第一个参数并且修改这个列
表。相反,Python中的sorted函数使用一个可迭代的对象(如列表或视图)作为第一个参数,并
返回一个排好序的新列表。例如,以下代码:

L = [3,5,2]
D = {'a':12, 'c':5, 'b':'dog'}
print(sorted(L))
print(L)
L.sort()
print(L)
print(sorted(D))
D.sort()

会输出:

[2, 3, 5]
[3, 5, 2]
[2, 3, 5]
['a', 'b', 'c']
AttributeError: 'dict' object has no attribute 'sort'

请注意,把sorted函数应用于一个字典时,会返回一个排好序的字典键的列表。相比之下,
在字典上应用sort方法时,会引发一个异常,因为没有dict.sort这个方法。
list.sort方法和sorted函数都可以有两个附加参数。参数key的作用和我们实现归并排序
时的compare一样:提供用于排序的比较函数。参数reverse指定对列表进行升序还是降序排序,
升序和降序都是相对于比较函数来说的。例如,以下代码:

L = [[1,2,3], (3,2,1,0), 'abc']


print(sorted(L, key = len, reverse = True))
按照长度的相反顺序对L中的元素进行排序,会输出:

[(3, 2, 1, 0), [1, 2, 3], 'abc']


list.sort方法和sorted函数都采用稳定排序方法,这意味着如果两个元素的比较项目(本
例中是长度)是相等的,那么它们在初始列表(或其他可迭代对象)中的相对顺序会被保留到最
终列表。(因为没有键会在一个dict对象中出现一次以上,所以应用于dict时,sorted函数是否
稳定这个问题其实没有意义。)

10.3 散列表
如果我们将归并排序与二分查找结合起来,就可以很好地解决列表搜索的问题。使用归并排
序在O(n*log(n))时间内对列表进行预处理,然后使用二分查找在O(log(n))时间内检验元素是否在
列表中。如果对列表进行k次搜索,那么总体时间复杂度就是O(n*log(n)) + k*O(log(n))。
这已经相当不错了,但我们还是要问,可以做一些预处理工作时,对数复杂度就是我们在搜
索问题上能得到的最好结果吗?
第5章介绍dict类型时说过,字典使用一种称为“散列”的技术进行搜索,这种技术使得搜
索时间几乎与字典大小无关。散列表背后的基本思想非常简单,我们将键转换为一个整数,然后
使用这个整数索引一个列表,这都可以在常数时间内完成。理论上,任何类型的值都可以轻松转
换为一个整数。归根结底,每个对象在计算机内部的表示都是一个位序列,任何一个位序列都可
以表示一个整数。举例来说,字符串'abc'的内部表示是位序列011000010110001001100011,它
可以表示十进制整数6 382 179。当然,如果想使用字符串的内部表示作为列表索引,那列表肯定
会非常长。
如果键已经是整数,又当如何?设想一下,如果我们正在实现一个字典,字典的键是美国社
保号码(9位整数)。用一个包含109个元素的列表来表示这个字典,并用社保号码索引这个列表,
就可以在常数时间内完成查找工作。当然,如果字典中只包含10 000(104)个人员的条目,就会浪费大量空间。
为了解决这个问题,我们引入散列函数。它会将一个大规模的输入空间(如所有自然数)映
射为一个小规模的输出空间(如0~5000的自然数)。所以,可以使用散列函数将数量巨大的键转
换为数量较少的整数索引。
因为输出空间小于输入空间,所以散列函数是个多对一映射。也就是说,多个不同输入会被
映射为同一输出。当两个输入被映射为同一个输出时,我们称这种情况为碰撞,随后会对其进行
介绍。一个好的散列函数会生成一个均匀分布,也就是说,范围内出现每种输出的可能性都是相
等的,这会使产生碰撞的可能性最小化。
图10-7中,我们使用一个简单的散列函数(回忆一下,i%j返回整数i除以整数j后的余数)实
现带有整数键的字典。

class intDict(object):
"""键为整数的字典"""
def __init__(self, numBuckets):
"""创建一个空字典"""
self.buckets = []
self.numBuckets = numBuckets
for i in range(numBuckets):
self.buckets.append([])
def addEntry(self, key, dictVal):
"""假设key是整数。添加一个字典条目。"""
hashBucket = self.buckets[key%self.numBuckets]
for i in range(len(hashBucket)):
if hashBucket[i][0] == key:
hashBucket[i] = (key, dictVal)
return
hashBucket.append((key, dictVal))
def getValue(self, key):
"""假设key是整数。
返回键为key的字典值"""
hashBucket = self.buckets[key%self.numBuckets]
for e in hashBucket:
if e[0] == key:
return e[1]
return None
def __str__(self):
result = '{'
for b in self.buckets:
for e in b:
result = result + str(e[0]) + ':' + str(e[1]) + ','
return result[:-1] + '}' #result[:-1] omits the last comma

图10-7 使用散列算法实现字典

上述实现的基本思想是通过散列桶列表表示intDict类的实例,每个散列桶都是一个元组形
式的键/值对列表。通过这种每个桶都是一个列表的方式,我们可以将散列到同一个桶的所有值
都保存在列表里,从而解决碰撞问题。
散列表的工作方式如下:实例变量buckets被初始化为一个列表,其中包含numBuckets个空
列表。如果想保存或查找一个键值为dictKey的条目,首先要使用散列函数%将dictKey转换为一
个整数,并使用这个整数在buckets中索引,找到与dictKey关联的散列桶。然后对这个桶(其
实是一个列表)进行线性搜索,看看是否有一个条目的键是dictKey。如果我们执行的是查找工
作,并且有一个条目的键是dictKey,那么返回保存在该条目中的值即可;如果没有条目的键是
dictKey,则返回None。如果要保存一个值,应当首先检查散列桶中是否已经有带有这个键的条
目。如果有,就使用一个新元组替换这个条目,否则向桶中添加一个新条目。
还有很多其他方法可以解决碰撞问题,有些方法比使用列表更高效。但这可能是最简单的一
种方法,如果散列表相对于要保存的元素数量足够大,并且散列函数能提供对均匀分布足够好的
近似,那么这种方法的效果会非常好。
请注意,__str__方法提供了一种字典的表示方法,这种方法与元素添加到字典中的顺序无
关,而是按照键的散列值排序的。这就解释了我们为什么不能预测dict类型对象中的键的顺序。
下面的代码首先创建一个带有17个桶和20个条目的intDict对象。条目中的值是0~19的整
数。键在0~1051且使用random.choice随机选择。(我们会在第14章和第15章讨论random模块。)
代码接着使用定义在类中的__str__方法输出intDict对象。最后,代码通过遍历D.buckets输出
每个散列桶。(这严重违背了信息隐藏原则,但在教学上很有用。)

import random
D = intDict(17)
for i in range(20):
#从0~10**5-1中选择一个随机整数
key = random.choice(range(10**5))
D.addEntry(key, i)
print('The value of the intDict is:')
print(D)
print('\n', 'The buckets are:')
for hashBucket in D.buckets: #破坏了抽象的屏障
print(' ', hashBucket)

运行这段代码会输出以下结果:①

The value of the intDict is:
{99740:6,61898:8,15455:4,99913:18,276:19,63944:13,79618:17,51093:15,827
1:2,3715:14,74606:1,33432:3,58915:7,12302:12,56723:16,27519:11,64937:5,
85405:9,49756:10,17611:0}
The buckets are:
[]
[(99740, 6), (61898, 8)]
[(15455, 4)]
[]
[(99913, 18), (276, 19)]
[]
[]
[(63944, 13), (79618, 17)]
[(51093, 15)]
[(8271, 2), (3715, 14)]
[(74606, 1), (33432, 3), (58915, 7)]
[(12302, 12), (56723, 16)]
[]
[(27519, 11)]
[(64937, 5), (85405, 9), (49756, 10)]
[]
[(17611, 0)]

当我们越过抽象边界,窥视indict对象的内部表示时,可以发现有些散列桶是空的,有些散
列桶则包含了一个、两个或三个条目,这取决于发生碰撞的次数。
那么,getVal的复杂度是多少呢?如果没有碰撞,复杂度就是O(1),因为每个散列桶的长度
都是0或1。但是,碰撞当然无法避免。如果所有键都被散列映射到同一个桶中,复杂度就是O(n),
这里的n是字典中的条目数量,因为代码会对该散列桶执行线性搜索。如果散列表足够大,就可
以减少碰撞次数,完全将复杂度降低到O(1)。也就是说,我们能够以空间换时间。但是如何进行
取舍呢?为了回答这个问题,需要了解一些概率方面的知识,所以我们将答案留到第15章。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

___Y1

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值