Bản chất của Convex Hull Trick (CHT): Kỹ thuật chuyển đổi bài toán tối ưu hóa quy hoạch động phi tuyến thành bài toán hình học quản lý bao lồi của các đường thẳng.
Chứng minh điều kiện loại bỏ đường thẳng: Công thức toán học đằng sau việc xác định một đường thẳng bị vô dụng (redundant line).
Thuật toán Static CHT: Cài đặt tối ưu bằng cấu trúc Deque trong \(O(N)\) hoặc Tìm kiếm nhị phân trong \(O(N \log N)\).
Thuật toán Dynamic CHT với Li Chao Tree: Cấu trúc dữ liệu mạnh mẽ để quản lý các đường thẳng thêm vào theo thứ tự bất kỳ.
Ứng dụng qua bài toán kinh điển: Batch Scheduling (IOI 2002) và ACQUIRE (USACO).
Trong các bài toán quy hoạch động, ta thường gặp công thức tối ưu hóa có dạng:
$\(dp[i] = \min_{0 \le j < i} \{ dp[j] + b[j] \cdot a[i] \} + c[i]\)$
Trong đó \(a[i]\) là tham số truy vấn tại bước \(i\), \(b[j]\) là hệ số góc thu được từ trạng thái \(j\), và \(dp[j]\) là giá trị tối ưu đã tính.
Nếu duyệt qua mọi trạng thái \(j < i\), độ phức tạp thời gian để tính toàn bộ mảng \(dp\) là \(O(N^2)\). Khi \(N \ge 10^5\), thuật toán này sẽ bị quá thời gian (TLE).
Với mỗi lựa chọn \(j\), ta có thể coi cụm giá trị phụ thuộc vào \(j\) là một đường thẳng trong hệ tọa độ \(Oxy\):
$\(f_j(x) = m_j \cdot x + c_j\)$
Trong đó:
- Hệ số góc: \(m_j = b[j]\)
- Hệ số tự do (tung độ gốc): \(c_j = dp[j]\)
Bài toán lúc này quy về: Tìm giá trị nhỏ nhất của tập hợp các đường thẳng \(\{f_0, f_1, \dots, f_{i-1}\}\) tại hoành độ truy vấn \(x = a[i]\).
Đường bao dưới cùng của tất cả các đường thẳng này được gọi là Đường bao dưới (Lower Envelope). Hình dạng của Lower Envelope là một đường lồi hướng xuống dưới, được ghép bởi các phân đoạn của các đường thẳng có hệ số góc giảm dần (hoặc tăng dần).
2. Phân tích Toán học về sự vô dụng của đường thẳng¶
Khi thêm một đường thẳng mới vào tập hợp, một số đường thẳng cũ có thể không bao giờ đạt giá trị nhỏ nhất tại bất kỳ hoành độ \(x\) nào nữa. Ta gọi những đường thẳng này là vô dụng (bad/redundant lines) và cần loại bỏ chúng khỏi cấu trúc quản lý bao lồi.
Xét ba đường thẳng liên tiếp \(L_1, L_2, L_3\) với hệ số góc giảm dần \(m_1 > m_2 > m_3\).
Phương trình của các đường thẳng:
$\(L_1: y = m_1 x + c_1\)$
$\(L_2: y = m_2 x + c_2\)$
$\(L_3: y = m_3 x + c_3\)$
Hoành độ giao điểm của \(L_1\) và \(L_2\) là:
$\(x_{12} = \frac{c_2 - c_1}{m_1 - m_2}\)$
Hoành độ giao điểm của \(L_2\) và \(L_3\) là:
$\(x_{23} = \frac{c_3 - c_2}{m_2 - m_3}\)$
Đường thẳng ở giữa \(L_2\) sẽ trở nên vô dụng khi và chỉ khi giao điểm của \(L_2\) và \(L_3\) nằm ở bên trái (hoặc trùng) giao điểm của \(L_1\) và \(L_2\):
$\(x_{23} \le x_{12}\)$
Điều này tương đương với:
$\(\frac{c_3 - c_2}{m_2 - m_3} \le \frac{c_2 - c_1}{m_1 - m_2}\)$
Vì \(m_1 > m_2 > m_3\), các hiệu số \(m_1 - m_2\) và \(m_2 - m_3\) đều lớn hơn \(0\). Ta có thể nhân chéo trực tiếp để tránh chia số thực dẫn đến sai số:
$\((c_3 - c_2)(m_1 - m_2) \le (c_2 - c_1)(m_2 - m_3)\)$
Nếu các đường thẳng được thêm vào theo thứ tự hệ số góc \(m_j\) đơn điệu (ví dụ giảm dần), ta gọi đây là Static CHT. Ta có hai trường hợp dựa trên tính đơn điệu của hoành độ truy vấn \(x\):
Trường hợp A: Hoành độ truy vấn \(x\) đơn điệu tăng (\(O(N)\))¶
Ta duy trì bao lồi bằng cấu trúc hàng đợi hai đầu Deque. Do \(x\) tăng dần, đường thẳng tối ưu ở đầu Deque cũng dịch chuyển theo một chiều. Ta dùng con trỏ tịnh tiến ở đầu Deque.
Trường hợp B: Hoành độ truy vấn \(x\) bất kỳ (\(O(N \log N)\))¶
Ta không thể loại bỏ phần tử ở đầu Deque vì các truy vấn sau có thể quay lại hoành độ nhỏ. Tuy nhiên, các khoảng tối ưu của các đường thẳng sắp xếp tăng dần. Ta dùng Tìm kiếm nhị phân (Binary Search) trên Deque để tìm đường thẳng tối ưu.
#include<bits/stdc++.h>usingnamespacestd;structLine{longlongm,c;longlongeval(longlongx)const{returnm*x+c;}};structConvexHullTrick{vector<Line>hull;// Kiểm tra xem l2 có vô dụng khi chèn l3 sau l1 khôngboolis_bad(constLine&l1,constLine&l2,constLine&l3){// Nhân chéo kiểu __int128 để tránh tràn sốreturn(__int128)(l3.c-l2.c)*(l1.m-l2.m)<=(__int128)(l2.c-l1.c)*(l2.m-l3.m);}// Thêm đường thẳng mới (yêu cầu m giảm dần)voidadd_line(longlongm,longlongc){Linenw={m,c};// Nếu trùng hệ số góc, chỉ giữ đường thẳng có tung độ gốc tốt hơnif(!hull.empty()&&hull.back().m==m){if(c>=hull.back().c)return;hull.pop_back();}while(hull.size()>=2&&is_bad(hull[hull.size()-2],hull.back(),nw)){hull.pop_back();}hull.push_back(nw);}// Trường hợp 1: x tăng dần (Duyệt tịnh tiến bằng con trỏ ptr)intptr=0;longlongquery_linear(longlongx){if(hull.empty())return1e18;ptr=min(ptr,(int)hull.size()-1);while(ptr+1<(int)hull.size()&&hull[ptr+1].eval(x)<=hull[ptr].eval(x)){ptr++;}returnhull[ptr].eval(x);}// Trường hợp 2: x bất kỳ (Tìm kiếm nhị phân)longlongquery_binary_search(longlongx){if(hull.empty())return1e18;intlo=0,hi=(int)hull.size()-1;while(lo<hi){intmid=(lo+hi)/2;if(hull[mid].eval(x)<=hull[mid+1].eval(x)){hi=mid;}else{lo=mid+1;}}returnhull[lo].eval(x);}};
classConvexHullTrick:def__init__(self):self.hull=[]# Danh sách các đường thẳng dạng (m, c)self.ptr=0def_is_bad(self,l1,l2,l3):# Nhân chéo tránh phép chia số thựcreturn(l3[1]-l2[1])*(l1[0]-l2[0])<=(l2[1]-l1[1])*(l2[0]-l3[0])defadd_line(self,m,c):nw=(m,c)ifself.hullandself.hull[-1][0]==m:ifc>=self.hull[-1][1]:returnself.hull.pop()whilelen(self.hull)>=2andself._is_bad(self.hull[-2],self.hull[-1],nw):self.hull.pop()self.hull.append(nw)defquery_linear(self,x):ifnotself.hull:returnfloat('inf')self.ptr=min(self.ptr,len(self.hull)-1)whileself.ptr+1<len(self.hull)and(self.hull[self.ptr+1][0]*x+self.hull[self.ptr+1][1])<=(self.hull[self.ptr][0]*x+self.hull[self.ptr][1]):self.ptr+=1returnself.hull[self.ptr][0]*x+self.hull[self.ptr][1]defquery_binary_search(self,x):ifnotself.hull:returnfloat('inf')lo,hi=0,len(self.hull)-1whilelo<hi:mid=(lo+hi)//2val1=self.hull[mid][0]*x+self.hull[mid][1]val2=self.hull[mid+1][0]*x+self.hull[mid+1][1]ifval1<=val2:hi=midelse:lo=mid+1returnself.hull[lo][0]*x+self.hull[lo][1]
Khi hệ số góc \(m\) được thêm vào theo thứ tự bất kỳ, ta không thể duy trì bao lồi bằng Deque. Mặc dù cấu trúc std::set động có thể giải quyết nhưng cài đặt cực kỳ phức tạp và dễ lỗi. Li Chao Tree là một cây phân đoạn Segment Tree được thiết kế để giải quyết bài toán CHT động một cách hiệu quả và ngắn gọn.
Cây quản lý miền giá trị của \(x\) là \([X_{min}, X_{max}]\). Mỗi nút của cây quản lý đoạn \([lo, hi]\) và lưu trữ đường thẳng tối ưu nhất tại hoành độ trung tâm \(mid = \lfloor (lo + hi)/2 \rfloor\).
Khi thêm một đường thẳng mới \(nw\) vào đoạn \([lo, hi]\) đang giữ đường thẳng \(cur\):
1. So sánh \(nw(mid)\) và \(cur(mid)\). Nếu \(nw\) tốt hơn, ta tráo đổi \(nw\) và \(cur\) để nút luôn lưu đường thẳng tốt nhất tại \(mid\).
2. Ta so sánh giá trị tại biên \(lo\) hoặc \(hi\) của \(nw\) và \(cur\) để quyết định đệ quy đẩy đường thẳng kém hơn xuống nút con bên trái hoặc bên phải.
classLine:def__init__(self,m=0,c=float('inf')):self.m=mself.c=cdefeval(self,x):returnself.m*x+self.cclassLiChaoTree:def__init__(self,lo,hi):self.min_x=loself.max_x=hi# Số lượng nút tối đa khoảng 4 * kích thước đoạnself.tree=[Line()for_inrange(4*(hi-lo+1))]def_add_line(self,node,lo,hi,nw):mid=(lo+hi)//2cur=self.tree[node]left_better=nw.eval(lo)<cur.eval(lo)mid_better=nw.eval(mid)<cur.eval(mid)ifmid_better:self.tree[node],nw=nw,curcur=self.tree[node]iflo==hi:returnifleft_better!=mid_better:self._add_line(2*node,lo,mid,nw)else:self._add_line(2*node+1,mid+1,hi,nw)defadd_line(self,m,c):self._add_line(1,self.min_x,self.max_x,Line(m,c))def_query(self,node,lo,hi,x):res=self.tree[node].eval(x)iflo==hi:returnresmid=(lo+hi)//2ifx<=mid:returnmin(res,self._query(2*node,lo,mid,x))else:returnmin(res,self._query(2*node+1,mid+1,hi,x))defquery(self,x):returnself._query(1,self.min_x,self.max_x,x)
5. Li Chao Tree kết hợp Nén tọa độ (Coordinate Compression)¶
Khi miền giá trị truy vấn \(x\) rất lớn (ví dụ \(10^9\)) nhưng số lượng điểm truy vấn thực tế nhỏ, ta có thể nén các giá trị truy vấn \(x\) thành các chỉ số từ \(0\) tới \(M-1\). Ta chạy Li Chao Tree trên không gian chỉ số này.
6. Bài toán kinh điển: Batch Scheduling (IOI 2002)¶
Bài toán: Có \(N\) công việc cần xử lý. Công việc thứ \(i\) có thời gian chạy \(t[i]\) và hệ số phạt \(f[i]\). Ta chia các công việc thành các nhóm liên tiếp (batch). Khi bắt đầu một batch mới, ta mất thời gian thiết lập \(S\). Tất cả các công việc trong một batch chỉ hoàn thành khi công việc cuối cùng của batch đó xong. Chi phí phạt của một công việc bằng tích thời điểm hoàn thành và hệ số phạt của nó. Tìm cách chia batch có tổng chi phí nhỏ nhất.
Gọi \(T[i] = \sum_{k=1}^i t[k]\) và \(F[i] = \sum_{k=1}^i f[k]\).
Công thức quy hoạch động tính từ cuối lên (hoặc tính xuôi bằng cách cộng dồn tác động thời gian thiết lập):
$\(dp[i] = \min_{0 \le j < i} \{ dp[j] + T[i] \cdot (F[N] - F[j]) + S \cdot (F[N] - F[j]) \}\)$
$\(dp[i] = \min_{0 \le j < i} \{ dp[j] - (T[i] + S) \cdot F[j] \} + (T[i] + S) \cdot F[N]\)$
Đây là cấu trúc CHT:
- Hoành độ truy vấn: \(x = T[i] + S\) (tăng dần).
- Hệ số góc đường thẳng: \(m_j = -F[j]\) (giảm dần do \(F\) tăng dần).
- Tung độ gốc: \(c_j = dp[j]\).
Ta áp dụng trực tiếp Static CHT Deque với \(O(N)\) thời gian.
#include<bits/stdc++.h>usingnamespacestd;intmain(){ios_base::sync_with_stdio(false);cin.tie(NULL);intn;longlongS;if(!(cin>>n>>S))return0;vector<longlong>t(n+1),f(n+1);for(inti=1;i<=n;i++)cin>>t[i]>>f[i];vector<longlong>T(n+1,0),F(n+1,0);for(inti=1;i<=n;i++){T[i]=T[i-1]+t[i];F[i]=F[i-1]+f[i];}deque<pair<longlong,longlong>>dq;// {m, c}dq.push_back({0,0});// dp[0] = 0autoeval=[](pair<longlong,longlong>line,longlongx){returnline.first*x+line.second;};autois_bad=[](pair<longlong,longlong>l1,pair<longlong,longlong>l2,pair<longlong,longlong>l3){return(__int128)(l3.second-l2.second)*(l1.first-l2.first)<=(__int128)(l2.second-l1.second)*(l2.first-l3.first);};vector<longlong>dp(n+1,0);for(inti=1;i<=n;i++){longlongx=T[i]+S;while(dq.size()>=2&&eval(dq[1],x)<=eval(dq[0],x)){dq.pop_front();}dp[i]=eval(dq[0],x)+(T[i]+S)*F[n]-S*F[i];// Chỉnh lại theo đúng công thức tích lũypair<longlong,longlong>nw={-F[i],dp[i]+S*F[i]};while(dq.size()>=2&&is_bad(dq[dq.size()-2],dq.back(),nw)){dq.pop_back();}dq.push_back(nw);}cout<<dp[n]<<"\n";return0;}
importsysfromcollectionsimportdequedefmain():input=sys.stdin.readdata=input().split()ifnotdata:returnn=int(data[0])S=int(data[1])t=[0]*(n+1)f=[0]*(n+1)idx=2foriinrange(1,n+1):t[i]=int(data[idx])f[i]=int(data[idx+1])idx+=2T=[0]*(n+1)F=[0]*(n+1)foriinrange(1,n+1):T[i]=T[i-1]+t[i]F[i]=F[i-1]+f[i]dq=deque()dq.append((0,0))# (m, c) của dp[0]defeval_line(line,x):returnline[0]*x+line[1]defis_bad(l1,l2,l3):return(l3[1]-l2[1])*(l1[0]-l2[0])<=(l2[1]-l1[1])*(l2[0]-l3[0])dp=[0]*(n+1)foriinrange(1,n+1):x=T[i]+Swhilelen(dq)>=2andeval_line(dq[1],x)<=eval_line(dq[0],x):dq.popleft()dp[i]=eval_line(dq[0],x)+(T[i]+S)*F[n]-S*F[i]nw=(-F[i],dp[i]+S*F[i])whilelen(dq)>=2andis_bad(dq[-2],dq[-1],nw):dq.pop()dq.append(nw)print(dp[n])if__name__=='__main__':main()
Khi kiểm tra điều kiện chèn đường thẳng:
$\((c_3 - c_2)(m_1 - m_2) \le (c_2 - c_1)(m_2 - m_3)\)$
Các giá trị có thể đạt tới \(10^{18}\), tích của chúng sẽ vượt quá giới hạn kiểu long long. Luôn sử dụng số nguyên lớn __int128 trong C++ hoặc kiểu số thực lớn nếu cần thiết để đảm bảo độ chính xác.
[!IMPORTANT]
2. Xử lý đường thẳng trùng hệ số góc (Coincident Slopes)¶
Khi hai đường thẳng có cùng hệ số góc \(m_1 = m_2\), chúng song song nhau. Khoảng cách giao điểm sẽ tiến tới vô cùng.
- Nếu \(c_1 \le c_2\), đường thẳng \(L_2\) hoàn toàn nằm phía trên \(L_1\) nên nó vô dụng.Ta bắt buộc phải loại bỏ đường thẳng có tung độ gốc kém hơn trước khi thực hiện so sánh chéo để tránh lỗi chia cho \(0\).