Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Valid Anagram #5

Merged
merged 3 commits into from
Sep 23, 2024
Merged

Valid Anagram #5

merged 3 commits into from
Sep 23, 2024

Conversation

rihib
Copy link
Owner

@rihib rihib commented Aug 4, 2024

Valid Anagramを解きました。レビューお願いいたします。

問題セット:Grind75 Easy
言語:Go
問題:https://leetcode.com/problems/valid-anagram/

サロゲートペア・結合文字・合字

Goの内部では常にUTF-8が使用されており、runeはUnicodeコードポイントを表す型で、int32のエイリアスである(https://go.dev/blog/strings )。しかし、Unicodeにはサロゲートペア・結合文字列・合字などがあるため、たとえruneを使ったとしてもそれだけでUnicode文字を完全に適切に処理できるわけではない(参照)。

日本語圏だけでなく、英語圏でも絵文字を使う関係でサロゲートペアの処理は必要である(参照)。

結論としては、文字列にサロゲートペア・結合文字・合字などが含まれる場合であっても、グラフィムクラスタを使えば"≠”のようなリガチャを除けば文字列を前から順に走査するのであれば問題なく文字数をカウントできそう(文字列の途中から走査する場合は旗シーケンスの文字列の場合は文字の境界が判定できないため、結局は文字列の先頭から走査する必要が生じる)。Goの場合、https://pkg.go.dev/golang.org/x/text/unicode/norm を使えばできそう(グラフィムクラスターをGo言語で扱う)。

Unicode

1980年代の当初の構想では、Unicodeは16ビット固定長で、216 = 6万5,536 個の符号位置に必要な全ての文字を収録する、というもくろみであった。しかし、Unicode 1.0公表後、拡張可能な空き領域2万字分を巡り、各国から文字追加要求が起こった。その内容は中国、日本、台湾、ベトナム、シンガポールの追加漢字約1万5千字、古ハングル約5千字、未登録言語の文字などである。このようにしてUnicodeの、16ビットの枠内に全世界の文字を収録するという計画は早々に破綻し、1996年のUnicode 2.0の時点で既に、文字集合の空間を16ビットから広げることが決まった。この時、それまでの16ビットを前提としてすでに設計されていたシステム(たとえばJavaのchar型や、Windows NTWindows 95のAPI)をなるべくそのままにしたまま、広げられた空間にある符号位置を表現する方法として、サロゲートペアが定義された。

絵文字を支える技術について - note

コードポイントというのがUnicodeが文字に割り当てた数字のことで、0x0000から0x10FFFFまであります。例えば「あ」は0x3042に割り当てられています。そして、その数値をエンコードしたもののうち、16bitの整数値でエンコードしたものがUTF-16、8bitの整数値でエンコードしたものがUTF-8となります。

絵文字を扱う上で、まず最初に気をつけないといけないことは、ほとんどの絵文字はBMPではないということです。ここでいうBMPとはBasic Multilingual Planeの略で、コードポイントが16bitに収まる文字の範囲をさします。当初は「16bitもあれば全部の文字を収録できるでしょ!」って思って設計された言語が多く、例えばJavaは文字を格納するchar型を16bitにしました。もし世界中の文字がBMPに収まっていれば、「1 char = 1 コードポイント」という非常にクリアで扱いやすい世界になっていたことでしょう。ですがUnicodeは早々に16bitでは無理だと気づいて、追加のコードポイントを16bitを超える領域に確保したので、この美しい世界は早々に終わりを告げました。しかし、世の中はすでに16bitの整数型で文字列が組まれていて、いまさら追加なんてできません。そこでUnicodeはU+FFFFよりも大きなコードポイントをなんとか16bitの整数型を維持したままで扱えないかと頭をひねりました。そして出てきたのが、「2個の16bit整数を組み合わせで16bit以上のコードポイントを表せばいいんだ!」というアイデアです。この2つの16bitの数字のペアがサロゲートペアと呼ばれるものです。

  • UTF-16文字列の文字列長とコードポイント数が一致しなくなった。
    (例:😝はUTF-16で文字列長は2、コードポイント長は1)

絵文字は比較的新しい文字なので、ほとんどはこのBMPではない領域に収録されています。ですので、絵文字を処理するということは、ほぼサロゲートペアの処理が必要になると思ってください。

これまで絵文字を見てきましたが、そのほとんどは1コードポイントで一つの絵文字を表していました。ですが近年、新たに追加されている絵文字の大部分はシーケンス絵文字とでもいいますか、複数のコードポイントを結合して一つの絵文字とするタイプの絵文字です。今後そのような絵文字をシーケンス絵文字と読んでいきます。シーケンス絵文字は結合に使用する文字種などの違いから、いくつかのグループに分かれています。
みなさんはもしかすると、「文字を結合する」と聞くと、ぎょっとするかもしれません。これから見ていく絵文字はちょっとアグレッシブすぎるかもしれませんが、文字を結合するという手法自体は、日本語を含めていろんな言語で一般的に使われています。例えば先に見た異体字セレクタも複数文字で一文字を表す例です。ほかにも日本語では「が(U+304C)」を一文字で表すこともできれば、「か(U+304B)」と濁点「゛(U+3099)」の二文字を合成した「が」で表すこともできます。これも複数コードポイントから一文字を作っている例となります。実際Mac OSXのファイルシステムなどでは「が」を2コードポイントに統一して保存しており(NFD正規化と言います)、WindowsやLinuxのファイルシステムではそんなこと気にしないで保存しているので、たまにMac OSXとファイルを共有して、開けなくなるトラブルになったりしてますね。この辺はあまり深入りすると戻ってこれなくなりそうなので、ここまでにすることにします。興味がある方はUnicode正規化で調べてみてください。

コードポイント数とは文字通り与えられた文字列に含まれているコードポイントの数です。直感的にはこれを文字数として使えそうな気がしますよね。でも絵文字だとこの予想は簡単に裏切られます。例えば、😝だと1コードポイントなので良さそうですが、☝🏻だと指差しの絵文字とモディファイヤーで2コードポイントですし、長いのだと🧑🏻‍❤️‍💋‍🧑🏼は10コードポイントで構成されています。さすがにこの状況で、1コードポイントが1文字だという主張は直感に反してますよね。

さて、コードポイントが文字数として直感に反しているということで、Unicodeがもっと人間の直感に近い文字の区切り方を発明してくれました。それがグラフィムクラスタです。日本語では書記素・・・でいいんですかね?

要は「グラフィムクラスタは大体ユーザーの考える1文字になってるはずだよ!」ってことです。このグラフィムクラスタを用いると、😝も☝🏻も🧑🏻‍❤️‍💋‍🧑🏼も1クラスタとなります。また、グラフィムクラスタはカーソルを置ける位置を計算するのにも使われているので、カーソルが置ける境界がグラフィムクラスタの境界、すなわち感覚的な文字の境界になっています。これがおそらく文字数として使うには最も良い指標でしょう。このグラフィムクラスタという概念は絵文字のみのものではなく、全言語に対して一般的に適用できる概念です。

実際にグラフィムクラスタを計算するには、Unicodeの文字データベースから文字情報を引っ張ってきて、ごにょごにょする必要があるのですが、当然自力でやるモチベーションは現在では皆無なので、おとなしくAPIを呼んでください。

かくして、文字数を数えるときはグラフィムクラスタを使えばよいということになりそうです。めでたしめでたし。

めでたしめでたしなのですが、万能に見えるグラフィムクラスタですが、いくつか注意すべき点があります。まず、グラフィムクラスタはUnicodeのデータベースに依存するということです。Unicodeは毎年新しいバージョンが出ます。そして新しい文字は当然新しいUnicodeのデータベースにしか存在しません。通常、UnicodeのデータベースはICU(International Component for Unicode)と呼ばれるライブラリを介して参照するので、同じ文字列をバージョンの違うICUでカウントすると別の文字数(グラフィムクラスタ数)になる、なんていうことが起こっていました。

英語などではリガチャと呼ばれる、複数の文字をつなげたスタイルを使用することがあります。たとえば、”fi”の文字でiの点がfに吸収されたような字形を取ることがあります。これはフォントの機能なので、コードポイントからだけでは判断ができません。”fi”の場合はリガチャになっても2文字だろうと思えるので良いのですが、世の中にはもっとアグレッシブなリガチャをするフォントがあります。プログラム用のフォントで”!=”を”≠”とリガチャしてしまうようなフォントもあります。”!=”と”≠”はさすがに文字数が変わってますよね。

正直、この状態になると、実際にプログラムを書いている人が要求事項に応じて文字数の定義を決め、それに合うような指標を選択しなければなりません。例えば、バックエンドサーバーが1024バイト分しか容量がなければコードポイントどころではなく、UTF-8なりUTF-16なりでエンコードした際のサイズが必要でしょう。ポリシー的理由で文字数制限が必要なのであれば、その趣旨に合わせてコードポイントかグラフィムクラスターかを決めてしまえば良いでしょう。画面上の幅の問題で文字数を制限する場合は、文字数なんてものは使わず、実際に測るのがよいでしょう。いずれにせよ、文字数まわりのプログラムを書く際に銀の弾丸は存在しないので、慎重な設計が要求される、非常に難しい問題です。

グラフィムクラスタの実装は、ある文字と文字の間が、その周辺の文字の属性を見て、グラフィムの「境界である」、「境界でない」というのを判定しています。

通常は、ある場所が境界であるかどうかを見るためにはその境界の前後の文字をみるだけで十分なのですが、一個だけ例外があります。旗シーケンスです。

グラフィム判定のように、ランダムな場所で「ここはグラフィム境界ですか?」という質問に応えようと思うと、指定された場所だけでは判定ができず、遡って最初まで行き、指定された場所から前に偶数個のRIがあった場合はグラフィム境界、奇数個のRIがあった場合はグラフィム境界ではない、という判定をせざるを得ません。これはつまり、最悪ケースで指定の位置がグラフィム境界であるかを判定するのにすべてのテキストを走査する必要があることを意味します。

なんでサロゲートペアみたいに1文字目と2文字目でグループを分けなかったのかとか、なんで間にZWJ入れなかったのかとか色々と後出しジャンケンはできますが、残念ながら今から変えることはできないので、ぐぬぬと言いながら実装しましょう。

文字列を反転させたい - note

UTF-8は可変長です。つまり、1コードポイントを表すのに必要なバイト数がコードポイントによって違うのです。例えば、英語だと1バイト、日本語だと大体3バイト必要で、絵文字などだと4バイト必要なものもあります。ではバイト列を読む時、何バイト読めばよいのでしょうか?読むべきバイト数は1バイト目に書いてあります。つまり、UTF-8デコーダは最初の1文字目を読んで、何バイト読めばよいかを判断して、それらを使って、コードポイントを復元します。

「が」は「か」という文字に濁点を付け加えてできる新しい文字です。Unicodeには「が」を表現するのに二通りのやりかたがあります。「が」という一文字を表す「\U304C」を使用するやりかたと、「か」を表す「\U304B」と濁点を表す「\U3099」を別々に表記するやり方、つまり「\U304B\U3099」で「が」を表す方法があります。後者のコードポイント列を入れ替えると、濁点が先に来てしまいます。たとえば、「がき」という文字を反転させると、「きが」になってほしいところで、「ぎか」という文字になってしまします。

では、このような問題に対してどうすればよいのでしょうか?答えはもうこの説のタイトルになっていますが、「グラフィムクラスター」を使います。グラフィムクラスターとは、ざっくりと言うと「人間が考える1文字」に相当します。たとえば、先程の例で見た通り、「が」を人は1文字だと考えますし、国旗も1文字だと考えるでしょう。つまり、このグラフィムクラスター単位で反転させてあげれば良さそうです。

右から左に書く言語を支える技術

@rihib rihib added the Grind75 label Aug 4, 2024
@rihib
Copy link
Owner Author

rihib commented Aug 4, 2024

直近に解いている方々:
NobukiFukui/Grind75-ProgrammingTraining#21
colorbox/leetcode#9
kzhra/Grind41#8

@rihib
Copy link
Owner Author

rihib commented Aug 4, 2024

他の方のPRを見ていて、Goではソートで解く場合、時間計算量は幾つになるのか気になったのでざっくりとですが調べてみました。

#20 (comment)

@colorbox
Copy link

colorbox commented Aug 5, 2024

良さそうに思いました。

@kzhra
Copy link

kzhra commented Aug 6, 2024

・文字コードの違いによる実装を考慮できている
・文字コードの内部の実装の把握(s[i] がまずい)
・自分が使う道具の把握(sort)ができている
いいと思います。言うことがないです


/*
かなり前に解いたものなので、詳細については忘れてしましました。
小文字のアルファベットのみが入力として与えられる場合と、Unicode文字も含まれる場合の2つの解法が含まれています。

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

これ、類似の話で他の方にコメントしたときは「日本語はだいたいOK」とふわっとごまかしていたのですが、実はruneにわけると次のような事が起こります。

func main() {
	str1 := "ですと"
	str2 := "すてど"
	fmt.Println(isAnagram_unicode_step1(str1, str2))
	str3 := "ですと"
	str4 := "すてど"
	fmt.Println(isAnagram_unicode_step1(str3, str4))
}
true
false

おそらくtrueになるのは、多くの日本語話者の感覚と反すると思いますし、実際普通の濁音文字ではfalseになりますが、Unicodeでは「"文字面"だけを見るとくっついているのにコードポイントの数は2文字分以上である」という事があります(結合文字)
そのため、runeに分ければ一般にUnicodeでOKかというと、ちょっと難しいところがあります。

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

文字コードは魔境で、これ自体は Unicode 正規化で解決しそうですが、訳の分からない非直感的な動作がたくさん起きます。
https://note.com/ttuusskk/n/n1bff5d8e638c
https://note.com/ttuusskk/n/n6f874b0274bd
https://note.com/ttuusskk/n/ne1f4466bb45f

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

なるほど、知りませんでした、、

@rihib rihib mentioned this pull request Aug 17, 2024
@rihib rihib merged commit 978ea1f into main Sep 23, 2024
@rihib rihib deleted the valid_anagram branch September 23, 2024 12:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants