Теоретический материал
Сайт: | Информатикс |
Курс: | Динамическое программирование |
Книга: | Теоретический материал |
Напечатано:: | Гость |
Дата: | Понедельник, 18 Август 2025, 06:33 |
Введение
К большинству олимпиадных задач ограничения (по времени, по памяти) жюри подбирает по принципу "как можно больше". То есть чтобы любые разумные реализации правильного решения проходили, а всё остальное -- нет.Когда встречается задача с маленькими ограничениями (например, до 10), это означает, что либо автор намеренно сбивает Вас с правильного пути, либо действительно эта задача решается каким-то (оптимизированным) перебором.
Динамическое программирование по профилю -- одна из таких оптимизаций. Часто в таких задачах дело происходит на прямоугольной таблице, одна из размерностей которой достаточно мала (не более 10). Требуется проверить существование, посчитать количество способов, стоимость и т.д. (как в обычном динамическом программировании). Асимптотика алгоритма, основанного на этой идее, является экспоненциальной только по одной размерности, а по второй -- линейная или даже лучше.
Некоторые обозначения и определения
Матрицей размера n×m называется прямоугольная таблица n×m, составленная из чисел.
Обычно матрицы обозначают заглавными латинскими буквами. Элемент i-й строки j-го столбца матрицы A обозначают через aij или a[i, j] (соответствующая маленькая латинская буква с индексами).
Произведением двух матриц
и
называется матрица такая
, что
В этом случае пишут: C = AB.
D в степени k (Dk), при условии, что D -- квадратная (т. е. n = m) определяется следующим образом:
- D0 = E (единичная матрица), где
E =На диагонали у нее стоят единицы, в остальных клетках -- нули. Она не зря называется единичной -- при умножении на нее матрица не изменяется: AE = EA = A.
.
- Di = Di - 1D, i > 0.
В приводимом коде будет использоваться функция bit(x,i), возвращающая единицу или ноль -- i-й бит в двоичной записи числа x (нумерация битов с нуля).
// возвращает i-й бит числа x, нумерация с нуля function bit(x, i : integer) : integer; begin if (i < 0) then bit := 0 else if (x and (1 shl i) = 0) then bit := 0 else bit := 1; end;
Задача о замощении домино
Чтобы понять, что такое динамика по профилю, будем рассматривать разные задачи и разбирать их решения с помощью этого приема.
Для начала рассмотрим известную задачу: дана таблица n×m, нужно найти количество способов полностью замостить ее неперекрывающимися костяшками домино (прямоугольниками 2×1 и 1×2). Считаем n, m10.
Заметим, что в процессе замощения каждая клетка таблицы будет иметь одно из двух состояний: покрыта какой-нибудь доминошкой или нет. Чтобы запомнить состояние клеток одного столбца, достаточно одной переменной P типа integer. Положим i-й бит P равным 1, если i-я сверху клетка данного столбца занята, 0 -- если свободна. Будем говорить в таком случае, что P -- битовая карта нашего столбца.
Теперь дадим определение базовой линии и профиля.
Базовой линией будем называть вертикальную прямую, проходящую через узлы таблицы.
Можно занумеровать все базовые линии, по порядку слева направо, начиная с нуля. Таким образом, базовая линия с номером i -- это прямая, отсекающая первые i столбцов от всех остальных (если такие имеются).
Отныне через bi будем обозначать базовую линию с номером i.
Столбцы занумеруем так: слева от bi будет столбец с номером i. Другими словами, столбец с номером i находится между bi - 1 и bi.
Профилем для базовой линии с номером i (bi) будем называть битовую карту для столбца с номером i при следующих дополнительных условиях:
- 1)
- все клетки слева от bi - 1 уже покрыты;
- 2)
- в i-м столбце нет вертикальных доминошек;
- 3)
- считается, что справа от bi нет покрытых клеток.
Первое условие возникает от желания считать количество способов постепенно, сначала рассматривая первый столбец, потом второй при условии, что первый уже заполнили и т.д. Второе и третье вводятся для того, чтобы один и тот же способ покрытия не был посчитан более одного раза. Точный смысл этих условий будет раскрыт ниже.
![]() |
Для каждого профиля p1 базовой линии bi определим множество профилей p2 базовой линии bi + 1, которые могут быть из него получены. На таблицу, соответствующую p1 (то есть все клетки слева от bi - 1 полностью покрыты, в i-м столбце клетки отмечены согласно p1), можно класть доминошки только двух типов:
- 1)
- горизонтальные доминошки, которые пересекают bi (то есть делятся ей пополам);
- 2)
- вертикальные доминошки, которые лежат слева от bi.
Также должны быть выполнены естественные дополнительные условия:
- 1)
- новые доминошки не должны перекрываться друг с другом;
- 2)
- они должны покрывать только незанятые клетки;
- 3)
- каждая клетка i-го столбца должна быть покрыта.
Битовая карта столбца i + 1 и будет возможным профилем p2.
Очевидно, что полученный таким образом профиль p2 действительно удовлетворяет всем условиям, накладываемым на профиль.
Пусть d[p1, p2] - количество способов из профиля p1 (для bi) получить p2 (для bi + 1). Очевидно, для данной задачи про доминошки это число может быть только единицей или нулем. Различные задачи будут отличаться в основном только значениями d[p1, p2].
Заметим, что всего профилей 2n: от 00...02 = 0 до 11...12 = 2n - 1. Поэтому в данном случае матрица D будет иметь размер 2n×2n.
Пусть теперь a[i, p] -- количество способов таким образом расположить доминошки, что p -- профиль для bi (таким образом, все клетки левее bi - 1 покрыты).
Напишем рекуррентное соотношение.
- Начальные значения (i = 1):
a[1, 0] = 1;
a[1, p2] = 0, p2 = 1, ..., 2n - 1 - Общая формула (i > 1):
Заметим, что для базовой линии номер 1 существует единственный профиль (то есть битовая карта, удовлетворяющая условиям профиля) -- карта незаполненного столбца.
Ответ на вопрос задачи будет записан в a[m + 1, 0]. Ошибкой бы было считать правильным ответом число a[m, 2n - 1], так как в этом случае не учитывается возможность класть вертикальные доминошки в последнем столбце (см. второй пример).
Обсудим "странные" условия на доминошки при получении одного профиля из другого. Казалось бы, забыт еще один тип доминошек, которые могут участвовать при формировании нового профиля, а именно полностью лежащие в столбце i + 1. Дело в том, что если разрешить их, то некоторые способы замощения будут считаться более одного раза. Например, пусть n = 2, m = 2. Тогда d'[0][3] = 2, так как можно положить либо две вертикальные доминошки, либо две горизонтальные. Аналогично, d'[3][3] = 1 (можно положить одну вертикальную). В итоге






