【问题标题】:Chained string replace in .NET.NET 中的链式字符串替换
【发布时间】:2016-06-25 19:22:32
【问题描述】:

给定:base64 字符串

何时:Foo 被调用

然后:Foo 返回输入字符串,并用以下字符替换 ('A' => '_', 'B' => '-', 'C' => '+') 并尽快完成

我比较了几种算法以确定Foo 的哪个版本更快。结果指向普通的string.Replace,这非常令人惊讶。我原以为正则表达式会在编译时受到初步打击,但随后会迅速突破并超越 string.Replace,每次调用 Foo 都会创建三个字符串副本。

我想看看其他人是否可以证实这些发现或解释为什么获胜者的表现优于其他人。

我使用这些算法运行了Foo 100k 次,结果是TimeSpan,在完成执行后在调试版本中使用StopWatch 测量:

00:00:00.0500790 <=== string.Replace [1]
00:00:00.0699696 <=== StringBuilder.Append [2]
00:00:00.0988960 <=== StringBuilder.Replace [3]
00:00:00.7440135 <=== Regex [4]

[1]:

Foo(string input) 
{ 
  return input.Replace("A", "_").Replace("B", "-").Replace("C", "+"); 
}

[2]:

Foo(string input)
{
    var sb = new StringBuilder(input.Length);
    foreach (var x in input)
    {
        if (x == 'A')
        {
            sb.Append('_');
        }
        else if (x == 'B')
        {
            sb.Append('-');
        }
        else if (x == 'C')
        {
            sb.Append('+');
        }
        else
        {
            sb.Append(x);
        }
    }
    return sb.ToString();
}

[3]:

Foo(string input) 
{
  return new StringBuilder(input, input.Length).Replace("A", "_").Replace("B", "-").Replace("C", "+").ToString()
}

[4]:

static readonly Regex charsRegex = new Regex(@"[ABC]", RegexOptions.Compiled);
Foo(string input)
{
  charsRegex.Replace(input, delegate (Match m)
    {
        var value = m.Value;
        if (value == "A")
        {
            return "_";
        }
        else if (value == "B")
        {
            return "-";
        }
        else if (value == "C")
        {
            return "+";
        }

        return value;
    });
}

【问题讨论】:

  • 请注意,您的正则表达式代码指定了 IgnoreCase,而其他解决方案则没有。你试过没有它的正则表达式吗?或者,不区分大小写地尝试其他方法(可能需要 x2 次调用)
  • 是的,这实际上不应该存在。已编辑
  • 那么时间是没有 IgnoreCase 的代码吗?您能否验证其他 sn-ps 是否与您计时的匹配?它可能会让人们猜错方向。
  • 另外,这是在编译时使用还是不使用调试信息和优化?由于没有中间变量,我希望编译器会优化链式替换以使其到位。因此,当您返回一个新字符串时,它不一定会创建一个字符串、替换所有字符串、返回新字符串、替换所有另一个字符串......但只有 ILDump 可以告诉
  • StringBuilder 将比 string.Replace 如果您替换更多字符更快。此外,Visual Studio 2015 中新的 Roslyn 编译器可以比 JIT 编译更好地优化可执行文件。

标签: c# regex string replace stringbuilder


【解决方案1】:

我想建议其他实现。

public /*unsafe*/ static string Foo(string text)
{
    char[] a = text.ToCharArray();
    for(int i = 0; i < a.Length; i++)
        switch(a[i])
        {
        case 'A': a[i] = '_'; break;
        case 'B': a[i] = '-'; break;
        case 'C': a[i] = '+'; break;
        }
    return new string(a);
}

public /*unsafe*/ static string Foo(string text)
{
    char[] a = new char[text.Length];
    for(int i = 0; i < text.Length; i++)
    {
        char c=text[i];
        switch(c)
        {
        case 'A': a[i] = '_'; break;
        case 'B': a[i] = '-'; break;
        case 'C': a[i] = '+'; break;
        default: a[i] = c; break;
        }
    }
    return new string(a);
}

