基于树状位压缩数组的字符集合

.Net 中内置的集合(HashSet<T>)采用的是哈希表数据结构,无论是插入还是删除的效率都非常高,但是作为通用的数据结构,难以对集合操作进行有效的优化。如果集合中存储的数据特定为字符的话,那么可以利用位压缩数组,即获得比较高的插入和删除效率,又能够有很高的集合操作效率。

一、基于树状位压缩数组存储字符集合

位压缩数组,就是 C# 中的 BitArray,它将 32 个布尔值压缩到一个 int[] 中,int 的一个位表示一个布尔值,以达到节约内存的目的。我这里使用的则是树状的位压缩数组,也就是说,它其实是一棵树,而不是一个简单的数组。

这里分析一下一个字符集合有什么特点:

  1. 与其它集合一样,里面存储的值都是不重复的,而且作为字符集合,存储的数据就是 char 结构。
  2. 字符的范围有限而且比较小,只有 0x0000~0xFFFF 之间的 2^16 = 65536 个,用位压缩数组的话只需要 8KB 就能完全存储下。
  3. 字符的分布稍微有规律一些,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)进行了测试,发现内存占用并不是问题,效率则得到了略微的提高,看来简单一些的数据结构还是能带来不少好处的。

posted @ 2013-04-04 22:19  CYJB  阅读(1620)  评论(0编辑  收藏  举报
Fork me on GitHub