天天看点

白话算法(6) 散列表(Hash Table)从理论到实用(中)

不用链接法,还有别的方法能处理碰撞吗?扪心自问,我不敢问这个问题。链接法如此的自然、直接,以至于我不敢相信还有别的(甚至是更好的)方法。推动科技进步的人,永远是那些敢于问出比外行更天真、更外行的问题,并且善于运用丰富的想象力找到新的可能性,而且有能力运用科学的方法实践的人。

  如果可以不用链表,把节省下来的链表的指针所占用的空间用作空槽,就可以减少碰撞的机会,提高查找速度。

使用开放寻址法处理碰撞

  不用额外的链表,以及任何其它额外的数据结构,就只用一个数组,在发生碰撞的时候怎么办呢?答案只能是,再找另一个空着的槽啦!这就是开放寻址法(open addressing)。但是这样难道不是很不负责任的吗?想象一下,有一趟对号入座的火车,假设它只有一节车厢,上来一位坐7号座位的旅客。过了一会儿,又上来一位旅客,他买到的是一张假票,也是7号座位,这时怎么办呢?列车长想了想,让拿假票的旅客去坐8号座位。过了一会儿,应该坐8号座位的旅客上来了,列车长对他说8号座位已经有人了,你去坐9号座位吧。哦?9号早就有人了?10号也有人了?那你去坐11号吧。可以想见,越到后来,当空座越来越少时,碰撞的几率就越大,寻找空座愈发地费劲。但是,如果是火车的上座率只有50%或者更少的情况呢?也许真正坐8号座位的乘客永远不会上车,那么让拿假票的乘客坐8号座位就是一个很好的策略了。所以,这是一个空间换时间的游戏。玩好这个游戏的关键是,让旅客分散地坐在车厢里。如何才能做到这一点呢?答案是,对于每位不同的旅客使用不同的探查序列。例如,对于旅客 A,探查座位 7,8,23,56……直到找到一个空位;对于旅客B,探查座位 25,66,77,1,3……直到找到一个空位。如果有 m 个座位,每位旅客可以使用 <0, 1, 2, ..., m-1> 的 m! 个排列中的一个。显而易见,最好减少两个旅客使用相同的探查序列的情况。也就是说,希望把每位旅客尽量分散地映射到 m! 种探查序列上。换句话说,理想状态下,如果能够让每个上车的旅客,使用 m! 个探查序列中的任意一个的可能性是相同的,我们就说实现了一致散列。(这里没有用“随机”这个词儿,因为实际是不可能随机取一个探查序列的,因为在查找这名旅客时还要使用相同的探查序列)。

  真正的一致散列是难以实现的,实践中,常常采用它的一些近似方法。常用的产生探查序列的方法有:线性探查,二次探查,以及双重探查。这些方法都不能实现一致散列,因为它们能产生的不同探查序列数都不超过 m2 个(一致散列要求有 m! 个探查序列)。在这三种方法中,双重散列能产生的探查序列数最多,因而能给出最好的结果(注:.net framework 的 HashTable 就是使用的双重散列法)。

  在上一篇中,我们实现了一个函数 h(k),它的任务是把数值 k 映射为一个数组(尽量分散)的地址。这次,我们使用开发寻找法,需要实现一个函数 h(k, i),它的任务是把数值 k 映射为一个地址序列,序列的第一个地址是 h(k, 0),第二个地址是 h(k, 1)……序列中的每个地址都要尽可能的分散。

线性探查

  有这样一个可以用 10 个槽保存 0~int.MatValue (但是不能处理碰撞)的 IntSet1:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

public class IntSet1

{

}

现在想用开放寻址法处理碰撞,该怎么改造它?最简单的方法是,如果发现 values[8] 已经被占用了,就看看 values[9] 是否空着,如果 values[9] 也被占用了,就看看 values[0] 是不是还空着。完整的描述是,先使用 H() 函数获取 k 的第一个地址,如果这个地址已被占用,就探查下一个紧挨着的地址,如果还是不能用,就探查下一个紧挨着的地址,如果到达了数组的末尾,就卷绕到数组的开头,如果探查了 m 次还是没有找到空槽,就说明数组已经满了,这就是线性探查(linear probing)。实现代码是:

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

