Линейный DP — самый частый шаблон в полосе CF 1100–1500. Его узнают мгновенно: «есть последовательность из шагов, на каждом шаге вариантов поведения, между шагами есть локальная зависимость». Состояние — пара (номер шага, в каком режиме закончили этот шаг), переход — выбор следующего режима с учётом разрешённых сочетаний. Эта задача — каноничная разминка такого шаблона: дней немного, состояний всего три, переходы умещаются в десяток строк.
Хочется потренироваться выводить уравнение DP «на ходу» — на этой задаче это особенно безболезненно. Здесь нет ни структурных хитростей, ни параметров, требующих пересчёта; вся сложность задачи — корректно описать тройку допустимых переходов из каждого состояния.
Что дано
Запланированы дней. Каждый день описывается одной из четырёх категорий:
- — спортивный зал закрыт, соревнования нет.
- — спортивный зал закрыт, соревнование есть.
- — спортивный зал открыт, соревнования нет.
- — спортивный зал открыт, соревнование есть.
В каждый день мы выбираем ровно одно из трёх:
- отдыхать (доступно всегда);
- писать соревнование (доступно, если в этот день есть соревнование);
- идти в зал на тренировку (доступно, если зал открыт).
Дополнительное ограничение: нельзя два дня подряд писать соревнование и нельзя два дня подряд ходить в зал. Отдыхать подряд можно сколько угодно.
Нужно минимизировать число дней отдыха.
Ограничения
- .
- .
- Время: 1 секунда, память: 256 МБ.
Формат ввода
В первой строке — число . Во второй — чисел , каждое в диапазоне –.
Формат вывода
Одно число — минимальное число дней отдыха.
Пример 1
Вход:
4
1 3 2 0
Вывод:
2
Комментарий: день — соревнование, день — зал (сменили активность), день — отдых (зал был вчера), день — отдых (закрыт всё). Итого дня отдыха.
Пример 2
Вход:
7
1 3 3 2 1 2 3
Вывод:
0
Комментарий: соревнование, зал, соревнование, зал, соревнование, зал, соревнование (или соревнование, зал, соревнование, зал, соревнование, зал, соревнование — есть несколько корректных раскладок без отдыха).
Пример 3
Вход:
2
2 2
Вывод:
1
Комментарий: оба дня — зал открыт, соревнований нет. В первый день — зал. Во второй идти в зал нельзя (повтор), писать соревнование тоже нельзя (его нет). Значит, второй день — отдых. Или наоборот: первый день — отдых, второй — зал. В любом раскладе один день отдыха.
Разберём первый пример руками
День : тип . Доступно — соревнование или отдых. Зал закрыт. Выбираем соревнование (отдых хуже). День : тип . Доступно — соревнование, зал или отдых. Соревнование нельзя (вчера было). Выбираем зал. День : тип . Доступно — зал или отдых. Зал нельзя (вчера был). Соревнования нет. Остаётся отдых. День : тип . Только отдых.
Итого: один день отдыха в третий день и один в четвёртый — всего . Других раскладок с меньшим числом отдыха не существует (формально проверяется тем же DP).
Идея решения
Состояние — пара , где — номер последнего рассмотренного дня, — что мы делали в этот день: — отдыхали, — писали соревнование, — были в зале. Значение DP — минимальное число дней отдыха в первых днях при условии, что в день мы закончили в режиме .
Переход на шаг зависит от типа дня :
- В состояние (отдых) можно прийти из любого предыдущего состояния. Добавляем к ответу.
- В состояние (соревнование) можно прийти, если в день есть соревнование () и в день мы не были в режиме «соревнование». Отдых не прибавляется.
- В состояние (зал) — аналогично: и предыдущее .
Если в день режим недоступен — соответствующее значение DP остаётся «бесконечностью» (помечаем большим числом).
Базовый случай: для всех — до начала никакого режима не было, и предыдущий не накладывает ограничений. Чтобы было удобно, тривиально: при нет ограничения «два дня подряд», поэтому переходы из в свободны от условия по предыдущему режиму.
Удобнее всего записать это так: , и для перехода в при требуем только «режим доступен в день », условие «вчера было не » автоматически выполняется (вчера было из псевдо-предыстории).
Ответ — .
Почему это работает
DP корректно описывает все допустимые раскладки. Любая последовательность активностей , удовлетворяющая ограничениям задачи, путём индукции по соответствует одному из значений . Минимум по всем валидным — это , а минимум числа отдыхов — то, что мы и считаем.
Корректность ограничения «нельзя дважды подряд» обеспечивается в самой формуле перехода: берётся как минимум по с , то есть случай явно исключён. Аналогично для .
Решение: псевдокод
dp_prev[0] = dp_prev[1] = dp_prev[2] = 0
для i от 1 до n:
t ← a[i]
INF ← очень большое число
dp_cur[0] ← min(dp_prev[0], dp_prev[1], dp_prev[2]) + 1
dp_cur[1] ← INF
dp_cur[2] ← INF
если t = 1 или t = 3: // доступно соревнование
dp_cur[1] ← min(dp_prev[0], dp_prev[2])
если t = 2 или t = 3: // доступен зал
dp_cur[2] ← min(dp_prev[0], dp_prev[1])
dp_prev ← dp_cur
ответ ← min(dp_prev[0], dp_prev[1], dp_prev[2])
Сложность: время и память после rolling (хранятся только три значения за прошлый шаг и три за текущий).
Код решения
Различий между языками здесь почти нет. В Python — float('inf') или просто как INF. В C++ — INT_MAX / 2 или константа (большее значение всё равно не понадобится: при максимум отдыхов — ).
Комментарии по реализации.
- Rolling вместо двумерного массива. Можно было бы держать
dp[n + 1][3], но при это бессмысленно — три переменных на «прошлый день» хватает. Это общий рефлекс линейного DP: если переход смотрит ровно на один шаг назад, хватит rolling. - INF как «недостижимо». Значение безопасно: ответ никогда не превышает , поэтому INF никогда не «закрепится» как реальное значение в ответе. Если бы суммирование могло переполнить INF — нужно было бы аккуратнее.
- Тип
int. — все промежуточные значения умещаются вint.long longне нужен. - Никаких краевых проверок. Первый день обрабатывается так же, как любой другой — благодаря выбору для всех состояний. Это типичный приём «единая база» в линейном DP.
Проверим на всех примерах из условия
Пример 1: , дни
| (rest) | (contest) | (gym) | ||
|---|---|---|---|---|
| — | ||||
| INF | ||||
| INF | ||||
| INF | INF |
Ответ: ✓
Пример 2: , дни
Расклад без отдыха: (соревнование), (зал), (соревнование), (зал), (соревнование), (зал), (соревнование). На каждом шаге активность доступна и отличается от предыдущей.
| rest | contest | gym | ||
|---|---|---|---|---|
| — | ||||
| INF | ||||
| INF | ||||
| INF | ||||
| INF | ||||
Ответ: ✓
Пример 3: , дни
| rest | contest | gym | ||
|---|---|---|---|---|
| — | ||||
| INF | ||||
| INF |
Ответ: ✓
Крайние случаи, на которых можно споткнуться
1. Все дни типа
Никаких активностей нет — ответ (все дни отдых). DP корректно даёт это: contest и gym всегда INF, rest инкрементируется на каждом шаге.
2. Один день,
зависит только от типа дня. Если — ответ . Если — ответ (одна доступная активность). Если — ответ .
3. Длинный «дрожащий» промежуток
Например, . Можно зал–отдых–зал–отдых–зал — два отдыха. DP даёт правильный ответ: каждый второй день обязательный отдых, нечётный — зал.
4. Все дни типа (всё доступно)
Между «соревнование» и «зал» можно чередовать без отдыха — ответ . DP это видит за счёт переходов «соревнование зал».
5. малое, INF большое
, минимум отдыхов . INF — гарантированно не «съест» реальный ответ. Если использовать INT_MAX, при INF + 1 в строке cur_rest = min(...) + 1 будет переполнение int. Поэтому INF берём как , а не максимум.
Типичные ошибки
- Переходы «вчера было то же самое» не блокируются. Если из
prev_contestразрешить переход вcur_contest, нарушается ограничение задачи. Симптом — заниженный ответ. - Базовый случай неверен. Если INF (как «вчера соревнование не было»), нельзя писать соревнование в день при — а в задаче это разрешено. Лекарство — поставить для всех состояний.
- Переполнение при
INF + 1. В C++INT_MAX + 1это UB и обычно даёт минус миллиард. Берите INF как — запас на сложение. min(...)от трёх элементов в C++. Стандартныйstd::min(a, b)принимает два. Для трёх —std::min({a, b, c})(с фигурными скобками) или последовательноstd::min(std::min(a, b), c).- Неверный диапазон цикла. Цикл по дням должен быть от до включительно (или эквивалентно по элементам массива ). Если случайно остановиться на — потеряем последний день.
- Пытаться «сэкономить» состояния. Соблазн объединить «контест и зал» в одно состояние «не отдыхал» — не работает: ограничение «два дня одинаковой активности подряд» завязано на конкретный тип активности.
Анализ сложности
- Время: — по одной итерации цикла на каждый день, в каждой итерации фиксированное число операций.
- Память: дополнительной (помимо хранения входа). Шесть переменных типа
intпод текущий и предыдущий слой DP.
Что ещё полезно потренировать
- Codeforces 580B «Kefa and Company» — линейный DP / sliding window над отсортированным массивом, тренирует «соседние состояния — соседи в линейке».
- Codeforces 909C «Python Indentation» — линейный DP с накоплением суммы, более сложная свёртка состояний, чем здесь.
- Codeforces 1195C «Basketball Exercise» — двухстрочный линейный DP с переходом «верх или низ», шаблон, родственный текущему.
- acmp.ru задача 116 «Эпсилон-полоса» — задача на линейный DP с двумя состояниями, отличный аналог.
Итого
- Идея: линейный DP с состоянием «чем закончили день»; три состояния, переходы по типу дня + запрет повторять активность подряд.
- Сложность: время, память после rolling.
- Ловушки: не забыть запрет «вчера было то же», аккуратный INF (не
INT_MAX), правильный базовый случай для всех .