如果您允许不安全代码并取消注释不安全,这可能会比 [1] 更快。

[1] 获胜,因为它都是原生的,尽管数据循环了 3 次 [2] 许多索引检查和当前索引增加 [3] 多个循环通过相同的数据,许多索引检查,但可以就地替换) [4] 最后,因为状态机的开销,并且调用了替换方法。加字符串比较,但不比较字符。

【讨论】:

  • switch 语句可以简化为if ( a[i] &lt;= 'C' &amp;&amp; a[i] &gt;= 'A' ) a[i] = "_-+"[a[i] - 'A'];,因为在这种情况下,switch 语句很可能会被编译为 if else 块。
【解决方案2】:

在我看来,正则表达式要复杂得多。

String.Replace 直接调用 Win32 并可能执行基于指针的字符串操作以防止碎片等(很难确定 - 但它不会在托管代码中执行此操作)。如果我启动 ILSpy,我会看到 RegEx.Replace 进行大量边界检查,然后进行匹配,然后使用 StringBuilder 执行对您的委托的调用结果。

【讨论】:

    【解决方案3】:

    如果我们检查您指定的方法的实现,我们最终不会感到意外。

    Regex.Replace 包括模式匹配和大量字符串连接,这会导致开销。而普通的 String.Replace 直接使用 C++ 实现(comstring.cpp 文件),这是低级的,很可能是非常优化的。

    【讨论】:

      【解决方案4】:

      是的,我也对您的发现感到惊讶...并不是说 Regex 是最慢的,这是意料之中的...而是链接 String.Replaceperformed 和 StringBuilder。 我自己做了一些检查,我比较了与您相同的 [1],但我修改了 [2] 以使其尽可能接近裸露的实现 O(n)。

      [1]

          static string Foo(string input)
          {
              string result = input.Replace("A", "_");
              result = result.Replace("B", "-");
              result = result.Replace("C", "+");
              return result;
          }
      

      [2]

          static string Foo2(string input)
          {
              var length = input.Length;
              var sb = new char[length];
              for (int i = 0; i < length; i++)
              {
                  switch (input[i])
                  {
                      case 'A':
                          sb[i] = '_';
                          break;
                      case 'B':
                          sb[i] = '-';
                          break;
                      case 'C':
                          sb[i] = '+';
                          break;
                      default:
                          sb[i] = input[i];
                          break;
                  }
              }
              return sb.ToString();
          }
      

      我的测试字符串长度超过 700 万个字符(7230872,Lorem Ipsum)。 所以我们可以注意到几点:

      1. 这两种方法的执行速度非常快(55ms vs 51ms aprox),因此其他进程的活动和 CPU 可用性在最终结果中起着重要作用。我将这两种方法执行了 100 次,并将所有执行时间相加,Foo 大约为 5500 毫秒,而 Foo2 大约为 5100 毫秒。
      2. Foo 方法至少遍历整个字符串 3 次(也许更多,不确定)。另一方面, Foo2 方法看起来它只通过字符串一次......但这不是真的......它至少通过它两次......input.Length 也通过它(到计算字符数)。谢谢 PhillipH
      3. 当您尝试替换越来越多的字符时,FooFoo2 之间的差异变得更加明显。使用 15 个字符替换,Foo 在大约 240 毫秒内执行,Foo2 在大约 60 毫秒内执行。

      所以......总之......这里没有魔法,它只是执行得非常快...... :)

      【讨论】:

      • String.Length 不会遍历字符串。其他语言使用需要迭代的空终止符,但嵌入在 .net 字符串中的空值是有效字符,因此无法通过迭代到终止符找到长度。有关此问题的讨论,请参阅 stackoverflow.com/questions/717801/…。因为字符串是不可变的,一旦创建,长度就不能改变,所以不需要动态计算。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2012-04-26
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      相关资源
      最近更新 更多