public class IntSet2

  在 Add() 函数中,先探查 LH(value, 0),它等于 H(value),如果发生了碰撞,就继续探查 LH(value, 1),它是 H(value) 的下一个地址,LH() 里面的 “... % 10”的意思是数组最后一个槽的下一个槽是第一个槽的意思。在 Contains() 函数里,使用和 Add() 函数一样的探查序列,如果找到了 item 返回 true;如果遇到了 null,说明 item 不在数组中。

  比较麻烦的是 Remove() 函数。不能简单地把要删除的槽设为 null,那样会导致 Contains() 出错。举个例子,如果依次把 3,13,23 添加到 IntSet2 中,会执行 _values[3] = 3,_values[4] = 13,_values[5] = 23。然后,Remove(13) 执行 _values[4] = null。这时,再调用 Contains(23),会依次检查 _values[3]、_values[4]、_values[5] 直到找到 23 或遇到 null,由于 _values[4] 已经被设为 null 了,所以 Contains(23) 会返回 false。有一个解决此问题的方法是,在 Remove(23) 时把 _values[4] 设为一个特殊的值(例如 -1)而不是 null。这样 Contains(23) 就不会在 _values[4] 那里因为遇到 null 而返回错误的 false 了。并且在 Add() 里,遇到 null 或 -1 都视为空槽,修改之后的代码如下:

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

  但是这种实现 Remove() 函数的方法有个很大的问题。想象一下,如果依次添加 0、1、2、3、4、5、6、7、8、9,然后再 Remove 0、1、2、3、4、5、6、7、8,这时再调用 Contains(0),此函数会依次检查 _values[0]、_values[1]..._values[9],这是完全无法接受的!这个问题先放一放,我们在下一篇还会继续讨论解决这个问题的方法。

  线性探查法虽然比较容易实现,但是它有一个叫做一次群集(primary clustering)的问题。就像本文开篇所讨论的,如果 7、8、9 号座位已被占用,下一个上车的旅客,无论他的票是7号、8号还是9号,都会被安排去坐10号;下一个上车的旅客,无论他的票是7号、8号、9号还是10号,都会被安排去坐11号……如果有 i 个连续被占用的槽,下一个空槽被占用的概率就会是 (i + 1)/m,就像血栓一样,一旦堵住,就会越堵越厉害。这样,使用线性探查法,很容易产生一长串连续被占用的槽,导致 Contains() 函数速度变慢。

  对于线性探查法,由于初始位置 LH(k, 0) = H(k) 确定了整个探查序列,所以只有 m 种不同的探查序列。

