热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

散列表(HashTable)探秘--中

不用链接法,还有别的方法能处理碰撞吗?扪心自问,我不敢问这个问题。链接法如此的自然、直接,以至于我不敢相信还有别的(甚至是更好的)方法。推动科技进步的人,永远是那些敢于问出比外行更天真、更外行的问题,

不用链接法,还有别的方法能处理碰撞吗?扪心自问,我不敢问这个问题。链接法如此的自然、直接,以至于我不敢相信还有别的(甚至是更好的)方法。推动科技进步的人,永远是那些敢于问出比外行更天真、更外行的问题,并且善于运用丰富的想象力找到新的可能性,而且有能力运用科学的方法实践的人。
  如果可以不用链表,把节省下来的链表的指针所占用的空间用作空槽,就可以减少碰撞的机会,提高查找速度。

使用开放寻址法处理碰撞

  不用额外的链表,以及任何其它额外的数据结构,就只用一个数组,在发生碰撞的时候怎么办呢?答案只能是,再找另一个空着的槽啦!这就是开放寻址法(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:

public class IntSet1
{
private object[] _values = new object[10];

private int H(int value)
{
return value % 10;
}

public void Add(int item)
{
_values[H(item)] = item;
}
public void Remove(int item)
{
_values[H(item)] = null;
}
public bool Contains(int item)
{
if (_values[H(item)] == null)
return false;
else
return (int)_values[H(item)] == item;
}
}

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

public class IntSet2
{
private object[] _values = new object[10];

private int H(int value)
{
return value % 10;
}

private int LH(int value, int i)
{
return (H(value) + i) % 10;
}

public void Add(int item)
{
int i = 0; // 已经探查过的槽的数量
do
{
int j = LH(item, i); // 想要探查的地址
if (_values[j] == null)
{
_values[j] = item;
return;
}
else
{
i += 1;
}
} while (i <= 10);
throw new Exception("集合溢出");
}
public bool Contains(int item)
{
int i = 0; // 已经探查过的槽的数量
int j = 0; // 想要探查的地址
do
{
j = LH(item, i);
if (_values[j] == null)
return false;

if ((int)_values[j] == item)
return true;
else
i += 1;
} while (i <= 10);
return false;
}
public void Remove(int item)
{
// 有点不太好办
}
}

在 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 都视为空槽,修改之后的代码如下:

public class IntSet2
{
private object[] _values = new object[10];
private readonly int DELETED = -1;

private int H(int value)
{
return value % 10;
}

private int LH(int value, int i)
{
return (H(value) + i) % 10;
}

public void Add(int item)
{
int i = 0; // 已经探查过的槽的数量
do
{
int j = LH(item, i); // 想要探查的地址
if (_values[j] == null || (int)_values[j] == DELETED)
{
_values[j] = item;
return;
}
else
{
i += 1;
}
} while (i <= 10);
throw new Exception("集合溢出");
}
public bool Contains(int item)
{
int i = 0; // 已经探查过的槽的数量
int j = 0; // 想要探查的地址
do
{
j = LH(item, i);
if (_values[j] == null)
return false;

if ((int)_values[j] == item)
return true;
else
i += 1;
} while (i <= 10);
return false;
}
public void Remove(int item)
{
int i = 0; // 已经探查过的槽的数量
int j = 0; // 想要探查的地址
do
{
j = LH(item, i);
if (_values[j] == null)
return;

if ((int)_values[j] == item)
{
_values[j] = DELETED;
return;
}
else
{
i += 1;
}
} while (i <= 10);
}
}

但是这种实现 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 + c2i 2 ) mod m
其中,c1 和 c2 是不为0的常数。例如,如果取 c1 = c2 = 1,二次探查的散列函数为:

private int QH(int value, int i)
{
return (H(value) + i + i * i) % 10;
}

