充足可能性問題と重み付き制約充足問題

重み付き制約充足問題

変数 $x_j (j=1,2,\ldots,n)$ に対して有限集合から成る領域 (domain) $D_j$ を定義する.

制約 $C_i (i=1,2,\ldots,m)$ は変数の $n$ 組の部分集合として定義される. 制約充足問題(constraint satisfaction problem)は,制約を満たすように, 変数に領域の中の1つの値を割り当てることを目的とした問題である.

これの判定問題版(解があるか否かを判定する問題)が,最初の$NP$-完全問題として知られる制約充足問題 (satisfiability problem; SAT) である.

この問題は実行可能性判定問題の範疇に含まれるが,これに制約からの逸脱ペナルティを付加することによって,以下の組合せ最適化問題になる.

解ベクトル $x$ が制約 $C_i$ から逸脱している量を表すペナルティ関数を $g_i (x)$ とする. 各制約 $C_i$ の重みを $w_i$ としたとき,重み付き制約充足問題(weighted constraint satisfaction problem)は以下のように書ける.

$$ \begin{array}{lll} minimize & \sum_{i=1}^m w_i g_i(x) & \\ s.t. & x_j \in D_j & j=1,2,\ldots,n \end{array} $$

この問題に対しては,メタヒューリスティクスに基づく制約最適化ソルバーSCOP (Solver for COnstraint Programing)が開発されている. SCOPについての詳細は,付録1を参照されたい.

SCOPは実務的な問題を解くために開発されたものであるが,様々な基本的な組合せ最適化問題も簡単に解くことができる. 例として,グラフ彩色問題のところで,SCOPを用いた数行の最適化コードを示している.

実務的な問題の例としては,シフトスケジューリングのことろで,看護婦スケジューリング問題のモデル化を示している. 以下では,もう1つの実務的な例として,時間割作成問題への適用を考える.

時間割作成問題

通常の時間割作成の他に,大学では試験の時間割などにもニーズがある.

次の集合が与えられている.

  • 授業(クラス)集合: 大学への応用の場合には,授業には担当教師が紐付けられている.
  • 教室集合
  • 学生集合
  • 期集合:通常は1週間(5日もしくは6日)の各時限を考える.

以下の条件を満たす時間割を求める.

  • すべての授業をいずれか期へ割当
  • すべての授業をいずれか教室へ割当
  • 各期、1つの教室では1つ以下の授業
  • 同じ教員の受持ち講義は異なる期へ
  • 割当教室は受講学生数以上の容量をもつ
  • 同じ学生が受ける可能性がある授業の集合(カリキュラム)は,異なる期へ割り当てなければならない

考慮すべき付加条件には,以下のものがある.

  • 1日の最後の期に割り当てられると履修学生数分のペナルティ
  • 1人の学生が履修する授業が3連続するとペナルティ
  • 1人の学生の授業数が、1日に1つならばペナルティ($0$ か $2$ 以上が望ましい)

これは制約最適化問題として以下のように定式化できる.

授業 $i$ の割り当て期を表す変数 $X_i$ (領域はすべての期の集合)と割り当て教室を表す変数 $Y_i$ (領域はすべての教室の集合)を用いる.

定式化では,これらの変数は値変数として表記する. それぞれ, 授業 $i$ を期 $t$ に割り当てるとき $1$ の $0$-$1$ 変数 $x_{it}$, 授業 $i$ を教室 $k$ に割り当てるとき $1$ の $0$-$1$ 変数 $y_{ik}$ となる.

SCOPにおいては,以下の2つの制約は自動的に守られるので必要ない.

  • すべての授業をいずれか期へ割当
  • すべての授業をいずれか教室へ割当

他の制約は,以下のように記述できる.

  • 各期 $t$ の各教室 $k$ への割り当て授業は1以下であることを表す制約: $$ \sum_{i} x_{it} y_{ik} \leq 1 \ \ \ \forall t,k $$

  • 同じ教員の受持ち講義は異なる期へ割り当て:

教員 $l$ の担当する授業の集合を $E_l$ とすると,この制約は 「$X_{i} (i \in E_l)$ はすべて異なる値をもつ」と書くことができる. SCOPでは,このようなタイプの制約を相異制約とよび,そのまま記述できる.

  • 割当教室は受講学生数以上の容量をもつ.

授業 $i$ ができない(容量を超過する)教室の集合を $K_i$ する.

$$ \sum_{i, k \in K_i} y_{ik} \leq 0 $$
  • 同じ学生が受ける可能性がある授業の集合(カリキュラム)は,異なる期へ割り当てなければならない.

カリキュラム $j$ に含まれる授業の集合を $C_j$ としたとき,以下の制約として記述できる.

