点Nの軌跡

競プロの話または日記

点Nはつくばで何を食べて生きていたのか(外食編)

いつまでもあると思うなBIG丼

点Nが行った飲食店やらなんやらを全部書きます。

多くの情報は2025年2月現在のものです

殿堂入り

RanRan(春4)(閉店)

つくば三大重食の一角。看板メニューは"BIG丼"で、ある時期からはそれ一本に絞っていた。その名の通り量が多い。最小サイズのミニでも公称値900gで、そこから小盛1200g、並盛1600g、大盛1900g、特盛2600g。具はもやしナムル、卵、唐揚げ、肉、野菜炒め、麻婆豆腐の6種で、どれも美味しい。具の種類の豊富さと丼スタイルのおかげで意外と食べられる。値段が安く、2023年ごろはミニが550円、並盛でも820円。点Nは週1で通っており、並盛を食べていた。休業などを挟みつつなんとか営業していたが、2023年6月に惜しまれつつも閉店。形見分けで食器類はファンの手に渡っており、点Nはスプーンを所持している。食器は岐阜の会社に特注したものらしい*1(ピンクの花が描かれたものが各種サイトでも見つからないのでそれか?)が、もう少し広く流通している器(金彩牡丹 8.0腰張丼 25cm、三色牡丹 8.0玉丼 25.5cm / 株式会社山万)を点Nは個人的に購入している。

BIG丼

詳細不明の器

特にお世話になったお店

だんごのたかの つくば店(西平塚)

だんご。安くて大きい。1本でもかなりボリュームがある。昔はもっと安かったのだが、現在は1本あたり90円から100円。出来立てはやわらかく特に美味しい。点Nの好みは あんきなこ と ずんだ。大福などの和菓子、太巻き、お弁当も扱っている。遅い時間に行くと売り切れていたりするので早めに行くのが吉(言えば作ってくれる時間帯もあるが)。なお、少々割高ではあるがつくば駅前のわくわく広場(tonarie CREOの1階)でもだんごが販売されている。

だんご

つけめん・まぜそば むじゃき(天2)

RanRan閉店後によく行くようになった店。点Nはもっぱら台湾まぜそばしか食べない。大盛りと追い飯が無料で、両方つけるとお腹いっぱい。人気店ゆえ待ち時間が長いときもある。

台湾まぜそば。大盛特製

軽食・喫茶 クラレット(天3)

つくば三大重食の一角であり、おそらく「重食」⇔軽食 の名の由来。名物になっているのが肉天重で、点Nはもっぱらこれしか食べない。おひつにしっかり米、その上に肉の天ぷら5枚ぐらい(と茄子と芋とピーマンの天ぷら)が乗っかっており、甘いたれがかかっている。暑い時期には自家製のしそジュースを売っていたりした(唯一の喫茶要素)が、今はわからない。ご夫婦がかなりご高齢なのでやや心配。いつまで筑波大生はこの味を食べられるのだろうか。「健康 毎日考えよう バランスの良いお食事を」「苦労は宝なり 素直は力なり 努力は人生なり」(と書かれた紙が店内に貼られている)

肉天重

インドネパールレストラン ナマステキッチン(天1)

インドカレー。ナンのおかわりは1枚無料(それ以上は食えないので知らん)。RanRan閉店後、お腹がすいたときによく利用していた。辛さが逆詐称気味でそんなに辛くないので、あまり恐れる必要はない。ポイントカードがたまるといいことがあったはずだが、点Nはポイントを貯めきる前につくばを離れることになってしまった。

カレーとナン

俺の生きる道 つくば(吾妻3)

二郎インスパイア系のラーメン店。入学してまもない時期に友人と一度訪問し、その後は天久保の店舗に行くようになったものの、そちらが保健所案件をやらかして休業したため再度行くようになった。筑波大学近辺のこの手の店のなかでもヘビーなほう。システムは親切。

ラーメン全マシ+生卵

カレーうどん ZEYO. (天2)

世にも珍しいカレーうどん専門店。学生は10円でコロッケ丼が追加できる、誕生日に訪問すると無料券がもらえる、店頭の100円ガチャでトッピングや(稀に)無料券などが当たるなど、サービスも多い。

百香筑波大学店(天3)

複数人で食事に行くときに重宝する中華料理店。名物の酢豚(他店のそれとはまったく異なり、黒い球体である)は一度食べておくべき。

清六家 筑波大学店(天3)

家系ラーメン。24時間営業しているのとライスをどれだけ食べても無料なのが強み。味の評価はそこまで高くない気がする。マクドナルドが近辺にない筑波大学近辺では、迷った挙句ここでいいかになる店のポジションだったりする。順当に普通のラーメンを食べる人が多いが、極み鶏しか食べないという派閥もいる。

そのほか、点Nの生活に彩りを添えてくれたお店

誠寿司(春4)

現在はランチ限定で営業している寿司屋。主要メニューはおまかせ握り、まぐろ丼、海鮮丼など。いずれにも器いっぱいのサラダと大量のわかめが入った味噌汁がついてくる。そのため総量はかなり多く、三大重食クラスなので満腹は不可避。まぐろ丼・海鮮丼の大盛りはすさまじいボリュームなので、うかつに手を出してはいけない。サラダはお持ち帰りできる。なお2009年ごろの写真ではそこまでヘビーではなかったようで、筑波大学の(それも体育系の学生が多く住む春4の)環境の影響があったのではないかと点Nは想像している。

まぐろ丼

夢屋(春4)

つくば三大重食の一角。名物はジャンボチキンカツ、通称ジャンチキ。米とカツ、それぞれ課金することで大盛りにできるが、点Nはふつうの量で満足。諸事情で政治色が強いが、特に気にしないなら問題ない。

ジャンチキ

フライパン(春4)

40年以上になる洋食屋。名物はフライパンで供される鉄板ソースチキンカツ。+100円でカツ大盛りにできるほか、無料で大量の野菜が追加できる。味が濃い目なので野菜もしっかり食べよう。材料がなくなって19時半にオーダーストップなんてこともあったりするので注意。

ソースチキンカツ 大盛り 野菜全部タルタル(おすすめトッピング)

東京背油 銀の豚(妻木)

背油ラーメンの店だが、点Nは銀郎ラーメンしか食べない。俺の生きる道ほどヘビーではない。

銀郎ラーメン 多分全マシ

銀のしずく(天2)(閉店)

深夜遅くまで営業しており貴重だったラーメン屋。店員が少なく忙しそうにしていた覚えがある。閉店後はハンバーガー屋になったが、そちらも現在休業している。激戦区。

ラーメン龍郎(吾妻3)

茨城近辺を牛耳る活龍グループの、二郎インスパイア店舗。角煮っぽい肉が乗っている。あっさりめ。

たぶん全マシ

中華そば 騰匠俐(桜2)

あたり、と読む。煮干し系ラーメン。つくばには界隈でその名を知らぬ者はいないイチカワという煮干しラーメン店があるが、こちらも無難に美味しい。

中華そば

道家直系 家系ラーメン がく(学園の森)

道家直系だからか、かなり美味しい。変にカスタムせずに全部普通にする方が点Nは好き。卓上の無限ニンニクとマヨネーズを米にかけると美味しい。

チャーシュー多めのやつだと思う

Spice curry TRIGGER(天2)

テレビでも取り上げられたスパイスカレー。ZEYO.から徒歩10秒のところにある。値段はそれなりにするが美味しい。

三種あいがけ+ラッシー

活龍本店(筑穂)

活龍グループの元締めのつけ麺屋。大学の遥か北にあり自転車でもやや遠いが、一度は行くべき。

机の凹凸がちょっと怖かった覚えがある

トタンコットンカフェ(天3→天4)

超ふわふわのパンケーキ。かなり待つが、待つ価値はある。

いちご

コートダジュール(東新井)

駅前から少ししたところにある正統派のケーキ屋。誕生日によく利用した。

ケーキ

Cafe4(春4)

ハンバーグ。どうしても提供に時間がかかるのと値段がそこそこするが、それを帳消しにするレベルには美味しい。けちけちせずに大きいサイズを注文したほうが幸せになれる。

200gだと思う、目玉焼き追加

煮干中華ソバイチカワ(天2)

行列のできる煮干しラーメンの超有名店。店内には独特の緊張感があり居心地が良いとは言えないが、味は確か。システムをよく調べてから行くべし。

出汁打ち込み式味玉も追加

和え玉

七福軒(天1)

筑波大学ICPCチーム、shichifukuの由来。治安が不安な場所にある。まぜそばがおいしかった。

まぜそば

あじよし(要)

大学近くにある、何かと理由を付けて店を休む名物おばちゃんが営む定食屋(メニューはその日ごと)。学生に嬉しい価格と量。やっているかはTwitterで@ajiyoshiver2で確認できる。

ラーメン異国龍(天2)

二郎っぽいのも普通のラーメンもやっているラーメン屋。店内の装飾が面白い。

龍二郎、だと思う

つけ麺丸長(春4)

あっさりしたスープのつけ麺。各地に同名の店があるが、そのあたりの事情は「丸長のれん会」で検索されたい。

金の馬結(天2)

麻婆豆腐の店。むじゃきが混みすぎで並ぶ気になれなかった日によく利用した。

麻婆ラーメン

お持ち帰り処 仲々(春4)

持ち帰り唐揚げ弁当。パックに収まらない量の唐揚げが衝撃的。店主のキャラが濃い。

唐揚げ弁当

櫻井洋菓子店(東光台)

大学エリアから少し離れたところにある小さなケーキ屋さん。シュークリームやプリンを購入。

あとクリームを焼いたっぽいサクサクのやつも

翁屋(竹園2)

和菓子。きぬの夕月(最中)や抹茶プリンが有名。

ウエストハウス(二の宮)

洋食屋。パフェに力を入れている。サークルの面々と巨大パフェ・ビックリサンダーマウンテンを食べに行った。今は洞峰公園近くの本店が残るのみだがかつては複数店舗あったようで、だんごのたかのの近くに特徴的な建物が残っている。

ビックリサンダーマウンテン。5人で完食

大成軒 学園店(天1)

中華。安くておいしい人気店。

福軒餃子(天3)

餃子。チャーハンもうまいらしい(チャーハン好きの友人の談)。台湾ラーメンはそんなに辛くない(味仙とかと比較すると)。

餃子と台湾ラーメン

N's cafe(天3)

米粉のふわふわパンケーキ。大きめが2枚。

いちごとチョコのやつ

moi!kka(春4)

モイッカ。小さなケーキ屋さん。アメリカンベイクというジャンルになるらしく、カラフルでかわいいカップケーキやバターケーキを売っている。

シマエナガ。右はピスタチオ

芛堂寺(天2)

いどうじ。行列のできるおしゃれラーメン。一度は行っておきたい。

おしゃれ~

和え玉(あん肝)

まる家(妻木)

ランチ営業のみ。生姜焼き。値段の割には思ったよりしっかり量があり味もよい

けっこう量がある

茶の木村園(春4)(閉店)

登録商標「とろりん茶」などを扱うお茶屋さん。かき氷が美味しい店だったが2025年2月に破産申請。しかし、何やら再起をめざしているようで、今後の動向に注目が集まる。

メロン

鶏々(天2)

鶏にこだわるラーメン・つけ麺。むじゃきが近いのでなかなか行くタイミングがないが美味しい。

つけ麺

油虎(筑穂)

遥か北のほうにある人気の油そば。点Nは日曜に1時間並んだ。確かに美味しいのだが、行くなら相当の覚悟が必要。

左のはラー油

焼肉・しゃぶしゃぶれんが亭つくば店(春4)

焼肉。チェーンよりは高級で味も良い、らしい(点Nは焼肉の良し悪しがあまりわからない)

韓国料理 高麗(春4)

夫婦で営む焼肉屋。焼肉定食には7種のナムルがついてかなり手頃な値段。料理が届くまで割と待つので余裕をもって。

ナムル

とんかつ 純平(天1)

とんかつ。カツカレーの大盛りはかなり量があるらしい。

