本発表では、循環 2 重リンクリストについて、その定義と実装方法を取り扱う。
循環 2 重リンクリストは、発表者の見る限り、日常的な Web アプリ開発では見かけることのないデータ構造である。しかしながら、データ構造やそれを扱う関数についての学習については一定のリターンがあると信じる。
本発表の構成としては、以下の 3 つで出来ている。
なお、説明や実装には、Standard ML および SML#を用いる。本発表で取り扱っている文章や記述に関しては、参考文献[1]の 8 章に基づくところがほとんどである。また、本発表では扱わない、SML における参照透明性や値多相性、SML#のランク 1 多相性については、参考文献[1]に取り扱われている。
循環 2 重リンクリストの定義および Standard ML での実装の前準備として、参照型と評価順序について説明する。なお、Standard ML についての説明は省略する。
ML の変数は、その変数に束縛された値を表す式である。そのため、変数の値を「更新する」といった概念はない。しかし、処理するデータ構造によっては、共有変数の更新ができると、効率よいプログラムが書ける場合が多い。この目的のために、特殊なデータ型である ref
型構成子が用意されている。
t ref
型:
t
型のデータへのポイントt
の参照型と呼ぶt
の型に関わらず、同一のポイントか否かの等値性テストが可能なeqtype
であるref
型に関して以下のような演算が定義されている。
infix 3 :=
val ref : 'a -> 'a ref
val ! : 'a ref -> 'a (* 値を取り出す *)
val := : 'a ref * 'a -> unit (* 参照型データの値を更新する演算子 *)
なお、この参照型を用いることにより、本発表で扱うリストだけでなく、一般のグラフ構造を表現できる。
参照型では、評価の順序によって結果が異なる。評価順序とそのための構文は、それぞれ以下の通りである。
式の評価順序:
let ... in exp end
では、順々に評価exp1 exp2
)の評価は、
exp1
を評価し、fn x => exp0
を得、exp2
を評価し、値 v を得x
をv
に束縛し、exp0
を評価する評価順序の制御のための構文:
(exp1; ...; expn)
exp1
からexpn
をこの順に評価し、 expn
の値をこの式全体の値とするexp1 before exp2
exp1
, exp2
の順で評価し、exp1
をこの式全体の値とするexp2
はunit
型の式でなければならない
before
だからwhile exp1 do exp2
bool
型を持つexp1
の評価結果がfalse
になるまで、exp1
とexp2
をこの順に繰り返し評価()
であるなお、本発表で取り扱うコードに関して、手元の SML の対話環境で動作を確認する場合、以下のようにすることで、表示される深さが変更可能である。
Control.Print.printDepth := 20;
循環 2 重リンクリストについて、説明する。
循環 2 重リンクリスト(双方向連結リスト)とは、通常のリストとは異なる点が 2 つある:
通常のリストは、先頭要素の取り出しおよび要素を順に処理するのに適したデータ構造であるが、一方、循環 2 重リンクリストは、リストを逆方向にたどったり、リストの中間要素を取り除くといった操作を効率的に行える柔軟なリスト構造である。実際、リスト全体を反復処理するケースでは、循環 2 重リンクリストの循環性が柔軟性を提供してくれる。(cf: Linux カーネルが循環的な二重リンクリストを使用してプロセスのリストを格納するのはなぜですか?)
次に、Standard ML における実装を見る。'a
を型変数とし、以下のようにすることで、ユーザー型定義で型構成子'a cell
を定義し(NIL
やCELL
は値構成子(データ構成子))、そして、型シノニム'a dlist
を与える。
(* 参照型とデータ型定義を組み合わせて循環2重リンクリストを作る *)
datatype 'a cell = NIL | CELL of {data: 'a, left: 'a cell ref, right: 'a cell ref}
type 'a dlist = 'a cell ref;
通常のリストと異なり、data
や後続(right
)の cell への参照だけではなく、前方(left
)への参照がある。特に、末尾の cell は先頭の cell への参照を持ち、また、先頭の cell は末尾の cell への参照を持っている。
循環 2 重リンクリストの基本操作関数の型は以下の通りである。関数については省略する。
val dataDlist : 'a dlist -> 'a (* 先頭のデータを取り出す *)
val rightDlist : 'a dlist -> 'a dlist (*ポインタを右にたどる*)
val leftDlist : 'a dlist -> 'a dlist (*ポインタを左にたどる*)
val insertDlist : 'a -> 'a dlist -> unit (*要素を追加する*)
val singleDlist : 'a -> 'a dlist (*要素1つの循環2重リンクリストを作る*)
val deleteDlist : 'a dlist -> unit (*先頭要素を削除する*)
val fromListToDlist : 'a list -> 'a dlist (*リストを循環2重リンクリストに変換する*)
2 つの循環 2 重リンクリストを連結させる関数を考えよう。つまり、
dlist1
: [l1 | d1 | r1 as ref (CELL{right=r11}, ...) ]
dlist2
: [l2 | d2 | r2 as ref (CELL{right=r21}, ...) ]
とあった場合に、
dlist1
: [l2 | d1 | r1 as ref (CELL{right=r21}, ...) ]
dlist2
: [l1 | d2 | r2 as ref (CELL{right=r11}, ...) ]
となるような関数である。
以下が求める関数の型である。
val concatDlist : 'a dlist -> 'a dlist -> unit
実装の方針は以下の通りである。
left
の更新は
dlist1
のleft
の値を、dlist2
の left の値、つまり、dlist2
の末尾の要素への参照にし、dlist2
のleft
の値を、更新前のdlist1
の left の値、つまり、更新前のdlist1
の末尾の要素への参照にし、right
の更新に関しては、right
の参照先のCELL
のleft
は更新しないようにすることに気をつけ、left
の場合と同様に行う。fun concatDlist dlist1 dlist2 =
case (dlist1, dlist2) of
(ref NIL, _) => (dlist1 := !dlist2)
| (_, ref NIL) => (dlist2 := !dlist1)
| (d1 as ref (CELL {right=r1 as ref (CELL{right=r11,...}), left=l1, ...}),
d2 as ref (CELL {right=r2 as ref (CELL{right=r21,...}), left=l2, ...}))
=> let
val previousL1 = !l1
val previousR11 = !r11
in
(l1 := !l2; l2 := previousL1; r11 := !r21; r21 := previousR11)
end;
以上で、循環 2 重リンクリストに関してのおおよその理解が得られたかと思う。次節からは、通常のリストとは異なり、循環 2 重リンクリストを扱う関数を作る上で気をつけねばならないことについて扱う。
次に、循環 2 重リンクリストをリストにする操作を関数として与えたい。
しかし、循環 2 重リンクリストは循環するため、通常のリストのように空になるまで再帰的に処理を行うといったプログラミは書けない。そのため、処理を行ったセルを記録し、すでに処理済みのセルが見つかるまで処理を行うようなコードを書かなければならない。
以下が求めている関数の型である。
val dlistToList : 'a dlist -> 'a list
実装例は次のようになる。
(* すでに処理済みかどうかを判定 *)
fun member x visited =
case visited
of nil => false
| (h::t) => if h = x then true else member x t;
fun dlistToList L =
let fun f l visited = (*すでにたどったポインタをvisitedに記録しながらリストを右にたどっていく*)
if member l visited then nil
else (dataDlist l)::(f (rightDlist l) (l::visited))
in f (rightDlist (leftDlist L)) nil (* 最初の要素の重複をさけるために、リンク内のポインタからスタート*)
end;
補助関数f
は、すでにたどったポインタをリストvisited
に記録しながらリストを右にたどっていく。関数member
は、メンバーシップテスト関数であり、すでに処理済みのポインタかどうかを判定できる。これらの関数によって、循環構造を辿る際の終了条件の適切な判定がなされる。
このように履歴を取ることによって、無限にループし続けないようにするためには何らかの履歴を取る必要がある。
新たな循環 2 重リンクリストを作ることが要求される関数を作る場合は、終了条件の判定に加えて、left
およびright
フィールドをたどると自分自身に戻ってくる CELL
への参照型データの作成にも注意を払わねばならない。
評価順序の節からもわかるように、参照型を用いると履歴に依存することになり、履歴に依存するプログラムは、各部分の動作がプログラム全体の流れに依存して決まるため、意味が把握しづらくなる。なので、不必要な参照型の利用は控えるように。
これまでの話より、循環 2 重リンクリストの長所と短所を感じられたかもしれないが、ここで、まとめよう。
なお、循環 2 重リンクリストの長所と短所については、GeeksforGeeks の記事"Doubly Circular Linked List | Set 1 (Introduction and Insertion)"にある記述から引用した。また、用語に関しては、意味を損なわない範囲で、本発表で使われた単語に置き換える。
O(1)
で済む。