正規表現で同じ文字の連続を探す:(.)\1+と連続の圧縮
同じ文字の連続を(.)\1+で探し、置換で短くする方法を.NET・Python・PHP・Dartで実測。a+や.+との違い、「www」や絵文字のサロゲートペアの落とし穴、100万文字の処理時間まで。
2026年11月改訂: この記事は2021年に韓国語で書いた講座を、日本語の読者向けに書き直したものです。元記事は
\w+@\w+\.\w+やPlayer[0-9][0-9]*を例に+・*・?を紹介していました。6つの例を .NET 10、Python 3.9・3.12 のre、PHP 8.5(PCRE2 10.47、/uあり・なし)、Dart 3.11(JavaScript の規則に従うRegExp)で同じ入力のまま実行し直し、マッチ結果はどのエンジンでも同じでした。ただし「\wは[a-zA-Z0-9_]」という定義は .NET・Python・PHP/uでは誤りです。量指定子の理屈は量指定子の記事に移し、この版はタイトルどおり同じ文字の連続を探す方法に絞りました。
すごーーーい、!!!!、wwww、1111 のような暗証番号など、同じ文字が続く部分を見つけたい、または短くしたい人向けの記事です。答えを先に書くと (.)\1+ です。1文字を捕まえて、まったく同じ文字がもう1回以上続くことを求めます。量指定子だけではできません。a+ は前もって書いた文字しか繰り返さず、.+ はどんな文字が混ざっていても通すからです。この方法は後方参照を使います。後方参照そのものは後方参照の記事で詳しく扱っています。
元記事の例を再確認
元の例と、全エンジンが返した結果です(入力は1行に1つ)。
| パターン | 入力 | マッチ |
|---|---|---|
\w+@\w+\.\w+ |
id@gmail.com、id@daumnet、id@naver.com、id@nate.com、id@google |
ドットを含む3つのアドレス |
bo+ |
b、bo、boo |
bo、boo |
bo* |
b、bo、boo |
b、bo、boo |
Player[0-9][0-9]* |
Player1 … Player100、Player_、Player@ |
数字が1つ以上付く名前すべて(Player100 は全体) |
Player[0-9][0-9]? |
同じ | 同じ名前。ただし Player100 からは Player10 |
aabb?cc |
aabcc、aabbcc、aabbbcc |
aabcc、aabbcc |
最後の行は、元のページに aabbcc が2回載っていましたが、b? の意味を考えると3行目は本来 aabcc だったはずです(移行時に書式が崩れたとみられます)。また Player[0-9][0-9]? は Player100 を除外せず、先頭10文字にマッチします。2桁までに限るなら末尾に \b を付けます。Player[0-9][0-9]?\b は Player1、Player100、Player12 のうち Player1 と Player12 だけにマッチしました。
元記事の説明で2点を訂正します。
\w はいつも [a-zA-Z0-9_] とは限りません。 山田@例え.テスト に \w+@\w+\.\w+ を使うと、.NET・Python・PHP /u はアドレス全体にマッチし、PHP(/u なし)と Dart(unicode: true でも)は何も見つけませんでした。前者の \w は漢字やかなも含むということです。詳しい表は文字クラスの記事にあります。
メールのパターンは例であって検証器ではありません。 first.last@mail.example.co.jp では全エンジンが last@mail.example を返しました。\w にドットが含まれないので、ローカル部もドメインも途中で切れます。実際の検証は @ が1つあるか程度にとどめ、確認メールで確かめるほうが確実です。
同じ文字をもう一度求める:(.)\1+
(.) は任意の1文字をグループ1に記録します。\1 はグループ1が記録したその文字を求め、+ がそれを繰り返させます。合わせると長さ2以上の連続が見つかります。
パターン:(.)\1+
テキスト:aaabccddddd
マッチ :aaa(グループ1 = a)、cc(c)、ddddd(d)
4エンジン・両モードで同じでした。最初に試しがちなパターンと比べると違いがはっきりします。
| パターン | テキスト | 結果 | 意味 |
|---|---|---|---|
.+ |
aab |
aab |
同じでも違っても任意の文字 |
(.)+ |
abc |
abc、グループ1 = c |
グループの繰り返し。グループには最後の文字だけ残る |
a{3} |
aab aaab |
aaa |
書いた文字を3つ |
(.)\1\1 |
aab aaab |
aaa |
最初に来た文字を3つ |
(.)\1+ |
aab aaab |
aa、aaa |
同じ文字が2つ以上 |
(.)+ はよくある落とし穴です。量指定子の付いたグループはすべての文字を覚えるわけではなく、グループ1には最後の繰り返しの c しか残りません。.NET は Groups[1].Captures に履歴を残しますが(実行結果は a|b|c)、ほかのエンジンの標準ライブラリにはこの機能がありません。
. をもっと狭い表現に変えれば、どの連続を数えるかを選べます。
(\w)\1+ "100% cool --- yes" → 00、oo (ダッシュは無視)
(\d)\1{3,} "PIN 1111, 1234, 00007" → 1111、0000
(.)\1+ "AaA bB"(大文字小文字を無視) → AaA、bB
最後の行のように大文字小文字を無視するオプションを付けると、後方参照の比較も大文字小文字を無視しました。試したすべてのエンジンで同じです。日本語の文 すごーーーい!!www に (.)\1+ を使うと、.NET・Python・PHP /u・Dart のすべてが ーーー、!!、www を見つけました。PHP で /u を外すと何も見つかりません。全角文字は UTF-8 で3バイトなので、バイト単位では同じバイトが連続しないからです。
置換で連続を縮める
連続を探す目的は、たいてい縮めることです。置換文字列でグループ1を参照し、書き方はエンジンによって違います。
| 目的 | パターン | .NET / PHP | Python | 結果 |
|---|---|---|---|---|
| 1文字に | (.)\1+ |
$1 |
\1 |
aaabccddddd → abcd |
| 最大2文字に | (.)\1{2,} |
$1$1 |
\1\1 |
soooo good!!!! → soo good!! |
すべてのエンジンで同じ結果でした。Dart の replaceAll には置換の構文がないので replaceAllMapped(re, (m) => m[1]! * 2) を使い、同じ結果になりました。ユーザーの文章を整えるなら、たいてい {2,} 版が適しています。good book soooo は good book soo になり、もともと2文字続く単語はそのまま、3文字以上の連続だけが縮みます。日本語でも すごーーーい!!wwww は全エンジン(PHP は /u 付き)で すごーーい!!ww になりました。
.NET の置換に \1 と書いたり、Python に $1 と書いたりしても、エラーにならずにその文字列がそのまま入ります。エンジンごとにどの書き方が通るかは後方参照の記事の表にまとめています。
絵文字と BMP の外の文字
入力に絵文字が入ると、同じパターンの結果が分かれました。
| テキスト | .NET | Python 3 | PHP /u なし |
PHP /u |
Dart | Dart unicode: true |
|---|---|---|---|---|---|---|
😀😀 ok |
マッチなし | 😀😀 |
マッチなし | 😀😀 |
マッチなし | 😀😀 |
👍👍👍 |
マッチなし | 👍👍👍 |
マッチなし | 👍👍👍 |
マッチなし | 👍👍👍 |
.NET の文字列は JavaScript と同じく UTF-16 です。😀 のような絵文字はコード単位2つからなるサロゲートペアで(C# では "😀😀".Length が 4)、. はそのうち1つにしかマッチしません。グループ1は前半を捕まえ、次の単位は後半なので値が違い、連続とみなされません。Python はコードポイント単位なので問題がなく、Dart と PHP も Unicode モードを有効にするとコードポイント単位で動きました。
.NET では、サロゲートペアを1単位として捕まえます。
Regex.Match("😀😀", @"([\uD800-\uDBFF][\uDC00-\uDFFF]|.)\1+").Value // "😀😀"
肌の色の修飾子や ZWJ で結合した絵文字は複数のコードポイントからなるので、この方法でも単純な絵文字までしか扱えません。「コメント内の連続した絵文字を縮める」のような機能なら、先に書記素クラスタ単位に分けてから(.NET は StringInfo、Dart は characters パッケージ)コードで比べるほうが安全です。
どれくらい速いか:100万文字で実測
大きなテキストの整形に正規表現が向いているかを見るため、チャット風のテキスト1,000,000文字(固定シード。soooo、!!!!、ㅋㅋㅋㅋ、wwww などを普通の単語と混ぜたもの)を作り、(.)\1{2,} をグループ1の2回に置換しました。連続区間は78,072個で、出力はどの方法でも859,424文字でした。各3回、Apple Silicon Mac での結果です。
| 方法 | 時間 |
|---|---|
Python 3.12 re.sub |
34.1〜35.2 ms |
Python 3.12 itertools.groupby のループ(同じ出力) |
175 ms |
.NET 10 Regex.Replace 既定 |
89〜100 ms |
.NET 10 Regex.Replace+RegexOptions.Compiled |
20.5〜23.1 ms |
この規模では正規表現はボトルネックにならず、Python では素直に書いたループより5倍速いという結果でした。量指定子の記事で見た入れ子の量指定子と違い、(.)\1+ は爆発しません。連続区間ごとに1回だけ消費し、戻って別の分け方を試すあいまいさがないからです。
早見表と検証方法
| やりたいこと | パターン |
|---|---|
| 同じ文字が2つ以上続く | (.)\1+ |
| 3つ以上続く | (.)\1{2,} |
| 同じ数字が4回以上(弱い暗証番号) | (\d)\1{3,} |
| 文字と数字だけを対象に | (\w)\1+(.NET・Python・PHP /u では漢字・かなも含む) |
| 1文字に縮める | (.)\1+ → グループ1 |
| 2文字に縮める | (.)\1{2,} → グループ1を2回 |
| .NET で絵文字にも対応 | ([\uD800-\uDBFF][\uDC00-\uDFFF]|.)\1+ |
検証環境:.NET 10.0.14(Regex.Matches、Regex.Replace)、Python 3.9.6・3.12.14(re.finditer、re.sub)、PHP 8.5.6+PCRE2 10.47(preg_match_all、preg_replace、/u あり・なし)、Dart 3.11.5(RegExp.allMatches、replaceAllMapped、既定と unicode: true)。Node.js がなかったので、JavaScript の規則は Dart で確かめました。パターンと入力は1つの JSON ファイルから読み、全エンジンが同じバイト列を見るようにしています。
2026年11月、Apple Silicon Mac(macOS)で実測。
よくある質問
「wwww」や「!!!!」を2文字に縮めるには?
(.)\1{2,} をグループ1を2回並べたものに置換します。置換文字列は .NET と PHP が $1$1、Python が \1\1、Dart は replaceAllMapped に (m) => m[1]! * 2 を渡します。全エンジンで「すごーーーい!!wwww」が「すごーーい!!ww」になりました。
C# で (.)\1+ が「😀😀」を見つけないのはなぜ?
.NET の文字列は UTF-16 で、. はコード単位1つにマッチします。絵文字は2単位(サロゲートペア)なので、後方参照が別々の半分を比べることになります。C# では "😀😀".Length が 4 です。Python、PHP /u、Dart unicode: true ではマッチし、.NET でも ([\uD800-\uDBFF][\uDC00-\uDFFF]|.)\1+ なら見つかりました。
a+ と (.)\1+ の違いは?
a+ はパターンに書いた文字 a だけを繰り返します。(.)\1+ は何か1文字を捕まえ、同じ文字がまた来ることを求めるので、どの文字の連続でも見つけます。.+ は同じでも違っても何でも続けばよいので、「aab」に対して .+ は「aab」、(.)\1+ は「aa」でした。