C# 位压缩列表

.Net 中自带了一个位压缩数组 BitArray,它的功能也跟数组一样,只能对每一位进行操作,而不能添加或删除位。这里介绍的 BitList 类,就是自己写的能够添加、删除位的位压缩列表。

一、基本操作

位压缩列表,就是用 uint[] 来存储 bool 数据,一个 bool 数据存储到 uint 的一个位中,因此占用的空间只有 bool[] 的八分之一(一个 bool 占 1 字节,可以存 8 个 bit)。在进行批量操作的时候也能够以 uint 为单位进行操作,效率会得到很大提高。

图 1 位压缩列表的存储方式

注意,这里我将 uint[] 的头放在了右边,而不是通常的左边,索引也是最右边是 0,从右向左递增,这样做是为了下面介绍起操作来更容易些。

位压缩列表中元素的访问需要用到二进制操作。假设现在要访问第 i 个 bool,那么相应的 uint 索引为 i / 32(即 i >> 5),对应于 uint 内的第 i % 32 个位(即 i & 0x1F)。访问第 i 个 bool 使用的代码即为

items[i >> 5] & (i & 0x1F)

如果得到的结果是大于 0 的,那么表示第 i 个 bool 为 true;如果结果等于 0,那么表示为 false。

如果要设置第 i 个位,那么需要用到简单的与操作(&)和或操作(|):

items[i >> 5] |= (1 << (i & 0x1F)) // 将第 i 为设为 true
items[i >> 5] &= ~(1 << (i & 0x1F)) // 将第 i 为设为 false

对 BitList 进行的二进制操作(与、或、非、异或)也很简单,只要一次对 uint[] 的每个元素执行相应的二进制操作即可,这里就不再多说。

二、其它操作

这里列出了删除、插入、填充和位移操作,因为这些操作实现起来比较困难,需要详细说明。

2.1 删除指定位

首先拿比较简单的情况——删除特定位来说明。假设下面的图 2 中每一个方框代表一个 uint,图 2(a) 里面的第 i 个位是要删除的位,它右边蓝色的部分和左边绿色的部分是其它数据。删除后的结果就如图 2(c) 所示,右边的位(蓝色)不变,左边的位(绿色)均被右移了一位。但是左边的位(绿色)可能是多个 uint,因此这里需要处理好边界处的位(青色),这些位原本是左边 uint 的最低位,现在则变成了右边 uint 的最高位。

图 2 删除一个位

删除某个位的操作被分成两步执行,第一步是将第 i 位删除,如图 2(b) 所示。具体作法是将第 i 位所在的 uint 的高位(highBits)和低位(lowBits)都提取出来,然后 highBits >> 1 | lowBits 就可以将第 i 位删除。提取高位和低位的代码如下所示:

uint spliter = 1u << (i & 0x1F);
uint highBits = value & ~((spliter << 1) - 1u);
uint lowBits = value & (spliter - 1u);
// spliter =             = 0000000010000000 
// spliter << 1          = 0000000100000000
// (spliter << 1) - 1    = 0000000011111111
// ~((spliter << 1) - 1) = 1111111100000000
// spliter - 1           = 0000000001111111

第二步就是将左边剩余的位(绿色和青色)依次右移。具体作法是将当前 uint 的最低位放到右边 uint 的最高位(通过左移 31 位),在将当前 uint 右移一位,如图 2(c) 所示。代码为:

int idx = (index >> 5) + 1;
int end = this.count >> 5;
for (; idx <= end; idx++) {
	this.items[idx - 1] |= this.items[idx] << 31;
	this.items[idx] >>= 1; 
}

该方法的完整代码:

void RemoveItem(int index)
{
	int idx = index >> IndexShift;
	uint value = this.items[idx];
	uint spliter = 1u << (index & IndexMask);
	uint highBits = value & ~((spliter << 1) - 1u);
	uint lowBits = value & (spliter - 1u);
	this.items[idx] = (highBits >> 1) | lowBits;
	int end = this.count >> IndexShift;
	for (idx++; idx <= end; idx++)
	{
		this.items[idx - 1] |= this.items[idx] << 31;
		this.items[idx] >>= 1;
	}
	this.count--;
}

