4.7

146. LRU 缓存 - 力扣(LeetCode)

img

问:需要几个哨兵节点?

答:一个就够了。一开始哨兵节点dummyprevnext都指向dummy。随着节点的插入,dummynext指向链表的第一个节点(最上面的书),prev指向链表的最后一个节点(最下面的书)。

问:为什么节点要把key也存下来?

答:在删除链表末尾节点时,也要删除哈希表中的记录,这需要知道末尾节点的key。

代码思路:双向链表+哈希表

自定义一个双向链表类 Node , 类LRUCache,哈希表、自定义方法remove(删除一个节点)、push_front(在表头添加节点)、Node* get_node(获取key值对应的节点并移到表头)。

题目要求实现的函数:

  1. int get(int key) 用Node* get_node实现

  2. void put(int key, int value) 用哈希表查询:

    • 有 更新值
    • 无 new node (因为是哈希表,所以如果已经有该key,新创建不影响)

    push_front

    判断capacity

    • 超过 back_node = dummy->prev ,哈希表erase该key( key_to_node.erase(back_node->key)😉 remove(back_node)删除节点
class Node {
public:
    int key;
    int value;
    Node* prev;
    Node* next;

    Node(int k = 0, int v = 0) : key(k), value(v) {}
};

class LRUCache {
private:
    int capacity;
    Node* dummy; // 哨兵节点
    unordered_map<int, Node*> key_to_node;

    // 删除一个节点(抽出一本书)
    void remove(Node* x) {
        x->prev->next = x->next;
        x->next->prev = x->prev;
    }

    // 在链表头添加一个节点(把一本书放在最上面)
    void push_front(Node* x) {
        x->prev = dummy;
        x->next = dummy->next;
        x->prev->next = x;
        x->next->prev = x;
    }

    // 获取 key 对应的节点,同时把该节点移到链表头部
    Node* get_node(int key) {
        auto it = key_to_node.find(key);
        if (it == key_to_node.end()) { // 没有这本书
            return nullptr;
        }
        Node* node = it->second; // 有这本书
        remove(node); // 把这本书抽出来
        push_front(node); // 放在最上面
        return node;
    }

public:
    LRUCache(int capacity) : capacity(capacity), dummy(new Node()) {//class LRUCache 
        dummy->prev = dummy;
        dummy->next = dummy;
    }

    int get(int key) {
        Node* node = get_node(key);
        return node ? node->value : -1;
    }

    void put(int key, int value) {
        Node* node = get_node(key);
        if (node) { // 有这本书
            node->value = value; // 更新 value
            return;
        }
        key_to_node[key] = node = new Node(key, value); // 新书
        push_front(node); // 放在最上面
        if (key_to_node.size() > capacity) { // 书太多了
            Node* back_node = dummy->prev;
            key_to_node.erase(back_node->key);
            remove(back_node); // 去掉最后一本书
            delete back_node; // 释放内存
        }
    }
};

206. 反转链表 - 力扣(LeetCode)

1.头插法

class Solution {
  public:
      ListNode* reverseList(ListNode* head) {        
          ListNode* pre = NULL;
          ListNode* cur = head;
          while(cur){
            ListNode* nxt = cur->next;
            cur->next = pre;
            pre = cur;
            cur = nxt;
          }
          return pre;
      }
  };

  • 时间复杂度:O(n),其中n为链表节点个数。
  • 空间复杂度:O(1),仅用到若干额外变量。

2.递归法

class Solution {
  public:
      ListNode* reverseList(ListNode* head) {
        //边界条件
       if(!head || !head->next) return head;
        //此时last为最后一个节点,head为倒数第二个节点,从后往前翻转
       ListNode* last = reverseList(head->next);
       head->next->next = head;
       head->next = NULL;

       return last;
      }
  };
  • 时间复杂度: O(n)
  • 空间复杂度: O(n)

3. 无重复字符的最长子串 - 力扣(LeetCode)

滑动窗口

法一:哈希表(判断cnt[c] > 1)

class Solution {
  public:
      int lengthOfLongestSubstring(string s) {
          int n = s.length() , ans = 0 , l = 0;
          unordered_map<char , int> cnt;

          for (int r = 0; r < n; r++) {
             char c = s[r];
             cnt[c] ++;
             while(cnt[c] > 1){ //有重复字符则窗口左端点右移
              cnt[s[l]] --;
              l ++;
             }
             ans = max(ans , r - l + 1);//每个循环更新最长长度
          }
          return ans;
      }
  };

法二:哈希集合(加入s[r]之前用while循环消除s[r],不断右移l)

class Solution {
  public:
      int lengthOfLongestSubstring(string s) {
          int n = s.length() , ans = 0 , l = 0;
          unordered_set<char> window;

          for (int r = 0 ; r < n ; r ++){
            char c = s[r];
            while(window.contains(c)){
              window.erase(s[l]);
              l ++;
            }
            window.insert(c);
            ans = max(ans , r - l + 1);
          }
          return ans;
      }
  };
  • 时间复杂度:O(n),其中 n 为 s 的长度。注意 left 至多增加 n 次,所以整个二重循环至多循环 O(n) 次。
  • 空间复杂度:O(∣Σ∣),其中 ∣Σ∣ 为字符集合的大小,本题中字符均为 ASCII 字符,所以 ∣Σ∣≤128。

912. 排序数组 - 力扣(LeetCode)

排序算法的分类:

1插入:插入,折半插入,希尔
2交换:冒泡,快速
3选择:简单选择,堆
4归并:归并(不只二路归并)
5基数:

sort.png

1.快排

思路

  1. 首先设定一个分界值(基准值pivot),通过该分界值将数组分成左右两部分。

  2. 将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。

  3. 左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。

  4. 重复上述过程。

    可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。

代码

class Solution {
  void quickSort(vector<int>& a , int l , int r){
      if(l >= r)  return;
      int x = a[l + r >> 1];
      int i = l - 1 , j = r + 1;

      while(i < j){
        while(a[++i] < x);
        while(a[--j] > x);
        if(i < j) swap(a[i] , a[j]);
      }

      quickSort(a , l , j);
      quickSort(a , j + 1 , r);
  }


  public:
      vector<int> sortArray(vector<int>& nums) {
        int n = nums.size();
          quickSort(nums , 0 , n - 1);
          return nums;
      }
  };

2.归并排序

归并排序是一种常用且高效的排序算法,采用分治法的思想来对数组或列表进行排序。归并排序的基本思想是将数组分成较小的子数组,递归地对这些子数组进行排序,然后将它们合并在一起,产生最终的有序数组

  • 归并排序是一种递归算法,将输入数组不断地分割成较小的子数组,直到每个子数组只有一个元素,这一个元素是有序的。

  • 然后,将排好序的子数组 合并在一起,产生较大的有序子数组。

  • 这个分割和合并的过程一直重复,直到整个数组都排序完毕。

举个例子

假设我们要对一个数组\([7, 2, 5, 3, 9, 8, 6]\)进行归并排序,具体步骤如下:

  1. 将数组划分为左右两个子数组:\([7, 2, 5, 3]\)\([9, 8, 6]\)
  2. 对左右两个子数组分别进行归并排序。这里以左边的子数组为例,右边的子数组同理。
    a. 将左边的子数组\(\[7, 2, 5, 3\]\)再次划分为左右两个子数组:\(\[7, 2\]\)\(\[5, 3\]\)
    b. 对左右两个子数组分别进行归并排序。这里以左边的子数组为例,右边的子数组同理。
    i. 将左边的子数组\(\[7, 2\]\)再次划分为左右两个子数组:\(\[7\]\)\(\[2\]\)
    ii. 将左右两个子数组\([7]\)\([2]\)进行归并,得到有序的子数组\([2, 7]\)
    c. 将步骤2.b中得到的左右两个有序子数组\([2, 7]\)\([3, 5]\)进行归并,得到有序的子数组\([2, 3, 5, 7]\)
  3. 对右边的子数组\([9, 8, 6]\)进行归并排序,得到有序的子数组\([6, 8, 9]\)
  4. 将步骤2中得到的左右两个有序子数组\(\[2, 3, 5, 7\]\)\(\[6, 8, 9\]\)进行归并,得到最终的有序数组\(\[2, 3, 5, 6, 7, 8, 9\]\)

如何用代码实现

定义一个数组a,用来保存原数组

定义一个数组temp,用作归并排序过程中的临时数组。

归并排序:void merge_sort(int a[], int l, int r): 对数组a的,l-r 的范围中的元素进行排序,也就是对 a[l -r]进行归并排序

  1. 边界处理:当 l >= r 的时候, 说明l-r这个范围内只有一个元素或没有元素。一个元素的数组,是有序的,递归结束(同快速排序)。
  2. 如果 l > r,则进行归并排序。
  3. 有数组 q, 左端点 l, 右端点 r
  4. 确定划分边界 mid = l + r >> 1
  5. 递归处理子问题 q[l..mid], q[mid+1..r]
  6. 合并子问题 当左右半边都排好序后,需要合并左右半边数组。合并数组用的是双指针法
  • 主体合并

    至少有一个小数组添加到 tmp 数组中

  • 收尾

    可能存在的剩下的一个小数组的尾部直接添加到 tmp 数组中

  • 将临时数组中的数,拷贝回原数组,排序结束。

    tmp 数组覆盖原数组

class Solution {
  vector<int> temp;
  
  void mergeSort(vector<int>& a , int l , int r){
    //只剩一个元素的时候,已经有序,返回
    if(l >= r) return;

    //寻找数组中点下标
    int mid = (l+r)>>1;
    //递归给左半边排序
    mergeSort(a,l,mid);
    //递归给右半边排序
    mergeSort(a,mid+1,r);
    //以下是合并排序好的两个数组

    //k:遍历合并后的数组的下标
    int k = 0;
    //i:左半边数组的下标,j:右半边数组的下标
    int i = l, j = mid + 1;
    //左右半边都没遍历完
    while(i <= mid && j <= r){
        //左边的元素小于右边的元素
        if(a[i] < a[j]) 
            //左边元素放如临时数组,并移动下标
            temp[k++] = a[i++];
        //否则,右边元素放入临时数组并移动下标 
        else temp[k++] = a[j++];
    }
    //如果左边数组有剩余,则放入临时数组
    while(i <= mid) temp[k++] = a[i++];
    //如果有边数组有剩余,则放入临时数组
    while(j <= r) temp[k++] = a[j++];
    //把临时数组中的元素拷贝至原数组
    k = 0;
    for(int i = l; i <= r; i++)  a[i] = temp[k++];       
}

public:
    vector<int> sortArray(vector<int>& nums) {
        int n = nums.size();
        temp.resize(n);
        mergeSort(nums , 0 , n - 1);
        return nums;
    }
};
  • 时间复杂度为O(n log n),其中n是输入数组中的元素个数。

  • 空间复杂度为O(n),其中n是输入数组中的元素个数。

3.堆排序

概念性质:

  1. 堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序, 它的最坏、最好、平均时间复杂度均为 O(nlogn), 它也是不稳定排序。

  2. 堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值, 这种情况称为大顶堆,注意:没有要求结点的左孩子的值和右孩子的值的大小关系。

算法步骤:

  1. 将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆。

如果要求排序为升序,一般采用大顶堆,如果要求排序为降序,一般采用小顶堆。

  1. 将堆顶元素与末尾元素交换,将最大元素”沉”到数组末端

  2. 重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。

image-20240319203222654

class Solution {
   // 维护最大堆性质(正确实现)
    void maxHeapify(vector<int>& a, int i, int heapSize) {
        int l = i * 2 + 1;    // 左子节点索引
       int r = i * 2 + 2;    // 右子节点索引
        int largest = i;      // 记录最大值的索引
       
        // 比较左子节点
        if (l < heapSize && a[l] > a[largest]) 
            largest = l;
        
        // 比较右子节点
        if (r < heapSize && a[r] > a[largest])
            largest = r;
        
        // 若子节点更大,交换并递归调整
        if (largest != i) {
            swap(a[i], a[largest]);
            maxHeapify(a, largest, heapSize);
        }
    }

    // 构建最大堆
    void buildMaxHeap(vector<int>& a, int heapSize) {
        // 从最后一个非叶子节点开始调整
        for (int i = heapSize / 2 - 1; i >= 0; --i) {
            maxHeapify(a, i, heapSize);
        }
    }

public:
    vector<int> sortArray(vector<int>& nums) {
        int n = nums.size();
        
        // 1. 构建初始最大堆
        buildMaxHeap(nums, n);
        
        // 2. 逐步提取最大值并调整堆
        for (int i = n - 1; i > 0; --i) {
            swap(nums[0], nums[i]);  // 将当前最大值移到末尾
            maxHeapify(nums, 0, i);  // 调整剩余元素的堆结构
        }
        
        return nums;
    }
};

347. 前 K 个高频元素 - 力扣(LeetCode)

第一思路代码(错误写法):

class Solution {
  public:
      vector<int> topKFrequent(vector<int>& nums, int k) {
          vector<int> res;
          unordered_map<int , int> mp;
          for(int i : nums) mp[i] ++;

          priority_queue<int , vector<int> , greater<int>> heap;

          for(auto& p : mp){
            if(heap.size() < k) heap.push(p[1]);
            else{
              if(p[1] > heap.top()){
                heap.pop();
                heap.push(p[1]);
              }
            }
          }

          for(auto& ip : heap){
            res.push_back(ip[0]);
          }
          return res;
      }
  };

问题:

第一个问题:unordered_map的遍历。在C++中,unordered_map的迭代器解引用后的类型是std::pair<const Key, Value>。所以当用户使用auto& p : mp时,p是一个pair的引用,其中键是p.first,值是p.second。但在代码中,用户写的是p[1],这在C++中是不正确的,因为pair没有下标访问,应该用p.second

第二个问题:堆的元素类型。用户定义的堆是priority_queue<int, vector<int>, greater<int>>,即堆中存储的是int类型。但在这里,用户需要根据元素的频率来维护堆,但堆里存储的是频率值,而不是元素本身。这样会导致问题,因为最终需要返回的是元素本身,而不是它们的频率。例如,当频率相同时,如何知道对应的元素是什么?因此,堆中应该存储的是元素及其频率的组合,这样才能在最后获取元素的值。

第三个问题:堆的比较函数。当前堆的比较函数是greater<int>,即小根堆,但堆中存储的是频率的数值。如果堆中存储的是频率,那么小根堆的堆顶是当前最小的频率。当新元素的频率大于堆顶时,替换堆顶,这样可以保持堆中始终有最大的k个频率。这部分逻辑是正确的,但问题在于堆里存储的是频率,而无法对应到元素本身。例如,当堆里的元素是频率值时,用户无法知道这些频率对应的元素是哪些,所以在最后无法将元素收集到结果中。

堆需要按频率排序,因此需存储 pair<频率, 元素> 并自定义比较逻辑。

第四个问题:结果的收集。用户尝试遍历堆,将ip[0]加入结果。但堆中的元素是int类型(频率),而ip在这里是一个int,所以ip[0]是错误的,会导致编译错误。此外,堆的遍历方式不正确,因为priority_queue不提供迭代器访问,用户不能直接使用范围for循环遍历堆。所以,这部分代码也是错误的。

改正后代码:

class Solution {
  public:
      vector<int> topKFrequent(vector<int>& nums, int k) {
          unordered_map<int , int> mp;
          for(int& i : nums){
            mp[i] ++;// 统计频率
          }
          vector<int> res;
          // 定义小根堆,存储 pair<频率, 元素>,按频率排序
          priority_queue<pair<int , int> , vector<pair<int , int>> , greater<pair<int , int>>> heap;

          for(auto& p : mp){ // 维护大小为 k 的堆
            int num = p.first;
            int freq = p.second;
            if(heap.size() < k) heap.push({freq , num});
            else{
              if(freq > heap.top().first){
                heap.pop();
                heap.push({freq , num});
              }
            }
          }
            // 收集结果
            while(!heap.empty()){
              res.push_back(heap.top().second);
              heap.pop();
            }
            return res;
      }
  };

复杂度分析

  • 时间复杂度:O(Nlogk),其中 N 为数组的长度。我们首先遍历原数组,并使用哈希表记录出现次数,每个元素需要 O(1) 的时间,共需 O(N) 的时间。随后,我们遍历「出现次数数组」,由于堆的大小至多为 k,因此每次堆操作需要 O(logk) 的时间,共需 O(Nlogk) 的时间。二者之和为 O(Nlogk)。

  • 空间复杂度:O(N)。哈希表的大小为 O(N),而堆的大小为 O(k),共计为 O(N)。


Top K问题

Top K 问题的常见形式:

给定10000个整数,找第K大(第K小)的数
给定10000个整数,找出最大(最小)的前K个数
给定100000个单词,求前K词频的单词

解决Top K问题若干种方法

  • 使用最大最小堆。求最大的数/第k大的数 用最小堆,求最小的数/第k小的数用最大堆。
  • Quick Select算法。使用类似快排的思路,根据pivot划分数组。
  • 使用排序方法,排序后再寻找top K元素。
  • 使用选择排序的思想,对前K个元素部分排序。
  • 将1000.....个数分成m组,每组寻找top K个数,得到m×K个数,在这m×k个数里面找top K个数。
  1. 使用最大最小堆的思路 (以top K 最大元素为例)
    按顺序扫描这10000个数,先取出K个元素构建一个大小为K的最小堆。每扫描到一个元素,如果这个元素大于堆顶的元素(这个堆最小的一个数),就放入堆中,并删除堆顶的元素,同时整理堆。如果这个元素小于堆顶的元素,就直接pass。最后堆中剩下的元素就是最大的前Top K个元素,堆顶元素就是Top 第K大的元素。

最小堆的插入时间复杂度为log(n),n为堆中元素个数,在这里是K。最小堆的初始化时间复杂度是nlog(n)

C++中的最大最小堆要用标准库的priority\_queue来实现。

struct Node {
    int value;
    int idx;
    Node (int v, int i): value(v), idx(i) {}
    friend bool operator < (const struct Node &n1, const struct Node &n2) ; 
};

inline bool operator < (const struct Node &n1, const struct Node &n2) {
    return n1.value < n2.value;
}

priority_queue<Node> pq; // 此时pq为最大堆
  1. 使用Quick Select的思路(以寻找第K大的元素为例)
    Quick Select脱胎于快速排序,提出这两个算法的都是同一个人。算法的过程是这样的: 首先选取一个枢轴,然后将数组中小于该枢轴的数放到左边,大于该枢轴的数放到右边。 此时,如果左边的数组中的元素个数大于等于K,则第K大的数肯定在左边数组中,继续对左边数组执行相同操作; 如果左边的数组元素个数等于K-1,则第K大的数就是pivot; 如果左边的数组元素个数小于K,则第K大的数肯定在右边数组中,对右边数组执行相同操作。

这个算法与快排最大的区别是,每次划分后只处理左半边或者右半边,而快排在划分后对左右半边都继续排序。

//此为Java实现
public int findKthLargest(int[] nums, int k) {
  return quickSelect(nums, k, 0, nums.length - 1);
}

// quick select to find the kth-largest element
public int quickSelect(int[] arr, int k, int left, int right) {
  if (left == right) return arr[right];
  int index = partition(arr, left, right);
  if (index - left + 1 > k)
    return quickSelect(arr, k, left, index - 1);
  else if (index - left + 1 == k)
    return arr[index];
  else
    return quickSelect(arr, k - (index - left + 1), index + 1, right);

}
  1. 使用选择排序的思想对前K个元素排序 ( 以寻找前K大个元素为例)
    扫描一遍数组,选出最大的一个元素,然后再扫描一遍数组,找出第二大的元素,再扫描一遍数组,找出第三大的元素。。。。。以此类推,找K个元素,时间复杂度为O(N*K)
posted @ 2025-04-09 00:01  七龙猪  阅读(6)  评论(0)    收藏  举报
-->