Перейти до основного вмісту

Трюк опуклої оболонки (Convex Hull Trick) і дерево Лі Чао

Розглянемо таку задачу. Є nn міст. Ми хочемо доїхати на машині з міста 11 до міста nn. Для цього потрібно купувати бензин. Відомо, що літр бензину коштує costkcost_k у kk-му місті. Спочатку наш бак порожній, і ми витрачаємо один літр бензину на кілометр. Міста розташовані на одній прямій у порядку зростання, причому kk-те місто має координату xkx_k. Крім того, за в'їзд у kk-те місто доводиться платити tollktoll_k. Наше завдання — здійснити поїздку з мінімально можливою вартістю. Очевидно, що розв'язок можна обчислити за допомогою динамічного програмування:

dpi=tolli+minj<i(costj(xixj)+dpj)dp_i = toll_i+\min\limits_{j<i}(cost_j \cdot (x_i - x_j)+dp_j)

Наївний підхід дасть нам складність O(n2)O(n^2), яку можна покращити до O(nlogn)O(n \log n) або O(nlog[Cε1])O(n \log [C \varepsilon^{-1}]), де CC — найбільше можливе xi|x_i|, а ε\varepsilon — точність, з якою розглядаються xix_i (ε=1\varepsilon = 1 для цілих чисел, що зазвичай і є нашим випадком). Для цього варто зауважити, що задача зводиться до додавання лінійних функцій kx+bk \cdot x + b до множини та знаходження мінімального значення цих функцій у деякій конкретній точці xx. Тут можна застосувати два основні підходи.

Коли підходить цей алгоритм?
  • Переходи ДП мають вигляд dpi=minj(kjxi+bj)+constdp_i = \min_j(k_j \cdot x_i + b_j) + \text{const}, тобто оптимізуєш мінімум лінійних функцій у точці?
  • Тобі справді потрібна оптимізація ДП, а не опукла оболонка множини точок? (якщо ні → Побудова опуклої оболонки)
  • Запити онлайн або кутові коефіцієнти не монотонні? Тоді бери дерево Лі Чао замість самого трюку.

Трюк опуклої оболонки (Convex Hull Trick)

Ідея цього підходу полягає в тому, щоб підтримувати нижню опуклу оболонку лінійних функцій. Насправді буде трохи зручніше розглядати їх не як лінійні функції, а як точки (k;b)(k;b) на площині, так що нам доведеться знаходити точку, яка має найменший скалярний добуток із заданою точкою (x;1)(x;1), тобто для цієї точки kx+bkx+b мінімізується, що те саме, що й вихідна задача. Такий мінімум обов'язково лежатиме на нижній опуклій огинаючій цих точок, як видно нижче:

lower convex hull

Потрібно зберігати точки опуклої оболонки та вектори нормалей до її ребер. Коли надходить запит (x;1)(x;1), доведеться знайти вектор нормалі, найближчий до нього за кутом між ними, тоді оптимальна лінійна функція відповідатиме одному з його кінців. Щоб це побачити, варто зауважити, що точки, які мають сталий скалярний добуток із (x;1)(x;1), лежать на прямій, ортогональній до (x;1)(x;1), тож оптимальною лінійною функцією буде та, у якій дотична до опуклої оболонки, колінеарна нормалі до (x;1)(x;1), торкається оболонки. Це така точка, що нормалі ребер, які лежать ліворуч і праворуч від неї, спрямовані в різні боки від (x;1)(x;1).

Цей підхід корисний, коли запити на додавання лінійних функцій монотонні щодо kk або якщо ми працюємо офлайн, тобто можемо спочатку додати всі лінійні функції, а вже потім відповідати на запити. Тож ми не можемо розв'язати задачу про міста/бензин у такий спосіб. Це вимагало б обробки онлайн-запитів. Однак, коли йдеться про обробку онлайн-запитів, усе стає складніше, і доведеться скористатися якоюсь множинною структурою даних, щоб реалізувати належну опуклу оболонку. Проте онлайн-підхід не розглядатиметься в цій статті через його складність і тому, що другий підхід (а саме дерево Лі Чао) дозволяє розв'язати задачу значно простіше. Варто згадати, що цей підхід все ж можна використовувати онлайн без ускладнень за допомогою кореневого розбиття. Тобто перебудовувати опуклу оболонку з нуля кожні n\sqrt n нових прямих.

Щоб реалізувати цей підхід, слід почати з деяких геометричних допоміжних функцій, тут ми пропонуємо використати тип комплексних чисел із C++.

typedef int ftype;
typedef complex<ftype> point;
#define x real
#define y imag

ftype dot(point a, point b) {
return (conj(a) * b).x();
}

ftype cross(point a, point b) {
return (conj(a) * b).y();
}

