C#のListをRegexとワイルドカードで高速フィルタ|先頭一致・末尾一致・部分一致と大文字小文字無視の実装ガイド

業務データの検証やログ抽出、ユーザー検索など「文字列の先頭・末尾・部分一致で絞り込みたい」という場面はC#で日常的に発生します。本記事では List<string> を対象に、正規表現(Regex)とワイルドカード(*)を安全かつ高速に扱う実装を、実務でそのまま使える完成コードとともに丁寧に解説します。大文字小文字の無視、ユーザー入力の無害化、パフォーマンス、カルチャ差異、タイムアウトまで網羅します。

目次

結論(先に要点)

  • 末尾一致:ABC$、先頭一致:^ABC、部分一致:ABC。大文字小文字の無視は RegexOptions.IgnoreCase を付与。
  • ユーザー入力は必ず Regex.Escape でエスケープしてからパターンに挿入。
  • ワイルドカード(*)は .* に置換し、^ と $ で全体をアンカーすると「*Some=末尾一致」「Some*=先頭一致」「*Some*=部分一致」を実現。
  • 単純な先頭/末尾/部分一致なら StartsWith/EndsWith/IndexOf(StringComparison.OrdinalIgnoreCase)のほうが速く、読みやすい。複雑になったらRegex。
  • Regexはタイムアウト・事前コンパイル・カルチャ設定を忘れない(RegexOptions.Compiled、RegexOptions.CultureInvariant、new Regex(pattern, options, timeout))。

基本パターンと最短コード

3条件の代表的な正規表現

抽出したい条件代表パターン説明
「ABC」で終わるABC$$ は行末アンカー
「ABC」で始まる^ABC^ は行頭アンカー
「ABC」を含むABC位置指定なし(部分一致)

List<string> をRegexでフィルタする最短コード

using System.Text.RegularExpressions;

// 例データ
var items = new List { "xABC", "ABCy", "xxABCyy", "abc", "Abc", "zzz" };

// 末尾がABC(大文字小文字無視)
var endWith = items.Where(s => Regex.IsMatch(s, "ABC$", RegexOptions.IgnoreCase));

// 先頭がABC
var startWith = items.Where(s => Regex.IsMatch(s, "^ABC", RegexOptions.IgnoreCase));

// 部分一致(含む)
var contains = items.Where(s => Regex.IsMatch(s, "ABC", RegexOptions.IgnoreCase)); 

補足:複数条件をひとつにまとめる場合はグルーピングを忘れずに。^ABC|ABC$|ABC は「ABC」だけで十分(部分一致が他を包含)ですが、明示したいなら (^ABC|ABC$|ABC) のように括弧でまとめます。

ユーザー入力は必ずエスケープする(安全性)

ユーザーが自由にキーワードを入力できる場合、.、+、?、(、) などの特殊文字が意味を持ち、意図しないマッチや性能劣化(ReDoS)を招きます。Regex.Escape で無効化してからパターンに組み込みましょう。

string keyword = userInput; // 例: "A.C(1)+?"
string escaped = Regex.Escape(keyword); // "A\.C\(1\)\+\?"

// 先頭/末尾/部分一致
string patternStart = $"^{escaped}";
string patternEnd   = $"{escaped}$";
string patternAny   = escaped;

// 実行(カルチャ非依存 + 大文字小文字無視 + タイムアウト)
var options = RegexOptions.IgnoreCase | RegexOptions.CultureInvariant;
var timeout = TimeSpan.FromMilliseconds(500);

var startHits = items.Where(s =&gt; Regex.IsMatch(s, patternStart, options, timeout));
var endHits   = items.Where(s =&gt; Regex.IsMatch(s, patternEnd,   options, timeout));
var anyHits   = items.Where(s =&gt; Regex.IsMatch(s, patternAny,   options, timeout));

ポイント:RegexOptions.CultureInvariant を付けるとカルチャ依存の大小比較ゆらぎ(ßなど)を避け、安定した結果になります。

ワイルドカード「*」を扱う:要件どおりの変換

