有没有办法从 IComparer 派生 IEqualityComparer?

Posted

技术标签:

【中文标题】有没有办法从 IComparer 派生 IEqualityComparer?【英文标题】:Is there a way to derive IEqualityComparer from IComparer? 【发布时间】:2015-06-15 17:12:17 【问题描述】:

TL;DR 我正在寻找一种从IComparer<T> 获取IEqualityComparer<T> 的方法,无论哪种数据类型是T,如果Tstring,则包括不区分大小写的选项。或者我需要一个不同的解决方案来解决这个问题。

这里是完整的故事:我正在使用 LFU 策略实现简单的通用缓存。要求是必须可以选择缓存是区分大小写还是不区分大小写——如果string 恰好是缓存键的数据类型(这不是必需的)。在我主要开发缓存的解决方案中,我预计会有数千亿次缓存查找,缓存大小最多为 100.000 个条目。由于这些数字,我立即放弃使用任何导致分配的字符串操作(例如.ToLower().GetHashCode() 等),而是选择使用IComparerIEqualityComparer,因为它们是标准的BCL 功能。此缓存的用户可以将比较器传递给构造函数。以下是相关代码片段:

public class LFUCache<TKey,TValue>

    private readonly Dictionary<TKey,CacheItem> entries;
    private readonly SortedSet<CacheItem> lfuList;

    private class CacheItem
    
        public TKey Key;
        public TValue Value;
        public int UseCount;
    

    private class CacheItemComparer : IComparer<CacheItem>
    
        private readonly IComparer<TKey> cacheKeyComparer;

        public CacheItemComparer(IComparer<TKey> cacheKeyComparer)
        
            this.cacheKeyComparer = cacheKeyComparer;
            if (cacheKeyComparer == null)
                this.cacheKeyComparer = Comparer<TKey>.Default;
        

        public int Compare(CacheItem x, CacheItem y)
        
            int UseCount = x.UseCount - y.UseCount;
            if (UseCount != 0) return UseCount;
            return cacheKeyComparer.Compare(x.Key, y.Key);
        
    

    public LFUCache(int capacity, IEqualityComparer<TKey> keyEqualityComparer,
                  IComparer<TKey> keyComparer)  // <- here's my problem
    
        // ...
        entries = new Dictionary<TKey, CacheItem>(keyEqualityComparer);
        lfuList = new SortedSet<CacheItem>(new CacheItemComparer(keyComparer));
    
    // ...

keyEqualityComparer 用于管理缓存条目(例如,如果用户愿意,键“ABC”和“abc”是相等的)。 keyComparer 用于管理按UseCount 排序的缓存条目,以便轻松选择最不常用的条目(在CacheItemComparer 类中实现)。

自定义比较的正确用法示例:

var cache = new LFUCache<string, int>(10000,
    StringComparer.InvariantCultureIgnoreCase,
    StringComparer.InvariantCultureIgnoreCase);

(这看起来很愚蠢,但StringComparer 实现了IComparer&lt;string&gt;IEqualityComparer&lt;string&gt;。)问题是如果用户给出不兼容的比较器(即不区分大小写的keyEqualityComparer 和区分大小写的keyComparer),那么最可能的结果是无效的 LFU 统计信息,因此充其量只能降低缓存命中率。另一种情况也不尽如人意。此外,如果密钥更复杂(我会有类似 Tuple&lt;string,DateTime,DateTime&gt; 的东西),可能会更严重地搞砸。

这就是为什么我希望在构造函数中只有一个比较器参数,但这似乎不起作用。我可以在IComparer&lt;T&gt;.Compare() 的帮助下创建IEqualityComparer&lt;T&gt;.Equals(),但我被困在IEqualityComparer&lt;T&gt;.GetHashCode() ——正如你所知,这非常重要。如果我可以访问比较器的私有属性来检查它是否区分大小写,我会使用CompareInfo 来获取哈希码。

我喜欢这种具有 2 种不同数据结构的方法,因为它为我提供了可接受的性能和可控的内存消耗——在我的笔记本电脑上大约 500.000 个缓存添加/秒,缓存大小为 10.000 个元素。 Dictionary&lt;TKey,TValue&gt;只是用于在O(1)中查找数据,SortedSet&lt;CacheItem&gt;在O(log n)中插入数据,通过在O(log n)中调用lfuList.Min找到要删除的元素,并找到要递增的条目在 O(log n) 中也使用计数。

欢迎任何有关如何解决此问题的建议。我会很感激任何想法,包括不同的设计。

【问题讨论】:

一种可能性是使用通用约束来定义一个静态工厂方法,该方法采用一个实现IEqualityComparer&lt;T&gt;IComparer&lt;T&gt; 的比较器参数。那么至少你没有将同一个对象传递给两个不同的参数。 这听起来很有趣,但不知何故我无法理解代码应该是什么样子。你能分享几行粗略的代码吗? ;-) 当然。看我的回答。 我认为值得检查一下是否可以自己实现SortedSet。如果您增加UseCount,则该组中只能向上更改一个位置。也许有一个 O(1) 的触摸实现。 Insert 应该是 O(1),因为它被添加到列表的末尾(也许 SortedSet 会这样做)。我将尝试一个新的示例实现。 有一个看起来很有趣 - dhruvbird.com/lfu.pdf 【参考方案1】:

不可能从IEqualityComparer 实现IComparer,因为您无法知道一个不相等的项目是大于还是小于另一个项目。

不可能从IComparer 实现IEqualityComparer,因为您无法生成符合IComparer 身份的哈希码。

也就是说,在您的情况下,您无需同时拥有两种类型的比较器。在计算 LRU 时,您将比较自项目用作主要比较器以来的时间,然后根据传入的比较器作为决胜局进行比较。只需删除最后一部分; 没有决胜局。当最近最少使用的存在平局时,让哪个项目离开缓存是未定义的。当你这样做时,你只需要接受IEqualityComparer,而不是IComparer

【讨论】:

如果将最后访​​问时间添加为键,则无法使用字典查找键,因为您不知道上次访问的时间,因此无法计算键查找字典。也许我没有正确理解您的解决方案(英语不是我的母语 - 抱歉)。 @Verarind 我不是建议 OP 改变他的解决方案。他显然已经有了一个可行的解决方案,其中他有一个并排的字典和排序集,使用字典进行查找和排序集来确定要删除的项目。 sortedset 中的项目有一个作为属性的键,如果这是您所要求的,它可用于在字典中查找项目。 非常感谢。是的,也许我会再次评估 LRU 与 LFU 的优势。 LRU 仅使用字典和双向链表更容易实现,并且具有更多 O(1) 属性。但是我不知道更改将如何影响缓存命中率。我需要做更多的工作。 @Servy 我明白了。他有一个需要IComparer 的解决方案。您告诉他更改它并为排序集使用时间戳。是的,您不会要求他完全改变它。他的解决方案也确实需要 O(log n) 来触摸一个元素。为什么不将其完全更改为在每种情况下都适用于 O(1) 并且不需要任何 UseCount 的解决方案?他还表示欢迎不同的设计。 @Verarind 不,我告诉他要做的就是不要将实际对象的比较用于应该丢弃的项目的决胜局,他应该让平局发生。如果有了这种变化,他的解决方案很好,那就太好了;没有别的事可做。如果他的整个解决方案除了有这个问题之外,也太慢了,那么这是一个完全不同的问题,与所提出的问题完全不同。他似乎觉得其余的代码运行良好,所以如果是这样,我认为没有理由重新编写整个代码。【参考方案2】:

正如我在评论中提到的那样,您可以添加一个帮助方法,这可能会使基本用例的事情变得更简单:

public class LFUCache<TKey,TValue>

    public static LFUCache<TKey, TValue> Create<TComp>(int capacity, TComp comparer) where TComp : IEqualityComparer<TKey>, IComparer<TKey>
    
        return new LFUCache<TKey, TValue>(capacity, comparer, comparer);
    

你会这样使用它:

var cache = LFUCache<string, int>.Create(10000, StringComparer.InvariantCultureIgnoreCase);

【讨论】:

仅仅因为在这种特殊情况下比较器碰巧实现了IEqualityComparerIComparer 并不意味着总是如此。对于所有其他情况,他需要创建一个新类来包装他拥有的两个比较器,然后该类需要确保两个比较器共享一个身份。这或多或少只是将问题推到别处,而不是消除它。 @Servy OP 要求提供与我的评论相关的代码,但评论有点太长了。我同意,它只是让 OP 所做的事情变得更简单。对于该用例,我可能会为构造函数留下两个比较器。一般来说,甚至不能保证GetHashCodeEqualsIEqualityComparer&lt;T&gt; 实现一致,更不用说与某些IComparer&lt;T&gt; 一致了。这就是我们进行代码审查和自动化测试的原因。【参考方案3】:

好的,下次试试。这是 LFU 的 AddTouch 的实现:

