【问题标题】:Replacing lots of substrings in big strings替换大字符串中的大量子字符串
【发布时间】:2017-03-16 04:03:26
【问题描述】:

我们模块的一个性能高度依赖于我们如何替换字符串中的子字符串。

我们形成一个“替换映射”,它可以包含超过 3500 个字符串对,然后我们将其与 StringUtils.replaceEach(text, searchList, replacementList) 一起应用于大字符串(几个 MB)。

键和值都是唯一的,并且在大多数情况下具有相同的字符长度(但这不是我们可以依赖的)。

有没有比StringUtils.replaceEach() 更复杂的方法来完成我的任务?对于replaceEach() 解决的简单替换,这可能有点过头了,但在我的“重”情况下要快得多。

【问题讨论】:

  • 什么是“StringUtils”?如果你使用库,你能显示实现或依赖吗?
  • @talex StringUtils 是(非常方便的)Apache Commons Lang 库中的一个实用程序类
  • 我不知道StringUtils有什么替代品,但是即使你找到了替代品,它也必须处理类似的逻辑,所以你最终需要对两者进行基准测试并从中挑选出最好的。
  • StringUtils 实现 replaceEach 不打算用于大量对。可以实现更复杂但更快的算法,但我不知道在哪里可以找到实现。
  • @mRcSchwering R?珀尔?您是否可能错误地输入了错误的标签?

标签: java string performance replace


【解决方案1】:

您可以使用正则表达式引擎,有效地将您的 keys 与输入字符串匹配,并替换它们。

首先,使用交替运算符连接所有键,如下所示:

var keys = "keyA|keyB|keyC";

接下来,编译一个pattern

Pattern pattern = Pattern.compile("(" + keys + ")")

根据您的输入文本创建一个匹配器:

Matcher matcher= pattern.matcher(text);

