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

Найдовша зростаюча підпослідовність

Нам дано масив із nn чисел: a[0n1]a[0 \dots n-1]. Завдання — знайти найдовшу строго зростаючу підпослідовність у aa.

Формально ми шукаємо найдовшу послідовність індексів i1,iki_1, \dots i_k таку, що

i1<i2<<ik,a[i1]<a[i2]<<a[ik]i_1 < i_2 < \dots < i_k,\quad a[i_1] < a[i_2] < \dots < a[i_k]

У цій статті ми розглянемо кілька алгоритмів розв'язання цього завдання. Також ми обговоримо деякі інші задачі, які можна звести до цієї.

Коли підходить цей алгоритм?
  • Чи шукаєте ви найдовшу зростаючу (упорядковану) підпослідовність елементів масиву зі збереженням їхнього порядку, а не суцільний підвідрізок?
  • Чи nn велике настільки, що квадратичне ДП за O(n2)O(n^2) надто повільне і потрібен розв'язок за O(nlogn)O(n \log n)? (якщо nn мале — достатньо простого ДП за O(n2)O(n^2))
  • Чи можна звести вашу задачу до LIS (наприклад, найдовша спільна підпослідовність двох перестановок)?

Розв'язок за O(n2)O(n^2) через динамічне програмування

Динамічне програмування — це дуже загальна техніка, яка дозволяє розв'язувати величезний клас задач. Тут ми застосуємо цю техніку до нашого конкретного завдання.

Спершу ми шукатимемо лише довжину найдовшої зростаючої підпослідовності, а вже потім навчимося відновлювати саму підпослідовність.

Пошук довжини

Щоб виконати це завдання, ми визначимо масив d[0n1]d[0 \dots n-1], де d[i]d[i] — це довжина найдовшої зростаючої підпослідовності, яка закінчується елементом з індексом ii.

інформація
a={8,3,4,6,5,2,0,7,9,1}d={1,1,2,3,3,1,1,4,5,2}\begin{array}{ll} a &= \{8, 3, 4, 6, 5, 2, 0, 7, 9, 1\} \\ d &= \{1, 1, 2, 3, 3, 1, 1, 4, 5, 2\} \end{array}

Найдовша зростаюча підпослідовність, яка закінчується на індексі 4, — це {3,4,5}\{3, 4, 5\} з довжиною 3; найдовша, що закінчується на індексі 8, — це або {3,4,5,7,9}\{3, 4, 5, 7, 9\}, або {3,4,6,7,9}\{3, 4, 6, 7, 9\}, обидві мають довжину 5; а найдовша, що закінчується на індексі 9, — це {0,1}\{0, 1\} з довжиною 2.

Ми обчислюватимемо цей масив поступово: спочатку d[0]d[0], потім d[1]d[1] і так далі. Після того як цей масив обчислено, відповіддю до задачі буде максимальне значення в масиві d[]d[].

Отже, нехай поточний індекс — це ii. Тобто ми хочемо обчислити значення d[i]d[i], а всі попередні значення d[0],,d[i1]d[0], \dots, d[i-1] уже відомі. Тоді є два варіанти:

  • d[i]=1d[i] = 1: шукана підпослідовність складається лише з елемента a[i]a[i].

  • d[i]>1d[i] > 1: підпослідовність закінчуватиметься на a[i]a[i], а безпосередньо перед ним буде якесь число a[j]a[j] з j<ij < i та a[j]<a[i]a[j] < a[i].

    Легко бачити, що підпослідовність, яка закінчується на a[j]a[j], сама буде однією з найдовших зростаючих підпослідовностей, що закінчуються на a[j]a[j]. Число a[i]a[i] просто продовжує цю найдовшу зростаючу підпослідовність ще на одне число.

    Тому ми можемо просто перебрати всі j<ij < i з a[j]<a[i]a[j] < a[i] і взяти найдовшу послідовність, яку отримуємо, дописуючи a[i]a[i] до найдовшої зростаючої підпослідовності, що закінчується на a[j]a[j]. Найдовша зростаюча підпослідовність, що закінчується на a[j]a[j], має довжину d[j]d[j], і її продовження на одне число дає довжину d[j]+1d[j] + 1.

    d[i]=maxj<ia[j]<a[i](d[j]+1)d[i] = \max_{\substack{j < i \\\\ a[j] < a[i]}} \left(d[j] + 1\right)

