Nguồn: P3G
Tác giả: Phan Minh Hoàng + Lê Anh Đức
Kĩ thuật bao lồi là kĩ thuật (hoặc là cấu trúc dữ liệu) dùng để xác định hiệu quả, có tiền xử lý, cực trị của một tập các hàm tuyến tính tại một giá trị của biến độc lập. Mặc dù tên gọi giống nhưng kĩ thuật này lại khá khác biệt so với thuật toán bao lồi của hình học tính toán.
Ngoài ra có một kĩ thuật liên quan đến kĩ thuật bao lồi là IT đoạn thẳng
Kĩ thuật bao lồi được biết đến nhiều nhất có lẽ vì nó cần để ăn trọn toàn bộ số điểm trong nhiều bài toán USACO, như là bài Acquire trong bảng gold của USACO tháng 3 năm 2008. Thuật toán được đưa vào các cuộc thi lập trình một cách rộng rãi sau bài Batch Scheduling trong kì thi IOI 2002. Đây là một kĩ thuật khá lạ và ít có nguồn trên mạng về vấn đề này.
Cho một tập lớn các hàm tuyến tính có dạng và một số lượng lớn truy vấn. Mỗi truy vấn là một số và hỏi giá trị cực tiểu có thể đạt được nếu chúng ta thế vào một trong những phương trình đã cho. Ví dụ, cho các phương trình , , và và truy vấn . Chúng ta phải tìm phương trình mà trả về giá trị cực tiểu hoặc giá trị cực tiểu đó (trong trường hợp này là phương trình và giá trị cực tiểu đó là 2).
Biến thể của bài toán có thể là tìm giá trị cực đại có thể giải quyết với một thay đổi nhỏ vì vậy trong bài viết này chỉ tập trung vào bài toán tìm giá trị cực tiểu.
Sau khi ta vẽ các đường thẳng lên hệ trục tọa độ, dễ thấy rằng: chúng ta muốn xác định, tại (đường màu đỏ) đường nào có tọa độ nhỏ nhất. Ở trong trường hợp này là đường nét đứt đậm .
Với mỗi truy vấn trong truy vấn, ta duyệt qua từng hàm số và thử từng từng hàm và xem thử hàm nào trả giá trị cực tiểu cho giá trị . Nếu có đường thẳng và truy vấn, độ phức tạp của thuật toán sẽ . Kĩ thuật bao lồi sẽ giúp giảm độ phức tạp xuống còn , một độ phức tạp hiệu quá hơn nhiều.
Xét hình vẽ ở trên. Đường thẳng sẽ không bao giờ là giá trị nhỏ nhất với tất cả giá trị của . Mỗi đường trong mỗi đường thẳng còn lại sẽ lại trả lại giá trị cực tiểu trong một và chỉ một đoạn liên tiếp (có thể có một biên là hoặc ). Đường chấm đậm sẽ cho giá trị cực tiểu với tất cả giá trị nằm bên trái điểm giao với đường đen đậm. Đường đen đậm sẽ cho giá trị cực tiểu với tất cả giá trị giữa giao điểm của nó với đường nhạt và đường chấm đậm. Và đường nhạt sẽ nhận cực tiểu cho tất cả giá trị bên phải giao điểm với đường đậm. Một nhận xét nữa là với giá trị của càng tăng thì hệ số góc của các hàm số sẽ giảm, . Với một chút chứng minh dễ thấy rằng điều này luôn đúng.
Điều này giúp chúng ta hiểu phần nào thuật toán:
Bỏ đi các đường thẳng không quan trọng như trong ví dụ (những đường thẳng mà không nhận giá trị cực tiểu trong bất kì đoạn nào)
Sắp xếp các đoạn thẳng còn lại theo hệ số góc và được một tập đoạn thẳng (của đường thẳng còn lại) mà mỗi đoạn có một đường thẳng nhận giá trị cực tiểu.
Dùng thuật toán tìm kiếm nhị phân cơ bản để có thể tìm kiếm đáp án cho từng truy vấn.
Cụm từ bao lồi được sử dụng để chỉ hình bao trên/dưới (upper / lower envelope). Trong ví dụ, nếu chúng ta coi mỗi phần đoạn thẳng tối ưu của đường thẳng (bỏ qua đường ), chúng ta sẽ thấy những đoạn đó tạo thành một hình bao dưới (lower envelope), một tập các đoạn thẳng chứa tất cả điểm cực tiểu cho mọi giá trị của (hình bao dưới được tô bằng màu xanh trong hình. Cái tên kĩ thuật bao lồi xuất phát từ việc đường bao trên tạo thành một đường lồi, từ đó thành bao lồi của một tập điểm.
Ta có thể thấy nếu ta có một tập đường thẳng đã được xác định sắp xếp, ta có thể dễ dàng trả lời bất kì truy vấn nào với độ phức tạp là với tìm kiếm nhị phân. Vậy nếu chúng ta tìm ra cách thêm một đường thẳng vào tính toán lại một cách hiệu quả là chúng ta đã có một thuật toán hoạt động ngon lành.
Giả sử chúng ta được xử lý tất cả đường thẳng trước khi làm các truy vấn thì chúng ta chỉ cần đơn giản sắp xếp các đường thẳng theo hệ số góc và thêm từng đường một vào. Sẽ có thể một số đường không quan trọng và sẽ bị bỏ đi. Chúng ta sẽ sử dụng cấu trúc dữ liệu Stack để cài đặt, bỏ từng đường thẳng vào stack và nếu đường nào không quan trọng sẽ bị bỏ ra ngoài đến khi chỉ còn một đường thẳng (đường thẳng cuối không thể bỏ)
Vậy làm sao để có thể xác định đường thẳng nào sẽ bị bỏ khỏi stack? Giả sử , và là đường thẳng áp chót (gần cuối ) trên stack, đường thẳng cuối cùng trong stack và đường thẳng được thêm vào stack. Đoạn không quan trọng(không có giá trị cực tiểu ở điểm nào) khi và chỉ khi giao điểm của và nằm bên trái giao điểm của và (Đoạn mà nhận giá trị cực tiểu đã nằm đè lên đoạn của ). Giả sử rằng không có ba đường nào trùng hay song song với nhau (có thể giải quyết một cách đơn giản).
Độ phức tạp bộ nhớ sẽ là : chúng ta cần một danh sách lưu lại các đoạn thẳng, biểu diễn bởi hai số thực.
Độ phức tạp thời gian cho phần tiền xây dựng:
Để sắp xếp các đoạn thẳng theo tăng dần hệ số góc sẽ tốn .
Duyệt qua các đường thẳng mỗi đường được cho vào stack và bỏ khỏi stack tối đa một lần vậy nên sẽ tốn cho bước này.
Vậy thời gian cho việc xây dựng sẽ là . Với mỗi truy vấn dùng chặt nhị phân sẽ cho độ phúc tạp tốt nhất .
Cho hình chữ nhật khác nhau về hình dạng, mục tiêu của bài toán là phải lấy được tất cả hình chữ nhật. Một tập hình chữ nhật có thể thu được với chi phí bằng tích của chiều dài dài nhất và chiều rộng dài nhất. Chúng ta cần phân hoạch tập các hình chữ nhật này một cách khôn khéo sao cho tổng chi phí có thể được tối thiểu hóa và tính chi phí này. Hình chữ nhật không thể được xoay (đổi chiều dài và chiều rộng).
Giả sử tồn tại hai hình chữ nhật A và B mà mà cả chiều dài và chiều rộng của hình B đều bé hơn hình A thì ta có thể nói hình B là không quan trọng vì ta có thể để hình B chung với hình A từ đó chi phí của hình B không còn quan trọng. Sau khi đã loại hết tất cả hình không quan trọng đi và sắp xếp lại các hình theo chiều dài giảm dần thì chiều rộng các hình đã sắp xếp sẽ theo chiều tăng.
Sau khi sắp xếp, ta có thể hình dung được rằng nếu chúng ta chọn hai hình chữ nhật ở vị trí và ở vị trí thì ta có thể chọn tất cả hình chữ nhật từ đến mà không tốn chi phí nào cả. Vậy ta có thể thấy rằng cách phân hoạch tối ưu là một cách phân dãy thành các đoạn liên tiếp và chi phí của một đoạn là bằng tích của chiều dài của hình chữ nhật đầu tiên và chiều rộng của hình chữ nhật cuối cùng.
Vậy bài toán trờ về bài toán phân dãy sao cho tổng chi phí của các dãy là tối ưu. Đây là một dạng bài quy hoạch động hay gặp và chúng ta có thể dễ dàng nghĩ ra thuật toán như mã giả phía dưới. (Giả sử các hình đã được sắp xếp và bỏ đi những hình chữ nhật không quan trọng)
input N
for i ∈ [1..N]
input rect[i].h
input rect[i].w
let cost[0] = 0
for i ∈ [1..N]
let cost[i] = ∞
for j ∈ [0..i-1]
cost[i] = min(cost[i],cost[j]+rect[i].h*rect[j+1].w)
print cost[N]
Ở trên cost[k]
lưu lại chi phí cực tiểu để lấy được k
hình chữ nhật đầu tiên. Hiển nhiên, cost[0]=0
. Để tính toán được cost[i]
với i
khác 0, ta có tính tổng chi phí để lấy được các tập trước và cộng nó với chi phí của tập cuối cùng(có chứa i
). Chi phí của một tập có thể dễ dàng tính bằng cách lấy tích của chiều dài hình chữ nhật đầu tiên và chiều rộng của hình chữ nhật cuối cùng. Vậy ta có min(cost[i],cost[j]+rect[i].h*rect[j+1].w)
với j là hình chữ nhật đầu tiên của tập cuối cùng. Với thì thuật toán này là quá chậm.
Với với là chiều rộng của hình chữ nhật và là chiều dài của hình chữ nhật . Vậy thì bài toán trờ về tìm hàm cực tiểu của bằng cách tìm tối ưu. Nó giống hoàn toàn bài toán chúng ta đã đề cập ở trên. Giả sử ta đã hoàn thành việc cài đặt cấu trúc đã đề cập ở trên chúng ta có thể có mã giả ở dưới đây:
input N
for i ∈ [1..N]
input rect[i].h
input rect[i].w
let E = empty lower envelope structure
let cost[0] = 0
add the line y=mx+b to E, where m=rect[1].w and b=cost[0] //b is zero
for i ∈ [1..N]
cost[i] = E.query(rect[i].h)
if i<N
E.add(m=rect[i+1].w,b=cost[i])
print cost[N]
Rõ ràng các đường thẳng đã được sắp xếp giảm dần về độ lớn của hệ số góc do chúng ta đã sắp xếp các chiều dài giảm dần. Do mỗi truy vấn có thể thực hiện trong thời gian , ta có thể dễ dàng thấy thời gian thực hiện của cả bài toán là . Do các truy vấn của chúng ta cũng tăng dần (do chiều rộng đã được sắp xếp tăng tần) ta có thể thay thế việc chặt nhị phân bằng một con trỏ chạy song song với việc quy hoạch động đưa bước quy hoạch động còn nhưng tổng độ phức tạp vẫn là do chi phí sắp xếp. Vậy là ta đã giải quyết thành công bài toán sử dụng kĩ thuật bao lồi đầu tiên của chúng ta .
Bạn được cho:
Định nghĩa rằng:
sum(i,j) = x[i] + x[i+1] + ... + x[j]
adjust(i,j) = a*sum(i,j)^2 + b*sum(i,j) + c
Ta có:
Mã giả:
dp[0] ← 0
for n ∈ [1..N]
for k ∈ [0..n-1]
dp[n] ← max(dp[n], dp[k] + adjust(k + 1 , n))
Hãy thử biến đổi hàm "adjust" một tí nào.
Định nghĩa là . Vậy với một số bất kì ta có thể viết là:
Nếu:
Ta có thể thấy là đại lượng mà chúng ta muốn tối ưu hóa bằng cách chọn . sẽ bằng đại lượng đó cộng thêm với (độc lập so với k). Trong đó cũng độc lập với , và và phụ thuộc vào k.
Ngược với bài "acquire" khi chúng ta phải tối thiểu hóa hàm quy hoạch động thì bài này chúng ta phải cực đại hóa nó. Chúng ta phải xây dựng một hình bao trên với các đường thẳng tăng dần về hệ số góc. Do đề bài đã cho hệ số góc của chúng ta tăng dần và luôn dương thỏa với điều kiện của cấu trúc chúng ta.
Do dễ thấy , giống như bài "acquire" các truy vấn chúng ta cũng tăng dần theo thứ tự do vậy chúng ta có thể khơi tạo một biến chạy để chạy song song khi làm quy hoạch động (bỏ được phần chặt nhị phân).
Ngày xửa ngày xưa, có trị trấn kiểu trung cỏ trong khu tự trị Moldavian. Các trhị trấn này được đánh số từ đến . Thị trấn là thủ đô. Các thị trấn được nối với nhau bằng con đường hai chiểu, mỗi con đường có độ dài được đo bằng km. Có duy nhất một tuyến đường nối giữa hai điểm bất kỳ (đồ thị các con đường là hình cây). Mỗi thị trấn không phải trung tâm có một người truyền tin.
Khi một thị trấn bị tấn công, tình hình chiến sự cần được bảo về thủ đô càng sớm càng tốt. Mội thông điệp được truyền bằng các người truyền tin. Mỗi người truyền tin được đặc trưng bởi lượng thời gian khởi động và vận tốc không đổi sau khi xuất phát.
Thông điệp luôn được truyền trên con đường ngắn nhất đến trung tâm. Ban đầu, thông tin chiến sự được đưa cho người truyền tin tại thị trấn bị tấn công. Từ thị trấn đó người truyền tin sẽ đi theo con đường về gần thủ đô hơn. Khi đến một thị trấn mới vừa đến. Lưu ý rằng khi chuyển sang người truyền tin mới thì người này cần một lượng thời gian để khởi động rồi mới đi chuyển tin. Như vậy, thông điệp sẽ được chuyển bằng một số người truyền tin trước khi đến thử đô.
Hãy xác định thời gian ít nhất cần chuyển tin từ các thị trấn về thủ đô.
Input
Output
Ví dụ
Input
5
1 2 20
2 3 12
2 4 1
4 5 3
26 9
1 10
500 2
2 30
Output
206 321 542 328
Giới hạn
Thuật toán QHĐ
Gọi là thời gian ít nhất để truyền tin từ thành phố thứ đến thủ đô, ta có công thức truy hồi:
với là một nút trên đường từ thành phố đến thành phố . Trong đó là khoảng cách giữa 2 thành phố và , có thể tính trong sử dụng mảng cộng dồn với là khoảng cách từ thành phố tới thủ đô. Thuật toán này có thể dễ dàng cài đặt với độ phức tạp là .
Áp dụng bao lồi
Công thức truy hồi có thể viết lại thành
Khi ta tính , thì giá trị là hằng số với mọi , vì vậy
Có thể thấy rằng ta cần tìm giá trị nhỏ nhất của hàm bậc nhất <dạng >.
Với trường hợp cây là đường thẳng, ta có thể trực tiếp kỹ thuật đã trình bày ở phần trước. Trong trường hợp tổng quát, ta cần một cấu trúc dữ liệu cho phép xử lí hai thao tác:
Các thao tác này có thể được thực hiện hiệu quả trong . Cụ thể ta sẽ biểu diễn stack bằng một mảng cũng một biến (kích thước stack). Khi thêm một đường thẳng vào, ta sẽ tìm kiếm nhị phân vị trí mới của nó, rồi chỉnh sửa biến cho phù hợp, chú ý là sẽ có tối đa một đường thẳng bị ghi đè, nên ta chỉ cần lưu lại nó. Khi cần trả về trạng thái ban đầu, ta chỉ cần chỉnh sửa lại biến đồng thời ghi lại đường thẳng đã bị ghi đè trước đó. Để quản lí lịch sử các thao tác ta sử dụng một lưu lại chúng.
Độ phức tạp cho toàn bộ thuật toán là .
#include <bits/stdc++.h>
#define X first
#define Y second
const int N = 100005;
const long long INF = (long long)1e18;
using namespace std;
typedef pair<int, int> Line;
struct operation {
int pos, top;
Line overwrite;
operation(int _p, int _t, Line _o) {
pos = _p; top = _t; overwrite = _o;
}
};
vector<operation> undoLst;
Line lines[N];
int n, top;
long long eval(Line line, long long x) {return line.X * x + line.Y;}
bool bad(Line a, Line b, Line c)
{return (double)(b.Y - a.Y) / (a.X - b.X) >= (double)(c.Y - a.Y) / (a.X - c.X);}
long long getMin(long long coord) {
int l = 0, r = top - 1; long long ans = eval(lines[l], coord);
while (l < r) {
int mid = l + r >> 1;
long long x = eval(lines[mid], coord);
long long y = eval(lines[mid + 1], coord);
if (x > y) l = mid + 1; else r = mid;
ans = min(ans, min(x, y));
}
return ans;
}
bool insertLine(Line newLine) {
int l = 1, r = top - 1, k = top;
while (l <= r) {
int mid = l + r >> 1;
if (bad(lines[mid - 1], lines[mid], newLine)) {
k = mid; r = mid - 1;
}
else l = mid + 1;
}
undoLst.push_back(operation(k, top, lines[k]));
top = k + 1;
lines[k] = newLine;
return 1;
}
void undo() {
operation ope = undoLst.back(); undoLst.pop_back();
top = ope.top; lines[ope.pos] = ope.overwrite;
}
long long f[N], S[N], V[N], d[N];
vector<Line> a[N];
void dfs(int u, int par) {
if (u > 1)
f[u] = getMin(V[u]) + S[u] + V[u] * d[u];
insertLine(make_pair(-d[u], f[u]));
for (vector<Line>::iterator it = a[u].begin(); it != a[u].end(); ++it) {
int v = it->X;
int uv = it->Y;
if (v == par) continue;
d[v] = d[u] + uv;
dfs(v, u);
}
undo();
}
int main() {
ios::sync_with_stdio(0); cin.tie(0);
cin >> n;
int u, v, c;
for (int i = 1; i < n; ++i) {
cin >> u >> v >> c;
a[u].push_back(make_pair(v, c));
a[v].push_back(make_pair(u, c));
}
for (int i = 2; i <= n; ++i) cin >> S[i] >> V[i];
dfs(1, 0);
for (int i = 2; i <= n; ++i) cout << f[i] << ' ';
return 0;
}
Kĩ thuật này có thể dễ dàng được thực hiện khi các đường thẳng được thêm trước tất cả các truy vấn hay các đường thẳng được thêm vào theo thứ tự giảm dần của hệ số góc. Hoặc với cấu trúc deque chúng ta cũng có thể thêm những đường thẳng có hệ số góc lớn hơn hết các đường thẳng đã có. Nhưng có những lúc sẽ có các bài toán khi chúng ta phải giải quyết các truy vấn và thêm đường thẳng lồng vào nhau với các hệ số góc ngẫu nhiên. Chúng ta không thể sắp xếp lại trước (do bị lồng vào truy vấn) và không thể sắp xếp lại với mỗi lần thêm đường thẳng (vậy sẽ cho ta một độ phức tạp tuyến tính với mỗi truy vấn).
Có tồn tại một cách để thêm các đường thẳng ngẫu nhiên vào trong độ phức tạp log. Chúng ta lưu các đoạn thẳng trong một cấu trúc có thứ tự động như std::set
của C++. Mỗi đường thẳng chứa hệ số góc và giao điểm (sắp xếp theo hệ số góc trước rồi theo ) cùng với một biến thêm, nhỏ nhất sao cho đường thẳng này đạt cực tiểu trong tập các đường thẳng.
Sắp đường thẳng này vào vị trí đúng của nó và những đường bị loại sẽ là các đường liên tiếp kế bên nó. Chúng ta dùng các điều kiện tương tự trên để bỏ các đường thẳng bên trái và bên phải nó.
Để trả lời truy vấn, chúng ta dùng một set nữa dùng chính các biến ấy nhưng lại sắp xếp theo . Vậy mỗi lần truy vấn ta có thể dễ dàng chặt nhị phân để tìm ra kết quả như đã nói ở trên.