现在,在循环中应用您的正则表达式,在您的 text 中查找所有 keys,并使用 appendReplacement(这是一个“ inline" 字符串替换方法),将它们全部替换为对应的

StringBuffer sb = new StringBuffer();
while (matcher.find()) {
    matcher.appendReplacement(sb,dictionary.get(matcher.group(0)));
}
matcher.appendTail(sb);

给你。

请注意,这乍一看可能有点幼稚,但实际上正则表达式引擎已针对手头的任务进行了大量优化,并且由于 Java 正则表达式实现还允许“内联”替换,所以一切都很好。

我做了一个小基准测试,通过应用一个颜色名称列表(约 200 种不同的颜色名称),如 /usr/share/X11/rgb.txt 中所定义的,针对 " Fyodor Dostoyevsky 的《罪与罚》,我从古腾堡项目(约 1MB 大小)下载,使用了所描述的技术, 它解决了

比 StringUtils.replaceEach 快 12 倍 - 900ms vs 10700ms

对于后者(不计入 Pattern 编译时间)。

附:如果您的键可能包含对 regexp 不安全的字符,例如 .^$(),则应在将它们添加到您的模式之前使用 Pattern.quote()。 p>

旁注:

此方法将替换 keys,按顺序,它们出现在模式列表中,例如"a=>1|b=>2|aa=>3" 当应用于 "welcome to bazaar" 将导致 "welcome to b1z11r",而不是 "welcome to b1z3r",如果你想要最长的匹配,你应该在将它们添加到模式之前按字典顺序对键进行排序(即 "b|aa|a" )。它也适用于您原来的 StringUtils.replaceEach() 方法。

更新:

上面的方法应该可以很好地解决问题,正如原始问题中所表述的那样,即当替换映射的大小与输入数据大小相比(相对)较小时。

如果您有一本很长的字典,适用于短文本, StringUtils.replaceEach() 所做的线性搜索可以比它更快。

我做了一个额外的基准来说明,通过应用 10000 个随机选择的单词(+4 个字符长)的字典:

cat /usr/share/dict/words | grep -E "^.{4,}$" | shuf | head -10000

反对:1024,2048,4096,8192,16384,32768,65536,131072,262144524288 字符长摘自同一个“罪与罚” " 文本。

结果如下:

text    Ta(ms)  Tb(ms)  Ta/Tb(speed up)
---------------------------------------
1024    99      240     0.4125
2048    43      294     0.1462585
4096    113     721     0.1567267
8192    128     1329    0.0963130
16384   320     2230    0.1434977
32768   2052    3708    0.5533981
65536   6811    6650    1.0242106
131072  32422   12663   2.5603728
262144  150655  23011   6.5470862
524288  614634  29874   20.574211
  • Ta - StringUtils.replaceEach() 时间
  • Tb - matcher.appendReplacement() 时间

注意模式字符串长度为135537字节(所有10000个键连接)

【讨论】:

  • 这很有趣,我将根据我的数据集进行尝试。
【解决方案2】:

虽然@zeppelin 提出的 appendReplacement 解决方案在“最重的数据”上速度惊人,但结果却是更大地图的噩梦。

到目前为止,最好的解决方案是我们所拥有的 (StringUtils.replaceEach) 和建议的组合:

protected BackReplacer createBackReplacer(Map<ReplacementKey, String> replacementMap) {
        if (replacementMap.isEmpty()) {
            return new BackReplacer() {
                @Override
                public String backReplace(String str) {
                    return str;
                }
            };
        }

        if (replacementMap.size() > MAX_SIZE_FOR_REGEX) {
            final String[] searchStrings = new String[replacementMap.size()];
            final String[] replacementStrings = new String[replacementMap.size()];

            int counter = 0;
            for (Map.Entry<ReplacementKey, String> replacementEntry : replacementMap.entrySet()) {
                searchStrings[counter] = replacementEntry.getValue();
                replacementStrings[counter] = replacementEntry.getKey().getValue();
                counter++;
            }

            return new BackReplacer() {
                @Override
                public String backReplace(String str) {
                    return StringUtils.replaceEach(str, searchStrings, replacementStrings);
                }
            };
        }

        final Map<String, String> replacements = new HashMap<>();
        StringBuilder patternBuilder = new StringBuilder();

        patternBuilder.append('(');
        for (Map.Entry<ReplacementKey, String> entry : replacementMap.entrySet()) {
            replacements.put(entry.getValue(), entry.getKey().getValue());
            patternBuilder.append(entry.getValue()).append('|');
        }

        patternBuilder.setLength(patternBuilder.length() - 1);
        patternBuilder.append(')');

        final Pattern pattern = Pattern.compile(patternBuilder.toString());

        return new BackReplacer() {
            @Override
            public String backReplace(String str) {
                if (str.isEmpty()) {
                    return str;
                }

                StringBuffer sb = new StringBuffer(str.length());

                Matcher matcher = pattern.matcher(str);
                while (matcher.find()) {
                    matcher.appendReplacement(sb, replacements.get(matcher.group(0)));
                }
                matcher.appendTail(sb);

                return sb.toString();
            }
        };
    }

StringUtils 算法(MAX_SIZE_FOR_REGEX=0):

type=TIMER, name=*.run, count=8127, min=4.239809, max=4235197.925261, 平均值=645.736554,标准差=47197.97968925558,持续时间单位=毫秒

追加替换算法(MAX_SIZE_FOR_REGEX=1000000):

type=TIMER, name=*.run, count=8155, min=4.374516, max=7806145.439165999, 平均值=1145.757953,标准差=86668.38562815856,持续时间单位=毫秒

混合解决方案(MAX_SIZE_FOR_REGEX=5000):

type=TIMER, name=*.run, count=8155, min=3.5862789999999998, max=376242.25076799997, mean=389.68986564688714, stddev=11733.9997814448, duration_unit=毫秒

我们的数据:

type=HISTOGRAM, name=initialValueLength, count=569549, min=0, max=6352327, mean=6268.940661478599, stddev=198123.040651236, median=12.0, p75=16.0, p95=32.0, p98=854.0, p99=1014.5600000000013, p999=6168541.008000023
type=HISTOGRAM, name=replacementMap.size, count=8155, min=0, max=65008, mean=73.46108949416342, stddev=2027.471388983965, median=4.0, p75=7.0, p95=27.549999999999955, p98=55.41999999999996, p99=210.10000000000036, p999=63138.68900000023

此更改将以前解决方案中 StringUtils.replaceEach 花费的时间减半,并使我们的模块(主要是 IO 绑定)的性能提升了 25%。

【讨论】:

  • 看起来您的第一个测试应用到的数据样本少于后两个测试:“count=8127”与“count=8155”/“count=8155”。这是故意的吗?
  • 我们的测试套件正在增长,不幸的是我没有太多时间重新运行所有算法,唯一的区别是算法,但 appendReplace 和混合解决方案已在干净的环境中与任何其他更改进行了比较。
  • 另外,看起来您的数据并没有真正遵循模式,如您原来的问题所述:“3500+ 个字符串对应用于大字符串(几 MB)”(即替换字典的大小相对较小与它所应用的文本相比)相反,您的数据字符串的平均大小仅为 6268 字节(除非我误读了统计信息),而您的替换地图则有 65008 个元素。
  • 当您的替换字典/模式也比它所应用的文本大得多时,通过数据字符串的线性搜索将比正则表达式匹配更快,这几乎是意料之中的。这使得这是一个与您最初询问的问题完全不同的问题。
  • 另外,我相信你关于匹配速度的假设是地图大小的函数(“......更大地图的噩梦......”)并不完全准确,它不是 F(M) (其中 M 是地图大小),而是 F(T/M) (其中 T - 文本大小,M 是地图大小(即相对于地图大小/图案长度)到输入文本大小),因此您可能应该基于此选择匹配算法,而不仅仅是替换地图大小,以获得最佳性能。
【解决方案3】:

该算法的缓慢部分是找到所有匹配项。如果以一种巧妙的方式进行替换(即在临时 char 缓冲区中,每个字符最多只移动一次),则替换很简单。

因此,您的问题实际上简化为“多字符串搜索”,这已经是一个经过充分研究的问题。您可以找到对 in this question 方法的一个很好的总结 - 但只有一行总结是“grep 做得很好”。

Zeppelin 已经为此展示了一个合理的循环 - appendReplacement 行为确保您不会不必要地转移事物(这会将其降级为 O(n))。

【讨论】:

    【解决方案4】:

    处理这种情况的最佳方法:将源字符串预编译成代码。 扫描每个源字符串以查找替换键;使用函数将字符串分解为一系列代码段,以将关键结果插入流中。例如:以下源字符串:

    The quick $brown $fox jumped over the $lazy dog.
    

    变成

    public StringBuilder quickBrown(Map<String, String> dict) {
      StringBuilder sb = new StringBuilder();
      sb.append("The quick ");
      sb.append(dict.getOrElse("$brown", "brown"));
      sb.append(" ");
      sb.append(dict.getOrElse("$fox", "fox"));
      sb.append(" jumped over the ");
      sb.append(dict.getOrElse("$lazy", "lazy");
      sb.append(" dog.");
      return sb;
    }
    

    然后,您使用要替换的映射字典调用与特定字符串对应的方法。

    请注意,“扫描”和“翻译”是指使用程序生成 Java 代码,然后根据需要动态加载已编译的类文件。

    【讨论】:

    • 如果目标是用不同的字典重复替换成同一个字符串,这似乎是合理的,但我的假设是替换字符串也会有所不同。因此,您将花费所有时间生成和编译替换代码(不是 OP 说替换是在至少几 MB 的字符串上执行的)。
    • 当然,如果key是不断变化的,更合适的策略是实现Aho-Corasick模式匹配替换算法。
    • 我问过他关心的 OP,但我的假设是文档多于替换集。
    【解决方案5】:

    StringUtils 正在使用O(n * m) 算法(对于要替换的每个单词,在输入中进行替换)。当m(要替换的字数)很小时,这实际上是O(n)(输入的大小)。

    但是,如果要检查“大量”替换,您最好处理输入的每个单词,这将在O(n) 时间完成。

    Map<String, String> subs = new HashMap<>(); // populated
    String replaced = Arrays.stream(input.split("\\b")) // O(n)
        .map(w -> subs.getOrDefault(w, w)) // O(1)
        .collect(Collectors.joining("")); // O(n)
    

    在单词边界上拆分不仅可以保留空格(通过不消耗输入),而且使代码相当简单。

    【讨论】:

    • 预播subs 查找地图将是O(m),所以这仍然是O(n + m),对吧?
    • 只有在被替换的字符串是完整的单词时才有效,但 OP 只提到 substrings
    【解决方案6】:

    首先 - 如果您在谈论优化,请发布您的分析结果。它是关于应该优化什么的唯一可靠信息来源(请参阅the Third Rule of Optimization)。

    如果您确定字符串操作确实花费了最多时间,那么需要记住两点。

    首先,Java 字符串是不可变的。每次调用替换方法时,您都会创建一个新字符串,在您的情况下,这很可能意味着大量内存分配。多年来,Java 使用它变得更好,不过,如果你可以跳过它,那就去做吧。我已经检查过,StringUtils.replaceEach 确实使用了缓冲区,并且应该相对内存效率更高。此外,特别是使用第二个注释中的自定义搜索算法,您可以实施自定义解决方案进行替换。自定义解决方案可能包括创建自己的字符缓冲区以进行有效替换,使用 StringBuilder/StringBuffer 进行替换(您必须跟踪替换的长度,因为在每次搜索 StringBuffer 之前调用 .toString()将与手动替换字符串一样低效)。

    其次,search algorithm 本身。我不知道 Apache 的StringUtils 使用的是哪个,但 Java 的默认实现并不是最优的。你可以使用separate library for searching

    【讨论】:

    • 也许不是一个很好的答案,但是一个答案。不知道谁会反对。
    • 由于StringUtils 已经被使用,我不明白解释字符串的不变性的意义。
    • @Matthias 我没有检查 StringUtils 的实现。我现在做了。它是节省内存的,虽然它确实一个一个地复制字母......
    猜你喜欢
    • 2010-12-27
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-04-03
    • 2013-07-23
    相关资源
    最近更新 更多