2.2 删除指定的多个位

删除多个位则要比删除一个位更困难些。假设下图 3(a)中从第 i 个位开始的橙色部分是要删除的位,它右边蓝色的部分和左边绿色的部分都是其它数据。删除后的结果就如图 3(c) 所示,右边的位不变,左边的位均被右移了多个位。

图 3 删除多个位

删除多个位的操作同样被分成两步,第一步是将红色部分删除(设其长度为 length),并将与被删除部分在同一个 uint 中的数据右移,如图 3(b) 所示。这一步中,将与被删除部分在同一个 uint 中的绿色数据分别标记为 highBits 和 lowBits,其中 highBits 是正好能与蓝色部分(称为 tailBits)组成一个完整 uint 的部分,lowBits 则是剩余的部分。

这里需要格外注意一些特殊情况,如图 4(a) 中所示的 highBits 为 0,图 4(b) 中所示的 highBits 和 lowBits 都为 0,以及图 4(c) 中所示的 lowBits 为 0 的这些情况,因此需要很多条件判断。

图 4 删除多个位的特殊情况

代码如下所示:

public void RemoveRange(int index, int length) {
	int valueIdx = (index + length) >> IndexShift;
	int idx = index >> IndexShift;
	int tailSize = index & IndexMask;
	int rSize = (index + length) & IndexMask;
	if (rSize > 0)
	{
		uint value = this.items[valueIdx];
		int highSize = tailSize == 0 ? 0 : UInt32Size - tailSize;
		int restSize = UInt32Size - rSize;
		if (highSize > restSize)
		{
			highSize = restSize;
		}
		if (highSize > 0)
		{
			uint tailMask = GetMask(tailSize);
			this.items[idx] = (this.items[idx] & tailMask) |
				((value >> rSize) << tailSize);
			tailSize += highSize;
			if (tailSize == UInt32Size)
			{
				idx++;
				tailSize = 0;
			}
		}
		if (restSize > highSize)
		{
			tailSize = restSize - highSize;
			items[idx] = value >> (UInt32Size - tailSize);
		}
		valueIdx++;
	}
	// 计算要复制的长度。
	int len = this.count - (valueIdx << IndexShift) + tailSize;
	if (len > 0)
	{
		this.CopyItems(this.items, valueIdx, idx, tailSize, len);
	}
	this.count -= length;
}

第二步是将剩余的 uint 前移,如图 3(c) 所示,这个步骤相当于将从 i3 起始的 uint[] 复制到从 i2 起始的位置中,做法还是将每个 uint 分成 highBits 和 lowBits 分别进行复制。这里需要注意的是超过 length 的位是不能受到影响的(主要是为了之后插入多个位时不会影响到现有数据)。这段代码非常长,因为 i2 可能是大于 0 的,因此必须保证 i2 之前的数据不能受到影响,情况就变得比较复杂,所以我把第一重循环单独拿出来进行判断,导致代码行数倍增。还有就是,length 参数中除了要正常复制的数据长度外,还特别包括了一份 lowSize(这就是为什么上面的代码的 len 中加了一次 tailSize),这样完全是为了这里计算长度时更方便一些。

