2024年1月27日土曜日

リリース時刻付きジョブスケジューリングの定式化

問題: nn個のジョブを1つの機械で任意順に処理する。同時に取り組めるジョブは1つであり、1つのジョブに着手したら途中で他のジョブに切り替えることはできない。ジョブiiを始めてから完了するまでの時間はpi (>0)p_i \ (> 0)であり、ジョブiiを始めるのは時刻ri (0)r_i \ (\ge 0)以降でなければならない。また、各ジョブには重み wi>0w_i > 0が与えられており、ジョブの処理を完了する時刻の重み付き総和を最小化したい。つまり、ジョブiiを時刻tit_iから始めるとした時、iwi(ti+pi)\sum_i w_i(t_i + p_i)を最小化したい。最適なt1,...,tnt_1, ..., t_nを求めよ。

ジョブスケジューリング問題の一種であり、分類としては1rjwjCj1|r_j|\sum w_jC_jという表記で表される。NP-hardである。

これをMIPとして定式化したい。自然な考え方としては、ジョブiiにジョブjj以前に取り組むことに対応するバイナリ変数xi,jx_{i, j}を使った定式化が考えられる。目的関数は定数項を無視するとiwiti\sum_i w_i t_iであり、制約は以下のようになる:

  • 任意のジョブjjについて tjrjt_j \ge r_j
  • 任意の異なるジョブi,ji, jについて tjti+piMxj,it_j \ge t_i + p_i - Mx_{j, i}
    • MMは十分に大きな定数
  • 任意の異なるジョブi,ji, jについて xi,j+xj,i=1x_{i, j} + x_{j, i} = 1
  • 任意の異なるジョブi,j,ki, j, kについて xi,j+xj,k1+xi,kx_{i, j} + x_{j, k} \le 1 + x_{i, k}
  • 任意の異なるジョブi,ji, jについて xi,j{0,1}x_{i, j} \in \{0, 1\}
  • 任意のジョブjjについて tjRt_j \in \mathbb R

これで定式化になっているが、NemhauserとSavelsberghによるとbig Mを使わない定式化が可能らしい。一般性を失わずにr1r2...rnr_1 \le r_2 \le ... \le r_nと仮定するとき、tjrjt_j \ge r_jtjti+piMxj,it_j \ge t_i + p_i - Mx_{j, i}の代わりに

tjrixi,j+k<i,kjpk(xi,k+xk,j1)+ki,kjpkxk,j(*) \tag{*} t_j \ge r_ix_{i, j} + \sum_{k < i, k \neq j}p_k(x_{i, k} + x_{k, j}-1) + \sum_{k \ge i, k \neq j}p_kx_{k, j}

とbig Mを使わずに書けるというのが論文の内容である。ただし、便宜上xi,i=1x_{i,i}=1とする。上のbig Mを使った定式化をFormM\operatorname{FormM}()(*)を使った定式化をForm*\operatorname{Form*}とする。Form*\operatorname{Form*}の正当性を確かめたい。

ジョブを処理する順番を固定して考えることにする。具体的には、次のことを示す:

定理: 1,...,n1, ..., nの順列π=(π(1),...,π(n))\pi = (\pi(1), ..., \pi(n))が与えられたとする。冒頭の問題に「ジョブをπ\piの順で処理する」という条件を追加したものの最適解の集合は、Form*\operatorname{Form*}のすべてのxi,jx_{i, j}を順列に従った値を取る定数としたLPの最適解の集合と一致する。

これが示されれば、

冒頭の問題の最適解の集合
= π:順列\bigcup_{\pi: 順列}(ジョブをπ\piの順で処理するという条件の下での冒頭の問題の最適解の集合) の要素で目的関数を最小化するものの集合
= π:順列\bigcup_{\pi: 順列}(Form*\operatorname{Form*}のすべてのxi,jx_{i, j}を順列に従った値を取る定数としたLPの最適解の集合) の要素で目的関数を最小化するものの集合
= Form*\operatorname{Form*}の最適解の集合

となってForm*\operatorname{Form*}は正しい定式化であると言える。

定理の証明

以降、ジョブjjpos(j)\operatorname{pos}(j)番目に処理するとする。pos(π(k))=k\operatorname{pos}(\pi(k)) = kである。

