本文使用 CP-SAT 和 Python 對約束程式設計 (CP:Constraint Programming ) 進行了實用介紹。以下是要點:
- 假設您是一家電子商務巨頭,想要建造一個新倉庫來改善客戶服務,但您需要知道最佳倉庫位置。
- 或者您是一家全球運輸公司,需要將包裹分配給送貨卡車,並且必須選擇最佳路線以節省汽油並減少司機加班。
- 或者一家航空公司希望為新地點提供服務,並且需要知道他們應該使用哪種型別的飛機以及按照什麼時間表飛行,以最大限度地提高收入。
上述這類問題被稱為離散最佳化問題。有幾種方法可用於解決此類問題。在本文中,我們將討論其中一種稱為約束規劃的理論和實踐。
宣告式正規化
約束程式設計 (CP) 是一種用於解決離散最佳化問題的宣告式正規化。這與我們通常習慣的命令式正規化形成對比。在指令式程式設計時,我們描述達到結果所需的步驟。
例如,假設我們想知道給定人員列表中的成年人是誰:
Name Age |
典型的命令式方法將解釋獲得所需結果所需的操作序列:
adult_people = [] |
同時,同樣的結果也可以宣告式地描述為:
SELECT person_name FROM people WHERE age >= 18; |
兩種方法的結果相同,但過程不同。
- 在命令式情況下,程式按順序執行每個步驟以達到結果。
- 在宣告式情況下,程式會獲得所需結果的描述(使用語言的可用結構)並自行實現該結果。
約束程式設計(CP)的基礎知識
與上面提到的宣告性示例類似,使用 CP:
- 我們可以描述問題的期望結果:此描述稱為模型。
模型的主要組成部分是變數和約束。
- 變數代表我們要尋找的內容,每個變數都有一個關聯的域,即允許此變數採用的一組值。
- 約束描述變數之間的關係。
CP 中的解決方案是從變數的域中為變數分配值,以滿足所有約束。
用三個人湊錢買一塊糖果的簡單例子來演示 CP 概念,展示如何對變數、域和約束進行建模。:
Alice、Bob 和 Carol 各自有 20 美元,他們想湊錢買一塊價值 50 美元的糖果(是的,通貨膨脹正在肆虐)。Alice 表示她將至少投入與 Bob 一樣多的錢。Carol 只有 5 美元的鈔票,所以她的貢獻將是 Bob 的倍數。他們中沒有人想貢獻與其他人完全相同的金額。
這裡,我們正在尋找每個人應該為購買糖果貢獻的金額。這意味著我們需要每個人一個變數,表示此人應該為購買貢獻的金額(a對於 Alice、bBob 和cCarol)。我們首先設定這些變數的域(符號∈表示“in”):
a ∈ {0, ..., 20} |
這些域確保變數的最終值代表捐款者口袋裡實際擁有的金額(即最多 20 美元)。
接下來,我們要確保合併後的捐款足以支付糖果的價格,因此我們新增了約束:
a + b + c == 50 |
Alice 的貢獻至少與 Bob 一樣多,因此我們將其轉化為:
a >= b |
Carol 的貢獻必須是 5 的倍數:
c % 5 == 0 |
最後,每個人的貢獻金額必須是獨一無二的。我們可以使用以下約束來建模:
a != b |
在這個例子中,我們只有少數幾個人,所以這些不平等現象會很好解決。
但如果人數有幾百人或幾千人呢?事實證明,CP 擁有豐富的表達約束目錄,可以封裝複雜的概念,稱為全域性約束。
上述方法的替代方法是使用所謂的alldifferent約束,它確保一組變數都被分配不同的值:
alldifferent(a, b, c)
這樣就完成了該問題的模型。您會注意到,我們沒有為a、b或c我們自己分配任何值。我們只是定義了三個變數及其域,並使用對這些變數的約束描述了問題的屬性。我們的工作完成了。
CP 求解器
解釋此模型並返回解決方案的軟體稱為求解器。求解器的內部工作原理超出了本文的討論範圍,因此出於我們的目的,我們將求解器視為一個黑匣子,它以模型作為輸入,並返回有效的解決方案:
a = 19 |
返回的解決方案是有效的,因為每個變數都從其域中取一個值,並且所有約束都得到滿足。
但是,我們看到 Carol 貢獻的金額幾乎是 Bob 的兩倍。也許存在另一種有效的解決方案,讓各方對購買的貢獻更加平等?
我們可以為 CP 模型新增一個目標,以嘗試實現這一目標。為模型新增目標使我們能夠最小化或最大化表示式,而不會損害最終解決方案的有效性(就約束而言)。如果我們能以某種方式最小化最大貢獻者的支出金額,這應該會使三項貢獻更加接近。那麼我們的目標就是找到一個有效的解決方案,使這個值最小:
x ∈ {0, ..., 20} |
為了實現這一點,我們建立了一個新變數x來表示最大貢獻的數額。maximum約束負責從中分配x最大值[a, b, c]。然後目標是最小化x。求解器返回的解決方案現在是:
a = 18 |
以前,最大捐款額和最小捐款額之間的差額為 9 美元。
根據我們引入的目標,這個差額現在已降至 3 美元,這是最公平的。
現在基本概念已經明確,讓我們繼續討論更具挑戰性的問題:介紹了一個更復雜的現實世界示例:為擁有多名員工、輪班和角色的商店建立工作時間表。
使用 Python 和 CP-SAT 的例項
讓我們利用這些新的 CP 知識來解決一個更復雜的現實世界示例:小型企業的員工排班。
一家商店的老闆希望為員工制定每週工作時間表。商店每天營業時間為上午 8 點至晚上 8 點,每天分為三個班次,每個班次 4 小時:早上、下午和晚上。商店中有兩個角色:收銀員和補貨員。
- 有些員工有資格擔任這兩個職位,但其他員工只能擔任收銀員或補貨員。
- 必須始終安排收銀員,但補貨每天僅需大約 4 小時。因此,對於補貨任務,我們每天只需安排一名員工進行一次輪班。這可以是任何輪班,但不能連續安排兩個補貨輪班。例如,如果在週二晚班安排補貨,我們就不能將週三的補貨安排在早班。
- 具備兩種職位資格的員工每班次仍只能被分配到一個職位。
- 員工每天的工作時間不能超過 8 小時,也就是 2 個班次。如果他們一天工作 2 個班次,我們必須確保這些班次之間沒有空閒時間 — 例如,我們不能讓他們同時工作在同一天的早班和晚班,因為他們在下午班次會有 4 小時空閒時間。
這是問題的基本前提。讓我們將其分解為可處理的部分。
空模型
我們首先使用CP-SAT建立一個空模型,CP-SAT 是 Google 作為其[url=https://developers.google.com/optimization]OR-Tools[/url]專案的一部分開發的開源 CP 求解器。
from ortools.sat.python import cp_model model = cp_model.CpModel() |
資料
一家商店的老闆希望為員工制定每週工作計劃。商店每天營業時間為上午 8 點至晚上 8 點,每天分為三個班次,每個班次4 小時:早上、下午和晚上。商店中有兩種角色:收銀員和補貨員。有些員工有資格擔任這兩種角色,但其他員工只能擔任收銀員或補貨員。
讓我們建立一個員工及其勝任職位的列表:
employees = {<font>"Phil": ["Restocker"], |
據說該時間表為期一週,並且我們被告知有三種輪班型別和兩種角色:
days = [<font>"Monday", |
變數
現在,讓我們定義我們要尋找的內容。要描述時間表,我們需要參考員工、角色、日期和班次:Emma 是否在週一晚班擔任補貨員?這可以使用布林變數來實現。布林變數是域為 的變數{0, 1}。
schedule = {e: |
函式 model.new_bool_var() 建立並返回一個布林變數,我們將其儲存在 schedule 中。在這個結構中,schedule["Emma"]["Restocker"]["Monday"]["Evening"]指的就是其中一個布林變數。如果 Emma 在週一晚班擔任補貨員,則該變數等於 1;如果 Emma 在週一晚班不擔任補貨員,則該變數等於 0。
我們的schedule變數目前不受約束。按照前面提出的問題描述並相應地約束這些變數,我們應該能夠得到一個滿足店主要求的時間表。
約束條件
讓我們來看看問題描述的其餘部分,以正確約束計劃變數。要在模型中新增新的約束條件,我們只需使用 model.add(...)。
任何時候都必須安排一名收銀員。
由於計劃表是由布林變數組成的,因此很容易理解我們如何透過求和這些變數的子集並對這些和施加限制:
for d in days: |
補貨]可以是任何班次,但兩個補貨班次不能先後安排。
由於前面的限制,我們已經知道兩個補貨班次不能在同一天進行。兩個補貨班次接連安排的唯一方法是,在一天的晚班上安排一個補貨班次,在第二天的早班上安排另一個補貨班次。透過強制規定每對晚班和早班的補貨班次總和不大於 1,我們可以確保這對晚班和早班最多隻能安排一次補貨班次:
for i in range(len(days)-1): |
同時具備兩種角色資格的員工,每班仍只能被指派擔任一種角色。
對於每個員工,所有日班對的所有分配角色總和要麼是 1(他們在該日班時段只擔任一個角色),要麼是 0(他們不在該日班時段工作):
for e in employees: |
有些員工可以勝任這兩種角色,但有些員工只能做收銀員或補貨員。
為了防止員工被分配到不符合條件的角色,我們只需將該員工的角色值匹配為 0(或者換一種說法,我們新增一個約束條件,斷言該值為 0):
for e in employees: |
員工每天工作時間不能超過 8 小時,即兩班倒。如果他們一天工作兩班,我們必須確保兩班之間沒有空閒時間--換句話說,我們不能安排他們在同一天的早班和晚班工作,因為他們在下午班會有 4 個小時的空閒時間。
事實證明,一個約束條件就能同時滿足這兩個要求:員工既可以上早班,也可以上晚班,或者兩班都不上:
for e in employees: |
請注意,上述約束不需要指定下午班次的任何內容。如果員工在上午工作,就不能在晚上工作,反之亦然。這樣既能確保員工每天最多工作兩班,又能確保沒有空閒時間,因為只有當員工同時上早班和晚班時才會有空閒時間。
現在問題的建模工作已經完成:
- 使用布林變數來表示員工是否在特定角色、班次和日期工作,來對這個問題進行建模。
- 演示瞭如何向模型新增各種約束,例如確保收銀員始終按時上班、限制工作時間以及滿足員工偏好。
- 它還引入了 CP 中的最佳化概念,展示瞭如何最小化分配給員工的最大和最小班次數之間的差異。
本文最後討論了不同的求解器狀態(最佳、不可行、可行、未知)及其在 CP 問題中的含義。
本文介紹為理解和使用 Python 和 CP-SAT 將約束規劃應用於實際最佳化問題奠定了基礎。
詳細點選標題