• 浅谈Python的列表和链表


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

    一. 从归并排序说起

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

    def mergeSort(nums):
        if len(nums) < 2:
            return nums
        mid = len(nums) // 2
        left = mergeSort(nums[:mid])
        right = mergeSort(nums[mid:])
        result = []
        while left and right:
            if left[0] <= right[0]:
                result.append(left.pop(0))
            else:
                result.append(right.pop(0))
        if left:
            result += left
        if right:
            result += right
        return result

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

    def myMergeSort(nums: [int]) -> [int]:
        # 我们重写的归并排序算法
        if len(nums) < 2:
            return nums
        mid = len(nums) // 2
        left = myMergeSort(nums[:mid])
        right = myMergeSort(nums[mid:])
        m, n = 0, 0
        for i in range(len(nums)):
            if n >= len(right) or (m < len(left) and left[m] < right[n]):
                nums[i] = left[m]
                m += 1
            else:
                nums[i] = right[n]
                n += 1
        return nums
    
    
    def test(func: Callable[[List[int]], List[int]]) -> None:
        li = list(range(int(1e5)))
        ans = li[:]
        random.shuffle(li)
        start = time.time()
        assert func(li) == ans
        print('func: {}, time cost: {:.2f}s'.format(func.__name__, time.time() - start))
    
    
    if __name__ == '__main__':
        test(mergeSort)
        test(myMergeSort)

    二者的执行用时如下:

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

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

    1. 列表的储存原理

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

      

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

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

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

    2. 列表的扩容机制

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

    from sys import getsizeof
    
    li = []
    for _ in range(10):
        li.append(None)
        print(f'length: {len(li)}, size: {getsizeof(li)}')

    运行结果如下:

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

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

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

    3. 链表的实现原理

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

    class LinkNode:
        def __init__(self, val: int = 0) -> None:
            self.val = val
            self.next = None
    
        def __repr__(self) -> str:
            # 别这么用,链表过长会超过递归层数
            return f'{self.val}->{repr(self.next)}'
    
        @staticmethod
        def make(arr: [int]) -> Optional['LinkNode']:
            root = LinkNode()
            node = root
            for num in arr:
                node.next = LinkNode(num)
                node = node.next
            return root.next
    
    
    link = LinkNode.make([1, 2, 3, 4, 5])
    print(link)

    运行结果如下:

     

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

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

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

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

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

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

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

    三. 一些应用实例

    1. LRU算法

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

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

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

    class LRUCache:
    
        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):

    class LinkNode:
    
        def __init__(self, key: int = 0, value: int = 0) -> None:
            self.key = key
            self.value = value
            # 为了更方便,这里使用双向链表,两个指针分别指向前置节点和后置节点
            self.prev = None
            self.next = None
    
        def connect(self, node: 'LinkNode') -> None:
            self.next = node
            node.prev = self
    
    
    class LRUCache:
    
        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 in self.nodes.keys():
                return -1
            node = self.nodes[key]
            self.add_to_head(node)
            return node.value
    
        def set(self, key: int, value: int) -> None:
            if key in self.nodes.keys():
                self.nodes[key].value = value
            else:
                self.nodes[key] = LinkNode(key, value)
                self.size += 1
                if self.size > self.capacity:
                    last = self.tail.prev
                    del self.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 and node.next:
                node.prev.connect(node.next)
            node.connect(self.head.next)
            self.head.connect(node)

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

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

    import random
    import time
    import sys
    
    n = 10000
    
    # 长度为n的字典, key值在[0, n - 1]之间, value为a-z的某个字母
    dict = {k: chr(random.randrange(97, 123)) for k in range(n)}
    
    # 用列表来存放dic的所有信息
    list = [None] * 10000
    for k, v in dict.items():
        list[k] = v
    
    
    def test(data):
        print(type(data))
        print(f'size:{sys.getsizeof(data)}')
        start = time.time()
        for _ in range(100):
            for key in range(n):
                value = data[key]
        print('time cost: {:.5f}s'.format(time.time() - start))
    
    
    test(dict)
    test(list)

    程序运行结果如下:

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

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

    4. 小结

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

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

  • 相关阅读:
    android 瀑布流的实现详解,附源码
    NodeJs新手学习笔记之工具准备
    android app的类响应式设计
    开源一个友盟 for android 操作的封装包
    谈谈多门程序语言的学习策略之一
    谈谈android 布局 的优化
    android 滑动指引页的设计
    彻底弄懂CSS盒子模式
    关于内容管理系统IWMS的几个问题
    数码相机常用英文缩写对照表
  • 原文地址:https://www.cnblogs.com/q1214367903/p/14213834.html
Copyright © 2020-2023  润新知