高效克隆 HashSet<T> 的方法是什么?

43

几天前,我在stackoverflow回答了一个有关HashSet<T>有趣问题。有一个可能的解决方案是克隆hashset,而在我的回答中,我建议这样做:

HashSet<int> original = ...
HashSet<int> clone = new HashSet<int>(original);
虽然这种方法非常直接,但我认为它非常低效:新的 HashSet<T> 的构造函数需要从原始哈希集中单独添加每个项,并检查它是否已经存在。显然这是浪费时间的:因为源集合是一个ISet<T>,它保证不包含重复项。应该有一种方法利用这个知识...
理想情况下, HashSet<T> 应该实现 ICloneable, 但不幸的是, 它并不是这种情况. 作者也使用 Reflector 检查了,如果源集合是哈希集,则 HashSet<T> 构造函数是否执行了特定操作,但没有。可能可以通过反射私有字段来完成,但那将是一个丑陋的hack...
那么,有人提出了更聪明的解决方案来更高效地克隆一个哈希集吗?
(请注意,此问题纯理论,我不需要在实际程序中这样做)

嗯,好问题。只是好奇,我们关注的理论无效率是什么?对于抽象数据类型的顺序符号,我有点生疏了,但是在目标哈希集合中检查是否存在不就是简单的O(1)冲突测试吗?从信息角度来看,我同意它可能会更好,但我们能否对其进行限制,这是否重要? - johnny g
2
我怀疑它们没有一个 HashSet<T>(ISet<T>) 构造函数,是因为任何类都可以实现 ISet<T>,也许做得不好;这意味着 ISet<T> 的存在并不能保证没有重复。 - Steve Ellinger
2
@Steve Ellinger,您可能是正确的。但是,他们本可以提供一个 HashSet<T>(HashSet<T>) 构造函数... - Thomas Levesque
实际上,我好奇的是为什么他们没有实现ICloneable接口,是因为任何实现都不会比你在答案中调用的构造函数更有效;因此,既然功能已经可用,为什么还要麻烦呢?你的复制构造函数也可能是同样的情况。当然,考虑到你关于“检查是否已经存在”的评论,这似乎不太可能。嗯。 - Steve Ellinger
3
即使反序列化程序不进行任何假设,也要使用AddIfNotPresent()。好主意,文化可能已经改变了。这是一个不能接受的事情。首先要质疑是否需要克隆。昂贵的操作应该是昂贵的。很棒的API设计。 - Hans Passant
显示剩余8条评论
6个回答

14

如果您真的想要克隆 HashSet<T> 最有效的方法,请按照以下步骤进行(但可能会牺牲可维护性)

  1. 使用反射器或调试器确定需要复制的 HashSet<T> 中的确切字段。您可能需要针对每个字段递归执行此操作。
  2. 使用Reflection.Emit或使用表达式树生成一个方法,该方法执行所有字段的必要复制。可能需要调用其他生成的方法来复制每个字段的值。我们使用运行时代码生成,因为这是唯一直接访问私有字段的方式。
  3. 使用FormatterServices.GetUninitializedObject(...) 实例化空对象。使用第2步生成的方法将原始对象复制到新的空对象中。

忘了提到(显而易见的优化),您需要缓存生成的方法并将其重用于所有克隆操作。 - jthg
抱歉,我错过了你称之为“丑陋黑客”的那部分。如果您使用表达式树而不是Reflection.Emit,它就不应该太丑陋。当然,对HashSet的实现细节的紧密依赖可能会使其变得丑陋,如果MS决定调整HashSet的话。 - jthg
我不喜欢在私有成员上使用反射的想法...但是除非微软实现了一个适当的复制构造函数,否则我同意这可能是最有效的方法。 - Thomas Levesque
由于源代码现在可用,我想知道是否有人已经实现了只使用现有相同类型的HashSet构造并初始化原始容量,而不使用AddWithPresent添加。我不明白为什么还没有人处理这个问题。 - Chibueze Opata
@jthg 我从未见过有人使用FormatterServices.GetUnintializedObject(...)来做除了序列化之外的事情...这是一个鲜为人知的方法的史诗级用法! - mhand

