Используйте правильный инструмент для задачи.
На моём первом собеседовании после окончания вуза мне задали классическую задачу про размен монет:
Дан набор номиналов монет, нужно найти минимальное количество монет, чтобы набрать заданную сумму. Например, для американских монет и 37 центов минимум — четыре монеты (квотер, дайм и 2 пенни).
Я реализовал простой жадный алгоритм и сразу попался в ловушку этого вопроса: жадный алгоритм работает только для «хорошо устроенных» наборов номиналов. Если номиналы равны [10, 9, 1], то для 37 центов жадный алгоритм возьмёт 10 монет, а оптимальное решение — 4 монеты (10+9+9+9). «Правильный» ответ — использовать алгоритм динамического программирования, которому я тогда был не обучен. Поэтому я провалил собеседование.
Но динамическое программирование вам нужно только в том случае, если вы пишете алгоритм сами. Всё становится просто, если отдать задачу решателю ограничений вроде MiniZinc и забыть о ней.
int: total;
array[int] of int: values = [10, 9, 1];
array[index_set(values)] of var 0..: coins;
constraint sum (c in index_set(coins)) (coins[c] * values[c]) == total;
solve minimize sum(coins);
Вы можете попробовать это онлайн здесь. Вас попросят ввести total, а затем покажут последовательно улучшающиеся решения:
coins = [0, 0, 37];
----------
coins = [0, 1, 28];
----------
coins = [0, 2, 19];
----------
coins = [0, 3, 10];
----------
coins = [0, 4, 1];
----------
coins = [1, 3, 0];
----------
Многие похожие вопросы на собеседованиях — это такого рода задачи математической оптимизации, где нужно найти максимум или минимум функции при заданных ограничениях. Они кажутся сложными в обычных языках программирования, потому что сами языки слишком низкоуровневые. Зато ровно под такие задачи и создавались решатели ограничений (англ. constraint solvers). Сложные leetcode-задачи — это простые задачи на ограничения. Здесь я использую MiniZinc, но вы вполне можете взять Z3, OR-Tools или любой другой любимый универсальный решатель.
Больше примеров
Рассмотрим еще одну задачу с другого собеседования (которое я, к счастью, прошёл):
Дан список цен акций в течение дня. Найдите максимальную прибыль, которую можно получить, купив одну акцию и продав её позже.
Легко сделать это за O(n²), а если чуть подумать — и за O(n). Или можно вообще не быть «хитрым» и просто записать задачу как задачу на ограничения:
array[int] of int: prices = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8];
var int: buy;
var int: sell;
var int: profit = prices[sell] - prices[buy];
constraint sell > buy;
constraint profit > 0;
solve maximize profit;
Когда я уже работал в той компании, мы тестировали ещё один вопрос для собеседований:
Дан список чисел. Определите, можно ли выбрать из него три числа и, складывая или вычитая их, получить 0?
Это задача удовлетворения ограничений, а не оптимизационная задача: нам не нужно «лучшее» решение, подойдёт любое. В итоге мы отказались от этого вопроса, посчитав его слишком хитрым для тех инженеров, на которых мы ориентировались. Но для решателя он вовсе не хитрый:
include "globals.mzn";
array[int] of int: numbers = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8];
array[index_set(numbers)] of var {0, -1, 1}: choices;
constraint sum(n in index_set(numbers)) (numbers[n] * choices[n]) = 0;
constraint count(choices, -1) + count(choices, 1) = 3;
solve satisfy;
Ещё один пример напоследок — задача, с которой я столкнулся в прошлом году на Chipy AlgoSIG. Формат там такой: берут несколько задач с LeetCode, и мы все их решаем. Вот эту задачу я решить не смог:
Дан массив целых чисел heights, представляющий высоты столбцов гистограммы, где ширина каждого столбца равна 1. Нужно вернуть площадь наибольшего прямоугольника в этой гистограмме.

