Vai al contenuto

EX 12 — Bilanciamento fra due operai

Classe: MILP · Legami: min-max, valore assoluto · Script: python/ex12_bilanciamento.py

Difficoltà: ★★★☆☆ · Tempo: 30–45 min

Apri in Colab

Uno dei quindici modelli numerici, della famiglia assegnamento e scheduling, con rimando alla famiglia della partizione e del bilanciamento.

EX 12

Quattro lavori indivisibili di durata \(2\), \(3\), \(6\) e \(7\) vanno assegnati a due operai in modo che i loro carichi siano il più possibile bilanciati.

«Bilanciato» va tradotto, e la traduzione si dichiara

«Carichi bilanciati» non è un obiettivo: è una parola. Le due traduzioni lineari sono quelle della tecnica 6.6: minimizzare il massimo dei carichi, oppure minimizzare la loro differenza. Poiché il totale \(q = 18\) è costante, vale l'identità

\[\max(w_1, w_2) = \frac{q}{2} + \frac{|w_1 - w_2|}{2},\]

quindi i due modelli hanno le stesse soluzioni ottime — ma valori ottimi diversi: \(9\) il primo, \(0\) il secondo. Riportare «\(9\)» come «differenza fra i carichi» è un errore, e riportare «\(0\)» come «carico massimo» pure.

Modello (min-max)

Con \(x_j = 1\) se il lavoro \(j\) va all'operaio 1 e \(z \ge 0\) il carico massimo:

\[ \begin{array}{rrrrrr c l} \min & & & & & z & & \\ \text{soggetto a} & 2x_1 & +3x_2 & +6x_3 & +7x_4 & -z & \le & 0\\ & -2x_1 & -3x_2 & -6x_3 & -7x_4 & -z & \le & -18\\ & x_1, & x_2, & x_3, & x_4 & & \in & \{0, 1\}\\ & & & & & z & \ge & 0 \end{array} \]

L'obiettivo è la sola \(z\): non compare nessun dato, perché il costo da minimizzare è il carico del più carico dei due operai. Sono le due righe seguenti a darle significato. La prima dice che il carico dell'operaio 1 non supera \(z\); la seconda, scritta sui lavori non assegnati, dice lo stesso dell'operaio 2 — il suo carico è \(18\) meno quello dell'operaio 1. Insieme spingono \(z\) sopra entrambi i carichi, e il minimo la schiaccia sul più grande dei due: è la tecnica min-max.

Euristica costruttiva: il bound primale

LPT: i lavori in ordine di durata decrescente, ciascuno all'operaio meno carico. Lavoro 4 (\(7\)) all'operaio 1; lavoro 3 (\(6\)) all'operaio 2; lavoro 2 (\(3\)) all'operaio 2, che arriva a \(9\); lavoro 1 (\(2\)) all'operaio 1, che arriva a \(9\). Carichi \((9, 9)\), quindi \(\mathit{UB} = 9\).

Rilassamento LP e duale: il bound duale

Rilassando a \(x_j \ge 0\) e usando la tabella di conversione — primale di minimo con vincoli \(\le\), quindi variabili duali \(\pi_1, \pi_2 \le 0\):

\[ \begin{array}{rrr c l} \max & & -18\pi_2 & & \\ \text{soggetto a} & 2\pi_1 & -2\pi_2 & \le & 0\\ & 3\pi_1 & -3\pi_2 & \le & 0\\ & 6\pi_1 & -6\pi_2 & \le & 0\\ & 7\pi_1 & -7\pi_2 & \le & 0\\ & -\pi_1 & -\pi_2 & \le & 1\\ & \pi_1, & \pi_2 & \le & 0 \end{array} \]

Poiché \(d_j > 0\), i primi vincoli dicono \(\pi_1 \le \pi_2\). Ponendo \(\pi_1 = \pi_2 = t\), il vincolo su \(z\) dà \(-2t \le 1\), cioè \(t \ge -1/2\); l'obiettivo \(-q t\) è massimo per \(t = -1/2\) e vale \(\mathit{LB} = q/2 = 9\). Significato: «i due carichi sommano a \(q\), quindi il maggiore vale almeno \(q/2\)».

La stessa cosa con variabili duali non negative

Molte trattazioni preferiscono variabili duali \(\ge 0\). Basta porre \(\alpha = -\pi_1\) e \(\beta = -\pi_2\): il duale diventa \(\max q\beta\) con \(\alpha \le \beta\) e \(\alpha + \beta \le 1\), e \(\alpha = \beta = 1/2\) dà di nuovo \(9\). È la stessa soluzione scritta con l'altro segno; quello che non si può fare è dichiarare variabili \(\ge 0\) e poi usarne una negativa.

\(UB\) (LPT) \(LB\) (duale a mano) \(z(\mathit{LP})\) \(z(\mathit{MILP})\) gap euristica
9 9 9 9 \(0{,}0\%\)

Tutti i numeri coincidono: i due bound a mano si toccano e l'ottimalità è dimostrata senza risolvere il MILP. Succede perché \(q\) è pari e i lavori si possono spezzare esattamente a metà; con \(q\) dispari il bound diventerebbe \(\lceil q/2 \rceil\) e servirebbe un argomento in più.