public class LfuCache<TKey, TValue>

    private readonly Dictionary<TKey, LfuItem> _items;

    private readonly int _limit;

    private LfuItem _first, _last;

    public LfuCache(int limit, IEqualityComparer<TKey> keyComparer = null)
    
        this._limit = limit;
        this._items = new Dictionary<TKey,LfuItem>(keyComparer);
    

    public void Add(TKey key, TValue value)
    
        if (this._items.Count == this._limit)
        
            this.RemoveLast();
        

        var lfuItem = new LfuItem  Key = key, Value = value, Prev = this._last ;
        this._items.Add(key, lfuItem);

        if (this._last != null)
        
            this._last.Next = lfuItem;
            lfuItem.Prev = this._last;
        

        this._last = lfuItem;

        if (this._first == null)
        
            this._first = lfuItem;
        
    

    public TValue this[TKey key]
    
        get
        
            var lfuItem = this._items[key];
            ++lfuItem.UseCount;

            this.TryMoveUp(lfuItem);

            return lfuItem.Value;
        
    

    private void TryMoveUp(LfuItem lfuItem)
    
        if (lfuItem.Prev == null || lfuItem.Prev.UseCount >= lfuItem.UseCount) // maybe > if you want LRU and LFU
        
            return;
        

        var prev = lfuItem.Prev;
        prev.Next = lfuItem.Next;
        lfuItem.Prev = prev.Prev;
        prev.Prev = lfuItem;

        if (lfuItem.Prev == null)
        
            this._first = lfuItem;
        
    

    private void RemoveLast()
    
        if (this._items.Remove(this._last.Key))
        
            this._last = this._last.Prev;
            if (this._last != null)
            
                this._last.Next = null;
            
        
    

    private class LfuItem
    
        public TKey Key  get; set; 

        public TValue Value  get; set; 

        public long UseCount  get; set; 

        public LfuItem Prev  get; set; 

        public LfuItem Next  get; set; 
    

在我看来,AddTouch 在 O(1) 中,不是吗?

目前我没有看到_first 的任何用例,但也许其他人需要它。删除一个项目_last 就足够了。

编辑 如果您不需要 MoveDown 操作,也可以使用单个链表。 编辑 没有单个链表不起作用,因为 MoveUp 需要 Next 指针将其更改为 Prev 指针。

【讨论】:

看起来很好,但是我看不到强制缓存大小限制的代码(这是事情开始变得棘手的地方)。 好点。我更新了Add并添加了RemoveLast。这应该说明它是如何工作的。 嗯,看着TryMoveUp(),我认为一定有一个循环。例如,如果我们有使用计数为 3、2、2、2、2、2、1 的条目,并且最右边的“2”被触摸,因此它的使用计数变为 3,它应该向左移动 4 次。否则,列表将不会将最常用的项目放在靠近顶部的位置。所以这是我们将拥有 O(n) 的唯一地方。 是的。几分钟前我意识到了这一点。我的假设有一个错误。可能有超过 1 个 MoveUp!如果所有n 条目都具有相同的UseCount,则最后一项必须向上移动到第一个位置,即O(n) 操作。对不起。此实现具有 O(1) 的最佳情况,但 O(n) 的最坏情况。不知道在最坏的情况下使用具有 O(log n) 的 SortedSet 或您找到的文档是否会更好。很抱歉。 我阅读了您链接的论文。很有意思。基本想法可能与我的想法相同。他们使用两个双向链表——一个用于 UseCount,一个用于相同 UseCount 的项目。使用这种方法,UseCount 列表中只有一个“MoveUp”,旧列表中的一个删除操作和新列表中的一个添加操作。听起来非常简单直接。很好的纸。我对找到它表示赞赏。也许有时我会需要它(希望我能记住这个帖子)。【参考方案4】:

您可以尝试在构造函数中使用IComparer 和定义GetHashCode() 的lambda,而不是使用IEqualityComparerIComparer。然后build an IEqualityComparer 基于if(IComparer==0)GetHashCode() = lambda

虽然我会说它很小,但当IComparer 返回 0 时,您仍然存在 HashCode 不匹配的风险。如果您想让代码的用户非常清楚,您可以随时扩展策略:构造函数中的两个 lambda:Func&lt;T,T,int&gt; 用于 IComparerIEqualityComparerFunc&lt;T,int&gt; 用于 GetHashCode

【讨论】:

以上是关于有没有办法从 IComparer 派生 IEqualityComparer?的主要内容,如果未能解决你的问题,请参考以下文章

从派生类调用重载函数

有没有办法根据派生类的可能性值范围来限制对象参数?

从派生类中删除虚函数

有没有办法在不知道派生类型的情况下将包含派生指针的 std::any 转换为基指针?

HashSet使用自定义IComparer排序而不使用LINQ

有没有办法让派生实例使用现有的基础实例