python的常见数据结构
一、字典、映射和散列表
在Python中,字典是核心的数据结构。字典可以储存任意数量的对象,每一个对象都由唯一的字典的键标识
字典通常也被成为映射、散列表、查找表或是关联数组。字典能够高效查找、插入和删除任何给定键关联的对象
备注:散列表(hash table,也有译为“哈希表”)。
散列函数是一种可以将任何长度的数据映射到固定长度的值的函数,这个映射过程称为散列(hash)。
散列函数具有以下三个特点:
- 计算速度快:计算一条数据的散列值,必须要快。
- 确定性:相同的字符串的散列值总相同。
- 散列值长度固定:无论输入的是1个字节、10个字节还是1万个字节,生成的散列值始终是固定的预定长度。
- 不可逆性:散列函数是一个“单向函数”,将字符串输入到散列函数,得到了散列值,但是不能反过来,不能从散列值得到原来的字符串。由于这个特性,它可以用于加密。常用的散列函数有:MD5, SHA-1, SHA-2, NTLM.
1.dict——首选字典实现
备注:散列表(hash table,也有译为“哈希表”)。
Python还提供一些有用的“语法糖”来处理程序中的字典。例如用胡括号字典标识语法和字典解析式能够方便的创建新的字典对象
Python 的字典由可散列类型的键来索引。可散列对象具有在其生命周期中永远不会改变的散列值(参见__hash__),并且可以与其他对象进行比较(参见__eq__)。另外,相等的可散列对象,其散列值必然相同。
在Python内置的对象类型中,并非都是可散列的,只有那些不可变对象,比如整数、浮点数、字符串、元组等,才是可散列的。
ython 字典基于经过充分测试和精心调整过的散列表实现,提供了符合期望的性能特征。一般情况下,用于查找、插入、更新和删除操作的时间复杂度都为O(1)。
Python 的标准库还包含许多特殊的字典实现。
1.collections.OrderedDict——能记住键的插入顺序
import collections >>> d = collections.OrderedDict(one=1, two=2, three=3) >>> d OrderedDict([('one', 1), ('two', 2), ('three', 3)]) >>> d['four'] = 4 >>> d OrderedDict([('one', 1), ('two', 2), ('three', 3), ('four', 4)]) >>> d.keys() odict_keys(['one', 'two', 'three', 'four'])
2.collections.defaultdict——为缺失的键返回默认值
defaultdict 是另一个dict 子类,其构造函数接受一个可调用对象,查找时如果找不到给定的键,就返回这个可调用对象。
与使用get()方法或在普通字典中捕获KeyError 异常相比,这种方式的代码较少,并能清晰地表达出程序员的意图。
>>> from collections import defaultdict >>> dd = defaultdict(list) # 访问缺失的键就会用默认工厂方法创建它并将其初始化 # 在本例中工厂方法为list(): >>> dd['dogs'].append('Rufus') >>> dd['dogs'].append('Kathrin') >>> dd['dogs'].append('Mr Sniffles') >>> dd['dogs'] ['Rufus', 'Kathrin', 'Mr Sniffles']
3.collections.ChainMap——搜索多个字典
collections.ChainMap 数据结构将多个字典分组到一个映射中,在查找时逐个搜索底层映射,直到找到一个符合条件的键。对ChainMap 进行插入、更新和删除操作,只会作用于其中的第一个字典。
>>> from collections import ChainMap >>> dict1 = {'one': 1, 'two': 2} >>> dict2 = {'three': 3, 'four': 4} >>> chain = ChainMap(dict1, dict2) >>> chain ChainMap({'one': 1, 'two': 2}, {'three': 3, 'four': 4}) # ChainMap 在内部从左到右逐个搜索, # 直到找到对应的键或全部搜索完毕: >>> chain['three'] 3 >>> chain['one'] 1 >>> chain['missing'] KeyError: 'missing'
4.types.MappingProxyType——用于创建只读字典
MappingProxyType 封装了标准的字典,为封装的字典数据提供只读视图。该类添加自Python 3.3,用来创建字典不可变的代理版本。
举例来说,如果希望返回一个字典来表示类或模块的内部状态,同时禁止向该对象写入内容,此时MappingProxyType 就能派上用场。使用MappingProxyType 无须创建完整的字典副本。
>>> from types import MappingProxyType >>> writable = {'one': 1, 'two': 2} >>> read_only = MappingProxyType(writable) # 代理是只读的: >>> read_only['one'] 1 >>> read_only['one'] = 23 TypeError: "'mappingproxy' object does not support item assignment" # 更新原字典也会影响到代理: >>> writable['one'] = 42 >>> read_only mappingproxy({'one': 42, 'two': 2})
字典是Python 中的核心数据结构。
大部分情况下,内置的dict 类型就足够了。
Python 标准库提供了用于满足特殊需求的实现,比如只读字典或有序字典。
二、数组数据结构
首先要知道数组的原理及用途。
数组由大小固定的数据记录组成,根据索引能快速找到其中的每个元素。
因为数组将信息存储在依次连接的内存块中,所以它是连续的数据结构(与链式列表等链式数据结构不同)。
现实世界中能用来类比数组数据结构的是停车场。
停车场可被视为一个整体,即单个对象,但停车场内的每个停车位都有唯一的编号索引。停车位是车辆的容器,每个停车位既可以为空,也可以停有汽车、摩托车或其他车辆。
各个停车场之间也会有区别。
有些停车场可能只能停一种类型的车辆。例如,汽车停车场不允许停放自行车。这种“有限制”的停车场相当于“类型数组”数据结构,只允许存储相同数据类型的元素。
在性能方面,根据元素的索引能快速查找数组中对应的元素。合理的数组实现能够确保索引访问的耗时为常量时间O(1)。
1.列表——可变动态数组
列表是Python 语言核心的一部分。虽然名字叫列表,但它实际上是以动态数组实现的。这意味着列表能够添加或删除元素,还能分配或释放内存来自动调整存储空间。
Python 列表可以包含任意元素,因为Python 中一切皆为对象,连函数也是对象。因此,不同的数据类型可以混合存储在一个列表中。
这个功能很强大,但缺点是同时支持多种数据类型会导致数据存储得不是很紧凑。因此整个结构占据了更多的空间。
2.元组——不可变容器
与列表一样,元组也是Python 语言核心的一部分。与列表不同的是,Python 的元组对象是不可变的。这意味着不能动态添加或删除元素,元组中的所有元素都必须在创建时定义。
就像列表一样,元组可以包含任意数据类型的元素。这具有很强的灵活性,但也意味着数据的打包密度要比固定类型的数组小。
3.array.array——基本类型数组
Python 的array 模块占用的空间较少,用于存储C 语言风格的基本数据类型(如字节、32位整数,以及浮点数等)。
使用array.array 类创建的数组是可变的,行为与列表类似。但有一个重要的区别:这种数组是单一数据类型的“类型数组”。
由于这个限制,含有多个元素的array.array 对象比列表和元组节省空间。存储在其中的元素紧密排列,因此适合存储许多相同类型的元素。
此外,数组中有许多普通列表中也含有的方法,使用方式也相同,无须对应用程序代码进行其他更改。
>>> import array >>> arr = array.array('f', (1.0, 1.5, 2.0, 2.5)) >>> arr[1] 1.5 # 数组拥有不错的__repr__方法: >>> arr array('f', [1.0, 1.5, 2.0, 2.5]) # 数组是可变的: >>> arr[1] = 23.0 >>> arr array('f', [1.0, 23.0, 2.0, 2.5]) >>> del arr[1] >>> arr array('f', [1.0, 2.0, 2.5]) >>> arr.append(42.0) >>> arr array('f', [1.0, 2.0, 2.5, 42.0]) # 数组中元素类型是固定的: >>> arr[1] = 'hello' TypeError: "must be real number, not str"
4.str——含有Unicode 字符的不可变数组
Python 3.x 使用str 对象将文本数据存储为不可变的Unicode 字符序列。实际上,这意味着str 是不可变的字符数组。说来也怪,str 也是一种递归的数据结构,字符串中的每个字符都是长度为1 的str 对象。
由于字符串对象专注于单一数据类型,元组排列紧密,因此很节省空间,适合用来存储Unicode 文本。因为字符串在Python 中是不可变的,所以修改字符串需要创建一个改动副本。最接近“可变字符串”概念的是存储单个字符的列表。
5.bytes——含有单字节的不可变数组
bytes 对象是单字节的不可变序列,单字节为0~255(含)范围内的整数。从概念上讲,bytes 与str 对象类似,可认为是不可变的字节数组。
与字符串一样,也有专门用于创建bytes 对象的字面语法,bytes 也很节省空间。bytes对象是不可变的,但与字符串不同,还有一个名为bytearray 的专用“可变字节数组”数据类型,bytes 可以解包到bytearray 中。后面会介绍更多关于bytearray 的内容。
6.bytearray——含有单字节的可变数组
bytearray 类型是可变整数序列,包含的整数范围在0~255(含)。bytearray 与bytes对象关系密切,主要区别在于bytearray 可以自由修改,如覆盖、删除现有元素和添加新元素,此时bytearray 对象将相应地增长和缩小。
bytearray 数可以转换回不可变的bytes 对象,但是这需要复制所存储的数据,是耗时为O(n)的慢操作。
7.关键要点
Python 中有多种内置数据结构可用来实现数组,上面只专注位于标准库中和核心语言特性中的数据结构。
如果不想局限于Python 标准库,那么从NumPy 这样的第三方软件包中可找到为科学计算和数据科学提供的许多快速数组实现。
对于Python 中包含的数组数据结构,选择顺序可归结如下。
如果需要存储任意对象,且其中可能含有混合数据类型,那么可以选择使用列表或元组,前者可变后者不可变。
如果存储数值(整数或浮点数)数据并要求排列紧密且注重性能,那么先尝试array.array,看能否满足要求。另外可尝试准库之外的软件包,如NumPy 或Pandas。
如果有需要用Unicode 字符表示的文本数据,那么可以使用Python 内置的str。如果需要用到“可变字符串”,则请使用字符列表。
如果想存储一个连续的字节块,不可变的请使用bytes,可变的请使用bytearray。
总之,在大多数情况下首先应尝试列表。如果在性能或存储空间上有问题,再选择其他专门的数据类型。一般像列表这样通用的数组型数据结构已经能同时兼顾开发速度和编程便利性的要求了。
强烈建议在初期使用通用数据格式,不要试图在一开始就榨干所有性能。
三、栈(后进先出)
栈是含有一组对象的容器,支持快速后进先出(LIFO)的插入和删除操作。与列表或数组不同,栈通常不允许随机访问所包含的对象。插入和删除操作通常称为入栈(push)和出栈(pop)。
现实世界中与栈数据结构相似的是一叠盘子。
新盘子会添加到栈的顶部。由于这些盘子非常宝贵且很重,所以只能移动最上面的盘子(后进先出)。要到达栈中位置较低的盘子,必须逐一移除最顶端的盘子。
栈和队列相似,都是线性的元素集合,但元素的访问顺序不同。
从队列删除元素时,移除的是最先添加的项(先进先出,FIFO);而栈是移除最近添加的项(后进先出,LIFO)。
在性能方面,合理的栈实现在插入和删除操作的预期耗时是O(1)。
栈在算法中有广泛的应用,比如用于语言解析和运行时的内存管理(“调用栈”)。树或图数据结构上的深度优先搜索(DFS)是简短而美丽的算法,其中就用到了栈。
Python 中有几种栈实现,每个实现的特性略有不同。下面来分别介绍并比较各自的特性。
1.列表——简单的内置栈
Python 的内置列表类型能在正常的O(1)时间内完成入栈和出栈操作,因此适合作为栈数据结构。
Python 的列表在内部以动态数组实现,这意味着在添加或删除时,列表偶尔需要调整元素的存储空间大小。列表会预先分配一些后备存储空间,因此并非每个入栈或出栈操作都需要调整大小,所以这些操作的均摊时间复杂度为O(1)。
这么做的缺点是列表的性能不如基于链表的实现(如collections.deque,下面会介绍),后者能为插入和删除操作提供稳定的O(1)时间复杂度。另一方面,列表能在O(1)时间快速随机访问堆栈上的元素,这能带来额外的好处。
使用列表作为堆栈应注意下面几个重要的性能问题。
为了获得O(1)的插入和删除性能,必须使用append()方法将新项添加到列表的末尾,删除时也要使用pop()从末尾删除。为了获得最佳性能,基于Python 列表的栈应该向高索引增长并向低索引缩小。
从列表前部添加和删除元素很慢,耗时为O(n),因为这种情况下必须移动现有元素来为新元素腾出空间。这是一个性能反模式,应尽可能避免。
>>> s = [] >>> s.append('eat') >>> s.append('sleep') >>> s.append('code') >>> s ['eat', 'sleep', 'code'] >>> s.pop() 'code' >>> s.pop() 'sleep' >>> s.pop() 'eat' >>> s.pop() IndexError: "pop from empty list"
2.collections.deque——快速且稳健的栈
deque 类实现了一个双端队列,支持在O(1)时间(非均摊)从两端添加和移除元素。因为双端队列支持从两端添加和删除元素,所以既可以作为队列也可以作为栈。
Python 的deque 对象以双向链表实现,这为插入和删除元素提供了出色且一致的性能,但是随机访问位于栈中间元素的性能很差,耗时为O(n)。
总之,如果想在Python 的标准库中寻找一个具有链表性能特征的栈数据结构实现,那么collections.deque 是不错的选择。
>>> from collections import deque >>> s = deque() >>> s.append('eat') >>> s.append('sleep') >>> s.append('code') >>> s deque(['eat', 'sleep', 'code']) >>> s.pop() 'code' >>> s.pop() 'sleep' >>> s.pop() 'eat' >>> s.pop() IndexError: "pop from an empty deque"
3.queue.LifoQueue——为并行计算提供锁语义
queue.LifoQueue 这个位于Python 标准库中的栈实现是同步的,提供了锁语义来支持多个并发的生产者和消费者。
除了LifoQueue 之外,queue 模块还包含其他几个类,都实现了用于并行计算的多生产者/多用户队列。
在不同情况下,锁语义即可能会带来帮助,也可能会导致不必要的开销。在后面这种情况下,最好使用list 或deque 作为通用栈。
>>> from queue import LifoQueue >>> s = LifoQueue() >>> s.put('eat') >>> s.put('sleep') >>> s.put('code') >>> s <queue.LifoQueue object at 0x108298dd8> >>> s.get() 'code' >>> s.get() 'sleep' >>> s.get() 'eat' >>> s.get_nowait() queue.Empty >>> s.get() # 阻塞,永远停在这里……
4.比较Python 中各个栈的实现
从上面可以看出,Python 中有多种栈数据结构的实现,各自的特性稍有区别,在性能和用途上也各有优劣。
如果不寻求并行处理支持(或者不想手动处理上锁和解锁),可选择内置列表类型或collections.deque。两者背后使用的数据结构和总体易用性有所不同。
- 列表底层是动态数组,因此适用于快速随机访问,但在添加或删除元素时偶尔需要调整大小。列表会预先分配一些备用存储空间,因此不是每个入栈或出栈操作都需要调整大小,这些操作的均摊时间复杂度为O(1)。但需要小心,只能用append()和pop()从“右侧”插入和删除元素,否则性能会下降为O(n)。
- collections.deque 底层是双向链表,为从两端的添加和删除操作进行了优化,为这些操作提供了一致的O(1)性能。collections.deque 不仅性能稳定,而且便于使用,不必担心在“错误的一端”添加或删除项。
总之,我认为collections.deque 是在Python 中实现栈(LIFO 队列)的绝佳选择。
5.关键要点
- Python 中有几个栈实现,每种实现的性能和使用特性略有不同。
- collections.deque 提供安全且快速的通用栈实现。
- 内置列表类型可以作为栈使用,但要小心只能使用append()和pop()来添加和删除项,以避免性能下降。
四、队列(先进先出)
下面我们将介绍仅使用 Python 标准库中的内置数据类型和类来实现 FIFO 队列数据结构,首先来 回顾一下什么是队列。
队列是含有一组对象的容器,支持快速插入和删除的先进先出语义。插入和删除操作有时称为入队(enqueue)和出队(dequeue)。与列表或数组不同,队列通常不允许随机访问所包含的对象。
来看一个先进先出队列在现实中的类比。
想象在 PyCon 注册的第一天,一些 Python 高手等着领取会议徽章。新到的人依次 进入会场并排队领取徽章,队列后面会有其他人继续排队。移除动作发生在队列前端, 因为开发者领取徽章和会议礼品袋后就离开了。
另一种记住队列数据结构特征的方法是将其视为管道。
新元素(水分子、乒乓球等)从管道一端移向另一端并在那里被移除。当元素在队列中(想象成位于一根坚固的金属管中)时是无法接触的。唯一能够与队列中元素交互的方法是在管道后端添加新元素(入队)或在管道前端删除元素(出队)。
队列与栈类似,但删除元素的方式不同。
队列删除的是最先添加的项(先进先出),而栈删除的是最近添加的项(后进先出)。
在性能方面,实现合理的队列在插入和删除方面的操作预计耗时为 O(1)。插入和删除是队列 上的两个主要操作,在正确的实现中应该很快。
队列在算法中有广泛的应用,经常用于解决调度和并行编程问题。在树或图数据结构上进行 宽度优先搜索(BFS)是一种简短而美丽的算法,其中就用到了队列。
调度算法通常在内部使用优先级队列。这些是特化的队列,其中元素的顺序不是基于插入时 间,而是基于优先级。队列根据元素的键计算到每个元素的优先级。后面会详细介绍优先级队列以及它们在 Python 中的实现方式。
不过普通队列无法重新排列所包含的元素。就像在管道示例中一样,元素输入和输出的顺序 完全一致。 Python 中实现了几个队列,每种实现的特征略有不同,下面就来看看。
1.列表——非常慢的队列
普通列表可以作为队列,但从性能角度来看并不理想。由于在起始位置插入或删除元素需要将所有其他元素都移动一个位置,因此需要的时间为O(n)。
因此不推荐在Python 中凑合用列表作为队列使用(除非只处理少量元素):
>>> q = [] >>> q.append('eat') >>> q.append('sleep') >>> q.append('code') >>> q ['eat', 'sleep', 'code'] # 小心,这种操作很慢! >>> q.pop(0) 'eat'
2.collections.deque——快速和稳健的队列
deque 类实现了一个双端队列,支持在O(1)时间(非均摊)中从任一端添加和删除元素。由于deque 支持从两端添加和移除元素,因此既可用作队列也可用作栈。
Python 的deque 对象以双向链表实现。这为插入和删除元素提供了出色且一致的性能,但是随机访问位于栈中间元素的性能很差,耗时为O(n)。
因此,默认情况下collections.deque 是Python 标准库中不错的队列型数据结构:
>>> from collections import deque >>> q = deque() >>> q.append('eat') >>> q.append('sleep') >>> q.append('code') >>> q deque(['eat', 'sleep', 'code']) >>> q.popleft() 'eat' >>> q.popleft() 'sleep' >>> q.popleft() 'code' >>> q.popleft() IndexError: "pop from an empty deque"
3.queue.Queue——为并行计算提供的锁语义
queue.Queue 在Python 标准库中以同步的方式实现,提供了锁语义来支持多个并发的生产者和消费者。
queue 模块包含其他多个实现多生产者/多用户队列的类,这些队列对并行计算很有用。
在不同情况下,锁语义可能会带来帮助,也可能会导致不必要的开销。在后面这种情况下,最好使用collections.deque 作为通用队列:
>>> from queue import Queue >>> q = Queue() >>> q.put('eat') >>> q.put('sleep') >>> q.put('code') >>> q <queue.Queue object at 0x1070f5b38> >>> q.get() 'eat' >>> q.get() 'sleep' >>> q.get() 'code' >>> q.get_nowait() queue.Empty >>> q.get() # 阻塞,永远停在这里……
4.multiprocessing.Queue——共享作业队列
multiprocessing.Queue 作为共享作业队列来实现,允许多个并发worker 并行处理队列中的元素。由于CPython 中存在全局解释器锁(GIL),因此无法在单个解释器进程上执行某些并行化过程,使得大家都转向基于进程的并行化。
作为专门用于在进程间共享数据的队列实现,使用multiprocessing.Queue 能够方便地在多个进程中分派工作,以此来绕过GIL 的限制。这种类型的队列可以跨进程存储和传输任何可pickle 的对象:
>>> from multiprocessing import Queue >>> q = Queue() >>> q.put('eat') >>> q.put('sleep') >>> q.put('code') >>> q <multiprocessing.queues.Queue object at 0x1081c12b0> >>> q.get() 'eat' >>> q.get() 'sleep' >>> q.get() 'code' >>> q.get() # 阻塞,永远停在这里……
5.关键要点
- Python 核心语言及其标准库中含有几种队列实现。
- 列表对象可以用作队列,但由于性能较差,通常不建议这么做。
- 如果不需要支持并行处理,那么collections.deque 是Python 中实现FIFO 队列数据结构的最佳选择。collections.deque 是非常优秀的队列实现,具备期望的性能特征,并且可以用作栈(LIFO 队列)。
七、优先队列
优先队列是一个容器数据结构,使用具有全序关系的键(例如用数值表示的权重)来管理元素,以便快速访问容器中键值最小或最大的元素。
优先队列可被视为队列的改进版,其中元素的顺序不是基于插入时间,而是基于优先级的。对键进行处理能得到每个元素的优先级。
优先级队列通常用于处理调度问题,例如优先考虑更加紧急的任务。
来看看操作系统任务调度器的工作。
理想情况下,系统上的高优先级任务(如玩实时游戏)级别应高于低优先级的任务(如在后台下载更新)。优先级队列将待执行的任务根据紧急程度排列,任务调度程序能够快速选取并优先执行优先级最高的任务。
下面我们将介绍如何使用Python 语言内置或位于标准库中的数据结构来实现优先队列。每种实现都有各自的优缺点,但其中有一种实现能应对大多数常见情况,下面一起来看看。
1.列表——手动维护有序队列
使用有序列表能够快速识别并删除最小或最大的元素,缺点是向列表插入元素表是很慢的O(n)操作。
虽然用标准库中的bisect.insort能在O(logn)时间内找到插入位置,但缓慢的插入操作才是瓶颈。
向列表添加并重新排序来维持顺序也至少需要O(nlogn)的时间。另一个缺点是在插入新元素时,必须手动重新排列列表。缺少这一步就很容易引入bug,因此担子总是压在开发人员身上。
因此,有序列表只适合在插入次数很少的情况下充当优先队列。
q = [] q.append((2, 'code')) q.append((1, 'eat')) q.append((3, 'sleep')) # 注意:每当添加新元素或调用bisect.insort()时,都要重新排序。 q.sort(reverse=True) while q: next_item = q.pop() print(next_item) # 结果: # (1, 'eat') # (2, 'code') # (3, 'sleep')
2.heapq——基于列表的二叉堆
heapq 是二叉堆,通常用普通列表实现,能在O(logn)时间内插入和获取最小的元素。
heapq 模块是在Python 中不错的优先级队列实现。由于heapq 在技术上只提供最小堆实现,因此必须添加额外步骤来确保排序稳定性,以此来获得“实际”的优先级队列中所含有的预期特性。
import heapq q = [] heapq.heappush(q, (2, 'code')) heapq.heappush(q, (1, 'eat')) heapq.heappush(q, (3, 'sleep')) while q: next_item = heapq.heappop(q) print(next_item) # 结果: # (1, 'eat') # (2, 'code') # (3, 'sleep')
3.queue.PriorityQueue——美丽的优先级队列
queue.PriorityQueue 这个优先级队列的实现在内部使用了heapq,时间和空间复杂度与heapq 相同。
区别在于PriorityQueue 是同步的,提供了锁语义来支持多个并发的生产者和消费者。
在不同情况下,锁语义可能会带来帮助,也可能会导致不必要的开销。不管哪种情况,你都可能更喜欢PriorityQueue 提供的基于类的接口,而不是使用heapq 提供的基于函数的接口。
from queue import PriorityQueue q = PriorityQueue() q.put((2, 'code')) q.put((1, 'eat')) q.put((3, 'sleep')) while not q.empty(): next_item = q.get() print(next_item) # 结果: # (1, 'eat') # (2, 'code') # (3, 'sleep')
4.关键要点
- Python 提供了几种优先队列实现可以使用。
- queue.PriorityQueue 是其中的首选,具有良好的面向对象的接口,从名称就能明白其用途。
- 如果想避免queue.PriorityQueue 的锁开销,那么建议直接使用heapq 模块。