$$ \sum_{i \in C_j} x_{it} \leq 1 \ \ \ \forall j, t $$
  • 1日の最後の期に割り当てられると履修学生数分のペナルティ

1日の最後の期の集合を $L$, 授業 $i$ の履修学生数を $w_i$ とする. $$ \sum_{i, t \in L} w_i x_{it} \leq 0 $$

  • 1人の学生が履修する授業が3連続すると1ペナルティ

$T$ を1日のうちで最後の2時間でない期の集合とする. $$ \sum_{i \in C_j} x_{it} + x_{i,t+1} +x_{i,t+2} \leq 2 \ \ \ \forall j, t \in T $$

  • 1人の学生の授業数が、1日に1つならば1ペナルティ(0か2以上が望ましい)

各日 $d$ に含まれる期の集合を $T_d$ とし, 日 $d$ におけるカリキュラム $j$ に含まれる授業数が $0$ か $2$ 以上なのかを表す $0$-$1$ 変数 $z_{jd}$ を用いる.

$$ \sum_{i \in C_j} x_{it} \leq |T_d| z_{jd} \ \ \ \forall d, j $$$$ \sum_{i \in C_j} x_{it} \geq 2 z_{jd} \ \ \ \forall d, j $$

上の定式化をSCOPで解くことによって,付加的な制約を「できるだけ」満たす時間割を作成することができる.

時間割作成の国際コンペティション (ITC2007) では, SCOPは3つの異なるトラックですべてメダル(3位, 2位, 3位)を獲得している.

OR-tools (cp-sat)

Googleが開発しているOR-tools (cp-sat)でも同様のことができる.cp_modelはSCOPとは異なる求解手法である制約伝播を用いているため,パズルのような問題を解くことが得意である. OR-toolsについての詳細は,以下を参照されたい.

https://developers.google.com/optimization

以下に,簡単な最適化の例を示す.

from ortools.sat.python import cp_model

model = cp_model.CpModel()
var_upper_bound = max(50, 45, 37)
x = model.NewIntVar(0, var_upper_bound, "x")
y = model.NewIntVar(0, var_upper_bound, "y")
z = model.NewIntVar(0, var_upper_bound, "z")

model.Add(2 * x + 7 * y + 3 * z <= 50)
model.Add(3 * x - 5 * y + 7 * z <= 45)
model.Add(5 * x + 2 * y - 6 * z <= 37)

model.Maximize(2 * x + 2 * y + 3 * z)

solver = cp_model.CpSolver()
status = solver.Solve(model)

if status == cp_model.OPTIMAL:
    print("Maximum of objective function: %i" % solver.ObjectiveValue())
    print()
    print("x value: ", solver.Value(x))
    print("y value: ", solver.Value(y))
    print("z value: ", solver.Value(z))
Maximum of objective function: 35

x value:  7
y value:  3
z value:  5

すべての解の列挙

class VarArraySolutionPrinter(cp_model.CpSolverSolutionCallback):
    """Print intermediate solutions."""

    def __init__(self, variables):
        cp_model.CpSolverSolutionCallback.__init__(self)
        self.__variables = variables
        self.__solution_count = 0

    def on_solution_callback(self):
        self.__solution_count += 1
        for v in self.__variables:
            print("%s=%i" % (v, self.Value(v)), end=" ")
        print()

    def solution_count(self):
        return self.__solution_count


# Creates the model.
model = cp_model.CpModel()

# Creates the variables.
num_vals = 3
x = model.NewIntVar(0, num_vals - 1, "x")
y = model.NewIntVar(0, num_vals - 1, "y")
z = model.NewIntVar(0, num_vals - 1, "z")

# Create the constraints.
model.Add(x != y)

# Create a solver and solve.
solver = cp_model.CpSolver()
solution_printer = VarArraySolutionPrinter([x, y, z])
status = solver.SearchForAllSolutions(model, solution_printer)

print("Status = %s" % solver.StatusName(status))
print("Number of solutions found: %i" % solution_printer.solution_count())
x=1 y=2 z=0 
x=1 y=0 z=0 
x=2 y=0 z=0 
x=2 y=1 z=0 
x=2 y=1 z=1 
x=2 y=0 z=1 
x=1 y=0 z=1 
x=1 y=2 z=1 
x=1 y=2 z=2 
x=1 y=0 z=2 
x=2 y=0 z=2 
x=2 y=1 z=2 
x=0 y=1 z=2 
x=0 y=1 z=1 
x=0 y=1 z=0 
x=0 y=2 z=0 
x=0 y=2 z=1 
x=0 y=2 z=2 
Status = OPTIMAL
Number of solutions found: 18