I carichi ottimi

Codice

Lo script completo — che risolve anche la versione «minima differenza» e verifica l'identità fra i due obiettivi — è python/ex12_bilanciamento.py; il notebook è notebooks/ex12_bilanciamento.ipynb.

Mostra lo script completo — python/ex12_bilanciamento.py (155 righe)
"""EX 12 -- Bilanciamento fra due operai (famiglia 7, rimando alla 11).

Quattro lavori indivisibili di durata 2, 3, 6, 7 e due operai: si vogliono
carichi il piu' possibile bilanciati.

Due avvertenze che è facile confondere:
1. «carichi bilanciati» si puo' scrivere come min-max oppure come min della
   differenza: le soluzioni ottime sono le stesse (il totale e' costante) ma i
   *valori* dell'obiettivo non coincidono. Qui si riportano entrambi.
2. il duale va scritto con i segni della tabella di conversione: in un minimo
   con vincoli <= le variabili duali sono <= 0. La presentazione con variabili
   >= 0 e' la stessa, a segni cambiati, e si mostra anche quella.
"""
import gurobipy as gp
import pandas as pd
from gurobipy import GRB

from mip import (ammissibile, due_rilassamenti, frazione, nuovo_modello, registra_bound,
                 risolvi, valuta)
from stile import intestazione, plt, salva_dati, salva_figura
from esteso import salva_modello

R = range

# ---------- 1. MODELLO E ISTANZA ----------
intestazione("EX 12. Bilanciamento: quattro lavori indivisibili su due operai")
d = [2, 3, 6, 7]
n, D = len(d), sum(d)
print(f"  Durate {d}; totale {D}; a carichi perfettamente pari ciascuno farebbe {frazione(D / 2)}")
salva_dati(pd.DataFrame({"lavoro": R(1, n + 1), "durata": d}), "ex12_lavori")


def modello_minmax(d):
    """min z  con  sum_j d_j x_j <= z  e  D - sum_j d_j x_j <= z."""
    n, D = len(d), sum(d)
    m = nuovo_modello("bilanciamento_minmax")
    x = m.addVars(n, vtype=GRB.BINARY, name="x")
    z = m.addVar(name="z")
    m.setObjective(z, GRB.MINIMIZE)
    m.addConstr(gp.quicksum(d[j] * x[j] for j in R(n)) - z <= 0, name="carico1")
    m.addConstr(-gp.quicksum(d[j] * x[j] for j in R(n)) - z <= -D, name="carico2")
    return m, x, z


def modello_differenza(d):
    """min s  con  s >= W1 - W2  e  s >= W2 - W1: la stessa scelta, un altro numero."""
    n, D = len(d), sum(d)
    m = nuovo_modello("bilanciamento_differenza")
    x = m.addVars(n, vtype=GRB.BINARY, name="x")
    s = m.addVar(name="s")
    m.setObjective(s, GRB.MINIMIZE)
    carico1 = gp.quicksum(d[j] * x[j] for j in R(n))
    m.addConstr(s >= 2 * carico1 - D, name="abs_piu")
    m.addConstr(s >= D - 2 * carico1, name="abs_meno")
    return m, x, s


def duale_minmax(d):
    """Duale del rilassamento senza i bound, con la convenzione del corso (pi <= 0):
       max 0*pi1 - D*pi2   s.t.  d_j (pi1 - pi2) <= 0 per ogni j;  -pi1 - pi2 <= 1;  pi <= 0."""
    n, D = len(d), sum(d)
    dl = nuovo_modello("duale_bilanciamento")
    pi1 = dl.addVar(lb=-GRB.INFINITY, ub=0.0, name="pi[0]")
    pi2 = dl.addVar(lb=-GRB.INFINITY, ub=0.0, name="pi[1]")
    dl.setObjective(-D * pi2, GRB.MAXIMIZE)
    dl.addConstrs((d[j] * (pi1 - pi2) <= 0 for j in R(n)), name="rc_x")
    dl.addConstr(-pi1 - pi2 <= 1, name="rc_z")
    return dl, pi1, pi2


m, x, z = modello_minmax(d)
salva_modello(m, "ex12_primale")

# ---------- 2. EURISTICA COSTRUTTIVA (UPPER BOUND) ----------
# LPT su due operai: i lavori in ordine di durata decrescente, ciascuno al meno carico
carico = [0, 0]
assegn = {}
for j in sorted(R(n), key=lambda j: -d[j]):
    k = 0 if carico[0] <= carico[1] else 1
    assegn[j] = k
    print(f"  Lavoro {j + 1} (durata {d[j]}): carichi {carico}; il minore e' l'operaio "
          f"{k + 1}, che passa a {carico[k] + d[j]}")
    carico[k] += d[j]
