【问题标题】:faster string sorting with long common prefix?使用长公共前缀更快的字符串排序?
【发布时间】:2013-04-21 04:32:12
【问题描述】:

我有一组字符串。其中 90% 是以 "http://www." 开头的 URL。我想按字母顺序对它们进行排序。

目前我使用 C++ std::sort()。但 std::sort 是基于比较的快速排序的一种变体,比较两个具有长公共前缀的字符串效率不高。但是(我认为)基数排序也不起作用,因为大多数字符串都放在同一个桶中,因为公共前缀很长。

对于这个问题,有没有比普通快速排序/基数排序更好的算法?

【问题讨论】:

  • 排序前可以去掉前缀吗?
  • 基数树可能会增加一些效率。

标签: string algorithm sorting data-structures


【解决方案1】:

令人惊讶的是,论文Engineering Parallel String Sorting 对 URL 数据集上的大量单线程算法进行了基准测试(参见第 29 页)。 Rantala 的带有缓存的多键快速排序变体领先;您可以在this repository cited in the paper 中测试 multikey_cache8。

我已经测试了该论文中的数据集,如果有任何迹象表明您在前 10 个字符中几乎没有看到一点熵,并且可以区分 100 个字符范围内的前缀。执行 100 次基数排序将破坏缓存而几乎没有什么好处,例如,对一百万个 url 进行排序意味着您要在每个键中寻找约 20 个可区分位,代价是约 100 次缓存未命中。

虽然一般基数排序在长字符串上表现不佳,但 Kärkkäinen 和 Rantala 在Engineering Radix Sort for Strings 中描述的优化对于 URL 数据集来说已经足够了。特别是他们提前读取了 8 个字符并用字符串指针缓存它们;对这些缓存值进行排序会产生足够的熵来解决缓存未命中问题。

对于较长的字符串,请尝试该存储库中的一些基于 LCP 的算法;根据我的经验,URL 数据集大约处于高度优化的基数类型排序和基于 LCP 的算法之间的收支平衡点,这些算法在较长的字符串上渐进地做得更好。