まず、ジョブをπ(1),...,π(n)\pi(1), ..., \pi(n)の順で処理するという条件下でのスケジューリング問題の最適解(t1,...,tn)=(T1,...,Tn)(t_1, ..., t_n) = (T_1, ..., T_n)の性質を考える。明らかに、可能な最も早い時刻にジョブを順次処理していくのが最適だから、Tπ(k+1)=max(rπ(k+1),Tπ(k)+pπ(k))T_{\pi(k+1)} = \max(r_{\pi(k+1)}, T_{\pi(k)} + p_{\pi(k)})である。これを利用してTjT_jrrppのみで表すことを考える。

まず、Tπ(1)=rπ(1)T_{\pi(1)}=r_{\pi(1)}である。また、π(1)\pi(1)以外の任意のジョブjjについては、jjより前に行うジョブでリリース時刻ちょうどに処理を始めるものが必ず存在する。その中で最後に処理するジョブをjj'とするとき

Tj=max(rj,rj+k:pos(j)pos(k)<pos(j)pk) T_j=\max \bigl(r_j, r_{j'} + \sum_{k : \operatorname{pos}(j') \le \operatorname{pos} (k) < \operatorname{pos}(j)}p_k \bigr)

である。したがって、次の3つの命題が示せればよい。以下、()(*)の右辺を Ri,j:=rixi,j+k<i,kjpk(xi,k+xk,j1)+ki,kjpkxk,jR_{i, j} := r_ix_{i, j} + \sum_{k < i, k \neq j}p_k(x_{i, k} + x_{k, j}-1) + \sum_{k \ge i, k \neq j}p_kx_{k, j} とする。ただし、ここではxi,jx_{i, j}は与えられた順列π\piに応じて値が定まっている定数である。

命題1: ジョブjπ(1)j \neq \pi(1)が与えられたとする。jjより前に行いかつリリース時刻ちょうどに処理を始めるジョブで最後に処理するものをjj'とするとき、Rj,j=rj+k:pos(j)pos(k)<pos(j)pkR_{j', j} = r_{j'} + \sum_{k: \operatorname{pos}(j') \le \operatorname{pos} (k) < \operatorname{pos}(j)}p_kである。

命題2: 任意のジョブjjについて、Tj=rjT_j = r_jならばRj,j=rjR_{j, j} = r_jである。

命題3: 任意のジョブi,ji, jについてRi,jTjR_{i, j} \le T_jである。

命題1と2はForm*\operatorname{Form*}tjt_jの下限に関するタイトな制約をすべて含むと主張している。命題3はそれ以外の制約が冗長であると主張している。

命題1の証明

Rj,j=rj+k<j,kjpk(xj,k+xk,j1)+kj,kjpkxk,jR_{j', j} = r_{j'} + \sum_{k < j', k \neq j}p_k(x_{j', k} + x_{k, j}-1) + \sum_{k \ge j', k \neq j}p_kx_{k, j}である。任意のジョブλj\lambda \neq jについて、pλp_\lambdaRj,jR_{j', j}にどう現れるか調べる:

  • pos(λ)<pos(j)\operatorname{pos}(\lambda) < \operatorname{pos}(j')の場合
    • λ<j\lambda < j'ならpλp_\lambdaの係数はxj,k+xk,j1=0x_{j', k} + x_{k, j}-1=0
    • λj\lambda \ge j'であることはない。なぜならジョブjj'rjr_{j'}ちょうどに開始するため、pos(λ)<pos(j)\operatorname{pos}(\lambda) < \operatorname{pos}(j')ならrλ<rjr_\lambda < r_{j'}でなければならず、つまりλ<j\lambda < j'だから。
  • pos(j)pos(λ)<pos(j)\operatorname{pos}(j') \le \operatorname{pos}(\lambda) < \operatorname{pos}(j)の場合
    • λ<j\lambda < j'ならpλp_\lambdaの係数はxj,λ+xλ,j1=1x_{j', \lambda} + x_{\lambda, j} -1=1
    • λj\lambda \ge j'ならpλp_\lambdaの係数はxλ,j=1x_{\lambda, j}=1
  • pos(j)<pos(λ)\operatorname{pos}(j) < \operatorname{pos}(\lambda)の場合
    • λ<j\lambda < j'ならpλp_\lambdaの係数はxj,λ+xλ,j1=0x_{j', \lambda} + x_{\lambda, j} -1=0
    • λj\lambda \ge j'ならpλp_\lambdaの係数はxλ,j=0x_{\lambda, j}=0