12
我检查了.NET Framework版本4.5.2和版本4.7.2的源代码。 版本4.7.2在构造函数中进行了优化,以处理传入的集合类型为HashSet的情况,使用了一些内部克隆逻辑。 为使此逻辑起作用,您还需要将比较器传递到构造函数中。 看起来版本4.5.2没有这种优化。
var clonedSet = new HashSet(set, set.Comparer);

2
是的,我认为它已经在4.6版中添加了。 - Thomas Levesque

2

编辑:经过仔细检查,这似乎不是一个好主意。在原始哈希集中有少于60个元素时,下面的方法似乎比创建新哈希集更慢。

免责声明:这似乎有效,但使用风险自负。如果您将序列化克隆的哈希集,您可能需要复制SerializationInfo m_siInfo。

我也遇到了这个问题,并尝试解决它。以下是一个扩展方法,它使用FieldInfo.GetValue和SetValue来复制所需的字段。它比使用HashSet(IEnumerable)更快,速度取决于原始哈希集中的元素数量。对于1,000个元素,差异约为7倍。对于100,000个元素,差异约为3倍。

还有其他可能更快的方法,但现在这个方法已经消除了我的瓶颈。我尝试使用表达式树和发射,但遇到了障碍,如果我让它们工作,我会更新此帖子。

using System;
using System.Collections.Generic;
using System.Reflection;
using System.Runtime.Serialization;

public static class HashSetExtensions
{
    public static HashSet<T> Clone<T>(this HashSet<T> original)
    {
        var clone = (HashSet<T>)FormatterServices.GetUninitializedObject(typeof(HashSet<T>));
        Copy(Fields<T>.comparer, original, clone);

        if (original.Count == 0)
        {
            Fields<T>.freeList.SetValue(clone, -1);
        }
        else
        {
            Fields<T>.count.SetValue(clone, original.Count);
            Clone(Fields<T>.buckets, original, clone);
            Clone(Fields<T>.slots, original, clone);
            Copy(Fields<T>.freeList, original, clone);
            Copy(Fields<T>.lastIndex, original, clone);
            Copy(Fields<T>.version, original, clone);
        }

        return clone;
    }

    static void Copy<T>(FieldInfo field, HashSet<T> source, HashSet<T> target)
    {
        field.SetValue(target, field.GetValue(source));
    }

    static void Clone<T>(FieldInfo field, HashSet<T> source, HashSet<T> target)
    {
        field.SetValue(target, ((Array)field.GetValue(source)).Clone());
    }

    static class Fields<T>
    {
        public static readonly FieldInfo freeList = GetFieldInfo("m_freeList");
        public static readonly FieldInfo buckets = GetFieldInfo("m_buckets");
        public static readonly FieldInfo slots = GetFieldInfo("m_slots");
        public static readonly FieldInfo count = GetFieldInfo("m_count");
        public static readonly FieldInfo lastIndex = GetFieldInfo("m_lastIndex");
        public static readonly FieldInfo version = GetFieldInfo("m_version");
        public static readonly FieldInfo comparer = GetFieldInfo("m_comparer");

        static FieldInfo GetFieldInfo(string name)
        {
            return typeof(HashSet<T>).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic);
        }
    }
}

0

易于实现但并不适用于许多集合的简单模式:

Class cloneableDictionary(Of T, U)
    Inherits Dictionary(Of T, U)
    Function clone() As Dictionary(Of T, U)
        Return CType(Me.MemberwiseClone, cloneableDict(Of T, U))
    End Function
End Class

不幸的是,我不知道微软是否采取了任何措施来防止在不应该调用MemberwiseClone的地方(例如声明名称为MemberwiseClone的方法之外的其他内容,比如类)中调用它,因此我不知道这种方法是否可行。