Имеем неправильный ответ 3 (можно посчитать вручную, что на самом деле ответ 2).
Напротив, если следовать данному правилу получения из одного профиля другого, то можно убедиться в верности вычислений.
Упражнение. Доказать, что a[i, p] вычисляется правильно.
Вот какими будут D и A если n = 2, m = 4:






Таким образом, замостить доминошками таблицу 2×4 можно 5 способами, а таблицу 2×2 -- двумя. Если смотреть a[m, 2n - 1], то получим 2 и 1. Очевидно, что таблицу 2×2 можно замостить двумя, а не одним способом: пропущен вариант, когда кладутся две вертикальные доминошки. В случае 2×4 пропущены три замощения -- все случаи, когда последний столбец покрыт вертикальной доминошкой.
Существует два способа для вычисления D. Первый заключается в том, чтобы для каждой пары профилей p1 и p2 проверять, можно ли из p1 получить p2 описанным способом.
При втором способе для каждого профиля p1 пытаемся его замостить, кладя при этом только домоношки разрешенных двух типов. Для всех профилей p2 (и только для них), которые при этом получались в следующем столбце, положим d[p1, p2]=1. В большинстве случаев этот способ более экономичный, так что логично использовать именно его.
Ниже приведен код рекурсивной процедуры, которая заполняет строку d[p], то есть находит все профили, которые можно получить из p:
procedure go(p, profile, len : integer); begin // n - из условия задачи // profile - текущий профиль // len - длина profile if (len = n) then begin // как только profile получился длины n, выходим d[p][profile] := 1; exit; end; if (bit(p, len) = 0) then begin // текущая ячейка в p (с номером len + 1) не занята go(p, profile + (1 shl len), len + 1); // положили горизонтальную доминошку if (len < n - 1) then if (bit(p, len + 1) = 0) then begin // не занята еще и следующая ячейка go(p, profile, len + 2); // положили вертикальную доминошку end; end else begin go(p, profile, len + 1); // текущая ячейка занята, ничего положить не можем end; end; procedure determine_D; var p : integer; begin for p := 0 to (1 shl n) - 1 do go(p, 0, 0); // запускать надо именно с такими параметрами end;
Алгоритм вычисления D и A работает за
Задача о симпатичных узорах
Рассмотрим еще одну задачу с прямоугольной таблицей.
Дана таблица n×m, каждая клетка которой может быть окрашена в один из двух цветов: белый или черный. Симпатичным узором называется такая раскраска, при которой не существует квадрата 2×2, в котором все клетки одного цвета. Даны n и m. Требуется найти количество симпатичных узоров для соответствующей таблицы.
![]() |
Будем считать профилем для bi битовую карту i-го столбца (единицей будет кодировать черную клетку, а нулем -- белую). При этом узор, заключенный между нулевой и i-й базовыми линиями, является симпатичным.
Из профиля p1 для bi можно получить p2 для bi + 1, если и только если можно так закрасить (i + 1)-й столбец, что его битовая карта будет соответствовать p2, и между bi - 1 и bi + 1 не будет полностью черных либо белых квадратиков 2×2.
![]() |
Сколькими же способами из одного профиля можно получить другой? Понятно,
что закрасить нужным образом либо можно, либо нельзя (так как раскрашивать можно
единственным образом -- так, как закодировано в p2). Таким образом,
d[p1, p2] {0, 1}.
Вычислять D можно либо проверяя на "совместимость" (то есть наличие одноцветных квадратов 2×2) все пары профилей, либо генерируя все допустимые профили для данного. Ниже приведен код, реализующий первую идею:
// можно ли из p1 получить p2 function can(p1, p2 : integer) : boolean; var i : integer; b : arrray[1..4] of byte; begin for i := 0 to n - 2 do begin b[1] := bit(p1, i); b[2] := bit(p1, i + 1); b[3] := bit(p2, i); b[4] := bit(p2, i + 1); if (b[1] = 1) and (b[2] = 1) and (b[3] = 1) and (b[4] = 1) then begin // квадрат в строках i и i + 1 черный can := false; exit; end; if (b[1] = 0) and (b[2] = 0) and (b[3] = 0) and (b[4] = 0) then begin // квадрат в строках i и i + 1 белый can := false; exit; end; end; can := true; end; procedure determine_D; var p1, p2 : integer; begin for p1 := 0 to (1 shl n) - 1 do for p2 := 0 to (1 shl n) - 1 do if can(p1, p2) then d[p1, p2] :=1 else d[p1, p2] := 0; end;
После того, как вычислена матрица D, остается просто применить формулу (2) (так как расуждения на этом этапе не изменяются).
Связь ДП по профилю и линейной алгебры
Рекуррентное соотношение (2) будет встречаться нам не только в задаче о замощении или симпатичном узоре, но и во многих других задачах, решаемых динамикой по профилю. Поэтому логично, что существует несколько способов вычисления A, используя уже вычисленную D (а не только наивно пo (2)). В этом пункте мы рассмотрим способ, основанный на возведении в степень матрицы:
- 1)
- a[i] можно считать матрицей 1×2n;
- 2)
- D -- матрица 2n×2n;
- 3)
- a[i] = a[i - 1]D. Если расписать эту формулу по определению произведения, то получится в точности (2).
Следуя определению степени матрицы, получаем
Вспомним, как возвести действительное число a в натуральную степень b за O(log b) (считaем, что два числа перемножаются за O(1)). Представим b в двоичной системе счисления: b = 2i1 + 2i2 +...+ 2ik, где i1 < i2 <...< ik. Тогда k = O(log b). Заметим, что a2i получается из a2(i - 1) возведением последнего в квадрат. Таким образом, за O(k) можно вычислить все apt, pt = 2it, t = 1, ..., k. Перемножить их за линейное время тоже не представляет труда.
Логично предположить, что аналогичный алгоритм сгодится и для квадратных матриц. Единственное
нетривиальное утверждение --
A2i = (A2i - 1)2, ведь по определению
A2t = , а мы хотим приравнять его к
(At)(At).
Его истинность следует из
ассоциативности умножения матриц
(AB)C = A(BC). Само свойство можно доказать непосредственно,
раскрыв скобки в обеих частях равенства.
Приведем код процедуры возведения в степень (функция mul перемножает две квадратные матрицы размера w × w):
function mul(a, b : tmatr) : tmatr; var res : tmatr; i, j, t : integer; begin for i := 1 to w do begin for j := 1 to w do begin res[i][j] := 0; for t := 1 to w do begin res[i][j] := res[i][j] + a[i][t]*b[t][j]; end; end; end; mul := res; end; function power(a : tmatr; b : integer) : tmatr; var i, j : integer; res, tmp : tmatr; begin res := E; // единичная матрица tmp := a; while (b > 0) do begin if (b mod 2 = 1) then res := mul(res, tmp); b := b div 2; tmp := mul(tmp, tmp); end; power := res; end;
Как уже говорилось, будет сделано O(log b) перемножений. В данном случае, на каждое перемножение тратится n3 операций (где n -- размерность матрицы). Так что этот алгоритм будет работать за O(n3log b).
Вернемся к (3). Матрицу D мы умеем вычислять за O((2n)2n) = O(4nn) (как в рассмотренных задачах). Вектор a[m] сумеем найти за O((2n)3log b) = O(8nlog m). В итоге получаем асимптотику O(8nlog m). При больших m (например, 10100) этот способ вычисления A несравнимо лучше наивного.
Задача о расстановке королей
Дана шахматная доска n×m и число k. Нужно посчитать количество способов размещения на этой доске k королей так, чтобы они не били друг друга.
Профилем опять будет битовая карта столбца слева от базовой линии. В данном случае удобно запоминать расположение королей. Таким образом, единица будет означать наличие короля на соответствующей позиции. При переходе от одного профиля к другому ставим королей справа от bi так, чтобы они не били друг друга и предшествующих им.
Заметим, что снова
dij {0, 1}.
Отличие этой задачи от предыдущих заключается в следующем. Количество "настоящих" профилей сильно
отличается от 2n: если в позиции j есть король, то в позициях j - 1 и j + 1 его заведомо нет.
То есть, в двоичной записи профиля не должно встречатся двух подряд идущих единиц. Пусть f (n) --
количество возможных профилей длины n.
Напишем для f (n) рекуррентную формулу. Количество профилей, у которых на n-м месте стоит 1, равно f (n - 2), так как на (n - 1)-м не может стоять 0, на остальные ячейки ограничений нет. Если же на n-м месте стоит 0, то количество будет равно f (n - 1). Тогда f (n) = f (n - 1) + f (n - 2); f (1) = 2, f (2) = 3. Получили, что f (n) -- (n+2)-е число Фибоначчи. Известно, что f (n) < (1, 62)n + 2. При n = 10 имеем f (n) = 144 против количества битовых карт 1 024, уменьшение более чем в 7 раз!
Использовать это наблюдение можно по-разному.
- Будем рассматривать только настоящие профили (при вычислении D и A), используя рекурсию:
procedure go(len, profile : integer); begin // profile - текущий профиль // len = (длина profile) + 1 if (len > n) then begin // как только profile получился длины n, выходим writeln(profile, ' - настоящий профиль'); exit; end; go(len + 1, profile*2); // приписать ноль мы всегда можем if (profile mod 2 = 0) then go(len + 1, profile*2 + 1); // приписать единицу можно только если рядом ноль end; procedure print_all_true_profiles; begin go(1, 0); //запускать надо именно с такими параметрами end;
- Занумеруем все настоящие профили так, чтобы быстро получать по номеру профиль.
Тогда чтобы перебрать все настоящие профили, нужно будет пустить цикл по номеру с 1 до их
количества f (n), и каждый раз находить профиль по текущему номеру.
Например, можно брать номера в лексикографически упорядоченном списке профилей
(чтобы сказать, какой из двух профилей больше лексикографически, достаточно сравнить их как числа).
-
Пусть p -- настоящий ненулевой профиль. Если заменить в нем произвольную единичку (в двоичной записи) на нолик, то получится тоже настоящий профиль. Другими словами, если убрать короля, то ничего плохого не будет. Аналогично, если в p заменить 0 на 1, чтобы не возникло двух подряд идущих единиц, результатом будет настоящий профиль.
На этом основан еще один способ получения всех настоящих профилей: "поиском в ширину". Из настоящих профилей, в котоых ровно i королей, получаем всевозможные настоящие профили из i + 1 королей.
var use : array[0..(1 shl n) - 1] of byte; // use[p] = 1, если p - в очереди q : array[1..(1 shl n)] of integer; // очередь r : integer; // итоговое количество настоящих профилей procedure determine_all_true_profiles; var i, l, x, y : integer; begin l := 0; r := 1; for i := 1 to (1 shl n) - 1 do use[i] := 0; use[0] := 1; q[1] := 0; while (l < r) do begin l := l + 1; x := q[l]; // теперь попробуем подобавлять единички for i := 0 to n - 1 do if (bit(x,i) = 0) and (bit(x, i - 1) = 0) and (bit(x, i + 1) = 0) then begin y := x + (1 shl i); if (use[y] = 0) then begin r := r + 1; q[r] := y; end; end; end; end;
После завершения работы процедуры в очереди q будут содержаться все настоящие профили.
В итоге при использовании нашего наблюдения получаем асимптотику O(m(f (n))2)
Задача о расстановке коней
На этот раз на шахматной доске n×n нужно подсчитать количество способов расставить k коней так, чтобы они не били друг друга.
Мы уже привыкли, что профиль -- битовая карта столбца левее bi. Но здесь этой информации мало, так как кони могут прыгать сразу через два столбца. Поэтому профиль должен описывать два соседних столбца.
Для двух профилей
pr1 = p1, p2
(p1 -- битовая карта первого столбца, p2 -- второго) и
pr2 =
p'1, p'2
положим
d[pr1, pr2] = 1 если и только если
- a)
- p2 = p'1;
- б)
- кони не бьют друг друга.
В противном случае d[pr1, pr2] = 0.
Таким образом, переходов на порядок меньше, чем количество профилей. Всего профилей 4n, а из каждого профиля получается менее 2n новых за счет совпадения p2 и p1'. Другими словами, матрица D сильно разрежена (имееет много нулевых элементов).
Поэтому в задаче о расстановки коней можно добиться значительного ускорения (по сравнению с шаблонным алгоритмом), если хранить D в виде "списков смежности": D'[pr1] -- список всех таких pr2, из которых можно получить pr1, то есть d[pr2][pr1] = 1. В этом случае формула (2) будет выглядеть так:
![$\displaystyle \sum_{t \in D'[p]}^{}$](http://informatics.mccme.ru/moodle/file.php/9/dyn_prof/img33.gif)
Суммарное время вычисления a[i] равно количеству всевозможных переходов, то есть количество ненулевых элементов в D. В нашем случае (задаче о конях) их не более 4n×2n = 8n, то есть время работы алгоритма с такой оптимизацией будет O(8nm) -- это в 2n раз меньше, чем время стандартного алгоритма.
ДП по изломанному профилю
Другое название этому методу -- "быстрая динамика по профилю". Идея в том, чтобы добиться как можно меньшего числа переходов (от одного профиля к другому).
Еще раз используем в качестве примера задачу о замощении. Базовая линия теперь будет ломаной: при прохождении через i-ю горизонталь сверху вниз, она переходит на предыдущую вертикаль и спускается до низу (см. рис. 5).
Профилем будет пара
p, i
, в p будет информация о n + 1 маленьком квадратике слева от базовой
линии, имеющем с ней общие точки; i обозначает номер горизонтали, на которой произошел излом.
Квадратики профиля будут нумероваться сверху вниз, так что угловой будет иметь номер i + 1. Горизонтали будем
нумеровать нуля, так что i пробегает значения 0..n - 1.
Для
двух профилей
pr1 = p1, i1
и
pr2 =
p2, i2
положим
d[pr1][pr2] = 1 если и только
если:
- а)
- если i < n - 1, то i1 + 1 = i2; иначе i2 = 0;
- б)
- можно так положить доминошку, накрывающую (i + 1)-й квадратик, что после этого в p2 будет храниться в точности информация о соответствующих квадратиках.
Проще говоря, доминошку можно класть только двумя способами -- как показано на рисунках (на (i + 1)-й
квадратик можно положить не более одной вертикальной и горизонтальной доминошки). То, что потом
получается после сдвига вниз излома, и будет новым профилем. Заметим, что если (i + 1)-я клетка занята,
то доминошку уже не надо класть, и
p, i
логично отождествить с
p, i + 1
("i + 1" пишется условно, нужно всегда иметь в виду возможность i = n - 1).
Легко заметить, что количество профилей увеличилось в 2n раз (добавилось число от 1 до n и еще один бит). Но зато количество переходов резко сократолсь с 2n до двух!
В нижеприведенном куске кода для профиля
p, i
выводятся все переходы из него (напомним, что
нумерация горизонталей начинается с нуля и
i = 0..n - 1):
procedure print_all_links(p, i : integer); begin if (bit(p, i + 1) = 0) then begin if (i = n - 1) then begin writeln('<', (p - (2 shl i)) shl 1, ', ', 0, '>'); end else begin writeln('<', p - (2 shl i), ', ', i + 1, '>'); end; end else begin if (bit(p, i) = 0) then begin if (i = n - 1) then begin writeln('<', p shl 1, ', ', 0, '>'); end else begin writeln('<', p + (1 shl i), ', ', (i + 1) mod n, '>'); end; end; if (i < n - 1) and (bit(p , i + 2) = 0) then begin writeln('<', p + (4 shl i), ', ', i + 1, '>'); end; end; end;
Рисунок 6. Возможные переходы.
При такой реализации существует немало профилей только с одним переходом (например, у которых (i + 1)-й бит равен единице).
Отождествим все профили с один переходом с теми, кто их них получается. Это будет выглядеть так: пусть pr2 (и только он) получается из pr1, который, в свою очередь, получается из pr0. Тогда имеются такие соотношения: d[pr0, pr1]=1, d[pr1, pr2]=1. Отождествить pr1 и pr2 -- это, по сути, заменить эти два соотношение на одно, то есть теперь d[pr0, pr1]=0 и d[pr1, pr2]=0, но d[pr0, pr2]=1, и так далее.
Таким образом, возможно сокращение профилей не менее чем вдвое. Дальнейшие оптимизации мы оставляем читателю.
В итоге получаем асимптотику 2nn (количество переходов, то есть время на вычисление a[i]) умножить на m равно O(2nnm). Она значительно лучше всего, что мы получали до сих пор, и это серьезный повод использовать изломанный профиль вместо обычного.
Задачи, на которых можно потренироваться
- Cайт http://acm.sgu.ru, задачи 131, 132, 197, 223, 225.
- Московская олимпиада по информатике, 2004 год, заочный тур, задача J
("Узор"),
http://www.olympiads.ru/moscow/2004/zaoch/problems.shtml