例題:覆面算

各文字に0から9の数字を入れて等式 $SEND+MORE=MONEY$ を成立させたい. ただし,数字に重複があってはならず, 先頭の文字に0を入れることはできない.

model = cp_model.CpModel()
S = model.NewIntVar(1,9,"S")
E = model.NewIntVar(0,9,"E")
N = model.NewIntVar(0,9,"N")
D = model.NewIntVar(0,9,"D")
M = model.NewIntVar(1,9,"M")
O = model.NewIntVar(0,9,"O")
R = model.NewIntVar(0,9,"R")
Y = model.NewIntVar(0,9,"Y")

model.Add(   1000*S + 100*E + 10*N + D + 
             1000*M + 100*O + 10*R + E 
 == 10000*M +1000*O + 100*N + 10*E + Y)

model.AddAllDifferent([S,E,N,D,M,O,R,Y])

solver = cp_model.CpSolver()
status = solver.Solve(model)

if status == cp_model.OPTIMAL:
    for v in [S,E,N,D,M,O,R,Y]:
        print(f"{v} = {solver.Value(v)}")
S = 9
E = 5
N = 6
D = 7
M = 1
O = 0
R = 8
Y = 2
solution_printer = VarArraySolutionPrinter([S,E,N,D,M,O,R,Y])
status = solver.SearchForAllSolutions(model, solution_printer)

print("Status = %s" % solver.StatusName(status))
print("Number of solutions found: %i" % solution_printer.solution_count())
S=9 E=5 N=6 D=7 M=1 O=0 R=8 Y=2 
Status = OPTIMAL
Number of solutions found: 1

練習問題:覆面算

https://www.math.uni-bielefeld.de/~sillke/PUZZLES/ALPHAMETIC/ にある覆面算の問題をどれか一つ解いてみよ.

例題:魔方陣

魔方陣とは,$n \times n$ の正方形の方陣に1から$n^2$までの整数を1つずつ入れて,縦・横・対角線のいずれの列の和も同じになるものをいう. (一列の和は $n(n^2+1)/2$ になる.)

注意: $n=12$ より大きい値で試さないこと.

n = 10
model = cp_model.CpModel()
x = {}
for i in range(n):
    for j in range(n):
        x[i, j] = model.NewIntVar(1, n * n, f"x({i},{j})")

x_list = [x[i,j] for i in range(n) for j in range(n)]
model.AddAllDifferent(x_list)

s = n*(n**2+1)//2

for i in range(n):
    model.Add(sum([x[i, j] for j in range(n)]) == s) 
for j in range(n):
    model.Add(sum([x[i, j] for i in range(n)]) == s) 

model.Add(sum([x[i, i] for i in range(n)]) == s) 
model.Add(sum([x[i, n - i - 1] for i in range(n)]) == s)

status = solver.Solve(model)

for i in range(n):
    for j in range(n):
        print(solver.Value(x[i,j]), end=" ")
    print()
87 1 18 66 88 95 39 25 35 51 
21 2 52 11 85 92 69 94 9 70 
73 81 15 14 19 96 65 22 56 64 
38 82 31 91 68 4 30 57 41 63 
27 23 72 17 47 97 45 37 79 61 
28 71 50 48 34 100 24 55 62 33 
49 83 36 67 44 6 46 54 80 40 
74 10 42 93 26 5 84 89 29 53 
32 77 90 20 86 7 60 59 16 58 
76 75 99 78 8 3 43 13 98 12 

練習問題:完全方陣

$n=4$ の場合の完全方陣(魔方陣でかつ四隅の合計と任意の$2\times2$の正方形の合計も同じ和になるもの)を求めよ.

例題:数独

数独は $9 \times 9$ の正方形の枠内に1から$n$までの数字を入れるパズルである. 初期配置に与えられた数字はそのままとし, 縦・横の各列とブロックとよばれる $3 \times 3$ の小正方形の枠内には,同じ数字を重複して入れてはいけないものとする.

以下のデータでは,数字の0が入っている枠は空白であるとする.

import math
problem = [
[1,0,0, 0,0,7, 0,9,0],
[0,3,0, 0,2,0, 0,0,8],
[0,0,9, 6,0,0, 5,0,0],

[0,0,5, 3,0,0, 9,0,0],
[0,1,0, 0,8,0, 0,0,2],
[6,0,0, 0,0,4, 0,0,0],

[3,0,0, 0,0,0, 0,1,0],
[0,4,0, 0,0,0, 0,0,7],
[0,0,7, 0,0,0, 3,0,0]]

model = cp_model.CpModel()  
n = len(problem)

