C#で文字列配列の重複単語をカウントする方法|LINQ GroupByとDictionaryで出現回数を集計

ログやCSVから読み込んだ文字列配列の中で「同じ単語がいくつ出現したか」を数えたい——C#ではとてもよくあるニーズです。しかし素朴なfor文だけで実装すると、カウンターを毎回リセットしてしまい、結局すべて1回と判定されるコードになりがちです。この記事では、LINQのGroupByとDictionaryを使って、文字列配列の「重複単語の出現回数」を正しく・読みやすく・高速に集計する実装パターンを、実務目線で詳しく解説します。

目次

C#で文字列配列の重複単語を数えたいシンプルな例

まず、今回のゴールをはっきりさせておきます。対象となる配列は次のようなものです。


var arr = new[] { "hello", "test", "hello", "raf" };

この配列から、次のような結果を得たいとします。


hello : 2
test  : 1
raf   : 1

つまり、

  • 同じ単語をまとめて
  • 「その単語が何回出てきたか」を数え
  • 見やすい形式で表示する

というのが要件です。一見シンプルですが、カウンターのリセットや、単語ごとの集計構造を用意し忘れると、期待どおりに動きません。

よくあるNGコードとその問題点

典型的なNGパターンは、次のような「単純ループ+カウンター」の組み合わせです。


var arr = new[] { "hello", "test", "hello", "raf" };

var _counter = 0;
foreach (var w in arr)
{
    _counter++;
    Console.WriteLine($"{w} : {_counter}");
    _counter = 0;
}

このコードでは、各単語ごとに毎回カウンターをリセットしているため、常に 1 しか表示されません。また、同じ単語が過去に何回出てきたかという情報をどこにも保持していない点も問題です。

項目NGパターン改善の方向性
集計方法ループ内の単一カウンターだけで数えている単語ごとの独立したカウンター(連想配列)を持つ
状態管理ループ末尾で _counter = 0; を行い毎回リセット単語ごとの出現回数を蓄積していき、ループ外でまとめて出力
設計思想「今見ている要素」だけに注目している「配列全体の統計」を取るという発想で設計する

このような失敗を避けるために、C#では次の2パターンを覚えておくと非常に便利です。

  • 解法A:LINQの GroupBy で一気にグループ化して集計
  • 解法B:Dictionary<TKey, TValue> で逐次カウント

どちらも平均O(n)で動作し、配列のサイズに対して効率よくカウントできます。以降では、それぞれの解法を詳しく見ていきます。

LINQのGroupByでシンプルに書く(解法A)

もっとも短く・読みやすく書けるのは、LINQの GroupBy を使った方法です。まずは基本形から見ていきましょう。

基本形:GroupByでグループ化してCountする

using System.Linq; を追加したうえで、次のように書きます。


using System;
using System.Linq;

var arr = new[] { "hello", "test", "hello", "raf" };

foreach (var g in arr.GroupBy(x =&gt; x))
{
    Console.WriteLine($"{g.Key} : {g.Count()}");
}

このコードのポイントは次の通りです。

  • GroupBy(x => x) で「同じ文字列ごと」に要素をまとめる
  • g.Key が「単語」(例: “hello”)
  • g.Count() が「その単語の出現回数」
  • グループは「配列で最初に出てきた順」に列挙される

つまり、arr に対して「値そのもの」をキーにグループ化し、そのグループごとに件数を数えているイメージです。

出力順をコントロールする:件数降順+単語昇順

集計結果を「出現回数の多い順」に並べたい場面も多いはずです。例えば、テキスト分析で「よく使われている単語トップ10」を出したい場合などです。

その場合は、GroupByの後に OrderByDescending と ThenBy を組み合わせて次のように書きます。


foreach (var g in arr.GroupBy(x =&gt; x)
                     .OrderByDescending(g =&gt; g.Count())
                     .ThenBy(g =&gt; g.Key))
{
    Console.WriteLine($"{g.Key} : {g.Count()}");
}

ここでは、

  • OrderByDescending(g => g.Count()) … 出現回数の多い順
  • ThenBy(g => g.Key) … 回数が同じもの同士は単語の昇順(アルファベット順)

というルールで並べ替えています。これにより、


hello : 2
raf   : 1
test  : 1

のような「頻度順+単語順」の一覧を簡単に作ることができます。

大文字小文字を区別せずに数える

現実のデータでは、"Hello" と "hello" を同じ単語として扱いたい場合が多くあります。そのときは、GroupByに IEqualityComparer<string> を指定してあげればOKです。


foreach (var g in arr.GroupBy(x =&gt; x, StringComparer.OrdinalIgnoreCase))
{
    Console.WriteLine($"{g.Key} : {g.Count()}");
}

ここでは StringComparer.OrdinalIgnoreCase を指定することで、

  • “Hello”
  • “HELLO”
  • “hello”

