Twitter: @kinaba

12:43 08/04/19

ろんぶん

CIAA 2008 通ったらしい。 (*AUTHORS*) さんの論文 (*TITLE*) が accept されましたっていう テンプレート埋まってないぞ感バリバリのメールが来ました。 初 LNCS ですドキドキです。

Inorder, no stack

ときどきの雑記帖 i戦士篇

正直そこまで面白いこと書いてない気がするんですが 適当に解説にトライ。 問題は、二分木をinorderでたどったリストを返す関数…要するにこれ

inorder(t) = if t == null then [] else inorder(t.left) ++ [t.value] ++ inorder(t.right)

を「再帰なし、自分でスタック作るのもなし、各ノードにboolフラグ足すのもなし」で実装せよ、というもの。 わりとよく知られてる手続き型の解があるんですけど、それを、できるだけ純粋関数型の範囲で プログラムの変形手続きで導出しよう。

１ステップ目。これがキーアイデア。 inorder(t.left) ++ [t.value] ++ inorder(t.right) は、 inorder( t.left の右下端に {left=null, value=t.value, right=t.right} をくっつけた木 ) と等しい。

inorder(t) = if t == null then [] else if t.left == null then [t.value] ++ inorder(t.right) else inorder( join(t.left, build(null,t.value,t.right)) )

build は引数３つから木のノードを作る関数。これは再帰してない。 join は木の右下端に別の木をくっつける関数。これは普通にやると実装に再帰が必要なので、まだダメ。 この join をなんとかして消さないといけません。

２ステップ目。それはそれとして、とりあえず末尾再帰にしてみた。

inorder(t , S=[] ) = if t == null then S else if t.left == null then inorder( t.right , S++[t.value] ) else inorder( join(t.left, build(null,t.value,t.right)) , S )

３ステップ目。joinを消すための前準備として、ノードの「右側の子」を得る操作を、 直接 t.right と書くんじゃなくて、関数としてパラメタライズしてみる。デフォルトは普通に.rightを返す処理。

inorder(t, S=[] , rt=(fun p -> p.right) ) = if t == null then S else if t.left == null then inorder( rt(t) , S++[t.value] , rt ) else inorder( join(t.left, build(null,t.value, rt(t) )), S , rt )

４ステップ目。join(l,r) ってのは「l の一番右端のノードの "right" が r を指すようにする」 という処理でした。今"right"を取得する部分を関数でパラメタライズしてあるので、そこをいじれば、 実際のjoin処理なしで、巧くごまかしてやることができます。 具体的には、rt の代わりに「l の一番右端の子孫が引数に渡されたら r を返す。それ以外の場合はrtと同じに 振る舞う」という関数を渡してやればOK。

inorder(t, S=[], rt=(fun p -> p.right)) = if t == null then S else if t.left == null then inorder( rt(t), S++[t.value], rt ) else let p = rightmost(t.left) in inorder( t.left, S, rt[p=>build(null,t.value,rt(t))] ) where rightmost(p) = if rt(p)==null then p else rightmost(rt(p)) rt[p => q] = (fun t -> if t==p then q else rt(t))

これでjoinはinorderの中に溶け込んで消えてしまいました。使われている再帰関数（inorder, rightmost）は どちらも末尾再帰なので、これで「再帰なし」は実現できたと言っていいでしょう。 まだ問題なのが引数 rt で、これ、木を一段降りるたびに build(null,t.value,rt(t)) っていう情報を 内部に蓄えてってるんですよね。要するにスタックじゃん。てことで、次はbuildを消します。

５ステップ目。build(null,t.value,rt(t)) って、left が null なの以外は t と同じです。 なので基本的には inorder( t.left, S, rt[p=>t] ) こうしちゃう方針でbuildを消していきます。 ただし、本当にそれだけやると無限ループするので、「rtをいじってできたrightmost(t.left)--->tの辺を たどってtに戻ってきたときは、leftには進まない」ように工夫します。

inorder(t, S=[], rt=(fun p -> p.right)) = if t == null then S else if t.left == null then inorder( rt(t), S++[t.value], rt ) else let p = rightmost(t.left) in if rt(p) == t then -- ループ時はleft==nullの時と同じ inorder( rt(t), S++[t.value], rt ) else inorder( t.left, S, rt[ p=>t ] ) where rightmost(p) = if rt(p)==null || rt(p)==t then p else rightmost(rt(p)) rt[p => q] = (fun t -> if t==p then q else rt(t))

６ステップ目。ちょっと証明（と手続き型的に考える時）の都合により、 リストと一緒に終了時のrtも返すようにします。あと、ループから戻った時にrtを元に戻すようにします。 inorder列挙処理自体にはあんまり関係ありません。

inorder(t, S=[], rt=(fun p -> p.right)) = if t == null then (rt,S) else if t.left == null then inorder( rt(t), S++[t.value], rt ) else let p = rightmost(t.left) in if rt(p) == t then -- ループ時はleft==nullの時と同じ inorder( rt(t), S++[t.value], rt [p=>null] ) else inorder( t.left, S, rt[p=>t] ) where rightmost(p) = if rt(p)==null || rt(p)==t then p else rightmost(rt(p)) rt[p => q] = (fun t -> if t==p then q else rt(t))

これで変形終わり。純粋関数型ゾーン終わり。

で、何？

詳細は略しますが、スライドに書いてあるようにやると、 このアルゴリズムが無限ループしないでちゃんと停止することと、終了時の rt が (fun p->p.right) と 関数として等しいことが示せるそうです。

最終形は、「末尾再帰をループに展開」「rt の仮想的な変更を、実際の破壊的なポインタ差し替えに変更」を 機械的に実行すると、こんな風な手続き型プログラムになります。

inorder(t) = S = [] while t != null: if t.left == null: t = t.right S << t.value else: -- p = rightmost(t.left) p = t.left while p.right!=null && p.right!=t: p = p.right if p.right == t: t = t.right S << t.value p.right = null -- rt[p=>null] else: t = t.left p.right = t -- rt[p=>t] return S

これが完成系。これで、再帰無しスタック無しフラグ無しinorder列挙ができました。 関数型バージョンで証明された「終了時の rt が (fun p->p.right) と関数として等しい」をこっちに当てはめると、 「色々ポインタ書き換えながら実行してるけど、最後は元通りにちゃんと戻してるよ」ということが証明されてる ことになります。