LeetCode 热题 HOT 100 打卡计划 | 第十八天 | 每日进步一点点

134 阅读8分钟

图片.png

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第19天,点击查看活动详情

146. LRU 缓存

题意解释

请为LRU缓存设计一个数据结构。支持两种操作:getset

  • get(key) : 如果key在缓存中,则返回key对应的值(保证是正的);否则返回-1
  • put(key, value): 如果key在缓存中,则更新key对应的值;否则插入(key, value),如果缓存已满,则先删除上次使用时间最老的key

思路

(双链表+哈希) O(1)

使用一个双链表和一个哈希表:

  • 双链表存储一个节点被使用(get或者put)的时间戳,且按最近使用时间从左到右排好序,最先被使用的节点放在双链表的第一位,因此双链表的最后一位就是最久未被使用的节点;

图片.png

  • 哈希表存储key对应的链表中的节点地址,用于key-value 的增删改查;

图片.png

初始化:

  • n 是缓存大小;
  • 双链表和哈希表都为空;

get(key): 首先用哈希表判断key是否存在:

  • 如果key不存在,则返回-1;
  • 如果key存在,则返回对应的value,同时将key对应的节点放到双链表的最左侧;

put(key, value): 首先用哈希表判断key是否存在:

  • 如果key存在,则修改对应的value,同时将key对应的节点放到双链表的最左侧;

  • 如果key不存在:

    • 如果缓存已满,则删除双链表最右侧的节点(上次使用时间最老的节点),更新哈希表;
    • 否则,插入(key, value):同时将key对应的节点放到双链表的最左侧,更新哈希表;

对应的双链表的几种操作

1、删除p节点

图片.png

  p->right->left = p->left;
  p->left->right = p->right;

2、在L节点之后插入p节点

图片.png

 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次:

  1. 第一次,将整个区间分成连续的若干段,每段长度是2:[a0,a1],[a2,a3],…[an−1,an−1][a0,a1],然后将每一段内排好序,小数在前,大数在后;
  2. 第二次,将整个区间分成连续的若干段,每段长度是4:[a0,…,a3],[a4,…,a7],…[an−4,…,an−1][a0,…,a3],然后将每一段内排好序,这次排序可以利用之前的结果,相当于将左右两个有序的半区间合并,可以通过一次线性扫描来完成;
  3. 依此类推,直到每段小区间的长度大于等于 n 为止;

另外,当 n 不是2的整次幂时,每次迭代只有最后一个区间会比较特殊,长度会小一些,遍历到指针为空时需要提前结束。

图片.png

举个例子:

根据图片可知,从底部往上逐渐进行排序,先将长度是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的长度处理

注意的是:需要通过lr记录当前组左链表和右链表使用了多少个元素,用的个数不能超过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 ,让我们找出数组中乘积最大的连续子数组对应的乘积。

样例:

图片.png

如样例所示,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]

图示:

图片.png

最后的结果是两种选择中取最大的一个,因此状态转移方程为: 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])

图示:

图片.png

综上,最大值的状态转移方程为: 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])

图示:

图片.png

综上,最小值的状态转移方程为: 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;
     }
 };