【讨论】:

    【解决方案2】:

    最后我发现三元快速排序效果很好。我在www.larsson.dogma.net/qsufsort.c找到了算法。

    这是我修改后的实现,与 std::sort 的接口类似。在我的机器和数据集上,它比 std::sort 快 40%。

    #include <iterator>
    
    template<class RandIt> static inline void multiway_qsort(RandIt beg, RandIt end, size_t depth = 0, size_t step_len = 6) {
        if(beg + 1 >= end) {
            return;
        }
    
        struct { /* implement bounded comparing */
            inline int operator() (
                    const typename std::iterator_traits<RandIt>::value_type& a,
                    const typename std::iterator_traits<RandIt>::value_type& b, size_t depth, size_t step_len) {
    
                for(size_t i = 0; i < step_len; i++) {
                    if(a[depth + i] == b[depth + i] && a[depth + i] == 0) return 0;
                    if(a[depth + i] <  b[depth + i]) return +1;
                    if(a[depth + i] >  b[depth + i]) return -1;
                }
                return 0;
            }
        } bounded_cmp;
    
        RandIt i = beg;
        RandIt j = beg + std::distance(beg, end) / 2;
        RandIt k = end - 1;
    
        typename std::iterator_traits<RandIt>::value_type key = ( /* median of l,m,r */
                bounded_cmp(*i, *j, depth, step_len) > 0 ?
                (bounded_cmp(*i, *k, depth, step_len) > 0 ? (bounded_cmp(*j, *k, depth, step_len) > 0 ? *j : *k) : *i) :
                (bounded_cmp(*i, *k, depth, step_len) < 0 ? (bounded_cmp(*j, *k, depth, step_len) < 0 ? *j : *k) : *i));
    
        /* 3-way partition */
        for(j = i; j <= k; ++j) {
            switch(bounded_cmp(*j, key, depth, step_len)) {
                case +1: std::iter_swap(i, j); ++i;      break;
                case -1: std::iter_swap(k, j); --k; --j; break;
            }
        }
        ++k;
    
        if(beg + 1 < i) multiway_qsort(beg, i, depth, step_len); /* recursively sort [x > pivot] subset */
        if(end + 1 > k) multiway_qsort(k, end, depth, step_len); /* recursively sort [x < pivot] subset */
    
        /* recursively sort [x == pivot] subset with higher depth */
        if(i < k && (*i)[depth] != 0) {
            multiway_qsort(i, k, depth + step_len, step_len);
        }
        return;
    }
    

    【讨论】:

    • 我喜欢!我以前没有听说过这个。但是它对前缀问题有什么帮助?
    • @tigger 在普通快速排序中,需要 O(nlogn * L) 时间,其中 L 是平均公共前缀长度。在三元快速排序中,我们使用 O(n) 进行分区并移动到更高的深度,移动需要 O(L) 次。所以最差的性能是 O(n * L)。
    • @richselian 我认为你的大 O 分析有问题。左右分区的排序方式与快速排序相同,因为“深度”没有增加。中间分区中的元素可以被认为是在较短的字符串上运行的快速排序,我们看到在实践中可以更快地进行比较,但是比较时间无论如何都被认为是恒定的,除非我们想引入一个平均字符串长度的术语。
    • @JoelNelson,您可以在larsson.dogma.net/ssrev-tr.pdf 获得严格的证明。该算法是 Jesper Larsson 的 qsufsort 的一部分。
    • @richselian 谢谢。我读它的方式,它说它是 O(n logn)。 O(n * L) 显然比 O(n logn) 好,所以如果 O(n * L) 真的是最坏的情况,那将是相当不错的。 +1 突出显示一个非常酷的算法。在这种情况下,它很可能是最快的通用排序。不过,40% 的改进并不意味着渐近改进。
    【解决方案3】:

    如果你在开始快速排序之前计算出向量中的最小值和最大值,那么你总是知道每次调用 partition() 的值的范围,因为分区值要么是最小值,要么是最大值(或者在每个子范围的最小接近最小值/最大值)和包含分区的最小值和最大值是每个子范围的另一端。

    如果子范围的最小值和最大值共享一个公共前缀,那么您可以从公共前缀后面的字符位置开始进行所有分区比较。随着快速排序的进行,范围越来越小,因此它们的公共前缀应该越来越长,并且在比较时忽略它们将节省越来越多的时间。多少,我不知道;您必须对此进行基准测试,看看它是否真的有帮助。

    无论如何,额外的开销是相当小的;一次遍历向量以找到最小和最大字符串,每个字符串花费 1.5 次比较 (*),然后检查每个分区以找到该分区的最大共享前缀;检查相当于比较,它可以从包含前缀的最大共享前缀开始,所以它甚至不是一个完整的字符串比较。


    • 最小/最大算法:一次扫描向量两个元素。对于每一对,首先将它们相互比较,然后将较小的与运行最小值进行比较,将较大的与运行最大值进行比较。结果:两个元素进行 3 次比较,或每个元素进行 1.5 次比较。

    【讨论】:

    • 这有点难以理解,但我相信这是最好的答案。对我来说,这个想法的核心是,一旦你对列表进行了多次分区,你就可以找到相邻分区元素的公共前缀,并且知道它们之间的元素共享该前缀。
    【解决方案4】:

    创建两个组:有前缀的和没有前缀的。对于第一组删除前缀,排序并添加前缀。对于第二组,只需排序。之后将第二组分为前前缀和后前缀。现在连接三个列表(list_2_before、list_1、list_2_after)。

    您可以编写自己的自定义代码,在前缀之后开始比较(即在比较时忽略前缀部分),而不是删除和添加第一个列表的前缀。

    附录:如果您有多个通用前缀,您可以进一步使用它们来加快速度。最好创建一个带有非常常见前缀的浅树并将它们连接起来。

    【讨论】:

    • 这将有助于提高性能。但 URL 可以分成更小的组,例如 http://www.google.com/xxxx 或 http://yahoo.com/xxxx。我们可以进一步改进它吗?
    • 只要前缀数量可控,您就可以进一步改进。对于更一般的情况,我建议使用非常浅的树结构(例如修剪过的 trie )来跟踪各种前缀。
    • 您可以将前缀替换为单个分配的字符,但您将无法维护非前缀字符串的真实排序顺序(可能会有“无前缀”分配的字符)。实际上按字母顺序排序重要吗?
    【解决方案5】:

    我怀疑,当您考虑 URL 的平均长度时,您尝试利用每个 URL 大约 10 个字符的公共前缀所花费的处理时间甚至不会为自己付出代价。

    尝试一个完全标准的排序。如果这还不够快,请考虑并行化或分发完全标准的排序。这是一种行之有效的简单方法。

    【讨论】:

    • 处理固定数量的前缀是一个O(N)的过程;在 O(N logN) 中排序需要 O(N logN) 比较。对于足够大的 N,这当然是个好主意,但问题是 N 必须有多大才能达到收支平衡
    • “处理前缀是一个 O(N) 过程” - 是吗?这取决于你在做什么处理。虽然你可能是对的,理论上可以做一些更聪明的事情的复杂性更低,但我给出了实用的建议。例如:理论上,基数排序是 O(N)(很容易少于 100 个有效的 URL 字符),那么为什么还要寻找其他地方呢?
    【解决方案6】:

    Common Prefixes 似乎自然地暗示了 trie 数据结构可能是有用的。所以想法是构建所有单词的 trie,然后对每个节点进行排序。排序应该是特定节点的子节点驻留在列表中并进行排序。这可以很容易地完成,因为在特定节点上我们只需要对子节点进行排序,因此自然会出现递归解决方案。更多灵感请参见:http://goanna.cs.rmit.edu.au/~jz/fulltext/acsc03sz.pdf

    【讨论】:

    • 我想知道将一个字符串拆分成树节点真的会有帮助。树节点进行更多的非连续访问。我并不是要避免 O(nlogn*LEN) 最坏的情况。我相信快速排序仍然比基于树的解决方案更快。
    猜你喜欢
    • 2012-01-24
    • 2018-05-07
    • 2017-03-19
    • 2011-10-21
    • 2019-05-03
    • 1970-01-01
    • 2020-12-12
    • 2021-02-14
    • 2020-11-13
    相关资源
    最近更新 更多