«Правильное» решение — хитрый алгоритм, в котором нужно аккуратно отслеживать кучу вспомогательных состояний. Всё это можно полностью обойти, если записать задачу как задачу на ограничения:
array[int] of int: numbers = [2,1,5,6,2,3];
var 1..length(numbers): x;
var 1..length(numbers): dx;
var 1..: y;
constraint x + dx <= length(numbers);
constraint forall (i in x..(x+dx)) (y <= numbers[i]);
var int: area = (dx+1)*y;
solve maximize area;
output ["(\(x)->\(x+dx))*\(y) = \(area)"]
Есть даже способ автоматически визуализировать решение (с помощью vis_geost_2d), но разбираться с этим к выходу очередного выпуска рассылки мне было уже лень.
Это лучше?
Если бы я действительно приносил такие задачи на собеседование, кандидат легко мог бы испортить мне день вопросом «Какова временная сложность вашего решения?». Время работы решателей ограничений плохо предсказуемо и почти всегда хуже, чем у идеального «заточенного» алгоритма, потому что решатели гораздо более выразительны. Я называю это компромиссом между возможностями и вычислительной сложностью (capability/tractability tradeoff). Но даже так они обычно работают значительно лучше, чем плохо написанный «самодельный» алгоритм, а я недостаточно силён в ручной реализации алгоритмов, чтобы стабильно обгонять решатель.
Однако настоящая сила решателей — в том, как легко они переваривают новые ограничения. Взглянем снова на задачу про выбор моментов покупки и продажи акций. Я могу написать алгоритм O(n²) за несколько минут и алгоритм O(n), если дать мне немного подумать. А теперь изменим условие:
Максимизировать прибыль, покупая и продавая до max_sales акций, при этом в каждый момент времени можно либо купить, либо продать только одну акцию, и одновременно можно держать не более max_hold акций.
Это уже гораздо более сложная задача даже для неэффективного алгоритма! Зато как задача на ограничения она усложняется лишь немного:
include "globals.mzn";
int: max_sales = 3;
int: max_hold = 2;
array[int] of int: prices = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5, 8];
array [1..max_sales] of var int: buy;
array [1..max_sales] of var int: sell;
array [index_set(prices)] of var 0..max_hold: stocks_held;
var int: profit = sum(s in 1..max_sales) (prices[sell[s]] - prices[buy[s]]);
constraint forall (s in 1..max_sales) (sell[s] > buy[s]);
constraint profit > 0;
constraint forall(i in index_set(prices)) (stocks_held[i] = (count(s in 1..max_sales) (buy[s] <= i) - count(s in 1..max_sales) (sell[s] <= i)));
constraint alldifferent(buy ++ sell);
solve maximize profit;
output ["buy at \(buy)\n", "sell at \(sell)\n", "for \(profit)"];
Большинство примеров использования решателей ограничений в интернете — это головоломки вроде судоку или «SEND + MORE = MONEY». Решать задачи с LeetCode было бы куда более интересной демонстрацией. К тому же они дают больше поводов поговорить про оптимизации, например про разрушение симметрии (symmetry breaking).
Если идея «сложную задачу формулируем, а дальше за нас работает оптимизатор» откликается, можно посмотреть, как это устроено в мире ML. В декабре как раз пройдут бесплатные демо-уроки:
Комментарии (5)

Krenodator
26.11.2025 15:24Отличный пример того, что алгоритм это не всегда код, а иногда формулировка

Jijiki
26.11.2025 15:24таки да, простые задачи решают, и играет ограничение, к С легко можно пристыковать любую задачу, начиная от реализуй буфер строки-строк, до реализуй менеджер памяти на дереве, и как бы вау да, но иногда прикольно заюзать язык "X" и там будет куча условностей и нюансов реализаций в самом языке, поэтому решает наверно, просто механизм погружения в задачу лучший с ограничениями и попытками, чем ничего не делать и думать, но так можно придти к тому, что нужен язык "другой X или X-1 или X-2"

wataru
26.11.2025 15:24Поздравляю, вы выяснили, что очень многие задачи, которые имеют быстрое и простое решение, можно свести к NP-полной задаче Целочисленного Линейного Программирования. Иными словами, достаточно крупным микроскопом можно забивать очень разные гвозди.
Вообще да, очень и очень многие дискретные задачи хитро подобранными переменными и ограничениями можно свести к задаче ЦЛП. Это интуитивный и понятный факт: более сложная задача будет обобщением различных более простых.
Вот одна только проблема - даже самый навороченный и тяжелый решатель будет решать задачу сильно медленее заточенного под нее динамического программирования или специальной структуры данных. Вот в той же задаче про торговлю у вас линейное количество переменных. А решатель почти гарантированно работает за экспоненциальную сложность от количества переменных. А в некоторых задачах у вас самих переменных будет экспоненциальное количество.
Например, попробуйте-ка свести к своим ограничениям что-нибудь комбинаторное, например: найдите k-ую по порядку перестановку чисел от 1 до n. Решается за O(n^2), а если еще и структуры данных применять - то за O(n log n).
И в нормальном коде никто никогда не будет использовать решатель ЦЛП, если только у вас не совсем сложная задача, которую быстрее все-равно никак не решить.

wataru
26.11.2025 15:24И отдельно про:
В этом контексте:«leetcode» — сленговое название «каверзных алгоритмических вопросов на собеседовании, которые практически не имеют никакого отношения к реальной работе, на которую вы претендуете
У меня уже есть пара статей с примерами и опровержениями, на днях выложу очередную. В ФААНГах, откуда эти задачи и пошли, они как раз имеют прямое отношение к работе. Встречаются не каждый день, но достаточно часто, чтобы имело смысл среди сотни кандидатов на место отбирать тех, кто раз в несколько недель с такой задачей справится, а не сожрет десятки серверов там, где хватит половины ядра одного процессора.
А за такой вот солвер в продуктовом коде вас там сразу уволят за проф-непригодность. Попробуйте-ка скормить солверу гигабайты данных, он надорвется.
Другое дело, когда условные ООО "Рога и Копыта" в собеседовании на формошлепа тоже дает такие вот задачи, потому что это модно и карго-культ. Это бред, да. Им стоило бы ограничиться каким-нибудь Fizz-Buzz-ом.
antonb73
Вы молодец. Но в реальной жизни сотрудник решит задачу потратив больше времени и использовав помошников созданных человечеством - поиск, ИИ.
Утверждение, что нужно решить за 10 минут иначе всем хана - примитивная манипуляция.
Вывод: Инженерия и спорт - вещи обе полезные, но разные.