持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第19天,点击查看活动详情
146. LRU 缓存
题意解释
请为LRU缓存设计一个数据结构。支持两种操作:get和set。
get(key): 如果key在缓存中,则返回key对应的值(保证是正的);否则返回-1;put(key, value): 如果key在缓存中,则更新key对应的值;否则插入(key, value),如果缓存已满,则先删除上次使用时间最老的key。
思路
(双链表+哈希) O(1)
使用一个双链表和一个哈希表:
-
双链表存储一个节点被使用(get或者put)的时间戳,且按最近使用时间从左到右排好序,最先被使用的节点放在双链表的第一位,因此双链表的最后一位就是最久未被使用的节点;
-
哈希表存储
key对应的链表中的节点地址,用于key-value的增删改查;
初始化:
n是缓存大小;- 双链表和哈希表都为空;
get(key): 首先用哈希表判断key是否存在:
- 如果key不存在,则返回-1;
- 如果key存在,则返回对应的value,同时将key对应的节点放到双链表的最左侧;
put(key, value): 首先用哈希表判断key是否存在:
-
如果key存在,则修改对应的value,同时将key对应的节点放到双链表的最左侧;
-
如果key不存在:
- 如果缓存已满,则删除双链表最右侧的节点(上次使用时间最老的节点),更新哈希表;
- 否则,插入(key, value):同时将key对应的节点放到双链表的最左侧,更新哈希表;
对应的双链表的几种操作
1、删除p节点
p->right->left = p->left;
p->left->right = p->right;
2、在L节点之后插入p节点
p->right = L->right;
p->left = L;
L->right->left = p;
L->right = p;
时间复杂度分析:双链表和哈希表的增删改查操作的时间复杂度都是 O(1),所以get和set操作的时间复杂度也都是 O(1)。
c++代码
class LRUCache {
public:
//定义双链表
struct Node{
int key,value;
Node* left ,*right;
Node(int _key,int _value): key(_key),value(_value),left(NULL),right(NULL){}
}*L,*R;//双链表的最左和最右节点,不存贮值。
int n;
unordered_map<int,Node*>hash;
void remove(Node* p)
{
p->right->left = p->left;
p->left->right = p->right;
}
void insert(Node *p)
{
p->right = L->right;
p->left = L;
L->right->left = p;
L->right = p;
}
LRUCache(int capacity) {
n = capacity;
L = new Node(-1,-1),R = new Node(-1,-1);
L->right = R;
R->left = L;
}
int get(int key) {
if(hash.count(key) == 0) return -1; //不存在关键字 key
auto p = hash[key];
remove(p);
insert(p);//将当前节点放在双链表的第一位
return p->value;
}
void put(int key, int value) {
if(hash.count(key)) //如果key存在,则修改对应的value
{
auto p = hash[key];
p->value = value;
remove(p);
insert(p);
}
else
{
if(hash.size() == n) //如果缓存已满,则删除双链表最右侧的节点
{
auto p = R->left;
remove(p);
hash.erase(p->key); //更新哈希表
delete p; //释放内存
}
//否则,插入(key, value)
auto p = new Node(key,value);
hash[key] = p;
insert(p);
}
}
};
148. 排序链表
思路
(归并排序) O(nlogn)
自顶向下递归形式的归并排序,由于递归需要使用系统栈,递归的最大深度是 logn,所以需要额外 O(logn) 的空间。 所以我们需要使用自底向上非递归形式的归并排序算法。 基本思路是这样的,总共迭代 logn次:
- 第一次,将整个区间分成连续的若干段,每段长度是2:[a0,a1],[a2,a3],…[an−1,an−1][a0,a1],然后将每一段内排好序,小数在前,大数在后;
- 第二次,将整个区间分成连续的若干段,每段长度是4:[a0,…,a3],[a4,…,a7],…[an−4,…,an−1][a0,…,a3],然后将每一段内排好序,这次排序可以利用之前的结果,相当于将左右两个有序的半区间合并,可以通过一次线性扫描来完成;
- 依此类推,直到每段小区间的长度大于等于 n 为止;
另外,当 n 不是2的整次幂时,每次迭代只有最后一个区间会比较特殊,长度会小一些,遍历到指针为空时需要提前结束。
举个例子:
根据图片可知,从底部往上逐渐进行排序,先将长度是1的链表进行两两排序合并,再形成新的链表head,再在新的链表的基础上将长度是2的链表进行两两排序合并,再形成新的链表head … 直到将长度是n / 2的链表进行两两排序合并
step=1: (3->4) -> (1->7) -> (8->9) -> (2->11) -> (5->6)
step=2: (1->3->4->7) -> (2->8->9->11) -> (5->6)
step=4: (1->2->3->4->7->8->9->11) ->5->6
step=8: (1->2->3->4->5->6->7->8->9->11)
具体操作,当将长度是i的链表两两排序合并时,新建一个虚拟头结点dummy,[j,j + i - 1]和[j + i, j + 2 * i - 1]两个链表进行合并,在当前组中,p指向的是当前合并的左边的链表,q指向的是当前合并的右边的链表,o指向的是下一组的开始位置,将左链表和右链表进行合并,加入到dummy的链表中,操作完所有组后,返回dummy.next链表给i * 2的长度处理
注意的是:需要通过l和r记录当前组左链表和右链表使用了多少个元素,用的个数不能超过i,即使长度不是 2n 也可以同样的操作
时间复杂度分析:
整个链表总共遍历 logn 次,每次遍历的复杂度是 O(n),所以总时间复杂度是O(nlogn)。
空间复杂度分析:
整个算法没有递归,迭代时只会使用常数个额外变量,所以额外空间复杂度是 O(1).
c++代码
/**
* Definition for singly-linked list.
* struct ListNode {
* int val;
* ListNode *next;
* ListNode() : val(0), next(nullptr) {}
* ListNode(int x) : val(x), next(nullptr) {}
* ListNode(int x, ListNode *next) : val(x), next(next) {}
* };
*/
class Solution {
public:
ListNode* sortList(ListNode* head) {
int n = 0;
for(auto p = head; p; p = p->next) n++;
auto dummy = new ListNode(-1); //虚拟头节点
dummy->next = head;
//每次归并段的长度,每次长度依次为1,2,4,8...n/2, 小于n是因为等于n时说明所有元素均归并完毕,大于n时同理
for(int i = 1; i < n; i *= 2)
{
auto cur = dummy ;
for(int j = 1; j + i <= n; j += 2*i ){ //j代表每一段的开始,每次将两段有序段归并为一个大的有序段,故而每次+2i //必须保证每段中间序号是小于等于链表长度的,显然,如果大于表长,就没有元素可以归并了
auto p = cur->next, q = p;//p表示第一段的起始点,q表示第二段的起始点,之后开始归并即可
for(int k = 0; k < i; k++) q = q->next;
//l,r用于计数第一段和第二段归并的节点个数,由于当链表长度非2的整数倍时表长会小于i,故而需要加上p && q的边界判断
int l = 0, r = 0;
while(l < i && r < i && p && q) //二路归并
{
if(p->val <= q->val) cur = cur->next = p, p = p->next, l++;
else cur = cur->next = q, q = q->next, r++;
}
while(l < i && p) cur = cur->next = p, p = p->next ,l++;
while(r < i && q) cur = cur->next = q, q = q->next ,r++;
cur->next = q;//记得把排好序的链表尾链接到下一链表的表头,循环完毕后q为下一链表表头
}
}
return dummy->next6+
}
};
152. 乘积最大子数组
思路
(动态规划) O(n)
给你一个整数数组 nums ,让我们找出数组中乘积最大的连续子数组对应的乘积。
样例:
如样例所示,nums = [2,3,-2,4],连续子数组 [2,3]有最大乘积 6,下面来讲解动态规划的做法。
状态表示:
f[i]表示以num[i]结尾的连续子数组乘积的最大值。
假设nums数组都是非负数,对于每个以nums[i]结尾的连续子数组,我们有两种选择方式:
- 1、只有
nums[i]一个数,那么以num[i]结尾的连续子数组乘积的最大值则为nums[i],即f[i] = nums[i]。 - 2、以
nums[i]为结尾的多个数连续组成的子数组,那么问题就转化成了以nums[i - 1]结尾的连续子数组的最大值再乘以nums[i]的值,即f[i] = f[i - 1] * nums[i]。
图示:
最后的结果是两种选择中取最大的一个,因此状态转移方程为: f[i] = max(nums[i], f[i - 1] * nums[i])。
但是nums数组中包含有正数,负数和零,当前的最大值如果乘以一个负数就会变成最小值,当前的最小值如果乘以一个负数就会变成一个最大值,因此我们还需要维护一个最小值。
新的状态表示:
f[i]表示以num[i]结尾的连续子数组乘积的最大值,g[i]表示以num[i]结尾的连续子数组乘积的最小值。
我们先去讨论以nums[i]结尾的连续子数组的最大值的状态转移方程:
- 1、如果
nums[i] >= 0,同刚开始讨论的一样,f[i] = max(nums[i], f[i - 1] * nums[i])。 - 2、如果
nums[i] < 0,只有nums[i]一个数,最大值为nums[i]。有多个数的话,问题就转化成了以nums[i - 1]结尾的连续子数组的最小值再乘以nums[i](最小值乘以一个负数变成最大值),即f[i] = max(nums[i], g[i - 1] * nums[i])。
图示:
综上,最大值的状态转移方程为: f[i] = max(nums[i], max(f[i - 1] * nums[i], g[i - 1] * nums[i]))。
再去讨论以nums[i]结尾的连续子数组的最小值的状态转移方程:
- 1、如果
nums[i] >= 0,同最大值的思考方式一样,只需把max换成min,即g[i] = min(nums[i], g[i - 1] * nums[i])。 - 2、如果
nums[i] < 0,只有nums[i]一个数,最小值为nums[i]。有多个数的话,问题就转化成了以nums[i - 1]结尾的连续子数组的最大值再乘以nums[i](最大值乘以一个负数变成最小值),即f[i] = min(nums[i], f[i - 1] * nums[i])。
图示:
综上,最小值的状态转移方程为: g[i] = min(nums[i], min(g[i - 1] * nums[i], f[i - 1] * nums[i]))。
最后的结果就是分别以nums[0]或nums[1],,,或nums[i]为结尾的连续子数组中取乘积结果最大的。
初始化:
只有一个数nums[0]时,以nums[i]结尾的连续子数组乘积的最大值和最小值都为nums[0]。
时间复杂度分析: 只遍历一次nums数组,因此时间复杂度为O(n),n是nums数组的长度。
c++代码
class Solution {
public:
int maxProduct(vector<int>& nums) {
int n = nums.size();
vector<int>f(n + 1), g(n + 1);
f[0] = nums[0], g[0] = nums[0];
int res = nums[0];
for(int i = 1; i < n; i++){
f[i] = max(nums[i], max(f[i - 1] * nums[i], g[i - 1] * nums[i]));
g[i] = min(nums[i], min(g[i - 1] * nums[i], f[i - 1] * nums[i]));
res = max(res, f[i]);
}
return res;
}
};