Được biên soạn bởi
- Vũ Lê Thế Anh (vltanh@illinois.edu)
nhằm phục vụ cho Trại hè Toán học và Ứng dụng PiMA 2024
Tham khảo
- Bài giảng và bài viết trên trang Facebook của trại hè
- Tài liệu của
scipy
- Bài giảng môn CS 473: Algorithms cho kì Spring 2023 của UIUC
Cập nhật 22:45 23/07/2024 (GMT +7)
import scipy.optimize as so
from scipy.optimize import linprog
import numpy as np
1. Sử dụng scipy và ví dụ¶
Thư viện scipy
có hỗ trợ sẵn một bộ giải cho các bài toán quy hoạch tuyến tính, cụ thể là hàm scipy.optimize.linprog
. Ta sẽ sử dụng thư viện này như một hộp đen để giải các bài toán quy hoạch tuyến tính. Cách sử dụng được mô tả rất chi tiết cùng ví dụ trong tài liệu của thư viện scipy
, cụ thể là ở đây.
1.1. Ví dụ¶
Hãy nhớ lại câu chuyện của anh nông dân Việt.
"Ở miền đồng bằng sông Chín Rồng xa xôi, anh Việt có một thửa ruộng rộng 8 ha để trồng lúa, ngô và đậu phộng (lạc). Theo kinh nghiệm của tổ tiên, để trồng được một tấn lúa thì phải có một diện tích đất là 3 ha, một tấn ngô sẽ chiếm 4 ha và một tấn đậu phộng đòi hỏi phải có 2 ha. Về nhu cầu phân bón cho cây, theo tính toán của anh, một tấn lúa, ngô và đậu phộng sẽ lần lượt cần một lượng phân bón là 400, 100 và 200 kg. Mặt khác lượng nước cần dùng để sản xuất cùng một khối lượng sản phẩm như trên (1 tấn) của lúa, ngô và đậu phộng lần lượt là 200, 300, 100 tấn nước. Do hạn chế của kỹ thuật và nguồn vốn gia đình, anh Việt chỉ có thể đầu tư 1.1 tấn phân bón và kênh đào của anh chỉ có thể vận chuyển tối đa 500 tấn nước vào ruộng.
Anh Việt cần lập kế hoạch trồng bao nhiêu tấn lúa, ngô và đậu phộng sao cho tổng số tiền bán nông sản là cao nhất, biết rằng một tấn lúa, ngô và đậu phộng có giá bán lần lượt là 5, 4, 3 triệu đồng?" - Trích bài viết trên fanpage PiMA
Cũng trong bài viết đó, vấn đề của anh Việt được mô hình hóa thành bài toán quy hoạch tuyến tính sau. $$ \begin{aligned} \max_{x, y, z} \quad & 5x + 4y + 3z \\ \textrm{s.t.} \quad & 3x + 4y + 2z \leq 8 \\ & 4x + y + 2z \leq 11 \\ & 2x + 3y + z \leq 5 \\ & x, y, z \geq 0 \end{aligned} $$
1.2. Tham số đầu vào¶
Nói tóm gọn, với $n, m_{ub}, m_{eq} \in \mathbb{N}$, $A_{ub} \in \mathbb{R}^{m_{ub} \times n}, b_{ub} \in \mathbb{R}^{m_{ub}}$, $A_{eq} \in \mathbb{R}^{m_{eq} \times n}, b_{eq} \in \mathbb{R}^{m_{eq}}$, $c, l, u \in \mathbb{R}^{n}$, hàm scipy.optimize.linprog
hỗ trợ giải quyết bài toán $$ \begin{aligned} \min_{x \in \mathbb{R}^n} \quad & c^T x \\ \textrm{s.t.} \quad & A_{ub} x \leq b_{ub} \\ & A_{eq} x = b_{eq} \\ & l \leq x \leq u \end{aligned}. $$
Ở đây, các (bất) đẳng thức đều được hiểu là theo từng phần tử của vector.
Lưu ý
- Bài toán đang xét là bài toán tối thiểu hóa ($\min c^T x$). Trong trường hợp muốn giải bài toán tối đa hóa ($\max c'^T x$), có thể đặt $c := -c'$ với $c' \in \mathbb{R}^n$.
- Các điều kiện bất đẳng thức đang xét là chiều $\leq$. Trong trường hợp có các điều kiện bất đẳng thức ngược chiều dạng $A' x \geq b' \Leftrightarrow - A' x \leq - b'$, có thể đặt $A := -A'$ và $b := -b'$ để kết hợp với các điều kiện thuận chiều.
- Trong trường hợp không có các điều kiện bất đẳng thức, ta coi như $A_{ub}$ và $b_{ub}$ không tồn tại. Ta có điều tương tự với $A_{eq}$ và $b_{eq}$ cho các điều kiện đẳng thức.
- Ta hoàn toàn có thể xem $l \leq x \leq u$ như các điều kiện bất đẳng thức. Thực ra, ta cũng hoàn toàn có thể xem $A_{eq} x = b_{eq}$ như các điều kiện bất đẳng thức ($A_{eq} x \leq b_{eq}$ và $-A_{eq} x \leq -b_{eq}$)
Hàm scipy.optimize.linprog
yêu cầu chủ yếu các tham số sau đây:
- Mảng 1 chiều
c
đại diện cho $c$ - Mảng 2 chiều
A_ub
đại diện cho $A_{ub}$. Đây là tham số không bắt buộc (ví dụ khi không có điều kiện bất đẳng thức). - Mảng 1 chiều
b_ub
đại diện cho $b_{ub}$. Đây là tham số không bắt buộc, nhưng phải có khi cóA_ub
. - Mảng 2 chiều
A_eq
đại diện cho $A_{eq}$. Đây là tham số không bắt buộc (ví dụ khi không có điều kiện đẳng thức). - Mảng 1 chiều
b_eq
đại diện cho $b_{eq}$. Đây là tham số không bắt buộc, nhưng phải có khi cóA_eq
. - Mảng 1 chiều các 2-tuple
bounds
dưới dạng[(l[i], u[i]) for i in range(len(c))]
vớil
vàu
là hai mảng 1 chiều đại diện lần lượt cho $l$ và $u$. Trong trường hợp biến không có cận ($l_i = -\infty$ hoặc $u_i = \infty$), ta dùngNone
. Ví dụ, điều kiện $x_i \geq 0$ (không có cận trên) được đại diện bởibounds[i] = (0, None)
; điều kiện $x_i \in \mathbb{R}$ (không có cả hai cận) được đại diện bởibounds[i] = (None, None)
. Đây là tham số không bắt buộc, được đặt mặc định là[(0, None) for i in range(len(c))]
đại diện cho điều kiện $x \geq 0$.
Ngoài ra, còn có một số tham số khác:
- Chuỗi (
str
)method
đại diện cho phương pháp muốn sử dụng. Các giá trị có thể của tham số này làhighs
(mặc định),highs-ds
,highs-ipm
,interior-point
(di sản),revised simplex
(di sản), andsimplex
(di sản). Các giá trị được đánh dấu là di sản sẽ không còn được hỗ trợ nữa ở các bảnscipy
mới hơn.
# Trước hết, ta cần tạo các tham số cần thiết để truyền vào hàm.
# Định nghĩa c
# Do đây là bài toán tối đa hóa, ta lấy phần đối của c
c = - np.array([
5,
4,
3,
])
# Định nghĩa A_ub và b_ub
A_ub = np.array([
[3, 4, 2],
[4, 1, 2],
[2, 3, 1],
])
b_ub = np.array([
8,
11,
5,
])
# Định nghĩa A_eq và b_eq
# Do bài toán không có điều kiện đẳng thức, ta không nhất thiết phải định nghĩa.
A_eq = None
b_eq = None
# Định nghĩa bounds
# Do các cận trùng với chế độ mặc định, ta không nhất thiết phải định nghĩa.
bounds = [
(0, None)
for i in range(len(c))
]
# Với đủ các tham số cần thiết, ta sử dụng hàm scipy.optimize.linprog
# để giải bài quy hoạch tuyến tính trên
result = linprog(
c=c,
A_ub=A_ub,
b_ub=b_ub,
A_eq=A_eq,
b_eq=b_eq,
bounds=bounds,
)
print(result)
message: Optimization terminated successfully. (HiGHS Status 7: Optimal) success: True status: 0 fun: -13.0 x: [ 2.000e+00 0.000e+00 1.000e+00] nit: 2 lower: residual: [ 2.000e+00 0.000e+00 1.000e+00] marginals: [ 0.000e+00 3.000e+00 0.000e+00] upper: residual: [ inf inf inf] marginals: [ 0.000e+00 0.000e+00 0.000e+00] eqlin: residual: [] marginals: [] ineqlin: residual: [ 0.000e+00 1.000e+00 0.000e+00] marginals: [-1.000e+00 -0.000e+00 -1.000e+00] mip_node_count: 0 mip_dual_bound: 0.0 mip_gap: 0.0
1.3. Kết quả trả về¶
Ta sử dụng biến result
để lưu kết quả trả về của hàm scipy.optimize.linprog
cho bài toán của chúng ta. Biến result
mang rất nhiều thông tin dưới dạng các thuộc tính (attribute). Trong đó, chúng ta quan tâm nhất đến các thuộc tính sau đây.
- Thuộc tính luận lý (
bool
)success
cho biết quá trình tối ưu có dừng thành công (True
) hay không (False
). Thuộc tínhstatus
phân loại cụ thể hơn kết quả bằng các số nguyên:0
tương ứng với thành công mỹ mãn,1
tương ứng với việc dừng do vượt quá số lần lặp tối đa,2
tương ứng với dự đoán là bài toàn không thể làm được (infeasible),3
tương ứng với dự đoán là bài toán không bị chặn (unbounded),4
tương ứng với việc quá trình chạy gặp phải các vấn đề tính toán số (numerical difficulties). Các kết quả mang tính "dự đoán" là do chỉ trong khả năng cho phép của cài đặt cụ thể này (có thể kết quả là một số rất lớn mà thuật toán chưa kịp tới được). Các thông tin này cũng được diễn giải bằng lời trong thuộc tính chuỗi (str
)message
. - Thuộc tính số thực (
float
)fun
cho biết giá trị tối ưu đạt được của hàm mục tiêu (đây là giá trị tối thiểu vì chúng ta đang xét bài toán tối thiểu hóa). - Thuộc tính mảng 1 chiều
x
cho biết nghiệm tối ưu (tức một bộ giá trị của các biến sao cho hàm mục tiêu đạt giá trị tối ưu).
Ta có thể truy cập các thuộc tính bằng cú pháp result.x
cho thuộc tính x
(ví dụ, result.success
).
if result.success:
print("Giá trị tối ưu:", result.fun)
print("Nghiệm tối ưu:", result.x)
else:
print("Lỗi:", result.message)
Giá trị tối ưu: -13.0 Nghiệm tối ưu: [2. 0. 1.]
Diễn giải kết quả trên, ta kết luận rằng anh Việt nên trồng 2 tấn lúa, không trồng ngô, và trồng 1 tấn đậu phộng để đạt số tiền thu được nhờ bán nông sản cao nhất là 13 triệu đồng. Kết quả này trùng khớp với kết quả nói đến trong bài viết.
2. Bài tập¶
Ở mục này, chúng ta ứng dụng những hiểu biết ở trên về cách sử dụng scipy.optimize.linprog
như một hộp đen giải bài toán quy hoạch tuyến tính vào việc giải quyết một số vấn đề thực tế.
Bài tập 1: Luồng cực đại¶
Ở bài tập này, ta xem xét bài toán luồng cực đại (maximum flow problem), là một trong những bài toán nổi tiếng trong lý thuyết đồ thị (graph theory). Bài toán này tuy cổ điển nhưng được áp dụng rất nhiều, không chỉ trong những vấn đề mà việc áp dụng là tự nhiên (Câu hỏi 1 và 2) mà còn trong những vấn đề mà thoạt nhìn qua không hề có liên kết gì cả (Câu hỏi 3).
Miễn trừ trách nhiệm Do lý thuyết đồ thị không nằm trong nội dung bài giảng của Trại hè, mục này sẽ phát biểu bài toán thông qua ví dụ hình tượng về hệ thống ống nước giữa các thành phố. Người đọc có kiến thức về đồ thị có thể dễ dàng kết nối các thực thể ví dụ như "thành phố" và "tuyến đường ống" với các khái niệm ví dụ như "đỉnh" và "cạnh" trong lý thuyết đồ thị. Nếu bạn có bất kì khúc mắc nào, hy vọng bạn thông cảm và đừng ngại liên hệ qua thông tin liên hệ ở trên.
Một siêu đô thị gồm nhiều thành phố được cấp phép xây dựng một hệ thống ống nước nhằm truyền tải nước từ một thành phố được xem là nguồn tới một thành phố được xem là đích.
Hệ thống ống nước gồm nhiều đường ống. Mỗi đường ống nối giữa hai thành phố khác nhau và dòng nước chỉ chảy theo một hướng xác định. Do việc xây dựng các đường ống được thực hiện bởi các đội khác nhau dưới các điều kiện khác nhau, mỗi đường ống có một dung tích khác nhau. Dung tích của một đường ống cho biết lưu lượng nước tối đa có thể chảy qua nó tại mọi thời điểm.
Tại mọi thời điểm, thành phố nguồn sẽ gửi một dòng chảy liên tục với lưu lượng cố định qua hệ thống ống nước trên để hướng đến thành phố đích. Tại mỗi thành phố tại mọi thời điểm, toàn bộ lưu lượng nước mà thành phố nhận từ các đường ống hướng vào sẽ lập tức được đưa chuyển tiếp vào các đường ống hướng ra.
Vấn đề đặt ra ở đây là: cho biết toàn bộ hệ thống ống nước, bao gồm định hướng dòng chảy và dung tích của từng tuyến đường ống, xác định lưu lượng nước tối đa mà thành phố nguồn có thể gửi đến thành phố đích.
Hình bên dưới (nguồn: Wiki) minh họa một siêu đô thị với $4$ thành phố và một hệ thống ống nước được xây dựng trên đó. Cụ thể, có $5$ đường ống: đường ống từ $s$ hướng tới $u$ có dung tích $10$, đường ống từ $s$ hướng tới $v$ có dung tích $5$, v.v. Đối với bài toán nhỏ này, ta có thể dễ dàng tìm được câu trả lời: lưu lượng nước tối đa mà thành phố nguồn có thể gửi là $15$, trong đó $5$ đơn vị sẽ đi $s \to v \to t$ và $10$ đơn vị sẽ đi $s \to u$ rồi tách ra $5$ đơn vị đi $u \to t$ và $5$ đơn vị đi $u \to v \to t$.
Tuy nhiên, với các hệ thống phức tạp hơn, làm sao để ta nhanh chóng tìm được câu trả lời? Trên thực tế, có nhiều thuật toán có thể giải quyết bài toán này một cách hiệu quả hơn (về mặt tài nguyên như bộ nhớ, thời gian chạy, v.v.). Tuy nhiên, ở đây, chúng ta cân nhắc phương pháp sử dụng quy hoạch tuyến tính.
Câu hỏi 0¶
Hãy mô hình hóa vấn đề trên thành bài toán quy hoạch tuyến tính. Cụ thể, hãy nêu và diễn giải ý nghĩa của các biến, hàm mục tiêu, và các điều kiện ràng buộc.
Lưu ý Do mục tiêu của bài giảng này là sử dụng scipy
để giải bài toán quy hoạch tuyến tính, các bạn được khuyến khích đọc đáp án của câu hỏi này nhằm thống nhất khái niệm cho các bài tập sau.
Gọi $n$ là số lượng thành phố. Ký hiệu $[n] = \{ 1, \dots, n \}$.
Gọi các thành phố lần lượt là $v_1, \dots, v_n$ (không nhất thiết phải theo thứ tự cụ thể nào). Không mất tính tổng quát, xét $v_1$ là thành phố nguồn và $v_n$ là thành phố đích.
Gọi $d_{i, j}$ với $i, j \in [n]$ là dung tích của tuyến đường ống nối giữa $v_i$ và $v_j$ nếu như đường ống tồn tại và hướng từ $v_i$ tới $v_j$, và bằng $0$ trong các trường hợp khác. Đây là các giá trị mà ta có từ đầu. Gọi $E = \{ (i, j) \in [n]^2: d_{i, j} > 0 \vee d_{j, i} > 0 \}$. Nói cách khác, $E$ chứa toàn bộ cặp $(i, j)$ sao cho có đường ống tồn tại giữa $v_i$ và $v_j$ (bất kể hướng).
Ta ký hiệu $f_{i, j}$ với $i, j \in [n]$ là lưu lượng nước chuyển từ $v_i$ tới $v_j$. Đây là biến trong bài toán tối ưu.
Ở đây, chúng ta muốn tối ưu lưu lượng nước gửi được từ nguồn. Do đó, hàm mục tiêu là $$ \sum_{j \in [n]} f_{1, j}. $$
Lưu lượng nước không thể âm. Do đó, ta có các điều kiện $$ f_{i, j} \geq 0, \forall i, j \in [n]. $$
Lưu lượng nước trên một đường ống bất kỳ không thể vượt quá dung tích của nó. Do đó, ta có các điều kiện $$ f_{i, j} \leq d_{i, j}, \forall i, j \in [n]. $$
Nếu chỉ dừng ở các điều kiện này, dễ thấy là nghiệm tối ưu sẽ đơn giản là đặt $f_{i, j} = d_{i, j}, \forall i, j \in [n]$. Tuy nhiên, điều này không phản ánh đúng thực tế. Thứ còn thiếu ở đây là các điều kiện về bảo toàn lưu lượng: với mỗi thành phố không phải nguồn hay đích, lưu lượng nước đi vào một thành phố phải bằng lưu lượng nước đi ra khỏi thành phố đó. $$ \sum_{i \in [n]: (i, j) \in E} f_{i, j} = \sum_{k \in [n]: (j, k) \in E} f_{j, k}, \forall j \in [n] \setminus \{1, n\}. $$
Tổng kết lại, ta có bài toán quy hoạch tuyến tính $$ \begin{aligned} \max_{\{f_{i, j}: i, j \in [n] \}} \quad & \sum_{j \in [n]} f_{1, j} \\ \textrm{s.t.} \quad & \sum_{i \in [n]:(i, j) \in E} f_{i, j} = \sum_{k \in [n]: (j, k) \in E} f_{j, k}, \forall j \in [n] \setminus \{1, n\} \\ & 0 \leq f_{i, j} \leq d_{i, j}, \forall i, j \in [n] \end{aligned}. $$
Câu hỏi 1¶
Hãy viết hàm để giải bài toán quy hoạch tuyến tính trên bằng scipy
. Khung mẫu hàm đã được cho sẵn.
Đáp án Câu hỏi 1¶
Vấn đề quan trọng của việc sử dụng thư viện như một hộp đen đó chính là xác định được các tham số truyền vào hàm.
Lấy ví dụ trong tình huống này, các biến trong bài toán tối ưu của chúng ta đang tạo thành một ma trận (mảng 2 chiều). Trong khi đó, các biến trong bài toán quy hoạch tuyến tính tiêu chuẩn lại mang ý nghĩa của một vector (mảng 1 chiều)! Do đó, chúng ta phải có được một cách nhất quán để chuyển đổi qua lại giữa chỉ số 2 chiều [i, j]
và chỉ số 1 chiều [k]
. Một cách có thể là [i, j]
ứng với [i * n + j]
và ngược lại [k]
ứng với [k // n, k % n]
.
def max_flow_sol(d):
"""
Hàm tìm ra lưu lượng nước tối đa mà thành phố nguồn có thể gửi
và lưu lượng nước ở từng thành phố ứng với lưu lượng tối đa này.
Đầu vào:
d (mảng 2 chiều):
dung tích của các tuyến đường ống
Đầu ra:
best (số thực):
lưu lượng nước tối đa mà thành phố nguồn có thể gửi
f (mảng 2 chiều):
lưu lượng nước của từng thành phố ứng với lưu lượng tối đa
"""
n, _ = d.shape
# Định nghĩa hàm mục tiêu
c = np.zeros(n * n)
for j in range(n):
c[0 * n + j] = 1
c = -c
# Định nghĩa các điều kiện bất đẳng thức
A_ub = None
b_ub = None
# Định nghĩa các điều kiện đẳng thức
A_eq = np.zeros((n, n * n))
for j in range(1, n - 1):
for i in range(n):
if d[i, j] > 0 or d[j, i] > 0:
A_eq[j, i * n + j] = 1
for k in range(n):
if d[j, k] > 0 or d[k, j] > 0:
A_eq[j, j * n + k] = -1
b_eq = np.zeros(n)
# Định nghĩa cận
bounds = []
for k in range(n * n):
i = k // n
j = k % n
bounds.append(
(0, d[i, j])
)
# Sử dụng scipy.optimize.linprog
result = linprog(
c=c,
A_ub=A_ub,
b_ub=b_ub,
A_eq=A_eq,
b_eq=b_eq,
bounds=bounds,
)
# Chuyển đổi từ nghiệm sang lưu lượng
best = -result.fun
f = np.zeros((n, n))
for i in range(n):
for j in range(n):
f[i, j] = result.x[i * n + j]
return best, f
d = np.array([
[0, 10, 5, 0],
[0, 0, 15, 5],
[0, 0, 0, 10],
[0, 0, 0, 0],
])
best, f = max_flow_sol(d)
print("Lưu lượng nước tối đa:", best)
print("Lưu lượng nước ở từng thành phố:")
print(f)
assert best == 15, \
"Kết quả trả về không đúng"
assert np.allclose(
f,
np.array([
[0, 10, 5, 0],
[0, 0, 5, 5],
[0, 0, 0, 10],
[0, 0, 0, 0],
])
), \
"Kết quả trả về không đúng"
Lưu lượng nước tối đa: 15.0 Lưu lượng nước ở từng thành phố: [[ 0. 10. 5. 0.] [ 0. 0. 5. 5.] [ 0. 0. 0. 10.] [ 0. 0. 0. 0.]]
Câu hỏi 2¶
Ở câu hỏi này, chúng ta tìm hiểu một ứng dụng khá tự nhiên của bài toán luồng cực đại. Một điều thú vị là vấn đề này vô cùng thực tế, vì nó đã thực sự được áp dụng để làm điều mà nó đang mô tả. Để mà nói chính xác thì mô hình cuối cùng phức tạp hơn (và sẽ được mô tả trong phần sau), nhưng tinh thần của ý tưởng vẫn ở đó.
Nói đến đây, có một điểm cần lưu ý mà nãy giờ chưa được nhắc đến. Ta có định lý sau, nói theo cách bình dân: nếu dung tích của tất cả các đường ống là số nguyên thì lưu lượng lớn nhất tìm được cũng là một số nguyên! Không những thế, các giá trị lưu lượng trên mọi đường ống ứng với lưu lượng lớn nhất này cũng đều là số nguyên. Chứng minh của định lý này phụ thuộc vào một thuật toán để giải quyết bài toán luồng cực đại, do đó không nằm trong khuôn khổ của bài giảng này.
Tại PiMA 2024, trước trại hè, các trại sinh được đưa cho một danh sách các mô tả dự án nhóm và được yêu cầu điền nguyện vọng của mình. Mỗi bạn trại sinh sẽ có một tập các dự án mà bạn yêu thích muốn nhận. Mỗi dự án nhóm chỉ được giao tối đa cho $k$ bạn. Vấn đề đặt ra cho BTC là làm sao để phân nhóm dự án cho tất cả các bạn trại sinh này.
Lưu ý Dễ thấy là không phải lúc nào cũng tồn tại cách phân chia cho tất cả các bạn trại sinh. Lấy ví dụ đơn giản chỉ có 2 bạn trại sinh đều chỉ thích 1 dự án mà chỉ nhận 1 người. Ngoài ra, còn rất nhiều tiêu chí khác mà để thỏa mãn được thì cần phải mở rộng yêu cầu bài toán này. Ta sẽ xem xét nó ở phần sau (quy hoạch tuyến tính nguyên).
Đáp án Câu hỏi 2¶
Ta có thể giải quyết bài toán này bằng cách đưa nó về bài toán luồng cực đại. Có thể hình dung ý tưởng như sau. Mỗi bạn trại sinh được "cấp" cho tối đa $1$ đơn vị để "cấp" cho dự án mình yêu thích. Tuy nhiên, mỗi dự án chỉ có thể được "cấp" tối đa $k$ lựa chọn.
Xét một hệ thống ống nước như sau.
Gọi mỗi bạn trại sinh là một thành phố loại I. Gọi mỗi dự án là một thành phố loại II. Ở đây không có sự khác biệt gì giữa hai loại thành phố trừ nguồn gốc của nó. Chúng ta thêm vào 1 thành phố nguồn và 1 thành phố đích.
Với mỗi thành phố loại I, ta xây dựng đường ống hướng từ thành phố nguồn tới vào thành phố đang xét. Bên cạnh đó, với mỗi thành phố loại II, ta xây dựng đường ống hướng từ thành phố đang xét tới thành phố đích.
Với mỗi bạn trại sinh, ứng với một thành phố loại 1, ta xây dựng đường ống hướng từ thành phố loại I này tới 2 thành phố loại II ứng với dự án mà bạn trại sinh đang xét yêu thích.
Ta có 3 loại đường ống: loại A nối giữa thành phố nguồn và thành phố loại I, loại B nối giữa một thành phố loại I và một thành phố loại II, loại C nối giữa thành phố loại II và thành phố đích. Đặt dung tích của mỗi đường ống loại A và loại B là $1$. Đặt dung tích của mỗi đường ống loại C là $k$.
Ta tìm lưu lượng lớn nhất cũng như là giá trị lưu lượng của từng đường ống lúc đó cho hệ thống ống nước trên. Ta có thể chuyển kết quả này thành giải pháp cho bài toán gốc như sau.
Với mỗi đường ống loại C, lưu lượng của nó chính là số trại sinh được nhận dự án tương ứng. Điều này hợp lệ nhờ vào định lý kết quả nguyên ở trên. Dựa vào điều kiện lưu lượng không vượt quá dung tích, ta cũng đảm bảo rằng mỗi dự án chỉ được giao cho $k$ trại sinh.
Cũng dựa vào định lý kết quả nguyên ở trên, lưu lượng đi qua mỗi đường ống loại A hoặc loại B là $0$ hoặc $1$. Với mỗi đường ống loại B, nếu nó bằng $1$, dự án tương ứng với thành phố loại II sẽ được giao cho bạn trại sinh ứng với thành phố loại I của đường ống đang xét. Ngược lại, dự án này không được giao cho bạn trại sinh. Đây là một cách diễn giải hợp lệ do các đường ống loại A chỉ có lưu lượng tối đa là $1$, và do đó không thể có nhiều hơn một đường ống loại B tương ứng (có chung thành phố loại I) với lưu lượng $1$.
Cuối cùng, với mỗi đường ống loại A, nếu lưu lượng của nó là $1$ thì bạn trại sinh tương ứng được giao cho dự án; ngược lại, chúng ta không giao được dự án nào cho bạn trại sinh này. Như vậy, lưu lượng tối ưu chính là số lượng tối đa các bạn trại sinh mà ta có thể giao dự án được. Nếu số lượng này bé hơn số lượng trại sinh, ta không có cách phân chia cho tất cả trại sinh.
def project_assignment_sol(m, k, preference):
'''
Hàm tìm cách sắp xếp dự án cho các bạn trại sinh.
Đầu vào:
m (số nguyên):
số lượng dự án. Các dự án được đánh số từ 0 đến m - 1.
k (số nguyên):
giới hạn số trại sinh cho 1 dự án
preference (danh sách các danh sách):
với mỗi trại sinh, lưu danh sách các dự án mà trại sinh thích
Đầu ra:
is_able (luận lý):
liệu có cách sắp xếp cho mọi trại sinh hay không
assignment (danh sách các số nguyên):
chỉ số của dự án mà mỗi trại sinh được giao
'''
# Số lượng thành phố loại I
n = len(preference)
# Các thành phố bao gồm thành phố nguồn,
# các thành phố loại I, loại II,
# và thành phố đích
n_v = 1 + n + m + 1
# Khởi tạo dung tích hệ thống ống nước
# bằng 0 cho mọi đường ống
d = np.zeros((n_v, n_v))
# Đối với các đường ống loại A
for i in range(n):
d[0, 1 + i] = 1
# Đối với các đường ống loại B
for i, preferred_projects in enumerate(preference):
for j in preferred_projects:
d[1 + i, 1 + n + j] = 1
# Đối với các đường ống loại C
for i in range(m):
d[1 + n + i, n_v - 1] = k
# Tìm lưu lượng cực đại và giá trị
# lưu lượng của mỗi đường ống cho
# hệ thống ống nước trên
best, f = max_flow_sol(d)
# Chuyển đổi từ lưu lượng sang giải pháp
is_able = (best == n)
assignment = [None for _ in range(n)]
for i in range(n):
for j in range(m):
if f[1 + i, 1 + n + j] == 1:
assignment[i] = j
return is_able, assignment
print("== Test 1")
m = 6
k = 3
preference = [
[2, 4],
[1, 3, 4, 5],
[2, 3, 5],
[1, 3, 4],
[0, 1, 2, 3, 5],
[0, 2, 3, 4],
[0, 1, 2, 3],
[1, 4, 5],
[1, 3, 4],
[3, 4, 5],
[1, 2, 3],
[1],
[2, 3, 4, 5],
[2, 3, 4, 5],
[1, 2, 3, 4, 5],
]
test = (m, k, preference)
is_able, assignment = project_assignment_sol(*test)
print("Tính khả thi:", is_able)
print("Kết quả:")
print(assignment)
print("Diễn giải:")
assignment_dict = dict()
unassigned = set()
for i, j in enumerate(assignment):
if j is None:
unassigned.add(i)
else:
assignment_dict.setdefault(j, []).append(i)
for i in range(m):
print(f"Dự án {i}: {assignment_dict.get(i, [])}")
print("Các trại sinh không được giao dự án:", unassigned)
assert is_able, \
"Kết quả trả về không đúng"
count = dict()
for i, j in enumerate(assignment):
assert j in preference[i], \
"Kết quả trả về không đúng"
count.setdefault(j, 0)
count[j] += 1
assert count[j] <= k, \
"Kết quả trả về không đúng"
print("== Test 1: thành công")
print()
print("== Test 2")
m = 2
k = 1
preference = [
[1],
[1],
]
test = (m, k, preference)
is_able, assignment = project_assignment_sol(*test)
print("Tính khả thi:", is_able)
print("Kết quả:")
print(assignment)
print("Diễn giải:")
assignment_dict = dict()
unassigned = set()
for i, j in enumerate(assignment):
if j is None:
unassigned.add(i)
else:
assignment_dict.setdefault(j, []).append(i)
for i in range(m):
print(f"Dự án {i}: {assignment_dict.get(i, [])}")
print("Các trại sinh không được giao dự án:", unassigned)
assert not is_able, \
"Kết quả trả về không đúng"
count = dict()
for i, j in enumerate(assignment):
if i not in preference:
continue
assert j in preference[i], \
"Kết quả trả về không đúng"
count.setdefault(j, 0)
count[j] += 1
assert count[j] <= k, \
"Kết quả trả về không đúng"
print("== Test 2: thành công")
== Test 1 Tính khả thi: True Kết quả: [4, 5, 2, 1, 0, 0, 0, 1, 3, 4, 3, 1, 5, 3, 5] Diễn giải: Dự án 0: [4, 5, 6] Dự án 1: [3, 7, 11] Dự án 2: [2] Dự án 3: [8, 10, 13] Dự án 4: [0, 9] Dự án 5: [1, 12, 14] Các trại sinh không được giao dự án: set() == Test 1: thành công == Test 2 Tính khả thi: False Kết quả: [1, None] Diễn giải: Dự án 0: [] Dự án 1: [0] Các trại sinh không được giao dự án: {1} == Test 2: thành công
Câu hỏi 3¶
Ở câu hỏi này, chúng ta sẽ áp dụng bài toán luồng cực đại vào một vấn đề mà không hiển nhiên ngay lập tức là có liên hệ gì với luồng cả!
Tại buổi tiệc họp mặt cựu trại sinh PiMA, các bạn được tham dự một buổi buffet đứng để giao lưu nói chuyện. Hai bạn cựu trại sinh bất kì trong buổi tiệc có thể quen biết nhau từ trước hoặc không.
Mỗi bạn cựu trại sinh sẽ muốn trò chuyện với tất cả người quen để cập nhật tình hình lẫn nhau, nhưng lại ngại chủ động tiến tới quá nhiều lần. Cụ thể hơn, mỗi bạn cựu trại sinh chỉ muốn chủ động tiến tới bắt chuyện với tối đa $k$ người. Tuy nhiên, nếu có ai chủ động đến bắt chuyện với họ thì họ luôn sẵn sàng nói chuyện!
Là Ban Tổ Chức buổi họp mặt, biết rõ những bạn nào đã quen biết nhau từ trước và giới hạn $k$ này, bạn tự hỏi liệu có cách sắp xếp thế nào để ai cũng có thể nói chuyện được với người quen hay không. Hãy lập trình để tìm ra cách nhé!
Đáp án Câu hỏi 3¶
Một cách bất ngờ (ít nhất là đối với người viết), chúng ta có thể chuyển bài toán này thành một bài toán luồng cực đại.
Xét một hệ thống ống nước như sau.
Gọi mỗi cặp người quen là một thành phố loại I. Gọi mỗi cựu trại sinh là một thành phố loại II. Ở đây không có sự khác biệt gì giữa hai loại thành phố trừ nguồn gốc của nó. Chúng ta thêm vào 1 thành phố nguồn và 1 thành phố đích.
Với mỗi thành phố loại I, ta xây dựng đường ống hướng từ thành phố nguồn tới vào thành phố đang xét. Bên cạnh đó, với mỗi thành phố loại II, ta xây dựng đường ống hướng từ thành phố đang xét tới thành phố đích.
Với mỗi cặp người quen, ứng với một thành phố loại 1, ta xây dựng đường ống hướng từ thành phố loại I này tới 2 thành phố loại II ứng với hai người trong cặp đang xét. Như vậy, mỗi thành phố loại I nối với đúng 2 thành phố loại II.
Ta có 3 loại đường ống: loại A nối giữa thành phố nguồn và thành phố loại I, loại B nối giữa một thành phố loại I và một thành phố loại II, loại C nối giữa thành phố loại II và thành phố đích. Đặt dung tích của mỗi đường ống loại A và loại B là $1$. Đặt dung tích của mỗi đường ống loại C là $k$.
Ta tìm lưu lượng lớn nhất cũng như là lưu lượng của từng đường ống ứng với lưu lượng lớn nhất cho hệ thống ống nước trên. Ta có thể chuyển kết quả này thành giải pháp cho bài toán gốc như sau.
Với mỗi đường ống loại C, lưu lượng của nó chính là số lần bạn cựu trại sinh tương ứng với thành phố loại II của đường ống đang xét phải chủ động. Điều này hợp lệ nhờ vào định lý kết quả nguyên ở trên. Dựa vào điều kiện lưu lượng không vượt quá dung tích, ta cũng đảm bảo rằng mỗi bạn cựu trại sinh không chủ động quá $k$ lần.
Cũng dựa vào định lý kết quả nguyên ở trên, lưu lượng đi qua mỗi đường ống loại A hoặc loại B là $0$ hoặc $1$. Với mỗi đường ống loại B, nếu nó bằng $1$, bạn cựu trại sinh ứng với thành phố loại II chính là người chủ động bắt chuyện đối với cặp người quen ứng với thành phố loại I của đường ống đang xét. Ngược lại, bạn cựu trại sinh còn lại trong cặp người quen là người chủ động. Đây là một cách diễn giải hợp lệ do các đường ống loại A chỉ có lưu lượng tối đa là $1$, và do đó không thể có cả hai đường ống loại B tương ứng (có chung thành phố loại I) với lưu lượng $1$.
Cuối cùng, với mỗi đường ống loại A, nếu lưu lượng của nó là $1$ thì cặp người quen tương ứng xác định được ai là người sẽ chủ động. Như vậy, giá trị của luồng (hàm mục tiêu) chính là số lượng cặp người quen ta sắp xếp được. Nếu số lượng này bé hơn số lượng cặp người quen, ta không có cách điều phối khả dĩ.
def party_arrange_sol(n, k, friends):
'''
Hàm tìm cách sắp xếp cho các bạn cựu trại sinh nói chuyện với nhau
trong buổi tiệc.
Đầu vào:
n (số nguyên):
số lượng cựu trại sinh
k (số nguyên):
giới hạn số lần bắt chuyện
friends (tập hợp):
tập hợp các cặp người quen
Đầu ra:
is_able (luận lý):
liệu có cách sắp xếp hay không
arrangement (tập hợp):
các cặp người quen được sắp xếp tối đa
'''
# Số lượng thành phố loại I
m = len(friends)
# Các thành phố bao gồm thành phố nguồn,
# các thành phố loại I, loại II,
# và thành phố đích
n_v = 1 + m + n + 1
d = np.zeros((n_v, n_v))
for i in range(m):
d[0, 1 + i] = 1
for t, (i, j) in enumerate(friends):
d[1 + t, 1 + m + i] = 1
d[1 + t, 1 + m + j] = 1
for i in range(n):
d[1 + m + i, n_v - 1] = k
best, f = max_flow_sol(d)
is_able = (best == m)
arrangement = []
for t, (i, j) in enumerate(friends):
if f[1 + t, 1 + m + i] == 1:
arrangement.append((i, j))
elif f[1 + t, 1 + m + j] == 1:
arrangement.append((j, i))
return is_able, arrangement
print("== Test 1")
n = 5
k = 2
friends = [
(0, 1), (0, 2),
(1, 2), (1, 3), (1, 4),
(2, 3), (2, 4),
(3, 4),
]
test = (n, k, friends)
is_able, arrangement = party_arrange_sol(*test)
print("Tính khả thi:", is_able)
print("Kết quả:")
print(arrangement)
assert is_able, \
"Kết quả trả về không đúng"
count = dict()
for i, j in arrangement:
count.setdefault(i, 0)
count[i] += 1
assert count[i] <= k, \
"Kết quả trả về không đúng"
print("== Test 1: thành công")
print()
print("== Test 2")
n = 5
k = 1
friends = [
(0, 1), (0, 2),
(1, 2), (1, 3), (1, 4),
(2, 3), (2, 4),
(3, 4),
]
test = (n, k, friends)
is_able, arrangement = party_arrange_sol(*test)
print("Tính khả thi:", is_able)
print("Kết quả:")
print(arrangement)
assert not is_able, \
"Kết quả trả về không đúng"
count = dict()
for i, j in arrangement:
count.setdefault(i, 0)
count[i] += 1
assert count[i] <= k, \
"Kết quả trả về không đúng"
print("== Test 2: thành công")
== Test 1 Tính khả thi: True Kết quả: [(0, 1), (0, 2), (2, 1), (3, 1), (1, 4), (3, 2), (2, 4), (4, 3)] == Test 1: thành công == Test 2 Tính khả thi: False Kết quả: [(0, 1), (1, 3), (2, 3), (4, 2), (3, 4)] == Test 2: thành công
Bạn đọc tinh ý sẽ nhận ra đây chính là bài toán xếp dự án PiMA ở trên!
Xét một bài toán xếp dự án nhóm như sau.
Mỗi cặp người quen là một "trại sinh". Mỗi bạn cựu trại sinh là một "dự án". Mỗi "trại sinh" (cặp người quen) chỉ có thể "yêu thích" 2 "dự án" (bạn cựu trại sinh) tương ứng. "Dự án" (bạn cựu trại sinh) được chọn tương ứng là người sẽ chủ động bắt chuyện. Do đó, việc mỗi "dự án" (bạn cựu trại sinh) chỉ có thể được giao cho tối đa $k$ "trại sinh" (cặp người quen) đồng nghĩa với việc mỗi bạn cựu trại sinh chỉ cần chủ động bắt chuyện với tối đa $k$ người quen.
Ta có thể chuyển kết quả xếp dự án thành giải pháp như sau. Nếu việc xếp dự án không thành công, việc sắp xếp nói chuyện cũng bất thành. Bất kể thành công hay không, mỗi "dự án" (cựu trại sinh) được giao cho "trại sinh" (cặp người quen) tương ứng với người sẽ chủ động bắt chuyện trong cặp người quen đó.
def party_arrange_sol2(n, k, friends):
'''
Hàm tìm cách sắp xếp cho các bạn cựu trại sinh nói chuyện với nhau
trong buổi tiệc.
Đầu vào:
n (số nguyên):
số lượng cựu trại sinh
k (số nguyên):
giới hạn số lần bắt chuyện
friends (tập hợp):
tập hợp các cặp người quen
Đầu ra:
is_able (luận lý):
liệu có cách sắp xếp hay không
arrangement (tập hợp):
các cặp người quen được sắp xếp tối đa
'''
# Số lượng "dự án" = số lượng cặp người quen
m = len(friends)
# Giới hạn số "trại sinh" cho mỗi "dự án"
# = giới hạn số lần bắt chuyện
k = k
# Mỗi "trại sinh" chỉ "yêu thích" 2 "dự án"
preference = [
[i, j]
for i, j in friends
]
# Giải bài toán xếp dự án
is_able_p, assignment_p = project_assignment_sol(m, k, preference)
print(assignment_p)
# Chuyển kết quả thành giải pháp
is_able = is_able_p
arrangement = []
for i, j in enumerate(assignment_p):
if j is None:
continue
u, v = friends[i]
if j == u:
arrangement.append((u, v))
else:
arrangement.append((v, u))
return is_able, arrangement
print("== Test 1")
n = 5
k = 2
friends = [
(0, 1), (0, 2),
(1, 2), (1, 3), (1, 4),
(2, 3), (2, 4),
(3, 4),
]
test = (n, k, friends)
is_able, arrangement = party_arrange_sol(*test)
print("Tính khả thi:", is_able)
print("Kết quả:")
print(arrangement)
assert is_able, \
"Kết quả trả về không đúng"
count = dict()
for i, j in arrangement:
count.setdefault(i, 0)
count[i] += 1
assert count[i] <= k, \
"Kết quả trả về không đúng"
print("== Test 1: thành công")
print()
print("== Test 2")
n = 5
k = 1
friends = [
(0, 1), (0, 2),
(1, 2), (1, 3), (1, 4),
(2, 3), (2, 4),
(3, 4),
]
test = (n, k, friends)
is_able, arrangement = party_arrange_sol(*test)
print("Tính khả thi:", is_able)
print("Kết quả:")
print(arrangement)
assert not is_able, \
"Kết quả trả về không đúng"
count = dict()
for i, j in arrangement:
count.setdefault(i, 0)
count[i] += 1
assert count[i] <= k, \
"Kết quả trả về không đúng"
print("== Test 2: thành công")
== Test 1 Tính khả thi: True Kết quả: [(0, 1), (0, 2), (2, 1), (3, 1), (1, 4), (3, 2), (2, 4), (4, 3)] == Test 1: thành công == Test 2 Tính khả thi: False Kết quả: [(0, 1), (1, 3), (2, 3), (4, 2), (3, 4)] == Test 2: thành công
Bài tập 2: Lập lịch¶
Ở bài tập này, ta xem xét một số ứng dụng của quy hoạch tuyến tính vào việc lập lịch.
Ban Tổ Chức PiMA (BTC) muốn in một số lượng lớn ấn phẩm truyền thông cho trại sinh. BTC tìm đến một nhà in có tiếng trong thành phố. Do quá nổi tiếng, nhà in chỉ còn lại $3$ máy in còn trống.
Nhà in báo rằng một ấn phẩm muốn được in sẽ tiêu tốn một lượng điện năng xác định tại mọi thời điểm trong một lượng thời gian xác định. Tuy nhiên, việc in ấn một ấn phẩm có thể được thực hiện trên bất kì máy nào. Đồng thời, nhà in cũng có thể tạm dừng và tiếp tục việc in ấn bất kì ấn phẩm nào ở trên cùng máy hoặc máy khác bất kì lúc nào cho đến khi hoàn thành. Tuy nhiên, tại mọi thời điểm, tổng lượng điện năng tiêu thụ của cả ba máy đều không thể vượt quá một mức điện năng tối đa.
Nhà in dự định cứ in lần lượt từng ấn phẩm, trống máy nào lại bỏ thêm vào máy đó trong giới hạn điện năng cho phép. Là ban tổ chức một trại hè Toán ứng dụng, BTC nhanh chóng nhận ra đây không phải là cách tối ưu thời gian. Vấn đề đặt ra ở đây là: cho biết lượng điện tiêu thụ và thời gian cần để in hoàn thành từng ấn phẩm và mức điện năng tối đa, làm thế nào để tìm một lịch trình in phù hợp và ngắn nhất?
Ví dụ
PiMA cần in $5$ ấn phẩm, mỗi ấn phẩm cần in hết $8.5, 9, 4, 3.5, 2$ giờ. Mặt khác, tại mỗi thời điểm, in $5$ ấn phẩm này sẽ tốn lần lượt một lượng điện năng là $10, 20, 60, 80, 90$ đơn vị. Tại mọi thời điểm, mức điện năng tối đa là $100$ đơn vị.
Một lịch trình khả dĩ tốn $12$ giờ:
- Làm tác vụ $1$ và $5$ trong $2$ giờ đầu (hoàn thành tác vụ $5$)
- Làm tác vụ $2$ và $4$ trong $3.5$ giờ tiếp theo (hoàn thành tác vụ $4$)
- Làm tác vụ $1$, $2$, và $3$ trong $4$ giờ tiếp theo (hoàn thành tác vụ $3$)
- Làm tác vụ $1$ và $2$ trong $1.5$ giờ tiếp theo (hoàn thành tác vụ $2$)
- Làm tác vụ $1$ trong $1$ giờ tiếp theo (hoàn thành tác vụ $1$)
Câu hỏi 0¶
Mô hình hóa vấn đề trên thành bài toán quy hoạch tuyến tính.
Gọi $n$ là số lượng ấn phẩm cần in. Đặt $[n] = \{ 1, \dots, n \}$ và $[0, n] = 0 \cup [n]$.
Gọi $h_i, i \in n$ là thời gian cần để in ấn phẩm thứ $i$. Gọi $p_i, i \in [n]$ là lượng điện năng tiêu thụ trong lúc in ấn phẩm thứ $i$. Gọi $P$ là mức điện năng tiêu thụ tối đa tại mọi thời điểm.
Ý tưởng ở đây là định nghĩa một lịch trình bằng các khung thời gian mà từng ấn phẩm, hoặc cặp ấn phẩm, hoặc bộ ba ấn phẩm được in cùng nhau.
Ta định nghĩa một bộ biến $d_{i, j, k}$ với $i, j, k \in [0, n]$ là lượng thời gian mà đồng thời ấn phẩm $i$ được in trên máy $1$, ấn phẩm $j$ được in trên máy $2$ và ấn phẩm $k$ được in trên máy $3$. Nếu $i, j,$ hoặc $k$ bằng $0$ thì xem như không có ấn phẩm nào được in trên các máy tương ứng.
Do ta muốn tối thiểu hóa thời gian in, hàm mục tiêu chính là $$ \begin{align*} \sum_{i, j, k \in [0, n]} d_{i, j, k} \end{align*} $$
Tất cả thời gian in nêu trên đều không âm. Do đó, ta có các điều kiện $$ d_{i, j, k} \geq 0, \forall i, j, k \in [0, n] $$
Một ấn phẩm không được in trên nhiều hơn $1$ máy cùng một lúc. Do đó, ta có các điều kiện $$ d_{i, i, k} = d_{i, j, j} = d_{i, j, i} = 0, \forall i, j, k \in [n] $$
Tại mọi thời điểm, lượng điện năng tiêu thụ không được vượt quá ngưỡng cho phép. Do đó, ta có các điều kiện $$ d_{i, j, k} = 0, \forall i, j, k \in [0, n] \text{ s.t. } \sum_{i, j, k \in [0, n]} p_i + p_j + p_k > P $$ với $p_0 = 0$. Nói cách khác, ta sẽ không thực hiện bất kì việc in cùng lúc các ấn phẩm nào tiêu tốn nhiều điện năng hơn cho phép.
Tất cả ấn phẩm đều phải được hoàn thành. Do đó, ta có các điều kiện $$ \sum_{j, k \in [0, n]} (d_{i, j, k} + d_{j, i, k} + d_{j, k, i}) = h_i, \forall i \in [n] $$
Tóm lại, bài toán quy hoạch tuyến tính của chúng ta là $$ \begin{aligned} \min_{d_{i, j, k}: i, j, k \in [0, n]} \quad & \sum_{i, j, k \in [0, n]} d_{i, j, k} \\ \textrm{s.t.} \quad & d_{i, i, k} = d_{i, j, j} = d_{i, j, i} = 0, \forall i, j, k \in [n] \\ & d_{i, j, k} = 0, \forall i, j, k \in [0, n] \text{ s.t. } \sum_{i, j, k \in [0, n]} p_i + p_j + p_k > P \\ & \sum_{j, k \in [0, n]} (d_{i, j, k} + d_{j, i, k} + d_{j, k, i}) = h_i, \forall i \in [n] \\ & d_{i, j, k} \geq 0, \forall i, j, k \in [0, n] \end{aligned} $$
Câu hỏi 1¶
Cài đặt sử dụng scipy.optimize.linprog
để giải quyết bài toán quy hoạch tuyến tính trên.
Đáp án Câu hỏi 1¶
def schedule(p, h, P):
nt = len(p)
n = (nt + 1) ** 3
c = np.ones(n)
A_eq = np.zeros((nt, n))
for ii in range(nt):
ai = np.zeros((nt + 1, nt + 1, nt + 1))
for i in range(nt + 1):
for j in range(nt + 1):
for k in range(nt + 1):
if i == (ii + 1) or j == (ii + 1) or k == (ii + 1):
ai[i, j, k] = 1
A_eq[ii] = ai.reshape(-1)
b_eq = h.copy()
l = np.zeros(n)
u = np.full((nt + 1, nt + 1, nt + 1), -1)
for i in range(1, nt + 1):
for j in range(1, nt + 1):
for k in range(1, nt + 1):
if i == j or j == k or i == k:
u[i, j, k] = 0
p_aux = p.copy()
p_aux.insert(0, 0)
for i in range(nt + 1):
for j in range(nt + 1):
for k in range(nt + 1):
if p_aux[i] + p_aux[j] + p_aux[k] > P:
u[i, j, k] = 0
u = u.reshape(-1)
bounds = [
(
0,
None if u[i] == -1 else u[i]
)
for i in range(n)
]
result = linprog(
c=c,
A_eq=A_eq,
b_eq=b_eq,
bounds=bounds,
)
return result.fun, result.x
p = [10, 20, 60, 80, 90]
h = [8.5, 9, 4, 3.5, 2]
P = 100
best, _ = schedule(p, h, P)
print("Thời gian ngắn nhất:", best)
Thời gian ngắn nhất: 11.5
Câu hỏi 2¶
Cài đặt thuật toán để chuyển nghiệm của bài toán quy hoạch tuyến tính thành giải pháp thực tế cho vấn đề lập lich.
Đáp án Câu hỏi 2¶
def schedule_interpret(p, h, P):
nt = len(p)
n = (nt + 1) ** 3
best, x = schedule(p, h, P)
sched = []
x = x.reshape(nt + 1, nt + 1, nt + 1)
for i in range(nt, -1, -1):
for j in range(nt, -1, -1):
for k in range(nt, -1, -1):
if x[i, j, k] > 0:
sched.append([x[i, j, k], i, j, k])
print(f'Thời gian tối thiểu: {best} giờ')
for t, *tasks in sched:
tasks = [
i
for i in tasks
if i != 0
]
print(f'Trong {t} giờ, làm {tasks}')
p = [10, 20, 60, 80, 90]
h = [8.5, 9, 4, 3.5, 2]
P = 100
schedule_interpret(p, h, P)
Thời gian tối thiểu: 11.5 giờ Trong 4.0 giờ, làm [1, 2, 3] Trong 3.0 giờ, làm [2, 4] Trong 2.0 giờ, làm [1, 5] Trong 0.5 giờ, làm [1, 4] Trong 2.0 giờ, làm [1, 2]
Bài tập 3: Quy hoạch tuyến tính nguyên và bài toán xếp nhóm¶
Ở bài tập này, ta tìm hiểu cách giải quyết các bài toán quy hoạch tuyến tính nguyên (integer linear programming) bằng hàm scipy.optimize.milp
và áp dụng vào việc giải quyết một bài toán thực tế.
Bật mí: Các bước dưới đây cũng chính là các bước mà BTC đã đi qua để phân nhóm dự án cho trại hè PiMA 2024! Dữ liệu trong bài tập cũng chính là từ bảng khảo sát mà các bạn đã điền (đã qua giấu tên và xáo trộn ngẫu nhiên các cột).
Câu hỏi 1¶
Ta xem xét phiên bản đầy đủ của bài toán phân nhóm dự án ở trên.
Tại PiMA 2024, trước trại hè, các trại sinh được đưa cho một danh sách các mô tả dự án nhóm và được yêu cầu đánh giá điểm cho từng dự án trên thang điểm từ $0$ đến $3$. Mỗi bạn sẽ được giao cho đúng $1$ dự án. Mỗi dự án nhóm được giao cho ít nhất cho $4$ bạn. Vấn đề đặt ra là làm sao để phân nhóm dự án cho tất cả các bạn trại sinh này sao cho đáp ứng tốt nhất nguyện vọng của các bạn.
Hãy mô hình hóa vấn đề này thành bài toán quy hoạch tuyến tính nguyên và cài đặt sử dụng scipy.optimize.milp
để giải quyết nó.
Đáp án Câu hỏi 1¶
def pima_assign(limit, scores):
n, m = scores.shape
c = - np.array([
scores[i, j]
for j in range(m)
for i in range(n)
])
# each project has limit mentees
A1 = np.array([
[
1 if k == j else 0
for k in range(m)
for i in range(n)
]
for j in range(m)
])
bl1 = np.array([
0
for j in range(m)
])
bu1 = np.array([
limit
for j in range(m)
])
# each mentee has 1 project
A2 = np.array([
[
1 if k == i else 0
for j in range(m)
for k in range(n)
]
for i in range(n)
])
bl2 = np.array([
1
for i in range(n)
])
bu2 = np.array([
1
for i in range(n)
])
A = np.vstack([
A1,
A2,
])
bl = np.concatenate([
bl1,
bl2,
])
bu = np.concatenate([
bu1,
bu2,
])
l = np.array([
0
for j in range(m)
for i in range(n)
])
u = np.array([
1
for j in range(m)
for i in range(n)
])
constraints = so.LinearConstraint(A, bl, bu)
bounds = so.Bounds(l, u, keep_feasible=True)
integrality = np.ones_like(c)
res = so.milp(c=c, constraints=constraints, bounds=bounds, integrality=integrality)
return res
import io
import pandas as pd
limit = 4
df_scores = pd.read_csv(io.StringIO('''
1,1,3,1,2,1
1,3,1,3,2,2
3,1,2,3,0,2
1,3,0,3,2,0
2,3,3,2,1,2
3,1,2,2,2,1
3,2,2,3,1,1
1,3,0,1,3,2
1,3,1,2,2,1
1,1,1,3,2,2
1,3,3,2,1,1
1,3,0,1,1,1
0,1,3,2,2,3
1,1,2,2,2,2
2,2,2,3,2,2
2,3,2,2,2,3
2,1,3,3,2,1
3,1,2,2,3,3
2,2,3,3,2,2
3,3,3,2,2,3
3,2,2,1,0,2
1,2,2,3,3,1
2,2,3,3,3,2
3,2,2,2,2,2
'''), header=None)
scores = df_scores.to_numpy()
res = pima_assign(limit, scores)
n, m = scores.shape
a = dict()
for j in range(m):
print(f'==> Dự án {j + 1} <==')
for i in range(n):
if res.x[j * n + i] == 1.0:
print(
f'Trại sinh {i}: '
+ ' '.join([
f'({x})' if k == j else str(x)
for k, x in enumerate(df_scores.iloc[i])
]),
)
a[i] = j
print()
==> Dự án 1 <== Trại sinh 2: (3) 1 2 3 0 2 Trại sinh 5: (3) 1 2 2 2 1 Trại sinh 20: (3) 2 2 1 0 2 Trại sinh 23: (3) 2 2 2 2 2 ==> Dự án 2 <== Trại sinh 1: 1 (3) 1 3 2 2 Trại sinh 3: 1 (3) 0 3 2 0 Trại sinh 8: 1 (3) 1 2 2 1 Trại sinh 11: 1 (3) 0 1 1 1 ==> Dự án 3 <== Trại sinh 0: 1 1 (3) 1 2 1 Trại sinh 4: 2 3 (3) 2 1 2 Trại sinh 10: 1 3 (3) 2 1 1 Trại sinh 18: 2 2 (3) 3 2 2 ==> Dự án 4 <== Trại sinh 6: 3 2 2 (3) 1 1 Trại sinh 9: 1 1 1 (3) 2 2 Trại sinh 14: 2 2 2 (3) 2 2 Trại sinh 16: 2 1 3 (3) 2 1 ==> Dự án 5 <== Trại sinh 7: 1 3 0 1 (3) 2 Trại sinh 13: 1 1 2 2 (2) 2 Trại sinh 21: 1 2 2 3 (3) 1 Trại sinh 22: 2 2 3 3 (3) 2 ==> Dự án 6 <== Trại sinh 12: 0 1 3 2 2 (3) Trại sinh 15: 2 3 2 2 2 (3) Trại sinh 17: 3 1 2 2 3 (3) Trại sinh 19: 3 3 3 2 2 (3)
Câu hỏi 2¶
Ngoài việc cố gắng đáp ứng nguyện vọng của các bạn, BTC còn xem xét một số yêu cầu phụ, tiêu biểu như:
- Ưu tiên tính đa dạng về giới tính (nam, nữ). Ví dụ: mỗi nhóm đều có ít nhất $1$ bạn nữ.
- Ưu tiên tính đa dạng vùng miền (miền Bắc, Bắc Trung Bộ, Nam Trung Bộ, Tây Nguyên, miền Nam, miền Tây, v.v.). Ví dụ: mỗi nhóm đều có ít nhất một bạn không phải miền Nam.
- Ưu tiên tính đa dạng về tuổi (lớp 10, 11, 12). Ví dụ: mỗi nhóm đều có ít nhất một bạn lớp 12.
- ...
Giả sử có thêm các thông tin về giới tính, nơi sinh sống, tuổi, v.v. của các bạn trại sinh. Hãy mở rộng mô hình trên để tích hợp thêm các yêu cầu này rồi lập trình để giải quyết bài toán mở rộng.
Đáp án Câu hỏi 2¶
def pima_assign_ext1(limit, scores, sex):
n, m = scores.shape
c = - np.array([
scores[i, j]
for j in range(m)
for i in range(n)
])
# each project has limit mentees
A1 = np.array([
[
1 if k == j else 0
for k in range(m)
for i in range(n)
]
for j in range(m)
])
bl1 = np.array([
0
for j in range(m)
])
bu1 = np.array([
limit
for j in range(m)
])
# each mentee has 1 project
A2 = np.array([
[
1 if k == i else 0
for j in range(m)
for k in range(n)
]
for i in range(n)
])
bl2 = np.array([
1
for i in range(n)
])
bu2 = np.array([
1
for i in range(n)
])
# each team has at least 1 and at most 2 female
A3 = np.array([
[
sex[i] == 'Nữ'
if k == j else 0
for k in range(m)
for i in range(n)
]
for j in range(m)
])
bl3 = np.array([
1
for j in range(m)
])
bu3 = np.array([
2
for j in range(m)
])
A = np.vstack([
A1,
A2,
A3,
])
bl = np.concatenate([
bl1,
bl2,
bl3,
])
bu = np.concatenate([
bu1,
bu2,
bu3,
])
l = np.array([
0
for j in range(m)
for i in range(n)
])
u = np.array([
1
for j in range(m)
for i in range(n)
])
constraints = so.LinearConstraint(A, bl, bu)
bounds = so.Bounds(l, u, keep_feasible=True)
integrality = np.ones_like(c)
res = so.milp(c=c, constraints=constraints, bounds=bounds, integrality=integrality)
return res
import io
import pandas as pd
limit = 4
df_scores = pd.read_csv(io.StringIO('''
1,1,3,1,2,1
1,3,1,3,2,2
3,1,2,3,0,2
1,3,0,3,2,0
2,3,3,2,1,2
3,1,2,2,2,1
3,2,2,3,1,1
1,3,0,1,3,2
1,3,1,2,2,1
1,1,1,3,2,2
1,3,3,2,1,1
1,3,0,1,1,1
0,1,3,2,2,3
1,1,2,2,2,2
2,2,2,3,2,2
2,3,2,2,2,3
2,1,3,3,2,1
3,1,2,2,3,3
2,2,3,3,2,2
3,3,3,2,2,3
3,2,2,1,0,2
1,2,2,3,3,1
2,2,3,3,3,2
3,2,2,2,2,2
'''), header=None)
scores = df_scores.to_numpy()
df_sex = pd.read_csv(io.StringIO('''
Nữ
Nữ
Nam
Nam
Nữ
Nữ
Nam
Nữ
Nam
Nữ
Nữ
Nam
Nam
Nữ
Nam
Nam
Nam
Nữ
Nam
Nam
Nữ
Nam
Nam
Nam
'''), header=None)
sex = df_sex[0].tolist()
res = pima_assign_ext1(limit, scores, sex)
n, m = scores.shape
a = dict()
for j in range(m):
print(f'==> Dự án {j + 1} <==')
for i in range(n):
if res.x[j * n + i] == 1.0:
print(
f'Trại sinh {i}: '
+ ' '.join([
f'({x})' if k == j else str(x)
for k, x in enumerate(df_scores.iloc[i])
]),
)
a[i] = j
print()
==> Dự án 1 <== Trại sinh 2: (3) 1 2 3 0 2 Trại sinh 5: (3) 1 2 2 2 1 Trại sinh 20: (3) 2 2 1 0 2 Trại sinh 23: (3) 2 2 2 2 2 ==> Dự án 2 <== Trại sinh 3: 1 (3) 0 3 2 0 Trại sinh 8: 1 (3) 1 2 2 1 Trại sinh 10: 1 (3) 3 2 1 1 Trại sinh 11: 1 (3) 0 1 1 1 ==> Dự án 3 <== Trại sinh 0: 1 1 (3) 1 2 1 Trại sinh 4: 2 3 (3) 2 1 2 Trại sinh 16: 2 1 (3) 3 2 1 Trại sinh 18: 2 2 (3) 3 2 2 ==> Dự án 4 <== Trại sinh 1: 1 3 1 (3) 2 2 Trại sinh 6: 3 2 2 (3) 1 1 Trại sinh 9: 1 1 1 (3) 2 2 Trại sinh 14: 2 2 2 (3) 2 2 ==> Dự án 5 <== Trại sinh 7: 1 3 0 1 (3) 2 Trại sinh 17: 3 1 2 2 (3) 3 Trại sinh 21: 1 2 2 3 (3) 1 Trại sinh 22: 2 2 3 3 (3) 2 ==> Dự án 6 <== Trại sinh 12: 0 1 3 2 2 (3) Trại sinh 13: 1 1 2 2 2 (2) Trại sinh 15: 2 3 2 2 2 (3) Trại sinh 19: 3 3 3 2 2 (3)
Câu hỏi 3¶
Ngoài ra, dựa trên đánh giá chủ quan của BTC cũng như là nguyện vọng của các mentor, đối với một số bạn trại sinh cụ thể, BTC muốn giới hạn các bạn chỉ được xếp vào một trong các dự án cụ thể (whitelist) hoặc giới hạn các bạn không được xếp vào một trong các dự án cụ thể (blacklist).
Giả sử có thêm các thông tin về whitelist và blacklist. Hãy mở rộng mô hình trên để tích hợp thêm các yêu cầu này rồi lập trình để giải quyết bài toán mở rộng.
Đáp án Câu hỏi 3¶
def pima_assign_ext2(limit, scores, sex, black_list):
n, m = scores.shape
c = - np.array([
scores[i, j]
for j in range(m)
for i in range(n)
])
# each project has limit mentees
A1 = np.array([
[
1 if k == j else 0
for k in range(m)
for i in range(n)
]
for j in range(m)
])
bl1 = np.array([
0
for j in range(m)
])
bu1 = np.array([
limit
for j in range(m)
])
# each mentee has 1 project
A2 = np.array([
[
1 if k == i else 0
for j in range(m)
for k in range(n)
]
for i in range(n)
])
bl2 = np.array([
1
for i in range(n)
])
bu2 = np.array([
1
for i in range(n)
])
# each team has at least 1 and at most 2 female
A3 = np.array([
[
sex[i] == 'Nữ'
if k == j else 0
for k in range(m)
for i in range(n)
]
for j in range(m)
])
bl3 = np.array([
1
for j in range(m)
])
bu3 = np.array([
2
for j in range(m)
])
A = np.vstack([
A1,
A2,
A3,
])
bl = np.concatenate([
bl1,
bl2,
bl3,
])
bu = np.concatenate([
bu1,
bu2,
bu3,
])
l = np.array([
0
for j in range(m)
for i in range(n)
])
u = np.array([
0
if j in black_list.get(i, [])
else 1
for j in range(m)
for i in range(n)
])
constraints = so.LinearConstraint(A, bl, bu)
bounds = so.Bounds(l, u, keep_feasible=True)
integrality = np.ones_like(c)
res = so.milp(c=c, constraints=constraints, bounds=bounds, integrality=integrality)
return res
import io
import pandas as pd
limit = 4
df_scores = pd.read_csv(io.StringIO('''
1,1,3,1,2,1
1,3,1,3,2,2
3,1,2,3,0,2
1,3,0,3,2,0
2,3,3,2,1,2
3,1,2,2,2,1
3,2,2,3,1,1
1,3,0,1,3,2
1,3,1,2,2,1
1,1,1,3,2,2
1,3,3,2,1,1
1,3,0,1,1,1
0,1,3,2,2,3
1,1,2,2,2,2
2,2,2,3,2,2
2,3,2,2,2,3
2,1,3,3,2,1
3,1,2,2,3,3
2,2,3,3,2,2
3,3,3,2,2,3
3,2,2,1,0,2
1,2,2,3,3,1
2,2,3,3,3,2
3,2,2,2,2,2
'''), header=None)
scores = df_scores.to_numpy()
pd.read_csv(io.StringIO('''
Nữ
Nữ
Nam
Nam
Nữ
Nữ
Nam
Nữ
Nam
Nữ
Nữ
Nam
Nam
Nữ
Nam
Nam
Nam
Nữ
Nam
Nam
Nữ
Nam
Nam
Nam
'''), header=None)
sex = df_sex[0].tolist()
black_list = {
i: {
j
for j in range(m)
if scores[i, j] <= 1 # lọc tất cả điểm <= 1
}
for i in range(n)
}
black_list_update = {
2: [0, 5],
14: [0, 1, 2, 4, 5],
17: [4, 5],
18: [0, 1, 3, 4],
}
res = pima_assign_ext2(limit, scores, sex, black_list)
n, m = scores.shape
a = dict()
for j in range(m):
print(f'==> Dự án {j + 1} <==')
for i in range(n):
if res.x[j * n + i] == 1.0:
print(
f'Trại sinh {i}: '
+ ' '.join([
f'({x})' if k == j else str(x)
for k, x in enumerate(df_scores.iloc[i])
]),
)
a[i] = j
print()
==> Dự án 1 <== Trại sinh 5: (3) 1 2 2 2 1 Trại sinh 6: (3) 2 2 3 1 1 Trại sinh 20: (3) 2 2 1 0 2 Trại sinh 23: (3) 2 2 2 2 2 ==> Dự án 2 <== Trại sinh 3: 1 (3) 0 3 2 0 Trại sinh 8: 1 (3) 1 2 2 1 Trại sinh 10: 1 (3) 3 2 1 1 Trại sinh 11: 1 (3) 0 1 1 1 ==> Dự án 3 <== Trại sinh 0: 1 1 (3) 1 2 1 Trại sinh 4: 2 3 (3) 2 1 2 Trại sinh 16: 2 1 (3) 3 2 1 Trại sinh 18: 2 2 (3) 3 2 2 ==> Dự án 4 <== Trại sinh 1: 1 3 1 (3) 2 2 Trại sinh 2: 3 1 2 (3) 0 2 Trại sinh 9: 1 1 1 (3) 2 2 Trại sinh 14: 2 2 2 (3) 2 2 ==> Dự án 5 <== Trại sinh 7: 1 3 0 1 (3) 2 Trại sinh 13: 1 1 2 2 (2) 2 Trại sinh 21: 1 2 2 3 (3) 1 Trại sinh 22: 2 2 3 3 (3) 2 ==> Dự án 6 <== Trại sinh 12: 0 1 3 2 2 (3) Trại sinh 15: 2 3 2 2 2 (3) Trại sinh 17: 3 1 2 2 3 (3) Trại sinh 19: 3 3 3 2 2 (3)
Câu hỏi 4+ (tự do)¶
a. Việc đánh giá điểm phụ thuộc vào cảm tính của mỗi bạn: có những bạn sẽ luôn cho điểm từ $2$ trở lên, có những bạn sử dụng chiến thuật chỉ đánh giá cao nhất điểm ở dự án mình muốn vào nhất. Liệu có cách nào để chuẩn hóa điểm đánh giá?
b. Một lựa chọn tự nhiên cho hàm mục tiêu là tổng điểm đánh giá của các bạn trại sinh với các dự án mà bạn được giao. Hãy cân nhắc các hàm mục tiêu khác và so sánh kết quả. Ví dụ:
- Tối ưu điểm đánh giá của bạn có đánh giá thấp nhất cho dự án bạn được giao
- Đánh trọng số các bạn dựa trên một quy tắc cụ thể. Ví dụ: ưu tiên các bạn có nguyện vọng mãnh liệt hơn các bạn khác (dựa trên đánh giá cảm tính thông qua lý do cho điểm của bạn).