などをすべて同じグループにまとめてカウントします。

入力配列GroupByのComparer“hello” 系のカウント
{ "Hello", "hello", "HELLO" }指定なし(区別する)それぞれ別グループ → 3種類が1回ずつ
{ "Hello", "hello", "HELLO" }StringComparer.OrdinalIgnoreCase1グループに統合 → “hello” が3回

GroupByのイメージを掴む:かごに商品をまとめる感じ

GroupByは「同じ値のものをひとまとめにする」操作です。スーパーでレジに並ぶ前に、

  • カゴA:カップラーメンを全部入れる
  • カゴB:お菓子を全部入れる
  • カゴC:飲み物を全部入れる

と整理してから、「カップラーメンは何個?」「お菓子は何個?」と数えるようなイメージです。GroupByが「カゴを作って仕分ける」役、Countが「各カゴの中身を数える」役、というわけです。

Dictionaryで逐次カウントする(解法B)

LINQをあまり使いたくない場合や、ループ中に細かい制御を入れたい場合は、Dictionary<string, int> を使った逐次カウントがおすすめです。

基本形:TryGetValueで1パス集計


using System;
using System.Collections.Generic;
using System.Linq;

var arr = new[] { "hello", "test", "hello", "raf" };
var counts = new Dictionary&lt;string, int>(); 
// 大文字小文字無視なら
// var counts = new Dictionary&lt;string, int&gt;(StringComparer.OrdinalIgnoreCase);

foreach (var w in arr)
{
    counts[w] = counts.TryGetValue(w, out var c) ? c + 1 : 1;
}

// 入力で初めて現れた順に出力
foreach (var w in arr.Distinct())
{
    Console.WriteLine($"{w} : {counts[w]}");
}

このコードでは、

  • counts は「単語 → 出現回数」を保持する連想配列
  • ループのたびに TryGetValue で現在の値を取り出し、見つかれば+1、なければ1で初期化
  • 最後に arr.Distinct() で「最初に現れた順」に単語を列挙して出力

という流れで処理しています。

ContainsKey版(より素直な書き方)

三項演算子が読みづらい場合は、ContainsKey を使って次のように書いてもOKです。


var counts = new Dictionary&lt;string, int&gt;();

foreach (var w in arr)
{
    if (counts.ContainsKey(w))
    {
        counts[w]++;
    }
    else
    {
        counts[w] = 1;
    }
}

このほうが初心者にも分かりやすいことが多いです。なお、出力時は先ほどと同じように arr.Distinct() を使うと入力順を保てます。

入力順を維持するためにDistinctを使う理由

Dictionary<TKey, TValue> は、キーの順番を保証しません。そのため、単純に


foreach (var kv in counts)
{
    Console.WriteLine($"{kv.Key} : {kv.Value}");
}

と書くと、出力順は実装依存になります。もし「配列で最初に現れた順」に出したいなら、次のように arr.Distinct() を併用するのが安全です。


foreach (var w in arr.Distinct())
{
    Console.WriteLine($"{w} : {counts[w]}");
}

これにより、「入力の見た目」と「出力の順番」が一致するので、デバッグやレビューもしやすくなります。

GroupByとDictionaryの比較

項目GroupBy(解法A)Dictionary(解法B)
コード量短くてシンプルやや長いが処理内容が明示的
読みやすさLINQに慣れていれば非常に読みやすいC#初心者にも理解しやすい
柔軟性LINQクエリ構文と組み合わせれば柔軟ループ内に細かいロジックを挿入しやすい
パフォーマンス多くの場合Dictionaryと同等超大規模データではわずかに有利なことも
出力順制御GroupByの後にOrderBy/ThenByで制御Dictionary + 別の列挙(Distinctなど)で制御

空白・null・大文字小文字を正規化してから数える

実務のデータでは、

  • 前後に空白がついている("hello " や " hello")
  • nullが混ざっている
  • 全角スペースが紛れ込んでいる

といったケースが頻繁に発生します。このようなデータをそのままカウントすると、

  • “hello”
  • “hello “
  • ” hello”

がすべて別の単語として扱われてしまいます。そこで、「正規化」のステップを事前に挟むと、結果が安定します。

Trim+null対策+空文字除外

次のように、正規化済みの列挙を作ってからGroupByするとスマートです。


var normalized = arr
    .Select(s =&gt; (s ?? string.Empty).Trim()) // nullを空文字にしてからTrim
    .Where(s =&gt; s.Length &gt; 0);               // 空文字は除外

foreach (var g in normalized.GroupBy(x =&gt; x, StringComparer.OrdinalIgnoreCase))
{
    Console.WriteLine($"{g.Key} : {g.Count()}");
}