二次探查

  可以在发生碰撞时,不像线性探查那样探查下一个紧挨着的槽,而是多偏移一些,以此缓解一次群集的问题。二次探查(quadratic probing)让这个偏移量依赖 i 的平方:

  h(k, i) = (h'(k) + c1i + c2i2) mod m

其中,c1 和 c2 是不为0的常数。例如,如果取 c1 = c2 = 1,二次探查的散列函数为:

private int QH(int value, int i)

对于数值 7,QH() 给出的探查序列是 7、9、3、9……由于初始位置 QH(k, 0) = H(k) 确定了整个探查序列,所以二次探查同样只有 m 种不同的探查序列。通过让下一个探查位置以 i 的平方偏移,不容易像线性探查那样让被占用的槽连成一片。但是,由于只要探查的初始位置相同,探查序列就会完全相同,所以会连成一小片、一小片的,这一性质导致一种程度较轻的群集现象,称为二次群集(secondary clusering)。

双重散列

  造成线性探查法和二次探查法的群集现象的罪魁祸首是一旦初始探查位置相同,整个探查序列就相同。这样,一旦出现碰撞,事情就会变得更糟。是什么造成一旦初始探查位置相同,整个探查序列就相同呢?是因为线性探查法和二次探查法都是让后续的探查位置基于初始探查位置(即 H(k))向后偏移几个位置,而这个偏移量,不管是线性的还是二次的,都仅仅是 i 的函数,但是只有 k 是不同的对不对?所以必须想办法让偏移量是 k 的函数才行。以线性探查为例,要想办法让 LH(k, i) 是 k 和 i 的函数,而不是 H(k) 和 i 的函数。说干就干,我们试着把线性探查

H(k) = k % 10

LH(k, i) = (H(k) + i) % 10

改造一下,先试试把 k 乘到 i 上面去,即

LH(k, i) = (H(k) + i * k) % 10

这有效果吗?很不幸,

结果 LH(k, i) 还是 H(k) 和 i 的函数。

再试试把 k 加到 i 上,即

LH(k, i) = (H(k) + i + k) % 10

这个怎么样?

太不幸了,LH(k) 仍然是 H(k) 和 i 的函数。好像怎么折腾都不行,除非把 H(K) 变成乘法散列法,或者使用双重散列(double hashing)法:

h(k, i) = (h1(k) + i*h2(k)) mod m

其中 h1(k) 和 h2(k) 是两个不同的散列函数。例如可以让

h1(k) = k mod 13

h2(k) = k mod 11

h(k, i) = (h1(k) + i*h2(k)) mod 10

这样,h(7, i) 产生的探查序列是 7、4、1、8、5……

h(20, i) 产生的探查序列是 7、6、5、4、3……

这回终于达到了初始探查位置相同,但是后续探查位置不同的目标。

  h2(k) 的设计很有讲究,搞不好会无法探查到每个空槽。以刚刚实现的 h(k, i) 为例,h(6, i) 的探查序列是“6、2、8、4、0、6、2、8、4、0”,如果恰巧数组中的“6、2、8、4、0”这几个位置都被占用了,将会导致程序在还有空槽的状态下抛出“集合溢出”的异常。要避免这种情况,要求 h2(k) 与 m 必须互质。可以看一看如果 h2(k) 与 m 不是互质的话,为什么会有无法探查数组的所有的槽的后果。例如 h2(6)=6 与 10 有公约数2,把它们代入 h(k, i):

h(6, i) = (h1(6) + i * h2(6)) mod 10

由于 (i6) mod 5) 只有 5 个不同的值,所以 h(6, i) 也只有 5 个值。而 h(16, i) = (3 + 5((i*5) mod 2)) mod 10 只有2个值,真是太糟糕了。

  要想让 h2(k) 与 m 互质,有2种方法。一种方法是让 m 为 2 的幂,并且设计一个总是产生奇数的 h2(k),利用的是奇数和 2 的 m 次幂总是互质的原理。另一种方法是让 m 为质数,并设计一个总是产生比 m 小的正整数的 h2(k)。可以这么实现后一种方法:首先使用上一篇实现的 GetPrime() 函数取得一个合适的质数作为 m,然后让

h1(k) = k mod m

h2(k) = 1 + (k mod (m-1))

在 h2(k) 里之所以要把 (k mod (m-1)) 加上个 1 是为了让 h2(k) 永不为0。因为 h2(k) 为 0 会让 i 不起作用,一旦正巧 h1(k) 产生碰撞就无法取得下一个空槽了。

这是一份完整的示例代码,我们将会在下一篇继续完善它:

73

74

75

76

77

78

79

80

81

82

83

84

85

86

87

88

89

90

91

92

93

94

95

96

97

98

99

100

101

102

103

104

105

106

107

108

109

110

111

112

113

114

115

116

117

118

119

120

121

122

123

public class IntSet4

  除了链接法和开放寻址法,还有更好的方法吗?人类永远不会停止追问,本篇却必须结束了。下一篇,我们将参考 .net framework 源代码,讨论实现散列表的一些重要的细节问题。

继续阅读