一、单调栈
栈(stack)是很简单的一种数据结构,先进后出的逻辑顺序,符合某些问题的特点,比如说函数调用栈。单调栈实际上就是栈,只是利用了一些巧妙的逻辑,使得每次新元素入栈后,栈内的元素都保持有序(单调递增或单调递减)。
单调栈用途不太广泛,只处理一类典型的问题,比如「下一个更大元素」,「上一个更小元素」等。本文用讲解单调队列的算法模版解决「下一个更大元素」相关问题,并且探讨处理「循环数组」的策略。
1.1 单调栈模板
现在给你出这么一道题:输入一个数组 nums
,请你返回一个等长的结果数组,结果数组中对应索引存储着下一个更大元素,如果没有更大的元素,就存 -1。函数签名如下:
vector<int> nextGreaterElement(vector<int>& nums);
比如说,输入一个数组 nums = [2,1,2,4,3]
,你返回数组 [4,2,4,-1,-1]
。因为第一个 2 后面比 2 大的数是 4; 1 后面比 1 大的数是 2;第二个 2 后面比 2 大的数是 4; 4 后面没有比 4 大的数,填 -1;3 后面没有比 3 大的数,填 -1。
暴力解法:
就是对每个元素后面都进行扫描,找到第一个更大的元素就行了。但是暴力解法的时间复杂度是 O(n^2)
。
特殊解法:
这个问题可以这样抽象思考:把数组的元素想象成并列站立的人,元素大小想象成人的身高。这些人面对你站成一列,如何求元素「2」的下一个更大元素呢?很简单,如果能够看到元素「2」,那么他后面可见的第一个人就是「2」的下一个更大元素,因为比「2」小的元素身高不够,都被「2」挡住了,第一个露出来的就是答案。
那么如何使用代码进行实现呢?
我们可以使用一个栈进行辅助实现,从后往前遍历(因为是求后面大于当前值的第一个数),主要思路为,在处理比如nums[1]时,我们将nums[1]之后的所有数都加入到栈中(2, 4,3),然后将栈中所有<=nums[1]的数都弹出,直到遇到第一个>nums[1]的值,将这个值放在res[1]上
具体实现:
如何将每个数的后面元素放入栈这个操作统一处理,因为不可能每次都遍历数组将后面所有元素放进栈中。
我们可以发现,每次其实只需要栈中有我们需要的下一个最大值即可,也就是说,当前元素到下一个最大值中间的值其实是不需要维护的,因为他们比当前元素小,不可能成为下一个”当前元素“的“下一个最大值”,所以有如下代码:
std::vector<int> nextGreaterElement(std::vector<int>& nums) {
int n = nums.size();
// 存放答案的数组
std::vector<int> res(n);
std::stack<int> s;
// 倒着往栈里放
for (int i = n - 1; i >= 0; i--) {
// pop掉目标高个前的所有矮个
while (!s.empty() && s.top() <= nums[i]) {
// 矮个起开,反正也被挡着了,并且矮个不需要维护,只有高个影响”下一个最大值“
//所以对于每个nums[i]都可以使用这一个栈来维护
s.pop();
}
// 此时top()就是nums[i] 身后的更大元素
//如果栈为空,说明后序没有元素比当前元素更高,所以存放-1
res[i] = s.empty() ? -1 : s.top();
//每个元素都要存放进栈
s.push(nums[i]);
}
return res;
}
单调栈中的单调是指,使用的栈其中元素一直保持单调,因此也有单调递增递减的区别,上述代码为单调递增,且为从后往前遍历,那么可知还有三种方式,递增从前往后,递减从后往前/从前往后
一般遇到的情况分为下面这几类:
1:要求后方下一个最大元素使用从后往前升序只需要处理栈顶,更简单,如果从前往后,需要对每一次pop()的元素进行处理,更复杂。具体例子详见每日温度的两种解法
2:求前方上一个最大元素,使用从前往后降序更简单,从后往前需要处理每个弹出的元素,与上述情况相反
3:求更小元素也是类似,需要结合上述两种情况自行思考
4:如果涉及到对两边元素处理,那么从前往后还是从后往前都不重要了,无论哪种顺序都需要对每个弹出的元素进行处理,需要我们对单调栈的理解更加深刻
主要是我们要对各种情况进行详细分析,比如下述代码(每日温度):
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& T) {
// 递增栈
stack<int> st;
vector<int> result(T.size(), 0);
st.push(0);
for (int i = 1; i < T.size(); i++) {
if (T[i] < T[st.top()]) { // 情况一
st.push(i);
} else if (T[i] == T[st.top()]) { // 情况二
st.push(i);
} else {
while (!st.empty() && T[i] > T[st.top()]) { // 情况三
result[st.top()] = i - st.top();
st.pop();
}
st.push(i);
}
}
return result;
}
};
知道各种情况的逻辑再进行化简(1.3中的解法、)
其次是要记住单调栈的思想,栈里面的元素维持单调,遇到不满足单调性的就不断出栈知道再次单调
1.2 下一个更大元素
此题可以直接套用单调栈,思路非常简单,只是在找下标数组时,我使用了两层for循环,时间复杂度很高,可以选择使用map来进行查找,一层for即可查到:
暴力查找下标数组:
class Solution {
public:
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
vector<int> idx;
vector<int> tmp;
vector<int> res;
stack<int> st;
tmp.resize(nums2.size());
for(int i = nums2.size()-1;i >=0;i--){
while(!st.empty()&& nums2[i] >= st.top()) st.pop();
tmp[i] = st.empty()? -1 :st.top();
st.push(nums2[i]);
}
for(int i=0;i < nums1.size();i++){
for(int j=0;j < nums2.size();j++)
if(nums1[i]==nums2[j]){
idx.push_back(j);
break;
}
}
for(int i : idx)
res.push_back(tmp[i]);
return res;
}
};
map查找相同元素,时间复杂度降低:
//解法二(复杂度降低)
class Solution {
public:
vector<int> nextGreaterElement(vector<int>& nums1, vector<int>& nums2) {
unordered_map<int,int> num_map;
vector<int> tmp;
vector<int> res;
stack<int> st;
tmp.resize(nums2.size());
for(int i = nums2.size()-1;i >=0;i--){
while(!st.empty()&& nums2[i] >= st.top()) st.pop();
tmp[i] = st.empty()? -1 :st.top();
st.push(nums2[i]);
}
//建立映射
for(int i=0;i < nums2.size();i++){
num_map[nums2[i]] = tmp[i];
}
for(int i=0;i < nums1.size();i++)
res.push_back(num_map[nums1[i]]);
return res;
}
};
1.3 每日温度
注意此题要在栈中存放下标,有一丁点不一样,不过思想完全一样
完整代码如下:
从后往前:
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
stack<int> st;
vector<int> res;
res.resize(temperatures.size());
for(int i = temperatures.size()-1;i >=0;i--){
while(!st.empty()&&temperatures[i] >= temperatures[st.top()])
st.pop();
res[i] = st.empty() ? 0 : (st.top()-i);
st.push(i);
}
return res;
}
};
从前往后:
// 版本二
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& T) {
stack<int> st; // 递增栈
vector<int> result(T.size(), 0);
for (int i = 0; i < T.size(); i++) {
while (!st.empty() && T[i] > T[st.top()]) { // 注意栈不能为空
result[st.top()] = i - st.top();
st.pop();
}
st.push(i);
}
return result;
}
};
1.4 处理环形数组
方法1,暴力拼接:
比较简单的思路就是将数组拼接一次,对拼接后的数组进行操作,然后再resize回到原大小,但是这样做内存消耗比较大,空间复杂度较高,代码如下:
注意,对于拼接部分,一开始写出了如下代码,不过空间复杂度较高,内存占用直接超标:
for(int i = 0;i < nums.size();i++)
nums.push_back(nums[i]);
后面换用如下代码进行拼接,复杂度下降,勉强能够通过:
vector<int> nums1(nums.begin(), nums.end());
nums.insert(nums.end(), nums1.begin(), nums1.end());
完整代码如下:
class Solution {
public:
vector<int> nextGreaterElements(vector<int>& nums) {
int n = nums.size();
vector<int> res;
stack<int> st;
// for(int i = 0;i < nums.size();i++)
// nums.push_back(nums[i]);
vector<int> nums1(nums.begin(), nums.end());
nums.insert(nums.end(), nums1.begin(), nums1.end());
res.resize(nums.size());
for(int i = nums.size()-1;i >= 0;i--){
while(!st.empty()&&nums[i]>=st.top())
st.pop();
res[i] = st.empty() ? -1 : st.top();
st.push(nums[i]);
}
res.resize(n);
return res;
}
};
方法2,%运算符模拟环形效果:
使用nums[i%n]的形式来循环遍历nums,尽管这样会导致 后面的操作覆盖之前的操作,但是由于我们只需要前n个位置,所以覆盖之后结果正好对应
完整代码如下:
class Solution {
public:
vector<int> nextGreaterElements(vector<int>& nums) {
int n = nums.size();
vector<int> res(n);
stack<int> st;
for(int i = 2*n-1;i >= 0;i--){
while(!st.empty()&&nums[i%n]>=st.top())
st.pop();
res[i%n] = st.empty() ? -1 : st.top();
st.push(nums[i%n]);
}
return res;
}
};
1.5 接雨水-单调栈深入(给我整懵了,之后再来补全吧)
之前做的单调栈题目都很简单,只需要掌握一个单调栈框架即可,但是这会导致对单调栈理解不深刻,此题使用的同样是单调栈,但是做法和前面的几题差别很大,涉及了
1:单调递减栈
2:分条件书写单调栈
3:从前往后处理单调栈
之前单调栈的代码都是比较简练的,这会导致很多细节被忽略.
这里给出从前往后遍历与从后往前遍历两种解法:
从前往后:
详解:
class Solution {
public:
int trap(vector<int>& height) {
if (height.size() <= 2) return 0; // 可以不加
stack<int> st; // 存着下标,计算的时候用下标对应的柱子高度
st.push(0);
int sum = 0;
for (int i = 1; i < height.size(); i++) {
if (height[i] < height[st.top()]) { // 情况一
st.push(i);
} if (height[i] == height[st.top()]) { // 情况二
st.pop(); // 其实这一句可以不加,效果是一样的,但处理相同的情况的思路却变了。
st.push(i);
} else { // 情况三
while (!st.empty() && height[i] > height[st.top()]) { // 注意这里是while
int mid = st.top();
st.pop();
if (!st.empty()) {
int h = min(height[st.top()], height[i]) - height[mid];
int w = i - st.top() - 1; // 注意减一,只求中间宽度
sum += h * w;
}
}
st.push(i);
}
}
return sum;
}
};
简解:
class Solution {
public:
int trap(vector<int>& height) {
int sum=0;
stack<int> st;
int l=0,r=0,mid=0;;
for(int i=0;i<height.size();i++){
while (!st.empty() && height[i] > height[st.top()]) { // 注意这里是while
int mid = st.top();
st.pop();
if (!st.empty()) {
int h = min(height[st.top()], height[i]) - height[mid];
int w = i - st.top() - 1; // 注意减一,只求中间宽度
sum += h * w;
}
}
st.push(i);
}
return sum;
}
};
从后往前:
详解:
类似上述详解
简解:
class Solution {
public:
int trap(vector<int>& height) {
int sum=0;
stack<int> st;
for(int i=height.size()-1;i >= 0;i--){
while (!st.empty() && height[i] > height[st.top()]) { // 注意这里是while
int mid = st.top();
st.pop();
if (!st.empty()) {
int h = min(height[st.top()], height[i]) - height[mid];
int w = st.top() - i - 1; // 注意减一,只求中间宽度
sum += h * w;
}
}
st.push(i);
}
return sum;
}
};
当前单调栈理解:
1:从前往后和从后往前是不一样的。比如每日温度中的两种解法:
解法1:
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& temperatures) {
stack<int> st;
vector<int> res;
res.resize(temperatures.size());
for(int i = temperatures.size()-1;i >=0;i--){
while(!st.empty()&&temperatures[i] >= temperatures[st.top()])
st.pop();
res[i] = st.empty() ? 0 : (st.top()-i);
st.push(i);
}
return res;
}
};
因为题目要求后方的最大值,所以我们只对递增栈的栈顶元素进行处理
解法2:
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& T) {
stack<int> st; // 递增栈
vector<int> result(T.size(), 0);
for (int i = 0; i < T.size(); i++) {
while (!st.empty() && T[i] > T[st.top()]) { // 注意栈不能为空
result[st.top()] = i - st.top();
st.pop();
}
st.push(i);
}
return result;
}
};
由于是从前往后入栈,要想得到后面的下一个更大值就需要在发现当前元素大于递增栈的栈顶元素后,对每一个出栈的元素都进行处理(因为栈中递增,所以只要小于当前元素的元素,他们的目标值都是当前元素)
2:各种情况分析的略去
比如上述的从前往后解法其完整写法应该如下:
class Solution {
public:
vector<int> dailyTemperatures(vector<int>& T) {
// 递增栈
stack<int> st;
vector<int> result(T.size(), 0);
st.push(0);
for (int i = 1; i < T.size(); i++) {
if (T[i] < T[st.top()]) { // 情况一
st.push(i);
} else if (T[i] == T[st.top()]) { // 情况二
st.push(i);
} else {
while (!st.empty() && T[i] > T[st.top()]) { // 情况三
result[st.top()] = i - st.top();
st.pop();
}
st.push(i);
}
}
return result;
}
};
3: 取==时如何处理,可以看到在上述代码中,从前往后和从后往前解法中,对于==的处理不一样
4:递增栈和递减的用途
二、单调队列
2.1 单调队列概述和框架
在双指针章节遇到过单调队列,但是理解较浅,这里进行重新解读和分析:
单调队列就是一个「队列」,只是使用了一点巧妙的方法,使得队列中的元素全都是单调递增(或递减)的,这一点和单调栈很像。
为啥要发明「单调队列」这种结构呢,主要是为了解决下面这个场景:
给你一个数组 window
,已知其最值为 A
,如果给 window
中添加一个数 B
,那么比较一下 A
和 B
就可以立即算出新的最值;但如果要从 window
数组中减少一个数,就不能直接得到最值了,因为如果减少的这个数恰好是 A
,就需要遍历 window
中的所有元素重新寻找新的最值。
框架:
普通队列:
class Queue {
public:
// enqueue 操作,在队尾加入元素 n
void push(int n);
// dequeue 操作,删除队头元素
void pop();
};
单调队列:
class MonotonicQueue {
public:
// 在队尾添加元素 n
void push(int n);
// 返回当前队列中的最大值
int max();
// 队头元素如果是 n,删除它
void pop(int n);
};
2.2 滑动窗口最大值
之前在队列章节总结过此题数据结构-栈和队列-总结(缺优先级队列)-CSDN博客
但是,并没有将其封装为一个特殊数据结构,导致理解其实不深刻
这里将其抽象为一个特殊数据结构来理解,并且和之前的代码进行对比分析,之前代码如下:
//方法2:双端队列
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
deque<int> tmp;
vector<int> result(nums.size() - k + 1);
for(int i=0;i<nums.size();i++){
while(!tmp.empty() && (nums[i] > tmp.back()))tmp.pop_back();
if(tmp.empty() || (nums[i] <= tmp.back() && tmp.size() <= k))tmp.push_back(nums[i]);
//<=k不能写为<k,不然会导致有些元素放不进(去掉tmp.size() <=k也行,这一步为了保证队列满了时把front剔除,但是后面的14行也有这个功能)
if(i>=k-1){
if(i>k-1 && nums[i-k] == tmp.front())tmp.pop_front();
result[i - k + 1] = tmp.front();
}
}
return result;
}
};
单调队列api封装实现:
1:push()
「单调队列」的核心思路和「单调栈」类似,push
方法依然在队尾添加元素,但是要把前面比自己小的元素都删掉:
#include<deque>
using namespace std;
class MonotonicQueue {
// 双链表,支持头部和尾部增删元素
// 维护其中的元素自尾部到头部单调递增
private:
deque<int> maxq;
// 在尾部添加一个元素 n,维护 maxq 的单调性质
public:
void push(int n) {
// 将前面小于自己的元素都删除
while (!maxq.empty() && maxq.back() < n) {
maxq.pop_back();
}
maxq.push_back(n);
}
};
如下图,4会将小于4的部分都弹出再入队列
2:max()
class MonotonicQueue {
// 为了节约篇幅,省略上文给出的代码部分...
public:
int max() {
// 队头的元素肯定是最大的
return maxq.front();
}
};
由于队列里面降序排列,所以队头就是最大值
3:pop()
有这个操作是因为这里是一个固定大小的滑动窗口,所以队列大小有上限,并不能像单调栈那样无限放入元素,所以我们需要在队列满了之后将数组中不属于该窗口的部分弹出
一般这个n会传入num[i - k +1]也就是数组中刚刚离开窗口的那个位置的值
这里不能直接进行pop(),首先要确定num[i-k+1]是否还在队列中,如果在肯定在队头(因为后面如果存在比它大的数,那么num[i-k+1]早就因为太小而被挤出队列了),所以只需要判断队头是否等于num[i-k+1]
如果不判断直接pop()的话,假如此时num[i-k+1]已经被弹出过了,我们再弹出队头元素,就有会将当前合理窗口中的最大值弹出从而使得结果输出有误
因此,有如下代码:
class MonotonicQueue {
// 为了节约篇幅,省略上文给出的代码部分...
public:
void pop(int n) {
if (n == maxq.front()) {
maxq.pop_front();
}
}
};
完整代码实现:
//使用api实现
class monotonicqueue{
private:
deque<int> que;
public:
void push(int n){
while(!que.empty()&& n>que.back())
que.pop_back();
que.push_back(n);
}
int max(){
return que.front();
}
void pop(int leave){
if(que.front() == leave)
que.pop_front();
}
};
class Solution {
public:
vector<int> maxSlidingWindow(vector<int>& nums, int k) {
vector<int> res;
monotonicqueue window;
for(int i =0;i < nums.size();i++){
if(i < k-1)window.push(nums[i]);
else{
window.push(nums[i]);
res.push_back(window.max());
window.pop(nums[i - k +1]);
}
}
return res;
}
};
对比之前代码发现,之前代码其实是将api实现和主函数混在了一起,虽然也能理解,但是不直观,后续以api形式为主
(和单调栈一样,遇到问题我们需要具体分析,不能一概抄这个api,不同题对于单调队列的内部处理可能不同,需要具体分析)
三、优先级队列
3.1 堆介绍
堆是一个完全二叉树,其主要操作就两个,sink
(下沉)和 swim
(上浮),用以维护二叉堆的性质。其主要应用有两个,首先是一种排序方法:堆排序,第二是一种很有用的数据结构:优先级队列。
根据堆的堆序性将堆分为如下两种:
最大堆的性质是:每个节点都大于等于它的两个子节点。类似的,最小堆的性质是:每个节点都小于等于它的子节点。
大根堆:
小根堆 :
堆的存储:
使用数组存储
如何使用数组直接访问到子节点和父节点呢?就要使用到完全二叉树的性质:
节点下标i,左子树下标(2i+1),右子树下标(2i+2),父节点下标(i/2)
因为这里要求父节点下标,所以我们arr[0]不存值,从1开始存储;
//存储元素的数组
Key* pq;
//当前堆的大小
int size = 0;
// 父节点的索引
int parent(int root) {
return root / 2;
}
// 左孩子的索引
int left(int root) {
return root * 2;
}
// 右孩子的索引
int right(int root) {
return root * 2 + 1;
}
堆操作:
//交换数组的两个元素
void swap(int i, int j) {
Key temp = pq[i];
pq[i] = pq[j];
pq[j] = temp;
}
//pq[i]是否比pq[j]小?
bool less(int i, int j) {
return pq[i] < pq[j];
}
//上浮第x个元素,以维护最大堆性质
void swim(int x) {...}
//下沉第x个元素,以维护最大堆性质
void sink(int x) {...}
3.2 堆的上浮和下沉
为什么要有上浮 swim
和下沉 sink
的操作呢?为了维护堆结构。
我们要讲的是最大堆,每个节点都比它的两个子节点大,但是在插入元素和删除元素时,难免破坏堆的性质,这就需要通过这两个操作来恢复堆的性质了。
1、如果某个节点 A 比它的子节点(中的一个)小,那么 A 就不配做父节点,应该下去,下面那个更大的节点上来做父节点,这就是对 A 进行下沉。
2、如果某个节点 A 比它的父节点大,那么 A 不应该做子节点,应该把父节点换下来,自己去做父节点,这就是对 A 的上浮。
上浮操作:·
void swim(int x) {
// 如果浮到堆顶,就不能再上浮了
while (x > 1 && less(parent(x), x)) {
// 如果第 x 个元素比上层大
// 将 x 换上去
swap(parent(x), x);
x = parent(x);
}
}
下沉操作:
void sink(int x) {
// 如果沉到堆底,就沉不下去了
while (left(x) <= size) {
// 先假设左边节点较大
int max = left(x);
// 如果右边节点存在,比一下大小
if (right(x) <= size && less(max, right(x)))
max = right(x);
// 结点 x 比俩孩子都大,就不必下沉了
if (less(max, x)) break;
// 否则,不符合最大堆的结构,下沉 x 结点
swap(x, max);
x = max;
}
3.3 实现优先级队列
大顶堆小顶堆,也叫优先级队列,是一种基于数组+平衡二叉树的数据结构。
优先级队列有两个主要 API,分别是 insert
插入一个元素和 delMax
删除最大元素(如果底层用最小堆,那么就是 delMin
)
insert:
template<typename Key>
class MaxPQ {
// 为了节约篇幅,省略上文给出的代码部分...
void insert(Key e) {
size++;
// 先把新元素加到最后
pq[size] = e;
// 然后让它上浮到正确的位置
swim(size);
}
};
delMax
方法先把堆顶元素 A
和堆底最后的元素 B
对调,然后删除 A
,最后让 B
下沉到正确位置。
Key delMax() {
// 最大堆的堆顶就是最大元素
Key max = pq[1];
// 把这个最大元素换到最后,删除之
swap(1, size);
pq[size] = nullptr;
size--;
// 让 pq[1] 下沉到正确位置
sink(1);
return max;
}
大顶堆优先级队列完整代码如下:
template <typename Key>
class MaxPQ {
private:
//存储元素的数组
Key* pq;
//当前Priority Queue中的元素个数
int size = 0;
public:
MaxPQ(int cap) {
//索引0不用,所以多分配一个空间
pq = new Key[cap + 1];
}
//返回当前队列中最大元素
Key max() {
return pq[1];
}
//插入元素e
void insert(Key e) {
size++;
// 先把新元素加到最后
pq[size] = e;
// 然后让它上浮到正确的位置
swim(size);
}
//删除并返回当前队列中最大元素
Key delMax() {
// 最大堆的堆顶就是最大元素
Key max = pq[1];
// 把这个最大元素换到最后,删除之
swap(1, size);
pq[size] = nullptr;
size--;
// 让 pq[1] 下沉到正确位置
sink(1);
return max;
}
private:
//交换数组的两个元素
void swap(int i, int j) {
Key temp = pq[i];
pq[i] = pq[j];
pq[j] = temp;
}
//pq[i]是否比pq[j]小?
bool less(int i, int j) {
return pq[i] < pq[j];
}
//上浮第x个元素,以维护最大堆性质
void swim(int x) {
// 如果浮到堆顶,就不能再上浮了
while (x > 1 && less(parent(x), x)) {
// 如果第 x 个元素比上层大
// 将 x 换上去
swap(parent(x), x);
x = parent(x);
}
}
//下沉第x个元素,以维护最大堆性质
void sink(int x) {
// 如果沉到堆底,就沉不下去了
while (left(x) <= size) {
// 先假设左边节点较大
int max = left(x);
// 如果右边节点存在,比一下大小
if (right(x) <= size && less(max, right(x)))
max = right(x);
// 结点 x 比俩孩子都大,就不必下沉了
if (less(max, x)) break;
// 否则,不符合最大堆的结构,下沉 x 结点
swap(x, max);
x = max;
}
public:
// 父节点的索引
int parent(int root) {
return root / 2;
}
// 左孩子的索引
int left(int root) {
return root * 2;
}
// 右孩子的索引
int right(int root) {
return root * 2 + 1;
}
};
四、前缀树(太复杂了,缺)
4.1前缀树原理
关于 Map
和 Set
,是两个抽象数据结构(接口),Map
存储一个键值对集合,其中键不重复,Set
存储一个不重复的元素集合。
常见的 Map
和 Set
的底层实现原理有哈希表和二叉搜索树两种,比如 Java 的 HashMap/HashSet 和 C++ 的 unorderd_map/unordered_set 底层就是用哈希表实现,而 Java 的 TreeMap/TreeSet 和 C++ 的 map/set 底层使用红黑树这种自平衡 BST 实现的。
而本文实现的 TrieSet/TrieMap 底层则用 Trie 树这种结构来实现。本质上 Set
可以视为一种特殊的 Map
,Set
其实就是 Map
中的键。
所以本文先实现 TrieMap
,然后在 TrieMap
的基础上封装出 TrieSet
。
Trie树本质上就是一种特殊的多叉树
多叉树:
/* 基本的多叉树节点 */
class TreeNode {
public:
int val;
vector<TreeNode*> children;
};
我们将孩子放在树枝上理解(本来应该归属于父节点的中的,这样写到外面更方便理解)
注意:不要将父节点的children指针在抽象为图时归属到子节点去了,这样会导致理解混乱!
Trie树:
Trie 树也叫前缀树,因为其中的字符串共享前缀,相同前缀的字符串集中在 Trie 树中的一个子树上,给字符串的处理带来很大的便利。
/* Trie 树节点实现 */
template<typename V>
struct TrieNode {
V val = NULL;
TrieNode<V>* children[256] = { NULL };
};
每个children数组的索引都有自己的意义,我们同样将其由父节点向外移动一点到树枝上进行显示
那么val的意义又如何理解呢,因为这里将字符存在了children中,val存放的数据似乎和字符串无关
如下图:val用于存放输出字符串的位置
形象理解就是,Trie 树用「树枝」存储字符串(键),用「节点」存储字符串(键)对应的数据(值)。所以我在图中把字符标在树枝,键对应的值 val
标在节点上:
现在已经将字符串存入了trie树中,下一步我们思考如何将字符串以O(1)取出
4.2 Triemap以及Trieset的实现
HashMap<K, V>
的优势是能够在 O(1) 时间通过键查找对应的值,但要求键的类型 K
必须是「可哈希」的;而 TreeMap<K, V>
的特点是方便根据键的大小进行操作,但要求键的类型 K
必须是「可比较」的。
本文要实现的 TrieMap
也是类似的,由于 Trie 树原理,我们要求 TrieMap<V>
的键必须是字符串类型,值的类型 V
可以随意。
我们实际上要实现的api如下:
template <typename V>
class TrieMap {
public:
// 在 Map 中添加 key
void put(string key, V val);
// 删除键 key 以及对应的值
void remove(string key);
// 搜索 key 对应的值,不存在则返回 null
// get("the") -> 4
// get("tha") -> null
V get(string key);
// 判断 key 是否存在在 Map 中
// containsKey("tea") -> false
// containsKey("team") -> true
bool containsKey(string key);
// 在 Map 的所有键中搜索 query 的最短前缀
// shortestPrefixOf("themxyz") -> "the"
string shortestPrefixOf(string query);
// 在 Map 的所有键中搜索 query 的最长前缀
// longestPrefixOf("themxyz") -> "them"
string longestPrefixOf(string query);
// 搜索所有前缀为 prefix 的键
// keysWithPrefix("th") -> ["that", "the", "them"]
vector<string> keysWithPrefix(string prefix);
// 判断是和否存在前缀为 prefix 的键
// hasKeyWithPrefix("tha") -> true
// hasKeyWithPrefix("apple") -> false
bool hasKeyWithPrefix(string prefix);
// 通配符 . 匹配任意字符,搜索所有匹配的键
// keysWithPattern("t.a.") -> ["team", "that"]
vector<string> keysWithPattern(string pattern);
// 通配符 . 匹配任意字符,判断是否存在匹配的键
// hasKeyWithPattern(".ip") -> true
// hasKeyWithPattern(".i") -> false
bool hasKeyWithPattern(string pattern);
// 返回 Map 中键值对的数量
int size();
};
完整实现代码如下:
#include <string>
#include <vector>
#include <memory>
template<typename V>
class TrieMap {
static const int R = 256;
int size = 0;
struct TrieNode {
V val = V();
TrieNode* children[R] = {nullptr};
~TrieNode() {
for (int i = 0; i < R; ++i) {
delete children[i];
}
}
};
TrieNode* root = nullptr;
public:
void put(const std::string& key, const V& val) {
if (!containsKey(key)) {
size++;
}
root = put(root, key, val, 0);
}
private:
TrieNode* put(TrieNode* node, const std::string& key, const V& val, int i) {
if (!node) {
node = new TrieNode();
}
if (i == key.length()) {
node->val = val;
return node;
}
char c = key[i];
node->children[c] = put(node->children[c], key, val, i + 1);
return node;
}
public:
void remove(const std::string& key) {
if (!containsKey(key)) {
return;
}
root = remove(root, key, 0);
size--;
}
private:
TrieNode* remove(TrieNode* node, const std::string& key, int i) {
if (!node) {
return nullptr;
}
if (i == key.length()) {
node->val = V();
} else {
char c = key[i];
node->children[c] = remove(node->children[c], key, i + 1);
}
if (node->val != V()) {
return node;
}
for (int c = 0; c < R; c++) {
if (node->children[c]) {
return node;
}
}
delete node;
return nullptr;
}
public:
V get(const std::string& key) {
TrieNode* x = getNode(root, key);
if (!x) {
return V();
}
return x->val;
}
bool containsKey(const std::string& key) {
return get(key) != V();
}
bool hasKeyWithPrefix(const std::string& prefix) {
return getNode(root, prefix) != nullptr;
}
std::string shortestPrefixOf(const std::string& query) {
TrieNode* p = root;
int i;
for (i = 0; i < query.length() && p; i++) {
if (p->val != V()) {
return query.substr(0, i);
}
char c = query[i];
p = p->children[c];
}
if (p && p->val != V()) {
return query;
}
return "";
}
std::string longestPrefixOf(const std::string& query) {
TrieNode* p = root;
int max_len = 0;
for (int i = 0; i < query.length() && p; i++) {
if (p->val != V()) {
max_len = i + 1;
}
char c = query[i];
p = p->children[c];
}
return query.substr(0, max_len);
}
std::vector<std::string> keysWithPrefix(const std::string& prefix) {
std::vector<std::string> res;
TrieNode* x = getNode(root, prefix);
traverse(x, prefix, res);
return res;
}
private:
void traverse(TrieNode* node, const std::string& prefix, std::vector<std::string>& res) {
if (!node) {
return;
}
if (node->val != V()) {
res.push_back(prefix);
}
for (int c = 0; c < R; c++) {
traverse(node->children[c], prefix + static_cast<char>(c), res);
}
}
public:
std::vector<std::string> keysWithPattern(const std::string& pattern) {
std::vector<std::string> res;
traverseWithPattern(root, "", pattern, res);
return res;
}
private:
void traverseWithPattern(TrieNode* node, const std::string& prefix, const std::string& pattern, std::vector<std::string>& res) {
if (!node) {
return;
}
int i = prefix.length();
if (i == pattern.length() && node->val != V()) {
res.push_back(prefix);
return;
}
if (i == pattern.length()) {
return;
}
char pat = pattern[i];
for (int c = 0; c < R; c++) {
if (pat == '.' || pat == c) {
traverseWithPattern(node->children[c], prefix + static_cast<char>(c), pattern, res);
}
}
}
public:
bool hasKeyWithPattern(const std::string& pattern) {
return match(root, pattern, 0);
}
private:
bool match(TrieNode* node, const std::string& pattern, int i) {
if (!node) {
return false;
}
if (i == pattern.length()) {
return node->val != V();
}
char c = pattern[i];
if (c == '.') {
for (int j = 0; j < R; j++) {
if (match(node->children[j], pattern, i + 1)) {
return true;
}
}
return false;
} else {
return match(node->children[c], pattern, i + 1);
}
}
TrieNode* getNode(TrieNode* node, const std::string& key) {
TrieNode* p = node;
for (int i = 0; i < key.length() && p; i++) {
char c = key[i];
p = p->children[c];
}
return p;
}
public:
int size() const {
return size;
}
~TrieMap() {
delete root;
}
};
前缀树的实现
五、LRU算法(缺,先不急)
5.1 LRU概述
Linux 的 Page Cache 和 MySQL 的 Buffer Pool 的大小是有限的,并不能无限的缓存数据,对于一些频繁访问的数据我们希望可以一直留在内存中,而一些很少访问的数据希望可以在某些时机可以淘汰掉,从而保证内存不会因为满了而导致无法再缓存新的数据,同时还能保证常用数据留在内存中。
这里我们就使用LRU算法来实现这个功能
LRU 算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的。那么,当空间不够了,就淘汰最久没被使用的节点,也就是链表末尾的数据,从而腾出内存空间。
传统的 LRU 算法的实现思路是这样的:
- 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部。
- 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页。
比如下图,假设 LRU 链表长度为 5,LRU 链表从左到右有编号为 1,2,3,4,5 的页。
如果访问了 3 号页,因为 3 号页已经在内存了,所以把 3 号页移动到链表头部即可,表示最近被访问了。
而如果接下来,访问了 8 号页,因为 8 号页不在内存里,且 LRU 链表长度为 5,所以必须要淘汰数据,以腾出内存空间来缓存 8 号页,于是就会淘汰末尾的 5 号页,然后再将 8 号页加入到头部。
5.2 算法框架
以此题为例实现LRU算法,要实现的api如下:
class LRUCache {
public:
LRUCache(int capacity) {
}
int get(int key) {
}
void put(int key, int value) {
}
};
api的一些具体操作演示如下:
//缓存容量为2
LRUCache* cache = new LRUCache(2);
//cache可以理解成一个队列
//假设左边为队头,右边为队尾
//最近使用的在队头,久未使用的在队尾
//键值对(key, val)用圆括号表示
cache->put(1,1);
//cache = [(1, 1)]
cache->put(2,2);
//cache = [(2, 2), (1, 1)]
cache->get(1); //返回1
//cache = [(1, 1), (2, 2)]
//解释:因为最近访问了键1,所以提前至队头
//返回键1对应的值1
cache->put(3,3);
//cache = [(3, 3), (1, 1)]
//解释:缓存容量已满,需要删除内容空出位置
//优先删除久未使用的数据,也就是队尾的数据
//然后把新的数据插入队头
cache->get(2); //返回-1(未找到)
//cache = [(3, 3), (1, 1)]
//解释:cache中不存在键为2的数据
cache->put(1,4);
//cache = [(1, 4), (3, 3)]
//解释:键1已存在,把原始值1覆盖为4
//不要忘记也要将键值对提前到队头
5.3 算法设计
分析上面的操作过程,要让 put
和 get
方法的时间复杂度为 O(1),我们可以总结出 cache
这个数据结构必要的条件:
1、显然 cache
中的元素必须有时序,以区分最近使用的和久未使用的数据,当容量满了之后要删除最久未使用的那个元素腾位置。
2、我们要在 cache
中快速找某个 key
是否已存在并得到对应的 val
;
3、每次访问 cache
中的某个 key
,需要将这个元素变为最近使用的,也就是说 cache
要支持在任意位置快速插入和删除元素。
什么数据结构同时符合上述条件呢?哈希表查找快,但是数据无固定顺序;链表有顺序之分,插入删除快,但是查找慢。所以结合一下,形成一种新的数据结构哈希链表 LinkedHashMap
:
借助这个结构,我们来逐一分析上面的 3 个条件:
1、如果我们每次默认从链表尾部添加元素,那么显然越靠尾部的元素就是最近使用的,越靠头部的元素就是最久未使用的。
2、对于某一个 key
,我们可以通过哈希表快速定位到链表中的节点,从而取得对应 val
。
3、链表显然是支持在任意位置快速插入和删除的,改改指针就行。只不过传统的链表无法按照索引快速访问某一个位置的元素,而这里借助哈希表,可以通过 key
快速映射到任意一个链表节点,然后进行插入和删除。