このコードでは、

  • (s ?? string.Empty) で null を空文字に変換
  • Trim() で前後の空白(全角・半角)を削除
  • Where(s => s.Length > 0) で空文字列を取り除く
  • 最後に StringComparer.OrdinalIgnoreCase で大文字小文字を無視

という流れになっています。

元の値正規化後備考
" hello""hello"先頭の空白が削除される
"hello ""hello"末尾の空白が削除される
null""(空文字)null合体演算子で空文字に変換されたあと、Whereで除外
"HELLO""HELLO"(GroupByで小文字とまとめて扱う)比較時のみ大文字小文字が無視される

計算量とパフォーマンスをざっくり理解する

GroupBy版もDictionary版も、基本的には入力要素数をnとしたとき、平均計算量はO(n)です。つまり、

  • 10件の配列 → 10回程度の処理
  • 10万件の配列 → 10万回程度の処理

と、データ量に比例して処理時間が伸びていきます。実務では、ログ解析や大量のCSV処理などで数十万〜数百万件を扱うことも多いですが、

  • 適切なComparerを指定する
  • 不要な文字列の生成を避ける

といった基本を守れば、どちらの解法でも十分な速度が出ます。厳密なパフォーマンスチューニングが必要になるのは、数千万件〜それ以上の世界になってからです。

実務でよくある応用パターン

「文字列配列の重複カウント」は、実務ではいろいろな形で出てきます。いくつか頻出パターンを挙げておきます。

ログのIPアドレス出現回数を数える

WebサーバーログなどからIPアドレスの一覧を取り出し、それぞれが何回アクセスしてきたかを集計するケースです。


var ipAddresses = LoadAccessLog()
    .Select(x =&gt; x.ClientIp);

foreach (var g in ipAddresses.GroupBy(ip =&gt; ip))
{
    Console.WriteLine($"{g.Key} : {g.Count()}");
}

CSVの特定列(カテゴリ名など)の頻度を集計する

商品カテゴリ、ユーザーの所属部署、エラーコードなど、カテゴリ値を持つ列の出現回数を数えるのも定番です。


var categories = csvRows.Select(row =&gt; row.CategoryName);

var counts = categories
    .Where(c =&gt; !string.IsNullOrWhiteSpace(c))
    .GroupBy(c =&gt; c)
    .OrderByDescending(g =&gt; g.Count());

foreach (var g in counts)
{
    Console.WriteLine($"{g.Key} : {g.Count()}");
}

文章を単語に分割して単語頻度を数える

簡単なテキストマイニングとして、「スペース区切りで単語を分割して頻度を数える」ような処理も同じパターンで書けます。


var text = "hello world hello test world";

var words = text
    .Split(' ', StringSplitOptions.RemoveEmptyEntries)
    .Select(w =&gt; w.Trim());

foreach (var g in words.GroupBy(w =&gt; w, StringComparer.OrdinalIgnoreCase))
{
    Console.WriteLine($"{g.Key} : {g.Count()}");
}

共通メソッドとして切り出しておくと便利

プロジェクト内で何度も同じような集計をするときは、「配列を渡すと Dictionary でカウントして返してくれる」メソッドを1つ用意しておくと便利です。

Dictionaryを返す汎用メソッド


public static Dictionary&lt;string, int&gt; CountWords(
    IEnumerable&lt;string&gt; source,
    bool ignoreCase = true,
    bool trim = true,
    bool skipEmpty = true)
{
    var comparer = ignoreCase
        ? StringComparer.OrdinalIgnoreCase
        : StringComparer.Ordinal;

    var dict = new Dictionary&lt;string, int&gt;(comparer);

    foreach (var raw in source)
    {
        var s = raw ?? string.Empty;

        if (trim)
        {
            s = s.Trim();
        }

        if (skipEmpty &amp;&amp; s.Length == 0)
        {
            continue;
        }

        if (dict.ContainsKey(s))
        {
            dict[s]++;
        }
        else
        {
            dict[s] = 1;
        }
    }

    return dict;
}

このメソッドを使えば、次のように簡潔に呼び出せます。


var arr = new[] { "hello", "test", "hello", "raf" };

var counts = CountWords(arr);

foreach (var kv in counts.OrderByDescending(x =&gt; x.Value))
{
    Console.WriteLine($"{kv.Key} : {kv.Value}");
}

オプション引数で、

  • ignoreCase … 大文字小文字を無視するか
  • trim … 前後の空白をTrimするか
  • skipEmpty … 空文字をスキップするか

といった細かい挙動を切り替えられるようにしておくと、再利用性が高まります。

LINQ版ヘルパーメソッド(GroupByラッパー)

GroupBy派のチームであれば、LINQを活かしたヘルパーメソッドとして、次のように書くこともできます。


