Шаг 167.
Рекурсия на Python. Выполнение программы. Мемоизация и динамическое программирование. Граф зависимости и динамическое программирование

    На этом шаге мы рассмотрим еще один способ решения задачи о нахождении самого длинного палиндрома в заданной строке.

    Динамическое программирование - парадигма проектирования алгоритмов, применяемая для решения задач оптимизации, которые можно разбить рекурсивно на меньшие подзадачи так, что разбиение приводит к перекрывающимся подзадачам. Метод может быть нисходящим (сверху вниз) с применением рекурсии и мемоизации. Но более общий подход заключается в применении восходящей (снизу вверх) стратегии. Суть её в том, чтобы реализовать восходящий итерационный алгоритм, который заполняет поисковую таблицу (такую же, как в рекурсивном алгоритме с мемоизацией) снизу вверх, начиная с простейших экземпляров задачи, и последовательно сохраняет получаемые результаты, которые позволят нам решить исходную задачу.

    Даже при том, что восходящий подход является итерационным, рекурсивное решение даёт представление о том, как следует заполнять поисковую таблицу. В частности, аналитики могут построить граф зависимости (dependency graph), называемый также "графом подзадач", указывающий на зависимости между различными подзадачами (то есть вызовами методов), которые необходимо решить с помощью рекурсивного алгоритма. Безусловно, этот граф - ориентированный, где каждый узел представляет собой конкретную подзадачу, а рёбра - зависимости между ними. Будем считать, что если подзадача A требует решения (зависима от) подзадачи B, то соединяющее их ребро будет направлено от A к B. На рисунке 1 приведён граф зависимости для рекурсивного алгоритма на базе рекуррентного соотношения F(n) = F(n - 1) + F(n - 2) для F(6) и начальных условий F(2) и F(1).


Рис.1. Граф зависимости для F(n) = F(n - 1) + F(n - 2)

    Обратите внимание на зависимость узлов n от узлов n - 1 и n - 2. Теперь для реализации решающего задачу итерационного алгоритма граф зависимости нужно проанализировать. Очевидно, что для чисел Фибоначчи алгоритм может получить результат за n шагов при наличии сохранённых решений подзадач F(1), F(2), ..., F(n - 1). Более того, из графа зависимости видно, что нет необходимости в списке длины n для хранения всех промежуточных результатов, поскольку на каждом шаге i нам нужны только два значения - F(i - 1) и F(i - 2), которые можно хранить в двух переменных.

    Что касается задачи о самой длинной подстроке-палиндроме, то на рисунке 2 приводится граф зависимости, относящийся к примеру 10.6, для исходной входной строки из пяти символов.


Рис.2. Граф зависимости для примера 10.6, решающего задачу о самой длинной подстроке-палиндроме

    В его узлах указаны только нижний и верхний индексы i и j (если i > j, то результатом является пустая строка, а сам узел помечен как ''). Заметьте, что узел (i, j) зависит лишь от решений для пар (i + 1, j), (i, j - 1) и (i + 1, j - 1), что следует из декомпозиции задачи. Каждый узел (i, j) связан с разными подзадачами, поэтому решение исходной задачи может быть получено только по завершении вызова, связанного с (затемнённым) узлом (0, n - 1).

    Если промежуточные результаты сохраняются в некоторой матрице L, то из графа зависимости на рисунке 2 следует, что Li,j, связанный с узлом (i, j), может быть получен после вычисления и сохранения Li+1,j, Li,j-1 и Li+1,j-1. Таким образом, граф зависимости задаёт порядок или направление (к верхнему правому углу), в котором нужно решать подзадачи и сохранять их решения. В частности, можно разработать алгоритм, который начинается с решения подзадач, расположенных на главной диагонали, с узлами (i, i) для i = 0, ..., n - 1. Затем на следующей диагонали с узлами (i, i + 1) для i = 0, ..., n - 2 и т. д. до достижения узла (0, n - 1). В примере 10.7 приведена возможная итерационная реализация алгоритма.


Пример 10.7. Поиск самой длинной подстроки-палиндрома в строке s с использованием динамического программирования
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def lps_dynamic_programming(s):
    n = len(s)
    L = [['' for i in range(n)] for j in range(n)]

    for i in range(n):
        L[i][i] = s[i]

    k = 1
    while k < n:
        i = 0
        j = k
        while j < n:
            if (len(L[i + 1][j - 1]) == j - i - 1
                    and s[i] == s[j]):
                L[i][j] = s[i:j + 1]
            elif len(L[i][j - 1]) >= len(L[i + 1][j]):  
                L[i][j] = L[i][j - 1]
            else:
                L[i][j] = L[i + 1][j]

            i = i + 1
            j = j + 1

        k = k + 1
    return L[0][n - 1]
Архив с файлом можно взять здесь.

    Заметьте, что он решает подзадачи снизу вверх, начиная с меньших экземпляров, пока не вернёт решение всей исходной задачи. В частности, сначала он заполняет матрицу L пустыми строками (строка 3), а также сохраняет si в Li,i для i = 0, ..., n - 1 (строки 5 и 6). Два следующих цикла последовательно, диагональ за диагональю, заполняют матрицу L, пока не достигнут верхнего правого узла графа зависимости. Строки 13-15 проверяют, что подстрока si,j является палиндромом, и, если это так, сохраняют ее. В противном случае следующие четыре строки сохраняют в Li,j самую длинную из подстрок Li+1,j и Li,j-1 На рисунке 3 приведено состояние матрицы L по завершении метода со всеми решениями подзадач для исходной входной строки s = 'bcaac'. Алгоритм работает за время Θ(n2), так как создаёт квадратную матрицу L, каждый элемент которой требует времени Θ(1) (можно разработать более эффективные алгоритмы, не использующие матрицу).


Рис.3. Матрица L по завершении метода из примера 10.7 для s = 'bcaac'

    На следующем шаге мы приведем решения нескольких задач.




Предыдущий шаг Содержание Следующий шаг