python列表与链表用处_浅谈Python的列表和链表

本文从实现原理的角度比较了python的列表和链表的性能差异, 并且通过LRU算法,实现一个最大堆等实例来阐明如何正确地使用它们.

一. 从归并排序说起

归并排序是分治法的一个经典实现案例, 我特别喜欢. 在维基百科里面, 使用python实现的归并排序实例如下:

defmergeSort(nums):if len(nums) < 2:returnnums

mid= len(nums) // 2left=mergeSort(nums[:mid])

right=mergeSort(nums[mid:])

result=[]while left andright:if left[0] <=right[0]:

result.append(left.pop(0))else:

result.append(right.pop(0))ifleft:

result+=leftifright:

result+=rightreturnresult

但是, 这个案例存在性能问题.现在, 我们重写一个归并排序函数, 然后测试二者的性能:

def myMergeSort(nums: [int]) ->[int]:#我们重写的归并排序算法

if len(nums) < 2:returnnums

mid= len(nums) // 2left=myMergeSort(nums[:mid])

right=myMergeSort(nums[mid:])

m, n=0, 0for i inrange(len(nums)):if n >= len(right) or (m < len(left) and left[m]

nums[i]=left[m]

m+= 1

else:

nums[i]=right[n]

n+= 1

returnnumsdef test(func: Callable[[List[int]], List[int]]) ->None:

li= list(range(int(1e5)))

ans=li[:]

random.shuffle(li)

start=time.time()assert func(li) ==ansprint('func: {}, time cost: {:.2f}s'.format(func.__name__, time.time() -start))if __name__ == '__main__':

test(mergeSort)

test(myMergeSort)

二者的执行用时如下:

python列表与链表用处_浅谈Python的列表和链表_第1张图片

可以看到, 我们写的归并函数明显性能更好, 这主要是由于, 我们在合并left和right两个列表时, 使用的是移动指针而非pop(0)操作, 后者的时间复杂度是O(n), 这直接把一个时间复杂度O(n log n)的算法优化到O(n³ log n).

二. 列表和链表的实现原理

1. 列表的储存原理

列表是在CPython的C层面实现的, 其本质上是一个数组, 数组的值为列表对应位置元素的指针. 对于一个数组来说, 它占有连续的内存空间:

python列表与链表用处_浅谈Python的列表和链表_第2张图片

因此, 假如我们现在有一个python列表, 其值为['h', 'e', 'l', 'l', 'o'], 那么在内存空间中, 它大概是长这个样子的:

77dd14a51a45e4c3b810d515e683ce28.png

这样做的好处是便于寻址, 由于内存地址连续, 我们可以在常数级别的时间内获取到位置为i的元素. 但是, 假如我们要添加或者删除元素, 情况就不一样了. 比如对于刚才的数组, 我们执行pop(0)操作:

python列表与链表用处_浅谈Python的列表和链表_第3张图片

对于列表而言, 向i位置添加或删除一个元素, 在i之后的所有元素都要挪动位置. 这也就解答了上一章提出的问题: 为什么列表pop(0)的时间复杂度为O(n).

2. 列表的扩容机制

上一节讲到, python列表的底层实现是一个数组, 数组的值指向对应位置元素的指针. 但是, 数组的长度是固定的, 添加或删除元素很不方便. 为了应对这个问题, python使用扩容机制对列表进行了优化. 下面的代码展示了python的扩容机制:

from sys importgetsizeof

li=[]for _ in range(10):

li.append(None)print(f'length: {len(li)}, size: {getsizeof(li)}')

运行结果如下:

python列表与链表用处_浅谈Python的列表和链表_第4张图片

可以看到, 列表占用的内存并不是线性增长的. 以['h', 'e', 'l', 'l', 'o']这个数组为例, 它在底层长这样:

python列表与链表用处_浅谈Python的列表和链表_第5张图片

对于一个列表, CPython为其分配长度为4,8,16,25,35...的数组. 比如上面的这个列表长度为5, 那么其底层数组的长度为8. 这样做的好处是, 在调用append操作时, 如果数组还有闲置空间, 我们就不需要重新创建数组, 等列表长度超过8之后, 我们再创建一个长度为16的数组也不迟. 因此, 虽然数组扩容的时间复杂度为O(n), 但是python避免了频繁的扩容. 把开销分摊到每一次的append上, 时间复杂度就是O(1).

pop(-1)的机制同理, python不会频繁地缩减空间, 因此时间复杂度也是O(1).

3. 链表的实现原理

通过如下代码, 我们就能定义一个链表:

classLinkNode:def __init__(self, val: int = 0) ->None:

self.val=val

self.next=Nonedef __repr__(self) ->str:#别这么用,链表过长会超过递归层数

return f'{self.val}->{repr(self.next)}'@staticmethoddef make(arr: [int]) -> Optional['LinkNode']:

root=LinkNode()

node=rootfor num inarr:

node.next=LinkNode(num)

node=node.nextreturnroot.next

link= LinkNode.make([1, 2, 3, 4, 5])print(link)

运行结果如下:

python列表与链表用处_浅谈Python的列表和链表_第6张图片

如果没有特殊需求, 建议使用collections库中自带的deque链表, deque的文档

链表的最大特点是内存不连续, 每个节点储存着下一个节点的指针. 在内存中它大概长这样:

python列表与链表用处_浅谈Python的列表和链表_第7张图片

相对于列表来说, 链表不能迅速定位元素, 如果你想要找到一个节点, 你就首先得找到这个节点的上一个节点, 要找到上一个节点, 你就得找到上一个节点的上一个节点. 循环往复, 直到头节点为止.

但是, 链表的这种数据结构也带来了插入和删除元素上的优势, 比如我们已经得到了A节点, 现在要在A节点之后插入B:

python列表与链表用处_浅谈Python的列表和链表_第8张图片

在上面的例子中, 我们只需要让A节点重新指向B, 然后让B指向C, 这样就完成了插入. 由于每个节点都只和它之前的一个节点有关联, 因此插入和删除节点的时间复杂度都是O(1).

4. 二者的性能对比和总结

总的来说, 列表和链表的最大区别是前者的内存空间连续, 后者不连续. 因此列表寻址很快, 插入和删除数据慢, 而链表相反, 对于python自带的deque双向链表来说, 头尾部的插入和删除很快, 索引很慢. 二者常用操作的时间复杂度如下:

python列表与链表用处_浅谈Python的列表和链表_第9张图片

三. 一些应用实例

1. LRU算法

LRU即Least Recently Used, 翻译成中文就是最近最少使用. 简单点说, 假如我们有一摞书:

176cf20fd81da432d7a1569827143aa8.png

现在要看某一本的话, 就会把它抽出来, 看完后, 再放回这摞书的最顶端. 这样, 近期看得最少的书很难获得放在最顶端的机会, 它就会一直在最底端. 等书的数量增多, 书架放不下之后, 最底端的肯定是最不常看的, 我们就可以把它扔掉, 这就是LRU算法.

现在我们用python来实现这样一个数据结构: 它提供get和set两个接口, 当调用set时, 如果容量达到上限, 它会基于LRU算法删除不常用的数据:

classLRUCache:def __init__(self, capacity: int) ->None:

...def get(self, key: int) ->int:

...def set(self, key: int, value: int) ->None:

...

首先, 基于上面对LRU算法的分析, 我们可以用一个链表来存放数据, 当一个节点被外部访问时, 我们就把它移动到链表头部, 当容量达到上限时, 我们就把链表尾部的节点移除. 由于链表的特性, 上述这些操作的时间复杂度都是O(1).

LRU淘汰算法解决了, 下一个问题就是如何定位到节点. 理论上, 定位到链表节点需要的时间复杂度为O(n), 这显然是无法接受的. 一个常用的优化方式就是用一个哈希表储存所有的节点地址, 这样我们就可以在O(1)的时间内定位到任意节点.

基于以上的分析, 我们使用一个双向链表和一个哈希表来实现LRU算法, 其get和set操作的时间复杂度都是O(1):

classLinkNode:def __init__(self, key: int = 0, value: int = 0) ->None:

self.key=key

self.value=value#为了更方便,这里使用双向链表,两个指针分别指向前置节点和后置节点

self.prev =None

self.next=Nonedef connect(self, node: 'LinkNode') ->None:

self.next=node

node.prev=selfclassLRUCache:def __init__(self, capacity: int) ->None:

self.capacity=capacity

self.size=0#所有的节点都储存在头部和尾部之间

self.head =LinkNode()

self.tail=LinkNode()

self.head.connect(self.tail)#用一个字典来快速定位节点

self.nodes ={}def get(self, key: int) ->int:if key not inself.nodes.keys():return -1node=self.nodes[key]

self.add_to_head(node)returnnode.valuedef set(self, key: int, value: int) ->None:if key inself.nodes.keys():

self.nodes[key].value=valueelse:

self.nodes[key]=LinkNode(key, value)

self.size+= 1

if self.size >self.capacity:

last=self.tail.prevdelself.nodes[last.key]

last.prev.connect(self.tail)

self.add_to_head(self.nodes[key])def add_to_head(self, node: LinkNode) ->None:if node.prev andnode.next:

node.prev.connect(node.next)

node.connect(self.head.next)

self.head.connect(node)

此外, python的有序字典OrderedDict内部就是用一个双向链表来保持有序的, 因此我们也可以直接用它来实现LRU算法:

classLRU(OrderedDict):'Limit size, evicting the least recently looked-up key when full'

def __init__(self, maxsize=128, /, *args, **kwds):

self.maxsize=maxsize

super().__init__(*args, **kwds)def __getitem__(self, key):

value= super().__getitem__(key)

self.move_to_end(key)returnvaluedef __setitem__(self, key, value):if key inself:

self.move_to_end(key)

super().__setitem__(key, value)if len(self) >self.maxsize:

oldest=next(iter(self))del self[oldest]

2. 实现一个最大堆

这个内容有点多, 我单独写了一篇文章-> 用Python实现最大堆.

3. 列表当成字典用

python字典的本质是一个哈希表, 其具体实现原理可以看这篇文章. 考虑到哈希冲突, 字典取值可能需要额外时间. 而相对的, 列表寻址很快, 而且每个索引值(不考虑负值)都指向列表中独一无二的位置. 现在我们分别测试二者的性能:

importrandomimporttimeimportsys

n= 10000

#长度为n的字典, key值在[0, n - 1]之间, value为a-z的某个字母

dict = {k: chr(random.randrange(97, 123)) for k inrange(n)}#用列表来存放dic的所有信息

list = [None] * 10000

for k, v indict.items():

list[k]=vdeftest(data):print(type(data))print(f'size:{sys.getsizeof(data)}')

start=time.time()for _ in range(100):for key inrange(n):

value=data[key]print('time cost: {:.5f}s'.format(time.time() -start))

test(dict)

test(list)

程序运行结果如下:

python列表与链表用处_浅谈Python的列表和链表_第10张图片

可以看到, 不管是内存占用还是取值需要时间, 列表都完胜字典.

基于以上, 在key值都是自然数的前提下, 列表是可以代替字典的, 其取值速度和内存占用都优于字典. 但是, 如果数据经常发生添加和删除等变动, 这时候列表就不占性能优势了. 因此, 是否用列表代替字典, 还得根据实际场景来定.

4. 小结

1. 链表的寻址很慢, 为了弥补这一劣势, 我们可以根据实际情况使用哈希表映射到链表节点, 降低寻址的时间复杂度;

2. 列表的索引机制给了它无限的可能, 它可以当二叉树用, 可以当字典用. 然而, 这些用法都有明显的局限性, 实际项目中还是得具体问题具体分析.

你可能感兴趣的:(python列表与链表用处)