前些时日,笔者晚上回到宿舍,听到隔壁同学激烈讨论LRU,那时候笔者还不知道LRU是什么,只听到什么哈希表、双向链表,哈希表和双向链表怎么对应诸如此类的东西,讨论甚是激烈。
于是笔者在想,LRU的实现很复杂吗?带着这个疑问,笔者开始了学习。
关于LRU的基本原理,推荐这一篇文章:
【常见缓存算法原理及其C++实现】LRU篇(包含过期时间详解)
这篇文章给出的代码实现好像有些问题,示例代码并不能运行成功,但是又找不出哪里错了,于是笔者去看了力扣的官方实现。
在看官方的代码实现时,在评论区看到官方使用C++实现的LRU缓存存在内存泄漏的问题。在构造函数中new了两个双向链表的节点,即head和tail。在结束时这两个在堆区new出来的对象要删除,并且在新增加双向链表的节点的时候,也是使用new,因此在最后,创建的双向链表要删除,哈希表也要清空。
可以通过在LRUCache类中添加一个析构函数来释放未释放的资源,以避免内存泄漏。析构函数通常用于在对象销毁时执行清理工作。在析构函数中,你可以删除双向链表中的节点,以及删除哈希表中的键值对,并释放由于创建LRUCache对象而分配的内存。
以下是详细注释的带析构函数的完整C++代码实现:
#include <iostream>
#include<unordered_map>
using namespace std;
//双向链表的存储结构
struct DLinkedNode{
int key, value;
DLinkedNode* prev; //前指针
DLinkedNode* next; //后指针
//构造函数
DLinkedNode() : key(0), value(0), prev(nullptr), next(nullptr){};
DLinkedNode(int _key, int _value) : key(_key), value(_value), prev(nullptr), next(nullptr){};
};
//LRUCache类
class LRUCache{
private:
//双向链表头结点和尾节点
DLinkedNode* head;
DLinkedNode* tail;
//哈希表, key和双向链表节点的地址
unordered_map<int, DLinkedNode*> cache;
//大小和容量
int size;
int capacity;
//一些辅助函数
//将一个双向链表中的节点在原来的位置断开,模拟删除操作,其实不是真正删除
//后续还有相关操作改变它的前后指针
void removeNode(DLinkedNode* node)
{
node->prev->next = node->next;
node->next->prev = node->prev;
}
//只在头部添加节点
void addToHead(DLinkedNode* node)
{
//注意这个函数的实现,要先赋值node的前后指针, 否则可能出现指向错误
node->prev = head;
node->next = head->next;
head->next->prev = node;
head->next = node; //这个一定是在最后,否则出现使用贡更新之后的head->next操作其他的
}
//将一个节点在原链表中断开,并将它移动到头部节点的下一个,
void moveToHead(DLinkedNode* node)
{
removeNode(node);
/* head->next = node;
node->prev = head;
node->next = head->next;
head->next->prev = node; //这样写的逻辑是错的*/
addToHead(node);
}
public:
//构造函数
LRUCache(int _capacity)
{
capacity = _capacity;
size = 0;
//初始化头尾指针
head = new DLinkedNode(-1,-1);
tail = new DLinkedNode(-1,-1);
head->next = tail;
tail->prev = head;
}
//析构函数在对象销毁时执行清理工作
//在析构函数中,可以执行删除双向链表中的节点,删除哈希表中的键值对,并释放由于创建LRUCache对象而分配的内存
~LRUCache()
{
// 释放双向链表中的节点
DLinkedNode* node = head->next;
while(node != tail)
{
DLinkedNode* nextNode = node->next;
delete node;
node = nextNode;
}
//清空哈希表
cache.clear();
//释放头尾节点
delete head;
delete tail;
}
//查找key对应的值, 通过哈希表查找key对应的节点在哪,
//然后通过removeNode将他在双向链表中的原位置断开,其实是断开前后,不用真正删除这个节点
//只是与原来的断开,移动到新的位置,这个位置在头结点的下一个位置,只要改变它的前后指针就可
int get(int key)
{
//通过哈希表查找这个不存在cache.count(key) == false
if (!cache.count(key))
{
return -1;
}
//如果key存在在哈希表中,返回val值,并把这个节点从原来位置断开,放到头结点后面,通过moveToHead实现
//这时不用修改哈希表,key对应的地址没变
DLinkedNode* node = cache[key];
moveToHead(node);
return node->value;
}
//向缓存中放数
void put(int key, int value)
{
//如果这个数在哈希表中不存在,那么新创建一个;在创建的时候,需要考虑缓存是否已经满了
// 如果存在,更新它的值,并移动到头部
if (!cache.count(key)) //不存在
{
DLinkedNode* node = new DLinkedNode(key, value);
cache[key] = node; //创建这个节点并更新哈希表
//将这个节点放在头部节点的后面
addToHead(node);
size++;
//检查是否缓存已经满了
if (size > capacity)
{
//删除尾节点的前一个节点,使用removeHead,由于这个函数没有真正删除,所以要手动删除
DLinkedNode *removed = tail->prev;
removeNode(removed);
cache.erase(removed->key); //哈希表也要删除
delete removed;
size--;
}
}
else //如果存在,更新它的值,并移动到头部
{
DLinkedNode* node = cache[key];
node->value = value;
moveToHead(node);
}
}
};
int main()
{
LRUCache *lruCache = new LRUCache(2);
lruCache->put(1, 100);
lruCache->put(2, 200);
cout<<lruCache->get(1)<<endl;
lruCache->put(3, 300);
cout<<lruCache->get(2)<<endl;
// 销毁对象并释放内存
delete lruCache;
return 0;
}
注意上述代码中的put函数,如果在缓存中不存在这个key-value节点,就要创建这个节点,更新哈希表,然后把这个节点放到双向链表中的头结点head的下一个节点。他是先往头部添加节点,然后再判断缓存满了没有,满了就删掉尾节点的前一个节点。那么这样可能存在一个问题,就是说,我创建这个节点的时候缓存已经满了,他可能插入不进去。那么应该先判断缓存是否是满的,然后再向缓存中添加,因此put函数的代码可以改成:
//向缓存中放数
void put(int key, int value)
{
//如果这个数在哈希表中不存在,那么新创建一个;在创建的时候,需要考虑缓存是否已经满了
// 如果存在,更新它的值,并移动到头部
if (!cache.count(key)) //不存在
{
DLinkedNode* node = new DLinkedNode(key, value);
cache[key] = node; //创建这个节点并更新哈希表
/*//将这个节点放在头部节点的后面
addToHead(node);
size++;*/
//检查是否缓存已经满了
if (size >= capacity)
{
//删除尾节点的前一个节点,使用removeHead,由于这个函数没有真正删除,所以要手动删除
DLinkedNode *removed = tail->prev;
removeNode(removed);
cache.erase(removed->key); //哈希表也要删除
delete removed;
size--;
}
//将这个节点放在头部节点的后面
addToHead(node);
size++;
}
else //如果存在,更新它的值,并移动到头部
{
DLinkedNode* node = cache[key];
node->value = value;
moveToHead(node);
}
}
在上述代码中的主函数中,最终使用了delete来销毁对象并释放内存,为了避免忘记这一步操作,可以使用智能指针(例如std::shared_ptr
)来管理LRUCache对象中的内存,以确保资源在不再需要时得到释放。这样可以避免手动管理内存,从而减少内存泄漏的风险。
以下是如何修改代码以使用std::shared_ptr
来管理LRUCache对象:
#include <iostream>
#include <unordered_map>
#include <memory>
using namespace std;
// 双向链表的存储结构
struct DLinkedNode {
int key, value;
DLinkedNode* prev;
DLinkedNode* next;
// 构造函数
DLinkedNode() : key(0), value(0), prev(nullptr), next(nullptr) {};
DLinkedNode(int _key, int _value) : key(_key), value(_value), prev(nullptr), next(nullptr) {};
};
// LRUCache类
class LRUCache {
private:
// 双向链表头结点和尾节点
DLinkedNode* head;
DLinkedNode* tail;
// 哈希表, key和双向链表节点的地址
unordered_map<int, DLinkedNode*> cache;
// 大小和容量
int size;
int capacity;
// 一些辅助函数
// ...
public:
// 构造函数
LRUCache(int _capacity) {
capacity = _capacity;
size = 0;
head = new DLinkedNode(-1, -1);
tail = new DLinkedNode(-1, -1);
head->next = tail;
tail->prev = head;
}
// 析构函数
~LRUCache() {
DLinkedNode* node = head->next;
while (node != tail) {
DLinkedNode* nextNode = node->next;
delete node;
node = nextNode;
}
cache.clear();
delete head;
delete tail;
}
// 其他函数...
};
int main() {
// 使用智能指针来管理LRUCache对象
shared_ptr<LRUCache> lruCache = make_shared<LRUCache>(2);
lruCache->put(1, 100);
lruCache->put(2, 200);
cout << lruCache->get(1) << endl;
lruCache->put(3, 300);
cout << lruCache->get(2) << endl;
// 当shared_ptr超出作用域时,析构函数会自动释放内存
return 0;
}
在上述代码中,我们使用std::shared_ptr
来管理LRUCache对象。当shared_ptr
超出作用域时,析构函数会自动调用,从而释放LRUCache对象及其内部资源,避免了内存泄漏问题。这是一种更安全和方便的方式来管理资源。
补充知识:
1、使用new创建出来的类对象使用析构函数销毁
使用new
创建的类对象可以使用析构函数销毁。在C++中,当你使用new
操作符来分配对象的内存时,你需要负责手动释放这些内存以避免内存泄漏。通常,你应该使用delete
操作符来销毁对象,并在销毁对象之前调用对象的析构函数来执行清理工作。
以下是示例代码,演示如何使用new
和delete
以及析构函数来创建和销毁类对象:
#include <iostream>
class MyClass {
public:
MyClass() {
std::cout << "MyClass constructor" << std::endl;
}
~MyClass() {
std::cout << "MyClass destructor" << std::endl;
}
void someMethod() {
std::cout << "Some method of MyClass" << std::endl;
}
};
int main() {
// 使用new创建MyClass对象
MyClass* obj = new MyClass;
// 调用对象的方法
obj->someMethod();
// 销毁对象并释放内存
delete obj;
return 0;
}
在上述示例中,我们使用new
创建了MyClass
对象,然后在不再需要对象时,使用delete
销毁对象并释放内存。在销毁对象时,析构函数会被调用来执行清理工作。这是一种手动管理内存的方式,但需要谨慎使用,以避免内存泄漏和悬挂指针等问题。在实际开发中,建议使用智能指针等现代C++特性来自动管理内存,以减少错误和提高代码的健壮性。
2、在C++中,有哪些方法可以创建类对象?
栈上创建对象: 这是最常见的方式,可以通过直接声明对象来创建,对象的生命周期在当前作用域内。例如:
MyClass obj; // 在栈上创建MyClass对象
堆上创建对象: 使用 new
操作符在堆上分配内存并返回指向对象的指针。需要手动释放内存以避免内存泄漏。例如:
MyClass* obj = new MyClass; // 在堆上创建MyClass对象
// ...
delete obj; // 手动释放内存
使用智能指针: 可以使用 std::shared_ptr
或 std::unique_ptr
来管理对象的生命周期,从而自动释放内存。例如:
std::shared_ptr<MyClass> obj = std::make_shared<MyClass>();
// 不需要手动释放内存,当引用计数为零时,对象会自动销毁
作为成员变量: 类对象可以作为其他类的成员变量。这样,当包含它的类对象被创建时,包含的类对象也会被创建。例如:
class MyClass {
public:
MyClass() {
// 构造函数代码
}
};
class AnotherClass {
private:
MyClass myObject; // MyClass对象作为AnotherClass的成员
public:
AnotherClass() {
// 构造函数代码
}
};
通过复制或移动: 可以通过复制或移动一个已经存在的对象来创建新对象。这通常涉及到拷贝构造函数或移动构造函数的调用。例如:
MyClass originalObj;
MyClass newObj = originalObj; // 复制构造函数被调用,创建一个新对象
这些是常见的创建类对象的方式,每种方法都有其用途和适用场景。你可以根据需求和对象的生命周期来选择适当的方法。注意要妥善处理内存管理,以避免内存泄漏或悬挂指针等问题。