Якщо поєднати ці два випадки, отримуємо остаточну відповідь для d[i]d[i]:

d[i]=max(1,maxj<ia[j]<a[i](d[j]+1))d[i] = \max\left(1, \max_{\substack{j < i \\\\ a[j] < a[i]}} \left(d[j] + 1\right)\right)

Реалізація

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

int lis(vector<int> const& a) {
int n = a.size();
vector<int> d(n, 1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i])
d[i] = max(d[i], d[j] + 1);
}
}

int ans = d[0];
for (int i = 1; i < n; i++) {
ans = max(ans, d[i]);
}
return ans;
}

Відновлення підпослідовності

Поки що ми навчилися лише знаходити довжину підпослідовності, але не саму підпослідовність.

Щоб мати змогу відновити підпослідовність, ми створимо додатковий допоміжний масив p[0n1]p[0 \dots n-1], який обчислюватимемо разом із масивом d[]d[]. p[i]p[i] буде індексом jj передостаннього елемента в найдовшій зростаючій підпослідовності, що закінчується на ii. Інакше кажучи, індекс p[i]p[i] — це той самий індекс jj, на якому було отримано найбільше значення d[i]d[i]. Цей допоміжний масив p[]p[] у певному сенсі вказує на предків.

Тоді, щоб отримати підпослідовність, ми просто починаємо з індексу ii з максимальним d[i]d[i] і йдемо за предками, доки не виведемо всю підпослідовність, тобто доки не досягнемо елемента з d[i]=1d[i] = 1.

Реалізація відновлення

Ми трохи змінимо код із попередніх розділів. Ми обчислюватимемо масив p[]p[] разом із d[]d[], а потім обчислимо підпослідовність.

Для зручності спочатку присвоюємо предкам p[i]=1p[i] = -1. Для елементів з d[i]=1d[i] = 1 значення предка залишиться 1-1, що буде трохи зручнішим для відновлення підпослідовності.

vector<int> lis(vector<int> const& a) {
int n = a.size();
vector<int> d(n, 1), p(n, -1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[j] < a[i] && d[i] < d[j] + 1) {
d[i] = d[j] + 1;
p[i] = j;
}
}
}

int ans = d[0], pos = 0;
for (int i = 1; i < n; i++) {
if (d[i] > ans) {
ans = d[i];
pos = i;
}
}

vector<int> subseq;
while (pos != -1) {
subseq.push_back(a[pos]);
pos = p[pos];
}
reverse(subseq.begin(), subseq.end());
return subseq;
}

Альтернативний спосіб відновлення підпослідовності

Відновити підпослідовність можна й без допоміжного масиву p[]p[]. Ми можемо просто переобчислити поточне значення d[i]d[i] і водночас побачити, як було досягнуто максимуму.

Цей спосіб приводить до трохи довшого коду, але натомість ми заощаджуємо трохи пам'яті.

Розв'язок за O(nlogn)O(n \log n) через динамічне програмування та бінарний пошук

Щоб отримати швидший розв'язок задачі, ми побудуємо інший розв'язок методом динамічного програмування, який працює за O(n2)O(n^2), а потім згодом покращимо його до O(nlogn)O(n \log n).

Ми використаємо масив динамічного програмування d[0n]d[0 \dots n]. Цього разу d[l]d[l] відповідає не елементу a[i]a[i] і не префіксу масиву. d[l]d[l] буде найменшим елементом, на якому закінчується зростаюча підпослідовність довжини ll.

