暴雪公司的魔兽、星际等游戏都一样一个非常大的MPQ文件,该文件存储了游戏中的大部分数据,想要把这些文字找出来,简单的办法是从数组头开始,一个个字符串读过去,比较每一个,直到找到对应的内容。Blizzard的天才和牛人们当然不会这样做,他们用了更聪明的方法: 用某种算法,把一个字符串压缩成一个整数,即hash。然后,根据这个整数值,直接得到此字符串在整个文件中的位置,从而直接读取之。
Blizzard的这个算法是非常高效的,被称为”One-Way Hash”。所谓One-Way Hash,就是无法从求得的hash值通过简单的逆运算就得到原来的字符串。关于具体的实现原理,inside MPQ 的第二章有详细的介绍,以下为第二章内容的翻译:
贯穿计算机发展历史,大多数进步都是源于某些问题的解决,在这一节中,我们来看一看与MPQ 格式相关问题及解决方案;
问题一:你有一个很大的字符串数组,同时,你另外还有一个字符串,需要知道这个字符串是否 已经存在于字符串数组中。你可能会对数组中的每一个字符串进行比较,但是在实际项目中,你会发现这种做法对某些特殊应用来说太慢了。必须寻求其他途径。那么如何才能在不作遍历比较的情况下知道这个字符串是否存在于数组中呢?
解决方案:哈希表。哈希表是通过更小的数据类型表示其他更大的数据类型。在这种情况下, 你可以把哈希表存储在字符串数组中,然后你可以计算字符串的哈希值,然后与已经存储的字符串的哈希值进行比较。如果有匹配的哈希值,就可以通过字符串比较 进行匹配验证。这种方法叫索引,根据数组的大小以及字符串的平均长度可以约100倍。
1
2
3
4
5
6
7
8
9
10
|
unsigned
long
HashString
(
char
*
lpszString
)
{
unsigned
long
ulHash
=
0xf1e2d3c4
;
while
(
*
lpszString
!=
0
)
{
ulHash
&
lt
;
&
lt
;
=
1
;
ulHash
+=
*
lpszString
++
;
}
return
ulHash
;
}
|
上面代码中的函数演示了一种非常简单的散列算法。这个函数在遍历字符串过程中,将哈希值左移一位,然后加上字符值;通过这个算法,字符串”arrunits.dat” 的哈希值是0x5A858026,字符串”unitneutralacritter.grp” 的哈希值是0x694CD020;现在,众所周知的,这是一个基本没有什么实用价值的简单算法,因为它会在较低的数据范围内产生相对可预测的输出,从而可能会产生大量冲突(不同的字符串产生相同的哈希值)。
MPQ格式,使用了一种非常复杂的散列算法(如下所示),产生完全不可预测的哈希值,这个算法十分有效,这就是所谓的单向散列算法。通过单向散列算法几乎不可能通过哈希值来唯一的确定输入值。使用这种算法,文件名 “arrunits.dat” 的哈希值是0xF4E6C69D,”unitneutralacritter.grp” 的哈希值是 0xA26067F3。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
unsigned
long
HashString
(
char
*
lpszFileName
,
unsigned
long
dwHashType
)
{
unsigned
char
*
key
=
(
unsigned
char
*
)
lpszFileName
;
unsigned
long
seed1
=
0x7FED7FED
,
seed2
=
0xEEEEEEEE
;
int
ch
;
while
(
*
key
!=
0
)
{
ch
=
toupper
(
*
key
++
)
;
seed1
=
cryptTable
[
(
dwHashType
&
lt
;
&
lt
;
8
)
+
ch
]
^
(
seed1
+
seed2
)
;
seed2
=
ch
+
seed1
+
seed2
+
(
seed2
&
lt
;
&
lt
;
5
)
+
3
;
}
return
seed1
;
}
|
问题二:您尝试在前面的示例中使用相同索引,您的程序一定会有中断现象发生,而且不够快 。如果想让它更快,您能做的只有让程序不去查询数组中的所有散列值。或者 您可以只做一次对比就可以得出在列表中是否存在字符串。听起来不错,真的么?不可能的啦
解决:一个哈希表就是以字符串的哈希值作为下标的一类数组。我的意思是,哈希表使用一个固定长度的字符串数组(比如1024,2的偶次幂)进行存储;当你要看看这个字符串是否存在于哈希表中,为了获取这个字符串在哈希表中的位置,你首先计算字符串的哈希值,然后哈希表的长度取模。这样如果你像上一节那样使用简单的哈希算法,字符串”arrunits.dat” 的哈希值是0x5A858026,偏移量0x26(0x5A858026 除于0x400等于0x16A160,模0x400等于0x26)。因此,这个位置的字符串将与新加入的字符串进行比较。如果0X26处的字符串不匹配或不存在,那么表示新增的字符串在数组中不存在。下面是示意的代码:
1
2
3
4
5
6
7
8
|
int
GetHashTablePos
(
char
*
lpszString
,
SOMESTRUCTURE *
lpTable
,
int
nTableSize
)
{
int
nHash
=
HashString
(
lpszString
)
,
nHashPos
=
nHash
%
nTableSize
;
if
(
lpTable
[
nHashPos
]
.
bExists
&
amp
;
&
amp
;
!
strcmp
(
lpTable
[
nHashPos
]
.
pString
,
lpszString
)
)
return
nHashPos
;
else
return
-
1
;
//Error value
}
|
上面的说明中存在一个刺眼的缺陷。当有冲突(两个不同的字符串有相同的哈希值)发生的时候怎么办?显而易见的,它们不能占据哈希表中的同一个位置。通常的解决办法是为每一个哈希值指向一个链表,用于存放所有哈希冲突的值;
MPQs使用一个存放文件名的哈希表来跟踪文件内部,但是表的格式与通常方法有点不同,首先不像通常的做法使用哈希值作为偏移量,存储实际的文件名。MPQs 根本不存储文件名,而是使用了三个不同的哈希值:一个用做哈希表偏移量,两个用作核对。这两个核对的哈希值用于替代文件名。当然从理论上说存在两个不同的文件名得到相同的三个哈希值,但是这种情况发送的几率是:1:18889465931478580854784,这应该足够安全了。
MPQ’s的哈希表的实现与传统实现的另一个不同的地方是,相对与传统做法(为每个节点使用一个链表,当冲突发生的时候,遍历链表进行比较),看一下下面的示范代码,在MPQ中定位一个文件进行读操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
int
GetHashTablePos
(
char
*
lpszString
,
MPQHASHTABLE *
lpTable
,
int
nTableSize
)
{
const
int
HASH_OFFSET
=
0
,
HASH_A
=
1
,
HASH_B
=
2
;
int
nHash
=
HashString
(
lpszString
,
HASH_OFFSET
)
,
nHashA
=
HashString
(
lpszString
,
HASH_A
)
,
nHashB
=
HashString
(
lpszString
,
HASH_B
)
,
nHashStart
=
nHash
%
nTableSize
,
nHashPos
=
nHashStart
;
while
(
lpTable
[
nHashPos
]
.
bExists
)
{
if
(
lpTable
[
nHashPos
]
.
nHashA
==
nHashA
&
amp
;
&
amp
;
lpTable
[
nHashPos
]
.
nHashB
==
nHashB
)
return
nHashPos
;
else
nHashPos
=
(
nHashPos
+
1
)
%
nTableSize
;
if
(
nHashPos
==
nHashStart
)
break
;
}
return
-
1
;
//Error value
}
|
无论代码看上去有多么复杂,其背后的理论并不难。读一个文件的时候基本遵循下面这样一个过程:
1. 计算出字符串的三个哈希值(一个用来确定位置,另外两个用来校验)
2. 察看哈希表中的这个位置
3. 哈希表中这个位置为空吗?如果为空,则肯定该字符串不存在,返回
4. 如果存在,则检查其他两个哈希值是否也匹配,如果匹配,则表示找到了该字符串,返回
5. 移到下一个位置,如果已经越界,则表示没有找到,返回
6. 看看是不是又回到了原来的位置,如果是,则返回没找到
7. 回到3
如果您注意的话,您可能已经从我们的解释和示例代码注意到,MPQ的哈希表已经将所有的文件入口放入MPQ中;那么当哈希表的每个项都被填充的时候,会发生什么呢?答案可能会让你惊讶:你不能添加任何文件。有些人可能会问我为什么文件数量上有这样的限制(文件限制),是否有办法绕过这个限制?就此而言,如果不重新创建MPQ 的项,甚至无法调整哈希表的大小。这是因为每个项在哈希表中的位置会因为跳闸尺寸而改变,而我们无法得到新的位置,因为这些位置值是文件名的哈希值,而我们根本不知道文件名是什么。