土浦魚市場(土浦市

食堂が併設されている。名物は土曜日のまぐろ食べ放題。まぐろだけでなく米も汁も煮物もサラダも食べ放題。

初期セット

ちゅら食堂(栗原)

ソーキそばが食べられる珍しい店。

左のごはんものはジューシーという

はりけんラーメン(栗原)

店構えは地味だがラーメンが洒落ており、バゲットとかが乗っている。

このティッシュにこのバゲット

こおひいはうす らんぷ(天3)

茶店。氷ミルクコーヒーのミルクはビーカーに入っている。

ナポリタン

ミルクレープ

ドルフ(天3)

学生向けの洋食。量はやや多め。

メニュー名を覚えてない。ガーリックでチキンなやつ

中華第一麺(天2)

多分本場に近い中華屋。刀削麺が食べられる。

店内にはメッセージを描いた付箋を貼るコーナーがある

やきとり 柳仙(竹園2)

歴史を感じる佇まいの居酒屋。訪れた際には焼き鳥だけでなくドライカレーもぜひ

Juwel of India(天3)

インドカレー。点Nはナマステキッチンに行きがちなのであまり利用しなかった。世界一甘い食べ物として知られるグラブジャムンが食べられる。

カレーとナンと仲間たち

2個はマジで余裕(個人差あり)

お好み焼き もんじゃ 鉄板焼 一太郎(天2)

お好み焼き。食べ放題もやっているようだが、果たして元は取れるのか。

めしや益さん(天3)

和食。お高め。

龍大衆麺処 真壁屋(天2)

活龍グループの、格安ラーメンの店舗。シンプルで値段相応の味。いろいろと限定メニューもやっている。

特級中華蕎麦 洋介(天2)

ラーメン。一風変わった洋二郎なるメニューがある。

洋二郎

志ち乃 つくば学園店(上野字向原)

バラエティ豊かなどら焼き。

灯禾軒(天3)

卒研の打ち上げで行った居酒屋。

薔薇絵亭(天2)

研究室の会合で利用した。ロシア料理の店らしい。

Bettei(天1)

研究室のイベントで使ったダイニングバー。

博多拉麺 一休(桜2)

つくばでは希少な博多ラーメン。知り合いの博多人的にはなんか違うらしい。点Nもなんか違う気がしている。

きくらげ

大学会館レストランプラザ 筑波デミ(筑波大学内)

大学の中ではかなり高いほうのレストラン。ドリンクバーとスープバーがつく

ハンバーグとカニクリームコロッケ




この記事はNovelbright『Walking with you』をBGMに作成されました


www.youtube.com

点Nはつくばで何を食べて生きていたのか(自炊編)

自炊の定義をかなり広めにとっております

はじめに

この記事は私が自身の大学生活を振り返るために書かれたものです。ひとり暮らし初心者の等身大の記録ゆえあまりボケる余地がないのですが、ぜひ最後までお付き合いください。ある意味でリアルな料理の写真が大量に掲載されているので、許せない人は画像を薄目で見るなどしてください。

食材の調達先

カスミ筑波大学店、通称ひらカス(地名の平砂ひらすなから)。大学の敷地内にある。品ぞろえはいいとは言えない。留学生が多い土地柄か海外の食品は豊富。開店が遅く、閉店が早い。桜にあるカスミは24時間なので羨ましい。今は毎日やっているが、以前は日曜定休だった。昔は300円ぐらいでそれなりに量のある弁当を売っていたが、今や見る影もない。卵10個のパックが100円で売っていた時代もあるが、今や見る影もない。寿司とか置いてないのが困る。

標準装備

調理器具その他

  • IHクッキングヒーター
  • 電気ケトル 1.2L
  • 深めのフライパン、20cm
  • 電子レンジ
  • 包丁(使わない)
  • まな板(使わない)
  • 皿(使わない)
  • 箸(使い捨てのやつを大量に実家から取ってきた)
  • クレラップ 幅30cmのやつ
  • キッチンペーパー

常設調味料

基本方針

  • 包丁を使わない。絶対に
  • 皿を使わない
  • 野菜と肉はなるべく食べる
  • フライパンをできるだけ汚さない。洗うのが面倒なので
  • 食費を削るための自炊ではないが、夕食なら700円ぐらいに収めたい(努力目標)

レシピ集

野菜炒め

暖かい時期の定番。

材料

  • お好きな野菜炒め用袋野菜 1袋
  • 豚こま 100gぐらい
  • 塩コショウ
  • サラダ油

作り方

  1. フライパンに油をひと回しして弱めの中火にかける
  2. 豚こまを投入して色が変わるまで炒める
  3. 念のため箸を変える
  4. 袋野菜を投入して気のすむまで炒める
  5. 塩コショウを10回ぐらい振る(蓋は小さいほう)

野菜炒め派生・焼きうどん

焼きうどん

材料

  • 野菜炒め
  • レンチンできる袋のうどん 1玉から2玉(今はないかも)
  • オタフクソース

作り方

  1. 野菜炒めを作る
  2. 並行してうどんを規定の時間レンジで加熱する
  3. フライパンに合流し、ソースを入れて軽く炒めながら混ぜる

豚と白菜の炒め物

材料

  • カット白菜 1袋
  • 豚こま 100gぐらい
  • サラダ油
  • 焼き肉のたれ

作り方

  1. 野菜炒めの作り方を、塩コショウ10回→焼き肉のたれふた回しに置き換えて読んでください

お好み焼きもどき

フライパンのテフロンが死んでからは作っていない。写真もない

材料

  • 卵 2個
  • ハム 4枚
  • 千切りキャベツ 1袋
  • サラダ油
  • 醤油
  • 塩コショウ
  • オタフクソース

作り方

  1. フライパンに油をひき、卵を落として溶く
  2. 卵かけご飯するならこれぐらいかなと思う量の醤油を入れる
  3. 塩分を気にしながら塩コショウを気持ち振る
  4. ハムをまんべんなく乗せる
  5. やっと弱めの中火にかけて加熱する
  6. 固まってきたら千切りキャベツを投下する
  7. 一体となったハムと卵をキャベツの上に頑張って持っていく
  8. キャベツが柔らかくなるまで待つ
  9. オタフクソースをかけるなどして食べる

寒い時期の定番。

材料

  • 豚こま 100gから200gぐらい
  • いい感じの袋野菜1袋 180gから350g(鍋用野菜が最適、カット白菜や野菜炒めのやつでも可)
  • 木綿豆腐 150gから400g
  • うどん 1玉から2玉
  • 卵(お好みで)
  • 寄せ鍋の素(プチっと鍋など、小分けになっていて水に溶かして使うもの)

作り方

  1. フライパンにマグカップ2杯ちょっとの水(700mlぐらい?)を入れ、中火にかける
  2. 寄せ鍋の素を、規定量を無視して1つだけ入れる
  3. 豚こまを入れて箸で軽くほぐす
  4. 安全のため手を洗うが、箸はどうせ加熱されるので変えない
  5. 肉のアクが出てくるが、どうしようもないので見守る
  6. 沸騰したら、こぼれないように注意しながら野菜を入れる
  7. 再度沸騰するのを待つ
  8. 先に木綿豆腐の水を入れ、次に豆腐本体を入れる
  9. 満足いくまで煮る(火をつけてから完成まで30分ぐらいが目安)
  10. あまり汁を減らさないように食べる
  11. 残りの汁にうどんを入れて満足いくまで煮る
  12. のぞむなら、好きなタイミングで卵を割り入れる

雑パスタ

パスタって大層なソースとかなくても十分美味しいと思うのですが

ハンバーグver.

材料

  • マ・マー 多くても300gまで(パスタ本体の味が重要なので妥協しない)
  • タキザワハムの冷蔵ハンバーグ1つか、ミートボール 2-3袋(最悪総菜コーナーのアジフライなど、なんでもいい)
  • アジシオ

作り方

  1. フライパンに水を張り、レトルトの何かを規定通りに加熱する
  2. レトルトのそれを取り出し、煮沸しているとはいえなんか気になるのでフライパンの湯を捨てる
  3. フライパンに再度水を張り加熱する(先にケトルで軽く沸かしておくのも吉)
  4. アジシオをひっくり返して中身を7秒前後入れ、沸騰させる
  5. パスタを入れ、規定より30秒ほど短くゆでる(一度にゆでる量は200g以下にする)
  6. パスタをシンクにぶちまけないよう細心の注意を払ってフライパンから水を切る(短めにゆでるのはこの工程でもたつくから)
  7. レトルトのそれを上からかける

ミートボールver.

アジフライver.

ハンバーグ丼

自炊しない日の定番

材料

  • タキザワハム お肉屋さんのハンバーグ 1つか2つ、味が違うとなお良い
  • パックご飯 1つ

作り方

  1. パックご飯のふたを全部はがし、ハンバーグを乗せる(2つは乗り切らないので端が少し重なってしまってよい)
  2. レンジで4分加熱する
  3. バーグと米は別々で食べたほうが美味しいのでそうする
  4. さすがに野菜を食べたいので袋サラダか袋千切りキャベツかなんかとともに食べる

シャウエッセン

数えるほどしか作ったことがない

朝ごはん寄りの夜ごはん

材料

作り方

  1. フライパンに薄く水を張る
  2. シャウエッセンを入れて加熱する
  3. 水が飛んでも続けて少し加熱し、米の上にのせる
  4. フライパンに油を敷いて目玉焼きを作る
  5. シャウエッセンの上に目玉焼きをのせる

ベーコン丼

ひとり暮らしの良さは誰にも邪魔されないことである

ベーコン丼

材料

省略

作り方

省略

(長いベーコンの時は米に乗せられないので別で食べる)

焼くのも難しい

たこ焼き丼

(笑)

米とたこ焼きは別で食べます

釜玉うどん

カスの昼ご飯

材料

  • レンチンできる袋のうどん 1袋
  • 卵 1個
  • 醤油

作り方

  1. うどんをレンチンする
  2. 紙コップに卵を割り入れ、醤油を少し入れて混ぜる
  3. うどんをつけて食べる

クリスマスケーキ

ホールケーキを食べよう

下に敷いてあるのはピザのやつ

材料

作り方

  1. いちごを洗い、半量を半分に切る(いちごはやわらかいので爪楊枝を刺して回転させれば包丁を使わずに切ることができる←でも最近の箸って爪楊枝入ってないな…)
  2. ラップの上にケーキの土台を乗せる
  3. 使い捨てのスプーンなどを使って断面にクリームを塗り、切ったいちごを並べる
  4. 上半分のケーキを乗せ、表面全体にクリームを塗る
  5. 完璧にはならないが、道具が道具なのでほどほどのところで諦める
  6. 余ったクリームと切ってないいちごでいい感じにする

番外編・お茶

  1. 湯を沸かす
  2. ペットボトルのおーいお茶をマグカップに1/4程度注ぐ
  3. 湯を入れる

主張

いい感じに冷めるし一番楽なんだってこれが、味薄いけど別に許容範囲だし、縁起悪い気がするのは気にしなければいいと思います

番外編・その他重宝した食べ物

  • レンチン焼売…ハンバーグ丼に飽きた日に。
  • レンチン餃子…同上。フライパンで焼くものより値が張る
  • おでんパック大…鍋に飽きた日に。残り汁は米で
  • 絹豆腐…暑い時期の追加の一品。醤油をかけて
  • 納豆…追加の一品。期限間近でお安いときに
  • 海苔佃煮…ハンバーグ丼のあとの米を消化する

おわりに

適度に外食とか冷食とかインスタントラーメンを挟めば手札がこれだけでも4年ぐらいなら生き延びられると思います。参考にしたい人は勝手にしてください。




この記事はBUMP OF CHICKEN feat. HATSUNE MIKU『ray』をBGMに作成されました


www.youtube.com

阿波踊り直前の徳島を歩く

阿波踊りはその名の通り阿波の国、今でいう徳島を発祥とする盆踊りである。日本三大盆踊りのひとつ(あとどこだよ*1)であり、また四国三大祭りのひとつ(よさこいと、あとどこだよ*2)にも数えられる。

毎年徳島市で開催される徳島市阿波おどりは特に盛大なものであり、訪れる観光客の数は100万を超える年もあったという(徳島市民の総数は約25万である)。

2024年の徳島市阿波おどりは8月11日から15日にかけて行われたのだが、ちょうどその直前の6日から10日にかけて徳島を訪れていたので、そのときの記録を残したいと思う。といっても、私的な旅行ではなく大学での活動の一環であるので日中は旅行らしいことはできていないのだが(私的な旅行なのに阿波踊りの時期をスレスレで外すわけ、ないだろ)。

Day1 移動

8月6日。つくばから徳島に行くには素直に飛行機を使うのが早い。地元の空港広島空港がきわめて不便な場所にあり、普段飛行機に乗る機会がないため、実に高3の修学旅行(台湾)以来5年ぶりとなる(パスポートの更新がまずい気がしている)。ついでに言うと、一人で乗るのはこれが初めてである。

例のごとくつくばからTXで秋葉原へ。山手線で浜松町まで行けば、あとは東京モノレール一本で羽田に着く。このためにわざわざ買ったスーツケースをそれらしい場所に置き、窓の外を眺めながら(天王洲アイルってあの辺なんだ)快速に揺られること約20分。羽田空港第二ターミナルに到着した。

さて、空港に着いたら搭乗券を発行せねばならないのだが、どうやら振り込み時に表示される重要な(そして受付メールに書かれていない)番号が必要とやらで少々面倒なことになった。ANAのお姉さんにマズい旨を相談すると人が対応している長蛇の列に並ばされたが、待っている途中で写真フォルダにその番号が表示されている画面の写真があることに気づき、無事に機械で搭乗券を獲得することができた。この私がこれほど重要なものを本当に失念しているわけがないのだ。

スーツケースを預け、保安検査を無事に掻い潜り、搭乗口がある建物までのバスに乗る。初めてでもなんとかなってよかった。

出発まで暇だったので建物内をうろついてみたりもしたが、あまり面白いものはなかった。念のためトイレにも行ったのだが、最近の洒落た建物にありがちな秘匿性ゼロのタイプのやつだったような気がする。嬉しくないので早いところ廃れてほしい。

行きの飛行機

座席は窓際の翼が見える位置だった。窓に空いている小さな穴はなんだろうか。まあなんか気圧のアレやろ*3

窓から翼がよく見える

羽田HNDから徳島TKSまでのフライトは1時間と少し。 目の前のモニターをいじり、サービスのジュースを飲み、外の景色を見て、飛行機の羽がしなったりトランスフォームしたりするのを眺めていればあっという間である。上空の大気がやや不安定だったようだが、無事に飛行機は徳島阿波おどり空港に到着した。前方に座っている子どもが見ていた映画はおそらく面白いシーンにさしかかったあたりだろうか。

モニターいっぱいの阿波踊り、阿南高専の作った阿波踊りロボなど、阿波踊りアピールの激しい徳島空港からはバスで徳島駅前まで移動。同じ用事で来ている人が多いので乗車できるか不安だったが、ギリギリ補助席に乗ることができた。

Day1 夕方

軽く*4駅ビルを散策したあと、重たい荷物をパージするために一度ホテル(東横イン徳島駅前)にチェックイン。部屋で簡単に駅近辺の情報をスマホで調べ、ふたたび駅前に戻ってきたのが17時過ぎ。今日と最終日以外は行動が大きく制限されるため、このままホテルのベッドで力尽きるわけにはいかない。

徳島駅

阿波踊りが乗っかっている駅前のポスト

まずは駅ビル内の和田乃屋で滝の焼餅を食べる。滝の焼餅は徳島の歴史ある和菓子で、菊の紋が押されているのが特徴である。味はシンプルな白のほか抹茶と胡麻の計3種類あり、どれも見た目通りの安心感のある味。あんこの甘さと冷たいお茶で少し回復し、この後の散策の準備を万全にする。

滝の焼餅とお茶

建物を出て、駅前から続くポッポ街商店街に入る。少なくないテナントにシャッターが下りており、かなりさびれた印象だったが、しかし駅前だけあって人通りはあり、気になる店もいくつか見つけた。

駅前側から見たポッポ街商店街

中ほどから後ろを振り返る

最後のほうまで歩くと案内が掲げられていた。かつてはフランチャイズアニメイトなんかも入っていたらしいが、数年前に移転、同時に直営化されるなどしたらしく、ここには残っていない。ちなみに直営化により各種キャンペーンで「アニメイト徳島を除く」という表示がなくなったのはインターネット上でそれなりな話題になったらしい。

むしろ生き残っている店舗に強さを感じる案内

18時。商店街を抜け、今度は踏切をまたいで駅の裏側に行ってみる。ちょうど電車汽車が煙を吐きながら出ていった*5

前からも後ろからも撮る

駅の裏には広大な徳島中央公園がある。武道館やら小学校やら博物館やらもあって構造がよくわからないが道なりに歩いてみることにした。

武道館

公園のトイレだが、ピクトグラム阿波踊り

公園の北側を歩いていると夕日が見えたので写真を撮った。大きな雲が出ているが、まあよしとする。

ぎりぎり夕日が見える

公園の北東にある助任橋すけとうばし*6で夕日リベンジのためしばらく待ってみたが、結果はさっきと同じ。

もう見た

引きつづき公園を散策。水場に鳥が集まっていた。

終始こちらを気にも留めなかった

階段の上には城跡などもあるらしいが、日も落ちてきていたため撤退。道中気になったものをまとめて載せておく。

父の像、藩祖の像、機関車、タイムカプセル、アンパンマン列車

Day1 夜

19時前。そろそろよい時間なので来た道を戻り、ここからは本日のメイン・眉山びざんをめざす。国道沿いは何度見ても阿波踊りムードである。

ここで踊るんだろうな

駅前。ここからまっすぐ歩く

駅前から南に750mほど進んだところにあるのが目的の阿波おどり会館眉山のロープウェイはこの建物の5階から生えている。

阿波おどり会館

暑い中歩いて喉が渇いたのでまずは1階の物産コーナーで涼むことにした。徳島の名産であるすだちのジュースを買い、会計を済ませてすぐに缶を開けた。

ジュースを飲んでいると、同じ用事で来ている後輩の一行がやってきた(あまり面識がないのでこちらは全然気づかなかったが)。軽く会話を交わしたあと、彼らは帰るようなのでとくに合流するでもなく、自分ひとりで上を目指すことになった。

似たようなドリンクいっぱい売ってた

あっという間に中身を飲み干した缶を捨て、建物の上へ。2階では無料で配られているうちわをもらった。装備が財布を入れる小さなバッグのみのため、この後ホテルに戻るまでずっと持ち運びに苦戦したが、暑さを多少和らげてくれた。

かわいい自販機(おへんろ。)、かわいくはない自販機(大谷翔平)、かわいい自販機(初音ミク

1000円と少しを払ってチケットを買い、少し待つとすぐにロープウェイがやってきた。アナウンスを聞き流しながら暗くなっていく徳島の街を見下ろしていると、ロープウェイは数分で展望台駅に到着した。

ロープウェイ

展望台へはさらに階段を上に。道中には阿波踊りポスターがところ狭しと貼られている。

徳島はufotableの拠点であり、代表の出身地でもある

眉山は日本の夜景百選にも選ばれた場所である。もちろん自分も夜景目当てでここまでやってきたわけだが、まだ完全に暗くはなっていないので先に軽く近くを散策。何やら建物の近くで写真を撮っている人がいたので、その人が去るのを待ってから見てみると、ここには日本に1000弱ある一等三角点のひとつがあったようだ。といっても学がないので測量のなんか大事なやつぐらいの認識しかないし、あまりコメントすることが思いつかない。

眉山の一等三角点(本質は左下の石)

さて、うろうろしていたらいい感じに暗くなってきたのでいよいよ夜景とご対面。徳島市街の明かりがきれいに見える。とはいえ、ここに限らず夜景というものは仲のいい友達か、可能なら彼女とかといっしょに来たほうが楽しいと思う。今回はそういう旅行じゃないので仕方なし。

眉山から見る徳島の夜景

展望台にはカップルが南京錠をかけるための場所があったり、横の無料休憩所内にはカップルで鳴らしたい感じの鐘があったりした。あと、眉華鏡まゆげきょうという大きなモニュメントがあったりもするのだが、これは使えなくなっていたようだった。

Fateの世界に阿波踊りはあるのだろうか

19時45分のロープウェイで下山したあとは夕食である。せっかく徳島に来たので初日の今日は徳島ラーメンの店に行くつもりなのだが、Googleの情報によると閉店(実際はラストオーダーのようだ)が20時30分のようで、少し早歩きで駅前方面へ戻る。

新町川にかかる橋を歩いていると、地面のタイルの模様が気になった。少し引いた位置からよく見てみると、

隙さえあればねじ込んでくる

隅々まで徹底している。

街では夕方からずっとどこからか阿波踊りのリズムが聞こえているのだが、その音が大きくなってきたのでそちらを向くと、本番にむけて練習している集団が見えた。

阿波踊りの練習風景

20時頃。本日のお店、中華そば萬里に到着した。内装は昔ながらのラーメン屋といった感じ。メニューは中華そば、餃子などいくつかのサイドメニュー、あとはビールと、かなりシンプル。店主が一人でやっているようなのでそれもそうか。中華そばは卵と肉の有無、そして大小を選べる形。こういうときに躊躇するのはよくないので、肉卵入りの大を注文。徳島ラーメンはスープの色によってさらに分類されるらしいが、見た感じこれは茶系と呼ばれるものだろうか。肉がしっかり入っていて美味しかった。阿波踊りの時期は営業時間に変更があるとの張り紙があった。

中華そば肉卵入り・大、1000円

このままホテルに戻ってもよかったが、明日から日中動けないので、地元のスーパーにでも行ってそれっぽいものを調達しておこうという気持ちになってきた。徳島の夜は早く、20時を過ぎるとたいていの店は閉まっているのだが、今からでも入れるスーパーを検索したところ、1kmちょっと離れた場所にはなるが深夜まで営業している店舗を見つけたのでそちらに向かうことに。

飲み屋の並ぶ繁華街を抜け、さらに歩くと目的のキョーエイ中央店。棚をあちこち見て回り、いくつかそれっぽいお菓子やお酒を買った。金ちゃんヌードルは一度手に取ったが別に地元広島でも買えるので棚に戻した*7。他にはヤクルトに似たスタミンという乳酸菌飲料が気になった*8

ポッポ街商店街近くの自販機や阿波踊り会館近くの自販機で見かけて気になっていた阿波踊り専用エナジードリンク(?)・アワライズがここで買えたりしないか少し期待していたのだが取り扱っていなかった。仕方がないので遠回りだが阿波おどり会館経由で戻ろうとしたところ、割とすぐに売っている自販機を見つけたので即座に買ってホテルに戻った。戻る道中アワライズのある自販機をもう一つ見つけた。

戦利品は以下の通り。これらは毎日少しずつ消費していった。ぶどう饅頭は薄い皮で包まれた餡を団子状に串に刺したお菓子、金のしずくは蜜の入った芋の饅頭である。

  • Theすだちサワー
  • 木頭ゆずチューハイ
  • POM愛媛かんきつミックス(愛媛だけど今年新発売だったので...)
  • アワライズすだちZERO
  • アワライズゆずすだち
  • ふどう饅頭
  • ぶどう饅頭塩あずき
  • 金のしずく

戦利品

ちなみにポケットに入れたiPhoneが計測したこの日の歩数は約23600歩。動き出しが少々遅いものの、徳島に着いてからは歩きっぱなしの一日だった。

Day2

8月7日。徳島滞在中、朝は東横インの無料朝食で済ませた。ICPCで訪れた横浜では麻婆豆腐が出ていたように、無料ながら若干の地域性があったりする。鶏肉(徳島の阿波尾鶏なのかは考えないことにする)とか、わかめの何かとか、フィッシュカツっぽいものとかがあった気がするが、寝ぼけているので記憶が定かでない。

滞在中の食事については初日に集めた情報と毎日の予定とをもとに計画をある程度まとめており、この日の昼はポッポ街商店街にある喫茶店TOMMYへ。ブラックベリーのケーキとソーダのセット(期間限定?)を注文した。

ブラックベリーケーキとブラックベリーソーダ、セットで750円

店内にはゆったりとした時間が流れており、地元のお客さんの会話をなんとなく聞きながら過ごした。あまり時間がなかったため少し時間を気にしながらになってしまったが、居心地のいい場所だったのでもう少しのんびりしていたかった。

夜は駅の近くにある洋食店、はなや食堂へ。店内に大きなランチの絵が飾られていたのと、赤と青のボードにびっしり書かれたメニューが印象的だった。店の前で何を食べるか散々悩んだにもかかわらず席に着いてからも少し悩み、結果としてバラエティセットを注文した。ベーコンエッグの乗ったハンバーグ、唐揚げ、チキンカツ南蛮にライスと味噌汁と漬物がついてくる。昼を軽く済ませたのもあって比較的空腹だったのだが、大ボリュームでお腹いっぱいになった。他のお客さんが食べているメニューも量が充実していた。

バラエティセット、1280円

自分は店の奥の方のカウンター席に座って食べていたのだが、偶然同じ大学の後輩グループが入店してきて、入口近くのテーブルに座った。席が遠いのもあってこちらには気づいていないようだった(目の前でお会計もしたが)。

Day3

8月8日。この日のお昼は駅前のショッピングセンター・アミコの地下にあるうどん店・ふなもと。サービスランチにはうどんのほかにおでんと爆弾コロッケなどがついてくる*9。麺が不ぞろいなのが特徴。コロッケは胡椒が効いていた。

サービスランチ770円

夜はイベントの懇親会で、少しだけ酒を飲み、寿司やサツマイモの天ぷら(鳴門金時であってくれ)などを食べた。

そういえばこの日は九州のほうで大きな地震があり、それを受けて気象庁から南海トラフ地震臨時情報が出された。徳島は特に何もなかったが、旅先にいるタイミングでこういうことが起きるのはあまり気持ちのいいものではない。家にいても嫌ではあるが。

明日はきっといいことある歩道橋

Day4

8月9日。お昼はポッポ街のカレー屋、だいきちカレーの徳Tokuランチ。初日に軽く見た感じ席数が多くなさそうだったので昼の行動時間が長めのこの日に訪れたが、やはり店に入るまで数分ほど待った。ライスはウコンと一緒に炊かれているらしい。揚げた野菜が甘くて美味しかった。

Mサイズ700円。映っていないがサラダもつくしコーヒーも飲める(飲むの忘れた)

夜。遅くなってしまい駅前で空いているのはどこにでもあるチェーンか、または一人で入るには厳しい居酒屋しかない状況。仕方ないのでホテル近くのローソンに行った。阿波踊りの関係でイートインが大量の飲み物で封鎖されていた。期間中は駐車場も封鎖するらしい。なけなしの徳島要素として、金ちゃんヌードルと、(徳島の店が監修した)豚バラマヨおにぎりを買い、ホテルに戻って食べた。

コンビニでお湯を注いでから5分が経過し(規定は3分)ややのびていた

金ちゃんヌードル、食べる前は食べたことないと思っていたのだが、食べたら食べたことあると思い出した。

余談ではあるが、金ちゃんヌードルを見るとCharlotte(2015年にやってたアニメ。徳島は特に関係ない)を思い出す。7話、最高ですよね。もう9年経つけど結局友利奈緒しか勝たんのですわ

Day5

8月10日。翌日から徳島市阿波おどりだというのに今日が最終日である。ANAからは空港が混むから早めに来いよとのメールが届いた。フライトは15時すぎなので、お昼ぐらいまでは自由時間である。とはいえ徳島市内でやりたいことはあらかた済ませているので、鳴門のほうに行ってみることにした。10時に大慌てでチェックアウトしたため渦潮を見るにはさすがに時間が足りないが、それは昔行ったことあるからいいか。

驚いたことにJR徳島駅は今でも自動改札機が導入されておらず、改札に駅員が立っている。時刻表を見ると鳴門行の列車は1時間に1本しか出ておらず、待つとなると相当なタイムロスになることがわかったのでJRを使うことは諦めた。仕方ないので駅前のバスターミナルでそれらしいバスを探すと、ちょうど鳴門方面のバスがあったのでそれに乗車した。当然だがバスでもICカードは使えない。

バスに乗りながら財布を確認すると、小銭は運賃に満たないほど少ししか入っていない。紙幣はもちろん十分な額を持っていたのだが、諭吉が少しと、流通し始めてから1か月しか経過していない柴三郎が数人。野口がいない。面倒なことになった。万札も新紙幣もこの手のバスの両替機には対応していない気がする。運転手にわけを話せば対応してもらえるかもしれないが、だとしても嫌な顔をされそうだし、本当にどうしようもなかった時が困る。苦肉の策として目の前のお婆さんに野口と柴三郎のトレードを交渉し、了承してもらった。

無事にバスから降りると、マスコットキャラの石像が出迎えてくれた。

うずひめちゃんとうずしおくん

鳴門のマンホール

商店街を適当に歩いてみたが、そこまでめぼしいものがなかったので駅前の大きなキョーエイを目指して歩くことにした。道中、公園があったので立ち寄ってみると、機関車が展示されていた。

撫養第3公園

C11 66

しばらく劣化が進んでいたようだが、有志の保存活動なども行われ、何か月か前には塗り直されたらしい。機関車の中にも入ることができ、そこには機関車を操る人形が座っていた。

内部のようす

公園が面している通りはちょうど開催中の鳴門市阿波おどり仕様になっており、バス停が一時的に潰されていたりした。

期間中、バスの運行経路から外れる鳴門駅西停留所

祭りは夜なので昼は無人

キョーエイに到着したのでいったん1階のスーパーで冷たいはちみつレモンを購入。空港までのバス移動に備え、千円札を崩して十分な量の小銭を入手することに成功した。

同じく1階にはとくもとという惣菜店があったが、すでに多くの商品が品切れのようで、名物らしい巻き寿司はなかった。また徳島市内の給食でおなじみのゼリーも見つけることができず(ケーキっぽいのはあったが)、結局何も買わずに上のフロアへ。

2階と3階は服や薬や少しのおもちゃといった、特に用のないよくあるそういうフロアだったので通過。最上階の4階はゲームコーナーになっていた。

田舎町のこの規模のスーパーの上階にありがちな少し古いゲームが並ぶ場所で、懐かしい気持ちになった。ドラえもんの運転するやつなんかもう何年も見てない。石ちゃんのグルメパニックって何。

カードは尽きたが未だ生き残っているドラえもんの運転だいスキ、人生で初めて見た石ちゃんのグルメパニック、日焼けしすぎているソーナンスがころんだ!、アームの軸がやばそうなカプリチオG-One、いつしか本物の風船を取り付けなくなったバルーンショット

時代を感じる筐体の中に黒炎の支配者*10が入っている温度差。阿波踊りの旅行客と思しき浴衣のグループも面白そうにゲームコーナーを見ていた。行ったときは誰もいなかったが、ゲームコーナーには小さい子向けの遊び場も併設されていた。人形が動きそうな時計が壁にかかっているのが気になった。実際に動くところは見ていないが。

ところでここにはかつてフードコートがあったようなのだが、今はその看板が残っているだけでもうない。いちおう写真の奥にあるのがそれで、中を覗いてみると石鹸のボトルや返却口と書かれた棚、余った割り箸などが確認できた。

今は数台の自販機とテーブルが置いてあるだけの休憩所になっている

キョーエイを出て駅に戻った。鳴門駅鳴門線の終着駅のため、線路はここで終わり。写真は撮っていないが外からでも終端部分を見ることができる。

鳴門駅。左手奥がさっきのキョーエイ

バス停には足湯がある。時間微妙だしタオルの用意がなかったし暑いから入らなかったけど。

画質が悪くて潰れているが足湯のところで風車が回っている

このあたりで何気なくTwitter*11を開いたのだが、ブラジルで乗員乗客が全員死亡する飛行機事故があったとの情報が目に入ってきた。国内線で乗客に死者が出る航空事故なんて長らく起きていないと認識しているが、今から飛行機に乗るぞというタイミングでこういう事故が起こるとさすがにちょっと怖い。

バスを待っていると自分と同じ用事で徳島に来ていた北海道の大学の人に話しかけられた。そのまま空港に戻るバスの中でも少し話をした。

空港に着いてのんきにラーメンを食べていたら保安検査の列が大変なことになっており、急いでお土産の金のしずくを購入。自分は初日に買った分をすでに食べているのでだいぶ無難な選択*12

渦潮ラーメン、1000円

自分用に徳島銘菓の小男鹿さおしかをどこかのタイミングで入手しようと思いつつここまで来てしまっていたのだが、空港の売店にはしっかりした大きさ(と値段)のものしか置いておらず購入を断念した。ちょっと心残り。

あとは、徳島滞在中にポスターなどで何度も見かけたししゃもねこ*13の何かしらとかも買っておいたら面白かったかもしれない。

金のしずくの箱を鞄に少々強引に詰めて列に並び、ゲートを通過して待つ。先に飛ぶ北海道行きの飛行機の客が揃わないのか空港のスタッフが殺気立っていたが、おそらく定刻から大きく遅れてなんとか飛んでいった。自分が乗る羽田行きの便は特に大きなトラブルもなく徳島を飛び立った。

さらば

機内サービスのジュースを飲んだ記憶はギリギリあるが、それ以外はほぼ寝ていた。羽田に着いてからはまぁ、特に面白いことはない。しいて言うなら通路が長すぎてウケたぐらい。

おわり。初日に全部やったおかげで結構楽しかった。

追記

東横イン徳島駅前すぐの交差点のモニターの横にデビルマンがいるの、今これ書きながらはじめて知ったんですけど、本当ですか?何回も通ったし何回も見たのに全然気づかなかったんですが…




この記事は吉田夜世『オーバーライド』をBGMに作成されました

www.nicovideo.jp

*1:西馬音内の盆踊@秋田と郡上踊り@群馬

*2:新居浜太鼓祭り

*3:気圧のアレであり、同時に結露のアレでもあるらしい

*4:といっても地下の土産屋まできっちり見ている

*5:徳島を走っているのは電車ではなく気動車である

*6:その名の通り助任川にかかっている。工事中で車が通れなくなっていたが歩くことはできた

*7:その割には食べた記憶がない

*8:どちらかといえば愛媛のようだが、四国の民に愛されている飲み物らしい

*9:何種類かから選べたはずだがこれ以外の選択肢は覚えていない

*10:2023年夏に出たポケカ

*11:青い鳥のままバージョンアップをしていない

*12:後日地元のお友達に配りました

*13:徳島生まれのキャラ。頭が猫で体がししゃものゆるいキメラ

ICPC2023 Asia Yokohama Regional 参加記(pointN)

pointNです。

ICPC2023アジア横浜地区大会に筑波大学からBig O of N cubedとして参加したのでそのことを書きます。

チームについて

チームメイトはniuezさんとruthenさんです(以下敬称略)。参加時のAtCoderのレートは3人とも2000ぐらいでした。全員前の年までは別のチームで活動しており、チームを組むのは今年が初めてでした。チーム名は要するに \mathcal{O}({N}^{3}) であり、由来は3人のハンネの最長共通部分列がnだからです。pointNとruthenは年齢の制約上今年がラストイヤーでした。ちなみに筑波にはもっと強いGoodBye2023というチームがおり、つまりBig O of N cubedわれわれは二軍です。

データ構造やアルゴリズムに対する知識量が他より多く重実装にも強いniuezが実装の中心になり、それを感覚寄りのpointNと理論寄りのruthenがいい感じに支えたりそうでなかったりというスタイルのチームでした。

金曜日(前々日)

大会後に持ち越すとまずい宿題があったのでそれをやり、残りはそわそわしていました。チームメイトはライブラリを準備してくれていたりしました。この辺、任せっきりになってたのは申し訳ないと思っています。恥ずかしながら蟻本すら持っておらず、螺旋本ぐらいしかないのでまあそれは持って行くねーとだけ言いました。

土曜日(前日)

11時に秋葉原で集合ということになっていたので、頑張って起きてTXに乗りました。途中で競プロイベントで使えそうな名札を忘れたことに気づきましたが、もう遅いし、どうせ知り合いも少ないので気にしないことにしました。niuezは先に秋葉原に行ってゲーセン行ってたっぽいです。改札前に待機していると上半身オレンジでめちゃくちゃ目立つ顧問(YesNoおじさんとして知られるアランニャ・クラウス先生)が来たので合流、まもなくniuez、ruthenの順に到着しました。GoodByeの人たちは別行動するらしいので、4人で京浜東北線に乗って横浜に向かいました。

僕は横浜に行くのは初めてだったのですが、練習で解いた問題の話をしていると思ったよりすぐ目的地の関内に着きました。駅を出たところに野球場があり、しかもこの日はファンミーティングがあってユニフォームを着ている人が多かった(このせいでホテルが取りにくかったそうな)ので、道中は野球の話をしていました。とはいえ、チーム全員あまり野球に明るくなく、どの球団がセ・リーグパ・リーグなのか、DeNAベイスターズって同一の球団だっけ、などと、かなり程度の低い会話をしていました。広島出身のpointNはもうちょっとプロ野球に精通しているべきだと思うのですが。

野球場

そこから中華街でお昼を食べました。金香楼というお店で、壺料理を推しているお店でした。店内には水が流れていました。僕がAランチ(スペアリブ壺スープ)、ruthenとniuezがCランチ(牛バラ壺煮)、Y/NおじさんがDランチ(麻婆豆腐)を選択しました。らっきょうを食べたことない人(niuez)とか杏仁豆腐を食べたことない人(niuez)とかがいて、世界は広いなあと思いました。

Aランチ

集合時間までには余裕があったので中華街を歩いて産貿ホールに向かいました。ずっと産貿ホールのことを産ホールだと思っていたのは内緒です。お昼時で人が多く、それなりに荷物が多い自分は歩くのが大変でした。鳥の揚げたでかいやつがおいしそうでした。

産貿ホールに着いてすぐ受付が始まりました。英語なのでちょっとだけ異世界感がありました。自分たちのスペースは入口にかなり近い位置でした。机上にはリハーサルで使う触れてはいけない封筒、名札、Tシャツなどが置かれており、名札はつけて、Tシャツは着てねーというアナウンスがモニターに映っていました。Tシャツを着るのは日曜の本番だけだと思っていたので、パーカーの上から半袖Tシャツという、かなり奇抜なファッションになってしまいました。

リハーサルは4問で行われました。システムを使うのは初めてだったのですが、なんとなく理解できました。Dで伝達ミスがありniuezに違う問題を解かせてしまいました。ごめんなさい。すぐ修正してくれるので心強かったです。ちなみに、リハーサルに置かれていた問題は横浜が初めての自分は知らないものでしたが、niuezが正しいほうのDを実装しながらなんかこれ書いたことある、などと言っていました。Clar質問の練習~とか言って、中華街でおすすめの料理店を聞いたりしました。関係ないことを聞くのはやめたほうがいい気がします(ちなみにYaku-Shoronpoという答えが返ってきたのですが、終わってから検索してもいまいちわかりませんでした)。

その後はチーム紹介でした。順に壇上に上がって30秒で簡単に紹介していくものでしたが、マイクを通した英語は全然わからないし、チーム数多いし、知り合い全然いないしでほとんど記憶に残りませんでした。インパクトのあるスライドと簡単なフレーズを意識するのが大事そうです。右後ろにSpeed Star(強者揃いの東大チーム、翌日ぶっちぎりで優勝する)がいることに気づき、はえーと思っていました。

終わってから一度ホテル(東横INN横浜スタジアム前2)に戻って荷物を置き、夜の中華街に3人で繰り出しました。最初に中華っぽい食品を扱うスーパーを覗きました。僕以外の二人は何か買っていました。

おなかがすいたので、お昼を食べた金香楼の隣にある東光飯店というお店に入りました。pointNが名物っぽい東光チャーハンを、チャーハンに目がないniuezも何らかのチャーハンを、ruthenはエビの入ったラーメンを頼み、別途、回鍋肉と小籠包を注文しました。スープが飛び散って美味しくなるなどと競プロerにしか通じないネタで盛り上がりました。3人で割って1800円ぐらいだったと思います。

その後は中華街をゆるっと歩きました。さっきの中華スーパーに戻り、pointNは5個入りの胡麻団子を、niuezは肉まんを買いました。niuezが肉まんの大きさに驚いていました。胡麻団子は僕が独り占めしてすべて平らげたのですが、あれは2個目がピークです。好きなので5個なら平気ですが。その後、pointNとruthenはパンダまんを買いました。率直な感想として、僕は「中のあんこは美味しいけど外の皮が観光客の味がする」と述べました。

パンダまん

ホテルに戻り、一人で食べるには多いと、ruthenが中華スーパーで購入したバナナチップスを分けてくれました。お腹に余裕がまだあったので受け取り、明日7時にホテルで朝食という約束を再確認して解散しました。バナナチップスは自室でポリポリ食べました。

甘いものが連続して少し辛い物が食べたくなり、一人で近くのコンビニに行きました。レモンの炭酸と、カラムーチョを選択しました。お風呂に入ってから食べたのですが、カラムーチョは意外と量が多く(しかもちょっと増量キャンペーン中)、後半食べるのに苦戦しました。ABCもあったのですが、普通に無理なので寝ました。

寝たはいいものの、3時、5時、6時などに目が覚めてしまいました。途中、昔の同級生が出てくる夢を見たのですが、残念ながら全然楽しくないストーリーでした。トコジラミツイッターのトレンドに入っていたりして若干不安だったことなども眠りが浅かった要因かなと思っています。

日曜日(当日)

無事全員起床に成功し、ホテルのバイキングに並びました。麻婆豆腐があって珍しいと言ったらruthenから「中華街だからでは」という真っ当な指摘が入り、なるほどと思いました。自分たちが席に着いた頃から急に客の列が長くなり、早めに行って正解でした。

その後はスマホの充電器を忘れそうになりながらも荷物をまとめました。起きたらちょっとだけ残しておいた炭酸が凍っており、その画像をツイッターにアップし、飲まずに部屋に忘れました。3人そろってチェックアウトして集合場所(隣のホテルのロビー)に行きました。GoodByeの人から、近くの台湾チームに貰ったというパイナップルケーキをもらいました。いくら競プロerとはいえ、"闇討ち"というフレーズがすぐに浮かんでしまったのは反省です。コンテスト後にでもありがたくいただくことにして出発しました。ギリギリ傘がなくても耐えそうな雨が降っており、寒かったです。

会場で受付を済ませ、自分たちのスペースに入って準備をしました。荷物は半透明の袋に詰める必要があったのですがそこまで大きくなく、詰め放題の主婦の要領で袋を引き延ばすことでどうにか入れ込んだのですが、気づくと袋が破けてしまっていました。スタッフの人に袋を指さしながら Excuse me, my plastic bag is broken ... などと怪しい英語で事情を説明すると一回り大きな新しい袋を持ってきてくれました。Thank you.

会場

ほどなくして、コンテストが始まりました。初動は、ログイン情報の入った小封筒の開封とPCのセットアップをruthen、Aをniuez、問題文の入った大封筒の開封とBをpointNという分担で動きました。Bの問題文が若干難しく、僕がモタモタしている裏でAはあっという間に通っていた印象です。後で問題を見返したら普通に重実装だったので、niuezがうまくはまったという印象です。

いっぽうBを読んでも全然解法がわからない僕はniuezと協力してBを、ruthenが以降を眺める方針をとりました。niuezがLazy Segment Tree有名ながらおよそBで使うとは思えないデータ構造を使った解法に気づいたので、完全に信頼して僕も問題読みに加わりました。

Fが驚くほど簡単で、ruthenに軽く説明をし、Bを通したniuezにruthenから説明を行い(僕の説明があまりにも雑で窘められた、リハーサルのことがあったので完全にruthenが正しい)、後は実装マシーンniuezに任せました。Dを読んで区間DPっぽいことを確認したり、Kのインタラクティブなどを読みました。インタラクティブを優先的に読んだのは、形式の違う問題なら多くのチームに解ける可能性のある難易度調整にしているだろうというメタ読みをしていたためです。他にもいろいろ読んだのですが、僕の力では解法に至れる問題はありませんでした。

ruthenとniuezがFとDをやっている間にKを考えました。円の位置と大きさを特定する問題なのですが、とりあえずの大まかな位置の特定にかなりのクエリ数を使うため、残りの部分がなかなか詰められませんでした。その間にFDが通り、他は解法すら浮かばないのでKをとりあえず書いていくことになりました。乱択でクエリ数の期待値を減らすなど迷走し、二分探索で行ける、いやこれ三分探索にならないか、いややっぱり二分探索か、にしてもこれ場合分け面倒すぎませんか、などの議論を経て、算数を頑張ることで比較的シンプルに解ける方針を思いついたので、それを実装しました。しかしバグらせてしまい、いったん印刷してPCを離れたりしました。

その後はniuez-ruthenチームとpointNチームで唸る時間があったような気がします。なんやかんやあってKのバグに気づいたので修正し、サンプルが通ることを確認しました。他のサンプルがなくて不安などとほざいていたらniuezがインタラクティブツールの何かしらをいじってうまいことやってくれました。大感謝。それも通ったので問題ないと判断し、投げるとACが返ってきました。

その後は全員でGとTLEしているEを考えました。途中でトイレに行きたくなったのでスタッフに Can I go to the restroom? などと言い、連れて行ってもらいました。戻ってきて僕もruthenにEの解法をなんとなく聞いて高速化の検討をしたのですが、あまり活躍できませんでした。niuezがEのゼータメビウス変換?とかいうやつを思いつく裏で定数倍高速化を頑張ったruthenがそのEを通し6完、終了間際にniuezが投げた愚直のGはWAが返ってきました。

システムトラブルがあったチームがあるらしく(GoodBye?)、延長したりしなかったり、変な感じでコンテストは終了しました。途中、なんかやばそうなアナウンスがあった気がしますが、大丈夫だったようでよかったです。

コンテストが終わり、いつの間にか配られていたまい泉のすずらん弁当を食べました。配られたドーナツも食べました。僕はポン・デ・リングの亜種(ショコラ?)をもらいました。スポンサーの紹介を聞き、解説を聞き、我らがYesNoおじさんによるYes/Noがありました。Big O of N cubedは27位でした。3の3乗なのは何の因果でしょうか。個人的には、全員がACに貢献できたのは平和でよかったと思っています。

懇親会では大量の食糧が置いてあったのですが、昨晩のカラムーチョのせいかあまり食べられず、また知り合いが少ないのでやや心細い思いをしました。niuez周辺に人だかりができており、ゲームとかそういうので繋がりがあるのかな~と思いました。企業ブースも何ヶ所か回り、いくらかの戦利品を入手しました。Fixstarsのクイズに正解してポロシャツをもらったのが特に大きかったです。その他、先輩であるTsumoiYorozuさんにご挨拶をしたり、ITF.PCでお世話になったtatyamさんにお礼を言ったりしました。

友達をチャーハンに連れていくというniuezとはここでお別れし、ruthenと一緒に(幸せそうな人であふれる)赤レンガ倉庫やハンマーヘッドを散策しながら競プロ引退老人トークをしました。みなとみらい駅まで歩き、横浜でお土産を買って秋葉原へ、そしてTXに乗って家に帰りました。休日の格安回数券を使い忘れたことを今も少し後悔しています。

この景色を見ながら競プロトークしたのかよ

ICPC5回目、最後の年に横浜に行けて本当によかったです。ruthenさん、niuezさん、ありがとうございました。

おわりに

競プロと出会ってからそれなりに長い時間が経ちました。未だに青と黄色を反復してしまっていますが、競プロerとしてまあまあ成長できたのではないかと思います。これまでにチームを組んだ人たち、サークルの人たち、その他競プロを通じて出会った人たち、chokudaiさん、Mike Mirzayanov、コンテストを運営してくれている皆さん、ありがとうございました。やり残したことだらけなので、今後も競プロとはゆるく付き合っていければなと思っています。

車輪を最発明しようの会 リード・ソロモン符号

この記事は呉高専アドベントカレンダー2022 11日目の記事です。

ごあいさつ

こんにちは、点Nです。電気情報工学科卒です。高専在学中は高専プロコンに出たり、呉高専史上初めてICPC(大学対抗競プロ合戦)に参加して予選敗退したりといった感じです。

アドベントカレンダーというと「(最新の何かすごい技術)を使って(こんな面白いもの)作ってみた!」みたいな(勝手な)イメージを持っているのでそういうキラキラした記事を書きたいところですが、あいにくそういうのは性に合わないので黒い画面に白い文字が出るタイプのプログラミングをやります。タイトルにもある通り車輪の最発明をやりますので、みなさんは僕が車輪を最発明しているようすを見ていてください。目次の長さや画面右のスクロールバーの大きさから察している方も多いと思いますが、この記事はめちゃくちゃ長いです。せっかくの日曜日ですが(なので)、どうか最後までお付き合いください。

注意点

  • 素人プログラミング
  • ふわふわ理論
  • ガタガタ構成
  • エンドレス文章

それでもOKな人はどうぞ

はじめに

(呉高専アドベントカレンダーなので、高専を絡めた導入をどうにか考えました。)

高専といえばロボコン。世の中そういうイメージの人が大半ですが、高専にはそれ以外のコンテストもあります。代表的なところでは、ごあいさつで述べた高専プロコンというプログラミングの大会が有名です。その中の競技部門という部門では与えられた課題(対戦ゲームや一人用ゲームなど)を解くプログラムの強さを競います。しかしこの部門、稀に「プログラミングするより人間がやったほうが強い」という現象が起こることがあります。例えば、第23回有明大会の競技課題は「サイコロの山を見てその数を数える」というものでしたが、上位の成績を収めた多くのチームが(画像処理ではなく)人の目でサイコロを数えるという手段をとったそうです。

第27回鳥羽大会でも似たようなことが起きました。競技内容は木製のジグソーパズルが(現物で!)与えられるので解け、というものでした。パソコンにピースの形状を読み込ませ、正しい配置を探索するというのが正攻法なのですが、それでも解くのが難しく、また外枠内に収めたピースの数で評価したために「最大のピースを諦めて残りを人が急いで詰め込む」という計算機完全無視の手段がかなり強くなってしまいました。実際、翌年の募集要項に「昨年の大会では,手でパズルを並べると上位に来ることができました」という記述があります(https://www.procon.gr.jp/wp-content/uploads/2017/04/8db30280f6638520be40ad86b4bf1b37.pdf)。

勘のいい人はお分かりかと思いますが、募集要項にこのようなことが書かれた第28回大島大会の競技課題は、第27回の競技課題をアレンジしたものでした。競技者はペナルティを支払うことでパズルを解くヒントを利用することができるようになり、ヒントを使ってでも「パズルを完成させる」ことを重視する評価基準になりました。そして、ヒントは紙に印刷されたQRコードで配布されました。仕組みを理解していれば不可能ではないのですが、よほどの天才でない限りQRコードは人力では読めないので、さすがにプログラミングをせざるを得ません。

高専プロコンの話はこれぐらいにして、本題に入ります。このQRコード、多少汚れるなどしていても読み取れるのをご存知でしょうか。これは本来のデータだけでなく、いっしょに「データが壊れた時に気付いて、元に戻すための手がかり」も記録しておくことにより、汚れなどで欠けた部分を補って正しいデータを復元できるようにしているためです。

これを実現するための具体的なやり方はいろいろありますが、QRコードではリード・ソロモン符号というものを用いています。今回はこの符号を最発明します。といっても理論を思いつくのは不可能なので、意図的に壊したデータを頑張って復旧させるプログラムを書きたいと思います。

余談:人力という戦略について


人の力に頼ること自体は悪いことではありません。人もシステムの一部とみなして有効に使うことで非常に優れた問題解決能力を実現できる場合があります。例えば、第30回都城大会の競技課題は陣取りゲーム形式の通信対戦ゲームでした。1ターンの時間は数秒から十数秒と短く、人力で最大8人もの味方の動きを制御するのは難しいため、基本的にはコンピュータが考えることになります。しかし、味方全員は不可能であっても、その中の一人か二人程度であれば、その動きを人が決定することが可能です。「シンプルな戦略に則ってコンピュータが個々の味方の行動を計算、人間は必要があればそれを修正し、大局的に見てより良い手を柔軟に選択できるようにする」という方針をとった東京高専はこの年の競技部門で優勝しました(というかこの年は東京高専が3部門全てで優勝したのですが)。

当時の東京高専の学生によるツイート:

shibh308 on Twitter: "#procon30 深夜になってしまいましたが、東京高専競技部門の方針について説明します" / Twitter

計算能力ではパソコンに遥かに劣りますが、人の力も決して弱くはありません。問題を人力で解くことが十分強いのであればそれも立派な戦略だと思います。プログラミングの大会としてコンピュータを一切使わないのが最適ってのはいかがなものかと思いますが。


ふんわり理解するリード・ソロモン符号

リード・ソロモン符号(Reed-Solomon Coding, RS符号)はリードさんとソロモンさんが考えた誤り訂正符号(データにくっつけておくことでデータに発生した間違いを発見し、直せる符号)です。QRコードをはじめ、Blu-rayディスク、地デジ放送、ADSLその他諸々に応用されています。この世界はリード・ソロモン符号なしには成り立たないと言っても過言ではありません。ちなみに背景にある数学的な理論はちゃんと難しいです。

符号化・復号の流れ

いったん、細かい話は抜きでざっくり書きます。かなり説明不足で厳密性に欠けます。

符号化

線形代数の気持ちになります。小文字がベクトルで大文字が行列です。まず元の情報 mがあります。これに生成行列 Gをかけて符号化します。それを wとします。 w=mGです。wmよりちょっと長くなります。mは壊れるとおしまいですが、これを多少壊れてもいい符号語 wに変換しました。終わりです。

復号

壊れている可能性のある、 wと思しきデータ w' mに復元しましょう。まず、 w'が本当に壊れているか確認します。これには検査行列 Hを使います。s=w'H ^ {T}とします。sはシンドロームと呼ばれます。 s \neq 0ならデータが壊れています。

 w' \neq wの場合を考えます。これは混入したエラー eがあって、w'=w+eな状態です。 wを復元するために eを特定します。方程式eH ^ {T}=sを解けばeが特定できます(ざっくり過ぎてこの辺まではだいたいの実用的な符号化に当てはまる話です)。ですが、今は未知の変数が多すぎて解けません。そこで、先にエラーのある場所を調べ、エラーでないと分かった変数を消します。

そのために、 sからシンドローム行列 Sなるものを作ります。なんとrank(S)がエラーの個数です。さらに、Sから誤り訂正多項式 \sigma (x)を得ます。その根( =0となる解)はw'のどこがwと異なるかを示します。無事エラーの位置が分かりました。変数が減ったので方程式が解け、eがわかります。

w'からeを除去し、wがわかります。mG=wなるmを求めれば、復号完了です。

忙しくない人と僕のためのRS符号の理論

実装するだけなら理解しなくても大丈夫ですが、RS符号がなんなのか全く分かっていないのもまずいので、ここでRS符号とはつまり何なのかについて知っておきたいと思います。適宜飛ばしてください。僕の理解が足りないので、間違った記述も含まれていると思います。

そもそもRS符号は何がすごいのか

とにかく訂正能力が高いです(そのぶん処理はやや複雑です)。正しいかは素人なので判断できませんが、僕は「理論値じゃん」という印象を受けました。

まず、符号どうしの距離を考えます。符号の最小距離dは、異なる符号どうしのハミング距離の最小値です(ハミング距離は違っている場所の数です。たとえば、"abcde"と"abcxy"という符号のハミング距離は2です)。距離の小さい、つまり似たような符号があると、ちょっとしたエラーが発生するだけでそれらを間違えてしまいそうです。なので、dはできるだけ大きくなるように符号化したい気持ちになります。この最小距離は符号の誤り訂正能力に直結し、最小距離dの符号が訂正できるのはd/2未満の誤りだけです。直感的には、もとの符号から出発して、他の符号とのちょうど中間のところまで来てしまうと、どこに帰ればよいかわからなくなってしまう、というイメージです。

しかし当然ながらdはどこまでも大きくできるわけではありません。k文字をn文字に符号化する符号において、dn-k+1以下になります。これをシングルトン限界と呼びます。

ここがすごいところなのですが、RS符号は、最小距離がシングルトン限界n-k+1に等しい「最大距離分離符号」です。だから訂正能力が高いです。

RS符号は (n-k+1)/2 未満のエラーであれば訂正できます。言い換えれば、訂正できるエラーの最大数は\lfloor (n-k)/2 \rfloor個です。

RS符号とは

ずばり、こういうものです。

x _ {1}, ..., x _ {n}を体\mathbb{F} _ {q}の異なる元とする.k\le nに対し,\mathbb{F} _ {q} [x] における次数がkより小さいすべての多項式の集合\mathbb{P} _ {k}を考える.リード・ソロモン符号 (RS)符号 (Reed-Solomon code)は,以下の符号語の全体からなる.

(f(x _ {1}), f(x _ {2}), ..., f(x _ {n}) )  (ただし f\in \mathbb{P} _ k

(参考書籍[1] p.63, 定義 4.1.1)

長さkのメッセージを長さnの符号語に変換する話をしています。体とは(ざっくり言うと)四則演算ができる世界のことです。その世界における多項式のうち、次数がk未満のもの(例えばk=3ならx ^ {3}以上の項がないもの、ax ^ {2}+bx+cbx+c,ax ^ {2}+c, cなど)をひとつ用意します。また、その世界からn個の元(要素、≒数)を持ってきて、その多項式に代入していった結果を並べて (f(?), f(!),...)のようなn次元ベクトルを作ります。これがひとつの符号語になります。考えうるすべての多項式に同じことをして得られる符号語の集合がRS符号ということです。

あるメッセージと対応する符号語の関係をざっくり言うと、符号語は「メッセージを多項式関数に置き換え、1から順に数を代入した結果の一覧」です。符号化はこの一覧表の作成をやるだけです。復号は結果一覧から多項式関数を特定する作業になります。多項式が違えば結果はある程度異なるものになるので、結果一覧に誤植があっても、少しなら問題ありません(誤り訂正)。

q素数のべき乗なら何でもよいのですが、実用上はqを2のべき乗、n=q-1x _ {i}=\alpha ^ {i-1}\ (1\le i\le q-1)とすることが多いです。\alphaは体\mathbb{F} _ {q}の原始元です。今回は全体的にこれにならうこととし、q=2 ^ {8}=256とします。ASCIIコードでは1bitが1文字に対応し、1bitは256種類の数が表現できるため、体の元と文字の対応付けも簡単です。

なぜ体が出てくるのか


なぜ文字をそのまま0から255までの整数だけの世界(256で割った余りの世界/mod 256の世界)で考えようとしないのかですが、それだと困ることがあります。加減算と乗算はよい(結果を256で割った余りに置き換えれば0~255の範囲に収め直せる)のですが、割り算が困ります。なんと割り算ができないことがあります(例えばmod 256の世界に2x=1を満たすxは存在しません、これはmod 非素数 の世界を考えた時に起こります)。この先で割り算を使うので、四則演算ができる体、特に要素が有限で閉じている(演算結果も必ずその体の要素な)もの(=有限体/ガロア体)の世界で考える必要があります。


原始元とは


体の元であって、\mathbb{F} _ {q}の原始(q-1)乗根(=(q-1)乗してはじめて1になるもの)です。これにより、体の0以外の元は原始元\alphaを用いて\alpha ^ {i}の形で表せます。


RS符号の世界(ガロア体)について

\mathbb{F} _ {q}は要素数q個と有限で、四則演算について閉じている(演算結果もまた体の元)という性質をもつ体で、これをガロア体とか有限体と呼びます。

ガロア体の例


最も簡単なガロア体は、ずばり、1bitの世界、0と1の世界です。\mathbb{F} _ {2}です。加算や乗算は整数の時とほぼ一緒ですが、2で割った余りを取ります。実際には、1+1=0であることだけが違いです。減算と除算もその逆を考えればよいです。注意すべきは0-1=1なことぐらいです。あと、この世界でもゼロ除算はダメです。

この加減算は排他的論理和(xor)という演算で、0どうし1どうしでは0に、0と1では1になります。プログラミング言語でもこの演算が(大抵)サポートされていて、(大抵)^という演算子を使います。

同様に、素数pに対してガロア\mathbb{F} _ {p}を作ることができます。加算と乗算はpで割った余りになります。


ガロア拡大体(今回扱う体の話)


ガロア\mathbb{F} _ {p}を拡大し、\mathbb{F} _ {p}m次元ベクトルを元(全p ^ {m}個)とするガロア拡大\mathbb{F} _ {p ^ {m} }を作ることができます。面倒なのでここからはp=2で進めます。

\mathbb{F} _ {2}を拡大し、\mathbb{F} _ {2 ^ {m} }を作ります。m次元ベクトルの要素を\mathbb{F} _ {2}上の(m-1)多項式の係数だと思うことにします。\{0,1\}=1, \{1,0\}=x,\{1,1\}=x+1のような感じです。加算はベクトルの要素ごとに和をとればよいです。つまりはbitごとにxorを計算するということです。乗算は少し大変で、a\times b\ (a,b\in \mathbb{F} _ {2 ^ {m} })は、a,b多項式a(x), b(x)に対応するとして、a(x)b(x)\ mod f(x)に対応する元(f(x)\mathbb{F} _ {2}上のm次の既約な(=因数分解できない)多項式)とします。

(ここから今回のm=8での話になります)x ^ {8}+x ^ {4}+x ^ {3}+  x ^ {2}+1は原始多項式という既約多項式で、この根(多項式に代入すると0となる解)は原始元\alphaになり、体の元は0を除いて\alphaのべき乗で表せます。\alphaは原始多項式の根なので\alpha ^ {8} = \alpha ^ {4}+\alpha ^ {3} + \alpha ^ {2} +1です。多項式的には、乗算のmodをこの多項式にします。x ^ {8}=x ^ {4}+x ^ {3}+  x ^ {2}+1です。

結局、こんな感じになります。

  • \mathbb{F} _ {2 ^ {8}}の元は0\alpha ^ {i}(0\le i \le 254, \alpha ^ {0} = 1)の合計256個です。
  • \alpha ^ {255} = \alpha ^ {0} = 1です。
  • GF(2 ^ {8})における四則演算は以下のようになります。乗除算については上の性質を使います。

体の元とbit列、多項式は次の表のような対応をします。

対応するbit列 多項式としての解釈
0 00000000 0
1=\alpha ^ {0} 00000001 1
\alpha 00000010 x
\alpha ^ {2} 00000100 x ^ {2}
\alpha ^ {3} 00001000 x ^ {3}
\alpha ^ {7} 10000000 x ^ {7}
\alpha ^ {8}=\alpha ^ {4}+\alpha ^ {3} + \alpha ^ {2} +1 00011101 x ^ {4}+x ^ {3}+x ^ {2}+1
\alpha ^ {9}=\alpha ^ {8} \times \alpha = \alpha ^ {5}+\alpha ^ {4}+\alpha ^ {3}+\alpha 00111010 x ^ {5}+x ^ {4}+x ^ {3}+x
\alpha ^ {i} =\alpha ^ {i-4}+\alpha ^ {i-3} + \alpha ^ {i-2} +\alpha ^ {i-8}\ (8\le i\le 254)

符号化

前提を再掲します:q=2 ^ {8}=256,n=q-1=255,x _ {i}=\alpha ^ {i-1}\ (1\le i\le n)

符号化は、長さkのメッセージm\mathbb{F} _ {2 ^ {8} }上の(k-1)多項式m(x)=\sum _ {i=1} ^ {k}m _ {k-i} x ^ {k-i}とみて、(m(x _ {1}), m(x _ {2}), ..., m(x _ {n}) )とすればよいです。実際には、生成行列Gをかけるという操作によって符号化します。

生成行列と検査行列

生成行列Gk\times nの行列で、これを用いてメッセージの符号化を行います。符号の定義より、G _ {i,j}=x _ {j} ^ {i-1}\ (1\le i\le k, 1\le j\le n)です。今回の場合はx _ {j} =\alpha ^ {j-1}ですので、G _ {i,j} = \alpha ^ {(i-1)(j-1)}\ (1\le i\le k, 1\le j\le n)となります。DFT行列に似ています。というか実際フーリエ変換そのものっぽいです(参考[6])。

検査行列HGh ^ {T}=0をみたす(線形独立な)行ベクトルhn-k個並べた(n-k)\times nの行列です。上の生成行列Gに対応する検査行列はH _ {i,j} =\alpha ^ {i(j-1)}\ (1 \le i\le n-k,1\le j\le n)です。

理由


実際に計算してみるとわかります。M=GH ^ {T}とします。M _ {i,j} = \sum _ {k} G _ {i,k}H^{T} _ {k,j}=\sum
 _ {k} \alpha ^ {(i-1)(k-1)} \alpha ^ {j(k-1)}=\sum _ {k=1} ^ {n} (\alpha ^ {i+j-1} ) ^ {k-1}です。1\le i+j-1 \le n-1より\alpha ^ {i+j-1}\neq 1なので、等比数列の和の公式よりM _ {i,j}=( (\alpha^ {i+j-1}) ^ {n}-1) / (\alpha ^ {i+j-1}-1)ですが、分子の(\alpha^ {i+j-1}) ^ {n}に注目すると(\alpha^ {i+j-1}) ^ {n}=(\alpha ^ {n}) ^ {i+j-1}=1 ^ {i+j-1}=1であるため、M _ {i,j}=0となります。


w'に誤りがなくw'=w(=mG)ならシンドロームs=wH ^ {T}=mGH ^ {T}=0になるはずですが、そうならない場合はエラーがあることになります。そのときのシンドロームはどうなっているかですが、あるエラーeがあってw'=w+eと表せるので、ここに検査行列をかけた値w'H ^ {T}=wH ^ {T}+eH ^ {T}=eH ^ {T}になっています。

エラー検出:ピーターソン法

eH ^ {T}=sでした。これを解けばeが分かるのですが、n=|e|>|s|=n-kより未知数に対して方程式の数が足りません。これを何とかします。

復号のアイデアは以下の通りだそうです。正直僕はあまり理解できてないです。

r=c+eを受信語とし,w(e)\le t=\lfloor (n-k)/2 \rfloorを仮定する.復号法のアイデアは,2変数多項式

Q(x,y) = Q _ {0}(x)+yQ _ {1}(x)\in \mathbb{F} _ {q}[x,y] \backslash \{0\}

であって,以下の条件を満たすものを決定することである.

  1. Q(x _ {i}, r _ {i})=0, i=1,...,n

  2. \deg (Q _ {0})\le n-1-t

  3. \deg (Q _ {1})\le n-1-t-(k-1)

(参考書籍[1], p.65)

本文中で使ってきたw',wはそれぞれr,cに対応しています。条件1より方程式がn個できますが、条件2,3よりQ _ {0}, Q _ {1}の未知の係数はそれより多くなるので、このような多項式があることがいえます。

また、この方程式に関して次の定理が成り立ちます。

送信語がg(x)で生成され,そして,誤りの個数がd/2より小さければ,g(x)=-Q _ {0}(x)/Q _ {1}(x)が成り立つ.

(参考書籍[1], p.65, 定理4.2.2)

なぜ?


参考書籍[1], p.65, 定理4.2.2の証明に基づく説明です。

恒等的にQ(x,g(x) )=0であることを示せばあとは単純な式変形で定理を導けるので、それを示します。

「送信語がg(x)で生成され」るとは、c _ {i}=g(x _ {i})\ (1\le i\le n)ということです。条件1よりQ(x _ {i}, g(x _ {i}) + e _ {i})=0です。エラー個数はt=\lfloor (n-k)/2\rfloor以下です。ということはeのうちn-t個以上の要素がゼロなので、それらに関してはQ(x _ {i}, g(x _ {i}) )=0が成立しています。これによりQ(x,g(x) )は少なくともn-t個の根をもつことになります。ところがこの多項式の次数は条件2,3より高々n-t-1で、それより多いn-t個の根をもっています。ということは、Q(x,g(x) )=0です。


エラーの位置はQ _ {1}(x)の根の中にありますQ(x,y)=Q _ {1}(x)(y-g(x) )と書けます。エラー位置 x _ {i}が根でないと仮定した場合、x=x _ {i}, y=r _ {i}を代入するとg(x _ {i})=c _ {i}\neq r _ {i}よりy-g(x)\neq 0、仮定よりQ _ {1}(x)\neq 0Q(x,y)\neq 0になってしまいます)。この性質からQ _ {1}(x)は誤り位置多項式と呼ばれます。

以後Q _ {0}, Q _ {1}の最大次数をl _ {0} = n-1-t, l _ {1} = n-1-t-(k-1)と表します。

さて、ここから復号までには何通りかの方法があります。ここではピーターソン法と呼ばれる方法を取り上げます。この方法ではまずエラー位置特定(Q _ {1}(x)の特定)を行い、次にeを求めます。

第一段階、エラー位置特定です。次の定理を用います。(n-kが奇数であることを仮定しています。偶数ならl _ {1}l _ {1}-1になります)

誤り位置多項式Q _ {1}の係数は,次の連立一次方程式の解である.

 
\begin{bmatrix}
S _ {1} & S _ {2} & \cdots & S _ {l _ {1}+1} \\ 
S _ {2} & S _ {3} & \cdots & S _ {l _ {1}+2} \\ 
\vdots & \vdots & \cdots & \vdots \\ 
S _ {l _ {1} } & S _ {l _ {1}+1} & \cdots & S _ {2l _ {1} }
\end{bmatrix}
\begin{bmatrix}
Q _ {1,0} \\ 
Q _ {1,1} \\ 
Q _ {1,2} \\ 
\vdots \\ 
Q _ {1,l _ {1} }
\end{bmatrix}
=
\begin{bmatrix}
0 \\ 
0 \\ 
0 \\ 
\vdots \\ 
0
\end{bmatrix}

(参考書籍[1], p.72, 定理4.4.1)

本当に?


参考[1] pp.72-74の証明にもとづいて説明をします。

Qのみたす条件1から、次のような関係が成立します。


\begin{bmatrix}
1 & x _ {1} & x _ {1} ^ {2} & \cdots & x _ {1} ^ {l _ {0} } \\ 
1 & x _ {2} & x _ {2} ^ {2} & \cdots & x _ {2} ^ {l _ {0} } \\ 
\vdots & \vdots & \vdots & \cdots & \vdots \\ 
1 & x _ {n} & x _ {n} ^ {2} & \cdots & x _ {n} ^ {l _ {0} } \\ 
\end{bmatrix}
\begin{bmatrix}
Q _ {0,0} \\ 
Q _ {0,1} \\ 
Q _ {0,2} \\ 
\vdots \\ 
Q _ {0, l _ {0} } \\ 
\end{bmatrix}
+
\begin{bmatrix}
r _ {1} & r _ {1} x _ {1} &  \cdots & r _ {1} x _ {1} ^ {l _ {1} } \\ 
r _ {2} & r _ {2} x _ {2} &  \cdots & r _ {2} x _ {2} ^ {l _ {1} } \\ 
\vdots & \vdots & \cdots & \vdots \\ 
r _ {n} & r _ {n} x _ {n} &  \cdots & r _ {n} x _ {n} ^ {l _ {1} } \\ 
\end{bmatrix}
\begin{bmatrix}
Q _ {1,0} \\ 
Q _ {1,1} \\ 
Q _ {1,2} \\ 
\vdots \\ 
Q _ {1, l _ {1} } \\ 
\end{bmatrix}
=
\begin{bmatrix}
0 \\ 
0 \\ 
0 \\ 
\vdots \\ 
0 \\ 
\end{bmatrix}

両辺に左から次の行列をかけると、(生成行列に検査行列をかけたのと同様の原理で)左辺第一項が消えます。


\begin{bmatrix}
x _ {1} & x _ {2} & \cdots & x _ {n} \\ 
x _ {1} ^ {2} & x _ {2} ^ {2} & \cdots & x _ {n} ^ {2} \\ 
\vdots & \vdots & \cdots & \vdots \\ 
x _ {1} ^ {l _ {1} } & x _ {2} ^ {l _ {1} } & \cdots & x _ {n} ^ {l _ {1} } \\ 
\end{bmatrix}

残った左辺第二項は、次のように変形できます。


\begin{bmatrix}
x _ {1} & x _ {2} & \cdots & x _ {n} \\ 
x _ {1} ^ {2} & x _ {2} ^ {2} & \cdots & x _ {n} ^ {2} \\ 
\vdots & \vdots & \cdots & \vdots \\ 
x _ {1} ^ {l _ {1} } & x _ {2} ^ {l _ {1} } & \cdots & x _ {n} ^ {l _ {1} } \\ 
\end{bmatrix}
\begin{bmatrix}
r _ {1} & r _ {1} x _ {1} &  \cdots & r _ {1} x _ {1} ^ {l _ {1} } \\ 
r _ {2} & r _ {2} x _ {2} &  \cdots & r _ {2} x _ {2} ^ {l _ {1} } \\ 
\vdots & \vdots & \cdots & \vdots \\ 
r _ {n} & r _ {n} x _ {n} &  \cdots & r _ {n} x _ {n} ^ {l _ {1} } \\ 
\end{bmatrix}
\begin{bmatrix}
Q _ {1,0} \\ 
Q _ {1,1} \\ 
Q _ {1,2} \\ 
\vdots \\ 
Q _ {1, l _ {1} } \\ 
\end{bmatrix}
=
\begin{bmatrix}
x _ {1} & x _ {2} & \cdots & x _ {n} \\ 
x _ {1} ^ {2} & x _ {2} ^ {2} & \cdots & x _ {n} ^ {2} \\ 
\vdots & \vdots & \cdots & \vdots \\ 
x _ {1} ^ {l _ {1} } & x _ {2} ^ {l _ {1} } & \cdots & x _ {n} ^ {l _ {1} } \\ 
\end{bmatrix}
\begin{bmatrix}
r _ {1} & \cdots & 0 & 0 \\ 
0 & r _ {2} & \cdots & 0 \\ 
\vdots & \vdots & \cdots & 0 \\
0 & 0 & \cdots & r _ {n} \\ 
\end{bmatrix}
\begin{bmatrix}
1 & x _ {1} &  \cdots & x _ {1} ^ {l _ {1} } \\ 
1 & x _ {2} &  \cdots & x _ {2} ^ {l _ {1} } \\ 
\vdots &  \vdots & \cdots & \vdots \\ 
1 & x _ {n} & \cdots & x _ {n} ^ {l _ {1} } \\ 
\end{bmatrix}
\begin{bmatrix}
Q _ {1,0} \\ 
Q _ {1,1} \\ 
Q _ {1,2} \\ 
\vdots \\ 
Q _ {1, l _ {1} } \\ 
\end{bmatrix}

あとは、


\begin{bmatrix}
S _ {1} & S _ {2} & \cdots & S _ {l _ {1}+1} \\ 
S _ {2} & S _ {3} & \cdots & S _ {l _ {1}+2} \\ 
\vdots & \vdots & \cdots & \vdots \\ 
S _ {l _ {1} } & S _ {l _ {1}+1} & \cdots & S _ {2l _ {1} } \\ 
\end{bmatrix}
=
\begin{bmatrix}
x _ {1} & x _ {2} & \cdots & x _ {n} \\ 
x _ {1} ^ {2} & x _ {2} ^ {2} & \cdots & x _ {n} ^ {2} \\ 
\vdots & \vdots & \cdots & \vdots \\ 
x _ {1} ^ {l _ {1} } & x _ {2} ^ {l _ {1} } & \cdots & x _ {n} ^ {l _ {1} } \\ 
\end{bmatrix}
\begin{bmatrix}
r _ {1} & \cdots & 0 & 0 \\ 
0 & r _ {2} & \cdots & 0 \\ 
\vdots & \vdots & \cdots & 0 \\
0 & 0 & \cdots & r _ {n} \\ 
\end{bmatrix}
\begin{bmatrix}
1 & x _ {1} &  \cdots & x _ {1} ^ {l _ {1} } \\ 
1 & x _ {2} &  \cdots & x _ {2} ^ {l _ {1} } \\ 
\vdots &  \vdots & \cdots & \vdots \\ 
1 & x _ {n} & \cdots & x _ {n} ^ {l _ {1} } \\ 
\end{bmatrix}

であることを確認します。行列を分解したことで計算が簡単になって、右辺の計算結果は


\begin{bmatrix}
\sum r _ {i} x _ {i} & \sum r _ {i} x _ {i} ^ {2} & \cdots & \sum r _ {i} x _ {i} ^ {l _ {1}+1} \\ 
\sum r _ {i} x _ {i} ^ {2} & \sum r _ {i} x _ {i} ^{3} & \cdots & \sum r _ {i} x  _ {i} ^ {l _ {1}+2} \\ 
\vdots & \vdots & \cdots & \vdots \\ 
\sum r _ {i} x _ {i} ^ {l _ {1} } & \sum r _ {i} x _ {i} ^ {l _ {2} } & \cdots & \sum r _ {i} x _ {i} ^ {2l _ {1} } \\ 
\end{bmatrix}

になることが確認できます。いっぽう、左辺についてはS=rH ^ {T}=Hr ^ {T}で計算したことを思い出すと、


\begin{bmatrix}
S _ {1} & S _ {2} & \cdots \\ 
\end{bmatrix}
=
\begin{bmatrix}
x _ {1} & x _ {2} & \cdots & x _ {n} \\ 
x _ {1} ^ {2} & x _ {2} ^ {2} & \cdots & x _ {n} ^{2} \\ 
\vdots & \vdots & \cdots & \vdots \\ 
\end{bmatrix}
\begin{bmatrix}
r _ {1} \\ 
r _ {2} \\ 
\vdots \\ 
r _ {n} \\
\end{bmatrix}
=
\begin{bmatrix}
\sum r _ {i} x _ {i} & \sum r _ {i} x _ {i} ^ {2} & \cdots  \\ 
\end{bmatrix}

であることから一致します。

証明は省きますが、Q _ {1}がこの方程式の解ならば最初の方程式の解Q _ {0}も存在します。


Q _ {1}がわかればその根を総当たりで求めることができ、誤り位置が判明します。

第二段階です。eH ^ {T}=sでした。エラーの数は\lfloor (n-k)/2\rfloor以下なので、eはそれらを残してあとは全部ゼロです。未知数が減ったので方程式を解くことができ、eが定まります。

最後に方程式mG=w'-eを解いて、元のmが復元できます。

実装

さて、だいぶ理論の話が長くなりましたが、ここから実装していきます。実装してから理論を書いたので若干記号が違ったりします。

何をするか

ユーザの入力:ASCII文字列mとデータ破壊数 t

プログラムから見た入力: w'、つまり破壊されたw

出力:もとのメッセージm

手抜きPoint
  • 今回はエラーがひどすぎて復元できない場合を想定しません。めんどくさいし、復号できないことがわかっても嬉しくないし、直せるものがきちんと直せると分かればいいので。
  • ヒントとして、元のメッセージの文字数は復号プログラムに教えます。固定長になるように分割したり付け足したりするか、特定の書式を用意したりすれば解決するのですが、そこは面白くない場所なので。
  • 計算資源に関する制約はあまり意識しません。最良の方法であることよりも実装の手軽さなどを優先し、一般的な家庭用のパソコンで実行したときに即座に実行が完了する程度に動けばよいことにします。

入力

入力された一行のメッセージmgetline()で受け取ります。cinは空白で区切られてしまうので不便です。符号化したメッセージwの長さを N = 2 ^ {8} -1 = 255とするため、メッセージ長K \lt Nである必要があります。もっというと破壊したいのでK\le N-2がよいですが、ここではとりあえず200文字以内のメッセージなら受理ということにします。自分が使うプログラムなので基本的には性善説に基づいた実装ですが、いちおう長すぎる入力や空の入力は弾くようにします。僕の環境ではどうにかなるようですが、マルチバイト文字は入れないでほしいです。

ついでに wの破壊文字数 tも入力してもらいます。最大でt _ {0} = \lfloor (N-K)/2 \rfloor文字破壊できます。それより上の要求は撥ね退けます。わけの分からん入力に関しては、文字列をint型の整数に変換する関数stoi()様の解釈を正義とします。エラーの場合は再入力です。データを破壊しないこと、つまりt=0は許容します。

const string input = Input::getRangeString("Input the string (<="+to_string(maxK)+" letters)",1,maxK+1);
const int K = input.size();
const int maxChange = (N-K)/2;
const int change = Input::getRangeNum("Input the number of data to be corrupted (<="+to_string(maxChange)+")",0,maxChange+1);

Input::xxx

namespace Input{
  bool inRange(const int n, const int l, const int r){
    return l<=n&&n<r;
  }
  bool isRangeNumStr(const string& s, const int l, const int r){
    int a;
    try{ a = stoi(s); }
    catch(...){ return false; }
    return inRange(a,l,r);
  }
  string getRangeString(const string& message, const int l, const int r){
    string S;
    while(true){
      cout << message << endl;
      getline(cin,S);
      if(inRange(S.size(),l,r)) return S;
    }
  }
  int getRangeNum(const string& message, const int l, const int r){
    string numString;
    while(true){
      cout << message << endl;
      getline(cin,numString);
      if(isRangeNumStr(numString,l,r)) return stoi(numString);
    }
  }
}

拡大体

\mathbb{F} _ {2 ^ {8} }上の元を表す型を用意しましょう。これを構造体alphaとして定義します。中身は8bitならいいのでunsigned char型にします。signedにするとろくなことにならない(筆者は論理シフトと算術シフトの違いで痛い目を見たことがあります)のでunsignedです。ここに加減乗除を定義します。演算子オーバーロードというやつです。これにより(ソースコード上で)普通の整数と同様に扱うことができます。

配列A[256]を用意し、Aを参照することで\alpha ^ {i}が即座に求められるようにします。A[i]=\alpha ^ {i}\ (0\le i\le 254), A[255]=0のような対応にしておきます。プログラムを起動したときに初期化関数を呼ぶことにし、配列Aは初期化関数内部で漸化式的に求めます。

加減算はbitwise xorなので^演算子を用いてシンプルに書けます。

乗除算も頑張ればbit演算でできる気がしますが、簡単な方法でやります。まず、0が絡む場合は場合分けして個別に処理します。それ以外は肩の数字のmod 255での加減算になります。各元は自分が\alphaの何乗なのかをわかっている必要がありますが、これも初期化の際にカンペを作っておいてそれを参照することにしましょう。

alpha 剰余をとるのは負の数を正の数に直してから。一部省略していますが、実際は複合代入演算子+=など)も定義しています。

struct alpha{
  unsigned char v;
  alpha():v(0){}
  alpha(unsigned char a):v(a){}
  const alpha operator+ (const alpha& r) const{ return alpha(v^r.v); }
  const alpha operator- (const alpha& r) const{ return alpha(v^r.v); }
  const alpha operator* (const alpha& r) const{
    if (*this==0 || r==0) return 0;
    int expL = revA[v], expR = revA[r.v];
    return A[(expL+expR)%255];
  }
  const alpha operator/ (const alpha& r) const{
    assert(r!=0);
    if(*this==0) return 0;
    int expL = revA[v], expR = revA[r.v];
    return A[(expL+255-expR)%255];
  }
  static alpha pow(int x){
    if(x<0) x+=((-x)/255+1)*255;
    return A[x%255];
  }
  static array<alpha,256> A;
  static array<unsigned char,256> revA;
  static void initialize(){
    A[255]=0;
    revA[0]=255;
    for(unsigned char i=0;i<8;i++){
      A[i]=1u<<i;
      revA[A[i].v]=i;
    }
    for(unsigned char i=8;i<255;i++){
      A[i]=A[i-4]+A[i-5]+A[i-6]+A[i-8];
      revA[A[i].v]=i;
    }
  }
}

あと、後々面倒なのでalphaのベクトルや行列、すなわちvector<alpha>vector<vector<alpha>>は略記ができるようにしておきます。

using Vec = vector<alpha>;
using Matrix = vector<vector<alpha>>;

符号化

生成行列

G _ {i,j}= \alpha ^ { (i-1)(j-1) } \ (1\le i\le K, 1\le j\le N)

K\times Nの行列です。これを用いてメッセージの符号化を行います。行列(2次元vector)を作るだけなのでプログラミング的な面白さはないです。

const Matrix G = RS::MatrixGen::Generate(K,N);

RS::MatrixGen::Generate

Matrix RS::MatrixGen::Generate(const int K, const int N){
  Matrix G(K,Vec(N));
  for(int k=0;k<K;k++) for(int n=0;n<N;n++) G[k][n] = alpha::pow(k*n);
  return G;
}

符号語生成

w=mGを計算します。|w|=Nです。行ベクトルと行列を受け取ってその積(行ベクトル)を返す関数を作り、それを呼び出してあげます。

const Vec message = RS::MatrixGen::message(K,input);
const Vec word = Math::mul(message,G);

RS::MatrixGen::message, Math::mul

Vec RS::MatrixGen::message(const int K, const string& S){
  assert((int)S.size()==K);
  Vec word(K);
  for(int i=0;i<K;i++) word[i] = S[i];
  return word;
}

Vec Math::mul(const Vec& a, const Matrix& M){
  int H = M.size(), W = M[0].size();
  Vec res(W);
  for(int i=0;i<W;i++) res[i] = 0;
  for(int y=0;y<H;y++) for(int x=0;x<W;x++) res[x] += a[y]*M[y][x];
  return res;
}

データ破壊

wの成分を指定の数だけランダムな別の値に書き換えたw'を用意します。面倒なので書き換える場所と内容はプログラムが乱数で決定します。

const Vec brokenWord = RS::MatrixGen::errorMessage(word,change);

RS::MatrixGen::errorMessage そうする意味は特にないのですが、アルゴリズムの癖として、連続した場所を破壊する傾向にあります(既に破損させた場所を再選択した場合に右方向で最も近い未破損データを壊すため)。

mt19937 mt(chrono::system_clock::to_time_t(chrono::system_clock::now()));
uniform_int_distribution<int> distN(0,N);
uniform_int_distribution<unsigned int> dist255(0,256);

Vec RS::MatrixGen::errorMessage(const Vec& word, const int change){
  Vec brokenWord(word);
  vector<bool> changeFlag(N,false);
  for(int i=0;i<change;i++){
    int pos = distN(mt);
    while(changeFlag[pos]) pos=(pos+1)%N;
    changeFlag[pos] = true;
    alpha after;
    do{ after = dist255(mt); } while(after==brokenWord[pos]);
    brokenWord[pos] = after;
  }
  return brokenWord;
}

符号語比較

人間が見てわかるようにここでww'の比較を行いたいと思います。

符号語表示

alpha型の中身はunsigned char型の変数ですが、文字としてそのまま出力することはできません(ASCIIが対応するのは0-127までなので範囲外になることがあり、かつ範囲内にも改行やヌル文字などの表示できない文字=制御文字がある)。unsigned charが扱う0~255の値を2桁の16進数(適宜0埋め)に変換して表示します。

表示

ostream& operator << (ostream& ost, const alpha& a){
  return ost << hex << setw(2) << setfill('0') << (int)a.v << setfill(' ') << dec;
}
ostream& operator << (ostream& ost, const Vec& v){
  for(auto itr=v.begin();itr!=v.end();itr++) ost << *itr;
  return ost;
}

比較

上の要領で符号語を人に読める文字にできますが、ふたつの符号語を上下に並べて表示したとして、それを比較するのはおよそ人間のやることではありません。違っているところを目立たせるために、文字と背景の色を反転させます。coutで出力するとき、\033[7mを噛ませることで反転、\033[0m=\033[mで戻すことができます(参考: もう一度基礎からC言語 第47回 特殊な画面制御~コンソール入出力関数とエスケープシーケンス エスケープシーケンスによる画面制御 )。

compare(word,brokenWord,"Original","Broken");

compare せっかくなので符号語以外のvectorも対応できるようにしておきます。

template <typename T>
void compare(const vector<T>& A, const vector<T>& B, const string& s1, const string& s2){
  assert(A.size()==B.size());
  const string flip = "\033[7m";
  const string endFlip = "\033[m";
  cout << endl;
  cout << "------compare------" << endl;
  int N = A.size();
  vector<int> dif;
  cout << s1 << ':' << endl;
  cout << A << endl;
  cout << s2 << ':' << endl;
  for(int i=0;i<N;i++){
    if(A[i]==B[i]) cout << B[i];
    else{
      dif.push_back(i);
      cout << flip << B[i] << endFlip;
    }
  }
  cout << endl;
  if(dif.empty()) cout << "No error." << endl;
  else cout << "Error at:" << dif << endl;
  cout << "-------------------" << endl << endl;
}

検証

検査行列

H _ {i,j} = \alpha ^ {(i-1)j} \ (1\le i\le N,1\le j\le N-K)

誤り検出に使用します。転置した状態で使うので最初から転置した状態のN\times (N-K)の行列にします。式にしたがって生成するだけなので特にいうことはありません。

const Matrix H = RS::MatrixGen::Check(N,N-K);

RS::MatrixGen::Check

Matrix RS::MatrixGen::Check(const int N, const int NK){
  Matrix H(N,Vec(NK));
  for(int n=0;n<N;n++) for(int x=1;x<=NK;x++) H[n][x-1] = alpha::pow(n*x);
  return H;
}

シンドローム

s = w' Hでシンドロームを計算します。符号語を生成したときと同じ要領なので同じ関数を使いまわします。結果がs=0の場合はそのままメッセージを復号、そうでない場合は復号の前にエラー処理を行います。

const Vec syndrome = Math::mul(brokenWord,H);
Vec restoredWord(word);
if(!Math::isZeroVec(syndrome)){
  const vector<int> errorPos = RS::specifyError(maxChange,syndrome);
  restoredWord = RS::restoreWord(brokenWord,errorPos,H,syndrome);
}
else { cout << "Data is not broken." << endl; }

エラー処理

eH=sを解けばeがわかりますが、このままでは解けないので先にeから零の要素を除去し、未知の変数を減らします。

RS::specifyError

vector<int> RS::specifyError(const int maxChange, const Vec& syndrome){
  Matrix S = RS::MatrixGen::Syndrome(maxChange,maxChange+1,syndrome);
  const int rnk = Math::gauss_jordan(S);
  const Vec sigma = MatrixGen::sigma(rnk,S);
  return RS::findErrorPos(sigma);
}

シンドローム行列と誤り位置多項式

 S _ {i} = s [ i;\ t _ {0}+i ]  t _ {0} \times (t _ {0}+1)行列になります。生成行列や検査行列に比べると少しややこしいです。

RS::MatrixGen::Syndrome

Matrix RS::MatrixGen::Syndrome(const int H, const int W, const Vec& syndrome){
  Matrix S(H,Vec(W));
  for(int y=0;y<H;y++) for(int x=0;x<W;x++) S[y][x] = syndrome[y+x];
  return S;
}

Sに掃き出し法を使い、対角成分に1t個並ぶように変形します(const int rnk = Math::gauss_jordan(S);)。下のt _ {0} - t行はゼロになります。エラー個数t=rank(S)が特定できます。掃き出し法についてはもう少し後で。

掃き出し法を使った後のSから誤り位置多項式\sigma (x) =  x ^ {t} + \sigma _ {1} x ^ {t-1} + \sigma _ {2} x ^ {t-2} + ... + \sigma _ {t-1} x + \sigma _ {t}を得ます。St+1列目の成分上からt個が、\sigma _ {t}, \sigma _ {t-1}, ..., \sigma _ {1}となります(const Vec sigma = MatrixGen::sigma(rnk,S);)。

RS::MatrixGen::sigma

Vec RS::MatrixGen::sigma(const int rnk, const Matrix& S){
  Vec sig(rnk+1);
  for(int y=0;y<rnk;y++) sig[y]=S[y][rnk];
  sig[rnk] = 1;
  return sig;
}

理論と言ってることが微妙に違いませんか


文献[4]を参考に、エラー個数と誤り位置多項式を一気に求める方法をとっています。

ここから適当なことを言うのですが、根を求めるだけなので、誤り位置多項式には適当な定数をかけてよく、最高次の係数を1にすることができます。その状態から変形すると掃き出し法に持っていける感じがしてきます。

こう

で、見つかるまで次数をあげていきながら多項式を求めている、という感じの処理になっている(のだと思っていますが、厳密な原理は参考元をあたってください)。


 \sigma (x) = 0をみたすxを求めます。実際にxに元を代入して全探索で求めます(RS::findErrorPos(sigma);)。

RS::findErrorPos

vector<int> RS::findErrorPos(const Vec& sigma){
  vector<int> errorPos;
  for(int j=0;j<N;j++){
    if(Math::polynomial(sigma,alpha::pow(j))==0) errorPos.push_back(j);
  }
  return errorPos;
}

alpha Math::polynomial(const Vec& coef, const alpha a){
  alpha x(1), sum(0);
  for(const alpha& c:coef){
    sum += c*x;
    x *= a;
  }
  return sum;
}

今回は排除しているので心配いりませんが、一般にt=t _ {0}の場合、エラーが t _ {0}個とは限りません(もっと多い可能性があります)。t _ {0}>tの場合は、この後出てくる誤り位置多項式の根が見つからなくなるようです(参考:文献[4])。

掃き出し法

掃き出し法の実装がプログラム全体で最も難しいと思います。とはいっても、今回は誤差やオーバーフローの心配がなく、その点ではやや簡単ともいえます。基本的には人間がやるような感じをプログラムに書き直すだけです。

具体的な手続き


大まかな流れを記述します。行列Aに対して掃き出し法を行います。左の列から(j=0から)順番に、繰り返し処理していきます。

  1. その列にゼロでない要素A _ {i,j}\neq 0をもつ(、固定されていない)行iをひとつ探し、\alpha _ {x} := A _ {i,j}とします。

  2. 見つかったら、行iの要素をすべて\alpha _ {x}で割ります。

  3. iを除くすべての行について、\alpha _ {y _ {k}}:=A _ {k,j}とし、各要素A _ {k,l}に対して、A _ {k,l} :=A _ {k,l}-\alpha _ {y _ {k} } \times A _ {i,l}とします。

  4. iを(固定されていない)いちばん上の行とswapし、その位置に固定します。


プログラムは雑に書くとそれ以降も参照する必要のある変数をうっかり書き換えてしまうバグを埋め込みやすいです。

Math::gauss_jordan 一部の処理の順番を入れ替えてあります。

int Math::gauss_jordan(Matrix& A){
  const int H = A.size(), W = A[0].size();
  int rnk=0;
  for(int x=0;x<W;x++){
    for(int y=rnk;y<H;y++){
      if(A[y][x]!=0){
        if(y!=rnk){
          swap(A[y],A[rnk]);
          y=rnk;
        }
        const alpha ayx = A[y][x];
        for(int xx=0;xx<W;xx++){
          A[y][xx] /= ayx;
        }
        for(int yy=0;yy<H;yy++){
          if(yy==y) continue;
          const alpha f = A[yy][x];
          for(int xx=0;xx<W;xx++){
            A[yy][xx] -= A[y][xx]*f;
          }
        }
        rnk++;
        break;
      }
    }
  }
  return rnk;
}

エラー除去

未知数が減ったので、方程式eH=sが解けるようになりました。e,H,sから必要な部分(eの非零要素、それとの掛け算が発生するHの行)を抜き出して、掃き出し法で方程式を解けばeがわかります。w=w'-eとして、破損前のデータwを得ることができます。

RS::restoreWord

Vec RS::restoreWord(const Vec& brokenWord, const vector<int>& errorPos, const Matrix& H, const Vec& syndrome){
  Matrix ErrorEquation = RS::MatrixGen::ErrorEq(errorPos,H,syndrome);
  Math::gauss_jordan(ErrorEquation);
  const Vec error = RS::MatrixGen::error(errorPos,ErrorEquation);
  return Math::sub(brokenWord,error);
}

Matrix RS::MatrixGen::ErrorEq(const vector<int>& errorPos, const Matrix& H, const Vec& syndrome){
  int error = errorPos.size();
  Matrix E(error+1,Vec(H[0].size()));
  for(int i=0;i<error;i++) E[i] = H[errorPos[i]];
  E[error] = syndrome;
  return Math::transpose(E);
}

Vec RS::MatrixGen::error(const vector<int>& errorPos, const Matrix& E){
  vector<alpha> err(N,0);
  int e = errorPos.size();
  for(int i=0;i<e;i++) err[errorPos[i]] = E[i].back();
  return err;
}

メッセージ復号

遥か昔のことなので忘れそうですが、w=mGというふうに符号化したのでした。w,Gはわかっているので連立方程式を解きます。これも掃き出し法で解けます。最後に復号したメッセージを出力して比較すれば無事return 0;です。

const string output = RS::decode(G,restoredWord,K);
cout << "Restored your input: " << output << endl;
compare(vector<char>(input.begin(),input.end()),vector<char>(output.begin(),output.end()),"Original message","Recovered message");
return 0;

RS::decode

string RS::decode(const Matrix& G, const Vec& word, const int K){
  Matrix Gw = Math::AugCoef(G,word);
  Math::gauss_jordan(Gw);
  string message = "";
  for(int i=0;i<K;i++) message += Gw[i].back().toChar();
  return message;
}

ソースコード

全体実装はそれなりに長いのでリンクを貼ります。

reedsolomon.cpp - Google ドライブ

動作確認

プログラムを動かしてみて、誤り訂正できていることを確かめました。僕の環境はC++14ですがC++11でも動くと思います。実行する場合は画面処理(文字色と背景色の交換)があるので対応できる環境で。Windowsコマンドプロンプトではうまくいきません。どうにかできるとは思いますが、おとなしくPowerShellとかWSLを使ったほうが手っ取り早いと思います。

かなり見づらい実行画面

おわりに

リード・ソロモン符号(のおいしいところだけ、ですが)を実装してみました。何かの役に立つわけでもないし、画面に文字が並ぶだけで見栄えもしませんが、我々の生活を陰で支えるアルゴリズムに目を向けて、実装してみるのもけっこう面白いと思います。それでは。

XXXXXX_XXX_XXXXXXX_XX_XXX_XXXX_XXXXX

209be895e02f0bfdfc911a984fc51f516687cb55ce343c3c38b806c0a72b0a371f012706e6adc468c2a1ad20891f107d647b3b5935e9137aaace5db2fcd1164b9df34c3102c10dbd317af006ec43ea45e5c5c0ef28c7dfb606071457244d31c345a83df373d6aac8f0139b05bb84dd4aa3fb21c3742d5fe3dcefec442a1f01e2f5ba4d11f909ffe5ebdbfef722ad5fc15e0ed2160d5753ae08c9de9fa58ebd9c46fc8753d2cb5c92cf15f638d0796efddfd60b3fd89e5f6bdcc25940777b32343fdc677fa4bb98d42cefe74c18f15c190846f6fb32dd0489d296154aedd499c140936a8768e04470e70a5a0252413c6855df9604b895ccf44fee14fa103b5a

おまけ

当初は高専要素皆無だった導入部分(こっちのほうが自然でいいと思う)


情報は、時に欠落や破損をしてしまうものです。手の甲のメモが消えて読めなくなってしまったり、「ニンニク増しで」って言ったのに「ニンニク無し」が出てきたり、(この先は血で汚れていて読めない)

しかし、頑張ればなんとかなるケースも多々あります。なんか文字化けし縺ヲ繧けどギリギリ読める、誤字脱字がるけど陽はこういう意味だよな、と人間の脳は情報を補完します。

同じことをコンピュータの世界でもやっています。QRコードは多少汚れていても読めるし、ディスクに多少傷がついていてもCDは再生できます。必要なデータだけでなく、いっしょに「データが壊れた時に気付いて、元に戻すための手がかり」も記録しておくことで欠けた部分を補っているのです。

具体的なやり方はいろいろあり、その一つにリード・ソロモン符号というものがあります。今回はこれを最発明します。といっても理論を思いつくのは不可能なので、意図的に壊したデータを頑張って復旧させるプログラムを書きたいと思います。


参考文献

[1] 誤り訂正符号入門(第2版), イエルン・ユステセン, トム・ホーホルト原著, 阪田省二郎, 栗原正純, 松井一, 藤沢匡哉 訳, 森北出版(2019)

[2] 有限体(ガロア体)の基本的な話 | 高校数学の美しい物語

[3] 有限体でのガロア拡大(その3) - zuruyasumineko2002’s blog

[4] QRコードの符号化・復号アルゴリズム解説:学習読み物としての試み, 佐藤創, 専修大学情報科学研究所所報,76,37-52 (2011-06-30)

[5] Reed-Solomon符号を15分で理解する【M3 Tech Talk 第187回】 - YouTube

[6] リード・ソロモン符号とその復号法, 松嶋敏泰, 映像情報メディア学会誌 Vol. 70, No. 4, pp. 571~575(2016) https://www.jstage.jst.go.jp/article/itej/70/7/70_571/_pdf

Cookie Clicker(ブラウザ版)の配色が気に食わない

OBが1人で3枠は取りすぎな気がしますが、記事が余ってた&カレンダーが23:30時点でまだ空いてたので呉高専アドベントカレンダー 5日目に放流します。呉高専なーんも関係ない短編です。

ネタバレを多く含みますのでご注意ください。

Cookie Clickerの説明は省きます。知らない人はまずここにアクセスして遊んでください:https://orteil.dashnet.org/cookieclicker/

僕は攻略wikiは見るけどゲームを有利に進めるmodは入れない立場です。なのでmodには基本消極的なのですが…

ゲームを進めていくと遊べるstock marketというミニゲーム、その配色がどうも気に食わないのです。

気に入らない配色

valueが安いときに買って高いときに売るってだけなのですが、どれが安くてどれが高いのかマジで分かりません。

この配色を、modで変更します。こんな感じ。

こっちのほうがまだいい

背景色とかをいい感じにするほうがいい気がしますが、とりあえずこれでひとつ。

0: Cookie Clickerのmod

ブラウザのブックマーク機能を使い、ブックマークレットを実行することで実現します。

1: 値段に応じてpriceの色を変化させる

stock marketの各アイテムの情報はGame.Objects.Bank.minigame.goods[?]を見ます。wikiにある計算式に基づいて標準価格を計算し、その0.5倍から2倍にかけてpriceの色を赤から緑に変化させていきます。プレイしている感じ、あまりよい色の付け方ではないです。安いものは5倍ぐらい振れるし高いのは1.5倍にすらならないので。あとfff4f4とかfafff4とか人の目じゃ分からないです。

// 赤<-> 緑
let rgAry = ["#ff0000","#ff2b2b","#ff5555","#ff8080","#ffaaaa","#ffd5d5","#ffeaea","#fff4f4","#ffffff","#fafff4","#f4ffea","#eaffd5","#d5ffaa","#bfff80","#aaff55","#95ff2b","#80ff00"];
for(let [key,val] of Object.entries(Game.Objects.Bank.minigame.goods)){
  let price=val.val; // 現在の価格
  let center=10+val.id*10+Game.Objects.Bank.level-1; // 標準価格
  let left=center/2; // の半分
  let right=center*2; // の倍
  if(center < price){ // 高い
    let d=Math.min(8,Math.floor(8*(price-center)/(right-center))); // 1倍~2倍を8段階に分けた時、どれぐらいか
    val.valL.style.color=rgAry[8+d]; // 高いほど緑
  }
  else if(price < center){ // 安い
    let d=Math.min(8,Math.floor(8*(center-price)/(center-left))); // 0.5倍~1倍を段階づける
    val.valL.style.color=rgAry[8-d]; // 安いほど赤く
  }
}

2: 在庫があるときの色は緑から黄色に

価格表示に緑を使う関係から、在庫があるときのstockの色が緑だとまずいです。黄色にします。在庫が0になったら元の薄い白に戻すのも忘れずに。

if(val.stock!==0){
  val.stockBoxL.style.color = "#ffff00"; // 黄色
}
else{
  val.stockBoxL.style.color = "#ffffffb3"; // デフォルト
}

3: 隠し実績

Third-partyという隠し実績があり、特定のmodを入れると解除されます(通常プレイで使われない実績解除コマンドがmod内で叩かれてるってだけで、やろうと思えば開発者モードからの解除も可能なのですが。)。せっかくなので解除します。

if(Game.Achievements['Third-party'].won==0){
  Game.Win('Third-party');
}

4: 以上をまとめて毎秒更新

stock marketの情報は時々刻々変化するので一定時間で更新します。1秒に1回で十分です。

setInterval(
  function(){
    // ここに1-3の内容を書くことで毎秒実行される
  }
  ,1000
);

全体

setInterval(
  function(){
    if(Game.Achievements['Third-party'].won==0){
      Game.Win('Third-party');
    }
    if(Game.Objects.Bank.minigameLoaded){
      let rgAry = ["#ff0000","#ff2b2b","#ff5555","#ff8080","#ffaaaa","#ffd5d5","#ffeaea","#fff4f4","#ffffff","#fafff4","#f4ffea","#eaffd5","#d5ffaa","#bfff80","#aaff55","#95ff2b","#80ff00"];
      for(let [key,val] of Object.entries(Game.Objects.Bank.minigame.goods)){
        let price=val.val;
        let center=10+val.id*10+Game.Objects.Bank.level-1;
        let left=center/2;
        let right=center*2;
        if(center < price){
          let d=Math.min(8,Math.floor(8*(price-center)/(right-center)));
          val.valL.style.color=rgAry[8+d];
        }
        else if(price < center){
          let d=Math.min(8,Math.floor(8*(center-price)/(center-left)));
          val.valL.style.color=rgAry[8-d];
        }

        if(val.stock!==0){
          val.stockBoxL.style.color = "#ffff00";
        }
        else{
          val.stockBoxL.style.color = "#ffffffb3";
        }
      }
    }
  },
  1000
);

5: 使う

ネットにアップして、ブックマークレットを作成します

javascript:(function(){Game.LoadMod('httpsから始まるmodソースファイルの場所.js');}());

これをブックマークしてゲームを開いているタブで開けばok

javascript:(function() {Game.LoadMod('https://pointn.github.io/cookiepricecheck/cookiepricecheck.js');}());

変なソートを作りたい

この記事は呉高専アドベントカレンダー2022に11月30日まで空きがあったら適当に埋めるための記事です。→ 4日目の記事になりました / 空きがないので放流します

ごあいさつ

こんにちは、点Nです。電気情報工学科卒です。高専在学中は高専プロコンに出たり、呉高専史上初めてICPC(大学対抗競プロ合戦)に参加して予選敗退したりといった感じです。

こちらは11日の記事の息抜きに書いていたものです。さっそく始めます。

はじめに

ソート。配列を昇順(あるいは降順)に並び替える処理です。単純ながらあらゆる場面で頻繁に使用するアルゴリズムであり、その実現方法には様々なものがあります。呉高専OBである私も、情報処理の授業で各種ソートを実装した覚えがあります。(余談:当時はC言語でしたが、シラバスを見たところ今の子たちはPythonでこれらを学ぶっぽいです。またシラバスにはグラフ理論動的計画法ナップサック問題)、乱択アルゴリズムなどに関する記述もあり、時代の変化を感じます)しかし、教科書に書いてあることを写してソート関数完成やったーというのは(必要かもしれませんが)あまり楽しいものではありません。自分でソートのアルゴリズムを考えて実装したほうが楽しいはずです。なのでそれを今からやりたいと思います。まずアルゴリズムの教科書によく書いてある有名なソートをいくつか振り返ります(知っている人は飛ばしてください)。次に、変なソートを作ってみて、その性能について考えてみたいと思います。途中でちょっとだけ競技プログラミング(競プロ)の話もします。

お約束

  • ソートしたい配列をA、その長さをNとします。0-index(AはA[0]からA[N-1]まで)です。ソートは常に昇順です。
  • 計算量はたいてい平均時間計算量の意味です。計算量の表現にオーダ記法を用います。使い方が雑ですが目をつぶってください。知らない人は、中に書かれた関数をプロットしたときに急に増えていかないほうが嬉しい、と思ってください。
  • Aの連続した区間は半開区間でA[a:b)のように表します。aを含みbを含まない、A[a]からA[b-1]まで、の意味です。

既存の有名ソートアルゴリズムたち

バブルソート

Aを走査しながら、A[i]>A[i+1]となっている部分をswapします。走査回数がN回、走査する部分の長さが平均N/2なので、計算量はO(N ^ {2})です。単純かつ実装難易度が低いですが、計算量は(実用に耐えうる)ソートアルゴリズムの中では悪く、優れたアルゴリズムではありません。

選択ソート

A[0:x)がソートされているとします。未ソートの範囲A[x:N)を走査します。最小の値がA[min]にあるとして、これをA[x]とswapするとA[0:x+1)がソート済みになります。これを繰り返します。計算量はバブルソートと同じ理屈でO(N ^ {2})です。

クイックソート

正直この記事とあまり関係ないので紹介しなくてもいいけどこれ紹介しないのは「アンパンマンワールドのゆかいななかまたちを紹介します!」って言ってアンパンマンを紹介しないのと同じようなものじゃんという感じなので仕方なく紹介します。

A[0:N)をソートすることを考えます。適当な値をピボットとして設定し、それ未満の値をA[0:x)、それ以上の値をA[x:N)の範囲に持ってきます。A[0:x)とA[x:N)がソートされれば全体をソートしたことになるので、これで小さな2つの問題に分割されました。この2つの問題も、同じようにそれぞれ小さな2つの問題に分割して解きます。問題の最小単位は長さ2以下の配列に対するソート(単純な大小比較とswap)です。

検索すればいくらでも出てくるので理屈は他の文献に委ねますが、計算量は平均O(N\log N)、かつ同程度に速い他の手法と比較しても優れていると言われる優秀なアルゴリズムです。しかし、最悪計算量 O(N ^ {2})という問題児な一面も兼ね備えています。

マージソート

分割統治の考え方に基づくアルゴリズムです。A[0:N)をソートすることを考えます。これをA[0:N/2)をソートする問題とA[N/2:N)をソートする問題に分割します。これを解いたら、この2つの数列をマージします。これは「A[0:N/2)とA[N/2:N)に対し、両者の先頭の値を比較して小さいほうを空の配列に加えて、元の配列から除去する」という操作を繰り返すことで可能です。言葉で聞くより図を見るほうがわかりやすいと思います。再帰の深さがおよそ \log _ {2} N、マージの計算量が各再帰深さについて合計O(N)なので、計算量はO(N\log N)です。

マージソート

このほか、挿入ソート:O(N ^ {2})ヒープソートO(N\log N)などが有名だと思います。また、C++11以降のstd::sort()はイントロソートというアルゴリズムを使っています(規格として定まってはいませんが、そのように実装されていることが多いそうです。参考: sort - cpprefjp C++日本語リファレンス )。イントロソートは、クイックソートをベースに、再帰が深くなるとヒープソートに切り替えることによって最悪計算量がO(N\log N)に改善されるというものです。PythonJavaやRustではティムソートなんてのを使っているらしいです。

変なソートを作ろう

思い付きで変なソートを作ってみたいと思います。目標は「バブルソートよりはマシ」、つまりO(N ^ {2})よりもよい計算量です。

平方分割バブルソート

上で述べた通り、バブルソートは計算量がO(N ^ {2})と悪いのですが、これにマージソートの要素を加えて計算量を改善してみたいと思います。

Aをいくつかのブロックに分割することを考えます。何個に分割するかは全体を俯瞰してから適切なものを決めたいので、ここではB個のブロックに分割するものとします。すると、Aは長さN/Bの配列B個に分割されます。

次に各ブロックに対し、ブロック内の範囲でバブルソートします。この操作の計算量は全体でO((N/B) ^ {2}\times B)=O(N ^ {2} / B)です。これによりソートされた長さN/Bの配列がB個できあがります。

最後にこれらの配列を1つの配列にマージします。B個の配列の先頭を走査し、最小の値をソート済み配列に追加し、元の配列から削除する、という一連の操作を繰り返します。配列の要素数の総和は当然Nですから、B要素の走査がN回行われることになり、この処理の計算量はO(NB)です。

全体の計算量はO(N ^{2}/B + NB)となりました。Bはいくらにするのが最適でしょうか。大きくするとNBが、小さくするとN ^ {2}/Bが支配的になってしまうので、その中間のN ^ {2}/B=NBになるあたりで手を打つのがよさそうです。これを変形するとB=\sqrt{N}になります。よって配列を長さ\sqrt{N}の配列\sqrt{N}個に分割して上の処理をすれば、ソート部分とマージ部分の計算量がどちらもO(N\sqrt{N})になり、O(N ^ {2})から改善されます(もちろん通常のO(N \log {N})マージソートには劣ります)。

このように大きさ\sqrt{N}のブロックに分割して計算量を落とす手法を平方分割と呼んだりします。競プロでもよく使う手法です。

例えばこんな問題が解けます


参考にしたのはこちらです。平方分割の練習をしようにも難しい問題ばかり、そんなお悩みに狙いを決めて手取り足取りのレクチャーです! - CADDi Tech Blog

長さNの配列Aが与えられる。以下の2種の命令を順に処理せよ。

  • A[i]をxに変更せよ。
  • A[l:r)の最大値を答えよ。

上の命令は簡単で、即座にできます。下の命令は単純に計算するとO(N)です。重たい処理に引っ張られるので、命令あたりの計算量はO(N)です。

ここで配列を長さ\sqrt{N}のブロック\sqrt{N}個に分割します。また事前に各ブロックごとに要素の最大値を計算し、メモしておきます。こうすると、範囲[l:r)はいくつかのブロックといくつかの端数に分割されます。ブロックの数は\sqrt{N}個以下、端数は右と左で各\sqrt{N}未満ずつなので、それらの最大値を求めるための計算量はO(\sqrt{N})に高速化されます(もちろん、変更命令の際にA[i]だけでなくA[i]が属するブロックの最大値も変更する必要があります。素直にブロック内を走査すればよく、O(\sqrt{N})で計算できます)。 なお、この問題はsegment treeというデータ構造(配列の一点変更と区間最大値をどちらもO(\log N)で処理できます、考え方はマージソートで使ったものに似ています)などを用いてもっと効率的に解くことができます。


ところで、このソートにおいて\sqrt{N}個の配列に分割した後、それらに対してこのソートを再帰的に適用すると、計算量は改善されるでしょうか。答えは否です。これは最終的に長さ\sqrt{N}の配列\sqrt{N}個をマージするパートの計算量がO(N\sqrt{N})から落ちないためです。再帰的に処理する元気があるなら普通のマージソートを使ってください。

追記

先行研究(バブルソートか選択ソートかの違いでほぼ同じもの)が見つかったので紹介します、"ソート 平方分割"などのワードで検索すると他にも出てくると思います:

最長増減部分列分割ソート

ソートの話をしすぎて、ソートをサボりたい気分になりました。配列からすでにソートされている部分を抜き出して、それらをマージするというのはどうでしょうか。例として

A={1,4,7,3,5,6,2,8}

を考えます。分割の際、先頭は必ず選ぶとして、それと同じか大きいものになっていくように選んでいきます。

A={1,4,7,3,5,6,2,8}

A={3,5,6,2}

A={2}

これで3個の配列{1,4,7,8} {3,5,6} {2}に分割されたのでこれを先ほどと同じ要領でマージします。

しかし、問題があります。こんな配列はどうでしょう。

A={8,7,6,5,4,3,2,1}

これは先頭が常に最大になるので8個の数列({8}{7}{6}{5}{4}{3}{2}{1})に分割されてしまいます。逆順ソートされた配列に対してはその要素数ぶんだけ長さ1の配列が作られるので、マージの計算量がO(N^ {2})になってしまいます。処理が複雑なぶん、バブルソートにも負けてしまいそうです。

では、ソートされている部分を抜き出すのと逆順ソートされている部分を抜き出すのを交互にやったらどうでしょうか。こうすれば、先ほどの配列は{8}{7,6,5,4,3,2,1}に分割され、逆順ソートされているものをもとに戻してマージすればいい感じになります。最初の{1,4,7,3,5,6,2,8}もできる配列は3つのままです。

本当にこれでいいのでしょうか。答えは否です。例えば、

A={8,1,7,2,6,3,5,4}

は8つの配列に分割されてしまいます。(逆順ソートを先に抜き出すと若干改善しますが、ほぼ変わりません)

では、この考えかたではO(N ^ {2})から改善することはできないのでしょうか。もう少し頑張ってみます。

なぜ先ほどのやり方ではうまくいかないのでしょうか。それは、「目先のものに後先考えず飛びついているから」です。{8,1,7,2,6,3,5,4}がいい例で、先頭の8に飛びつかず、次の1が来るのを待って、7を我慢して、2を取って、という感じで、できれば{1,2,3,4}{8,7,6,5}に分割してもらいたいのです。ようは、できるだけ長い部分列を取ってきてほしいわけですが、これを実現するアルゴリズムがあります。

最長増加部分列

上の問題は最長増加部分列(Longest Increasing Subsequence、LIS)といい、競プロでも頻出です。これを解くための計算量はO(N\log N)で、{8,1,7,2,6,3,5,4}から部分列{1,2,3,4}を抜き出すことができます。アルゴリズムの中身は動的計画法(Dynamic Programming)と呼ばれるものです。配列dpを用意して、Aを走査しながら

dp [ i ] = 長さiの増加列の末尾の最小値

となるように更新していきます(今回はLISを配列から抜き出す必要があるので、末尾の最小値ではなく末尾の最小値が格納されているインデックス、としています)。

LISについてもう少し詳しく


例としてA={1,6,2,5,8,3,4,7}のLISを考えます。最初、dp={inf, inf, inf, inf, inf, inf, inf, inf}です(infは無限大の意)。また、今だけ1-indexにします(そのほうが理解が容易です)。

  1. A[1]=1に注目します。これを使うと長さ1の増加列{1}ができます(増加列というのは違和感ですが)。dp[1]:=1と更新します。dp={1, inf, ..., inf}です。

  2. A[2]=6です。先ほどの1と合わせて長さ2の増加列{1, 6}ができます。dp[2]:=6です。dp={1, 6, inf, ..., inf}です。

  3. A[3]=2です。残念ながら長さ3の列{1,6,2}は増加列ではありません(6→2で減ってしまう)。ですが、最初の1と合わせて長さ2の増加列{1, 2}を作ることができます。これは{1, 6}よりも嬉しいです。なぜなら、6より大きい数よりも2より大きい数のほうが多いため、増加列を伸ばしやすくなるからです。dp[2]:=2、dp = {1, 2, inf, ..., inf} です。

  4. A[4]=5です。さっきのdp[2]=2が効いて、長さ3の増加列{1, 2, 5}が作れます。dp[3]:=5、dp = {1, 2, 5, inf, ..., inf}です。

  5. A[5]=8です。dp[4]:=8、dp = {1, 2, 5, 8, inf, ..., inf}です。

  6. A[6]=3です。今度の更新はdp[3]:=3になります。5を3に書き換えることで後ろに続くことができる数の範囲を広げます。dp = {1, 2, 3, 8, inf, ..., inf}です。

  7. A[7]=4です。dp[4]:=4とできます。dp = {1, 2, 3, 4, inf, ..., inf}です。

  8. A[8]=7です。5. の終わりではdp={1, 2, 5, 8, ...}でしたがこれが6. と7. で{1, 2, 3, 4, ...}に変わったのが効いて、dp[5]:=7とできます。dp = {1, 2, 3, 4, 7, inf, ..., inf}でフィニッシュです。dpからinfでない部分を抜き出して、LISは{1, 2, 3, 4, 7}となります。

さて、dp[?]:=A[i]という更新をたくさんやってきましたが、この?の位置はどうやって決めればよいでしょうか。よく観察すると、この位置は「A[i]より大きい数が入っている場所のうち、最も左」です。これは二分探索で効率的に求められます。

(LISについてはこちらも合わせてご覧ください: LIS でも大活躍! DP の配列使いまわしテクニックを特集 - Qiita

二分探索とは


二分探索は単調増加/減少なものから何かを探索するときに使うアルゴリズムです。

今回は、「dp配列のうちA[i]より大きい数値が入っている場所の左端」を求めます。dp配列が以下のように単調増加になっているので二分探索が使えます。

dp[1]\le dp[2]\le ... \le dp[N]

まず、配列全体のど真ん中を見ます。もしその値がA[i]より大きいなら、更新すべき場所は配列のど真ん中それ自身か、それより左側のどこかです。逆に小さければ、更新すべき場所は配列のど真ん中より右側のはずです。これだけで考えるべき範囲が一気に半分になりました。次に、半分になった探索範囲のど真ん中を見て、それよりも右か左かを判定します。すると、全体の半分だった探索範囲をさらに半分にできます。これを繰り返すことで探索範囲を毎回半分に減らしていくことができ、いずれ1か所に特定できます。繰り返し回数はおよそ\log _ {2} N回で、Nに対してかなり小さい値になります。

このように、探索範囲を半分に狭めながら行う探索が二分探索です。



これを用いて配列の最長増加部分列と最長減少部分列のうち長いほうを順次抜き出していき、最後それらをマージする、これならいい感じに計算量が減ってくれそうです。

さて、最長増加部分列または最長減少部分列に分解していくと、結局いくつの配列ができあがるのでしょうか。これについて、興味深い定理があります。それがErdös Szekersの定理です。

a,b を正の整数とする。各項が相異なる長さ ab+1 の数列があるとき,以下の二つのうち少なくとも一つは必ず存在する。

  1. 長さ a+1 の部分列で,単調増加なもの

  2. 長さ b+1 の部分列で,単調減少なもの

(Erdös Szekersの定理とその証明 | 高校数学の美しい物語, https://manabitimes.jp/math/909)

ここでは証明には触れませんが(鳩の巣原理という競プロでもよく使う原理を使います。そこまで難しくないと思うのでぜひ引用元のページを読んでみてください)、要はどんな場合も現時点の配列の長さの平方根以上の長さの単調増加or減少部分列を取ることができると解釈できます。

この性質を知ったうえで、ある長さの配列が何個ぐらいの部分列に分割されるかを実際にプログラムで試してみます。具体的には漸化式

f(0)=0,f(n) = f(n- \lfloor \sqrt{n} \rfloor ) +1

nを変えて計算します。すると、

f(1000)=63, f(10000)=199, f(10 ^ {6}) = 1999, f(10 ^ {10})=199999

と、f(n)\approx 2\sqrt{n}になりそうです。

ざっくりとした理屈を考えてみましょう。まず\lfloor\sqrt{n}\rfloorが気持ち悪いのでn=x ^ {2}として式を書き直すと、

f(x ^ {2})=f(x ^ {2}-x)+1=f( (x-1/2) ^ {2}-1/4)+1\approx f( (x-1/2) ^ {2})+1

となります。g(x)=f(x ^  {2})とおくとg(x)=g(x-1/2)+1となり、これは一次関数g(x)=2xです。結果的に、

f(n)=g(x)=2x=2\sqrt{n}=O(\sqrt{n} )

になりそうです。

配列数がO(\sqrt{N})なので、マージにかかる計算量はO(N\sqrt{N})です。問題はLISを求めるパートの更新計算にかかる\log {N}です。二分探索を行う回数がO(N)とかになってくれないかなーと思ったのですが残念ながらO(N\sqrt{N})になるようなので、こちらがボトルネックO(N\sqrt{N}\log{N})っぽいです。

二分探索回数の解析


配列がいくつに分割されるか考えた時とほぼ同様の手法です。その時点での配列長nにおける二分探索の実行回数S(n)は、

S(0)=0,S(n)=n+S(n-\lfloor\sqrt{n}\rfloor)

であると考えられます。n=x ^ {2}として書き直すと

S(x ^ {2})\approx x ^ {2}+S( (x-1/2) ^ {2}) =x ^ {2}+(x-1/2) ^ {2}+S( (x-1) ^ {2})=...=\sum _ {i=1} ^ {2x} (i/2) ^ {2}=(\sum _ {i=1} ^ {2x} i ^ {2}) /4

で、自然数の二乗の和の公式

\sum _ {i=1} ^ {x} i ^ {2}=x(x-1)(2x-1)/6

より、

S(n)\approx (16x ^ {3}/6) / 4=2x ^ {3}/3=O(x ^ {3})=O(n\sqrt{n}) です。実際にプログラムで検証してみると、これに近い結果が得られます。


実験

手元のノートパソコンで、要素数N={10000, 50000, 100000, 500000}、0以上 230 未満のランダムな値の配列各100個に対して各種ソートを実行し、実行時間を計測してみます。せっかく計算量を評価したので、変なソートたちがバブルソートより速いこと(そして標準のソートより数段遅いこと)、あとできれば変なソート達の計算量の差が確認できると嬉しいです。

というわけでプログラムを書きました。200行近いソースコードをここに貼ると大変なことになるのでもう少し下にリンクを貼っています。

下表に平均処理時間[ms]を示します。xと書いてある部分のデータは膨大な時間がかかるのでとっていません。処理時間がきわめて短いものは範囲で示しています。

N 標準ソート バブルソート 平方分割 部分列分解
1e4 0~1 118 1~4 12
5e4 1~4 3915 27 158
1e5 4~8 x 74 463
5e5 34 x 740 5732

この表から分かる通り、C++標準のソートは速いしバブルソートは使い物になりません。平方分割によってバブルソートは格段に速くなりますが標準のソートよりだいぶ遅いです。部分列分解は平方分割よりさらに数倍遅く、\logや実装の重さが響いている感じでしょうか。

ソースコード

リンクはこちら(N=100や1000のデータもとるようになっているのですが、軽すぎて無意味でした)。自然な実装にしてあるので、「おいおい何やこの無駄な処理は」と思う方は我慢してください。Intel Core i7-7500U(3.5GHz)、gcc8.1.0、C++17、最適化オプションO2です。

strangesort.cpp - Google ドライブ

おわりに

使い物にならないソートを作り、その性能を確かめました。皆さんも面白いソートを作ってみてはいかがでしょうか。