今天来学习链表(上)。
引言
缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的CPU缓存、数据库缓存、浏览器缓存等等。
缓存大小有限,当缓存被用满时,就涉及到了缓存淘汰策略。
- 先进先出策略
FIFO
(First In, First Out)
- 最少使用策略
LFU
(Least Frequently Used)
- 最近最少使用策略
LRU
(Least Recently Used)
经典的链表应用场景,LRU
缓存淘汰算法。
五花八门的链表结构
单链表
首先需要搞清几个概念
- 链表结点:存储数据和后继指针的内存块
- 后继指针:记录下个结点地址的指针
- 头结点:第一个结点
- 尾结点:最后一个结点,指向一个空地址NULL
插入删除操作时间复杂度O(1),随机访问时间复杂度O(n)
循环链表
循环链表是一种特殊的单链表,它跟单链表唯一的区别就在尾结点。单链表的尾结点指针指向空地址。而循环链表的尾结点指针是指向链表的头结点。
和单链表相比,循环链表的优点是从链尾到链头比较方便。当要处理的数据具有环形结构特点时,就特别适合采用循环链表,比如著名的约瑟夫问题。尽管用单链表也可以实现,但使用循环链表实现的话,代码会简洁很多。
双向链表
在实际的软件开发,双向链表更加常用。
单链表只有一个方向,结点只有一个后继指针,next
指向后面的结点。而双向链表,它支持两个方向,每个结点不止有一个后继指针next
指向后面的结点,还有一个前驱指针prev
指向前面的结点。
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址。所以,如果存储同样多的数据,双向链表要比单链表占用更多的内存空间。虽然两个指针比较浪费存储空间,但可以支持双向遍历,这样带来了双向链表操作的灵活性。
单链表 vs 双向链表
在实际的软件开发中,从链表删除一个数据无外乎这两种情况:
- 删除结点中“值等于某个给定值”的结点
- 删除给定指针指向的结点
对于第一种情况,无论是单链表和双向链表,都需要遍历查找。所以删除操作的时间复杂度是O(1),遍历查找的时间复杂度是O(n),根据加法法则,总的时间复杂度O(n)。
对于第二种情况,由于删除某个结点q
需要知道其前驱结点,而单链表并不支持直接获取前驱结点,时间复杂度O(n);而双向链表中的结点已经保存了前驱结点的指针,不需要像单链表那样遍历,时间复杂度O(1)
同理在链表的某个结点前面或者后面插入一个结点,双向链表时间复杂度O(1);单链表时间复杂度O(n)
除了插入、删除操作有优势之外,对于一个有序链表,双向链表的按值查询的效率也要比单链表高一些。因为双向链表可以记录上次查找的位置p
,每次查询时,根据要查询值和位置p的值的大小关系,决定往前查找还是往后查找,所以平均只需要查找一半的数据。
Java
语言中的LinkedHashMap
就用到了双向链表,尽管比较费内存,但是应用比较广泛。
这里面,涉及到一个重要的涉及思想:空间换时间。对于执行比较慢的程序,可以通过消耗更多的内存(空间换时间),来进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。
双向循环链表
概念容易理解,但是代码写起来更复杂了....
数组 vs 链表
底层结构上:数组连续的内存空间;链表零散的内存块
时间复杂度:
插入、删除 |
O(n) |
O(1) |
随机访问 |
O(1) |
O(n) |
在实际开发中,不能简单的根据时间复杂度来决定使用哪个数据结构来存储数据。
数组简单易用,在实现上使用的是连续的内存空间,可以借助CPU的缓存机制,预读数组中的数据,访问效率更高。而链表在内存中并不是连续存储,所以对CPU缓存不友好,没办法有效预读。
数组的缺点是大小固定,一经声明就要占用整块连续内存。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足”。如果声明的数组过小,则可能出现不够用的情况。这是只能再申请一个更大的内存空间,把原数组拷贝过去,非常耗时。链表本身没有大小的限制,天然地支持动态扩容。这是数组和链表最大的区别
除此之外,如果对内存的使用非常苛刻,那么数组更适合。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片。
解答开篇
如何基于链表实现LRU缓存淘汰算法?
思路:维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历单链表。
- 如果此数据之前已经被缓存在链表中,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。
- 如果此数据没有在缓存链表中,又可以分为两种情况:
- 如果此时缓存未满,则将此结点直接插入到链表的头部;
- 如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
时间复杂度O(n)
思考:如何利用数组来实现LRU缓存淘汰策略?
思考
如何判断一个字符串是否是回文字符串的问题?
这个问题考查了两个知识点:
- 快慢指针
- 反转相邻结点
花了一个下午写了关于链表的一些基本操作—。—,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309
| class Node: """ 单链表-结点 """ def __init__(self, data, next_node=None): self.data = data self.next = next_node class SingleLinkedList(object): """ 单链表 """ def __init__(self): self.__head = None def find_by_value(self, value): """ 根据value在链表中查找 时间复杂度O(n) :return: node """ node = self.__head while (node is not None) and (node.data != value): node = node.next return node
def find_by_index(self, index): """ 根据index在链表中查找 时间复杂度O(n) :return: node """ node = self.__head pos = 0 while (node is not None) and (pos != index): node = node.next pos += 1 return node
def insert_to_head(self, value): """ 头结点插入 时间复杂度O(1) :return: None """ node = Node(value) node.next = self.__head self.__head = node
def insert_after(self, node, value): """ 在链表的某个指定Node节点之后插入一个存储value数据的Node节点 时间复杂度O(1) :return: None """ if not node: return new_node = Node(value)
new_node.next = node.next node.next = new_node
def insert_before(self, node, value): """ 在链表的某个指定Node节点之前插入一个存储value数据的Node节点 时间复杂度O(n) :return: None """ if (node is None) or (self.__head is None): return
if node == self.__head: self.insert_to_head(value) return
new_node = Node(value) pos = self.__head not_found = False while pos.next != node: if pos.next is None: not_found = True break pos = pos.next
if not_found: pos.next = new_node new_node.next = node
def delete_by_node(self, node): """ 在链表中删除指定Node的节点 时间复杂度O(n) :return: None """ if self.__head is None: return
if node == self.__head: self.__head = self.__head.next return
pos = self.__head not_found = False while pos.next != node: if pos.next is None: not_found = True break pos = pos.next
if not_found: pos.next = node.next
def delete_by_value(self, value): """ 在链表中删除指定存储数据的Node节点 时间复杂度O(n) :return: None """ if self.__head is None: return
if self.__head.data == value: self.__head = self.__head.next return
pre = self.__head node = self.__head.next not_found = False while node.data != value: if node.next is None: not_found = True break
pre = node node = node.next
if not_found: pre.next = node.next
def delete_last_n_node(self, n): """ 删除链表中倒数第N个节点 主要思路: 设置快、慢两个指针,快指针先行,慢指针不动;当快指针走了N步时,快慢指针同时向链表尾部移动, 当快指针到达链表尾部时,慢指针指向的就是链表倒数第N个结点(脑子绕不过弯来时,画画图就明白了) 时间复杂度O(n)
** 单链表删除结点的时候,一定要注意保存被删除结点的前驱结点 :return: None """ fast = self.__head slow = self.__head step = 0 while step <= n: fast = fast.next step += 1
tmp = None while fast.next is not None: tmp = slow slow = slow.next fast = fast.next
tmp.next = slow.next
def find_mid_node(self): """ 查找链表中的中间节点 主要思想: 设置快慢指针,快指针每次跨两步,满指针每次跨一步,则当快指针到达链表尾部的时候,慢指针指向链表的中间结点 同理 脑子绕不过弯时,画画图就明白了 时间复杂度O(n) :return: node """ fast = self.__head slow = self.__head
while (fast is not None) and (fast.next is not None): fast = fast.next.next slow = slow.next return slow
def create_node(self, value): """ 创建一个存储value值的Node节点 :return: node """ return Node(value)
def print_all(self): """ 打印当前链表所有节点数据 时间复杂度O(n) :return: None """ if self.__head is None: return
node = self.__head while node.next is not None: print(node.data, end='') node = node.next print(node.data)
def reversed_self(self): """ 翻转链表 时间复杂度O(n) :return: None """ pre = self.__head node = self.__head.next
while node is not None: pre, node = self.__reversed_with_two_node(pre, node)
self.__head.next = None self.__head = pre
def __reversed_with_two_node(self, pre, node): """ 翻转相邻两个结点 思路:利用一个中间结点tmp :return: node, node """ tmp = node.next node.next = pre
pre = node node = tmp return pre, node
def has_ring(self): """ 检测是否有环 主要思想: 设置快、慢两个指针,快指针每次跨两步、慢指针每次跨一步, 如果快指针没有与慢指针相遇而是顺利到达链表尾部,说明没有环,反之存在环 自己画画图看看 就明白了 时间复杂度O(n) :return: True or False """ fast = self.__head slow = self.__head
while (fast.next is not None) and (fast is not None): fast = fast.next.next slow = slow.next if fast == slow: return True
return False
def reverse_head(self, head): """ 根据给的结点,翻转之后的结点 :return: node """ reverse_head = None while head: next_node = head.next head.next = reverse_head reverse_head = head head = next_node return reverse_head
def is_palindrome(self): """ 判断是否是回文串 主要思路:快、慢两个指针,快指针每次前进两步、慢指针每次前进一步。当快指针到达链表尾部时,慢指针到达链表中间节点,翻转慢指针到链表尾部的这部分结 点,然后依次对比链表头部和翻转后的部分链表数据是否相同 :return: True or False """ slow = self.__head fast = self.__head
while fast and fast.next: fast = fast.next.next slow = slow.next
head_a = self.__head head_b = self.reverse_head(slow)
is_palin = True while head_a and head_b: if head_a.data == head_b.data: head_a = head_a.next head_b = head_b.next else: is_palin = False break return is_palin
|
总结
写链表代码时有几点感受:
- 在删除链表结点的时候一定要注意利用中间变量保存前驱结点
- 快慢指针法在链表中有着广泛的应用,比如说查找中间结点、检测链表是否包含环、判断是否为回文串、删除链表倒数第N个结点等等。
- 多写多练
参考链接:
- 数据结构与算法之美相关代码