したがって、Rj,j=rj+λ:pos(j)pos(λ)<pos(j)pλR_{j', j} = r_{j'} + \sum_{\lambda: \operatorname{pos}(j') \le \operatorname{pos} (\lambda) < \operatorname{pos}(j)}p_\lambda

命題2の証明

Rj,j=rjxj,j+k<jpk(xj,k+xk,j1)+k>jpkxk,jR_{j, j} = r_jx_{j, j} + \sum_{k < j}p_k(x_{j, k} + x_{k, j}-1) + \sum_{k > j}p_kx_{k, j}である。

kjk \neq jなら常に xj,k+xk,j1=0x_{j, k} + x_{k, j} - 1 = 0 なので k<jpk(xj,k+xk,j1)=0\sum_{k < j}p_k(x_{j, k} + x_{k, j}-1) = 0。次にk>jpkxk,j\sum_{k > j}p_kx_{k, j}について考える。k>jk > jならばrkrjr_k \ge r_jだから、jjrjr_jちょうどに開始するという仮定の下ではkkは必ずjjの後に行うことになる。つまり、xk,j=0x_{k, j} = 0。従って総和もゼロであり、Rj,j=rjxj,j=rjR_{j, j} = r_j x_{j, j} = r_j

命題3の証明

pos(i)pos(j)\operatorname{pos}(i) \le \operatorname{pos}(j)の場合とpos(j)<pos(i)\operatorname{pos}(j) < \operatorname{pos}(i)の場合にわけて検討する。

まず、pos(i)pos(j)\operatorname{pos}(i) \le \operatorname{pos}(j)の場合を扱う。任意のジョブλj\lambda \neq jについて、命題1の証明と同じようにpλp_\lambdaRi,jR_{i, j}にどう現れるか調べる。

  • pos(λ)<pos(i)\operatorname{pos}(\lambda) < \operatorname{pos}(i)の場合
    • λ<i\lambda < iならpλp_\lambdaの係数は xi,λ+xλ,j1=0x_{i, \lambda}+x_{\lambda, j} - 1 = 0
    • λi\lambda \ge iならpλp_\lambdaの係数は xλ,j=1x_{\lambda, j} = 1 ――(A)
  • pos(i)pos(λ)<pos(j)\operatorname{pos}(i) \le \operatorname{pos}(\lambda) < \operatorname{pos}(j)の場合、命題1の証明と同様にpλp_\lambdaの係数は1である
  • pos(j)<pos(λ)\operatorname{pos}(j) < \operatorname{pos}(\lambda)の場合、命題1の証明と同様にpλp_\lambdaの係数は0である

(A)に該当するλ\lambdaが存在しなければ Ri,j=ri+λ:pos(i)pos(λ)<pos(j)pλR_{i, j} = r_i + \sum_{\lambda: \operatorname{pos}(i) \le \operatorname{pos} (\lambda) < \operatorname{pos}(j)}p_\lambda だから、次のことが成り立つ。

補題1: pos(i)pos(j)\operatorname{pos}(i) \le \operatorname{pos}(j)とする。(A)に該当するジョブが存在しなければRi,jTjR_{i, j} \le T_jである。

また、(A)に該当するλ\lambdaが存在する場合は実は次のことが成り立つ:

補題2: pos(i)pos(j)\operatorname{pos}(i) \le \operatorname{pos}(j)とし、(A)に該当するジョブが存在するとする。そのようなジョブの中でもっとも早く行うものλ\lambdaに注目する。言い換えると、λ\lambdaλi\lambda \ge iであるジョブの中でもっとも早く行うとする。このとき、Ri,jRλ,jR_{i, j} \le R_{\lambda, j}である。

証明: pos(i)pos(j)\operatorname{pos}(i) \le \operatorname{pos}(j)に注意すると、Rλ,j,Ri,jR_{\lambda, j}, R_{i, j}は以下のように表せる:

Rλ,j=rλ+k<λ,pos(λ)<pos(k)<pos(j)pk+kλ,pos(k)<pos(j)pk R_{\lambda, j} = r_\lambda + \sum_{k < \lambda, \operatorname{pos}(\lambda) < \operatorname{pos}(k) < \operatorname{pos}(j)}p_k + \sum_{k \ge \lambda, \operatorname{pos}(k) < \operatorname{pos}(j)}p_k Ri,j=ri+k<i,pos(i)<pos(k)<pos(j)pk+ki,pos(k)<pos(j)pk R_{i, j} = r_i + \sum_{k < i, \operatorname{pos}(i) < \operatorname{pos}(k) < \operatorname{pos}(j)}p_k + \sum_{k \ge i, \operatorname{pos}(k) < \operatorname{pos}(j)}p_k

Ri,jR_{i, j}にどのようなpkp_kが現れるか調べるために、総和に登場する添字kkの集合を分割する。次の1~4のうち、1が一つ目の総和のkkの集合で、2~4が二つ目の総和のkkの集合の分割である。

  1. {k[n]:k<i,pos(i)<pos(k)<pos(j)}\{k \in [n]: k < i, \operatorname{pos}(i) < \operatorname{pos}(k)<\operatorname{pos}(j)\}
  2. {k[n]:ik<λ,pos(k)<pos(λ)}\{k \in [n]: i \le k < \lambda, \operatorname{pos}(k) < \operatorname{pos}(\lambda)\}
  3. {k[n]:ik<λ,pos(λ)<pos(k)<pos(j)}\{k \in [n] : i \le k < \lambda, \operatorname{pos}(\lambda) < \operatorname{pos}(k)<\operatorname{pos}(j)\}
  4. {k[n]:kλ,pos(k)<pos(j)}\{k \in [n]: k \ge \lambda, \operatorname{pos}(k) < \operatorname{pos}(j)\}

iλi \le \lambdaであるλ\lambdaの中でもっとも早く行うものを選んでいるので2は空である。また、1と3の合併は{k[n]:k<λ,pos(λ)<pos(k)<pos(j)}\{k \in [n]: k < \lambda, \operatorname{pos}(\lambda) < \operatorname{pos}(k) <\operatorname{pos}(j)\}に包含され、後者はRλ,jR_{\lambda, j}の一つ目の総和の添字集合に等しい。また、4はRλ,jR_{\lambda, j}の二つ目の総和の添字集合に等しい。従って、Ri,jRλ,jR_{i, j} \le R_{\lambda, j}である。\square

次に、pos(j)<pos(i)\operatorname{pos}(j) < \operatorname{pos}(i)の場合を検討する。Ri,j=k<i,kjpk(xi,k+xk,j1)+ki,kjpkxk,jR_{i, j} = \sum_{k < i, k \neq j}p_k(x_{i, k} + x_{k, j}-1) + \sum_{k \ge i, k \neq j}p_kx_{k, j}である。やはり任意のジョブλj\lambda \neq jについて、pλp_\lambdaRi,jR_{i, j}にどう現れるか調べる。

  • pos(λ)<pos(j)\operatorname{pos}(\lambda) < \operatorname{pos}(j)の場合
    • λ<i\lambda < i ならpλp_\lambdaの係数はxi,λ+xλ,j1=0x_{i, \lambda}+x_{\lambda, j} -1 = 0
    • λi\lambda \ge i ならpλp_\lambdaの係数はxλ,j=1x_{\lambda, j} = 1 ――(B)
  • pos(j)<pos(λ)<pos(i)\operatorname{pos}(j) < \operatorname{pos}(\lambda) < \operatorname{pos}(i)の場合
    • λ<i\lambda < iならpλp_\lambdaの係数はxi,λ+xλ,j1=1x_{i, \lambda}+x_{\lambda, j} -1 = -1
    • λi\lambda \ge iならpλp_\lambdaの係数はxλ,j=0x_{\lambda, j} = 0
  • pos(i)pos(λ)\operatorname{pos}(i) \le \operatorname{pos}(\lambda)の場合
    • λ<i\lambda < iならpλp_\lambdaの係数はxi,λ+xλ,j1=0x_{i, \lambda} + x_{\lambda, j} -1=0
    • λi\lambda \ge iならpλp_\lambdaの係数はxλ,j=0x_{\lambda, j}=0

(B)に該当するλ\lambdaが存在しなければ、Ri,j0TjR_{i, j} \le 0 \le T_jだから、次のことが成り立つ:

補題3: pos(j)<pos(i)\operatorname{pos}(j) < \operatorname{pos}(i)とする。(B)に該当するλ\lambdaが存在しなければRi,jTjR_{i, j} \le T_jである。

また、(B)に該当するλ\lambdaが存在する場合は次のことが成り立つ:

補題4: pos(j)<pos(i)\operatorname{pos}(j) < \operatorname{pos}(i)とする。(B)に該当するジョブが存在するとする。そのようなジョブの中でもっとも早く行うものλ\lambdaに注目する。言い換えると、λ\lambdaλi\lambda \ge iであるジョブの中でもっとも早く行うとする。このとき、Ri,jRλ,jR_{i, j} \le R_{\lambda, j}である。

証明: pos(λ)<pos(j)<pos(i)\operatorname{pos}(\lambda) < \operatorname{pos}(j) < \operatorname{pos}(i)に注意すると、Rλ,j,Ri,jR_{\lambda, j}, R_{i, j}は以下のように評価できる:

Rλ,j=k<λ,pos(λ)<pos(k)<pos(j)pk+kλ,pos(k)<pos(j)pk R_{\lambda, j} = \sum_{k < \lambda, \operatorname{pos}(\lambda) < \operatorname{pos}(k) < \operatorname{pos}(j)}p_k + \sum_{k \ge \lambda, \operatorname{pos}(k) < \operatorname{pos}(j)}p_k Ri,j=k<i,pos(j)<pos(k)<pos(i)pk+ki,pos(k)<pos(j)pkki,pos(k)<pos(j)pk R_{i, j} = -\sum_{k < i, \operatorname{pos}(j) < \operatorname{pos}(k) < \operatorname{pos}(i)}p_k + \sum_{k \ge i, \operatorname{pos}(k) < \operatorname{pos}(j)}p_k \le \sum_{k \ge i, \operatorname{pos}(k) < \operatorname{pos}(j)}p_k

Ri,jR_{i, j}の上界ki,pos(k)<pos(j)pk\sum_{k \ge i, \operatorname{pos}(k) < \operatorname{pos}(j)}p_kにどのようなpkp_kが現れるか調べるために、添字kkの集合を3つに分割する:

  1. {k[n]:ik<λ,pos(k)<pos(λ)}\{k \in [n]: i \le k < \lambda, \operatorname{pos}(k) < \operatorname{pos}(\lambda)\}
  2. {k[n]:ik<λ,pos(λ)<pos(k)<pos(j)}\{k \in [n] : i \le k < \lambda, \operatorname{pos}(\lambda) < \operatorname{pos}(k)<\operatorname{pos}(j)\}
  3. {k[n]:kλ,pos(k)<pos(j)}\{k \in [n]: k \ge \lambda, \operatorname{pos}(k) < \operatorname{pos}(j)\}

iλi \le \lambdaであるジョブλ\lambdaの中でもっとも早く行うものを選んでいるので1は空である。また、2はRλ,jR_{\lambda, j}の一つ目の総和の添字集合に包含され、3は二つ目の総和の添字集合に等しい。従って、Ri,jRλ,jR_{i, j} \le R_{\lambda, j}である。\square

補題1~4を適用することで、命題3が示せる: 補題1と3より、(A)や(B)に該当するようなλ\lambdaが存在しない(i,j)(i, j)に対してはRi,jTjR_{i, j} \le T_jである。また、(A)や(B)に該当するようなλ\lambdaが存在する場合、どちらの場合もi<λi < \lambdaだから、補題2または4を繰り返し適用することで、i<λ1<λ2<...i < \lambda_1 < \lambda_2 < ..., Ri,jRλ1,jRλ2,j...R_{i, j} \le R_{\lambda_1, j} \le R_{\lambda_2, j} \le ... という列が得られる。高々有限回の適用で(A)や(B)に該当する項がないものが見つかり、補題1または3によってRi,jTjR_{i, j} \le T_jが従う。

これでForm*\operatorname{Form*}の正当性が示された。


上のNemhauserとSavelsberghの論文ではLP双対を経由した非常に短い「証明」が書かれている……が、読んでも双対を取ることで問題が自明になっていると思えなかった。双対に注目しても、ギャップを埋めようとすればRi,jR_{i, j}のうちどれがどれより大きいのかという細かい議論を避けられないように思える。

実験

big Mを避けることで実際に速くなるかどうか知りたい。FormM\operatorname{FormM}Form*\operatorname{Form*}をSCIPに与えて、ランダムインスタンスで比較した。

import math
import random
import statistics
from dataclasses import dataclass
from itertools import pairwise, product
from typing import Any

import pandas as pd
import pulp


@dataclass
class SchedulingInstance:
    n: int
    release_times: list[float]
    weights: list[float]
    processing_times: list[float]


@dataclass
class SchedulingSolution:
    n: int
    start_times: list[float]
    model: pulp.LpProblem  # artifact


def validate_solution(
    instance: SchedulingInstance, solution: SchedulingSolution
) -> None:
    n = instance.n
    release_times = instance.release_times
    processing_times = instance.processing_times
    start_times = solution.start_times
    eps = 1e-8

    for i in range(n):
        assert release_times[i] <= start_times[i] + eps

    for i1, i2 in pairwise(sorted(range(n), key=lambda i: start_times[i])):
        assert start_times[i1] + processing_times[i1] <= start_times[i2] + eps
        # optimality condition
        assert math.isclose(
            max(start_times[i1] + processing_times[i1], release_times[i2]),
            start_times[i2],
            rel_tol=eps,
            abs_tol=eps,
        )


def make_random_instance(n: int, mag: float, seed: int) -> SchedulingInstance:
    rng = random.Random(seed)
    release_times = sorted((rng.randrange(0, mag) for _ in range(n)))
    weights = [rng.randrange(1, mag) for _ in range(n)]
    processing_times = [rng.randrange(1, mag) for _ in range(n)]
    return SchedulingInstance(n, release_times, weights, processing_times)


def solve(
    instance: SchedulingInstance, is_big_m: bool, time_out_sec: float = 600.0
) -> SchedulingSolution | None:
    n = instance.n
    release_times = instance.release_times
    weights = instance.weights
    processing_times = instance.processing_times

    model = pulp.LpProblem("single_machine_scheduling", pulp.LpMinimize)

    start_times = [pulp.LpVariable(f"start_time_{i}") for i in range(n)]
    is_priors = [
        [pulp.LpVariable(f"is_prior_{i}_{j}", cat=pulp.LpBinary) for i in range(n)]
        for j in range(n)
    ]

    model += pulp.lpSum(start_times[i] * weights[i] for i in range(n))

    def is_prior(i: int, j: int) -> pulp.LpAffineExpression:
        assert i != j
        if i > j:
            return pulp.LpAffineExpression(1 - is_priors[j][i])
        else:
            return pulp.LpAffineExpression(is_priors[i][j])

    for i, j, k in product(range(n), range(n), range(n)):
        if i == j or j == k or k == i:
            continue
        model += is_prior(i, j) + is_prior(j, k) <= 1 + is_prior(i, k)
    for i in range(n):
        model += start_times[i] >= release_times[i]

    if is_big_m:
        big_m = 2 * (release_times[-1] + sum(processing_times)) + 1
        for i, j in product(range(n), range(n)):
            if i == j:
                continue
            model += start_times[j] >= start_times[i] + processing_times[
                i
            ] - big_m * is_prior(j, i)
    else:
        for i, j in product(range(n), range(n)):
            if i == j:
                continue
            model += start_times[j] >= release_times[i] * is_prior(i, j) + pulp.lpSum(
                processing_times[k] * (is_prior(i, k) + is_prior(k, j) - 1)
                for k in range(i)
                if k != j
            ) + pulp.lpSum(
                processing_times[k] * is_prior(k, j) for k in range(i, n) if k != j
            )

    solver = pulp.SCIP_CMD(timeLimit=time_out_sec)
    model.solve(solver)
    print(f"{model.solutionTime=}")
    if model.status == pulp.LpStatusNotSolved:
        return None
    assert model.status == pulp.LpStatusOptimal, pulp.LpStatus[model.status]
    solution_start_times = [pulp.value(start_times[i]) for i in range(n)]
    sol = SchedulingSolution(n, solution_start_times, model)
    validate_solution(instance, sol)
    return sol


def time_with_param(n: int, mag: float, is_big_m: bool, n_iter: int) -> dict[str, Any]:
    times: list[float] = []
    objs: list[float] = []
    for seed in range(n_iter):
        instance = make_random_instance(n, mag, seed)
        sol = solve(instance, is_big_m, time_out_sec=600.0)
        times.append(sol.model.solutionTime if sol else math.inf)
        objs.append(pulp.value(sol.model.objective) if sol else math.nan)
    print(objs)
    return (
        {"n": n, "method": "big M" if is_big_m else "no M"}
        | {f"seed {i}": times[i] for i in range(n_iter)}
        | {"median": statistics.median(times)}
    )


df = pd.DataFrame(
    [
        time_with_param(10, 10, True, 5),
        time_with_param(15, 15, True, 5),
        time_with_param(10, 10, False, 5),
        time_with_param(15, 15, False, 5),
        time_with_param(20, 20, False, 5),
        time_with_param(30, 30, False, 5),
        time_with_param(50, 50, False, 5),
    ],
)
print(df.to_markdown(index=False, floatfmt=".2f"))

結果は次のようになった。infは600秒以内に解けなかったことを表す。

n method seed 0 seed 1 seed 2 seed 3 seed 4 median
10 big M 2.26 0.94 3.95 1.30 9.83 2.26
15 big M inf 298.36 inf 233.59 inf inf
10 no M 0.21 3.47 2.25 1.20 0.07 1.20
15 no M 4.04 4.07 2.56 0.70 4.65 4.04
20 no M 3.29 0.32 3.60 0.36 3.41 3.29
30 no M 12.25 4.03 5.63 5.40 14.96 5.63
50 no M 20.19 65.13 inf inf 37.51 65.13

big Mを避けた定式化のほうが速く解けると言える。しかし、この定式化であっても、O(2nn)O(2^nn)の自明なDPで扱える規模(n<30n < 30)を超えてSCIPで新たに解けるようになる範囲は+10~20程度のようだ。ただし、上の定式化は最低限の制約しか使っておらず、冗長な制約(妥当不等式)を追加していくなどによってさらに速くなる可能性はある。妥当不等式に関しては、NemhauserとSavelsberghの論文にも議論がある。

その他

NemhauserとSavelsberghのテクニックは、機械が1つであれば冒頭の問題設定以外にも様々なリリース時刻付き問題に使える。例えば、ジョブに依存関係がある1prec;rjwjCj1|\textrm{prec};r_j|\sum w_jC_jや、納期に対する遅れ時間maxi(Tidi)\max_i(T_i - d_i)を最小化する1rjLmax1|r_j|L_{\max}など。

Khowalaが単一マシンの様々なジョブスケジューリング問題と定式化のベンチマークを行っていて、ここで扱ったような処理順序を表すバイナリ変数を導入するやり方以外の定式化も知ることができる。

この問題に関しては、ジョブの順番に制約がなくて順番を決めれば最適解が自明に求まるという性質上、局所探索が明らかに簡単かつ有効ではある。実用的にはサイズがかなり大きくなっても困らないタイプの問題と言えるかもしれない。(実際はだいたい面倒な制約が付くだろうけど……)

文献

  • Brucker, Peter, Jan Karel Lenstra and Alexander H. G. Rinnooy Kan. “Complexity of machine scheduling problems.” (1975).
  • Khowala, Ketan. “Single Machine Scheduling: Comparison of MIP Formulations and Heuristics for Interfering Job Sets.” (2012).
  • Nemhauser, George L. and Mwp Martin Savelsbergh. “A Cutting Plane Algorithm for the Single Machine Scheduling Problem with Release Times.” (1992).

2024年1月18日木曜日

functional graphの閉路の情報を得る

問題: 関数f:{0,1,...,N1}{0,1,...,N1}f: \{0, 1, ..., N-1\} \rightarrow \{0, 1, ..., N-1\}が与えられる。ffによって定まるfunctional graph上の閉路の情報をO(N)O(N)で得たい。具体的には、次の配列を作りたい:

  • cycle[v]:=\operatorname {cycle}[v] := 閉路上の頂点でvvに最も近いもの。より形式的には、fk(v)f^k(v)が閉路に含まれるような最小の非負整数kkに関するfk(v)f^k(v)
  • length[v]:=v\operatorname{length}[v] := vが閉路に含まれるならその閉路の長さ。そうでないならvvからcycle[v]\operatorname{cycle}[v]までの距離。

以下はグラフと求めたい値の例。頂点番号 (cycleの値, lengthの値)という表記になっている。

アルゴリズム

長さNNの配列tmp,length,cycle\mathrm{tmp}, \mathrm{length}, \mathrm{cycle}を初期化する。tmp\mathrm{tmp}1-1で埋め、length,cycle\mathrm{length}, \mathrm{cycle}00で埋める。

頂点r=0,1,...,N1r = 0, 1, ..., N-1について、それぞれ未訪問(tmp[r]=1\mathrm{tmp}[r] = -1)であればrrに関するイテレーションを開始する。

1回のイテレーションにおいては、vrv \leftarrow rを始点にしてvf(v)v \leftarrow f(v)を繰り返してグラフを辿っていく。この際、rrからvvまでの距離ddを保持して、tmp[v]d\mathrm{tmp}[v] \leftarrow dと記録していく。以前に訪れた頂点(tmp[v]1\mathrm{tmp}[v] \neq -1)を再訪したところでストップするが、このとき、vvがこのイテレーションで初めて訪問した頂点である(length[v]=0\mathrm{length}[v] = 0)か、以前のイテレーションで訪問した頂点かで必要な処理が異なる。

vvがこのイテレーションで初めて訪問した頂点であれば新しい閉路を発見したことになるので、閉路の長さ(dtmp[v]d - \mathrm{tmp}[v])に従って、閉路をもう一周しながらlength\mathrm{length}cycle\mathrm{cycle}を埋める。さらに、始点rrから閉路に達するまでの頂点についてもlength\mathrm{length}cycle\mathrm{cycle}を埋める。

vvが以前のイテレーションで訪問した頂点の場合、始点rrから辿ってcycle\mathrm{cycle}cycle[v]\mathrm{cycle}[v]で埋めていく。length\mathrm{length}については、vvが閉路中の頂点(v=cycle[v]v = \mathrm{cycle}[v])であればtmp[v]\mathrm{tmp}[v]を採用し、vvが閉路外の頂点であればtmp[v]+length[v]\mathrm{tmp}[v]+\mathrm{length}[v]を採用して始点から埋めていく。

どの頂点についても高々2回しかffを適用しないのでO(N)O(N)

以下はPythonの実装例:

def get_cycle_info(f: list[int]) -> tuple[list[int], list[int]]:
    """{0, 1, ..., N-1}上の写像fを入力として2つのリストcycle, lengthを返す

    cycle[v] := 閉路に含まれるような最初のf(f(...f(v)...))
    length[v] := vが閉路上の頂点の場合は閉路の長さ、そうでない場合はvから閉路までの距離
    """
    n = len(f)
    tmp = [-1] * n
    cycle = [0] * n
    length = [0] * n
    for start in range(n):
        v = start
        dist = 0
        while tmp[v] == -1:
            tmp[v] = dist
            v = f[v]
            dist += 1
        if length[v] == 0:
            u = start
            path_length = tmp[v]
            for i in range(path_length):
                cycle[u] = v
                length[u] = path_length - i
                u = f[u]
            cycle_length = dist - tmp[v]
            for _ in range(cycle_length):
                cycle[v] = v
                length[v] = cycle_length
                v = f[v]
        else:
            u = start
            path_length = dist if v == cycle[v] else dist + length[v]
            for i in range(dist):
                cycle[u] = cycle[v]
                length[u] = path_length - i
                u = f[u]
    return cycle, length
cycle, length = get_cycle_info([1, 2, 3, 4, 5, 6, 3, 8, 1, 10, 11, 10, 6, 12, 14])
assert cycle == [3, 3, 3, 3, 4, 5, 6, 3, 3, 10, 10, 11, 6, 6, 14]
assert length == [3, 2, 1, 4, 4, 4, 4, 4, 3, 1, 2, 2, 1, 2, 1]

cycle, length = get_cycle_info([1, 2, 2])
assert cycle == [2, 2, 2]
assert length == [2, 1, 1]

cycle, length = get_cycle_info([])
assert cycle == []
assert length == []

たぶん基本的な問題だけど書いてある文献が見つからなかったのでメモ。