正規表現の後方参照\1:.NET・Python・PHP・JSで検証

正規表現の後方参照(\1、\k<name>)を.NET・Python・PHP・JS系エンジンで同じ入力のまま実行して検証。重複語や引用符・タグの対応、エンジンごとの構文の違い、バックトラックの時間とタイムアウトの実測。

2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語で書き直したものです。元記事のパターンを .NET 10(System.Text.RegularExpressions)、Python 3.9・3.12 の re、PHP 8.5(PCRE2 10.47)、Dart 3.11(JavaScript の構文に従う RegExp)で、同じ入力のまま実行し直しました。2つの例のマッチ結果はどのエンジンでも元記事どおりでしたが、「2桁の後方参照はグループがなければ構文エラー」という説明は Python にしか当てはまらず、\b を「空白と同じ」とした説明も正確ではありませんでした。エンジンごとの違いとバックトラックのコストの実測を加えています。

「同じ単語が2回続いている箇所」や「開きタグと閉じタグの番号がそろっている箇所」を正規表現で探したい人、Python で使っていたパターンを .NET や PHP に移したら後方参照の書き方で詰まった人向けの記事です。先に結論を書くと、後方参照 \1 は「グループ1と同じパターン」ではなく「グループ1が実際に捕まえた文字列と同じ文字列」に一致します。名前付きグループ、置換文字列、参加しなかったグループの扱いはエンジンごとに違うので、エンジンをまたぐときは必ず確かめ直す必要があります。

後方参照:グループが捕まえた文字列をもう一度探す

丸かっこで囲んだ部分は、マッチした文字列を番号付きのグループに記録します。同じパターンの中で \1、\2 と書くと、そのグループがいま捕まえた文字列そのものをもう一度要求します。元記事の最初の例です。

パターン:\b([a-z]+) \1\b
テキスト:why so so serious

4つのエンジンすべてが so so だけを見つけました。エンジンは why から始めてグループ1に why を記録し、空白の後にもう一度 why が来るかを確かめます。次の単語は so なので失敗し、次の位置へ進みます。最初の so の位置ではグループ1が so、空白の後も so なのでマッチします。

フローチャート:エンジンは「why so so serious」の単語境界から始める。グループ ([a-z]+) が単語をグループ1に記録する。続いて空白が必要で、\1 は次の文字列が記録した単語とまったく同じかを確かめる。「why」の位置では次の単語が「so」なので失敗し、次の位置へ進む。最初の「so」の位置では次も「so」で単語境界が続くので、マッチは「so so」になる。
\b([a-z]+) \1\b が「why so so serious」から「so so」を見つける流れ。グループ1はマッチした文字列そのものを覚え、\1 は同じ文字列にもう一度一致しなければならない(単語なら何でもよいわけではない)。

元記事では \b を「ここでは空白と同じと考えてよい」と説明していましたが、これは正確ではありません。空白にはパターン中のリテラルの空白文字が一致しており、\b は文字を消費しない位置(単語文字と非単語文字の境目)です。\b の役目は、単語の途中にマッチさせないことです。先頭の \b を外すとどうなるかを確かめました。

パターン:([a-z]+) \1
テキスト:this is it
結果:    「is is」 ← this の末尾の「is」と次の単語「is」

4つのエンジンすべてが is is を返しました。重複語ではないのに引っかかる誤検出です。実務で重複語を探すときは、両側の \b と大文字小文字の無視を組み合わせます。

パターン:\b(\w+)\s+\1\b  (大文字小文字を無視)
テキスト:The the cat sat on the the mat
結果:    「The the」「the the」

大文字小文字を無視するオプションを付けると、後方参照の比較も大文字小文字を無視するため、The the も見つかりました(4エンジンとも同じ)。英文に近い約100万文字のテキストにこのパターンを適用すると、.NET 10 で 65〜72ms、重複は22,720件でした。原稿チェックのスクリプトに入れても負担にならない速さです。

元記事の見出しタグの例を検証し直す

2つ目の例は、<H1>〜<H6> の開きタグと閉じタグの数字がそろっているものだけを探すパターンです。

パターン:<H([1-6])>.*?</H\1>
テキスト:<H1>Text</H1>
          <H2>Text</H3>
          <H3>Text</H3><H4>Text</H4>
結果:    <H1>Text</H1> / <H3>Text</H3> / <H4>Text</H4>

元記事の説明どおり、<H2>Text</H3> だけが除外されました。ただし元記事は3行目全体を1つのマッチとして強調していましたが、実際には <H3>…</H3> と <H4>…</H4> は別々の2つのマッチです。

元記事は「この例では貪欲な .* を使っても問題ない」とも書いていました。このテキストではそのとおりですが、同じ行に同じレベルの見出しが2つあると結果が変わります。

パターン:<H([1-6])>.*</H\1>
テキスト:<H1>a</H1> <H1>b</H1>
結果:    「<H1>a</H1> <H1>b</H1>」 ← 1つにまとまってしまう