Спочатку ми вважаємо, що d[0]=d[0] = -\infty, а для всіх інших довжин d[l]=d[l] = \infty.

Ми знову поступово оброблятимемо числа, спочатку a[0]a[0], потім a[1]a[1] і т. д., і на кожному кроці підтримуватимемо масив d[]d[] актуальним.

інформація

Для масиву a={8,3,4,6,5,2,0,7,9,1}a = \{8, 3, 4, 6, 5, 2, 0, 7, 9, 1\} нижче наведено всі його префікси та відповідні їм масиви динамічного програмування. Зауважте, що значення масиву не завжди змінюються в кінці.

префікс={}d={,,}префікс={8}d={,8,,}префікс={8,3}d={,3,,}префікс={8,3,4}d={,3,4,,}префікс={8,3,4,6}d={,3,4,6,,}префікс={8,3,4,6,5}d={,3,4,5,,}префікс={8,3,4,6,5,2}d={,2,4,5,,}префікс={8,3,4,6,5,2,0}d={,0,4,5,,}префікс={8,3,4,6,5,2,0,7}d={,0,4,5,7,,}префікс={8,3,4,6,5,2,0,7,9}d={,0,4,5,7,9,,}префікс={8,3,4,6,5,2,0,7,9,1}d={,0,1,5,7,9,,}\begin{array}{ll} \text{префікс} = \{\} &\quad d = \{-\infty, \infty, \dots\}\\ \text{префікс} = \{8\} &\quad d = \{-\infty, 8, \infty, \dots\}\\ \text{префікс} = \{8, 3\} &\quad d = \{-\infty, 3, \infty, \dots\}\\ \text{префікс} = \{8, 3, 4\} &\quad d = \{-\infty, 3, 4, \infty, \dots\}\\ \text{префікс} = \{8, 3, 4, 6\} &\quad d = \{-\infty, 3, 4, 6, \infty, \dots\}\\ \text{префікс} = \{8, 3, 4, 6, 5\} &\quad d = \{-\infty, 3, 4, 5, \infty, \dots\}\\ \text{префікс} = \{8, 3, 4, 6, 5, 2\} &\quad d = \{-\infty, 2, 4, 5, \infty, \dots \}\\ \text{префікс} = \{8, 3, 4, 6, 5, 2, 0\} &\quad d = \{-\infty, 0, 4, 5, \infty, \dots \}\\ \text{префікс} = \{8, 3, 4, 6, 5, 2, 0, 7\} &\quad d = \{-\infty, 0, 4, 5, 7, \infty, \dots \}\\ \text{префікс} = \{8, 3, 4, 6, 5, 2, 0, 7, 9\} &\quad d = \{-\infty, 0, 4, 5, 7, 9, \infty, \dots \}\\ \text{префікс} = \{8, 3, 4, 6, 5, 2, 0, 7, 9, 1\} &\quad d = \{-\infty, 0, 1, 5, 7, 9, \infty, \dots \}\\ \end{array}

Коли ми обробляємо a[i]a[i], можна поставити собі запитання. Якими мають бути умови, щоб ми записали поточне число a[i]a[i] до масиву d[0n]d[0 \dots n]?

Ми покладаємо d[l]=a[i]d[l] = a[i], якщо існує найдовша зростаюча послідовність довжини ll, що закінчується на a[i]a[i], і немає найдовшої зростаючої послідовності довжини ll, яка закінчується меншим числом. Подібно до попереднього підходу, якщо ми вилучимо число a[i]a[i] з найдовшої зростаючої послідовності довжини ll, то отримаємо іншу найдовшу зростаючу послідовність довжини l1l - 1. Тож ми хочемо продовжити найдовшу зростаючу послідовність довжини l1l - 1 числом a[i]a[i], і очевидно, що найкраще підійде та найдовша зростаюча послідовність довжини l1l - 1, яка закінчується найменшим елементом, інакше кажучи, послідовність довжини l1l-1, що закінчується елементом d[l1]d[l-1].

