正規表現の量指定子:貪欲・最短・強欲とバックトラック実測

量指定子{n}・{n,m}・*・+・?を.NET・Python・PHP・Dartで実測。{3}が4桁の中にもマッチする理由、貪欲/最短/強欲の違い、a++の対応状況、(a+)+bのバックトラック爆発の時間まで。

2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語の読者向けに書き直したものです。元記事の5つの例を .NET 10、Python 3.9・3.12 の re、PHP 8.5(PCRE2 10.47)、Dart 3.11(JavaScript の規則に従う RegExp)で、同じ入力のまま実行し直しました。マッチ結果はどのエンジンでも同じでしたが、説明が2か所誤っていました。{3} は4桁の数字を除外せず、{2,4} も 12345 を除外しません。アンカーがなければ長い数字の内側でマッチを見つけます。強欲な量指定子、アトミックグループ、バックトラック時間の実測は新しく加えた内容です。

\d{3} と書いたのに想定外の値が通ってしまった人や、正規表現1つでリクエスト処理が固まった経験がある人向けの記事です。量指定子は直前の要素を何回繰り返してよいかを決めます。結果を左右するのは2つで、回数({n}、{n,m}、*、+、?)とモード(貪欲・最短・強欲)です。回数の規則はエンジン間でほぼ共通ですが、モードはそうではなく、組み合わせを誤ると正規表現の呼び出し1回に数秒かかります。

*・+・? をメタ文字として初めて見る場合は、先にメタ文字の記事をどうぞ。「同じ文字の連続」を探すのは量指定子とは別の技法なので、同じ文字の連続を探す記事で扱います。

回数指定 {n}・{n,}・{n,m} を再確認

量指定子 意味
{n} ちょうど n 回
{n,} n 回以上
{n,m} n 回以上 m 回以下

* は {0,}、+ は {1,}、? は {0,1} と同じです。元記事の例と、全エンジンの結果です。

パターン:[0-9]{3}       テキスト:12 / 123 / 1234 / 45678(1行に1つ)
結果  :123、123、456

パターン:\d{3,}\.\d+    テキスト:123.456 / 12.3456 / 1.23456 / 1234.56 / 12345.6
結果  :123.456、1234.56、12345.6

パターン:\d{2,4}        テキスト:1 / 12 / 123 / 1234 / 12345
結果  :12、123、1234、1234

元記事は「[0-9]{3} は4桁なら一致しない」「[0-9]{2,4} は 12345 に一致しない」と書いていましたが、結果はそうなっていません。エンジンはパターンが当てはまる位置を探し、123 は 1234 の中に当てはまります。量指定子はその要素が消費する文字数を制限するだけで、前後に何があるかは見ません。「数字全体がその桁数」という意味にしたいなら、境界を付けます。

\b[0-9]{3}\b             → 123 だけ
^\d{2,4}$(複数行モード)  → 12、123、1234

郵便番号の「3桁-4桁」のように桁数そのものが規則の入力では、この違いがそのままバグになります。\d{3}-\d{4} だけだと 1234-56789 の中の 234-5678 にもマッチしてしまうので、^\d{3}-\d{4}$ と書きます。

量指定子がかかるのは直前の1要素だけです。ab+ は b だけを繰り返すので、abab abbb からは ab、ab、abbb が返りました。長いかたまりを繰り返すなら (ab)+ とまとめます。こちらは abab、ab でした。グループはグループの記事で詳しく扱います。

境界ケースも2つ確かめました。前後を逆にした a{3,1} は全エンジンでエラーです。

.NET   : Invalid pattern 'a{3,1}' at offset 6. Illegal {x,y} with x > y.
Python : min repeat greater than max repeat at position 2
PHP    : Compilation failed: numbers out of order in {} quantifier at offset 5
Dart   : FormatException: numbers out of order in {} quantifier.

最小値を省いた {,3} は移植できません。aaaa a{,3} に使うと、Python(3.9・3.12)と PHP は {0,3} と読んで aaa、a と複数の空マッチを返し、.NET と Dart は文字どおりに読んで a{,3} という文字列にマッチしました。Dart の unicode: true はエラーです。{0,3} と書きましょう。

貪欲な量指定子と最短一致

普通に書いた量指定子は貪欲(greedy)で、できるだけ多く取り、後ろのパターンが失敗したときだけ1文字ずつ返します。後ろに ? を付けると最短一致(lazy)になり、最小限だけ取って必要なときだけ伸ばします。元記事の例がいちばん分かりやすいままです。

