てくメモ

trivial な notes

【C#】文字列中の特定の文字や文字列をカウントする


【追記】
.NET 8 では、ReadOnlySpan<T>Count(T)Count(ReadOnlySpan<T>)とできる拡張メソッドが追加されました。

StringComparisonが不要の場合、(文字列をAsSpan()して)それらを利用するのが簡潔で速いです。



文字列中の特定の文字(1文字)や文字列をカウントについて、一般的な方法を整理して計測してみる。


文字列中の特定の文字をカウントする場合、LINQはまず挙がる手段。

private string text = "あのイーハトーヴォのすきとおった風、夏でも底に冷たさをもつ青いそら、うつくしい森で飾られたモリーオ市、郊外のぎらぎらひかる草の波。";

public int ByLinqCount() => text.Count(v => v == 'の');


他の手段としては、正規表現やループがある。

// 正規表現 (GeneratedRegex)
public int ByRegexCount()
{
    Regex regex = MyRegex();
    return regex.Count(text);
}

[GeneratedRegex("の")]
private static partial Regex MyRegex();

// ループ
public int ByLoop()
{
    var span = text.AsSpan();
    int count = 0;
    int index;
    while ((index = span.IndexOf('の')) >= 0)
    {
        ++count;
        span = span[(index + 1)..];
    }
    return count;
}


比較してみる。

       Method |      Mean |     Error |    StdDev |   Gen0 | Allocated |
------------- |----------:|----------:|----------:|-------:|----------:|
  ByLinqCount | 535.74 ns | 198.48 ns | 10.879 ns | 0.0095 |      32 B |
 ByRegexCount | 202.17 ns |  76.31 ns |  4.183 ns |      - |         - |
       ByLoop |  34.50 ns |  16.49 ns |  0.904 ns |      - |         - |

相対的には遅いが利用が簡易な LINQ を基本として、必要ならループを選ぶことになりそう。


カウント対象が文字列の場合はどうか。

次のようなガイドがあり、そこでは次のような LINQ が使われている。
文字列での単語の出現回数をカウントする方法 (LINQ) (C#) - C# | Microsoft Learn

private string text = @"Historically, the world of data and the world of objects" +
  @" have not been well integrated. Programmers work in C# or Visual Basic" +
  @" and also in SQL or XQuery. On the one side are concepts such as classes," +
  @" objects, fields, inheritance, and .NET APIs. On the other side" +
  @" are tables, columns, rows, nodes, and separate languages for dealing with" +
  @" them. Data types often require translation between the two worlds; there are" +
  @" different standard functions. Because the object world has no notion of query, a" +
  @" query can only be represented as a string without compile-time type checking or" +
  @" IntelliSense support in the IDE. Transferring data from SQL tables or XML trees to" +
  @" objects in memory is often tedious and error-prone.";

public int ByLinq()
{
    string searchTerm = "data";
    string[] source = text.Split(new char[] { '.', '?', '!', ' ', ';', ':', ',' }, StringSplitOptions.RemoveEmptyEntries);

    var matchQuery = from word in source
                     where word.Equals(searchTerm, StringComparison.InvariantCultureIgnoreCase)
                     select word;

    return matchQuery.Count();
}

文字列のカウントとしては少しリッチな感じがする。
実際、ガイドにも次のように書かれている。

文字列に対する操作が単語のカウントのみである場合は、Matches または IndexOf メソッドの使用を検討してください。


Matchesというか正規表現、そしてIndexOf(ループ)を使うと以下のような感じ。
なお正規表現は今回、GeneratedRegex とそうでないもの両方を記載。

// 正規表現
public int ByRegexCount()
{
    Regex regex = new("data", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
    return regex.Count(text);
}

// 正規表現 (GeneratedRegex)
public int ByGenRegexCount()
{
    Regex regex = MyRegex();
    return regex.Count(text);
}

[GeneratedRegex("data", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
private static partial Regex MyRegex();

// ループ
public int ByLoop()
{
    var span = text.AsSpan();
    var searchSpan = "data".AsSpan();
    int count = 0;
    int index;
    int len = searchSpan.Length;
    while ((index = span.IndexOf(searchSpan, StringComparison.InvariantCultureIgnoreCase)) >= 0)
    {
        ++count;
        span = span[(index + len)..];
    }
    return count;
}


BenchmarkDotNet で比較してみる。

          Method |        Mean |       Error |    StdDev |   Gen0 | Allocated |
---------------- |------------:|------------:|----------:|-------:|----------:|
          ByLinq | 10,160.5 ns | 1,941.43 ns | 106.42 ns | 2.0447 |    6416 B |
    ByRegexCount |  3,580.0 ns |   161.45 ns |   8.85 ns | 0.9308 |    2920 B |
 ByGenRegexCount |    412.9 ns |    12.11 ns |   0.66 ns |      - |         - |
          ByLoop |  2,586.5 ns |   201.40 ns |  11.04 ns |      - |         - |


今回は GeneratedRegex 版が抜群に速かった。

これは利用した例の関係でStringComparison.InvariantCultureIgnoreCase相当の処理を必要としていたのがおそらく影響していて、ループ版では内部的にカルチャ(CultureInfo)での処理に行くが、GeneratedRegex では機械生成コードでより直接的な処理が行われている。

参考:GeneratedRegex 生成コードの一部

// 生成コードの一部。CultureInfo を使わず IndexOfAny('D', 'd') のように判別
if (pos <= inputSpan.Length - 4)
{
    // The pattern begins with a character in the set [Dd].
    // Find the next occurrence. If it can't be found, there's no match.
    ReadOnlySpan<char> span = inputSpan.Slice(pos);
    for (int i = 0; i < span.Length - 3; i++)
    {
        int indexOfPos = span.Slice(i).IndexOfAny('D', 'd');
        if (indexOfPos < 0)
        {
            goto NoMatchFound;
        }
        i += indexOfPos;
        
        // The primary set being searched for was found. 2 more sets will be checked so as
        // to minimize the number of places TryMatchAtCurrentPosition is run unnecessarily.
        // Make sure they fit in the remainder of the input.
        if ((uint)(i + 3) >= (uint)span.Length)
        {
            goto NoMatchFound;
        }
        
        if (((span[i + 1] | 0x20) == 'a') &&
            ((span[i + 3] | 0x20) == 'a'))
        {
            base.runtextpos = pos + i;
            return true;
        }
    }
}


【C#】GeneratedRegex が速すぎる - てくメモ
過去にも記事にしているが、生成コードが頑張ってくれて速いので積極的に利用したい感じはする。


文字列を見つける処理は色んなシチュエーションがあるからなんとも言えないけれど、とりあえずカウントだけ考えるなら簡潔なRegex.Countを基本でいい気がする。