一、問題描述
排班問題(Scheduling Problem),是運籌最佳化領域的一個經典問題。在日常生活中,企事業單位進行人力資源管理時經常會碰到一個問題,那就是員工的倒班問題。企業每天有額定的在崗人數,如何生成排班方案,使得在不使員工加班的前提下,僱傭更少的員工?
只是模型罷了
我們就以阿米諾斯航空(以下簡稱A航)在某機場的人力資源規劃方案為例:
已知A航在該機場對值機人員的在崗人數需求如表一所示:
表一: 阿米諾斯航空值機人員需求表
Mon. | Tue. | Wed. | Thur. | Fri. | Sat. | Sun. | |
---|---|---|---|---|---|---|---|
06:00-10:00 | 8 | 8 | 8 | 8 | 10 | 10 | 6 |
10:00-14:00 | 12 | 10 | 12 | 10 | 16 | 16 | 8 |
14:00-18:00 | 16 | 12 | 16 | 12 | 20 | 20 | 8 |
18:00-20:00 | 9 | 8 | 9 | 8 | 12 | 12 | 4 |
由表一可見,在每天的不同時段及每週的不同日期,A航對工作人員的在崗數量需求各不相同。A航將每天的營業時間分為四個時段,每個時段4個小時。例如,週一的上午6點至10點需要安排8名員工。航司在排班時還應當考慮如下公司政策及勞務合同的規定(好好好):
- 每個僱員一天內連續工作8小時
- 實行3個輪班工作制,即6:00-14:00, 10:00-18:00, 14:00-22:00
- 每個僱員連續工作5天后休息2天, 7天為一個週期。
如何指定排班方案,使得在不影響企業正常運營的前提下,使企業僱傭的員工最少?
二、模型構建
一週七天,共三個班組,可設定整型變數\(x_{ij}\), 其含義為:
其中,下標變數\(i\), \(j\)的取值分別為:
為使企業僱傭的員工數量最少,定義目標函式如下所示:
隨後,我們根據表一中的四個時段分別列出約束條件,這裡以週一為例
2.1 時段一:06:00-10:00
在該時段僅有第一班工作人員(6:00-14:00)上崗,其餘兩班工作人員均未到崗,對照表一可得:
注意\(x_{ij}\)的含義,按照前述的"上五休二"規則.週二和週三上崗的員工此時處於休假期間,故不出現在約束條件中, 同理可列出其餘六天的第一時段的約束條件.
2.2 時段二:10:00-14:00
此時段上崗的員工應包括6:00-14:00, 10:00-18:00兩個時段, 即:
2.3 時段三:14:00-18:00
此時段上崗的員工應當包括10:00-18:00, 14:00-22:00兩個時段, 即:
2.4 時段四:18:00-22:00
在該時段僅有第三班工作人員(6:00-14:00)上崗, 即:
此時我們已經表示出了表一第一列所對應的約束, 同理,對照表一可以得到其餘六天的四個時段所對應的約束條件,共計28個不等式約束。
到此,我們已經構建出了完整的排班最佳化模型。
三、程式設計思路
gurobipy是gurobi提供的python API, 方便我們在python環境中呼叫gurobi定義最佳化模型並求解。
模型擁有28個約束,21個決策變數,為節約時間這裡不建議手動輸入。我們需要觀察一下目標函式和約束條件中,兩個下標\(i\), \(j\)取值的分佈規律。
3.1 目標函式
目標函式實現邏輯較為簡單,雙重遍歷,快速求和即可,這個在gurobi的API中已經封裝了現成的方法:
import gurobipy as grb
m = grb.model("Stuff Scheduling Problem")
# 批次新增決策變數,七行三列表示一週七天,三個班組,引數obj=1表示變數的目標函式係數全是1
# gurobi允許你給模型中的決策變數和約束命名, 這樣當模型規模較大時可以按名稱查詢到對應的約束或變數, 方便除錯
week_days = 7
crew_nums = 3
x_mat = m.addVars(week_days, crew_nums, obj=1, vtype = grb.GRB.INTEGER, name = 'x')
以上為gurobi中目標函式的隱式寫法,適用於目標函式較簡單的情況。我們也可以顯式地定義目標函式,程式碼如下:
import gurobipy as prb
m = grb.model("Stuff Scheduling Problem")
# 此時我們先不指定obj引數的值
week_days = 7
crew_nums = 3
x_mat = m.addVars(week_days, crew_nums, vtype = grb.GRB.INTEGER, name = 'x')
m.setObjective(grb.quicknum(x_mat[i, j] for i in range(week_days) for i in range(crew_nums)), grb.GRB.MINIMIZE)
以上兩種寫法是等價的,任選其一即可,這裡多說一句, 變數x_mat
的索引是由元組(7, 3)來定義的,因此遍歷時只能寫作x_mat[i, j]
而不能寫作x_mat[i][j]
, 否則gurobipy會丟擲KeyError。
3.2 約束條件
根據公司政策,員工連續工作5天,休息2天,如表二所示:
表二:阿米諾斯航空值機人員作息日程表
Mon. | Tue. | Wed. | Thur. | Fri. | Sat. | Sun. | |
---|---|---|---|---|---|---|---|
Mon. | 班 | 班 | 班 | 班 | 班 | ||
Tue. | 班 | 班 | 班 | 班 | 班 | ||
Wed. | 班 | 班 | 班 | 班 | 班 | ||
Thur. | 班 | 班 | 班 | 班 | 班 | ||
Fri. | 班 | 班 | 班 | 班 | 班 | ||
Sat. | 班 | 班 | 班 | 班 | 班 | ||
Sun. | 班 | 班 | 班 | 班 | 班 |
表二中,行索引表示日期,列索引表示員工開始工作的日期,如表格第一行第二列的含義為:週一當天,所有周二開始上班的員工此時正在休假。
由於表二的資料分佈具有強週期性,我們可以使用如下程式碼直接生成工作日的索引,無需讀取外部資料表:
有無大佬幫忙證明一下這個數學原理是什麼
import numpy as np
working_day = np.array([[(i+j) % 7 for j in range(7)] for i in range(7)])
working_day = np.delete(working_day, [1, 2], axis=1)
直接將表一作為輸入資料,根據表一中的四個時段分別構造約束,程式碼如下:
import pandas as pd
req_mat = pd.read_excel(url+"/"+"AminoacScheduling.xlsx").values
m.addConstrs(grb.quicksum(x_mat[j, 0] for j in working_day[i])>=req_mat[0][i] for i in range(week_days))
m.addConstrs(grb.quicksum(x_mat[j, 0] + x_mat[j, 1] for j in working_day[i])>=req_mat[1][i] for i in range(week_days))
m.addConstrs(grb.quicksum(x_mat[j, 1] + x_mat[j, 2] for j in working_day[i])>=req_mat[2][i] for i in range(week_days))
m.addConstrs(grb.quicksum(x_mat[j, 2] for j in working_day[i])>=req_mat[3][i] for i in range(week_days))
至此,模型的目標函式和全部約束條件構建完成,直接求解即可
完整程式碼
import gurobipy as grb
import numpy as np
import pandas as pd
m = grb.Model("Stuff Scheduling Problem")
# 此時我們先不指定obj引數的值
__week_days__ = 7
__crew_nums__ = 3
__dir__ = r"D:/Coding/ProgramData/AirlineProb"
x_mat = m.addVars(__week_days__, __crew_nums__, vtype=grb.GRB.INTEGER, name='x')
m.setObjective(grb.quicksum(x_mat[i, j] for i in range(__week_days__) for j in range(__crew_nums__)), grb.GRB.MINIMIZE)
working_day = np.array([[(i + j) % __week_days__ for j in range(__week_days__)] for i in range(__week_days__)])
working_day = np.delete(working_day, [1, 2], axis=1)
req_mat = pd.read_excel(__dir__ + "/" + "ManpowerPlan.xlsx").values
m.addConstrs(grb.quicksum(x_mat[k, 0] for k in working_day[i]) >= req_mat[0][i] for i in range(__week_days__))
m.addConstrs(grb.quicksum(x_mat[k, 0] + x_mat[k, 1] for k in working_day[i]) >= req_mat[1][i]
for i in range(__week_days__))
m.addConstrs(grb.quicksum(x_mat[k, 1] + x_mat[k, 2] for k in working_day[i]) >= req_mat[2][i]
for i in range(__week_days__))
m.addConstrs(grb.quicksum(x_mat[k, 2] for k in working_day[i]) >= req_mat[3][i] for i in range(__week_days__))
# m.write("StuffScheduling.lp")
m.optimize()
for v in m.getVars():
# if v.x != 0:
print(v.varName, v.x)