Задача о порядке перемножения матриц
Задача о порядке перемножения матриц (англ. chain matrix multiplication) — классическая задача, которая может быть решена с помощью динамического программирования. В этой задаче нам дается последовательность матриц, в которой мы хотим найти самый эффективный способ их перемножения.
У нас есть множество способов перемножить матрицы, потому что операция перемножения ассоциативна. Другими словами, нет разницы в каком порядке мы расставим скобки между множителями, результат будет один и тот же. Например, если у нас есть четыре матрицы A, B, C и D, то существуют следующие варианты:
- (ABC)D = (AB)(CD) = A(BCD) = A(BC)D = ....
Однако, порядок в котором мы расставим скобки между матрицами повлияет на количество арифметических операций, которые потребуются на вычисление ответа, или, другими словами, на эффективность.
Например, предположим, что А = (10 × 30), B = (30 × 5), C = (5 × 60). Тогда:
- (AB)C = (10×30×5) + (10×5×60) = 1500 + 3000 = 4500 операций
- A(BC) = (30×5×60) + (10×30×60) = 9000 + 18000 = 27000 операций.
Как мы видим, первый способ гораздо эффективней.
Решение задачи
Перебор всех вариантов
Сначала, давайте определимся, что мы хотим узнать минимальное количество операций (или минимальную стоимость), необходимых для перемножения матриц. Если мы перемножаем только две матрицы, то мы можем осуществить это едиственным способом, следовательно минимальная стоимость — это стоимость перемножения этих двух матриц. В общем, мы можем найти минимальную стоимость используя следующий рекурсивный алгоритм:
- Взять последовательность матриц и разделить её на две части.
- Найти минимальную стоимость перемножения на каждой подпоследовательности.
- Сложить эти две стоимости и прибавить к этому стоимость перемножения двух получившихся матриц.
- Сделать это для каждой возможной позиции в последовательности, в которой она может быть разделена и взять минимум среди всех результатов.
Например, если у нас есть четыре матрицы ABCD, то мы посчитаем для (A)(BCD), (AB)(CD), и (ABC)(D), делая рекурсивные вызовы на отрезках ABC, AB, CD, и BCD, чтобы найти минимальную стоимость. Потом среди них мы выбираем лучший вариант. Так же, этот алгоритм дает не только минимальную стоимость, но и показывает наилучший способ перемножения матриц: нужно только сгрупировать тем же образом матрицы, каким дается нам минимальная стоимость.
Однако, если мы применим этот алгоритм, то мы обнаружим, что он работает также медленно, как и наивный способ перебирания всех скобочных последовательностей! Что пошло не так? Ответом на этот вопрос является то факт, что мы делаем значительное количество ненужной работы. Например, в выше описанном алгоритме, мы делали рекурсивный вызов, чтобы найти наилучшую стоимость для подсчета ABC и AB. Но нахождение наилучшей стоимости для подсчета ABC так же требует нахождения лучшей стоимости для AB. Так как рекурсия растет вглубь все больше и больше, то и число ненужных повторений увеличивается. Итоговая асимптотика, как было сказано выше, равняется n–ому числу Каталана, да плюс вычисление для каждой правильной скобочной последовательности затрат на перемножение (то есть ). -ое число Каталана равняется или асимптотически .
Оптимизации динамическим программированием
Одно из простых решений — это мемоизация. Каждый раз, когда мы считаем минимальную стоимость перемножения определенной подпоследовательности, давайте мы будем запоминать ответ. Если мы когда либо ещё раз захотим посчитать это ещё раз, то мы уже будет иметь ответ и не будем пересчитывать. Поскольку существует всего
подотрезков, где — это количество матриц, то память занимаемая программой будет не так велика. Можно сказать, что с помощью этого простого трюка мы уменьшили асимптотику алгоритма (перебор) с до , что является достаточно эффективным для реальных приложений.Восстановление ответа
С помощью вышеописанного алгоритма мы можем восстановить порядок, в котором нам необходимо перемножать матрицы, чтобы достичь минимального количества арифметических операций, затрачиваемых на вычисление ответа. Когда мы узнаем, как нам нужно разбить отрезок на два подотрезка, то при восстановлении ответа мы заключаем эти два подотрезка(последовательности матриц) в скобки и передаем получившийся ответ выше по рекурсии.
Псевдокод
int dp[][]; int v[]; // dp[i][j] — меморизация на отрезке [i, j) // Массив v[] — хранит все размеры матриц по порядку // Так как у нас размеры соседних матриц по вертикали и горизонтали совпадают, то они занесены в этот массив однократно int matrixChainMultiplication(int l, int r) { //l — включая в отрезок //r — исключая из отрезка if dp[l][r] == -1 //Если значение динамики не посчитано if l == r - 1 dp[l][r] = 0; //Если у нас подотрезок длины 1, то количество операций для перемножения равно нулю else dp[l][r] = infinity; for (int i = l + 1; i < r; i++) dp[l][r] = min(dp[l][r], v[l] * v[i] * v[r - 1] + matrixChainMultiplication(l, i) + matrixChainMultiplication(i, r)); return dp[l][r]; }
Литература
- Английская википедиа Matrix chain multiplication