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

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

Нам дано масив із nn чисел: a[0…n−1]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(nlog⁡n)O(n \log n)? (якщо nn мале — достатньо простого ДП за O(n2)O(n^2))
  • Чи можна звести вашу задачу до LIS (наприклад, найдовша спільна підпослідовність двох перестановок)?

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

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

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

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

Щоб виконати це завдання, ми визначимо масив d[0…n−1]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[i−1]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]=max⁡j<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,max⁡j<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[0…n−1]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(nlog⁡n)O(n \log n) через динамічне програмування та бінарний пошук​

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

Ми використаємо масив динамічного програмування d[0…n]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[0…n]d[0 \dots n]?

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

Найдовша зростаюча послідовність довжини l−1l - 1, яку ми можемо продовжити числом a[i]a[i], існує саме тоді, коли d[l−1]<a[i]d[l-1] < a[i]. Тож ми можемо просто перебирати кожну довжину ll і перевіряти, чи можемо продовжити найдовшу зростаючу послідовність довжини l−1l - 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[l−1]<d[l]d[l-1] < d[l] для всіх i=1…ni = 1 \dots n.

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

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

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

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

Реалізація​

Це дає нам покращену реалізацію за O(nlog⁡n)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(nlog⁡n)O(n \log n) зі структурами даних​

Замість наведеного вище способу обчислення найдовшої зростаючої підпослідовності за O(nlog⁡n)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[0…a[i]−1]+1)d[i] = \max\left(t[0 \dots a[i] - 1] + 1\right)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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