ub = max(carico)
sol_eur = {f"x[{j}]": 1 for j in R(n) if assegn[j] == 0} | {"z": ub}
assert ammissibile(m, sol_eur)
print(f"  Soluzione euristica: operaio 1 = {[j + 1 for j in R(n) if assegn[j] == 0]}, "
      f"operaio 2 = {[j + 1 for j in R(n) if assegn[j] == 1]}, carichi {carico}")
print(f"  ub = max dei carichi = {frazione(ub)}")

# ---------- 3. RILASSAMENTO LP E DUALE (LOWER BOUND) ----------
dl, pi1, pi2 = duale_minmax(d)
salva_modello(dl, "ex12_duale")
# ricetta: i vincoli d_j (pi1 - pi2) <= 0 impongono pi1 <= pi2; con pi1 = pi2 = t il
# vincolo -pi1 - pi2 <= 1 da' t >= -1/2, e l'obiettivo -D t cresce al calare di t
mano = {"pi[0]": -0.5, "pi[1]": -0.5}
lb, viol = valuta(dl, mano)
assert viol <= 1e-9, viol
print("  Duale a mano: i vincoli d_j (pi1 - pi2) <= 0 impongono pi1 <= pi2; ponendo")
print("  pi1 = pi2 = t, il vincolo -pi1 - pi2 <= 1 da' t >= -1/2, e l'obiettivo -D t")
print(f"  e' massimo per t = -1/2:  lb = -{D} * (-1/2) = {frazione(lb)}")
print("  Presentazione equivalente a segni cambiati (alpha = -pi1, beta = -pi2, >= 0):")
print("  max D beta con alpha <= beta e alpha + beta <= 1; alpha = beta = 1/2 da' lo stesso 9.")
print("  Significato: «i due carichi sommano a D, quindi il maggiore vale almeno D/2».")
zlp, zlpr, pi = due_rilassamenti(m, dl)

# ---------- 4. OTTIMO DEL MILP E TABELLA DEI BOUND ----------
zv = risolvi(m)
op1 = [j + 1 for j in R(n) if x[j].X > 0.5]
op2 = [j + 1 for j in R(n) if x[j].X <= 0.5]
c1 = sum(d[j - 1] for j in op1)
print(f"  Soluzione ottima (min-max): operaio 1 = {op1} (carico {c1}), operaio 2 = {op2} "
      f"(carico {D - c1});  z(MILP) = {frazione(zv)}")
riga = registra_bound("EX 12 bilanciamento", ub, lb, zlp, zlpr, zv)
salva_dati(pd.DataFrame([riga]), "ex12_bound")
assert lb <= zlp <= zv <= ub + 1e-9

# ---------- 5. LO STESSO PROBLEMA CON L'OBIETTIVO «DIFFERENZA» ----------
intestazione("EX 12 (seguito). Lo stesso problema scritto come minima differenza")
md, xd, sd = modello_differenza(d)
zd = risolvi(md)
op1d = [j + 1 for j in R(n) if xd[j].X > 0.5]
c1d = sum(d[j - 1] for j in op1d)
print(f"  Soluzione ottima (differenza): operaio 1 = {op1d} (carico {c1d}), carichi "
      f"({c1d}, {D - c1d});  z = {frazione(zd)}")
print(f"  La ripartizione e' la stessa; i due obiettivi valgono {frazione(zv)} e "
      f"{frazione(zd)}.")
print(f"  Il legame e' esatto: max = D/2 + differenza/2, cioe' {frazione(D / 2)} + "
      f"{frazione(zd / 2)} = {frazione(zv)}.")
assert abs(zv - (D / 2 + zd / 2)) < 1e-9
print("  Percio' i due modelli hanno le stesse soluzioni ottime, ma i loro valori non si")
print("  confrontano: chiamare 'differenza' il valore del min-max e' un errore.")
salva_dati(pd.DataFrame([{"obiettivo": "min-max", "z": zv},
                         {"obiettivo": "minima differenza", "z": zd}]), "ex12_obiettivi")

# ---------- 6. FIGURA ----------
fig, ax = plt.subplots(figsize=(6.6, 2.6))
colori = ["#0E7490", "#C0392B", "#1E8449", "#CA6F1E"]
for k, lavori in enumerate([op1, op2]):
    inizio = 0
    for j in lavori:
        ax.barh(k, d[j - 1], left=inizio, color=colori[(j - 1) % 4], edgecolor="white")
        ax.annotate(f"{j}", (inizio + d[j - 1] / 2, k), ha="center", va="center",
                    color="white", fontsize=9, fontweight="bold")
        inizio += d[j - 1]
ax.axvline(D / 2, color="#16324A", ls="--", lw=1.4)
ax.annotate(f"D/2 = {frazione(D / 2)}", (D / 2, -0.62), ha="center", fontsize=9, color="#16324A")
ax.set_yticks([0, 1])
ax.set_yticklabels(["operaio 1", "operaio 2"])
ax.set_xlabel("carico")
ax.set_title(f"EX 12: carichi ottimi ({c1}, {D - c1}); max = {frazione(zv)}, "
             f"differenza = {frazione(zd)}")
ax.invert_yaxis()
salva_figura(fig, "ex12_ottimo")
print("Fine.")