public static IEnumerable&lt;(string Word, int Count)&gt; CountWordsLinq(
    IEnumerable&lt;string&gt; source,
    bool ignoreCase = true,
    bool trim = true,
    bool skipEmpty = true)
{
    var q = source.Select(s =&gt; s ?? string.Empty);

    if (trim)
    {
        q = q.Select(s =&gt; s.Trim());
    }

    if (skipEmpty)
    {
        q = q.Where(s =&gt; s.Length &gt; 0);
    }

    var comparer = ignoreCase
        ? StringComparer.OrdinalIgnoreCase
        : StringComparer.Ordinal;

    return q.GroupBy(x =&gt; x, comparer)
            .Select(g =&gt; (Word: g.Key, Count: g.Count()));
}

呼び出し側では、次のように非常にスッキリ書けます。


var arr = new[] { "hello", "test", "hello", "raf" };

foreach (var item in CountWordsLinq(arr)
    .OrderByDescending(x =&gt; x.Count)
    .ThenBy(x =&gt; x.Word))
{
    Console.WriteLine($"{item.Word} : {item.Count}");
}

C# 7以降のタプル構文を使うことで、戻り値の型名を定義する手間を減らしつつ、読みやすいコードを維持できます。

よくある質問(FAQ)と実践的なポイント

Q1. GroupBy と Dictionary はどちらを選ぶべき?

ざっくりとした目安は次の通りです。

状況おすすめ解法理由
短く読みやすいコードを最優先したいGroupByクエリ全体が1〜2行で収まり、意図が伝わりやすい
ループ中にログ出力や複雑な条件分岐を入れたいDictionaryforeachループの中に柔軟な処理を書きやすい
LINQにあまり慣れていないチームDictionary手続き的なコードのほうが理解・レビューしやすい
LINQ中心で書いているプロジェクトGroupBy既存コードとの一貫性を保てる

Q2. 極端にデータが大きいときに気をつけることは?

数百万件〜数千万件といった規模になると、

  • 不要な文字列生成を減らす
  • 必要以上にToList()を挟まない
  • Comparerを適切に選ぶ(文化依存の比較よりOrdinal系が速いことが多い)

といった点が効いてきます。ただし、多くの業務アプリでは、素直なGroupByやDictionary実装で十分間に合うケースがほとんどです。

Q3. 出力順を「アルファベット順」や「辞書順」にしたい

GroupByでもDictionaryでも、最後に OrderBy(x => x.Key) を噛ませれば辞書順に並べ替えられます。


foreach (var g in arr.GroupBy(x =&gt; x)
                     .OrderBy(g =&gt; g.Key))
{
    Console.WriteLine($"{g.Key} : {g.Count()}");
}

Dictionary版の場合は、


foreach (var kv in counts.OrderBy(kv =&gt; kv.Key))
{
    Console.WriteLine($"{kv.Key} : {kv.Value}");
}

のように書けばOKです。並び順が仕様として重要な場合は、「何順か」をコメントに明記しておくと将来の修正時に混乱しません。

Q4. 数字や記号を含む文字列も同じように扱える?

はい、今回紹介した方法は「文字列であればなんでも」扱えるので、

  • ユーザーID
  • タグ("error-500" など)
  • エラーコード

といった値も同じロジックでカウントできます。比較方法(大文字小文字、文化依存かどうか)だけは要件に合わせてComparerを選んでください。

Q5. マルチスレッドで同時にカウントしたい場合は?

今回の例はシングルスレッド前提です。マルチスレッドで同時にカウントしたい場合は、

  • ConcurrentDictionary<string, int> を使う
  • 各スレッドごとにローカルDictionaryで集計し、最後にマージする

といった設計が必要になります。並列処理はスレッドセーフティの考慮が一気に難しくなるので、「本当に並列が必要か」「まずはシングルスレッドで十分か」をよく検討するとよいでしょう。

まとめ:重複単語カウントの定番パターンを押さえておこう

この記事では、C#で文字列配列の「重複単語の出現回数」を数える方法として、

  • LINQのGroupByを使う方法(解法A)
  • Dictionaryで逐次カウントする方法(解法B)

の2つを中心に解説しました。

  • NGコードは「単語ごとのカウント構造がない」「ループのたびにカウンターをリセットする」のが典型的な失敗
  • GroupByは短く読みやすく、頻度順ソートなども簡単に表現できる
  • Dictionaryはループ内に柔軟な処理を挿入でき、初心者にも理解しやすい
  • 空白・null・大文字小文字といったデータの揺れは、事前の正規化で吸収する
  • 共通メソッド化しておけば、ログ解析やCSV処理などさまざまな場面で再利用できる

一度しっかりパターンを身につけておけば、ログ解析・レポート集計・簡易テキスト分析など、多くの場面で「サッと書けるC#コード」として活躍します。ぜひ、自分のプロジェクト向けに小さなヘルパーメソッドを用意しておき、重複単語カウントを日常的な道具として使いこなしてみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次