Найдовша зростаюча послідовність довжини l1l - 1, яку ми можемо продовжити числом a[i]a[i], існує саме тоді, коли d[l1]<a[i]d[l-1] < a[i]. Тож ми можемо просто перебирати кожну довжину ll і перевіряти, чи можемо продовжити найдовшу зростаючу послідовність довжини l1l - 1, перевіряючи цей критерій.

Додатково нам також потрібно перевірити, чи, можливо, ми вже знайшли найдовшу зростаючу послідовність довжини ll з меншим числом у кінці. Тож ми оновлюємо лише якщо a[i]<d[l]a[i] < d[l].

Після обробки всіх елементів a[]a[] довжина шуканої підпослідовності — це найбільше ll з d[l]<d[l] < \infty.

int lis(vector<int> const& a) {
int n = a.size();
const int INF = 1e9;
vector<int> d(n+1, INF);
d[0] = -INF;

for (int i = 0; i < n; i++) {
for (int l = 1; l <= n; l++) {
if (d[l-1] < a[i] && a[i] < d[l])
d[l] = a[i];
}
}

int ans = 0;
for (int l = 0; l <= n; l++) {
if (d[l] < INF)
ans = l;
}
return ans;
}

Тепер зробимо два важливі спостереження.

  1. Масив dd завжди буде відсортованим: d[l1]<d[l]d[l-1] < d[l] для всіх i=1ni = 1 \dots n.

    Це тривіально, адже ви можете просто вилучити останній елемент зі зростаючої підпослідовності довжини ll і отримаєте зростаючу підпослідовність довжини l1l-1 з меншим завершальним числом.

  2. Елемент a[i]a[i] оновить щонайбільше одне значення d[l]d[l].

    Це безпосередньо випливає з наведеної вище реалізації. У масиві може бути лише одне місце з d[l1]<a[i]<d[l]d[l-1] < a[i] < d[l].

Отже, ми можемо знайти цей елемент у масиві d[]d[] за допомогою бінарного пошуку за O(logn)O(\log n). Фактично ми можемо просто шукати в масиві d[]d[] перше число, яке строго більше за a[i]a[i], і намагатися оновити цей елемент так само, як у наведеній вище реалізації.

Реалізація

Це дає нам покращену реалізацію за O(nlogn)O(n \log n):

int lis(vector<int> const& a) {
int n = a.size();
const int INF = 1e9;
vector<int> d(n+1, INF);
d[0] = -INF;

for (int i = 0; i < n; i++) {
int l = upper_bound(d.begin(), d.end(), a[i]) - d.begin();
if (d[l-1] < a[i] && a[i] < d[l])
d[l] = a[i];
}

int ans = 0;
for (int l = 0; l <= n; l++) {
if (d[l] < INF)
ans = l;
}
return ans;
}

Відновлення підпослідовності

За допомогою цього підходу також можливо відновити підпослідовність. Цього разу нам доведеться підтримувати два допоміжні масиви. Один із них повідомляє нам індекс елементів у d[]d[]. І знову нам потрібно створити масив «предків» p[i]p[i]. p[i]p[i] буде індексом попереднього елемента для оптимальної підпослідовності, що закінчується на елементі ii.

Ці два масиви легко підтримувати під час ітерації по масиву a[]a[] паралельно з обчисленнями d[]d[]. А наприкінці неважко відновити шукану підпослідовність за допомогою цих масивів.

Розв'язок за O(nlogn)O(n \log n) зі структурами даних

Замість наведеного вище способу обчислення найдовшої зростаючої підпослідовності за O(nlogn)O(n \log n) ми можемо розв'язати задачу й по-іншому: використовуючи деякі прості структури даних.

Повернімося до першого методу. Згадаймо, що d[i]d[i] — це значення d[j]+1d[j] + 1 з j<ij < i та a[j]<a[i]a[j] < a[i].

