Leetcode精选50题-Day12
146 LRU缓存机制
1. 题目描述
运用你所掌握的数据结构,设计和实现一个 LRU
(最近最少使用) 缓存机制 。
实现 LRUCache
类:
LRUCache(int capacity)
以正整数作为容量capacity
初始化LRU
缓存int get(int key)
如果关键字key
存在于缓存中,则返回关键字的值,否则返回-1
。void put(int key, int value)
如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」
。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
进阶:你是否可以在
O
(
1
)
O(1)
O(1) 时间复杂度内完成这两种操作?
2. 思路&代码
哈希表 + 双向链表
算法
LRU 缓存机制可以通过哈希表辅以双向链表实现,我们用一个哈希表和一个双向链表维护所有在缓存中的键值对。
-
双向链表按照被使用的顺序存储了这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。
-
哈希表即为普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置。
这样以来,我们首先使用哈希表进行定位,找出缓存项在双向链表中的位置,随后将其移动到双向链表的头部,即可在 O ( 1 ) O(1) O(1) 的时间内完成 get 或者 put 操作。具体的方法如下:
-
对于
get
操作,首先判断key
是否存在:
如果key
不存在,则返回 −1;
如果key
存在,则key
对应的节点是最近被使用的节点。通过哈希表定位到该节点在双向链表中的位置,并将其移动到双向链表的头部,最后返回该节点的值。 -
对于
put
操作,首先判断key
是否存在:
如果key
不存在,使用key
和value
创建一个新的节点,在双向链表的头部添加该节点,并将key
和该节点添加进哈希表中。然后判断双向链表的节点数是否超出容量,如果超出容量,则删除双向链表的尾部节点,并删除哈希表中对应的项;
如果key
存在,则与get
操作类似,先通过哈希表定位,再将对应的节点的值更新为value
,并将该节点移到双向链表的头部。
上述各项操作中,访问哈希表的时间复杂度为 O ( 1 ) O(1) O(1),在双向链表的头部添加节点、在双向链表的尾部删除节点的复杂度也为 O ( 1 ) O(1) O(1)。而将一个节点移到双向链表的头部,可以分成「删除该节点」和「在双向链表的头部添加节点」两步操作,都可以在 O ( 1 ) O(1) O(1) 时间内完成。
小贴士
在双向链表的实现中,使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。
class DLinkedNode:
def __init__(self, key=0, value=0):
self.key = key
self.value = value
self.prev = None
self.next = None
class LRUCache:
def __init__(self, capacity: int):
self.cache = dict()
# 使用伪头部和伪尾部节点
self.head = DLinkedNode()
self.tail = DLinkedNode()
self.head.next = self.tail
self.tail.prev = self.head
self.capacity = capacity
self.size = 0
def get(self, key: int) -> int:
if key not in self.cache:
return -1
# 如果 key 存在,先通过哈希表定位,再移到头部
node = self.cache[key]
self.moveToHead(node)
return node.value
def put(self, key: int, value: int) -> None:
if key not in self.cache:
# 如果 key 不存在,创建一个新的节点
node = DLinkedNode(key, value)
# 添加进哈希表
self.cache[key] = node
# 添加至双向链表的头部
self.addToHead(node)
self.size += 1
if self.size > self.capacity:
# 如果超出容量,删除双向链表的尾部节点
removed = self.removeTail()
# 删除哈希表中对应的项
self.cache.pop(removed.key)
self.size -= 1
else:
# 如果 key 存在,先通过哈希表定位,再修改 value,并移到头部
node = self.cache[key]
node.value = value
self.moveToHead(node)
def addToHead(self, node):
node.prev = self.head
node.next = self.head.next
self.head.next.prev = node
self.head.next = node
def removeNode(self, node):
node.prev.next = node.next
node.next.prev = node.prev
def moveToHead(self, node):
self.removeNode(node)
self.addToHead(node)
def removeTail(self):
node = self.tail.prev
self.removeNode(node)
return node
复杂度分析
时间复杂度:对于 put
和 get
都是
O
(
1
)
O(1)
O(1)。
空间复杂度: O ( capacity ) O(\text{capacity}) O(capacity),因为哈希表和双向链表最多存储 capacity + 1 \text{capacity} + 1 capacity+1个元素。
148 排序链表
1. 题目描述
给你链表的头结点 head ,请将其按 升序
排列并返回 排序后的链表 。
-
进阶:
你可以在 O ( n l o g n ) O(n log n) O(nlogn) 时间复杂度和常数级空间复杂度下,对链表进行排序吗?
2. 思路&代码
「147. 对链表进行插入排序」要求使用插入排序的方法对链表进行排序,插入排序的时间复杂度是 O ( n 2 ) O(n^2) O(n2) ,其中 n n n 是链表的长度。这道题考虑时间复杂度更低的排序算法。题目的进阶问题要求达到 O ( n log n ) O(n \log n) O(nlogn) 的时间复杂度和 O ( 1 ) O(1) O(1) 的空间复杂度,时间复杂度是 O ( n log n ) O(n \log n) O(nlogn) 的排序算法包括归并排序、堆排序和快速排序(快速排序的最差时间复杂度是 O ( n 2 ) O(n^2) O(n2) ),其中最适合链表的排序算法是归并排序。
归并排序基于分治算法。最容易想到的实现方式是自顶向下的递归实现,考虑到递归调用的栈空间,自顶向下归并排序的空间复杂度是 O ( log n ) O(\log n) O(logn)。如果要达到 O ( 1 ) O(1) O(1) 的空间复杂度,则需要使用自底向上的实现方式。
方法一:自顶向下归并排序
对链表自顶向下归并排序的过程如下。
-
找到链表的中点,以中点为分界,将链表拆分成两个子链表。寻找链表的中点可以使用快慢指针的做法,快指针每次移动 2 步,慢指针每次移动 1 步,当快指针到达链表末尾时,慢指针指向的链表节点即为链表的中点。
-
对两个子链表分别排序。
-
将两个排序后的子链表合并,得到完整的排序后的链表。可以使用「21. 合并两个有序链表」的做法,将两个有序的子链表进行合并。
上述过程可以通过递归实现。递归的终止条件是链表的节点个数小于或等于 1,即当链表为空或者链表只包含 1 个节点时,不需要对链表进行拆分和排序。
class Solution:
def sortList(self, head: ListNode) -> ListNode:
def sortFunc(head: ListNode, tail: ListNode) -> ListNode:
if not head:
return head
if head.next == tail:
head.next = None
return head
slow = fast = head
while fast != tail:
slow = slow.next
fast = fast.next
if fast != tail:
fast = fast.next
mid = slow
return merge(sortFunc(head, mid), sortFunc(mid, tail))
def merge(head1: ListNode, head2: ListNode) -> ListNode:
dummyHead = ListNode(0)
temp, temp1, temp2 = dummyHead, head1, head2
while temp1 and temp2:
if temp1.val <= temp2.val:
temp.next = temp1
temp1 = temp1.next
else:
temp.next = temp2
temp2 = temp2.next
temp = temp.next
if temp1:
temp.next = temp1
elif temp2:
temp.next = temp2
return dummyHead.next
return sortFunc(head, None)
复杂度分析
-
时间复杂度: O ( n log n ) O(n \log n) O(nlogn),其中 n n n 是链表的长度。
-
空间复杂度: O ( log n ) O(\log n) O(logn),其中 n n n 是链表的长度。空间复杂度主要取决于递归调用的栈空间。
方法二:自底向上归并排序
使用自底向上的方法实现归并排序,则可以达到 O ( 1 ) O(1) O(1) 的空间复杂度。
首先求得链表的长度 length \textit{length} length , 然后将链表拆分成子链表进行合并。
具体做法如下:
-
用 subLength \textit{subLength} subLength 表示每次需要排序的子链表的长度,初始时 subLength = 1 \textit{subLength}=1 subLength=1。
-
每次将链表拆分成若干个长度为 subLength \textit{subLength} subLength 的子链表(最后一个子链表的长度可以小于 subLength \textit{subLength} subLength),按照每两个子链表一组进行合并,合并后即可得到若干个长度为 subLength × 2 \textit{subLength} \times 2 subLength×2 的有序子链表(最后一个子链表的长度可以小于 subLength × 2 \textit{subLength} \times 2 subLength×2)。合并两个子链表仍然使用「21. 合并两个有序链表」的做法。
-
将 subLength \textit{subLength} subLength 的值加倍,重复第 2 步,对更长的有序子链表进行合并操作,直到有序子链表的长度大于或等于 length \textit{length} length,整个链表排序完毕。
如何保证每次合并之后得到的子链表都是有序的呢?可以通过数学归纳法证明。
初始时 subLength = 1 \textit{subLength}=1 subLength=1,每个长度为 1 的子链表都是有序的。
如果每个长度为 subLength \textit{subLength} subLength 的子链表已经有序,合并两个长度为 subLength \textit{subLength} subLength 的有序子链表,得到长度为 subLength × 2 \textit{subLength} \times 2 subLength×2 的子链表,一定也是有序的。
当最后一个子链表的长度小于 subLength \textit{subLength} subLength 时,该子链表也是有序的,合并两个有序子链表之后得到的子链表一定也是有序的。
class Solution:
def sortList(self, head: ListNode) -> ListNode:
def merge(head1: ListNode, head2: ListNode) -> ListNode:
dummyHead = ListNode(0)
temp, temp1, temp2 = dummyHead, head1, head2
while temp1 and temp2:
if temp1.val <= temp2.val:
temp.next = temp1
temp1 = temp1.next
else:
temp.next = temp2
temp2 = temp2.next
temp = temp.next
if temp1:
temp.next = temp1
elif temp2:
temp.next = temp2
return dummyHead.next
if not head:
return head
length = 0
node = head
while node:
length += 1
node = node.next
dummyHead = ListNode(0, head)
subLength = 1
while subLength < length:
prev, curr = dummyHead, dummyHead.next
while curr:
head1 = curr
for i in range(1, subLength):
if curr.next:
curr = curr.next
else:
break
head2 = curr.next
curr.next = None
curr = head2
for i in range(1, subLength):
if curr and curr.next:
curr = curr.next
else:
break
succ = None
if curr:
succ = curr.next
curr.next = None
merged = merge(head1, head2)
prev.next = merged
while prev.next:
prev = prev.next
curr = succ
subLength <<= 1
return dummyHead.next
复杂度分析
-
时间复杂度: O ( n log n ) O(n \log n) O(nlogn),其中 n n n 是链表的长度。
-
空间复杂度: O ( 1 ) O(1) O(1)。
155 最小栈
1. 题目描述
2. 思路&代码
方法一:辅助栈
思路
要做出这道题目,首先要理解栈结构先进后出的性质。
对于栈来说,如果一个元素 a
在入栈时,栈里有其它的元素 b, c, d
,那么无论这个栈在之后经历了什么操作,只要 a
在栈中,b, c, d
就一定在栈中,因为在 a
被弹出之前,b, c, d
不会被弹出。
因此,在操作过程中的任意一个时刻,只要栈顶的元素是 a
,那么我们就可以确定栈里面现在的元素一定是 a, b, c, d
。
那么,我们可以在每个元素 a
入栈时把当前栈的最小值 m
存储起来。在这之后无论何时,如果栈顶元素是 a
,我们就可以直接返回存储的最小值 m
。
算法
按照上面的思路,我们只需要设计一个数据结构,使得每个元素 a
与其相应的最小值 m
时刻保持一一对应。因此我们可以使用一个辅助栈,与元素栈同步插入与删除,用于存储与每个元素对应的最小值。
-
当一个元素要入栈时,我们取当前辅助栈的栈顶存储的最小值,与当前元素比较得出最小值,将这个最小值插入辅助栈中;
-
当一个元素要出栈时,我们把辅助栈的栈顶元素也一并弹出;
-
在任意一个时刻,栈内元素的最小值就存储在辅助栈的栈顶元素中。
class MinStack:
def __init__(self):
self.stack = []
self.min_stack = [math.inf]
def push(self, x: int) -> None:
self.stack.append(x)
self.min_stack.append(min(x, self.min_stack[-1]))
def pop(self) -> None:
self.stack.pop()
self.min_stack.pop()
def top(self) -> int:
return self.stack[-1]
def getMin(self) -> int:
return self.min_stack[-1]
复杂度分析
-
时间复杂度:对于题目中的所有操作,时间复杂度均为 O ( 1 ) O(1) O(1)。因为栈的插入、删除与读取操作都是 O ( 1 ) O(1) O(1),我们定义的每个操作最多调用栈操作两次。
-
空间复杂度: O ( n ) O(n) O(n),其中 n n n 为总操作数。最坏情况下,我们会连续插入 n n n 个元素,此时两个栈占用的空间为 O ( n ) O(n) O(n)。