随笔 - 123  文章 - 24 评论 - 2701 阅读 - 101万

  泛型 Dictionary 和 Hashtable 都是散列表的一种实现。只不过 Hashtable 使用的是双重散列法(开放寻址法的一种),而 Dictionary 使用的是除法散列法+链接法处理碰撞。知道了这一点,就可以写一个让 Dictionary 出丑的测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int SIZE = 10103;
IDictionary<int, int> dict = new Dictionary<int, int>(SIZE);
Hashtable h = new Hashtable(SIZE);
 
for (int i = 0; i < 10000; i++)
{
    dict.Add(i*SIZE, i);
    h.Add(i*SIZE, i);
}
 
Stopwatch t = new Stopwatch();
 
t.Reset();
t.Start();
// 为了让测试效果明显,重复10万次查询
for (int i = 0; i < 100000; i++)
{
    dict.ContainsKey(SIZE);
}
t.Stop();
Console.WriteLine("Dictionary:" + t.Elapsed.TotalMilliseconds.ToString() + "毫秒");
 
t.Reset();
t.Start();
// 为了让测试效果明显,重复10万次查询
for (int i = 0; i < 100000; i++)
{
    h.ContainsKey(SIZE);
}
t.Stop();
Console.WriteLine("Hashtable:" + t.Elapsed.TotalMilliseconds.ToString() + "毫秒");

测试结果:

怎么样,效果很惊人吧?当然,计算倍数并没有什么实际意义,只不过听起来很恐怖,比较容易吸引眼球罢了。而且,这个测试对 Dictionary 是不公平的,毕竟是我故意制造了一个让 Dictionary 产生1万次碰撞的输入。一般情况下,Dictionary 和 Hashtable 的效率是不相上下的,在 Key 是原生类型的情况下 Dictionary 还略快一些。不过,这个测试也说明开放寻址法自有其优点,毕竟我们难以制造一个能让 Hashtable 出这么大的丑的测试。
  上面那个测试让人心中不爽,但是在实际使用时一般不会有太大问题。当容量比较小时,使用除法散列法确实容易产生碰撞,但是就算达到极端的 O(n) 查找又如何——把百八十个项全部遍历一遍也用不了多少时间。当容量较大时,例如容量是 10103 时,要每隔 10103 个数字才发生一次碰撞,如果可以假设实际添加到字典中的项都是比较靠近的,就不会发生大量碰撞。
  在阅读 Dictionary 源代码之前,我们先来做个独立思考,如何把上一篇使用双重散列法实现的 HashSet4 改造成泛型字典?

HashSet4 到泛型字典

  泛型字典在功能上与 Hashtable 的不同之处主要有2点:1)它的 Key 和 Value 都是泛型的;2)遍历 Key 得到的序列与添加时的顺序一致。
  先看第一点:泛型。如果把 HashSet4 的 Key 的类型由 Object 改成泛型参数 TKey,就无法把 null 和 _bucket 赋值给 Key 了,这两个特殊的值分别表示槽是空槽和标记为删除的槽,所以要为 Bucket 增加一个状态属性,并修改 MarkDeleted() 和 IsEmputyOrDeleted() 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class HashSet4<TKey>
{
    [DebuggerDisplay("Key = {Key}  k = {k}  IsCollided = {IsCollided}")]
    private struct Bucket
    {
        public TKey Key;
        private int _k;   // Store hash code; sign bit means there was a collision.
        public BucketState State;
        public int k
        {
            get { return _k & 0x7FFFFFFF; } // 返回去掉最高的碰撞位之后的 _k
            set
            {
                _k &= unchecked((int)0x80000000); // 将 _k 除了最高的碰撞位之外的其它位全部设为0
                _k |= value; // 保持 _k 的最高的碰撞位不变,将 value 的值放到 _k 的后面几位中去
            }
        }
        // 是否发生过碰撞
        public bool IsCollided
        {
            get { return (_k & unchecked((int)0x80000000)) != 0; } // _k 的最高位如果为1表示发生过碰撞
        }
        // 将槽标记为发生过碰撞
        public void MarkCollided()
        {
            _k |= unchecked((int)0x80000000); //  将 _k 的最高位设为1
        }
 
        public void MarkDeleted()
        {
            State = BucketState.Deleted;
            k = 0;
        }
 
        public bool IsEmputyOrDeleted()
        {
            return State == BucketState.Empty || State == BucketState.Deleted;
        }
    }
    private enum BucketState
    {
        Empty = 0,
        Full = 1,
        Deleted = 2
    }
    // ...
}