テキスト:       <b>BOLD!</b><hr><b>BOLD!</b>
<b>.*</b>      → マッチ1件:行全体
<b>.*?</b>     → マッチ2件:<b>BOLD!</b>、<b>BOLD!</b>
<b>[^<]*</b>   → マッチ2件(最短一致と同じ)

全エンジンで同じでした。実務では3つ目のほうがよいことが多いです。中に入ってよい文字を否定クラスで決めておけば、戻る余地そのものがありません。最短一致にも意外な結果があります。\d{2,4}? を 12345 に使うと 12、34、\d??\d を 12 に使うと 1、2 でした。最短一致はどの長さから試すかを変えるだけで、成功するはずのマッチを失敗させることはありません。

貪欲 最短 強欲
* *? *+
+ +? ++
? ?? ?+
{n,m} {n,m}? {n,m}+

強欲な量指定子とアトミックグループ

強欲な(possessive)量指定子は貪欲に取り、決して返しません。速さだけでなく結果も変わります。

テキスト:abc1
\w+\d        → abc1        (\w+ が abc1 を取り、1 を返す → \d が 1)
\w++\d       → マッチなし   (\w++ は 1 を返さない)
(?>\w+)\d    → マッチなし   (アトミックグループ:同じ動き)
[a-z]++\d    → abc1        (クラスが 1 を取れないので返す必要がない)
入力 abc1 から3つのモードに分かれる図。貪欲:\w+ が abc1 を取り、1 を返して、マッチは abc1。最短:\w+? が a、ab、abc と伸ばし、\d が 1 にマッチしてマッチは abc1。強欲:\w++ が abc1 を取って返さず、\d に残る文字がないのでマッチなし。
同じ入力 abc1 を3つのモードで照合した結果。貪欲な \w+ は全部取ってから 1 を返し、\d がマッチする。最短の \w+? は1文字から始めて \d が合うまで伸ばす。強欲な \w++(アトミックグループ (?>\w+) も同じ)は 1 を返さないので失敗する。

対応状況でエンジンが分かれます。エラーメッセージを覚えておくと原因にすぐ気づけます。

パターン .NET 10 Python 3.9 Python 3.12 PHP 8.5 Dart 3.11
\w++\d(abc1) エラー エラー マッチなし マッチなし エラー
(?>\w+)\d(abc1) マッチなし エラー マッチなし マッチなし エラー
"[^"]*+"(say "hi" now) エラー エラー "hi" "hi" エラー
.NET 10    : Invalid pattern '\w++\d' at offset 4. Nested quantifier '+'.
Python 3.9 : multiple repeat at position 3
Python 3.9 : unknown extension ?> at position 1
Dart 3.11  : FormatException: Nothing to repeat \w++\d
Dart 3.11  : FormatException: Invalid group (?>\w+)\d

Python のドキュメントでは両方とも 3.11 で追加された機能とされており、実際に 3.9 は拒否し、3.12 は受け付けました。.NET では ++ の代わりにアトミックグループ (?>…) を使います。(?>\w+) は \w++ と同じ意味です。JavaScript、つまり Dart と Flutter にはどちらもないので、パターンの構造そのものを直すことになります。

バックトラック爆発の実測:(a+)+b

強欲・アトミックの形が本領を発揮するのは、マッチが失敗するときです。(a+)+b では、連続する a を内側の + と外側の + で分ける方法が指数的にあり、バックトラック型のエンジンはそれをすべて試してから失敗を宣言します。入力は "a" × n + "!b" です。! があるので必ず失敗しますが、入力の中に b は存在します。呼び出し1回の時間、Release ビルド、Apple Silicon Mac での結果です。

n .NET 10 既定 .NET 10 Compiled Python 3.12 Python 3.9 Dart 3.11(AOT)
16 13 ms 4.2 ms 4.6 ms 4.4 ms 4.5 ms
20 209 ms 70 ms 72 ms 71 ms 72 ms
22 837 ms 272 ms 266 ms 275 ms 283 ms
24 3,963 ms 1,067 ms 1,083 ms 1,088 ms 1,165 ms
26 タイムアウト(5秒) 4,437 ms 4,626 ms 4,422 ms 4,608 ms

.NET 既定エンジンをもう一度測ると n = 22、24 は 773 ms と 3,047 ms で、.NET Compiled と Dart の2回目は表との差が4%以内でした。2文字増えるごとに約4倍です。このペースなら数十文字でリクエストが止まります。