.* が行末まで進んでから、最後の </H1> までしか戻らないためです。怠惰な .*? を使うのが正解で、小文字の <h1> も対象にするなら大文字小文字を無視するオプションが必要です。実際の HTML には属性・入れ子・コメントがあるので、正規表現はログや簡単なテンプレートの点検にとどめ、本物の HTML はパーサーで扱いましょう。

実用例:対になった引用符を探す

後方参照がいちばん役立つのは「開いた記号と同じ記号で閉じる」場面です。シングルクォートとダブルクォートが混ざった文から、正しく対になった文字列だけを取り出します。

パターン:(["']).*?\1
テキスト:say "it's fine" and 'ok'
結果:    "it's fine" / 'ok'

グループ1が " を捕まえているので、途中の '(it's のアポストロフィ)では閉じず、次の " まで進みます。["'].*?["'] と書いていたら "it' で終わっていたはずです。4エンジンとも結果は同じでした。日本語の文章なら 「 と 」 のように開きと閉じが別の文字なので、後方参照ではなく 「[^」]*」 のような文字クラスのほうが向いています。

エンジンごとの違い(実際に動かした結果)

考え方は共通でも、構文と境界での挙動はエンジンごとに違います。テスト環境に Node.js がなかったため、JavaScript の挙動は ECMAScript の正規表現構文を実装している Dart の RegExp で確認しました(先読み・後読みの記事と同じ方法です)。

名前付きグループ。 テキスト x 'ok' y から 'ok' を探す4通りの書き方を試しました。

パターン .NET Python 3.9 / 3.12 PHP (PCRE2) Dart (JS 構文)
(?<q>["'])\w+\k<q> マッチ エラー unknown extension ?<q マッチ マッチ
(?P<q>["'])\w+(?P=q) エラー Unrecognized grouping construct マッチ マッチ エラー Invalid group
(?<q>…) と (?P=q) の混在 エラー エラー マッチ エラー
(?P<q>…) と \k<q> の混在 エラー エラー bad escape \k マッチ エラー

PCRE2 は4通りすべてを受け付けるので、PHP で動いていたパターンがほかの環境で壊れる、ということが起きます。Python から .NET に移すときは (?P<name> を (?<name> に、(?P=name) を \k<name> に書き換えます。

置換文字列。 \b(?<w>\w+) \k<w>\b(Python は (?P<w>…)(?P=w))で so so serious の重複語を1つにまとめ、置換文字列だけを変えました。

置換文字列 .NET Regex.Replace Python re.sub PHP preg_replace
$1 so serious $1 serious(文字どおり) so serious
\1 \1 serious(文字どおり) so serious so serious
${1} so serious 文字どおり so serious
\g<1> 文字どおり so serious 文字どおり
${w}(名前) so serious 文字どおり 文字どおり
\g<w>(名前) 文字どおり so serious 文字どおり

怖いのは、間違った書き方がエラーにならず文字どおり入ることです。出力を目で確認しないと、$1 serious のような文字列がそのまま保存されます。Dart の replaceAll には置換文字列の構文自体がなく(すべて文字どおり)、グループを使うには replaceAllMapped でコールバックを渡します。ブラウザの JavaScript の String.prototype.replace は $1 と $<name> を使いますが、今回は実行できていません。

参加しなかったグループ。 (a)?b\1 をテキスト b に適用すると、グループ1は何も捕まえていない状態になります。

エンジン (a)?b\1 → b 選択 (?:(a)|b)\1 → b
.NET / Python / PHP 不一致 不一致
Dart (JS 構文) b b

JavaScript 系のエンジンは、参加しなかったグループへの後方参照を空文字列とみなして成功させます。省略可能なグループを後方参照するパターンは、エンジンを移すと意味が変わる可能性があります。

存在しないグループと2桁の番号。 元記事は「2桁の後方参照は、その番号のグループがあれば後方参照、なければ構文解析エラー」と説明していました。グループが1つしかない (a)\10 で確かめると、エラーになったのは Python(invalid group reference 10)だけで、.NET・PHP・Dart は \10 を8進数のエスケープ(バックスペース、U+0008)と解釈し、a +バックスペースにマッチしました。(a)\2 は .NET(Reference to undefined group number 2)・Python・PHP がエラーで、Dart はエラーを出さずに何も見つけませんでした(unicode: true ではエラー)。グループが10個以上あるときは、.NET の \k<10> や名前付きグループのような曖昧さのない書き方が安全です。

バックトラックの実測とタイムアウト

後方参照を含むと、.NET の線形時間エンジン RegexOptions.NonBacktracking は使えません。.NET 10 で試した結果です。

NotSupportedException: RegexOptions.NonBacktracking is not supported in conjunction
with expressions containing: 'backreference (\ number)'.

そのため後方参照のパターンは常にバックトラック型エンジンで動き、形が悪いと入力の長さに応じて時間が爆発します。「最初の単語と最後の単語が同じ行」を探す2つのパターンを比べました。先に、どちらも so so、so it so、the cat ran the にマッチし、sox so、so also にはマッチしないことを確認しています。

var timeout = TimeSpan.FromSeconds(2);
var bad  = new Regex(@"^(\w+)\b(?:\s*\w+)*\s+\1$", RegexOptions.None, timeout);
var good = new Regex(@"^(\w+)\b.*\s\1$",           RegexOptions.None, timeout);
string input = "the " + new string('a', n) + "!";   // 末尾の ! があるので必ず不一致
// RegexMatchTimeoutException を捕まえて TIMEOUT と記録

bad は (?:\s*\w+)* の中の \s* が空でもよいため、aaaa… を何個の「単語」に区切るかの組み合わせを全部試してから諦めます。.NET 10、Release、Apple Silicon Mac で2回実行した結果です。

n(文字数) bad 1回目 bad 2回目 good
16 5.7 ms 4.8 ms 0.01 ms
20 77.1 ms 75.1 ms 0.01 ms 未満
22 302.7 ms 302.8 ms 0.01 ms
24 1,241.4 ms 1,215.5 ms 0.01〜0.05 ms
26 以上 タイムアウト(2,000 ms) タイムアウト 0.01 ms
1,000 タイムアウト タイムアウト 0.03 ms
入力の長さ n と照合時間の棒グラフ:n = 16 は 4.8ms、n = 20 は 75ms、n = 22 は 303ms、n = 24 は 1,215ms、n = 26 は 2,000ms のタイムアウト。破線が 2,000ms のタイムアウトを示す。
^(\w+)\b(?:\s*\w+)*\s+\1$ が「the 」+ n 文字+「!」で失敗するまでの時間(.NET 10、Release、Apple Silicon Mac)。2文字増えるごとに約4倍になり、n = 26 から2秒のタイムアウトに達した。修正版 ^(\w+)\b.*\s\1$ はどの n でも 0.05ms 以下。

2文字増えるごとに約4倍になりました。Python 3.9 の re も同じ入力で 4.2 / 68.5 / 279.8 / 1,121.3 ms とほぼ同じ曲線を描きましたが、re にはタイムアウトの指定がないので、入力が長ければそのまま止まってしまいます。ユーザーが入力したテキストに後方参照のパターンを使うなら、.NET ではコンストラクターに matchTimeout を渡して RegexMatchTimeoutException を処理しましょう。根本的な対策は、bad のように量指定子の中に量指定子を入れ、その区切りを省略可能にする形を避けることです。

まとめと検証方法

やりたいこと パターン
重複語 \b(\w+)\s+\1\b+大文字小文字を無視
対になった引用符 (["']).*?\1
番号のそろった開き・閉じ見出しタグ <H([1-6])>.*?</H\1>(怠惰な量指定子)
名前で後方参照 .NET・JS (?<n>…)\k<n> / Python (?P<n>…)(?P=n) / PHP はどちらも可
置換でグループを使う .NET $1・${n} / Python \1・\g<n> / PHP $1・\1

検証したエンジンは .NET 10.0.14(Regex.Matches、Regex.Replace)、Python 3.9.6 と 3.12.14(re.finditer、re.sub)、PHP 8.5.6(preg_match_all、preg_replace、PCRE2 10.47)、Dart 3.11.5(RegExp.allMatches、通常モードと unicode: true)です。パターンと入力は1つの JSON ファイルにまとめ、すべてのエンジンが同じファイルを読むようにしました。正規表現に唯一の標準はないので、コードが実際に動くエンジンで確かめるのがいちばん確実です。

確認環境:macOS(Apple Silicon Mac)、2026年11月。

よくある質問

置換文字列では $1 と \1 のどちらを使う?

エンジンによります。.NET の Regex.Replace は $1・${1}・${name} を使い、\1 は文字どおり残りました。Python の re.sub は \1・\g<1>・\g<name> を使い、$1 は文字どおり残りました。PHP の preg_replace は $1・\1・${1} がすべて動きましたが、名前(${w})は動きませんでした。

マッチに参加しなかったグループを後方参照するとどうなる?

(a)?b\1 を「b」に適用すると、.NET・Python・PHP(PCRE2)は不一致でしたが、JavaScript の仕様に従う Dart の RegExp は「b」にマッチしました。JavaScript 系は参加しなかったグループへの後方参照を空文字列として扱うためです。

後方参照を含むパターンに RegexOptions.NonBacktracking は使える?

使えません。.NET 10 で \b(\w+) \1\b を NonBacktracking 付きで作ると NotSupportedException が発生しました。後方参照はバックトラック型エンジンでしか動かないので、信頼できない入力には Regex のコンストラクターで matchTimeout を指定します。

  • #正規表現
  • #後方参照
  • #Python
  • #.NET
  • #PHP
  • #JavaScript

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