Оптимізація Кнута
Оптимізація Кнута, також відома як прискорення Кнута-Яо (Knuth-Yao Speedup), — це особливий випадок динамічного програмування на відрізках, який дозволяє покращити часову складність розв'язків у лінійну кількість разів: зі стандартного для ДП на відрізках до .
- Чи має перехід вигляд ДП на відрізках , який потрібно прискорити з до ?
- Чи виконується нерівність на оптимальну точку розбиття (достатньо, щоб задовольняла та нерівність чотирикутника для )?
- Чи розбиття відрізка на дві частини, а не вибір однієї точки з попереднього рядка? (якщо ні → ДП «розділяй і володарюй»)
Умови
Прискорення застосовне для переходів вигляду
Подібно до ДП «розділяй і володарюй», нехай — це максимальне значення , яке мінімізує вираз у переході (далі в цій статті ми називаємо «оптимальною точкою розбиття»). Оптимізація вимагає, щоб виконувалося таке:
Ми можемо показати, що це правда, коли функція вартості задовольняє такі умови для :
-
;
-
(нерівність чотирикутника [QI]).
Цей результат доведено нижче.
Алгоритм
Оброблятимемо стани ДП так, щоб обчислити та перед , а заразом обчислити й та . Тоді для обчислення замість перебору значень від до нам достатньо перебрати лише від до . Щоб обробляти пари у такому порядку, достатньо використати вкладені цикли for, у яких йде від максимального значення до мінімального, а — від до максимального значення.
Загальна реалізація
Хоча реалізація може відрізнятися, ось доволі загальний приклад. Структура коду майже ідентична до ДП на відрізках.
- C++
- Python
- TypeScript
- Go
int solve() {
int N;
... // зчитуємо N та вхідні дані
int dp[N][N], opt[N][N];
auto C = [&](int i, int j) {
... // Реалізуємо функцію вартості C.
};
for (int i = 0; i < N; i++) {
opt[i][i] = i;
... // Ініціалізуємо dp[i][i] відповідно до задачі
}
for (int i = N-2; i >= 0; i--) {
for (int j = i+1; j < N; j++) {
int mn = INT_MAX;
int cost = C(i, j);
for (int k = opt[i][j-1]; k <= min(j-1, opt[i+1][j]); k++) {
if (mn >= dp[i][k] + dp[k+1][j] + cost) {
opt[i][j] = k;
mn = dp[i][k] + dp[k+1][j] + cost;
}
}
dp[i][j] = mn;
}
}
return dp[0][N-1];
}
def solve():
N = ... # зчитуємо N та вхідні дані
dp = [[0] * N for _ in range(N)]
opt = [[0] * N for _ in range(N)]
def C(i, j):
... # Реалізуємо функцію вартості C.
for i in range(N):
opt[i][i] = i
... # Ініціалізуємо dp[i][i] відповідно до задачі
# i йде від максимального значення до мінімального,
# тож dp(i, j-1) та dp(i+1, j) уже обчислені до dp(i, j)
for i in range(N - 2, -1, -1):
for j in range(i + 1, N):
mn = float("inf")
cost = C(i, j)
# перебираємо лише вікно [opt(i, j-1), opt(i+1, j)]
for k in range(opt[i][j - 1], min(j - 1, opt[i + 1][j]) + 1):
if mn >= dp[i][k] + dp[k + 1][j] + cost:
opt[i][j] = k
mn = dp[i][k] + dp[k + 1][j] + cost
dp[i][j] = mn
return dp[0][N - 1]
function solve(): number {
const N: number = ...; // зчитуємо N та вхідні дані
const dp: number[][] = Array.from({ length: N }, () => new Array(N).fill(0));
const opt: number[][] = Array.from({ length: N }, () => new Array(N).fill(0));
const C = (i: number, j: number): number => {
...; // Реалізуємо функцію вартості C.
};
for (let i = 0; i < N; i++) {
opt[i][i] = i;
...; // Ініціалізуємо dp[i][i] відповідно до задачі
}
// i йде від максимального значення до мінімального,
// тож dp(i, j-1) та dp(i+1, j) уже обчислені до dp(i, j)
for (let i = N - 2; i >= 0; i--) {
for (let j = i + 1; j < N; j++) {
let mn = Infinity;
const cost = C(i, j);
// перебираємо лише вікно [opt(i, j-1), opt(i+1, j)]
for (let k = opt[i][j - 1]; k <= Math.min(j - 1, opt[i + 1][j]); k++) {
if (mn >= dp[i][k] + dp[k + 1][j] + cost) {
opt[i][j] = k;
mn = dp[i][k] + dp[k + 1][j] + cost;
}
}
dp[i][j] = mn;
}
}
return dp[0][N - 1];
}
func solve() int {
var N int
... // зчитуємо N та вхідні дані
dp := make([][]int, N)
opt := make([][]int, N)
for i := range dp {
dp[i] = make([]int, N)
opt[i] = make([]int, N)
}
C := func(i, j int) int {
... // Реалізуємо функцію вартості C.
}
for i := 0; i < N; i++ {
opt[i][i] = i
... // Ініціалізуємо dp[i][i] відповідно до задачі
}
// i йде від максимального значення до мінімального,
// тож dp(i, j-1) та dp(i+1, j) уже обчислені до dp(i, j)
for i := N - 2; i >= 0; i-- {
for j := i + 1; j < N; j++ {
mn := math.MaxInt
cost := C(i, j)
// перебираємо лише вікно [opt(i, j-1), opt(i+1, j)]
for k := opt[i][j-1]; k <= min(j-1, opt[i+1][j]); k++ {
if mn >= dp[i][k]+dp[k+1][j]+cost {
opt[i][j] = k
mn = dp[i][k] + dp[k+1][j] + cost
}
}
dp[i][j] = mn
}
}
return dp[0][N-1]
}
Складність
Складність алгоритму можна оцінити такою сумою:
Як бачимо, більшість доданків у цьому виразі взаємно скорочуються, окрім додатних доданків з та від'ємних доданків з . Тому всю суму можна оцінити як
а не як , як було б, якби ми використовували звичайне ДП на відрізках.
На практиці
Найпоширеніше застосування оптимізації Кнута — це ДП на відрізках із заданим переходом. Єдина складність полягає в тому, щоб довести, що функція вартості задовольняє наведені умови. Найпростіший випадок — коли функція вартості є просто сумою елементів підмасиву для деякого масиву (залежно від задачі). Утім, іноді вони бувають складнішими.
Зауважимо, що важливішою за умови на перехід ДП і функцію вартості є нерівність на оптимальну точку розбиття. У деяких задачах, як-от задача про оптимальне бінарне дерево пошуку (яка, до речі, і є початковою задачею, для якої цю оптимізацію було розроблено), переходи та функції вартості будуть менш очевидними, проте все одно можна довести, що , а отже, скористатися цією оптимізацією.
Доведення коректності
Щоб довести коректність цього алгоритму в термінах умов на , достатньо довести, що
за умови, що задані умови виконуються.
також задовольняє нерівність чотирикутника, якщо виконуються умови задачі.
Доведення
Доведення цієї леми використовує сильну індукцію. Його взято зі статті Efficient Dynamic Programming Using Quadrangle Inequalities авторства F. Frances Yao, у якій було введено прискорення Кнута-Яо (це конкретне твердження — Лема 2.1 у статті). Ідея полягає в тому, щоб провести індукцію за довжиною . Випадок тривіальний. Для розглянемо 2 випадки:
-
Нерівність зводиться до (тут припускаємо, що для всіх , що справджується для всіх задач, у яких використовується ця оптимізація). Нехай .-
Якщо ,
зауважимо, щоОтже,
З припущення індукції . Також задано, що . Поєднання цих 2 фактів із наведеною вище нерівністю дає шуканий результат.
-
Якщо , доведення цього випадку симетричне до попереднього.
-
-
Нехай та .-
Якщо ,
де
Застосування QI до та до стану ДП для індексів (з припущення індукції) дає шуканий результат.
-
Якщо , доведення цього випадку симетричне до попереднього.
-
На цьому доведення леми завершується.
Тепер розглянемо таку конструкцію. У нас є 2 індекси . Покладемо .
Припустимо, ми покажемо, що
Поклавши , за означенням маємо . Отже, застосовуючи цю нерівність до всіх , можемо зробити висновок, що щонайменше таке саме велике, як , чим доводимо першу половину нерівності.
Тепер, застосовуючи QI до деяких індексів , отримуємо
Нарешті,
Це доводить першу частину нерівності, тобто . Другу частину можна показати тією самою ідеєю, починаючи з нерівності .
На цьому доведення завершується.