【Python 雑談・雑学 + coding challenge】文字列中の数字を抜き出して桁区切りをつけよう! 正規表現 (regular expression ) を使うと「えっ!?」っていうくらい簡単ですょ。lookahead と negative lookahead を使います。 投稿一覧へ戻る
Published 2020年9月19日21:33 by mootaro23
SUPPORT UKRAINE
- Your indifference to the act of cruelty can thrive rogue nations like Russia -
問題 ( 制限時間: 40 分 ):
上のような文字列があった場合、この中の数字に桁区切りをつけましょう。
この場合の出力結果は以下のようになります。
注意:
文字列から数字部分を抜き出して、そのサブ文字列に format() 関数等を利用した書式化操作をしてはいけませんよ。
つまり、
なんてやって桁区切り付き文字列を取得してはいけません。
また、解答例では正規表現を使った例も示しますが、使わなければいけないわけではありません。
では実装してみてください。
s = 'The numbers are 1234567890 and 98'
上のような文字列があった場合、この中の数字に桁区切りをつけましょう。
この場合の出力結果は以下のようになります。
# The numbers are 1,234,567,890 and 98
注意:
文字列から数字部分を抜き出して、そのサブ文字列に format() 関数等を利用した書式化操作をしてはいけませんよ。
つまり、
sub_s = '1234567890'
n = f"{sub_s:,}"
n = f"{sub_s:,}"
なんてやって桁区切り付き文字列を取得してはいけません。
また、解答例では正規表現を使った例も示しますが、使わなければいけないわけではありません。
では実装してみてください。
さて、いかがだったでしょうか?
まず力技的に実装した例です。
def add_place(s):
result = []
end = n if (n := len(s) % 3) else 3 # 4:
result.append(s[0:end])
start = end
while start < len(s): # 5:
result.append(s[start:start + 3])
start += 3
return ','.join(result) # 6:
lst = s.split() # 1:
for index, ele in enumerate(lst): # 2:
if ele.isdigit(): # 3:
lst[index] = add_place(ele) # 7:
print(' '.join(lst)) # 8:
# The numbers are 1,234,567,890 and 98
result = []
end = n if (n := len(s) % 3) else 3 # 4:
result.append(s[0:end])
start = end
while start < len(s): # 5:
result.append(s[start:start + 3])
start += 3
return ','.join(result) # 6:
lst = s.split() # 1:
for index, ele in enumerate(lst): # 2:
if ele.isdigit(): # 3:
lst[index] = add_place(ele) # 7:
print(' '.join(lst)) # 8:
# The numbers are 1,234,567,890 and 98
1: 文字列を空白文字で区切った単語のリストを取得します。
2: 数字部分は、桁区切りを付けたもので上書きをするためインデックス番号が必要です。enumerate() で取得します。
3: 取り出した単語が数字だけで構成されていれば「桁区切り付加処理」を行います。
4: 桁区切りは 3 桁ごとですが、最上位の桁だけは 1 桁、2 桁、3 桁の可能性があります。サブ文字列の長さを 3 で割った余りがその桁数に該当します。
5: 最上位以外の桁は 3 桁ごとですから、先頭のインデックス番号を 3 ずつ加算しながら長さ 3 の部分文字列を取り出してリストに追加していきます。
6: リストの各要素を桁区切り文字 (',') で結合します。
7: 作成した桁区切り数字で元のサブ文字列を上書きします。
8: 1: で分解した文字列を再び結合して終了です。
さて、お気付きの方もいると思いますが、この問題はちょっと妥協しています。
もし、与えられた文字列が次のような場合はどうでしょう?
s1 = 'The numbers are 1234567890, 98, and 5555.'
各数字サブ文字列の最後には、カンマ (',') や ピリオド ('.') が付いているため、上の例ではうまく機能しません。
lst = s1.split()
for index, ele in enumerate(lst):
if ele.isdigit():
lst[index] = add_place(ele)
print(' '.join(lst))
# The numbers are 1234567890, 98, and 5555.
for index, ele in enumerate(lst):
if ele.isdigit():
lst[index] = add_place(ele)
print(' '.join(lst))
# The numbers are 1234567890, 98, and 5555.
この文字列でうまく機能させるためには、取り出したサブ文字列 (単語) をもう少し処理するコードが必要になりますね。
そこで、いよいよ正規表現バージョンの登場です。
まずは解答例をどうぞ。
import re
pat = re.compile(r'(?P<place>\d{1,3})(?=(\d{3})+(?!\d))') # 1:
s_ans = pat.sub(r'\g<place>,', s) # 2:
print(s_ans)
# The numbers are 1,234,567,890 and 98
s1_ans = pat.sub(r'\g<place>,', s1)
print(s1_ans)
# The numbers are 1,234,567,890, 98, and 5,555.
pat = re.compile(r'(?P<place>\d{1,3})(?=(\d{3})+(?!\d))') # 1:
s_ans = pat.sub(r'\g<place>,', s) # 2:
print(s_ans)
# The numbers are 1,234,567,890 and 98
s1_ans = pat.sub(r'\g<place>,', s1)
print(s1_ans)
# The numbers are 1,234,567,890, 98, and 5,555.
はい、1: と 2: の 2 行だけです。これだけで 2 つの文字列に完全に対応できます。「正規表現」万歳!ですね。
ちょっと 1: はややっこしいですから、部品々々を見ていきましょう!
A: (?P<place>\d{1,3})
この部分が実際にマッチして欲しい部分で、グループ化しています。
?P<place> : このマッチグループに 'place' という名前をつけています。
今回取得するグループはここ 1 ヶ所だけなのでわざわざ名前を付けるまでもないのですが、勉強のために。
もし名前をつけなければ、2: の sub() でこのグループの内容を参照するには \g<1> もしくは単に \1 と記述できます。
\d{1,3} : 数値が 1 つから 3 つ並んでいるものとマッチします。
この場合、正規表現エンジンはなるだけ多くの数からマッチを試みますから、3 桁 -> 2 桁 -> 1 桁の順番で検索します。
B: (?=(\d{3})+(?!\d))
さて、ここが今回の肝の lookahead 部分です。
lookahead (先読み書式) については 2 回ほど取り上げていますので、興味のある方はどうぞ。
・正規表現の 先読み ( look ahead; ?=expression ) 機能を使いこなそう!
・複数のマッチパターンを look ahead (先読み) シンタックスを利用して 1 つのマッチパターンにまとめて処理しよう! re.match() の呼び出しも 1 回で済んじゃいます!
まず全体を (?=...) で囲んでいますので、この構文全てが lookahead として扱われます。
つまり、A: でいくら 1 桁から 3 桁の数字としてマッチした部分があっても、その後に続く文字パターンが B: でなければそのマッチは違うよ、ということです。
そして、そのパターンとは...
a) (\d{3})+ : 数値がぴったり 3 つ並んでいるものが 1 つ以上あること
b) (?!/d) : a) の直後は数字ではない文字が続いていること
ということです。
そしてここでの目玉は b) の negative lookahead (否定先読み書式) です。
(?!...) 書式は、... が後に続かなければマッチ、ということです。
ですから、a) の後が b) ではない、というパターンが 1 桁から 3 桁の数字の後に続いていれば、この数字の並びはマッチ、として扱われるんですね。
ということは、s1 = 'The numbers are 1234567890, 98, and 5555.' という文字列において、'98' というサブ文字列はマッチしていません。
まぁ、2 桁しかありませんから桁区切りを付ける必要はないわけですし、問題はありません。
では、'1234567890' というサブ文字列はどのようにマッチしているのでしょうか?
A: の部分でも書きましたが、Python の正規表現エンジンは、まず 3 桁の数値とのマッチを探します。
'123' がヒットします。
そこで、lookahead パターンと一致するかを確かめます。3 桁の数字の組が 1 つ以上、かつ、その後ろは数字以外です。
'456'、'789'、'0'。ダメですね。3 桁の数字の組は 2 つありますが、その直後も数字 '0' です。よって、'123' はマッチとは認められません。
そこでエンジンは 2 桁とのマッチを探します。'12' がヒットします。後ろは...
'345'、'678'、'90'。ダメでした。
1 桁ならどうでしょう。'1' がヒットします。後ろは...
'234'、'567'、'890'、','。マッチしました! よって、'1' がマッチグループ <place> の値として保存されます。
しかし、ここで終わりではありません。
lookahead 書式によって参照された文字列部分は「消費」されません。
つまり、'1' はマッチして検索対象からは外れますが、'234567890' という文字列は依然として残っているためエンジンによる検索が続けられ、その結果、'234'、'567' というマッチが追加されます。
残っているのは '890' ですが、これはどうやっても直後に 3 桁の数字が続きませんからマッチしません。
よって、'1234567890' をマッチ検索した結果、<place>グループとして参照可能なマッチは '1'、'234'、'567' の 3 つですね。
最後に 2: で sub() を利用して、ここまで取得したマッチを \g<place> で参照し、直後に ',' を付加した文字列を元の文字列と入れ替えて出来上がり、です!
文章にすると長ーいですけど、滅茶苦茶複雑涙目です、というわけではないと思いますので、是非理解して使いこなしましょう!
この記事に興味のある方は次の記事にも関心を持っているようです...
- People who read this article may also be interested in following articles ... -