要件では「*Some は末尾一致」「Some* は先頭一致」「*Some* は部分一致」と定義されています。これはワイルドカード * を正規表現の .* に展開し、全体を ^ ... $ で囲むだけで実現できます。

入力例意味変換後の正規表現
*Some末尾が Some^.*Some$
Some*先頭が Some^Some.*$
*Some*どこかに Some^.*Some.*$
string wildcard = userInput; // 例: "*Some"
string body = Regex.Escape(wildcard).Replace(@"\*", ".*");
string pattern = $"^{body}$";

var regex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, TimeSpan.FromSeconds(1));
var hits = items.Where(s =&gt; regex.IsMatch(s));

検証例:データ {"SomeOne","HasSome","Some","something"} に対し、入力が *Some のとき、マッチするのは {"HasSome","Some"} です(SomeOne は末尾ではないので除外)。

「*」を任意位置で使う一般形

もし a*b*c のような任意位置の * を許可するなら、同じ置換で対応できます(* → .*、全体を ^...$ で囲む)。? も使いたい場合は \? を . に置換するだけでOKです。

static Regex BuildWildcardRegex(string input, bool ignoreCase = true)
{
    if (string.IsNullOrEmpty(input) || input == "*")
        return new Regex("^.*$", RegexOptions.Singleline | (ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None));

    string escaped = Regex.Escape(input)
                          .Replace(@"\*", ".*")
                          .Replace(@"\?", ".");
    string pattern = $"^{escaped}$";

    var options = RegexOptions.CultureInvariant | RegexOptions.Singleline |
                  (ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None);

    return new Regex(pattern, options, TimeSpan.FromMilliseconds(500));
}

Regexを使わない高速解法(実務の第一選択)

単純な「始まる/終わる/含む」だけなら、StartsWith/EndsWith/IndexOf のほうが高速で読みやすく、バグも入りにくいです。比較は StringComparison.OrdinalIgnoreCase を使います(カルチャによる大小の差を避け、安定して高速)。

static IEnumerable&lt;string&gt; FilterStart(IEnumerable&lt;string&gt; src, string key) =&gt;
    src.Where(s =&gt; s?.StartsWith(key, StringComparison.OrdinalIgnoreCase) == true);

static IEnumerable&lt;string&gt; FilterEnd(IEnumerable&lt;string&gt; src, string key) =&gt;
    src.Where(s =&gt; s?.EndsWith(key, StringComparison.OrdinalIgnoreCase) == true);

static IEnumerable&lt;string&gt; FilterContains(IEnumerable&lt;string&gt; src, string key) =&gt;
    src.Where(s =&gt; s?.IndexOf(key, StringComparison.OrdinalIgnoreCase) &gt;= 0);

目安:データ量が多い・繰り返し回数が多い場合はまずこの方法。複数キーワードの組合せや否定・後読みなどが必要になってからRegexへ移行すると良いです。

パフォーマンス・保守の勘所

  • 事前コンパイル:同じパターンを何度も使うなら RegexOptions.Compiled または [GeneratedRegex](ソースジェネレータ)を検討。
  • タイムアウト:ユーザー入力から生成したRegexは必ずタイムアウトを設定(new Regex(pattern, options, timeout))。
  • カルチャ:RegexOptions.CultureInvariant/StringComparison.OrdinalIgnoreCase を基本にすると環境差による不一致を防止。
  • キャッシュ:構築済みRegexを ConcurrentDictionary<string, Regex> にキャッシュ。

[GeneratedRegex] を用いた例

using System.Text.RegularExpressions;