n = 22 での1回の検索時間の棒グラフ:.NET 10 既定エンジン 837 ms、.NET 10 Compiled 272 ms、Python 3.12 266 ms、Dart 3.11 283 ms、PHP 8.5 はバックトラック上限エラーまで 2.1 ms、アトミック版 (?>a+)+b はほぼ 0 ms。
"a" × 22 + "!b" で (a+)+b を1回検索した時間(Release ビルド、Apple Silicon Mac)。バックトラック型のエンジンは 266〜837 ms かかった。PHP は 2.1 ms で「Backtrack limit exhausted」となり false を返した。アトミック版 (?>a+)+b は対応するすべてのエンジンで 0.01 ms 未満。

直したパターンです。どれも aaab には今までどおり aaab でマッチすることを確認しました。

  • (?>a+)+b(.NET、Python 3.12、PHP)と a++b(Python 3.12、PHP):どの n でも 0.00 ms。a+ が一度取ったかたまりを分け直す余地がありません。
  • 本来の意図であろう a+b:Dart を含む全エンジンで 0.00 ms。
  • .NET の RegexOptions.NonBacktracking+元の (a+)+b:0.01 ms 以下。ただしこのオプションはアトミックグループとは併用できません:RegexOptions.NonBacktracking is not supported in conjunction with expressions containing: 'atomic subexpressions (?> pattern)'。

PHP は違う動きをし、その違いが落とし穴です。JIT が有効な既定の状態で preg_match('/(a+)+b/', …) は、n = 16 では 0.52 ms で 0 を返しましたが、n ≥ 20 では約2 ms で false を返し、preg_last_error_msg() は Backtrack limit exhausted でした(pcre.backtrack_limit = 1,000,000)。JIT を切ると約12.5 ms 後に同じエラーです。サーバーは固まりませんが、if (preg_match(...)) だけで判定すると false を「マッチなし」として素通りします。また、入力に b がまったくないとき、PCRE2 は必須文字がないことを先に調べて 0.00 ms で 0 を返しました。.NET にはその近道がなく、"a" × 24 で 4,509 ms かかりました。

実務での量指定子の選び方

状況 こう書く 避ける
桁数を固定 ^\d{3}$、\b\d{3}\b アンカーなしの \d{3}
区切り文字まで "[^"]*" ".*"
目印の間の最短 <b>.*?</b> <b>.*</b>
繰り返しの中の繰り返し 平らにする:a+b (a+)+b
返してはいけない .NET (?>…)、PHP・Python 3.11+ ++ .NET・Dart で ++
.NET で外部入力 matchTimeout か NonBacktracking 制限なしの既定 Regex
PHP === false と preg_last_error() を確認 if (preg_match(...)) だけ

後方参照を含むパターンで同じ爆発がどう現れるかは後方参照の記事にあります。繰り返しグループの中の区切りが省略可能というだけで、26文字で2秒のタイムアウトに達しました。

検証方法

.NET 10.0.14(Regex.Matches、Stopwatch、Release)、Python 3.9.6・3.12.14(re.finditer、time.perf_counter)、PHP 8.5.6+PCRE2 10.47(preg_match_all、hrtime、JIT あり・なし)、Dart 3.11.5(RegExp.allMatches、dart compile exe、Stopwatch)。Node.js は入っていなかったので、JavaScript の規則は Dart で確かめました。マッチの検証はパターンと入力を1つの JSON ファイルから読み、全エンジンが同じバイト列を見るようにしています。時間はウォームアップ後の1回の値なので、ベンチマークではなく桁を見るためのものです。

2026年11月、Apple Silicon Mac(macOS)で実測。

よくある質問

[0-9]{3} は4桁の数字を除外してくれる?

いいえ。アンカーがなければ、試したすべてのエンジンで「1234」の中の「123」にマッチしました。\d{2,4} も「12345」の中の「1234」にマッチします。桁数をきっちり決めたいなら \b[0-9]{3}\b や、複数行モードの ^\d{2,4}$ のように境界を付けます。

a++ のような強欲な量指定子はどこで使える?

PHP(PCRE2 10.47)と Python 3.12 で動きました。Python 3.9 は「multiple repeat at position 3」、.NET 10 は「Nested quantifier」、Dart は「Nothing to repeat」でエラーです。.NET は同じ意味のアトミックグループ (?>...) に対応し、Python 3.12 も対応しています。Dart はどちらも非対応です。

preg_match が 0 ではなく false を返すのはなぜ?

PCRE のバックトラック上限に達したためです。(a+)+b を a が20個以上と「!b」の入力に使うと、PHP 8.5 は約2msで false を返し、preg_last_error_msg() は「Backtrack limit exhausted」でした。if (preg_match(...)) だけだと「マッチなし」と区別できないので、false を明示的に確認します。

  • #正規表現
  • #量指定子
  • #バックトラック
  • #Python
  • #.NET
  • #PHP
  • #Dart

t.laboratory

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

運営者情報 · アプリ

↑