LRU缓存如何比哈希表更快?

3

除了它由哪些结构组成,我没有仔细研究过LRU缓存,但我仍然很惊讶它比常规哈希表快多了。

我进行了一个测试,使用常规哈希表保存递归(动态规划)期间结果的问题(一个递归组合问题),并且只有一个区别:使用了LRU缓存实现(大小为1024)。

性能从1秒下降到0.006秒!

现在,这非常令人惊讶,我不知道为什么会这样。哈希表对于大多数操作具有O(1)时间复杂度,而LRU缓存需要同时哈希表双向链表。

背景:

  • 我在此项目中使用c ++。所讨论的哈希表是具有字符串键和整数值的unordered_map。我听说过unordered_map具有最坏情况下的复杂度N或N2,但据我所知,它通常在O(1)中执行所有操作。

  • LRU缓存实现是从stackoverflow上复制粘贴的:D

代码

使用LRU缓存

#include <bits/stdc++.h>

using namespace std;
using namespace std::chrono;

template <typename T,typename U>                                                   
std::pair<T,U> operator+(const std::pair<T,U> & l,const std::pair<T,U> & r) {   
    return {l.first+r.first,l.second+r.second};                                    
}
#pragma GCC optimize ("Ofast")
#pragma GCC target ("avx2")


// LRU Cache implementation
template <class KEY_T, class VAL_T> class LRUCache{
private:
        list< pair<KEY_T,VAL_T> > item_list;
        unordered_map<KEY_T, decltype(item_list.begin()) > item_map;
        size_t cache_size;
private:
        void clean(void){
                while(item_map.size()>cache_size){
                        auto last_it = item_list.end(); last_it --;
                        item_map.erase(last_it->first);
                        item_list.pop_back();
                }
        };
public:
        LRUCache(int cache_size_):cache_size(cache_size_){
                ;
        };

        void put(const KEY_T &key, const VAL_T &val){
                auto it = item_map.find(key);
                if(it != item_map.end()){
                        item_list.erase(it->second);
                        item_map.erase(it);
                }
                item_list.push_front(make_pair(key,val));
                item_map.insert(make_pair(key, item_list.begin()));
                clean();
        };
        bool exist(const KEY_T &key){
                return (item_map.count(key)>0);
        };
        VAL_T get(const KEY_T &key){
                assert(exist(key));
                auto it = item_map.find(key);
                item_list.splice(item_list.begin(), item_list, it->second);
                return it->second->second;
        };

};


// recursive solution to a combinatorics problem

// number of permutations of each parcel
int item_ways(int w, int n, int max_w){
    if (w == 0 and n == 0)
        return 1;
    if (w <= 0 or n <= 0)
        return 0;
    int ways = 0;
    for (int i = 1; i <= max_w; i++)
        ways += item_ways(w-i, n-1, i);
    return ways;
}   

// total combinations for answer
LRUCache<string,int> dp(1024);
//unordered_map<string,int> dp;
int parcel_ways(int p, int max_w, int n, int w){
    if (p == 0 and n == 0) 
        return 1;
    if (p <= 0 and n <= 0)
        return 0;

    string x; 
    x += char(p); 
    x += char(max_w);
    x += char(n);
    x += char(w);
    
    if(dp.exist(x)) // caching/dp skips recursion here
    {
        return dp.get(x);
    }

    int ways = 0;
    for (int i = 1; i <= n; i++){
        ways += parcel_ways(p-1, max_w, n-i, w) * item_ways(w, i, max_w);
    }
    
    dp.put(x,ways); // cache here
    return ways;
}

// input any 4 numbers for problem
void solve()
{
    auto start = high_resolution_clock::now();
    cout << parcel_ways(5,8,23,17);
    auto stop = high_resolution_clock::now();
    auto duration = duration_cast<microseconds>(stop - start);
    cout << "Time taken by function: "
         << duration.count() << " microseconds" << endl;

}

int main()
{
    solve();
    return 0;
}

使用unordered_map(哈希表)

#include <bits/stdc++.h>

using namespace std;
using namespace std::chrono;

template <typename T,typename U>                                                   
std::pair<T,U> operator+(const std::pair<T,U> & l,const std::pair<T,U> & r) {   
    return {l.first+r.first,l.second+r.second};                                    
}
#pragma GCC optimize ("Ofast")
#pragma GCC target ("avx2")

