链式结构
上一节讲到了支持随机访问的线性结构,这次我们开始讲链式结构, 视频里我会说下这两种结构的区别,然后讲解最常见的单链表和双链表。
之前在专栏文章那些年,我们一起跪过的算法题[视频]里实现过一个 lru_cache,
使用到的就是循环双端链表,如果感觉这篇文章有点难理解,我们这里将会循序渐进地来实现。
后边讲到哈希表的冲突解决方式的时候,我们会再次提到链表。
上一节我们分析了 list 的各种操作是如何实现的,如果你还有印象的话,list
在头部进行插入是个相当耗时的操作(需要把后边的元素一个一个挪个位置)。假如你需要频繁在数组两头增删,list 就不太合适。
今天我们介绍的链式结构将摆脱这个缺陷,当然了链式结构本身也有缺陷,比如你不能像数组一样随机根据下标访问,你想查找一个元素只能老老实实从头遍历。
所以嘛,学习和了解数据结构的原理和实现你才能准确地选择到底什么时候该用什么数据结构,而不是瞎选导致代码性能很差。
单链表
和线性结构不同,链式结构内存不连续的,而是一个个串起来的,这个时候就需要每个链接表的节点保存一个指向下一个节点的指针。
这里可不要混淆了列表和链表(它们的中文发音类似,但是列表 list 底层其实还是线性结构,链表才是真的通过指针关联的链式结构)。
看到指针你也不用怕,这里我们用的 python,你只需要一个简单赋值操作就能实现,不用担心 c 语言里复杂的指针。
先来定义一个链接表的节点,刚才说到有一个指针保存下一个节点的位置,我们叫它 next, 当然还需要一个 value 属性保存值
class Node(object):def __init__(self, value, next=None):self.value = valueself.next = next
然后就是我们的单链表 LinkedList ADT:
class LinkedList(object):""" 链接表 ADT[root] -> [node0] -> [node1] -> [node2]"""
实现我们会在视频中用画图来模拟并且手动代码实现,代码里我们会标识每个步骤的时间复杂度。这里请高度集中精力,
虽然链表的思想很简单,但是想要正确写对链表的操作代码可不容易,稍不留神就可能丢失一些步骤。
这里我们还是会用简单的单测来验证代码是否按照预期工作。
来看下时间复杂度:
链表操作 | 平均时间复杂度 |
---|---|
linked_list.append(value) | O(1) |
linked_list.appendleft(value) | O(1) |
linked_list.find(value) | O(n) |
linked_list.remove(value) | O(n) |
双链表
上边我们亲自实现了一个单链表,但是能看到很明显的问题,单链表虽然 append 是 O(1),但是它的 find 和 remove 都是 O(n)的,
因为删除你也需要先查找,而单链表查找只有一个方式就是从头找到尾,中间找到才退出。
这里我之前提到过如果要实现一个 lru 缓存(访问时间最久的踢出),我们需要在一个链表里能高效的删除元素,
并把它追加到访问表的最后一个位置,这个时候单链表就满足不了了,
因为缓存在 dict 里查找的时间是 O(1),你更新访问顺序就 O(n)了,缓存就没了优势。
这里就要使用到双链表了,相比单链表来说,每个节点既保存了指向下一个节点的指针,同时还保存了上一个节点的指针。
class Node(object):# 如果节点很多,我们可以用 __slots__ 来节省内存,把属性保存在一个 tuple 而不是 dict 里# 感兴趣可以自行搜索 python __slots____slots__ = ('value', 'prev', 'next')def __init__(self, value=None, prev=None, next=None):self.value, self.prev, self.next = value, prev, next
对, 就多了 prev,有啥优势嘛?
- 看似我们反过来遍历双链表了。反过来从哪里开始呢?我们只要让 root 的 prev 指向 tail 节点,不就串起来了吗?
- 直接删除节点,当然如果给的是一个值,我们还是需要查找这个值在哪个节点? - 但是如果给了一个节点,我们把它拿掉,直接让它的前后节点互相指过去不就行了?哇欧,删除就是 O(1) 了,两步操作就行啦
好,废话不多说,我们在视频里介绍怎么实现一个双链表 ADT。你可以直接在本项目的 docs/03_链表/double_link_list.py
找到代码。
最后让我们看下它的时间复杂度:(这里 CircularDoubleLinkedList 取大写字母缩写为 cdll)
循环双端链表操作 | 平均时间复杂度 |
---|---|
cdll.append(value) | O(1) |
cdll.appendleft(value) | O(1) |
cdll.remove(node),注意这里参数是 node | O(1) |
cdll.headnode() | O(1) |
cdll.tailnode() | O(1) |
源码
double_link_list.py
# -*- coding: utf-8 -*-class Node(object):__slots__ = ('value', 'prev', 'next') # save memorydef __init__(self, value=None, prev=None, next=None):self.value, self.prev, self.next = value, prev, nextclass CircularDoubleLinkedList(object):"""循环双端链表 ADT多了个循环其实就是把 root 的 prev 指向 tail 节点,串起来"""def __init__(self, maxsize=None):self.maxsize = maxsizenode = Node()node.next, node.prev = node, nodeself.root = nodeself.length = 0def __len__(self):return self.lengthdef headnode(self):return self.root.nextdef tailnode(self):return self.root.prevdef append(self, value): # O(1), 你发现一般不用 for 循环的就是 O(1),有限个步骤if self.maxsize is not None and len(self) >= self.maxsize:raise Exception('LinkedList is Full')node = Node(value=value)tailnode = self.tailnode() or self.roottailnode.next = nodenode.prev = tailnodenode.next = self.rootself.root.prev = nodeself.length += 1def appendleft(self, value):if self.maxsize is not None and len(self) >= self.maxsize:raise Exception('LinkedList is Full')node = Node(value=value)if self.root.next is self.root: # emptynode.next = self.rootnode.prev = self.rootself.root.next = nodeself.root.prev = nodeelse:node.prev = self.rootheadnode = self.root.nextnode.next = headnodeheadnode.prev = nodeself.root.next = nodeself.length += 1def remove(self, node): # O(1),传入node 而不是 value 我们就能实现 O(1) 删除"""remove:param node # 在 lru_cache 里实际上根据key 保存了整个node:"""if node is self.root:returnelse: #node.prev.next = node.nextnode.next.prev = node.prevself.length -= 1return nodedef iter_node(self):if self.root.next is self.root:returncurnode = self.root.nextwhile curnode.next is not self.root:yield curnodecurnode = curnode.nextyield curnodedef __iter__(self):for node in self.iter_node():yield node.valuedef iter_node_reverse(self):"""相比单链表独有的反序遍历"""if self.root.prev is self.root:returncurnode = self.root.prevwhile curnode.prev is not self.root:yield curnodecurnode = curnode.prevyield curnodedef test_double_link_list():dll = CircularDoubleLinkedList()assert len(dll) == 0dll.append(0)dll.append(1)dll.append(2)assert list(dll) == [0, 1, 2]assert [node.value for node in dll.iter_node()] == [0, 1, 2]assert [node.value for node in dll.iter_node_reverse()] == [2, 1, 0]headnode = dll.headnode()assert headnode.value == 0dll.remove(headnode)assert len(dll) == 2assert [node.value for node in dll.iter_node()] == [1, 2]dll.appendleft(0)assert [node.value for node in dll.iter_node()] == [0, 1, 2]if __name__ == '__main__':test_double_link_list()
linked_list.py
# -*- coding: utf-8 -*-class Node(object):def __init__(self, value=None, next=None): # 这里我们 root 节点默认都是 None,所以都给了默认值self.value = valueself.next = nextdef __str__(self):"""方便你打出来调试,复杂的代码可能需要断点调试"""return '<Node: value: {}, next={}>'.format(self.value, self.next)__repr__ = __str__class LinkedList(object):""" 链接表 ADT[root] -> [node0] -> [node1] -> [node2]"""def __init__(self, maxsize=None):""":param maxsize: int or None, 如果是 None,无限扩充"""self.maxsize = maxsizeself.root = Node() # 默认 root 节点指向 Noneself.tailnode = Noneself.length = 0def __len__(self):return self.lengthdef append(self, value): # O(1)if self.maxsize is not None and len(self) >= self.maxsize:raise Exception('LinkedList is Full')node = Node(value) # 构造节点tailnode = self.tailnodeif tailnode is None: # 还没有 append 过,length = 0, 追加到 root 后self.root.next = nodeelse: # 否则追加到最后一个节点的后边,并更新最后一个节点是 append 的节点tailnode.next = nodeself.tailnode = nodeself.length += 1def appendleft(self, value):if self.maxsize is not None and len(self) >= self.maxsize:raise Exception('LinkedList is Full')node = Node(value)if self.tailnode is None: # 如果原链表为空,插入第一个元素需要设置 tailnodeself.tailnode = nodeheadnode = self.root.nextself.root.next = nodenode.next = headnodeself.length += 1def __iter__(self):for node in self.iter_node():yield node.valuedef iter_node(self):"""遍历 从 head 节点到 tail 节点"""curnode = self.root.nextwhile curnode is not self.tailnode: # 从第一个节点开始遍历yield curnodecurnode = curnode.next # 移动到下一个节点if curnode is not None:yield curnodedef remove(self, value): # O(n)""" 删除包含值的一个节点,将其前一个节点的 next 指向被查询节点的下一个即可:param value:"""prevnode = self.root #for curnode in self.iter_node():if curnode.value == value:prevnode.next = curnode.nextif curnode is self.tailnode: # NOTE: 注意更新 tailnodeif prevnode is self.root:self.tailnode = Noneelse:self.tailnode = prevnodedel curnodeself.length -= 1return 1 # 表明删除成功else:prevnode = curnodereturn -1 # 表明删除失败def find(self, value): # O(n)""" 查找一个节点,返回序号,从 0 开始:param value:"""index = 0for node in self.iter_node(): # 我们定义了 __iter__,这里就可以用 for 遍历它了if node.value == value:return indexindex += 1return -1 # 没找到def popleft(self): # O(1)""" 删除第一个链表节点"""if self.root.next is None:raise Exception('pop from empty LinkedList')headnode = self.root.nextself.root.next = headnode.nextself.length -= 1value = headnode.valueif self.tailnode is headnode: # 勘误:增加单节点删除 tailnode 处理self.tailnode = Nonedel headnodereturn valuedef clear(self):for node in self.iter_node():del nodeself.root.next = Noneself.length = 0self.tailnode = Nonedef reverse(self):"""反转链表"""curnode = self.root.nextself.tailnode = curnode # 记得更新 tailnode,多了这个属性处理起来经常忘记prevnode = Nonewhile curnode:nextnode = curnode.nextcurnode.next = prevnodeif nextnode is None:self.root.next = curnodeprevnode = curnodecurnode = nextnodedef test_linked_list():ll = LinkedList()ll.append(0)ll.append(1)ll.append(2)ll.append(3)assert len(ll) == 4assert ll.find(2) == 2assert ll.find(-1) == -1assert ll.remove(0) == 1assert ll.remove(10) == -1assert ll.remove(2) == 1assert len(ll) == 2assert list(ll) == [1, 3]assert ll.find(0) == -1ll.appendleft(0)assert list(ll) == [0, 1, 3]assert len(ll) == 3headvalue = ll.popleft()assert headvalue == 0assert len(ll) == 2assert list(ll) == [1, 3]assert ll.popleft() == 1assert list(ll) == [3]ll.popleft()assert len(ll) == 0assert ll.tailnode is Nonell.clear()assert len(ll) == 0assert list(ll) == []def test_linked_list_remove():ll = LinkedList()ll.append(3)ll.append(4)ll.append(5)ll.append(6)ll.append(7)ll.remove(7)print(list(ll))def test_single_node():# https://github.com/PegasusWang/python_data_structures_and_algorithms/pull/21ll = LinkedList()ll.append(0)ll.remove(0)ll.appendleft(1)assert list(ll) == [1]def test_linked_list_reverse():ll = LinkedList()n = 10for i in range(n):ll.append(i)ll.reverse()assert list(ll) == list(reversed(range(n)))def test_linked_list_append():ll = LinkedList()ll.appendleft(1)ll.append(2)assert list(ll) == [1, 2]if __name__ == '__main__':test_single_node()test_linked_list()test_linked_list_append()test_linked_list_reverse()
lru_cache.py
"""
python3 only
LRU cache
"""
from collections import OrderedDict
from functools import wrapsdef fib(n):if n <= 1: # 0 or 1return nreturn f(n - 1) + f(n - 2) # 由于涉及到重复计算,这个递归函数在 n 大了以后会非常慢。 O(2^n)"""
下边就来写一个缓存装饰器来优化它。传统方法是用个数组记录之前计算过的值,但是这种方式不够 Pythonic
"""def cache(func):"""先引入一个简单的装饰器缓存,其实原理很简单,就是内部用一个字典缓存已经计算过的结果"""store = {}@wraps(func)def _(n): # 这里函数没啥意义就随便用下划线命名了if n in store:return store[n]else:res = func(n)store[n] = resreturn resreturn _@cache
def f(n):if n <= 1: # 0 or 1return nreturn f(n - 1) + f(n - 2)"""
问题来了,假如空间有限怎么办,我们不可能一直向缓存塞东西,当缓存达到一定个数之后,我们需要一种策略踢出一些元素,
用来给新的元素腾出空间。
一般缓存失效策略有
- LRU(Least-Recently-Used): 替换掉最近请求最少的对象,实际中使用最广。cpu缓存淘汰和虚拟内存效果好,web应用欠佳
- LFU(Least-Frequently-Used): 缓存污染问题(一个先前流行的缓存对象会在缓存中驻留很长时间)
- First in First out(FIFO)
- Random Cache: 随机选一个删除LRU 是常用的一个,比如 redis 就实现了这个策略,这里我们来模拟实现一个。
要想实现一个 LRU,我们需要一种方式能够记录访问的顺序,并且每次访问之后我们要把最新使用到的元素放到最后(表示最新访问)。
当容量满了以后,我们踢出最早访问的元素。假如用一个链表来表示的话:[1] -> [2] -> [3]假设最后边是最后访问的,当访问到一个元素以后,我们把它放到最后。当容量满了,我们踢出第一个元素就行了。
一开始的想法可能是用一个链表来记录访问顺序,但是单链表有个问题就是如果访问了中间一个元素,我们需要拿掉它并且放到链表尾部。
而单链表无法在O(1)的时间内删除一个节点(必须要先搜索到它),但是双端链表可以,因为一个节点记录了它的前后节点,
只需要把要删除的节点的前后节点链接起来就行了。
还有个问题是如何把删除后的节点放到链表尾部,如果是循环双端链表就可以啦,我们有个 root 节点链接了首位节点,
只需要让 root 的前一个指向这个被删除节点,然后让之前的最后一个节点也指向它就行了。使用了循环双端链表之后,我们的操作就都是 O(1) 的了。这也就是使用一个 dict 和一个 循环双端链表 实现LRU 的思路。
不过一般我们使用内置的 OrderedDict(原理和这个类似)就好了,要实现一个循环双端链表是一个不简单的事情,因为链表操作很容易出错。补充:其实 lru 有个缺点就是额外的链表比较占用空间,如果你感兴趣的话可以看看 redis 如何实现的 lru 算法
"""class LRUCache:def __init__(self, capacity=128):self.capacity = capacity# 借助 OrderedDict 我们可以快速实现一个 LRUCache,OrderedDict 内部其实也是使用循环双端链表实现的# OrderedDict 有两个重要的函数用来实现 LRU,一个是 move_to_end,一个是 popitem,请自己看文档self.od = OrderedDict()def get(self, key, default=None):val = self.od.get(key, default) # 如果没有返回 default,保持 dict 语义self.od.move_to_end(key) # 每次访问就把key 放到最后表示最新访问return valdef add_or_update(self, key, value):if key in self.od: # updateself.od[key] = valueself.od.move_to_end(key)else: # insertself.od[key] = valueif len(self.od) > self.capacity: # fullself.od.popitem(last=False)def __call__(self, func):"""一个简单的 LRU 实现。有一些问题需要思考下:- 这里为了简化默认参数只有一个数字 n,假如可以传入多个参数,如何确定缓存的key 呢?- 这里实现没有考虑线程安全的问题,要如何才能实现线程安全的 LRU 呢?当然如果不是多线程环境下使用是不需要考虑的- 假如这里没有用内置的 dict,你能使用 redis 来实现这个 LRU 吗,如果使用了 redis,我们可以存储更多数据到服务器。而使用字典实际上是缓存了Python进程里(localCache)。- 这里只是实现了 lru 策略,你能同时实现一个超时 timeout 参数吗?比如像是memcache 实现的 lazy expiration 策略- LRU有个缺点就是,对于周期性的数据访问会导致命中率迅速下降,有一种优化是 LRU-K,访问了次数达到 k 次才会将数据放入缓存"""def _(n):if n in self.od:return self.get(n)else:val = func(n)self.add_or_update(n, val)return valreturn _@LRUCache(10)
def f_use_lru(n):if n <= 1: # 0 or 1return nreturn f_use_lru(n - 1) + f_use_lru(n - 2)def test():import timebeg = time.time()for i in range(34):print(f(i))print(time.time() - beg)beg = time.time()for i in range(34):print(f_use_lru(i))print(time.time() - beg)# TODO 要怎么给 lru 写单测?if __name__ == '__main__':test()######################################### 使用双链表实现 LRUcache ####################################################
"""
一般面试中不会让我们直接用内置结构,所以这里提供一个自己实现的双链表+map lru 缓存。这也是力扣上的一道真题:
[146] LRU 缓存 https://leetcode-cn.com/problems/lru-cache/description/
"""class ListNode:def __init__(self, key=None, value=None):self.key = keyself.value = valueself.prev = self.next = Noneclass List:def __init__(self):"""循环双链表。注意增加了虚拟头尾结点 head,tail 方便处理"""self.head = ListNode()self.tail = ListNode()self.head.prev = self.head.next = self.tailself.tail.next = self.tail.prev = self.headdef delete_node(self, node): # 删除指定节点node.prev.next = node.nextnode.next.prev = node.prevdef add_to_head(self, node): # 指定节点添加到 self.head 后nextnode = self.head.nextnode.next = nextnodenode.prev = self.headself.head.next = nodenextnode.prev = nodeclass LRUCache(object):def __init__(self, capacity):"""思路:循环双链表 + 字典:type capacity: int"""self.map = dict()self.ll = List()self.capacity = capacitydef get(self, key):""":type key: int:rtype: int"""if key not in self.map:return -1node = self.map[key]self.ll.delete_node(node)self.ll.add_to_head(node)return node.valuedef put(self, key, value):""":type key: int:type value: int:rtype: None"""if key in self.map: # 更新不会改变元素个数,这里不用判断是否需要剔除node = self.map[key]node.value = value # 修改结构体会也会修改 map 对应 value 的引用self.ll.delete_node(node)self.ll.add_to_head(node)else:if len(self.map) >= self.capacity: # 直接用 len(self.map) ,不需要self.size 字段了tailnode = self.ll.tail.prevself.ll.delete_node(tailnode)del self.map[tailnode.key]node = ListNode(key, value)self.map[key] = nodeself.ll.add_to_head(node)
小问题:
- 这里单链表我没有实现 insert 方法,你能自己尝试实现吗? insert(value, new_value),我想在某个值之前插入一个值。你同样需要先查找,所以这个步骤也不够高效。
- 你能尝试自己实现个 lru cache 吗?需要使用到我们这里提到的循环双端链表
- 借助内置的 collections.OrderedDict,它有两个方法 popitem 和 move_to_end,我们可以迅速实现一个 LRU cache。请你尝试用 OrderedDict 来实现。
- python 内置库的哪些数据结构使用到了本章讲的链式结构?
相关阅读
那些年,我们一起跪过的算法题- Lru cache[视频]
勘误:
视频中 LinkedList.remove 方法讲解有遗漏, linked_list.py 文件已经修正,请读者注意。具体请参考 fix linked_list & add gitigonre。视频最后增加了一段勘误说明。
Leetcode
反转链表 reverse-linked-list
这里有一道关于 LRU 的练习题你可以尝试下。
LRU Cache
合并两个有序链表 merge-two-sorted-lists