private void CopyItems(IList<uint> source, int sourceIdx, int itemIdx, int lowSize, int length)
{
	int highSize = UInt32Size - lowSize;
	int cnt = source.Count;
	// 特殊处理第一次循环。
	if (length >= UInt32Size)
	{
		if (lowSize == 0)
		{
			this.items[itemIdx] = source[sourceIdx];
		}
		else
		{
			uint lowMask = GetMask(lowSize);
			this.items[itemIdx] = (source[sourceIdx] << lowSize) |
				(this.items[itemIdx] & lowMask);
		}
		length -= UInt32Size;
		sourceIdx++;
		itemIdx++;
	}
	else
	{
		uint value = 0u;
		if (lowSize == 0)
		{
			value = source[sourceIdx];
		}
		else
		{
			if (sourceIdx > 0)
			{
				value = source[sourceIdx - 1] >> highSize;
			}
			if (sourceIdx < cnt)
			{
				value |= source[sourceIdx] << lowSize;
			}
		}
		uint lenMask = GetMask(length - lowSize) << lowSize;
		this.items[itemIdx] = (this.items[itemIdx] & ~lenMask) | (value & lenMask);
		return;
	}
	while (length > 0)
	{
		if (length >= UInt32Size)
		{
			if (lowSize == 0)
			{
				this.items[itemIdx] = source[sourceIdx];
			}
			else
			{
				this.items[itemIdx] = (source[sourceIdx] << lowSize) |
					(source[sourceIdx - 1] >> highSize);
			}
			length -= UInt32Size;
			sourceIdx++;
			itemIdx++;
		}
		else
		{
			uint value = 0u;
			if (lowSize == 0)
			{
				value = source[sourceIdx];
			}
			else
			{
				if (sourceIdx > 0)
				{
					value = source[sourceIdx - 1] >> highSize;
				}
				if (sourceIdx < cnt)
				{
					value |= source[sourceIdx] << lowSize;
				}
			}
			uint lenMask = GetMask(length);
			this.items[itemIdx] = (this.items[itemIdx] & ~lenMask) | (value & lenMask);
			break;
		}
	}
}

2.3 插入一个位

接下来是插入一个位,这个操作也并不是很复杂。图 5(a) 里面的第 i 个位是要插入的位,它右边蓝色的部分和左边绿色的部分都是其它数据。插入后的结果就如图 5(c) 所示,右边的位不变,左边的位均被做移了一位,同时新的位(红色)被插入到索引 i。这里只需要处理好边界处的位(青色部分),这些位原本是右边 uint 的最高位,现在则变成了做边 uint 的最低位。

图 5 插入一个位

插入一个位操作被同样被分成两步执行,第一步是将左边的位(绿色和青色)依次左移。具体作法是从左向右遍历,依次将当前 uint 左移一位,再将右边 uint 的最高位放到当前 uint 的最低位,如图 5(b) 所示。这里需要记得确保 uint[] 还有足够的空间。代码为:

int cnt = (this.count >> 5);
if (cnt + 1 > this.items.Length)
{
	EnsureCapacity(this.count + 1);
}
int idx = index >> 5;
for (int i = cnt; i > idx; i--)
{
	this.items[i] <<= 1;
	this.items[i] |= this.items[i - 1] >> 31;
}

第二步是将新的位添加到空出来的第 i 位,如图 5(c) 所示。具体作法是将第 i 位所在的 uint 的高位(highBits)和低位(lowBits)都提取出来,然后 highBits << 1 | newBit | lowBits 就可以将新的位插入进去。这段代码如下所示(接着上一段代码):

uint value = this.items[idx];
uint spliter = (1u << (index & 0x1F));
uint lowMask = spliter - 1;
uint lowBits = value & lowMask;
uint highBits = value & (uint.MaxValue - lowMask);
if (!item) {
	spliter = 0u;
}
this.items[idx] = (highBits << 1) | spliter | lowBits;

2.4 插入多个位

插入多个位是最困难的操作。假设下图 6(a)中将要把多个位(设其长度为 length)插入到第 i 个位,它右边蓝色的部分和左边绿色的部分都是其它数据。插入后的结果就如图 6(c) 所示,右边的位不变,左边的位均被左移了 length 位。

图 6 插入多个位