Отже, якщо ми визначимо додатковий масив t[]t[] такий, що

t[a[i]]=d[i],t[a[i]] = d[i],

то задача обчислення значення d[i]d[i] еквівалентна знаходженню максимального значення на префіксі масиву t[]t[]:

d[i]=max(t[0a[i]1]+1)d[i] = \max\left(t[0 \dots a[i] - 1] + 1\right)

Задача знаходження максимуму на префіксі масиву (який змінюється) — це стандартна задача, яку можна розв'язати багатьма різними структурами даних. Наприклад, ми можемо використати дерево відрізків або дерево Фенвіка.

Цей метод, очевидно, має деякі недоліки: з точки зору довжини та складності реалізації цей підхід буде гіршим за метод із використанням бінарного пошуку. До того ж, якщо вхідні числа a[i]a[i] особливо великі, нам довелося б застосовувати певні прийоми, як-от стиснення чисел (тобто перенумерування їх від 00 до n1n-1) або використання динамічного дерева відрізків (генерувати лише ті гілки дерева, які важливі). Інакше споживання пам'яті буде надто високим.

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

Ось кілька задач, які тісно пов'язані із задачею знаходження найдовшої зростаючої підпослідовності.

Найдовша неспадна підпослідовність

Це насправді майже та сама задача. Тільки тепер у підпослідовності дозволено використовувати однакові числа.

Розв'язок, по суті, також майже той самий. Нам лише потрібно змінити знаки нерівностей і трохи модифікувати бінарний пошук.

Кількість найдовших зростаючих підпослідовностей

Ми можемо використати перший розглянутий метод — або версію за O(n2)O(n^2), або версію зі структурами даних. Нам лише потрібно додатково зберігати, скількома способами можна отримати найдовші зростаючі підпослідовності, що закінчуються значеннями d[i]d[i].

Кількість способів утворити найдовші зростаючі підпослідовності, що закінчуються на a[i]a[i], — це сума всіх способів для всіх найдовших зростаючих підпослідовностей, що закінчуються на jj, де d[j]d[j] максимальне. Таких jj може бути кілька, тож нам потрібно підсумувати їх усі.

За допомогою дерева відрізків цей підхід також можна реалізувати за O(nlogn)O(n \log n).

Для цієї задачі неможливо використати підхід із бінарним пошуком.

Найменша кількість незростаючих підпослідовностей, що покривають послідовність

Для заданого масиву з nn чисел a[0n1]a[0 \dots n - 1] нам потрібно розфарбувати числа в найменшу кількість кольорів так, щоб кожен колір утворював незростаючу підпослідовність.

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

Доведення: Нам потрібно довести двоїстість цих двох задач.

Позначимо через xx довжину найдовшої зростаючої підпослідовності, а через yy — найменшу кількість незростаючих підпослідовностей, що утворюють покриття. Нам потрібно довести, що x=yx = y.

Зрозуміло, що y<xy < x неможливе, бо якщо в нас є xx строго зростаючих елементів, то жодні два з них не можуть належати до однієї й тієї самої незростаючої підпослідовності. Тому маємо yxy \ge x.

Тепер покажемо, що y>xy > x неможливе, від супротивного. Припустимо, що y>xy > x. Тоді розглянемо будь-який оптимальний набір із yy незростаючих підпослідовностей. Перетворимо цей набір таким чином: поки існують дві такі підпослідовності, що перша починається раніше за другу, і перша послідовність починається з числа, більшого за або рівного першому числу другої, ми відчіпляємо це початкове число й приєднуємо його до початку другої. Після скінченної кількості кроків ми маємо yy підпослідовностей, і їхні початкові числа утворюватимуть зростаючу підпослідовність довжини yy. Оскільки ми припустили, що y>xy > x, ми дійшли до суперечності.

Отже, звідси випливає, що y=xy = x.

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

Задачі для практики

Відеоматеріали