Rehash()、Add()、Contains()、Remove() 也要做相应的修改:

  接下来考虑如何保留添加时的顺序。可以为 Bucket 添加两个整型变量 Prev 和 Next 来模拟双向链表,添加时的顺序将保存在链表里。Prev 保存前一被添加的项的下标,Next 保存下一个被添加的项的下标,-1 表示没有前驱或后继。还要在 HashSet4 里增加整型变量 _first 和 _last 保持第一个和最后一个被添加的项,以及向链表添加和删除项的函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class HashSet4<TKey> : IEnumerable<TKey>
{
    private int _first = -1; // 第一个节点下标
    private int _last = -1; // 最后一个节点下标
    // 将_buckets[index]追加到链表末尾
    private void AppendToLink(int index)
    {
        if (_first == -1)
            _first = index;
        if (_last == -1)
        {
            _buckets[index].Prev = -1;
            _buckets[index].Next = -1;
        }
        else
        {
            _buckets[_last].Next = index;
            _buckets[index].Prev = _last;
            _buckets[index].Next = -1;
        }
        _last = index;
    }
    // 将_buckets[index] 从链表中移除
    private void RemoveFromLink(int index)
    {
        if (_first == index)
            _first = _buckets[index].Next;
        if (_last == index)
            _last = _buckets[index].Prev;
        if (_buckets[index].Prev != -1)
            _buckets[_buckets[index].Prev].Next = _buckets[index].Next;
        if (_buckets[index].Next != -1)
            _buckets[_buckets[index].Next].Prev = _buckets[index].Prev;
    }
    // ...
}

再在 Add() 时调用 AppendToLink(j),在 Remove() 时调用 RemoveFromLink(j) 就可以完成链表的维护了。然后再让 HashSet4 实现 IEnumerable<TKey> 就大功告成了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class HashSet4<TKey> : IEnumerable<TKey>
{
    public IEnumerator<TKey> GetEnumerator()
    {
        return new Enumerator(this);
    }
 
    IEnumerator IEnumerable.GetEnumerator()
    {
        return new Enumerator(this);
    }
 
    public struct Enumerator : IEnumerator<TKey>
    {
        private HashSet4<TKey> _set;
        private TKey _current;
        private int _index;
 
        public Enumerator(HashSet4<TKey> set)
        {
            _set = set;
            _current = default(TKey);
            _index = set._first;
        }
        public TKey Current
        {
            get { return _current; }
        }
        public void Dispose()
        {
        }
        object IEnumerator.Current
        {
            get { return _current; }
        }
        public bool MoveNext()
        {
            if (_index != -1)
            {
                _current = _set._buckets[_index].Key;
                _index = _set._buckets[_index].Next;
                return true;
            }
                 
            _current = default(TKey);
            return false;
        }
        public void Reset()
        {
            _index = _set._first;
            _current = default(TKey);
        }
    }
    // ...
}

附上 HashSet4 完整源码:

  如此看来,让使用了双重散列法的 HashSet4 变成泛型以及保留添加顺序并不十分困难(代价是为每一个槽增加了2个整型变量和一个枚举变量,是不是还有更好的方法呢?),不知道为什么 Dictionary 没有沿用 Hashtable 的算法。难道是看中了除法散列法+链接法常数因子比较小的优点?
  鉴于本篇已经够长的了,我们下一篇再详细解析 Dictionary 的源码。提前作个预告:虽然 Dictionary 是使用的除法散列法+链接法,但是并没有使用真正的链表,而是用一个数组模拟链表,即节省了空间,又能够保留添加时的顺序,可谓一举两得。
  

posted on   1-2-3  阅读(4714)  评论(14编辑  收藏  举报
编辑推荐:
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 单线程的Redis速度为什么快?
· 展开说说关于C#中ORM框架的用法!
· SQL Server 2025 AI相关能力初探
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
点击右上角即可分享
微信分享提示