一、废话
字节数组搜索,即在一个字节数组中搜索一个更短的子数组。C#中并没有直接提供字节数组的搜索算法,而如果用Linq扩展方法的话效率又很低,且不支持通配符,本文简述了如何利用 表达式树 实现支持通配符的字节数组搜索。
字节数组的搜索算法可以参考字符串的搜索算法,而且实现起来比后者更加简单高效。因为元素的范围就那么大,预处理快速且不占空间。
任何字符串搜索算法都可以使用,这里我选择了一个Sunday算法的改进版本,具体可以参考 《Sunday字符串匹配算法的效率改进》 一文,当然懒得看也没关系,各位读者老爷一定明白这类算法的核心就是(先做预处理然后)一旦发现不匹配就跳过尽可能多的元素。我在后文也会给出简单的描述。
完整的项目可以在 这里 看到。
二、算法简述
Sunday算法的思路和实现都非常简单,这个改进版本也是如此。具体来说分为两大块骤:
- 根据模式串Pattern的长度进行预处理,生成一个跳过表。
- 遍历目标数组并进行比照时,如果不匹配,则根据跳过表跳过相应数量的元素。
具体细节恕我表达能力差,直接看代码吧:
//改进的Sunday算法,被称作RoSunday
private static int RoSunday(byte[] source, byte[] pattern)
{
var count = source.Length;
var patternLength = pattern.Length;
var pattMaxIdx = patternLength - 1;
var maxLen = count - patternLength + 1;
var moveTable = new int[256];
//下面两个for循环用于初始化跳过表
for (int i = 0; i < 256; i++)
{
//默认的跳过长度是Pattern的长度
moveTable[i] = patternLength;
}
for (int i = 0; i < patternLength; i++)
{
//针对Pattern的每个元素,计算它对应的跳过长度。
//其实就是元素索引的翻转
moveTable[pattern[i]] = pattMaxIdx - i;
}
var index = 0;
while (index < maxLen)
{
var mov = moveTable[source[index + pattMaxIdx]];
if (mov < patternLength)
{
index += mov;
for (var i = 0; i < patternLength; i++)
{
if (source[index + i] != pattern[i])
{
++index;
continue;
}
return index;
}
}
else
{
index += patternLength;
}
}
return -1;
}
如果不需要支持通配符,那么上面的代码稍微优化一下就能够直接使用了。
三、支持通配符的字符串Pattern的格式设计
- 字节数组中的每个元素必须用两位十六进制数表示,如1表示为
01
,16表示为10
。 - 任何空格(
0x20
)都会被忽略。例如010203
,0102 03
,01 02 03
都是等价的。 -
?
表示通配符。它必须成对出现,或者搭配一位十六进制数。如??
表示255,1?
表示0x10~0x1F ,?1
表示 0x11, 0x21, 0x31......0xF1。
这种设计的目标就是为了偷懒,便于解析。
如果你不嫌麻烦,可以给字符串Pattern设计更宽松的规则,如果你有时间而且有耐心去搞量词处理,甚至可以试着弄一个正则表达式版本,无非就是先转语法树。之前我想着搞个大新闻,花了一个星期写了个极烂的正则表达式的实现,速度慢不说,关键是大部分语法都用不到……
其实这种通配符的设计可以理解成一个只有 连(Concat) 的正则表达式,对于我来说足够了,因为就是写修改器的时候用……
四、增加通配符搜索的支持
支持通配符,无非就是把byte[]
形式的Pattern换成了string
形式。 在第二节的代码中,对于Pattern的内容有要求的地方只有两处:
//针对Pattern的每个元素,计算它对应的跳过长度。
//其实就是元素索引的翻转
moveTable[pattern[i]] = pattMaxIdx - i;
以及
if (source[index + i] != pattern[i]){}
为了方便说明我们先定义一个示例Patern:A1 B? ?C ??
。
然后先看第二处,它的作用是依次比照主串source
和pattern
的元素,根据规则,描述示例Pattern的方法就不言而喻了:
if (source[index + i++] != 0xA1 || (source[index + i++] & 0xF0) != 0xB0 || (source[index + i++] & 0x0F) != 0x0C){}
原始的代码位于一个循环之内,对于想要支持通配符,这种设计肯定不行,只有把循环转化为以上的形式。至于最后的??
,直接无视即可。
再来看第一处,初始化跳转表。对于通配符而言,它需要处理的不再是某一个元素,而是某个范围内的元素。
??
一切都匹配:
for (int j = 0; j < 256; i++)
{
moveTable[j] = badMove;
}
?C
则是:
for (int j = 0x0C; j < 256; j+=0x10)
{
moveTable[j] = badMove;
}
而B?
则是:
for (int j = 0xB0; j< 256; i++)
{
moveTable[j] = badMove;
}
而上面三段代码中badMove
的值也很好确定,还是以示例Pattern为例,这个串去掉所有空格后是 A1B??C??
,长度为8,除以2后即是这个Pattern的“实际意义上的长度”……
没错,定义规则时我的第一原则就是方便解析……
五、Pattern的解析与生成表达式树
无论如何,Pattern必须要进行解析才能够使用。而解析完毕之后,通配符和主串中元素的比对也需要更多的操作,效率会变低。所以一个更好的办法是,在解析Pattern的同时,直接生成表达式树,然后编译成委托的形式直接调用。
微软提供的文档中 表达式树 的介绍和 API文档 相当完善, 这里就不详细介绍了,这里直接上代码。
这里的代码仅作示例,不考虑Pattern错误的情况。
我们的Pattern是支持空格分隔的,所以要先去掉空格:
pattern = pattern.Replace(" ", string.Empty);
然后将跳过表初始化为默认值:
for (int i = 0; i < 256; i++)
{
moveTable[i] = pattern.Length / 2;
}
然后定义最初的表达式:
//注意这里的判断:要先确定余下的source元素数量是否大于patern的实际长度,以防止溢出。
Expression exp = Expression.LessThanOrEqual(
Expression.Constant(pattern.Length / 2, typeof(int)),
Expression.Subtract(Expression.ArrayLength(ExpParamSource), ExpParamSourceIndex));
设置一些常用的变量:
var patternMaxIndex = pattern.Length / 2 - 1;
var ExpParamSource = Expression.Parameter(typeof(byte[]), "source");
var ExpParamSourceIndex = Expression.Parameter(typeof(int), "sourceIndex");
var ExpArrayItemIterator = Expression.ArrayIndex(ExpParamSource, Expression.PostIncrementAssign(ExpParamSourceIndex));
然后就开始解析Pattern,同时设置跳过表,并生成表达式树了:
for (int idx = 0; idx < pattern.Length; idx += 2) //两个一组
{
badMove = patternMaxIndex - (idx / 2);
//.....
}
在上面的循环中,如果pattern[idx]
和pattern[idx+1]
表示一个数字hexNum
:
moveTable[hexNum] = badMove;
exp = Expression.AndAlso(
exp,
Expression.Equal(
ExpArrayItemIterator,
Expression.Constant(hexNum, typeof(byte))));
若pattern[idx]
和pattern[idx+1]
表示 ??
:
for (int j = 0; j < 256; i++)
{
moveTable[j] = badMove;
}
exp = Expression.AndAlso(
exp,
Expression.Block(
Expression.PreIncrementAssign(ExpParamSourceIndex),
Expression.Constant(true, typeof(bool))));
若pattern[idx]
表示问号,而pattern[idx+1]
表示一位数字lowDigit
:
for (int j = lowDigit; j < 256; j += 0x10)
{
moveTable[j] = badMove;
}
exp = Expression.AndAlso(
exp,
Expression.Equal(
Expression.And(
ExpArrayItemIterator,
Expression.Constant((byte) 0x0F, typeof(byte))),
Expression.Constant((byte) lowDigit, typeof(byte))));
若pattern[idx]
表示一位数字highDigit
,而pattern[idx+1]
表示问号:
for (int j = highDigit; j< 256; i++)
{
moveTable[j] = badMove;
}
exp = Expression.AndAlso(
exp,
Expression.Equal(
Expression.And(
ExpArrayItemIterator,
Expression.Constant((byte) 0xF0, typeof(byte))),
Expression.Constant((byte) highDigit, typeof(byte))));
关于表达式树的创建,看着代码有些多,其实很简单,就是利用 Expression.AndAlso
把每次的判断串起来……严格的说这离树还差得远呢,就是一坨连起来的二元运算。
全部工作做完之后,编译表达式:
var comparedFunc = Expression.Lambda<Func<byte[], int, bool>>(
exp, ExpParamSource, ExpParamSourceIndex)
.Compile();
最后,把第二节的代码略作修改,作为算法框架:
private static int RoSunday(byte[] source, int[] moveTable, Func<byte[], int, bool> compareFunc, int patternLength)
{
var count = source.Length;
var pattMaxIdx = patternLength - 1;
var maxLen = count - patternLength + 1;
var index = 0;
while (index < maxLen)
{
var mov = moveTable[source[index + pattMaxIdx]];
if (mov < patternLength)
{
index += mov;
if (compareFunc(source, index)) return index;
++index;
}
else
{
index += patternLength;
}
}
return -1;
}
通过这种方式实现的支持通配符的搜索,除了初始化以及第一次调用时花费的时间略长外,之后的执行都是相当快速的。
六、其他
虽然一提到字符串搜索算法很多人首先想到的就是BM,其实Sunday算法的效果意外的好。
我用 我的实现 和另一个纯C#写的标准BM实现做了速度测试,100字节~16M的千次随机数据,结果如下:
Total: bytes pattern cost is 1309.7017 , string pattern cost is 948.357200000001 , wildcard string pattern cost is 32906.4641, Boyer Moore algorithm implementation is 1440.9692
Total: bytes pattern cost is 676.126699999998 , string pattern cost is 608.047799999999 , wildcard string pattern cost is 14577.6418, Boyer Moore algorithm implementation is 929.0357
前一条是Debug下的,后一条是Release下的。显而易见的是,(这个改进的)Sunday算法优势很大,尽管通配符功能的效率又量级的差距,但也能控制在20毫秒以内,是可以接受的范围。
(全文完)