如何提高 C 程序中的拼写检查时间?
Posted
技术标签:
【中文标题】如何提高 C 程序中的拼写检查时间?【英文标题】:How can I improve spell check time in C program? 【发布时间】:2016-09-14 05:02:53 【问题描述】:作为哈佛 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,我能够在大约半秒内准确地进行拼写检查。
但是,当我查看来自输入文本的单词时,很明显这些单词中有很大一部分是常见的(例如“the”、“and”、“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 上的第一篇文章,我正在努力做到彻底。我在发布之前进行了搜索,但大多数其他“拼写检查”帖子都与“如何”而不是“改进”有关。我很感谢您提出的建议,这些建议让我朝着正确的方向前进。
http://github.com/Ganellon/spell_check
【问题讨论】:
缓存思想的问题:你会加快处理恰好在缓存中的单词,但减慢处理不在缓存中的单词。因此,例如,如果将拼写错误的单词放入缓存中,那么您将加快大约 5000 个单词的处理速度,但会减慢大约 114 万个单词的处理速度。这是一个糟糕的权衡。所以我会跳过缓存的想法,并努力让所有 8 个核心计算哈希函数。 如果您在此处找不到所需内容,请尝试 Code Review。 你分析过你的代码吗? 鉴于您有大量重复的拼写错误,您是否考虑将拼写错误添加到哈希中?如果您存储查找的真/假结果,并在哈希链的开头插入坏词,您可能会恢复一些性能。 @PaulHankin - 有点像,在调用每个函数之间使用 getrusage 和 rusage 结构,其中将字典加载到内存中,根据哈希表检查传入的单词,以及释放堆也是单独定时的作为整个 Valgrind 确保一切都被清理干净。 【参考方案1】:在您的两次试验中,值得注意的是大多数单词拼写正确。因此,您应该专注于优化字典中单词的查找。
例如,在您的第一次试验中,只有 1.5% 的单词拼写错误。假设查找一个不在字典中的单词平均需要两倍的时间(因为需要检查桶中的每个单词)。即使您将其降低到 0(理论上的最小值 :)),您的程序速度也会提高不到 3%。
一种常见的哈希表优化是将找到的键移到存储桶链的开头,如果它不存在的话。这将倾向于减少检查常用词的哈希条目的数量。这并不是一个巨大的加速,但在某些键的查找频率比其他键高得多的情况下,它肯定会被注意到。
通过减少哈希表占用来减少链长度可能会有所帮助,但会以更多内存为代价。
另一种可能性,因为一旦构建字典就不会修改它,是将每个桶链存储在连续的内存中,没有指针。这不仅会减少内存消耗,还会提高缓存性能,因为由于大多数单词都很短,所以大多数存储桶都可以放在单个缓存行中。
而且由于单词往往很短,因此您很可能能够找到一种优化比较的方法。 strcmp()
进行了很好的优化,但通常针对较大的字符串进行了优化。如果您被允许使用它,SSE4.2 PCMPESTRI 操作码将非常强大(但弄清楚它的作用以及如何使用它来解决您的问题可能会浪费大量时间)。更简单地说,您应该能够同时比较四个 8 字节前缀和 256 位比较操作(甚至可能有 512 位操作可供您使用),因此通过巧妙的数据排列,您很可能能够做整个桶的并行比较。
这并不是说哈希表一定是解决这个问题的最佳数据结构。但是请记住,您在单个缓存行中可以执行的操作越多,您的程序运行速度就越快。链表密集型数据结构即使在纸面上看起来不错,也可能不是最理想的。
在考虑了这个问题几天并实际编写了一些代码之后,我得出的结论是,对于成功的哈希表查找速度进行优化可能不正确对于现实世界的拼写检查器。确实,被查找的文本中的大多数单词通常拼写正确——尽管这取决于拼写检查用户——但试图建议正确拼写的算法可能会在可能的拼写错误中循环执行很多不成功的查找.我知道这可能超出了这个问题的范围,但它确实对优化产生了影响,因为你最终会得到两种完全不同的策略。
如果您想快速拒绝,则需要大量可能为空的桶链,或者布隆过滤器或其道德等价物,这样您就可以在第一次探测时拒绝大多数错误。
例如,如果你有一个很好的哈希算法,它产生的比特比你需要的多——而且你几乎肯定会这样做,因为拼写检查字典没有那么大——那么你可以在hash 用于辅助散列。甚至不必费心去实现整个 Bloom 过滤器,您只需向每个存储桶标头添加一个 32 位掩码,表示存储在该存储桶中的值中五个辅助哈希位的可能值。结合稀疏表——我在实验中使用了 30% 的占用率,这并不是那么稀疏——你应该能够拒绝 80-90% 的查找失败而不超出存储桶标题。
另一方面,如果您尝试优化以获得成功,那么可能会证明较大的存储桶会更好,因为它会减少存储桶标头的数量,从而提高缓存使用率。只要整个存储桶适合一个缓存行,多重比较的速度就会如此之快,以至于您不会注意到差异。 (而且由于单词往往很短,所以 64 字节的缓存线可以容纳五六个字是合理的。)
不管怎样,不用做太多工作,我就能在 70 毫秒的 CPU 中完成一百万次查找。多处理可以大大加快运行时间,特别是考虑到哈希表是不可变的,因此不需要锁定。
我想从中汲取的道德:
为了优化:
您需要了解您的数据
您需要了解您的预期使用模式
您需要根据上述情况调整您的算法
你需要做很多实验。
【讨论】:
感谢您的回复,rici。我喜欢将“最常见”链接移到列表前面的想法,即使它只节省了几次迭代。占用不是一个问题,因为我使用的是一个非常大的表,对于 150K 的字典,内部冲突少于 2K,尽管我还没有分析每个桶来确定冲突分布的均匀程度。 (我有一个二级整数表,它存储哈希表的索引以便于清理。)我一定会看 PCMPESTRI,我很感激这个建议。 PCMPESTRI 实际上在 ASM 中比在 C 中更容易实现。这对我来说是第一次。 @drew:有内在函数 :) 如果你没有很多冲突,它可能对多个桶比较没用(但摆脱链接列表仍然是字典的一个很好的优化)。 PCMPESTRI 对于实现类似 strtok 的东西也很有用。但它不是切片面包。分析一下,看看它是否真的有帮助。 有趣的是,您的第二个实验花费的时间明显更长,而且查找的单词更少。字长了吗?或者,与我在回答中的建议相反,是否有什么东西大大减慢了失败的查找速度? re:更长的查找时间 - 这是托管在 cloud9 Ubuntu IDE 上的,所以中午运行是可疑的。我刚才在同一个数据集上再次运行它,结果是: Unique Misspellings: 8348 WORDS MISSPELLED: 45691 WORDS IN DICTIONARY: 143091 WORDS IN TEXT: 904612 TIME IN load: 0.05 TIME IN check: 0.45 TIME IN size: 0.00卸载时间:0.03 总时间:0.53【参考方案2】:您可能会探索的一些见解/想法:
如果值的长度相似 - 或略大于指针 - 封闭式散列将比任何开放式散列(即单独的链接方法)提供更好的性能
检查单词的长度是一种便宜的(如果您要跟踪它,可能是免费的)方式,您可以将验证定向到最适合该单词长度的方法
为了将更多的单词放到更少的内存页面上(从而对缓存更加友好),您可以尝试使用多个哈希表,其中存储桶的大小调整为其中的最长文本长度
李>4 字节和 8 字节存储桶方便地允许单指令对齐的 32 位和 64 位值比较,如果您使用 NUL 填充字符串(即您可以合并 uint32_t
和char[4]
,或uint64_t
和char[8]
,并比较整数值)。
您选择的哈希函数很重要:多试几次
您选择的碰撞处理策略也很重要:具有线性、二次和可能的素数列表(1、3、7、11...)的轮廓。
bucket 的数量是一种平衡行为:太少,你有太多的冲突,太多的 bucket,你有更多的内存缓存未命中,因此使用一系列值进行测试以找到最佳设置
您可能会使用更避免碰撞的素数存储桶进行分析,其中 %
将哈希值折叠到存储桶索引范围内,而不是使用两个存储桶计数的幂,您可以使用更快的 &
位掩码
上述许多相互作用:例如如果您使用强哈希函数,则对质数桶的需求较少;如果您的碰撞次数较少,您就不需要通过备用存储桶进行复杂的碰撞后搜索顺序
拼写检查很容易通过线程进行扩展,因为您正在执行只读哈希表查找;事先将字典插入到哈希表中 - 虽然使用上述多个表提供了一种并行化方法,但情况较少
【讨论】:
【参考方案3】:这是一个很好解决的问题。 ;-) 您应该研究一个名为trie 的数据结构。 trie 是由单个字符构建的树,因此 path 表示信息。每个节点都由您可以合法添加到当前前缀的字母组成。当一个字母是一个有效的单词时,它也会被记录下来。
四个字:
root-> [a]-> [a]-> [r]-> [d]-> [v]-> [a]-> [r]-> [k*]->[s*]
[b]
\> [a]-> [c]-> [i*]
[u]-> [s*]
这将代表“土豚”、“土豚”、“算盘”和“算盘”。节点是垂直连续的,所以第 2 个字母 [ab] 是一个节点,第 5 个字母 [i*u] 是一个节点。
逐个字符遍历trie,并在点击空格时检查有效单词。如果你不能用你拥有的字符遍历,那么这是一个坏词。如果你在点击空格的时候没有找到valid,那就是一个坏词。
这是 O(n) 的处理时间(n = 字长),而且非常非常快。构建 trie 会消耗大量 RAM,但我认为你不在乎。
【讨论】:
感谢您的来信,奥斯汀。我曾考虑过使用 trie 而不是哈希表,但我不相信它会提高速度。我接近 O(1),而转向 trie 将是朝着错误方向迈出的一步,除非我遗漏了关于您的建议的一些基本内容。无论如何,我将继续进行尝试,并稍后发布结果。希望避免反复试验,但由于我正在学习,这些都是休息时间。 :-) 散列一个词是 O(len) 在这个词上。 Trie 遍历也是 O(len)。这是一个苹果/苹果的场景。 @Drew:O(1) 可能很快,也可能非常慢。重要的是常数因子。 (调整你的教练 - 我很惊讶有多少孩子从学校出来认为他们可以用大 O 雪人。)我倾向于同意奥斯汀的 trie,因为如果字典中只有 50k 个单词,trie不会很深。更重要的是,您可以将其用于spelling correction。另外,我敢打赌他们没有告诉你另一个技巧——你可以将字典预编译成代码。这将为您带来巨大的加速。 @MikeDunlavey 感谢您的回复。对你提出的每一点的挑战(它们是有效的)是我完全不知道他们会向我扔什么字典。所以,他们可能会给我一本有 10 个单词的字典,或者两次运行之间有 500,000 个单词。否则,我肯定会花 30-40 秒来预处理和持久化一个完美的哈希表。那很好啊!但是,他们正在使用这个技巧。 ;-)以上是关于如何提高 C 程序中的拼写检查时间?的主要内容,如果未能解决你的问题,请参考以下文章