// number of permutations of each parcel
int item_ways(int w, int n, int max_w){
    if (w == 0 and n == 0)
        return 1;
    if (w <= 0 or n <= 0)
        return 0;
    int ways = 0;
    for (int i = 1; i <= max_w; i++)
        ways += item_ways(w-i, n-1, i);
    return ways;
}   

// total combinations for answer
unordered_map<string,int> dp;
int parcel_ways(int p, int max_w, int n, int w){
    if (p == 0 and n == 0) 
        return 1;
    if (p <= 0 and n <= 0)
        return 0;

    string x; 
    x += char(p); 
    x += char(max_w);
    x += char(n);
    x += char(w);
    
    if(dp[x]) // caching/dp skips recursion here
    {
        return dp[x];
    }

    int ways = 0;
    for (int i = 1; i <= n; i++){
        ways += parcel_ways(p-1, max_w, n-i, w) * item_ways(w, i, max_w);
    }
    
    dp[x] = ways; // cache here
    return ways;
}

void solve()
{
    auto start = high_resolution_clock::now();
    cout << parcel_ways(5,8,23,17);
    auto stop = high_resolution_clock::now();
    auto duration = duration_cast<microseconds>(stop - start);
    cout << "Time taken by function: "
         << duration.count() << " microseconds" << endl;
}

int main()
{
    solve();
    return 0;
}

2
请在您的问题中加入 [mcve] 实现两个版本的代码。如果没有代码,我们只能猜测发生了什么。 - Stephen Newell
1
就性能而言,FWIW unordered_map<T> 实际上很差。由于标准所要求的限制,它基本上必须被实现为一个 std::vector<std::list<T>>。这保证了每次访问至少有一个缓存未命中,很可能还会更多。这意味着你最好以 RAM 速度运行。 - NathanOliver
3
默认设置是没有进行任何优化。在没有进行优化的情况下测量运行时间并不是很有用。例如,尝试使用“-O3”选项进行优化。 - 463035818_is_not_a_number
5
@ron0studios 的代码中出现了 #include <bits/stdc++.h> 这个头文件,这个不太好。另外还有 #define mp make_pair#define pb push_back#define ll long long,这也不太好。你是在使用一个“在线编程竞赛”网站来编写你的例子吗?同时,这段代码中也没有输入数据。 - PaulMcKenzie
1
@ron0studios 我使用了std::chrono的高精度时钟 -- 那应该是发布的代码的一部分。 - PaulMcKenzie
显示剩余19条评论
2个回答

5

你的实现中有很多不够优化的地方,但是我唯一看到可以导致你看到巨大差异的地方是这个:

if(dp[x]) // caching/dp skips recursion here
{
    return dp[x];
}

如果 dp[x]==0,这个函数不会返回任何结果,因此您将重新计算任何 0 结果。
带有 LRU 缓存的版本使用 exists,在这种情况下将进行早期返回。
可以通过使用 dp.contains(x)(如果您没有 c++20,则为 dp.count(x))来完成。

1
要实现LRU缓存,您需要高效地执行以下操作:
  • 按名称存储条目
  • 检查是否为给定名称存储了条目
  • 按名称检索条目
  • 获取存储的元素数量
  • 获取最旧的条目并将其从缓存中删除
如果您想使用仅一个数据结构来实现LRU缓存,则平衡树是最佳选择,因为每个操作都需要O(log(N))。或者,如果您确定有足够的缓存空间,并且不需要清除任何元素,则可以使用哈希表。
但是,在维护事物排序方面,哈希表很糟糕,这就是为什么我们需要使用双向链表的原因。
在LRU缓存实现中,为避免重复,链表存储实际值,而哈希表存储链表节点的内存地址。
“哈希映射对于大多数操作具有O(1)时间复杂度”这个说法是不正确的。存储和检索需要O(1),但是在哈希映射中删除最旧的条目需要O(N)。当访问存储在缓存中的元素时,我们需要将此现有元素移动到列表的前面,只有在双向链表中才能有效地执行此操作,而不是哈希映射。由于我们在哈希映射中存储了链表的指针,因此我们可以在O(1)时间内从哈希映射中检索元素并通过设置retrieved_node.prev==Nullretrieved_node.prev==null来删除它。但是,在此之前,您需要保留prevnext的引用以保持链接的链表连接。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接