这个操作虽然困难,但却有比较巧妙的解决办法。同样是分为两步来解决,第一步左移现有数据以空出空间,如图 6(b) 所示。现在假设绿色部分的最左 uint 总是填满的(假设的部分为浅绿色,这么做是为了省去部分位对齐操作,而且也不会有什么影响),那么 i2 就是绿色部分的最高位,i3 则是能保证空出 length 长度的最高位的位置,这个左移操作就可以看成是将从 i2 起始绿色部分逆向复制到从 i3 位置。实现则与正向复制类似,还是分为 highBits 和 lowBits,分别复制,只不过将方向反过来,同样不能够复制末尾多余的位,因为如果插入的位很少,复制末尾多余的位可能会导致错误的覆盖现有的数据。

对于起始位置之前的位到无所谓,覆盖就覆盖了,所以不用像上面的正向复制一样把第一次复制单独考虑。还有就是,这里的 length 同样为了计算简便而多加了一份 lowSize。

private void CopyItemsBackward(int sourceIdx, int itemIdx, int lowSize, int length)
{
	int highSize = UInt32Size - lowSize;
	while (length > 0)
	{
		if (length >= UInt32Size)
		{
			if (highSize == 0)
			{
				this.items[itemIdx] = this.items[sourceIdx];
			}
			else
			{
				this.items[itemIdx] = (this.items[sourceIdx + 1] << lowSize) |
					(this.items[sourceIdx] >> highSize);
			}
			length -= UInt32Size;
			sourceIdx--;
			itemIdx--;
		}
		else
		{
			uint value = 0u;
			if (highSize == 0)
			{
				value = this.items[sourceIdx];
			}
			else
			{
				value = this.items[sourceIdx + 1] << lowSize;
				if (sourceIdx >= 0)
				{
					value |= this.items[sourceIdx] >> highSize;
				}
			}
			uint lenMask = GetMask(UInt32Size - length);
			this.items[itemIdx] = (this.items[itemIdx] & lenMask) | (value & ~lenMask);
			break;
		}
	}
}

第二步则要将新数据插入到空出来的空间中,直接使用之前定义的 CopyItem 方法就可以了,所以 CopyItem 需要保证不能覆盖任何数据。

2.5 填充多个位

图 7 填充多个位

填充多个位的过程非常简单,如图 7 所示,蓝色部分是其它的数据,绿色和青色部分是要填充的位,第一步是填充与 tailBits 位于同一个 uint 的 highBits 部分,因为这部分会影响到之前的数据。第二步就是用循环填充剩下的部分了。

public void Fill(int index, int length, bool value)
{
	if (length == 0)
	{
		return;
	}
	uint uv = value ? uint.MaxValue : 0u;
	int idx = index >> IndexShift;
	int tailSize = index & IndexMask;
	if (tailSize > 0)
	{
		uint mask = GetMask(tailSize);
		if (length < UInt32Size)
		{
			mask |= ~GetMask(length) << tailSize;
		}
		this.items[idx] = (this.items[idx] & mask) | (uv & ~mask);
		length -= UInt32Size - tailSize;
		idx++;
	}
	while (length > 0)
	{
		if (length >= UInt32Size)
		{
			this.items[idx] = uv;
			length -= UInt32Size;
			idx++;
		}
		else
		{
			uint mask = GetMask(length);
			this.items[idx] = (this.items[idx] & ~mask) | (uv & mask);
			break;
		}
	}
}

2.6 位移操作

有了前面的基础,位移操作的实现非常容易,它可以由已有的操作组合而成。需要注意的是,这里的左移和右移分别是向着索引增大的方向和索引减小的方向移动,与整数的位移方向相同。

设位移的长度为 offset,左移操作等价于在索引 0 处插入 offset 位(这 offset 位都是 0),只不过不改变列表的长度。而右移操作就等价于移除从索引 0 开始的 offset 为,再将最后空出来的部分用 0 填充。

BitList 的主要操作就是这些了,批量插入和批量删除操作就是其中的核心,写起来也最困难。完整的代码见 BitList 类。这个类在进行内容比较的时候也可以一次比较一个 uint 从而实现加速,因此我加入了 BitListEqualityComparer 类来实现这一点。

posted @ 2013-04-14 11:08  CYJB  阅读(1822)  评论(2编辑  收藏  举报
Fork me on GitHub