Тут ми вважатимемо, що при додаванні лінійних функцій їхній kk лише зростає, і ми хочемо знаходити мінімальні значення. Точки зберігатимемо у векторі hullhull, а вектори нормалей — у векторі vecsvecs. Коли ми додаємо нову точку, треба подивитися на кут, утворений між останнім ребром опуклої оболонки та вектором від останньої точки оболонки до нової точки. Цей кут має бути спрямований проти годинникової стрілки, тобто скалярний добуток останнього вектора нормалі в оболонці (спрямованого всередину оболонки) та вектора від останньої точки до нової має бути невід'ємним. Доки це не виконується, ми маємо видаляти останню точку опуклої оболонки разом із відповідним ребром.

vector<point> hull, vecs;

void add_line(ftype k, ftype b) {
point nw = {k, b};
while(!vecs.empty() && dot(vecs.back(), nw - hull.back()) < 0) {
hull.pop_back();
vecs.pop_back();
}
if(!hull.empty()) {
vecs.push_back(1i * (nw - hull.back()));
}
hull.push_back(nw);
}

Тепер, щоб отримати мінімальне значення в деякій точці, ми знайдемо перший вектор нормалі в опуклій оболонці, спрямований проти годинникової стрілки від (x;1)(x;1). Лівий кінець такого ребра і буде відповіддю. Щоб перевірити, чи вектор aa не спрямований проти годинникової стрілки від вектора bb, слід перевірити, чи їхній векторний добуток [a,b][a,b] додатний.

int get(ftype x) {
point query = {x, 1};
auto it = lower_bound(vecs.begin(), vecs.end(), query, [](point a, point b) {
return cross(a, b) > 0;
});
return dot(query, hull[it - vecs.begin()]);
}

Дерево Лі Чао

Припустимо, нам дано множину функцій, таких, що кожні дві з них перетинаються щонайбільше один раз. Зберігатимемо в кожній вершині дерева відрізків деяку функцію так, щоб, рухаючись від кореня до листка, ми гарантовано зустріли одну з функцій на шляху, яка дає мінімальне значення в цьому листку. Подивімося, як це побудувати.

Припустимо, ми перебуваємо в деякій вершині, що відповідає напіввідрізку [l,r)[l,r), і там зберігається функція foldf_{old}, а ми додаємо функцію fnewf_{new}. Тоді точка перетину буде або в [l;m)[l;m), або в [m;r)[m;r), де m=l+r2m=\left\lfloor\tfrac{l+r}{2}\right\rfloor. Ми можемо ефективно це з'ясувати, порівнявши значення функцій у точках ll та mm. Якщо домінуюча функція змінюється, то перетин у [l;m)[l;m), інакше — у [m;r)[m;r). Тепер для тієї половини відрізка, де перетину немає, ми оберемо нижню функцію та запишемо її в поточну вершину. Можна бачити, що це завжди буде та функція, яка нижча в точці mm. Після цього ми рекурсивно переходимо до іншої половини відрізка з функцією, що була верхньою. Як бачите, це збереже коректність на першій половині відрізка, а в іншій коректність буде підтримана під час рекурсивного виклику. Отже, ми можемо додавати функції та перевіряти мінімальне значення в точці за O(log[Cε1])O(\log [C\varepsilon^{-1}]).

Ось ілюстрація того, що відбувається у вершині, коли ми додаємо нову функцію:

Li Chao Tree vertex

Перейдімо тепер до реалізації. Знову ж таки, для зберігання лінійних функцій ми скористаємося комплексними числами.

typedef long long ftype;
typedef complex<ftype> point;
#define x real
#define y imag

ftype dot(point a, point b) {
return (conj(a) * b).x();
}

ftype f(point a, ftype x) {
return dot(a, {x, 1});
}

Функції зберігатимемо в масиві lineline та використовуватимемо бінарну індексацію дерева відрізків. Якщо ви хочете застосувати це до великих чисел або чисел з рухомою комою, слід використати динамічне дерево відрізків. Дерево відрізків має бути ініціалізоване значеннями за замовчуванням, наприклад, прямими 0x+0x + \infty.

const int maxn = 2e5;

point line[4 * maxn];

void add_line(point nw, int v = 1, int l = 0, int r = maxn) {
int m = (l + r) / 2;
bool lef = f(nw, l) < f(line[v], l);
bool mid = f(nw, m) < f(line[v], m);
if(mid) {
swap(line[v], nw);
}
if(r - l == 1) {
return;
} else if(lef != mid) {
add_line(nw, 2 * v, l, m);
} else {
add_line(nw, 2 * v + 1, m, r);
}
}

Тепер, щоб отримати мінімум у деякій точці xx, ми просто обираємо мінімальне значення вздовж шляху до цієї точки.

ftype get(int x, int v = 1, int l = 0, int r = maxn) {
int m = (l + r) / 2;
if(r - l == 1) {
return f(line[v], x);
} else if(x < m) {
return min(f(line[v], x), get(x, 2 * v, l, m));
} else {
return min(f(line[v], x), get(x, 2 * v + 1, m, r));
}
}

Задачі