4.7
146. LRU 缓存 - 力扣(LeetCode)
问:需要几个哨兵节点?
答:一个就够了。一开始哨兵节点
dummy的prev和next都指向dummy。随着节点的插入,dummy的next指向链表的第一个节点(最上面的书),prev指向链表的最后一个节点(最下面的书)。问:为什么节点要把key也存下来?
答:在删除链表末尾节点时,也要删除哈希表中的记录,这需要知道末尾节点的key。
代码思路:双向链表+哈希表
自定义一个双向链表类 Node , 类LRUCache,哈希表、自定义方法remove(删除一个节点)、push_front(在表头添加节点)、Node* get_node(获取key值对应的节点并移到表头)。
题目要求实现的函数:
int get(int key) 用Node* get_node实现
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基数:
1.快排
思路
首先设定一个分界值(基准值pivot),通过该分界值将数组分成左右两部分。
将大于或等于分界值的数据集中到数组右边,小于分界值的数据集中到数组的左边。
左边和右边的数据可以独立排序。对于左侧的数组数据,又可以取一个分界值,将该部分数据分成左右两部分,同样在左边放置较小值,右边放置较大值。右侧的数组数据也可以做类似处理。
重复上述过程。
可以看出,这是一个递归定义。通过递归将左侧部分排好序后,再递归排好右侧部分的顺序。当左、右两个部分各数据排序完成后,整个数组的排序也就完成了。
代码
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]\)进行归并排序,具体步骤如下:
- 将数组划分为左右两个子数组:\([7, 2, 5, 3]\)和\([9, 8, 6]\)。
- 对左右两个子数组分别进行归并排序。这里以左边的子数组为例,右边的子数组同理。
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]\)。- 对右边的子数组\([9, 8, 6]\)进行归并排序,得到有序的子数组\([6, 8, 9]\)。
- 将步骤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]进行归并排序
- 边界处理:当
l >= r的时候, 说明l-r这个范围内只有一个元素或没有元素。一个元素的数组,是有序的,递归结束(同快速排序)。- 如果
l > r,则进行归并排序。- 有数组 q, 左端点 l, 右端点 r
- 确定划分边界 mid = l + r >> 1
- 递归处理子问题 q[l..mid], q[mid+1..r]
- 合并子问题 当左右半边都排好序后,需要合并左右半边数组。合并数组用的是双指针法
主体合并
至少有一个小数组添加到 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.堆排序
概念性质:
堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序, 它的最坏、最好、平均时间复杂度均为 O(nlogn), 它也是不稳定排序。
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值, 这种情况称为大顶堆,注意:没有要求结点的左孩子的值和右孩子的值的大小关系。
算法步骤:
- 将无序序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆。
如果要求排序为升序,一般采用大顶堆,如果要求排序为降序,一般采用小顶堆。
将堆顶元素与末尾元素交换,将最大元素”沉”到数组末端。
重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
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个数。
- 使用最大最小堆的思路 (以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为最大堆
- 使用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);
}
- 使用选择排序的思想对前K个元素排序 ( 以寻找前K大个元素为例)
扫描一遍数组,选出最大的一个元素,然后再扫描一遍数组,找出第二大的元素,再扫描一遍数组,找出第三大的元素。。。。。以此类推,找K个元素,时间复杂度为O(N*K)




浙公网安备 33010602011771号