partial class MyRegex
{
// .NET 7+ : コンパイル済みRegexを生成(大文字小文字無視・カルチャ非依存)
[GeneratedRegex("ABC$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
public static partial Regex EndWithAbc();
}

// 使い方
var hits = items.Where(s => MyRegex.EndWithAbc().IsMatch(s)); 

改行・複数行の注意点:^/$ と \A/\z

  • 既定では ^ と $ は文字列全体の先頭・末尾を指します。RegexOptions.Multiline を付けると各行の先頭・末尾にマッチします。
  • 文字列全体のアンカーを常に指したいときは \A(先頭)と \z(末尾)を使うと安全です。
// 常に文字列全体の末尾にマッチ(Multilineの影響を受けない)
var endStrict = new Regex(@"ABC\z", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);

完成ユーティリティ:安全・高速・汎用

要件(先頭/末尾/部分一致・ワイルドカード・大文字小文字無視)をひとまとめにした、実務投入可能なユーティリティです。

using System.Collections.Concurrent;
using System.Text.RegularExpressions;

public enum MatchMode { StartsWith, EndsWith, Contains, Wildcard }

public static class TextFilter
{
    private static readonly ConcurrentDictionary&lt;string, Regex&gt; Cache = new();

    public static IEnumerable&lt;string&gt; Filter(IEnumerable&lt;string&gt; source,
                                             string input,
                                             MatchMode mode,
                                             bool ignoreCase = true,
                                             bool preferPlainSearch = true)
    {
        if (source == null) yield break;

        // 空文字 or "*" は全件返す(要件に応じて変更)
        if (string.IsNullOrEmpty(input) || input == "*")
        {
            foreach (var s in source) yield return s;
            yield break;
        }

        // まずは高速な文字列APIを試す(ワイルドカード以外)
        if (preferPlainSearch &amp;&amp; mode != MatchMode.Wildcard)
        {
            var comp = StringComparison.OrdinalIgnoreCase;
            foreach (var s in source)
            {
                if (s == null) continue;
                bool ok =
                    mode == MatchMode.StartsWith ? s.StartsWith(input, comp) :
                    mode == MatchMode.EndsWith   ? s.EndsWith(input, comp)   :
                    s.IndexOf(input, comp) &gt;= 0;
                if (ok) yield return s;
            }
            yield break;
        }

        // Regexにフォールバック
        var regex = GetRegex(input, mode, ignoreCase);
        foreach (var s in source)
        {
            if (s != null &amp;&amp; regex.IsMatch(s))
                yield return s;
        }
    }

    private static Regex GetRegex(string input, MatchMode mode, bool ignoreCase)
    {
        string key = $"{mode}|{ignoreCase}|{input}";
        return Cache.GetOrAdd(key, _ =&gt;
        {
            string pattern;
            if (mode == MatchMode.Wildcard)
            {
                string body = Regex.Escape(input)
                                   .Replace(@"\*", ".*")
                                   .Replace(@"\?", ".");
                pattern = $"^{body}$";
            }
            else
            {
                string e = Regex.Escape(input);
                pattern = mode switch
                {
                    MatchMode.StartsWith =&gt; $"^{e}",
                    MatchMode.EndsWith   =&gt; $"{e}$",
                    _                    =&gt; e, // Contains
                };
            }

            var options = RegexOptions.CultureInvariant |
                          (ignoreCase ? RegexOptions.IgnoreCase : RegexOptions.None);

            return new Regex(pattern, options, TimeSpan.FromMilliseconds(500));
        });
    }
}

使用例

var values = new List<string> { "SomeOne", "HasSome", "Some", "Thing" };

var endSome   = TextFilter.Filter(values, "Some", MatchMode.EndsWith)   .ToList(); // { "HasSome", "Some" }
var startSome = TextFilter.Filter(values, "Some", MatchMode.StartsWith) .ToList(); // { "Some", "SomeOne" }
var anySome   = TextFilter.Filter(values, "Some", MatchMode.Contains)   .ToList(); // { "SomeOne", "HasSome", "Some" }
var wcEnd     = TextFilter.Filter(values, "*Some", MatchMode.Wildcard)  .ToList(); // { "HasSome", "Some" }
var wcStart   = TextFilter.Filter(values, "Some*", MatchMode.Wildcard)  .ToList(); // { "SomeOne", "Some" }
var wcAny     = TextFilter.Filter(values, "*Some*", MatchMode.Wildcard) .ToList(); // { "SomeOne", "HasSome", "Some" } 

サンプルデータでの結果比較(Regex vs 文字列API)

入力条件Regexパターン文字列APIに相当一致例
ABC末尾一致ABC$EndsWith("ABC"){"xABC", "xxABC"}
ABC先頭一致^ABCStartsWith("ABC"){"ABCy", "ABCxx"}
ABC部分一致ABCIndexOf("ABC") >= 0{"xABC", "ABCy", "xxABCyy"}
*Some末尾一致^.*Some$—{"HasSome","Some"}
Some*先頭一致^Some.*$—{"Some","SomeOne"}
*Some*部分一致^.*Some.*$—{"Some","SomeOne","HasSome"}

よくある落とし穴と対策

  • グルーピング忘れ:^ABC|ABC$ は (^ABC)|(ABC$) と同義。複雑な式では ( ) を明示。
  • Multilineの影響:行単位でマッチさせたいときだけ RegexOptions.Multiline。全体末尾を指したいなら \z を使う。
  • カルチャ依存:IgnoreCase はカルチャの影響を受ける。安定性重視なら RegexOptions.CultureInvariant または文字列APIの OrdinalIgnoreCase。
  • ReDoS対策:ユーザー入力由来のパターンに .*.* のような貪欲表現が含まれがち。必ずタイムアウトを設定し、可能なら入力側で桁数制限。
  • 不要なRegex:StartsWith/EndsWith/IndexOf で十分ならRegexは使わない。速度・可読性・保守性が上がる。
  • 空文字の扱い:意図しない全件ヒットを防ぐため、空文字や*だけの入力は「フィルタなし」と明示ルール化。

テストコード(xUnit)で安心運用

using Xunit;

public class TextFilterTests
{
private readonly List values = new() { "SomeOne", "HasSome", "Some", "Thing" };


[Fact]
public void EndsWith_Some()
{
    var result = TextFilter.Filter(values, "Some", MatchMode.EndsWith).ToList();
    Assert.Equal(new[] { "HasSome", "Some" }, result);
}

[Fact]
public void StartsWith_Some()
{
    var result = TextFilter.Filter(values, "Some", MatchMode.StartsWith).ToList();
    Assert.Equal(new[] { "Some", "SomeOne" }, result);
}

[Fact]
public void Contains_Some()
{
    var result = TextFilter.Filter(values, "Some", MatchMode.Contains).ToList();
    Assert.Equal(new[] { "SomeOne", "HasSome", "Some" }, result);
}

[Fact]
public void Wildcard_End_Some()
{
    var result = TextFilter.Filter(values, "*Some", MatchMode.Wildcard).ToList();
    Assert.Equal(new[] { "HasSome", "Some" }, result);
}


} 

運用Tips:現場で役立つ小技

  • 大量データ:LINQの前に AsParallel() を検討(CPU/メモリと相談)。
  • UI連動:入力が1文字未満なら検索抑制、2文字以上で発火などのスロットリング。
  • 前処理:多言語や濁点・半角全角が混在するなら正規化(string.Normalize())。
  • ログ/監査:生成したパターンとオプション、経過時間を記録して原因追跡を容易に。

まとめ

  • 3条件はアンカーで素直に書く:^、$、リテラル。
  • 大文字小文字はオプションで統制:IgnoreCase+CultureInvariant。
  • ユーザー入力は必ずエスケープ:Regex.Escape。
  • ワイルドカードの核:*→.*、全体を^...$。
  • まずは文字列API、必要ならRegex:性能と保守性を両立。
  • タイムアウト・キャッシュ・コンパイル:実運用の安定性を確保。

付録:頻出レシピ集

目的コード断片
大小無視で「含む」items.Where(s => s?.IndexOf("abc", StringComparison.OrdinalIgnoreCase) >= 0)
Regexで「先頭一致」Regex.IsMatch(s, "^" + Regex.Escape(key), RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)
Wildcard一般形new Regex("^" + Regex.Escape(w).Replace(@"\*", ".*") + "$", RegexOptions.IgnoreCase)
Regexタイムアウトnew Regex(pat, opts, TimeSpan.FromMilliseconds(500))
行全体末尾を厳密に@"ABC\z"

この記事を書いた人

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

コメント

コメントする

目次