对于数值 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 上面去,即
H(k) = k % 10
LH(k, i) = (H(k) + i * k) % 10
这有效果吗?很不幸,
LH(k, i) = (H(k) + i * k) % 10
           = (H(k) + i * (k%10) % 10
           = (H(k) + i * H(k)) % 10
           = (H(k) * (1 + i)) % 10
结果 LH(k, i) 还是 H(k) 和 i 的函数。
再试试把 k 加到 i 上,即
H(k) = k % 10
LH(k, i) = (H(k) + i + k) % 10
这个怎么样?
LH(k, i) = (H(k) + i + k) % 10
           = (H(k) + i + k%10) % 10
           = (H(k) + i + H(k)) % 10
           = (2*H(k) + i) % 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
          = (6 + i * 6) mod 10
          = (6 + (i * 6) mod 10) mod 10
          = (6 + 2*((i*6) mod 5)) mod 10
由于 (i*6) 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) 产生碰撞就无法取得下一个空槽了。
这是一份完整的示例代码,我们将会在下一篇继续完善它:

public class IntSet4
{
private object[] _values;
private readonly int DELETED = -1;

public IntSet4(int capacity)
{
int size = GetPrime(capacity);
_values = new object[size];
}

// 质数表
private readonly int[] primes = {
3, 7, 11, 17, 23, 29, 37, 47, 59, 71, 89, 107, 131, 163, 197, 239, 293, 353, 431, 521, 631, 761, 919,
1103, 1327, 1597, 1931, 2333, 2801, 3371, 4049, 4861, 5839, 7013, 8419, 10103, 12143, 14591,
17519, 21023, 25229, 30293, 36353, 43627, 52361, 62851, 75431, 90523, 108631, 130363, 156437,
187751, 225307, 270371, 324449, 389357, 467237, 560689, 672827, 807403, 968897, 1162687, 1395263,
1674319, 2009191, 2411033, 2893249, 3471899, 4166287, 4999559, 5999471, 7199369};

// 判断 candidate 是否是质数
private bool IsPrime(int candidate)
{
if ((candidate & 1) != 0) // 是奇数
{
int limit = (int)Math.Sqrt(candidate);
for (int divisor = 3; divisor <= limit; divisor += 2) // divisor = 3、5、7...candidate的平方根
{
if ((candidate % divisor) == 0)
return false;
}
return true;
}
return (candidate == 2); // 除了2,其它偶是全都不是质数
}
// 如果 min 是质数,返回 min;否则返回比 min 稍大的那个质数
private int GetPrime(int min)
{
// 从质数表中查找比 min 稍大的质数
for (int i = 0; i {
int prime = primes[i];
if (prime >= min) return prime;
}

// min 超过了质数表的范围时,探查 min 之后的每一个奇数,直到发现下一个质数
for (int i = (min | 1); i {
if (IsPrime(i))
return i;
}
return min;
}

int H1(int value)
{
return value % _values.Length;
}
int H2(int value)
{
return 1 + (value % (_values.Length - 1));
}
int DH(int value, int i)
{
return (H1(value) + i * H2(value)) % _values.Length;
}

public void Add(int item)
{
int i = 0; // 已经探查过的槽的数量
do
{
int j = DH(item, i); // 想要探查的地址
if (_values[j] == null || (int)_values[j] == DELETED)
{
_values[j] = item;
return;
}
else
{
i += 1;
}
} while (i <= _values.Length);
throw new Exception("集合溢出");
}
public bool Contains(int item)
{
int i = 0; // 已经探查过的槽的数量
int j = 0; // 想要探查的地址
do
{
j = DH(item, i);
if (_values[j] == null)
return false;

if ((int)_values[j] == item)
return true;
else
i += 1;
} while (i <= _values.Length);
return false;
}
public void Remove(int item)
{
int i = 0; // 已经探查过的槽的数量
int j = 0; // 想要探查的地址
do
{
j = DH(item, i);
if (_values[j] == null)
return;

if ((int)_values[j] == item)
{
_values[j] = DELETED;
return;
}
else
{
i += 1;
}
} while (i <= _values.Length);
}
}

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


推荐阅读
  • JDK源码学习之HashTable(附带面试题)的学习笔记
    本文介绍了JDK源码学习之HashTable(附带面试题)的学习笔记,包括HashTable的定义、数据类型、与HashMap的关系和区别。文章提供了干货,并附带了其他相关主题的学习笔记。 ... [详细]
  • 模板引擎StringTemplate的使用方法和特点
    本文介绍了模板引擎StringTemplate的使用方法和特点,包括强制Model和View的分离、Lazy-Evaluation、Recursive enable等。同时,还介绍了StringTemplate语法中的属性和普通字符的使用方法,并提供了向模板填充属性的示例代码。 ... [详细]
  • Whatsthedifferencebetweento_aandto_ary?to_a和to_ary有什么区别? ... [详细]
  • Iamtryingtomakeaclassthatwillreadatextfileofnamesintoanarray,thenreturnthatarra ... [详细]
  • 向QTextEdit拖放文件的方法及实现步骤
    本文介绍了在使用QTextEdit时如何实现拖放文件的功能,包括相关的方法和实现步骤。通过重写dragEnterEvent和dropEvent函数,并结合QMimeData和QUrl等类,可以轻松实现向QTextEdit拖放文件的功能。详细的代码实现和说明可以参考本文提供的示例代码。 ... [详细]
  • 开发笔记:加密&json&StringIO模块&BytesIO模块
    篇首语:本文由编程笔记#小编为大家整理,主要介绍了加密&json&StringIO模块&BytesIO模块相关的知识,希望对你有一定的参考价值。一、加密加密 ... [详细]
  • CSS3选择器的使用方法详解,提高Web开发效率和精准度
    本文详细介绍了CSS3新增的选择器方法,包括属性选择器的使用。通过CSS3选择器,可以提高Web开发的效率和精准度,使得查找元素更加方便和快捷。同时,本文还对属性选择器的各种用法进行了详细解释,并给出了相应的代码示例。通过学习本文,读者可以更好地掌握CSS3选择器的使用方法,提升自己的Web开发能力。 ... [详细]
  • Java容器中的compareto方法排序原理解析
    本文从源码解析Java容器中的compareto方法的排序原理,讲解了在使用数组存储数据时的限制以及存储效率的问题。同时提到了Redis的五大数据结构和list、set等知识点,回忆了作者大学时代的Java学习经历。文章以作者做的思维导图作为目录,展示了整个讲解过程。 ... [详细]
  • Android JSON基础,音视频开发进阶指南目录
    Array里面的对象数据是有序的,json字符串最外层是方括号的,方括号:[]解析jsonArray代码try{json字符串最外层是 ... [详细]
  • 本文详细介绍了Java中vector的使用方法和相关知识,包括vector类的功能、构造方法和使用注意事项。通过使用vector类,可以方便地实现动态数组的功能,并且可以随意插入不同类型的对象,进行查找、插入和删除操作。这篇文章对于需要频繁进行查找、插入和删除操作的情况下,使用vector类是一个很好的选择。 ... [详细]
  • 生成式对抗网络模型综述摘要生成式对抗网络模型(GAN)是基于深度学习的一种强大的生成模型,可以应用于计算机视觉、自然语言处理、半监督学习等重要领域。生成式对抗网络 ... [详细]
  • 本文讨论了一个关于cuowu类的问题,作者在使用cuowu类时遇到了错误提示和使用AdjustmentListener的问题。文章提供了16个解决方案,并给出了两个可能导致错误的原因。 ... [详细]
  • 本文详细介绍了Spring的JdbcTemplate的使用方法,包括执行存储过程、存储函数的call()方法,执行任何SQL语句的execute()方法,单个更新和批量更新的update()和batchUpdate()方法,以及单查和列表查询的query()和queryForXXX()方法。提供了经过测试的API供使用。 ... [详细]
  • Java学习笔记之面向对象编程(OOP)
    本文介绍了Java学习笔记中的面向对象编程(OOP)内容,包括OOP的三大特性(封装、继承、多态)和五大原则(单一职责原则、开放封闭原则、里式替换原则、依赖倒置原则)。通过学习OOP,可以提高代码复用性、拓展性和安全性。 ... [详细]
  • Redis底层数据结构之压缩列表的介绍及实现原理
    本文介绍了Redis底层数据结构之压缩列表的概念、实现原理以及使用场景。压缩列表是Redis为了节约内存而开发的一种顺序数据结构,由特殊编码的连续内存块组成。文章详细解释了压缩列表的构成和各个属性的含义,以及如何通过指针来计算表尾节点的地址。压缩列表适用于列表键和哈希键中只包含少量小整数值和短字符串的情况。通过使用压缩列表,可以有效减少内存占用,提升Redis的性能。 ... [详细]
author-avatar
o0沢田纲吉0o
这个家伙很懒,什么也没留下!
Tags | 热门标签
RankList | 热门文章
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有