【澈丹,我想要个钻戒。】【小北,等等吧,等我再修行两年,你把我烧了,舍利子比钻戒值钱。】
——自扯自蛋
无论开发一个程序还是谈一场恋爱,都差不多要经历这么4个阶段:
1)从零开始。没有束缚的轻松感。似乎拥有无限的可能性,也有相当多的不确定,兴奋、紧张和恐惧。
2)从无到有。无从下手的感觉。一步一坎,进展缓慢。走弯路,犯错,投入很多产出很少。目标和现实之间产生强大的张力。疑惑、挫败、焦急和不甘心。
3)渐入佳境。快速成长。创新,充实,满足。但是在解决问题的同时往往会不可避免地引入更多的问题和遗憾。
4)接近成功。已经没有那么多的新鲜感和成就感,几乎是惯性般地努力前行。感觉成功在望,但又好像永远也不能100%搞定。有时,一心想要完成的欲望甚至超越了原本的目标。
经过前面2篇,我们也来到了第4阶段。让我们深吸一口气,把遗留下来的这几个问题全部搞定吧。
1)能不能支持所有的对象而不仅限于整数?
2)如何支持所有整数而不只是正整数?
3)被删除了的槽仍然占用查找时间。
4)随着时间的推移,被标记为碰撞的槽越来越多,怎么办?
5)必须在创建容器的时候指定大小,不能自动扩张。
6)只是一个 HashSet,而不是HashTable。
继续改造上一篇最后给出的 IntSet4。
支持所有对象而不仅限于整数
要想支持所有对象而不只是整数,就需要一个能把各种类型的对象变换成整数的方法。这一点得到了 .net 特别的优待,Object 类的 GetHashCode() 就是专门干这个的。它提供的默认实现是:对于引用类型,每创建一个新对象都会把Object里的一个内部的计数器增加1,并把计数器的值作为这个对象的 HashCode;对于 struct 对象,将基于每个字段的 HashCode 计算得出一个整型值作为对象的 HashCode。Object 的子类型可以 override GetHashCode() 函数,对于整数类型的变量,GetHashCode() 的返回值与变量的值相同;小数、字符串等都有自己的变换规则(至于具体的规则限于篇幅将不再详细介绍)。总之,我们只要调用对象的 GetHashCode() 函数,把得到的整型值作为 k 的值就行了。另外还需要一个 Object 类型的变量 Key 保存添加的对象,我们把这两个变量封装到一个名为 Bucket 的 struct 里,Add()、Remove()、Contains() 函数也要做相应的修改:
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
|
public class HashSet1 { [DebuggerDisplay( "Key = {Key} k = {k}" )] private struct Bucket { public Object Key; public int k; // Store hash code; } private Bucket[] _buckets; private readonly int DELETED = -1; private int GetHashCode(Object key) { return key.GetHashCode(); } public HashSet1( int capacity) { int size = GetPrime(capacity); _buckets = new Bucket[size]; } public void Add(Object item) { int i = 0; // 已经探查过的槽的数量 Bucket bucket = new Bucket { Key = item, k = GetHashCode(item) }; do { int j = DH(bucket.k, i); // 想要探查的地址 if (_buckets[j].Key == null || _buckets[j].k == DELETED) { _buckets[j] = bucket; return ; } else { i += 1; } } while (i <= _buckets.Length); throw new Exception( "集合溢出" ); } public bool Contains(Object item) { int i = 0; // 已经探查过的槽的数量 int j = 0; // 想要探查的地址 int hashCode = GetHashCode(item); do { j = DH(hashCode, i); if (_buckets[j].Key == null ) return false ; if (_buckets[j].k == hashCode && _buckets[j].Key.Equals(item)) return true ; else i += 1; } while (i <= _buckets.Length); return false ; } public void Remove(Object item) { int i = 0; // 已经探查过的槽的数量 int j = 0; // 想要探查的地址 int hashCode = GetHashCode(item); do { j = DH(hashCode, i); if (_buckets[j].Key == null ) return ; if (_buckets[j].k == hashCode && _buckets[j].Key.Equals(item)) { _buckets[j].k = DELETED; return ; } else { i += 1; } } while (i <= _buckets.Length); } // 其它部分与 IntSet4 相同 } |
让 HashSet 支持所有整数
GetHashCode() 的返回值是 int 类型,也就是说可能为负数。这时,因为数组的大小 m 是正数,所以由 k mod m 计算得出的数组下标也为负数,这可不行。解决方法是先把GetHashCode() 的返回值变换成正数再赋值给 k,最直接的做法是:
int t = key.GetHashCode();
uint k = (uint)t;
由于负数在计算机里以补码的形式保存,所以当 t 是负数时,相当于 k = uint.MaxValue - |t| + 1。这个方案的好处是能够利用 0 ~ uint.MaxValue 范围内的所有整数,更不容易发生碰撞。
不过 .net framework 的 HashTable 却是:
int t = key.GetHashCode();
int k = t & 0x7FFFFFFF; // 把 t 的最高位设为0
由于负数在计算机里以补码的形式保存,所以当 t 是负数时,相当于 k = int.MaxValue - |t| + 1。这个方案的缺点是如果添加 -9 和 2147483639 这两个数字就会发生碰撞,但是因为实际的输入总是比较扎堆的,所以这个缺点也不太容易造成太大的性能问题。它的优点是省下了最高的一个比特位。因为我们在解决问题(3)的时候需要增加一个布尔类型的变量记录是否发生了碰撞,到时候如果利用这个节省下来的比特位,就不用增加一个布尔类型的变量了。按照这个方法修改一下代码:
1
2
3
4
5
6
7
8
|
public class HashSet2 { private int GetHashCode(Object key) { return key.GetHashCode() & 0x7FFFFFFF; } // ... } |
另外,由于最高的那个比特位有别的用处了,所以不能再把删除的槽的 k 设置成 -1 了,那要设置成什么值好呢? 设置成什么值都不好,因为输入的 k 可能是任何正整数,所以总会有冲突的可能。所以我们改成把 Key 赋值为 _buckets 作为被删除的标志,完整的代码如下:
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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
|
public class HashSet2 { [DebuggerDisplay( "Key = {Key} k = {k}" )] private struct Bucket { public Object Key; public int k; // Store hash code; sign bit means there was a collision. } private Bucket[] _buckets; private int GetHashCode(Object key) { return key.GetHashCode() & 0x7FFFFFFF; } // 将 bucket 标记为已删除 private void MarkDeleted( ref Bucket bucket) { bucket.Key = _buckets; bucket.k = 0; } // 判断 bucket 是否是空槽或者是已被删除的槽 private bool IsEmputyOrDeleted( ref Bucket bucket) { return bucket.Key == null || bucket.Key.Equals(_buckets); } public HashSet2( int capacity) { int size = GetPrime(capacity); _buckets = new Bucket[size]; } public void Add(Object item) { int i = 0; // 已经探查过的槽的数量 Bucket bucket = new Bucket { Key = item, k = GetHashCode(item) }; do { int j = DH(bucket.k, i); // 想要探查的地址 if (IsEmputyOrDeleted( ref _buckets[j])) { _buckets[j] = bucket; return ; } else { i += 1; } } while (i <= _buckets.Length); throw new Exception( "集合溢出" ); } public bool Contains(Object item) { int i = 0; // 已经探查过的槽的数量 int j = 0; // 想要探查的地址 int hashCode = GetHashCode(item); do { j = DH(hashCode, i); if (_buckets[j].Key == null ) return false ; if (_buckets[j].k == hashCode && _buckets[j].Key.Equals(item)) return true ; else i += 1; } while (i <= _buckets.Length); return false ; } public void Remove(Object item) { int i = 0; // 已经探查过的槽的数量 int j = 0; // 想要探查的地址 int hashCode = GetHashCode(item); do { j = DH(hashCode, i); if (_buckets[j].Key == null ) return ; if (_buckets[j].k == hashCode && _buckets[j].Key.Equals(item)) { MarkDeleted( ref _buckets[j]); return ; } else { i += 1; } } while (i <= _buckets.Length); } } |
减少已删除的槽对查找时间的影响
我们在上一篇举过这样一个例子:假设有一个容量 m 为10 的 HashSet,先依次添加 0、1、2、3、4、5、6、7、8、9,然后再删除 0、1、2、3、4、5、6、7、8,这时调用 Contains(0),此函数会依次检查 _values[0]、_values[1]..._values[9],也就是把整个数组遍历了一遍!为什么会这样呢?因为我们在删除一个数字的时候,由于不知道这个数字的后面是否还有一个因与它碰撞而被安排到下一个空槽的数字,所以我们不敢直接把它设为 null。为了解决这个问题,需要一种方法可以指出每个槽是否发生过碰撞。最直接的方法是增加一个 bool 类型的变量,不过为了节省空间,我们将利用在解决问题(2)的时候预留出来的 k 的最高位。如果新添加的项与某个槽发生了碰撞,就把那个槽的碰撞位设为1。有了碰撞位,就可以知道:
1)如果碰撞位为0,说明要么没发生过碰撞,要么它是碰撞链的最后一个槽。
2)如果碰撞位为1,说明它不是碰撞链的最后一个槽。
对于碰撞位为0的槽,删除时可以直接把 Key 设为 null;对于碰撞位为1的槽,因为它不是碰撞链的最后一个槽,所以在删除时还是不能把它的 Key 设为null,而是设为 _buckets 表示已删除,并且要保留 k 的最高位为 1,把 k 的其它位设为0。由于我们其实是把一个 int 类型的变量 _k 当成了一个 bool 类型的变量和一个正整数类型的变量来用,所以要先改造一下 Bucket。(ps:用了很多位操作,要是 C# 也有类似于 C++ 的共用体那种东东的话就不用这么麻烦了)
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
|
public class HashSet3 { [DebuggerDisplay( "Key = {Key} k = {k} IsCollided = {IsCollided}" )] private struct Bucket { public Object Key; private int _k; // Store hash code; sign bit means there was a collision. 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 } } // ... } |
不需要修改 Contains() 函数,把 Add() 和 Remove() 函数按上面的讨论进行修改:
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 HashSet3 { public void Add(Object item) { int i = 0; // 已经探查过的槽的数量 int k = GetHashCode(item); do { int j = DH(k, i); // 想要探查的地址 if (IsEmputyOrDeleted( ref _buckets[j])) { _buckets[j].Key = item; _buckets[j].k = k; // 仍然保留 _k 的最高位不变 return ; } else { _buckets[j].MarkCollided(); i += 1; } } while (i <= _buckets.Length); throw new Exception( "集合溢出" ); } public void Remove(Object item) { int i = 0; // 已经探查过的槽的数量 int j = 0; // 想要探查的地址 int hashCode = GetHashCode(item); do { j = DH(hashCode, i); if (_buckets[j].Key == null ) return ; if (_buckets[j].k == hashCode && _buckets[j].Key.Equals(item)) // 找到了想要删除的项 { if (_buckets[j].IsCollided) { // 如果不是碰撞链的最后一个槽,要把槽标记为已删除 MarkDeleted( ref _buckets[j]); } else { // 如果是碰撞链的最后一个槽,直接将 Key 设为 null _buckets[j].Key = null ; _buckets[j].k = 0; } return ; } else { i += 1; } } while (i <= _buckets.Length); } // ... } |
如您所见,一旦被扣上了“发生过碰撞的槽”的帽子,可就一辈子摘不掉了,即使碰撞链的最后一个槽已经被删除了,删除碰撞链的倒数第二个槽时也不会把碰撞位设为0,随着时间的推移,被标记为碰撞过的槽只会越来越多。如果频繁地删完了添、添完了删,发展到极致就可能产生每个槽都被标记为碰撞的情况,这时候 Contains() 函数的复杂度又变成 O(n) 了。解决这个问题的方法是发现被标记为碰撞的槽的超过一定的比例之后,重新排列整个数组,详细的方法会在后面实现容器的自动扩张时一并给出。
我们可以很自然地想到,如果不是使用一个比特来位标记是否发生过碰撞,而是为每个槽增加一个整型变量精确记录发生过多少次碰撞,并且在删除碰撞链的最后一个槽时,把整条碰撞链的每一个槽的碰撞次数减少1,这样就可以完美解决上面那个问题了。这跟垃圾回收机制很像。之所以没有采用这种方法,一是因为这样做会使耗费的存储空间增加1倍,二是因为使用了双重散列法之后,不是很容易产生一长串碰撞链,如果不是特别频繁地删除、添加的话,问题不会很严重。
HashSet 的自动扩张
在数组满了之后,再要添加新项,就得先把数组扩张得大一些。显然,让新数组只比老数组多一个槽是不恰当的,因为那样岂不是以后每添加一个新项都得去扩张数组?那么应该创建多大的新数组才合适呢?目前通常的做法是让新数组比老数组大1倍。
另外,我们在前一篇已经提到过,开放寻址法在座位全部坐满的情况下性能并不好。上座率越低性能越好,但是也越浪费空间。这个上座率——也就是装载因子(_loadFactor)设为多少能达到性能与空间的平衡呢?.net framework 使用的是 0.72 这个经验值。数组允许添加的项的数量上限 _loadSize = _buckets.Length * _loadFactor。我们会添加一个整型的变量 _count 记录添加到数组中的项的个数(Add() 时 _count 增加1;Remove() 时 _count 减少1),当检测到 _count >= _loadSize 时就要扩张数组,而不会等到数组满了之后。
还需要添加一个整型变量 _occupancy 用于记录被标记为碰撞的槽的数量。当 _occupancy > _loadSize 时,有可能造成 O(n) 复杂度的查找,所以这时也应该重新排列数组。
首先,添加上面提到那几个变量,并在构造函数里初始化它们:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
|
public class HashSet4 { private int _count = 0; // 添加到数组中的项的总数 private int _occupancy = 0; // 被标记为碰撞的槽的总数 private float _loadFactor = 0.72f; // 装载因子 private int _loadsize = 0; // 数组允许放置的项的数量上限,_loadsize = _bucket.Length * _loadFactor,在确定数组的大小时被初始化。 public HashSet4( int capacity) { int size = GetPrime(( int )(capacity / _loadFactor)); if (size < 11) size = 11; // 避免过小的size _buckets = new Bucket[size]; _loadsize = ( int )(size * _loadFactor); } // ... } |
然后实现用于扩张容器的 Expand() 函数和重新排列数组的 Rehash() 函数。因为 Rehash() 函数需要使用新数组的长度计算下标,所以需要改造一下 H1()、H2() 和 DH(),让它们可以接收数组的大小 m 做为参数。Rehash() 与 Add() 函数很像:
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
56
57
58
59
|
public class HashSet4 { private int H1( int k, int m) { return k % m; } private int H2( int k, int m) { return 1 + (k % (m - 1)); } private int DH( int k, int i, int m) { return (H1(k, m) + i * H2(k, m)) % m; } // 扩张容器 private void Expand() { int newSize = GetPrime(_buckets.Length * 2); // buckets.Length*2 will not overflow Rehash(newSize); } // 将老数组中的项在大小为 newSize 的新数组中重新排列 private void Rehash( int newSize) { _occupancy = 0; // 将标记为碰撞的槽的数量重新设为0 Bucket[] newBuckets = new Bucket[newSize]; // 新数组 // 将老数组中的项添加到新数组中 for ( int oldIndex = 0; oldIndex < _buckets.Length; oldIndex++) { if (IsEmputyOrDeleted( ref _buckets[oldIndex])) continue ; // 跳过已经删除的槽好空槽 // 向新数组添加项 int i = 0; // 已经探查过的槽的数量 do { int j = DH(_buckets[oldIndex].k, i, newBuckets.Length); // 想要探查的地址 if (IsEmputyOrDeleted( ref newBuckets[j])) { newBuckets[j].Key = _buckets[oldIndex].Key; newBuckets[j].k = _buckets[oldIndex].k; break ; } else { if (newBuckets[j].IsCollided == false ) { newBuckets[j].MarkCollided(); _occupancy++; } i += 1; } } while ( true ); } // 用新数组取代老数组 _buckets = newBuckets; _loadsize = ( int )(newSize * _loadFactor); } // ... } |
Add() 和 Remove() 需要稍稍修改一下,加上统计 _count 和 _occupancy 的代码,并在需要的时候扩张或重排数组:
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
56
57
58
59
60
61
62
63
64
65
66
|
public class HashSet4 { public void Add(Object item) { if (_count >= _loadsize) Expand(); // 如果添加到数组中的项数已经到达了上限,要先扩张容器 else if (_occupancy > _loadsize && _count > 100) Rehash(_buckets.Length); // 如果被标记为碰撞的槽的数量和 _loadsize 一般多,就要重新排列所有的项 int i = 0; // 已经探查过的槽的数量 int k = GetHashCode(item); do { int j = DH(k, i, _buckets.Length); // 想要探查的地址 if (IsEmputyOrDeleted( ref _buckets[j])) { _buckets[j].Key = item; _buckets[j].k = k; // 仍然保留 _k 的最高位不变 _count++; return ; } else { if (_buckets[j].IsCollided == false ) { _buckets[j].MarkCollided(); _occupancy++; } i += 1; } } while (i <= _buckets.Length); throw new Exception( "集合溢出" ); } public void Remove(Object item) { int i = 0; // 已经探查过的槽的数量 int j = 0; // 想要探查的地址 int hashCode = GetHashCode(item); do { j = DH(hashCode, i, _buckets.Length); if (_buckets[j].Key == null ) return ; if (_buckets[j].k == hashCode && _buckets[j].Key.Equals(item)) // 找到了想要删除的项 { if (_buckets[j].IsCollided) { // 如果不是碰撞链的最后一个槽,要把槽标记为已删除 MarkDeleted( ref _buckets[j]); } else { // 如果是碰撞链的最后一个槽,直接将 Key 设为 null _buckets[j].Key = null ; _buckets[j].k = 0; } _count--; return ; } else { i += 1; } } while (i <= _buckets.Length); } // ... } |
终于完成了一个比较实用的 HashSet 了!附上完整代码:
HashSet 到 HashTable
前面为了简单起见,一直都是只有 Key 而没有 Value。现在我们若想添加一个 Value 简直就跟玩儿似的:
1
2
3
4
5
6
7
8
9
10
11
|
public class HashTable { [DebuggerDisplay( "Key = {Key} k = {k} IsCollided = {IsCollided}" )] private struct Bucket { public Object Key; public Object Value; // ... } // ... } |
当然,Add() 函数也要加上一个 Value 参数,还有按 Key 查找 Value 等等功能限于篇幅就不再啰嗦了。
HashTable 和泛型 Dictionary
在 .net framework 的 HashTable 的源代码的注释里写着“泛型 Dictionary 是 Copy 自 HashTable(The generic Dictionary was copied from Hashtable's source - any bug fixes here probably need to be made to the generic Dictionary as well.)”,但是瞎了我的极品高清三角眼,怎么看了半天也没找着双重散列的身影呢?除了是泛型的以外,Dictionary 的实现与 HashTable 还有多少不同之处呢?也许下次我们可以一起研究下 Dictionary 的源代码。