我认为标准集合不支持公共克隆方法而只支持受保护的方法是有充分理由的:如果克隆一个从集合派生的类可能会严重破坏它,如果基类的克隆方法是公共的,就无法防止将派生类的对象提供给期望克隆它的代码。

话虽如此,如果.NET包括cloneableDictionary和其他这样的类作为标准类型(尽管显然不是像上面那样实现),那就太好了。


2
这样做不行...它执行的是浅复制,这正是我(有点)想要的,但它太浅了:大多数集合在内部使用数组来存储项和/或桶,而MemberwiseClone将创建一个具有相同数组实例的集合副本。因此,克隆将不是独立的副本:如果我修改其中一个集合,另一个集合也会受到影响,并且会变得损坏,这更糟糕! - Thomas Levesque
请注意上面的编辑。最好将其保留为答案,以防其他人可能会提出相同的“解决方案”。顺便说一句,微软没有将“BaseClone”作为受保护方法,其默认实现将是成员逐个克隆,并定义了一种标准方法来禁用它(例如,使用另一个名为BaseClone的非方法内容进行遮蔽)。 - supercat
@Thomas Levesque:真是一个令人尴尬的错误,特别是因为我只是试图找出可克隆对象的正确模式。当我看到你的第一篇帖子时,我立刻知道我犯了错误。似乎很多人更喜欢复制构造函数的概念,但总的来说,复制构造函数是克隆方法的不良替代品,因为由复制构造函数创建的对象类型将是构造函数的类型,而不是被复制对象的类型。也许我会在博客上发布我的克隆模式并链接到它。 - supercat
@Thomas Levesque:你觉得http://supercatnet.blogspot.com/2010/10/thoughts-about-object-cloning.html上的克隆模式怎么样?封装方法的后代类不应该调用的方法似乎有点棘手,但可行;有更好的方法吗?派生类有没有任何方法可以在不使用反射的情况下搞砸事情?我应该把这个模式发布为“这是一个好模式吗”的问题吗? - supercat

-2

O(n)克隆是理论上可以得到的最好的克隆两个不共享相同底层数据结构的集合的方法。

检查一个元素是否在HashSet中应该是一个常数时间(即O(1))操作。

因此,您可以创建一个包装器,只需包装现有的HashSet并保留任何新添加的内容,但这似乎相当反常。

当您说“高效”时,您的意思是“比现有的O(n)方法更高效”-我认为您实际上无法在没有对“克隆”的含义进行严格语义游戏的情况下获得比O(n)更高效的方法。


2
不,当我说“高效”时,我并不是指更好的复杂度。你说它无论如何都会是O(n)操作是正确的,但这不仅仅是关于复杂度的问题。考虑一下:List<T>.AddHashSet<T>.Add一样具有O(1)的复杂度,但它更,因为它不需要检查项目是否已经存在。所以,当我说“高效”的时候,我的意思是更快,而不是更简单。 - Thomas Levesque
这不是O(n)。它是O(n log n)。每次插入新的东西时,他们都需要查看该物品是否已经存在。 - user4951

-4

只是个随意的想法,可能有点儿傻。

由于它们没有实现 ICloneable 接口,而且构造函数没有使用源类型相同的知识,所以我认为我们只有一种选择。就是实现优化版本,并将其作为扩展方法添加到该类型中。

类似于:

namespace ExtensionMethods
{
    public static class MyExtensions
    {
        public static HashSet<int> Clone(this HashSet<int> original)
        {
            HashSet<int> clone = new HashSet<int>();
            //your optimized code here 
            return clone;
        }
    }   
}

那么,你在问题中的代码将会是这样的:

HashSet<int> original = ...
HashSet<int> clone = HashSet<int>.Clone(original);

7
你会用什么替换这条评论?这就是我的问题所在... - Thomas Levesque

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