cell_size = math.ceil(math.sqrt(n))
line_size = cell_size ** 2
line = range(0, line_size)
cell = range(0, cell_size)

x = {}
for i in line:
    for j in line:
        x[i, j] = model.NewIntVar(1, line_size, f"x({i},{j})")

for i in line:
    model.AddAllDifferent([x[i, j] for j in line])

for j in line:
    model.AddAllDifferent([x[i, j] for i in line])

for i in cell:
    for j in cell:
        one_cell = []
        for di in cell:
            for dj in cell:
                one_cell.append(x[(i * cell_size + di, j * cell_size + dj)])
        model.AddAllDifferent(one_cell)
    
for i in line:
    for j in line:
        if problem[i][j]:
            model.Add(x[i, j] == problem[i][j])
            
status = solver.Solve(model)

for i in line:
    for j in line:
        print(solver.Value(x[i,j]), end=" ")
    print()
1 6 2 8 5 7 4 9 3 
5 3 4 1 2 9 6 7 8 
7 8 9 6 4 3 5 2 1 
4 7 5 3 1 2 9 8 6 
9 1 3 5 8 6 7 4 2 
6 2 8 7 9 4 1 3 5 
3 5 6 4 7 8 2 1 9 
2 4 1 9 3 5 8 6 7 
8 9 7 2 6 1 3 5 4 
solution_printer = VarArraySolutionPrinter([x[i,j] for i in line for j in line])
status = solver.SearchForAllSolutions(model, solution_printer)

print("Status = %s" % solver.StatusName(status))
print("Number of solutions found: %i" % solution_printer.solution_count())
x(0,0)=1 x(0,1)=6 x(0,2)=2 x(0,3)=8 x(0,4)=5 x(0,5)=7 x(0,6)=4 x(0,7)=9 x(0,8)=3 x(1,0)=5 x(1,1)=3 x(1,2)=4 x(1,3)=1 x(1,4)=2 x(1,5)=9 x(1,6)=6 x(1,7)=7 x(1,8)=8 x(2,0)=7 x(2,1)=8 x(2,2)=9 x(2,3)=6 x(2,4)=4 x(2,5)=3 x(2,6)=5 x(2,7)=2 x(2,8)=1 x(3,0)=4 x(3,1)=7 x(3,2)=5 x(3,3)=3 x(3,4)=1 x(3,5)=2 x(3,6)=9 x(3,7)=8 x(3,8)=6 x(4,0)=9 x(4,1)=1 x(4,2)=3 x(4,3)=5 x(4,4)=8 x(4,5)=6 x(4,6)=7 x(4,7)=4 x(4,8)=2 x(5,0)=6 x(5,1)=2 x(5,2)=8 x(5,3)=7 x(5,4)=9 x(5,5)=4 x(5,6)=1 x(5,7)=3 x(5,8)=5 x(6,0)=3 x(6,1)=5 x(6,2)=6 x(6,3)=4 x(6,4)=7 x(6,5)=8 x(6,6)=2 x(6,7)=1 x(6,8)=9 x(7,0)=2 x(7,1)=4 x(7,2)=1 x(7,3)=9 x(7,4)=3 x(7,5)=5 x(7,6)=8 x(7,7)=6 x(7,8)=7 x(8,0)=8 x(8,1)=9 x(8,2)=7 x(8,3)=2 x(8,4)=6 x(8,5)=1 x(8,6)=3 x(8,7)=5 x(8,8)=4 
Status = OPTIMAL
Number of solutions found: 1

練習問題:数独

数独(ナンプレ)の問題を検索して探し,上のコードで解いてみよ.

練習問題:不等式パズル

$5 \times 5$ の正方形の枠内に1から5の整数を入れる.初期配置に与えられた数字はそのままとし,さらに2つの格子間に不等式 $<$ が付加されている. ただし,各行・各列ともに数字の重複があってはいけない.

初期配置を入れた行列(リストのリスト)においては,0は空白を意味する.

不等式を表すリストは,長さ4のリスト $[i,j,k,l]$ から構成され,これは$i$行$j$列の数字が$k$行$l$列の数字より小さいことを表す.

以下の例題は, https://en.wikipedia.org/wiki/Futoshiki からとったものである.

initial = [
    [0, 0, 0, 0, 0],
    [4, 0, 0, 0, 2],
    [0, 0, 4, 0, 0],
    [0, 0, 0, 0, 4],
    [0, 0, 0, 0, 0]]
#不等式(添字は1から始まるものとする)
inequality = [
    [1, 2, 1, 1],
    [1, 4, 1, 3],
    [1, 5, 1, 4],
    [4, 4, 4, 5],
    [5, 1, 5, 2],
    [5, 2, 5, 3]
]