M.Hiroi's Home Page

F# Programming

お気楽 F# プログラミング超入門

[ PrevPage | F# | NextPage ]

モジュール

「モジュール (module)」はデータ構造とそれを操作する関数を一つにまとめるための仕組みです。近年はモジュールに相当する機能を持つプログラミング言語が多くなりました。もちろん、F# にもモジュールがあります。List や Array などの標準ライブラリはモジュールにまとめられています。

●モジュールの定義

F# の場合、キーワード module を使ってモジュールを定義します。module の使い方は二種類あって、ひとつはソースファイルの先頭で module を宣言することです。これを「トップレベルのモジュール」といいます。

module モジュール名

これでソースファイルに書かれている変数、関数、例外、型などがモジュールに格納されます。モジュールに定義されている変数や関数は モジュール名 + "." + 名前 でアクセスすることができます。

なお、F# はトップレベルのモジュールが宣言されていなくても、ソースファイルをモジュールとして扱います。この場合、モジュール名はファイル名の先頭を英大文字に変えたものになります。たとえば、foo.fs や foo.fsx というファイルを #load した場合、モジュール名は Foo となります。

トップレベル以外のモジュールは次の構文で定義します。

module モジュール名 = begin ... end

[軽量構文]
module モジュール名 =
  ...

begin ... end がモジュールの本体です。軽量構文を使うと begin と end を省略することができます。モジュールは入れ子にすることができます。たとえば、モジュール Foo の中でモジュール Bar を定義した場合、Bar の変数や関数は Foo.Bar.名前 でアクセスすることができます。

●スタック

それでは簡単な例として、「スタック (stack)」というデータ構造を考えてみましょう。次の図を見てください。


                  図 1 : スタックの動作例

上図は、バネがついた容器を表していて、上から品物を出し入れすることができます。初めは空の状態です。ここに品物を乗せると、重さによってバネを圧縮し、品物が容器に格納されます。さらにもう一つ品物を上に乗せると、さらにバネを圧縮し、その品物も容器に格納することができます。バネが限界まで圧縮されると、もう品物は追加できなくなります。取り出す場合は、上にある品物から行います。一つ取り出すと、その分バネが伸びて下にある品物が上に押し出されます。

この容器の動作が、スタックの動作なのです。スタックにデータを追加する操作をプッシュ (PUSH) といい、スタックからデータを取り出す操作をポップ (POP) といいます。品物をデータに見立てれば、データ A をスタックにプッシュし (2)、次にデータ B をプッシュします (3)。データを取り出す場合、あとから入れたデータ B が先にポップされ (4)、その次にデータ A がポップされてスタックが空になります (5)。このように、スタックはあとから入れたデータが先に取り出されるので、後入れ先出し (LIFO : Last-In, First-Out) と呼ばれます。

●スタックの実装

F# の場合、スタックはリストを使って簡単に実現することができます。データを追加するときはリストの先頭に追加し、データを取り出すときはリストの先頭から行うように操作を限定すると、スタックの動作と同じになります。プログラムは次のようになります。

リスト 1 : スタック (stack.fs)

module Stack

// 例外
exception Empty

// スタックの定義
type 'a stack = SNil | SCell of 'a * 'a stack

// 空のスタック
let create = SNil

// データの追加
let push st x = SCell (x, st)

// データの取得
let top = function
  SNil -> raise Empty
| SCell (x, _) -> x

// データの削除
let pop = function
  SNil -> raise Empty
| SCell (_, xs) -> xs

// スタックは空か
let is_empty st = st = SNil

最初に type でスタック 'a stack を定義します。リストを使ってもいいのですが、ここではヴァリアントで stack 型を定義しています。データ構造はリストと同じです。空のスタックを返す create は関数ではなく変数として定義します。

スタックの操作関数は簡単です。push はデータを stack の先頭に追加します。pop は stack の先頭要素を取り除きます。データの取得は関数 top で行います。スタックが空の場合、pop と top を適用することができないので、例外 Empty を送出します。関数 is_empty はスタックが空かチェックする述語です。

実際に stack.fs を読み込むと次のように表示されます。

> #load "stack.fs";;
[読み込み中 /home/mhiroi/fsharp/stack.fs]
namespace FSI_0002
  exception Empty
  type 'a stack =
    | SNil
    | SCell of 'a * 'a stack
  val create: 'a stack
  val push: st: 'a stack -> x: 'a -> 'a stack
  val top: _arg1: 'a stack -> 'a
  val pop: _arg1: 'a stack -> 'a stack
  val is_empty: st: 'a stack -> bool when 'a: equality

module Stack が省略されていますが、モジュール内で定義されている要素の型が表示されます。これを「シグネチャ (signature)」といいます。シグネチャはモジュールの仕様を記述したものです。モジュールを定義するとシグネチャが生成されますが、ユーザーがシグネチャを定義して、それをモジュールに設定することもできます。シグネチャはあとで簡単に説明します。

簡単な実行例を示します。

> let a0 = Stack.create;;
val a0: 'a Stack.stack

> let a1 = Stack.push a0 1;;
val a1: int Stack.stack = SCell (1, SNil)

> let a2 = Stack.push a1 2;;
val a2: int Stack.stack = SCell (2, SCell (1, SNil))

> Stack.top a2;;
val it: int = 2

> let a3 = Stack.pop a2;;
val a3: int Stack.stack = SCell (1, SNil)

> Stack.is_empty a3;;
val it: bool = false

> let a4 = Stack.pop a3;;
val a4: int Stack.stack = SNil

> Stack.is_empty a4;;
val it: bool = true

正常に動作していますね。関数型言語の場合、変数の値を書き換えることができないので、push や pop の返り値を別の変数に格納する必要があります。このとき、参照型の変数にスタックを格納すれば、値を書き換えることができるので便利なように思います。これはあとで試してみましょう。

●open 宣言

ここで open 宣言を使うと、モジュール名を省略することができます。

open モジュール名
> open Stack;;
> let b = create;;
val b: 'a stack

> let c = push b 10;;
val c: int stack = SCell (10, SNil)

open 宣言は同じ名前が既に存在している場合、元の名前を隠蔽してしまいます。ご注意くださいませ。

●名前空間

F# はトップレベルのモジュールのかわりに「名前空間 (namespace)」を使うことができます。

namspace 名前

モジュールと違って、名前空間は変数や関数などの値を直接定義することはできません。値を定義するためのモジュールが必要になります。たとえば、名前空間 Foo の中にモジュール Bar を定義する場合、Bar の中の要素 name は Foo.Bar.name でアクセスすることができます。

簡単な例として、リスト 1 の stack.fs を名前空間を使って書き直すと次のようになります。

リスト 2 : スタック (名前空間を使用)

namespace Mylib

module Stack =
  // 例外
  exception Empty

  // スタックの定義
  type 'a stack = SNil | SCell of 'a * 'a stack

  // 空のスタック
  let create = SNil

  // データの追加
  let push st x = SCell (x, st)

  // データの取得
  let top = function
    SNil -> raise Empty
  | SCell (x, _) -> x

  // データの削除
  let pop = function
    SNil -> raise Empty
  | SCell (_, xs) -> xs

  // スタックは空か
  let is_empty st = st = SNil
> #load "stack.fsx";;
[読み込み中 /home/mhiroi/fsharp/stack.fsx]
namespace FSI_0003.Mylib
  exception Empty
  type 'a stack =
    | SNil
    | SCell of 'a * 'a stack
  val create: 'a stack
  val push: st: 'a stack -> x: 'a -> 'a stack
  val top: _arg1: 'a stack -> 'a
  val pop: _arg1: 'a stack -> 'a stack
  val is_empty: st: 'a stack -> bool when 'a: equality

> open Mylib.Stack;;
> let a = create;;
val a: 'a stack

> let b = push a 123;;
val b: int stack = SCell (123, SNil)

open 宣言で名前空間を省略することもできます。open Mylib.Stack とすれば、名前空間とモジュール名を省略することができます。

名前空間は一つのソースファイルの中で複数定義することができます。

namespace Foo

... 略 ...

namespace Bar

... 略 ...

名前空間 Foo の範囲は、最初の namespace から次の namespace までの間です。二番目の Bar は次の namespace まで、またはファイルの終わりまでになります。名前空間を入れ子にする場合は、名前で明示してください。たとえば、Foo の中で Bar を定義するには次のように記述します。

namespace Foo

... 略 ...

namespace Foo.Bar

... 略 ...

namespace Foo.Bar と宣言することで、Foo の中に Bar を定義することができます。

●シグネチャの定義

次はシグネチャについて簡単に説明します。F# の場合、シグネチャはファイルで定義します。拡張子は .fsi とします。たとえば、ソースファイル foo.fs のシグネチャは foo.fsi に記述します。foo.fs を「実装ファイル」といい、foo.fsi を「シグネチャファイル」といいます。

シグネチャファイルでも名前空間やモジュールを宣言しますが、その名前は実装ファイルと同じでなければいけません。そして、モジュールの中で要素の型 (仕様) を記述します。以下に主な仕様を示します。

F# のシグネチャファイルは実装ファイルの仕様を記述したものですが、もう一つ重要な役割として外部とのインターフェースがあります。たとえば、モジュールの内部だけで使用する変数や関数は、外部から使われることがないように隠した方がよいでしょう。

このような場合、外部に公開する関数や変数だけをシグネチャに記述すればよいのです。シグネチャに記述されていない変数や関数は非公開となり、外部からアクセスすることができなくなります。また、type で定義されているコンストラクタも、シグネチャに記述しなければ隠蔽することができます。

たとえば Stack の場合、次のように type で型が宣言されています。

type 'a stack = SNil | SCell of 'a * 'a stack

この場合、コンストラクタ SNil や SCell が公開されるので、コンストラクタを使ってスタックを生成したり、パターンマッチングでスタックの要素を取り出すことができます。

> let a1 = SCell (1, SNil);;
val a1: int stack = SCell (1, SNil)

> match a1 with
-   | SNil -> 0
-   | SCell (x, _) -> x;;
val it: int = 1

このように、コンストラクタが公開されているとスタックを直接操作することが可能となります。データ構造を隠蔽したい場合、シグネチャで宣言する type の右辺を記述しません。具体的には次のようにシグネチャファイルを定義します。

リスト 3 : データ構造の隠蔽 (stack.fsi)

namespace Mylib
module Stack =
  type 'a stack
  exception Empty
  val create: 'a stack
  val push: 'a stack -> 'a -> 'a stack
  val top:  'a stack -> 'a
  val pop:  'a stack -> 'a stack
  val is_empty: 'a stack -> bool when 'a: equality

これでコンストラクタを利用することができなくなります。簡単な実行例を示しましょう。

> #load "stack.fsi" "stack.fs";;
[読み込み中 /home/mhiroi/fsharp/stack.fsi
 読み込み中 /home/mhiroi/fsharp/stack.fs]
namespace FSI_0002.Mylib
  exception Empty
  type 'a stack =
    | SNil
    | SCell of 'a * 'a stack
  val create: 'a stack
  val push: 'a stack -> 'a -> 'a stack
  val top: 'a stack -> 'a
  val pop: 'a stack -> 'a stack
  val is_empty: 'a stack -> bool when 'a: equality

> open Mylib.Stack;;
> let a1 = SCell (1, SNil);;

  let a1 = SCell (1, SNil);;
  ---------^^^^^

/home/mhiroi/fsharp/stdin(3,10): error FS0039: 値またはコンストラクター 'SCell' が定義されていません。

シグネチャファイルのロードは実装ファイルをロードする前に行ってください。

シグネチャを使ってデータ構造を隠蔽する方法は OCaml と同じですが、F# にはアクセス制御指定子 (public、internal、private) を使ってアクセスを制御する方法も用意されています。これはモジュールでも使用することができるので、こちらを使ったほうが簡単かもしれません。たとえば、次のように type の右辺で private を指定します。

type 'a stack = private SNil | SCell of 'a * 'a stack

これでコンストラクタ SNil, SCell を非公開にできるので、外部から使用することができなくなります。アクセス制御については「オブジェクト指向」を取り上げるときに詳しく説明する予定です。

●参照型を使ったスタックの実装

ところで、スタックを参照型の変数に格納しておいて、その値を書き換えることでスタックの状態を更新することができます。手続き型言語のプログラミングスタイルになりますが、for ループや while ループと組み合わせて使うときには便利でしょう。次のリストを見てください。

リスト 4 : 参照型のスタック (stack1.fs)

module Stack

// 例外
exception Empty

// スタックの定義
type 'a stack = private SNil | SCell of 'a * 'a stack

// 空のスタック
let create () = ref SNil

// データの追加
let push (st: 'a stack ref) x =
  st.Value <- SCell (x, st.Value)

// データの取得
let top (st: 'a stack ref) =
  match st.Value with
    SNil -> raise Empty
  | SCell (x, _) -> x

// データの削除
let pop (st: 'a stack ref) =
  match st.Value with
    SNil -> raise Empty
  | SCell (x, xs) -> st.Value <- xs; x

// スタックは空か
let is_empty (st: 'a stack ref) = st.Value = SNil

create は空のスタックの参照を返す関数として定義します。操作関数はスタックを格納した参照型の変数を受け取ります。この変数を書き換えることで、スタックを操作することができます。関数 pop は仕様を変更して、取り除いたデータを返すことにします。あとは特に難しいところはないでしょう。

それでは簡単な実行例を示します。

> #load "stack1.fs";;
[読み込み中 /home/mhiroi/fsharp/stack1.fs]
namespace FSI_0002
  exception Empty
  type 'a stack =
    private | SNil
            | SCell of 'a * 'a stack
  val create: unit -> 'a stack ref
  val push: st: 'a stack ref -> x: 'a -> unit
  val top: st: 'a stack ref -> 'a
  val pop: st: 'a stack ref -> 'a
  val is_empty: st: 'a stack ref -> bool when 'a: equality

> open Stack;;
> let a : int stack ref = create ();;
val a: int stack ref = { contents = SNil }

> for i = 1 to 10 do push a i done;;
val it: unit = ()

> while not (is_empty a) do printfn "%d" (pop a) done;;
10
9
8
7
6
5
4
3
2
1
val it: unit = ()

正常に動作してますね。

●キュー

もう一つ簡単な例として、「キュー (queue)」という基本的なデータ構造を作ってみましょう。キューは「待ち行列」といわれるデータ構造です。たとえばチケットを買う場合、窓口に長い列ができますが、それと同じだと考えてください。チケットを買うときは、列の途中に割り込むことはできませんね。いちばん後ろに並んで順番を待たなければいけません。列の先頭まで進むと、チケットを購入することができます。

このように、要素を取り出す場合は列の先頭から行い、要素を追加する場合は列の後ろに行うデータ構造がキューなのです。キューは「先入れ先出し (FIFO : first-in, first-out)」とも呼ばれます。

キューにデータを入れることを enqueue といい、キューからデータを取り出すことを dequeue といいます。リストを使ってキューを実装する場合、上図のようにキューの先頭とリストの先頭を対応させます。すると、キューからデータを取り出すには、リストの先頭からデータを取り出すだけですみます。これはとても簡単ですね。

ただし、キューにデータを入れるには、リストの最後尾にデータを追加することになるため、ちょっとした工夫が必要になります。たとえば、データの追加に演算子 @ を使うと、データを追加するたびにリスト(キュー)がコピーされてしまいます。このため、キューに格納されているデータが多くなると時間がかかるようになります。

この場合、対策として次のような方法が考えられます。

(1) 最後尾のセルを参照する変数を用意する。
(2) 循環リスト (circular list) というデータ構造を使う。

OCaml の 参考文献 1 は (1) の方法でキューを実装しています。また、OCaml のモジュール Queue は (2) の方法を使っています。どちらの方法も簡単にキューを実装できますが、参照型変数 (または mutable のレコード) を使う必要があります。

そこで、今回はちょっと変わった方法ですが、連結リストを 2 つ使ってキューを作ってみましょう。なお、この方法は SML/NJ のライブラリを参考にしました。

次の図を見てください。

上図は 2 つのリストでキューを表しています。データを取り出すときは front のリストを、データを追加するときは rear のリストを使います。front と rear で一つのキューを構成し、rear のリストはデータを逆順で格納することになります。ようするに、front が先頭で rear が最後尾になるわけです。上図のキューを一つのリストで表すと [0; 1; 2; 3; 4; 5] になります。

したがって、front が空リストでも rear にデータがあれば、キューは空ではありません。rear のリストを逆順にして front にセットし、rear を空リストにします。これで front からデータを取り出すことができます。キューが空の状態は front と rear が両方とも空リストの場合です。

●キューの実装

それではプログラムを作りましょう。次のリストを見てください。

リスト 5 : キュー (queue.fs)

module Queue
// 例外
exception Empty

// データ型の定義
type 'a queue = private Q of 'a list * 'a list

// 空のキューを返す
let create = Q ([], [])

// データの挿入
let enqueue a = function Q (front, rear) -> Q (front, a::rear)

// データの削除
let rec dequeue = function
  Q ([], []) -> raise Empty
| Q ([], rear) -> dequeue (Q (List.rev rear, []))
| Q (x::front, rear) -> Q (front, rear)

// 先頭のデータを取得
let rec top = function
  Q ([], []) -> raise Empty
| Q ([], rear) -> top (Q (List.rev rear, []))
| Q (x::_, _) -> x

// キューは空か
let is_empty q = q = create

キューを表すデータ型は 'a queue としました。型式は 'a list * 'a list で、組の第 1 要素が front で第 2 要素が rear になります。例外 Empty は空のキューからデータを取り出そうとしたときに送出します。キューの生成には変数 create を使います。create の値は空のキュー Q ([], []) です。

関数 enqueue はキューにデータ x を追加します。これは x を rear の先頭に追加するだけです。関数 dequeue はキューからデータを取り除きます。キューが空の場合は例外 Empty を送出します。front が空リストの場合は、キュー Q (List.rev rear, []) を作って dequeue を再帰呼び出しします。front にデータがある場合は先頭要素を取り除くだけです。

関数 top はキューの先頭要素を返します。処理は dequeue とほとんど同じで、front の先頭データ x を返すだけです。関数 is_empty は、キューが空であれば true を、そうでなければ false を返します。

最後に簡単な実行例を示します。

> #load "queue.fs";;
[読み込み中 /home/mhiroi/fsharp/queue.fs]
namespace FSI_0002
  exception Empty
  type 'a queue = private | Q of 'a list * 'a list
  val create: 'a queue
  val enqueue: a: 'a -> _arg1: 'a queue -> 'a queue
  val dequeue: _arg1: 'a queue -> 'a queue
  val top: _arg1: 'a queue -> 'a
  val is_empty: q: 'a queue -> bool when 'a: equality

> open Queue;;
> let a0 = create;;
val a0: 'a queue

> is_empty a0;;
val it: bool = true

> let a1 = enqueue 1 a0;;
val a1: int queue = Q ([], [1])

> let a2 = enqueue 2 a1;;
val a2: int queue = Q ([], [2; 1])

> is_empty a2;;
val it: bool = false

> top a2;;
val it: int = 1

> let a3 = dequeue a2;;
val a3: int queue = Q ([2], [])

> top a3;;
val it: int = 2

> let a4 = dequeue a3;;
val a4: int queue = Q ([], [])

> is_empty a4;;
val it: bool = true

バックトラック法

複雑な問題を厳密に解こうとするときや、条件を満たす解をすべて求める必要があるとき、可能性のあるパターンをすべて生成して、条件を満たしているかチェックするしか方法がない場合があります。このようなとき用いる手法に「バックトラック法 (backtracking)」があります。

たとえば、簡単な例として迷路を考えてみましょう。ある地点 A で道が左右に分かれているとします。ここで、左の道を選んで先へ進むと、行き止まりになってしまいました。この場合は A 地点まで戻って右の道へ進まないといけません。

このように、失敗したら元に戻って別の選択枝を選ぶ、という試行錯誤を繰り返して解を見つける方法がバックトラック法なのです。バックトラック法は、いろいろな分野の問題に応用できる方法です。そして、再帰定義を使うと簡単にプログラムを作ることができます。今回は簡単な例題として、バックトラック法でパズルを解いてみましょう。

●小町算

パズルの世界では、1 から 9 までの数字を 1 個ずつすべて使った数字を「小町数」といいます。123456789 とか 321654987 のような数字が小町数です。「小町算」というものもあり、123 + 456 + 789 とか 321 * 654 + 987 のようなものです。今回は小町算の中でも特に有名なパズルを解いてみましょう。

[問題] 小町算

1 から 9 までの数字を順番に並べ、間に + と - を補って 100 になる式をすべて求めよ。今回は 1 の先頭に - 符号は付けないものとする。

例 : 1 + 2 + 3 - 4 + 5 + 6 + 78 + 9 = 100

●型の定義

この問題は演算子が + と - だけしかないので、式はリストで表すことにします。F# の場合、異なるデータ型をリストに格納することはできないので、+ と - と数値を表すデータ型を定義します。

リスト 6 : データ型の定義

type term =  Plus | Minus | Num of int  

term を使うと数式は次のように表すことができます。

1 + 2 + 3 - 4 + 5 + 6 + 78 + 9
=> [Num 1; Plus; Num 2; Plus; Num 3; Minus; Num 4; Plus; 
    Num 5; Plus; Num 6; Plus; Num 78; Plus; Num 9]

あとは、式を生成して値を計算するだけです。式を生成するとき、リストを逆順で管理すると簡単です。次の図を見てください。

[Num 1] => [Num 2, Plus, Num 1]  => [Num 3, Plus, Num 2, Plus, Num 1]
                                 => [Num 3, Minus, Num 2, Plus, Num 1]
                                 => [Num 23, Plus, Num 1]
        => [Num 2, Minus, Num 1] => [Num 3, Plus, Num 2, Minus, Num 1]
                                 => [Num 3, Minus, Num 2, Minus, Num 1]
                                 => [Num 23, Minus, Num 1]
        => [Num 12]              => [Num 3, Plus, Num 12]
                                 => [Num 3, Minus, Num 12]
                                 => [Num 123]


                        図 4 : 式の生成

式を生成するとき、リストに数字と演算子を順番に追加していきます。Num と Plus, Minus を追加する処理は簡単です。プログラムのポイントは数字を連結する処理、たとえば 1 と 2 を連結して一つの数値 12 にする処理です。この処理はリストの先頭の数字 Num 1 を Num 12 (= 1 * 10 + 2) に置き換えることで実現できます。リストが [Num 2, Plus, Num 1] であれば、Num 2 を Num 23 (= 2 * 10 + 3) に置き換えます。

●式の生成

式を生成するプログラムは次のようになります。

リスト 7 : 式の生成

// 式の表示
let rec print_expr = function
  [] -> printfn " = 100"
| Num x :: xs -> printf "%d" x; print_expr xs
| Plus :: xs -> printf " + "; print_expr xs
| Minus :: xs -> printf " - "; print_expr xs

// 式の生成
let rec make_expr n expr =
  if n = 10 then
    let expr1 = List.rev expr
    if calc_expr expr1 = 100 then print_expr expr1 else ()
  else
    match expr with
      Num x :: xs ->
        make_expr (n + 1) (Num n :: Plus :: expr);
        make_expr (n + 1) (Num n :: Minus :: expr);
        make_expr (n + 1) (Num (x * 10 + n) :: xs)
    | _ -> failwith "make_expr"

式の生成はバックトラック法を使うと簡単です。関数 make_exp の引数 n が追加する数字、expr が生成する式(リスト)です。n が 10 になったら式が完成したので値を計算します。関数 List.rev で式を元に戻し、関数 calc_expr で式 expr1 を計算します。その結果が 100 になれば関数 print_expr で式を出力します。

n が 10 より小さい場合は数値と演算子をリストにセットします。最初に Num n と Plus をセットして make_expr を再帰呼び出しします。その次に、Num n と Minsu をセットして make_expr を呼び出します。最後に、Num x を Num (x * 10 + n) に置き換えてから make_expr を呼び出します。これで、全部の数式を生成することができます。

●式の計算

次は式を計算する関数 calc_exp を作ります。今回の問題は演算子に + と - しかないので、リストで表現した式を計算することは簡単です。次のプログラムを見てください。

リスト 8 : 式の計算

let calc_expr expr =
  let rec calc_expr_sub expr a =
    match expr with
      [] -> a
    | Plus :: Num x :: xs -> calc_expr_sub xs (a + x)
    | Minus :: Num x :: xs -> calc_expr_sub xs (a - x)
    | _ -> failwith "calc_expr_sub"
  match expr with
    Num x :: xs -> calc_expr_sub xs x
  | _ -> failwith "calc_expr"

実際の計算処理は局所関数 calc_expr_sub で行います。第 1 引数が数式 (リスト) で、第 2 引数が計算結果です。calc_expr は先頭の数値 x を取り出し、残りの数式を calc_expr_sub の第 1 引数に、x を第 2 引数に渡します。すると、数式の先頭は Plus か Minus になります。

calc_expr_sub では、Plus の場合は次の数値 x を sum に加算し、Minus の場合は sum から減算します。あとは calc_expr_sub を再帰呼び出しするだけです。

●実行結果

それでは実行結果を示します。

> make_expr 2 [Num 1];;
1 + 2 + 3 - 4 + 5 + 6 + 78 + 9 = 100
1 + 2 + 34 - 5 + 67 - 8 + 9 = 100
1 + 23 - 4 + 5 + 6 + 78 - 9 = 100
1 + 23 - 4 + 56 + 7 + 8 + 9 = 100
12 + 3 + 4 + 5 - 6 - 7 + 89 = 100
12 + 3 - 4 + 5 + 67 + 8 + 9 = 100
12 - 3 - 4 + 5 - 6 + 7 + 89 = 100
123 + 4 - 5 + 67 - 89 = 100
123 + 45 - 67 + 8 - 9 = 100
123 - 4 - 5 - 6 - 7 + 8 - 9 = 100
123 - 45 - 67 + 89 = 100
val it: unit = ()

全部で 11 通りの解が出力されます。この他にも、いろいろな解き方があると思います。興味のある方は、もっとクールな方法を考えてみてください。

●8 クイーン

もう一つ、有名なパズルを解いてみましょう。8 クイーンはコンピュータに解かせるパズルの中でも特に有名な問題です。8 クイーンは、8 行 8 列のチェスの升目に、8 個のクイーンを互いの利き筋が重ならないように配置する問題です。クイーンは将棋の飛車と角をあわせた駒で、縦横斜めに任意に動くことができます。解答の一例を示します。


    図 5 : 8 クイーンの解答例

8 クイーンを解くには、すべての置き方を試してみるしか方法はありません。最初のクイーンは、盤上の好きなところへ置くことができるので、64 通りの置き方があります。次のクイーンは 63 通り、その次は 62 通りあります。したがって、置き方の総数は 64 から 57 までの整数を掛け算した 178462987637760 通りもあります。

ところが、解答例を見ればわかるように、同じ行と列に 2 つ以上のクイーンを置くことはできません。上図の解答例をリストを使って表すと、 次のようになります。

  1  2  3  4  5  6  7  8    <--- 列の位置
---------------------------
 [1, 7, 5, 8, 2, 4, 6, 3]   <--- 要素が行の位置を表す  


        図 6 : リストでの行と列の表現方法

列をリストの位置に、行番号を要素に対応させれば、各要素には 1 から 8 までの数字が重複しないで入ることになります。すなわち、1 から 8 までの順列の総数である 8! = 40320 通りの置き方を調べるだけでよいのです。パズルを解く場合は、そのパズル固有の性質をうまく使って、調べなければならない場合の数を減らすように工夫することが大切です。

順列を生成するプログラムは 順列と組み合わせ で作成しました。あとは、その順列が 8 クイーンの条件を満たしているかチェックすればいいわけです。このように、正解の可能性があるデータを作りそれをチェックするという方法を「生成検定法 (generate and test)」といいます。

可能性のあるデータをもれなく作るような場合、バックトラック法は最適です。ただし、「生成するデータ数が多くなると時間がとてもかかる」という弱点があるので注意してください。

●プログラムの作成

それでは、プログラムを作りましょう。次のリストを見てください。

リスト 9 : 8 クイーンの解法

// 盤面の表示
let print_board board = printfn "%A" board

// 要素の削除
let rec remove n xs = List.filter (fun x -> x <> n) xs

// 衝突の検出
let attack x xs =
  let rec attack_sub x n = function
      [] -> true
    | y :: ys -> if x = y + n || x = y - n then false
                 else attack_sub x (n + 1) ys
  attack_sub x 1 xs

// 安全確認
let rec safe = function
  [] -> true
| x :: xs -> if attack x xs then safe xs else false

let rec queen f nums board =
  if nums = [] then
    if safe board then f board else ()
  else
    List.iter (fun x -> queen f (remove x nums) (x :: board)) nums

関数 queen は順列を生成するプログラムと同じです。順列を一つ生成したら、述語 safe で 8 クイーンの条件を満たしているかチェックします。そうであれば、print_board で盤面 board を表示します。

述語 safe はリストの先頭の要素からチェックしていきます。衝突のチェックは斜めの利き筋を調るだけです。端にあるクイーンから順番に調べるとすると、斜めの利き筋は次のように表せます。

  1 2 3    --> 調べる方向
*-------------
| . . . . . .
| . . . -3. .  5 - 3 = 2
| . . -2. . .  5 - 2 = 3
| . -1. . . .  5 - 1 = 4
| Q . . . . .  Q の位置は 5  
| . +1. . . .  5 + 1 = 6
| . . +2. . .  5 + 2 = 7
| . . . +3. .  5 + 2 = 8
*-------------


    図 7 : 衝突の検出

図を見てもらえばおわかりのように、Q が行 5 にある場合、ひとつ隣の列は 4 と 6 が利き筋に当たります。2 つ隣の列の場合は 3 と 7 が利き筋に当たります。このように単純な足し算と引き算で、利き筋を計算することができます。これをプログラムしたものが関数 attack です。

attack は、斜めの利き筋に当たった場合に false を返し、利き筋に当たらない場合は true を返します。実際の処理は局所関数 attack_sub で行います。attack_sub はリストの先頭から斜めの利き筋に当たるか調べます。第 1 引数がクイーンの位置、第 2 引数が位置の差分、第 3 引数がリストになります。

最初の節がクイーンを全て調べた場合です。クイーンは衝突していないので true を返します。次の節で、リストから先頭の要素 y を取りだし、利き筋に当たるか調べます。これは、y + n または y - n が x と等しいかチェックするだけです。衝突している場合は false を返します。そうでなければ、attack_sub を再帰呼び出しして次のクイーンを調べます。このとき、差分 n の値を +1 することをお忘れなく。

●実行結果

これでプログラムは完成です。それでは実行してみましょう。

> queen print_board [1 .. 8] [];;
[4; 2; 7; 3; 6; 8; 5; 1] 

... 省略 ...

[5; 7; 2; 6; 3; 1; 4; 8] 
val it: unit = ()

解は全部で 92 通りあります。ところで、このプログラムはクイーンの個数を増やすと極端に遅くなります。クイーンの個数を増やして試してみたところ、実行時間は次のようになりました。

リスト 10 : N Queens Problem

let test_queen f n =
  let c = ref 0
  f (fun _ -> c.Value <- c.Value + 1) [1 .. n] []
  c.Value
> #time;;

--> 今すぐタイミング オン

> test_queen queen 8;;
リアル: 00:00:00.035、CPU: 00:00:00.050、GC 全般0: 10, 全般1: 1, 全般2: 0
val it: int = 92

> test_queen queen 9;;
リアル: 00:00:00.306、CPU: 00:00:00.349、GC 全般0: 113, 全般1: 1, 全般2: 1
val it: int = 352

> test_queen queen 10;;
リアル: 00:00:02.326、CPU: 00:00:02.370、GC 全般0: 1121, 全般1: 1, 全般2: 0
val it: int = 724

> test_queen queen 11;;
リアル: 00:00:26.296、CPU: 00:00:26.310、GC 全般0: 12323, 全般1: 3, 全般2: 0
val it: int = 2680
表 1 : 実行時間 (秒)
個数 8 9 10 11
解の個数 92 352 724 2680
queen() 0.035 0.306 2.326 26.296

実はこのプログラム、とても非効率なことをやっているのです。

●8 クイーンの高速化

実行速度が遅い理由は、失敗することがわかっている順列も生成してしまうからです。たとえば、最初 (1, 1) の位置にクイーンを置くと、次のクイーンは (2, 2) の位置に置くことはできませんね。したがって、[1; 2; X; X; X; X; X; X] という配置はすべて失敗するのですが、順列を発生させてからチェックする今の方法では、このような無駄を省くことができません。

そこで、クイーンの配置を決めるたびに衝突のチェックを行うことにします。これをプログラムすると次のようになります。

リスト 11 : 8 クイーン (改良版)

let rec queen_fast f nums board =
  if nums = [] then f board
  else List.iter (fun x ->
    if attack x board then queen_fast f (remove x nums) (x :: board) else ()) nums

匿名関数の中で、追加したクイーンが board 内のクイーンと衝突していないか関数 attack でチェックします。順列を生成している途中でチェックを入れることで、無駄な順列を生成しないようにするわけです。この場合、safe は必要ありません。

このように、できるだけ早い段階でチェックを入れることで、無駄なデータをカットすることを「枝刈り」と呼びます。バックトラックを使って問題を解く場合、この枝刈りのよしあしによって実行時間が大きく左右されます。ところが、枝刈りの方法はパズルによって違います。パズル固有の性質をよく調べて、適切な枝刈りを考えることが重要なのです。

パズル自体はコンピュータに解かせるのですが、枝刈りの条件は私達が考えるわけです。これも「パズルの解法」の面白いところでしょう。解を求めるだけでなく、いかに効率の良い条件を見つけて実行時間を短縮するか、ということでも楽しむことができます。

●実行結果 (2)

それでは、実行結果を表 2 に示します。

表 2 : 実行時間 (秒)
個数 8 9 10 11 12 13
解の個数 92 352 724 2680 14200 73712
queen 0.035 0.306 2.326 26.296 ---- ----
queen_fast 0.003 0,009 0.022 0.098 0.536 2.604

このように、枝刈りを行うことで実行時間を大幅に短縮することができます。ところで、今回は単純にリストを出力するだけなので、ちょっと面白くありません。興味のある方は、解答例のような図を出力するプログラムを作ってみてください。


Copyright (C) 2022 Makoto Hiroi
All rights reserved.

[ PrevPage | F# | NextPage ]