作为哈佛大学CS50课程的一项任务,学生的任务是创建一个拼写检查程序。任务的主要目标是速度 - 纯粹的速度 - 我已经达到了我击败员工实施的程度,但我觉得我可以做得更好,我正在寻找推动正确的方向。
这是我的伪代码:
// read the dictionary word list
Read entire dictionary in one fread into memory
rawmemchr through and pick out the words
send each word through the hash function
create chain links for any index where collisions occur
// accept the incoming test words
Run the test word through the hash function
compare to the existing table / linked list
return the result of the comparison
使用150K字的字典,输入文本最大为6MB,我能够在大约半秒内准确拼写检查。
但是,当我查看来自输入文本的单词时,很明显这些单词的大部分是常见的(例如""," &"," for"),并且大多数拼写错误的单词也会被多次检查。
我的直觉说我应该能够"缓存"好的点击"和坏的命中"这样我就不会一遍又一遍地为表查找散列相同的单词。即使当前结果非常接近O(1),我觉得我应该能够通过重新评估我的方法来减少几微秒的时间。
例如,在我加载字典后,文本输入可能只有8MB,但是这样:"误导"。因此,我不想反复哈希/检查相同的单词(以计算费用),我想了解是否有一种方法可以编程方式丢弃已经被散列和拒绝的单词,但其方式比哈希/检查本身。 (我正在使用MurmurHash3,fwiw)。
我意识到理论上的性能提升将限于输入文本很长的情况,并且存在大量的重复拼写错误。基于我评估的一些输入文本,以下是一些结果:
Unique Misspellings: 6960
Total Misspellings: 17845
Words in dictionary: 143091
Words in input text: 1150970
Total Time: 0.56 seconds
Unique Misspellings: 8348
Total Misspellings: 45691
Words in dictionary: 143091
Words in input text: 904612
Total Time: 0.83 seconds
在第二个示例运行中,您可以看到我必须为每个拼写错误的单词返回哈希表约5.5次!这对我来说似乎很疯狂,我觉得必须有一种更有效的方法来解决这种情况,因为我的大部分时间都用在哈希函数中。
我可以实现Posix线程(这是在8核系统上运行)来改善程序的时间,但我更感兴趣的是改进我的方法和围绕这个问题的思维过程。
对不起,这是漫长的啰嗦,但这是我的第一个Stack Overflow帖子,我试图彻底。我在发布之前进行了搜索,但大多数其他"拼写检查"帖子与"如何"而不是"改善"。我很感激能让我指出正确方向的建议。
答案 0 :(得分:6)
这是一个很好解决的问题。 ;-)您应该研究一个名为trie的数据结构。 trie是由单个字符构成的树,因此路径表示信息。每个节点都包含可以合法添加到当前前缀的字母。当一个字母是一个有效的单词时,也会记录下来。
四个字:
root-> [a]-> [a]-> [r]-> [d]-> [v]-> [a]-> [r]-> [k*]->[s*]
[b]
\> [a]-> [c]-> [i*]
[u]-> [s*]
这将代表" aardvark"," aardvarks"," abaci"和" abacus。"节点是垂直连续的,因此第二个字母[ab]是一个节点,第五个字母[i * u]是一个节点。
逐个字符遍历特里字符,并在您触及空格时检查有效字。如果你不能用自己的角色进行遍历,那么这就是一个坏词。如果你在击中太空时没有找到有效的信息,那就不好了。
这是O(n)处理(n =字长)并且它非常非常快。构建trie将占用大量内存,但你并不关心我的想法。
答案 1 :(得分:5)
在你的两个试验中,值得注意的是大多数单词拼写正确。因此,您应该专注于优化词典中单词的查找。
例如,在您的第一次试用中,只有1.5%的单词拼写错误。假设平均需要两倍的时间来查找不在字典中的单词(因为需要检查存储桶中的每个单词)。即使你把它减少到0(理论最小值:)),你的程序速度也会提高不到3%。
常见的哈希表优化是将您找到的密钥移动到存储桶链的开头(如果尚未存在)。这将倾向于减少为常用单词检查的散列条目的数量。这不是一个巨大的加速,但如果一些键比其他键更频繁地查找,它肯定会被注意到。
通过减少哈希表占用率来减少链长可能会有所帮助,但需要花费更多内存。
另一种可能性,因为你不打算在构建字典后修改字典,就是将每个存储桶链存储在连续的内存中,而不是指针。这不仅可以减少内存消耗,还可以提高缓存性能,因为大多数单词都很短,大多数存储区都适合单个缓存行。
由于单词往往很短,你很可能找到一种优化比较的方法。 strcmp()
已经过优化,但通常针对较大的字符串进行了优化。如果你被允许使用它,SSE4.2 PCMPESTRI操作码非常强大(但弄清楚它的作用以及如何使用它来解决你的问题可能是一个巨大的浪费时间)。更简单地说,您应该能够同时比较四个八字节前缀和256位比较操作(您甚至可以使用512位操作),因此通过巧妙的数据排列,您可能能够做到整个桶并行比较。
这并不是说哈希表必然是这个问题的最佳数据结构。但请记住,在单个缓存行中可以执行的操作越多,程序运行的速度就越快。即使链接列表密集型数据结构在纸面上看起来很好,它们也可能不是最理想的。
在考虑了这个问题并且实际编写了一些代码之后,我得出的结论是,对于一个真实世界的拼写检查程序,优化成功的散列表查找速度可能不正确。确实,正在查找的文本中的大多数单词通常都是正确拼写的 - 虽然这取决于拼写检查用户 - 但是试图建议正确拼写的算法可能会进行大量不成功的查找,因为它会循环可能的拼写错误。我知道这可能超出了这个问题的范围,但它确实对优化产生了影响,因为你最终得到了两种截然不同的策略。
如果你想快速拒绝,你需要很多可能是空的桶链,或者布隆过滤器或它的道德等价物,所以你可以拒绝第一次探测时的大多数错误。
例如,如果你有一个好的哈希算法产生比你需要的更多的比特 - 你几乎肯定会这样做,因为拼写检查字典不是那么大 - 那么你可以使用一些其他未使用的位哈希为二级哈希。如果没有实现整个Bloom过滤器的麻烦,您可以在每个桶标头中添加一个32位掩码,表示存储在该存储桶中的值中五个二级散列位的可能值。结合稀疏表 - 我使用30%的占用率进行实验,这不是那么稀疏 - 你应该能够拒绝80-90%的查找失败,而不会超出桶标题。
另一方面,如果您正在尝试优化以获得成功,那么可能会发现较大的存储桶更好,因为它减少了存储区标头的数量,从而提高了缓存使用率。只要整个存储桶适合缓存行,多次比较的速度就会很高,以至于您不会注意到差异。 (因为单词往往很短,所以期望五或六个适合64字节的高速缓存行是合理的。)
无论如何,在没有太多工作的情况下,我设法在70毫秒的CPU中进行了一百万次查找。多处理可以大大加快经过的时间,特别是因为哈希表是不可变的,所以不需要锁定。
我想从中得出的道德:
为了优化:
您需要了解您的数据
您需要了解您的预期使用模式
您需要根据上述
你需要做很多实验。
答案 2 :(得分:1)
您可能会探索的一些见解/想法:
其中值的长度相似 - 或者比指针大一点 - 封闭散列将比任何开放散列(即单独的链接方法)提供更好的性能
正在检查的单词的长度是便宜的(如果你正在跟踪它可能是免费的),你可以将验证指向最适合该单词长度的方法
为了在更少的内存页面上获得更多的单词(从而更加缓存友好),你可以尝试使用多个哈希表,其中桶的大小是其中最长的文本长度
4字节和8字节存储桶可以方便地进行单指令对齐的32位和64位值比较,如果用NUL填充字符串(即你可以建立{{1}的并集}和uint32_t
,或char[4]
和uint64_t
,并比较整数值。
您选择哈希函数很重要:尝试一下
您选择的碰撞处理策略也很重要:配置文件包含线性,二次和可能的素数列表(1,3,7,1 ...)。
桶的数量是一个平衡行为:太少,你有太多的冲突,太多的桶,你有更多的内存缓存未命中,所以测试一系列值,以找到最佳设置
您可能会使用更具冲突力的素数数据桶,其中char[8]
折叠哈希值进入存储区索引范围,而不是两个存储桶数量,您可以使用更快的{{1} } bitmasking
上述许多互动:例如如果使用强哈希函数,则需要较少数量的桶;如果碰撞较少,则不需要通过替代铲斗进行详细的碰撞后搜索订单
使用线程进行拼写检查非常容易,因为您正在进行只读哈希表查找;先前将字典插入到哈希表中 - 尽管如此,尽管如上所述使用多个表提供了一种并行化的方法