基于树状位压缩数组的字符集合
.Net 中内置的集合(HashSet<T>)采用的是哈希表数据结构,无论是插入还是删除的效率都非常高,但是作为通用的数据结构,难以对集合操作进行有效的优化。如果集合中存储的数据特定为字符的话,那么可以利用位压缩数组,即获得比较高的插入和删除效率,又能够有很高的集合操作效率。
一、基于树状位压缩数组存储字符集合
位压缩数组,就是 C# 中的 BitArray,它将 32 个布尔值压缩到一个 int[] 中,int 的一个位表示一个布尔值,以达到节约内存的目的。我这里使用的则是树状的位压缩数组,也就是说,它其实是一棵树,而不是一个简单的数组。
这里分析一下一个字符集合有什么特点:
- 与其它集合一样,里面存储的值都是不重复的,而且作为字符集合,存储的数据就是 char 结构。
- 字符的范围有限而且比较小,只有 0x0000~0xFFFF 之间的 2^16 = 65536 个,用位压缩数组的话只需要 8KB 就能完全存储下。
- 字符的分布稍微有规律一些,0~127 是 ASCII 码范围,使用频率最高。之后的字符,使用的较少而且很稀疏。
由以上三点,如果直接使用 8KB 大小的位压缩数组,会浪费很多不必要的空间。因此最好的办法是将数组分层(组织成树状),并且将 ASCII 码范围的字符进行特殊处理以加快速度,之后的字符根据需要动态的添加,这样就可以有效减少空间的浪费。当然分层不能过多,这样处理起来会太过复杂,效率也比较低;分层也不能过少,这样会有比较大的空间浪费。
最后,我将位压缩数组分为三层,使用 UInt32 作为底层存储单元。总共有 16 个顶层单元,每个顶层单元对应 16 个中间层单元(总计 256 个中层单元),每个中间层单元对应 8 个底层单元,总共 2048 个底层单元,其中的每一位都与一个字符对应。前 8 个底层单元对应的是 256 个字符,正好是扩展 ASCII 码(EASCII),可以单独考虑以加快速度。存储方式类似下图:
图 1 CharSet 的存储方式
初始情况下,只有表示 EASCII 的前 8 个存储单元,之后的存储单元都为 null,这样能够最大程度的避免空间浪费,在之后需要的时候在动态进行添加。根据分层情况,可以得到下面的字符掩码:
图 2 char 的掩码
在存储字符的时候,只要通过位操作,根据掩码存储到相应的位置就可以了,效率也并不低。而且由于是以位压缩数组的原理储存的集合,所以在进行一些集合操作时,一次可以操作 32 位,能够有效的提高集合操作的效率。如果集合中的元素数量非常多,使用位压缩数组的方法反而能够极大的减少内存的消耗,HashSet<char> 需要位每个元素需要两个 int(hashCode 和 next)和一个 char(数据),总计 7B 的内存,当集合接近满的时候,需要占用数百 KB 的内存,而位压缩数组储存满总共才需要占用不到 10KB 的空间。
如果字符集合是不区分大小写的话,情况则要复杂不少。虽然在添加、删除和查找时可以全部转为大写或小写判断,但是遍历(GetEnumerator)和集合操作就会复杂一些。在遍历集合时,是需要正确的输出原字符(而不是只输出小写或大写字符)的,因此必须存储原字符是大写还是小写的信息。
一种存储策略是:如果是大写字符,直接保存在集合中;如果是小写字符,在集合中即保存大写字符,又保存小写字符(由于不区分大小写,所以集合中显然不会同时保存大写和小写字符)。这样,只要判断相应的小写字符在集合中是否存在,就可以正确的区分原先保存的字符是大写还是小写了。这个策略虽然能够正确处理集合的遍历,但在进行集合操作时,小写字符会被操作两次(因为集合又保存了相应的大写字符),导致集合长度出现错误。
因此,只能采用另一个策略——如果实际存入的是小写字符,那么只在集合中保存相应的大写字符,同时用另外一个集合标记当前位置存入的是小写字符。在实际中我将原本一个中层单元对应 8 个底层单元扩展到了对应 16 个底层单元,其中前 8 个单元用来存储字符,后 8 个单元存储相应位置的字符是否原本是小写字符。这么做会使用两倍的存储空间,但好处就是集合操作的效率只会有很少的损失,而且位压缩数组本来就很节约空间,这样做问题也不大。
二、基本操作
对于集合的增加、删除和查找操作,其核心就是找到字符所存储的 UInt32 以及相应的位掩码,要访问字符 c 的话,可以采用计算公式 data[c >> 12][(c >> 8) & 0xF][(c >> 5) & 7] & (1 << (c & 0x1F)),具体的代码如下所示:
private uint[] FindMask(int c, out int idx, out uint binIdx) { uint[] arr = null; if (c <= 0xFF) { arr = eascii; } else { idx = c >> 12; binIdx = 0; uint[][] arrMid = data[idx]; if (arrMid == null) { return null; } idx = (c >> 8) & 0xF; arr = arrMid[idx]; if (arr == null) { return null; } } idx = (c >> 5) & 7; binIdx = 1u << (c & 0x1F); return arr; } private uint[] FindAndCreateMask(int c, out int idx, out uint binIdx) { uint[] arr = null; if (c <= 0xFF) { arr = eascii; } else { idx = c >> 12; uint[][] arrMid = data[idx]; if (arrMid == null) { arrMid = new uint[16][]; data[idx] = arrMid; } idx = (c >> 8) & 0xF; arr = arrMid[idx]; if (arr == null) { arr = new uint[8]; arrMid[idx] = arr; } } idx = (c >> 5) & 7; binIdx = 1u << (c & IndexMask); return arr; }
这两段代码大体相同,只不过一个只负责查找,另一个在查找失败的情况下会创建相应的存储单元。需要注意的是对于不区分大小写的字符集合,一个中层单元对应 16 个底层单元。
也是由于使用位压缩数组的缘故,使用 IEnumerator<char> 枚举字符会很繁琐,需要根据索引拼装出原始的字符出来。如果是不区分大小写的字符集合,还要根据相应存储单元的后 8 个 uint 中得到大小写信息才可以。
public override IEnumerator<char> GetEnumerator() { for (int i = 0; i < 16; i++) { uint[][] arr1 = data[i]; if (arr1 == null) { continue; } int c1 = i << 12; for (int j = 0; j < 16; j++) { uint[] arr2 = arr1[j]; if (arr2 == null) { continue; } int c2 = c1 | (j << 8); for (int k = 0; k < 8; k++) { int c3 = c2 | (k << 5); uint value = arr2[k]; uint valueIg = ignoreCase ? arr2[k + 8] : 0; for (int n = -1; value > 0; ) { int oneIdx = (value & 1) == 1 ? 1 : value.BinTrailingZeroCount() + 1; if (oneIdx == 32) { // C# 中 uint 右移 32 位会不变。 value = 0; } else { value = value >> oneIdx; } n += oneIdx; char c4 = (char)(c3 | n); if ((valueIg & (1 << n)) > 0) { c4 = char.ToLower(c4, culture); } yield return c4; } } } } }
代码中有一个扩展方法是 BinTrailingZeroCount,它的作用是计算二进制表示的末尾有多少个 0,这样可以通过一次移位就得到有效位,而不用多次循环右移,该方法的代码如下:
private static readonly int[] MultiplyDeBruijnBitPosition32 = new int[] { 0, 1, 28, 2, 29, 14, 24, 3, 30, 22, 20, 15, 25, 17, 4, 8, 31, 27, 13, 23, 21, 19, 16, 7, 26, 12, 18, 6, 11, 5, 10, 9 }; public static int BinTrailingZeroCount(this uint value) { return MultiplyDeBruijnBitPosition32[(uint)((value & -value) * 0x077CB531U) >> 27]; }
这个方法只需要两次位运算、一次乘法和一次数组访问直接就求出了末尾 0 的个数,效率很高,不过原理比较复杂,可以参考这里。
三、集合操作
CharSet 最大的优势就在于集合操作特别的快,树状的存储方式可以避免很多不必要的比较,而且可以一次性操作 32 个字符,对于稀疏数据的操作效率也不低。所有操作都是首先对树进行遍历,找到当前集合与 other 集合对应的结点,然后使用位操作对结点值进行运算。下面主要是介绍如何进行位操作,遍历过程不再细说。
3.1 ExceptWith 操作
ExceptWith 操作是从当前集合中排除另一个集合中的所有字符,可以采用 thisValue &= ~otherValue 将 otherValue 为 1 的位设为 0。这时还需要计算被排除的字符数量,就是要找 thisValue 中为 1,同时 otherValue 中也为 1 的位,即 thisValue & otherValue,再计算二进制 1 的个数即可。下面给出 ExceptWith 方法的代码,这段代码仅限于相同集合的操作,与其它集合的操作是不能用这个方法的。
private void ExceptWith(CharSet other) { for (int i = 0; i < 16; i++) { uint[][] arrMid = data[i]; if (arrMid == null) { continue; } uint[][] otherArrMid = other.data[i]; if (otherArrMid == null) { continue; } for (int j = 0; j < 16; j++) { uint[] arrBottom = arrMid[j]; if (arrBottom == null) { continue; } uint[] otherArrBottom = otherArrMid[j]; if (otherArrBottom == null) { continue; } for (int k = 0; k < 8; k++) { uint removed = arrBottom[k] & otherArrBottom[k]; if (removed > 0) { this.count -= removed.BinOneCnt(); arrBottom[k] &= ~removed; if (ignoreCase) { arrBottom[k + 8] &= ~removed; } } } } } }
3.2 IntersectWith 操作
IntersectWith 操作是使当前集合中仅包含指定集合中也存在的元素,正好与上一操作相反,所以可以采用 thisValue &= otherValue 将 otherValue 为 0 的位也设为 0。被排除的字符数量则为 thisValue 中为 1,同时 otherValue 中为 0 的位,即 thisValue & ~otherValue。
3.3 SymmetricExceptWith 操作
SymmetricExceptWith 操作类似于异或操作,会使使当前集合仅包含当前集和或指定集合中存在的元素(但不可包含两者共有的元素),二进制操作很自然的为 thisValue ^= oherInt。但是这里计算修改的字符个数比较困难,因为需要加上 thisValue 从 0 变 1 的位,再减去 thisValue 从 1 变 0 的位,无论如何都需要计算两次 1 的个数,所以直接计算一下异或前和异或后的 1 的个数,再进行相减。
3.4 UnionWith 操作
UnionWith 操作是使当前集合包含当前集合和指定集合中同时存在的所有元素,所以使用 thisValue |= otherValue 操作。添加的字符数量就是 thisValue 为 0 且 otherValue 为 1 的位,使用 ~thisValue & otherValue 计算。
3.5 计算二进制中 1 的个数
计算二进制中 1 的个数其实不需要用循环 32 次去判断,可以使用二分的办法,通过 15 次位运算和 5 次加法得到结果:
public static int BinOneCnt(this uint value) { value = (value & 0x55555555) + ((value >> 1) & 0x55555555); value = (value & 0x33333333) + ((value >> 2) & 0x33333333); value = (value & 0x0F0F0F0F) + ((value >> 4) & 0x0F0F0F0F); value = (value & 0x00FF00FF) + ((value >> 8) & 0x00FF00FF); value = (value & 0x0000FFFF) + ((value >> 16) & 0x0000FFFF); return (int)value; }
CharSet 的大题思路和基本操作就是这样,完整的代码可以参考 CharSet 类,它的插入、删除效率大概之后 HashSet<char> 的 70% 左右(主要因素应该是将数组分为了三层),但集合操作效率一般会高于 HashSet<char>,在集合比较大的时候(几千),能有数倍的效率提升。
后来我又对两层数组(顶层长度为 64,底层长度为 32)进行了测试,发现内存占用并不是问题,效率则得到了略微的提高,看来简单一些的数据结构还是能带来不少好处的。
作者:CYJB
出处:http://www.cnblogs.com/cyjb/
GitHub:https://github.com/CYJB/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。