
Каждую осень HR просит запланировать отпуска на следующий год. Обычно люди сразу берут между майскими, кто-то подбирает даны под день народного единства, а многие просто ставят на декабрь, чтобы потом двигать можно было.
А я задался вопросом - можно ли найти оптимальный отпуск под запросы и открыл VS Code\Jupiter с F# (пока оно еще работает). Всё живёт в Jupyter-ноутбуке с ядром .NET Interactive — да, F# умеет в ноутбуки, питонисты, не ревнуйте.
Правила игры на 2027
Коротко и без претензии на юридическую консультацию:
28 календарных дней, один кусок — не короче 14 дней подряд (ст. 125 ТК). Остальные 14 беру по одному.
Праздники в отпуск не входят и продлевают его (ст. 120). Выходные — входят.
Календарь 2027: праздники 1–8 января, 23 февраля, 8 марта, 1 и 9 мая, 12 июня, 4 ноября; переносы на 22 февраля, 3 и 10 мая, 14 июня, 5 ноября и 31 декабря; рабочая суббота 20 февраля. Новогодние каникулы есть у всех — считаем с 11 января.
Деньги: за каждый календарный день отпуска платят годовой заработок ÷ 12 ÷ 29,3 (ст. 139), за каждый неотработанный рабочий день теряешь оклад ÷ рабочие дни месяца.
Для оклада 150 000 ₽:
День отпуска |
Рабочих дней в месяце |
Деньги |
|---|---|---|
будний день в январе |
15 |
−4 881 ₽ |
будний день в феврале или мае |
19 |
−2 775 ₽ |
будний день в ноябре |
20 |
−2 381 ₽ |
будний день в июне или октябре |
21 |
−2 023 ₽ |
будний день в остальные месяцы |
22 |
−1 699 ₽ |
суббота или воскресенье |
— |
+5 119 ₽ |
Посмотрите на последнюю строку ещё раз. Суббота в отпуске приносит деньги. Вы ничего не теряете, а отпускные получаете.
Производственный календарь в коде
День — это просто номер d, считая с 11 января: 0, 1, 2, … 354. Все свойства дня считаются один раз и складываются в массивы, дальше модель только смотрит в них по индексу.
module Cal = let first, last = DateOnly(2027, 1, 11), DateOnly(2027, 12, 31) let days = last.DayNumber - first.DayNumber + 1 let date (d: int) = first.AddDays d let day (ddMM: string) = DateOnly.ParseExact(ddMM + ".2027", "dd.MM.yyyy").DayNumber - first.DayNumber let show d = (date d).ToString "dd.MM" let private key (x: DateOnly) = x.ToString "dd.MM" let private holidays = set [ "23.02"; "08.03"; "01.05"; "09.05"; "12.06"; "04.11" ] let private movedDaysOff = set [ "22.02"; "03.05"; "10.05"; "14.06"; "05.11"; "31.12" ] let private workingSaturday = "20.02" let private isHoliday (x: DateOnly) = holidays.Contains(key x) || x.Month = 1 && x.Day <= 8 /// нерабочий ли день: выходной, праздник или перенесённый выходной (кроме рабочей субботы) let isOff (x: DateOnly) = key x <> workingSaturday && (x.DayOfWeek = DayOfWeek.Saturday || x.DayOfWeek = DayOfWeek.Sunday || isHoliday x || movedDaysOff.Contains(key x)) let holiday = Array.init days (date >> isHoliday) let off = Array.init days (date >> isOff) let workdays = [| for d in 0 .. days - 1 do if not off[d] then d |] /// 14 дней отпуска с дня start: праздники пропускаем, они продлевают отпуск let chunk start = if holiday[start] then None else let ds = seq { start .. days - 1 } |> Seq.filter (fun d -> not holiday[d]) |> Seq.truncate 14 |> Seq.toArray if ds.Length = 14 then Some ds else None printfn $"Дней: {Cal.days}, рабочих: {Cal.workdays.Length}" // Дней: 355, рабочих: 247
Что здесь происходит:
Производственный календарь — три множества (
set) строк. Праздники, переносы и одна рабочая суббота. Ключ — строка"dd.MM". Не очень эффективно, зато сверяется с постановлением правительства глазами за десять секунд.Выходные и праздники - не одно и то же.
holiday— праздник: в отпуск не входит и продлевает его.off— любой нерабочий день: выходной, праздник или перенос. Путать их нельзя: суббота в отпуск входит и приносит деньги, а 23 февраля — не входит и не приносит ничего.Рабочая суббота 20 февраля проверяется первой и побеждает всё остальное. Государство дало, государство взяло.
workdays— номера рабочих дней по порядку.chunk start— дни 14-дневного куска с началомstart. Праздники внутри пропускаются, поэтому кусок через 8 марта длится 15 календарных дней. Начинать отпуск с праздника нельзя —None. Сверка с реальностью: 247 рабочих дней в 2027-м, как в официальном календаре. Значит, ничего не потеряли.
Деньги за день считаются тут же, по формулам из таблицы — и сразу в копейках. Это эффективнее и дальше пригодится:
Копейки за каждый день года
/// копейки за день отпуска: отпускные минус потерянная зарплата (ст. 139: годовой ÷ 12 ÷ 29,3) static member val MoneyPerDay : int64[] = let salary = 150_000m let workdaysInMonth = Array.init 12 (fun i -> let m = i + 1 Seq.init (DateTime.DaysInMonth(2027, m)) (fun j -> DateOnly(2027, m, j + 1)) |> Seq.filter (Cal.isOff >> not) |> Seq.length) Array.init Cal.days (fun d -> if Cal.holiday[d] then 0L else let lost = if Cal.off[d] then 0m else salary / decimal workdaysInMonth[(Cal.date d).Month - 1] int64 (Math.Round((salary / 29.3m - lost) * 100m)))
Праздник — ноль: за него не платят и не вычитают. Выходной — чистые отпускные. Будний день — отпускные минус дневная зарплата этого месяца. Вся таблица выше — это вот эти десять строк.
Перебор умирает на пятом дне
14-дневный кусок — это одна переменная: день начала. Прогоняем Cal.chunk по всем дням года — вариантов 336, максимум находится мгновенно.
Максимум денег — +13 897 ₽ за кусок 21 февраля — 7 марта. Максимум отдыха — 18 дней с 7 февраля: туда влезают и 22-е, и 23-е, и рабочая суббота.
А дальше 14 одиночных дней. Непраздничных дней в году 349. Выбрать из них пять — 41 922 588 819 вариантов. Выбрать четырнадцать — 3,5 · 10²⁴. До выхода в отпуск не досчитаем. Даже до пенсии.
Перебор можно оптимизировать — начинать перебор с дней, примыкающих к выходным, отсекать варианты по методу ветвей и границ, и еще много вариантов. Но писать это все сложно, даже с помощью ИИ.
На помощь приходит солвер
CP-SAT — бесплатный солвер из Google OR-Tools. Для .NET это обычный NuGet-пакет. Много лет подряд он собирает медали на MiniZinc Challenge — чемпионате солверов, есть и такой.
Работа с ним выглядит так: вы не пишете алгоритм. Вы описываете задачу — какие есть переменные, что им нельзя, что хочется максимизировать, — а солвер сам ищет ответ. Как SQL: пишешь «что», а не «как».
Ответ приходит с одним из статусов:
Optimal — план найден, и доказано, что лучше не бывает;
Feasible — план найден, но доказать оптимальность не успел (кончился лимит времени);
Infeasible — доказано, что ограничения противоречат друг другу. Отпуска не будет. Доказательство — главное отличие от эвристик и генетических алгоритмов. Те говорят «вроде неплохо». CP-SAT говорит «лучше нельзя, вот гарантия».
Как он обходит 10²⁴ вариантов, не перебирая их? Три приёма, работающих одновременно:
Распространение ограничений. Выбрал кусок 21 февраля — 7 марта? Все 14 дней внутри мгновенно вычёркиваются из кандидатов в одиночные дни. Одно решение тянет за собой сотни выводов.
Обучение на конфликтах — от SAT в названии. Зашёл в тупик — солвер выясняет почему и запоминает причину как новое правило. В этот тупик он больше не зайдёт ни с какой стороны.
Оценки сверху. Солвер умеет быстро посчитать «больше этого точно не получится» и отрезать целые ветки поиска, не заходя в них. Как это работает — расскажу ниже.
Плюс параллельность — солвер может запускаться на нескольких ядрах. На каждом ядре свой воркер со своей стратегией, и они делятся находками друг с другом.
Что CP-SAT не умеет — и это важно, потому что дальше вся статья — борьба с этими ограничениями:
Только целые числа. Никаких
float. Поэтому все деньги в модели — в копейках,int64.У каждой переменной — границы. «Целое от 0 до 136» — можно. «Целое какое-нибудь» — нельзя.
Только линейные ограничения и линейная цель: суммы переменных с постоянными коэффициентами.
3·x + 5·y ≤ 10— да.x²илиx·y— формально естьAddMultiplicationEquality, но солвер его не любит и становится медленным и грустным.Цель одна. «Побольше отдохнуть и не потерять денег» — придётся упаковать в одно число.
Худший случай всё равно экспоненциальный. Задача NP-трудная, чудес не бывает. Плохая формулировка может не решиться за разумное время — проверим на себе. Зато есть удобные готовые ограничения. Нам хватит самых простых:
AddExactlyOne(xs)— ровно одна булева переменная из списка истинна;AddAtMostOne(xs)— не больше одной;AddLinearConstraint(expr, lo, hi)— линейное выражение в диапазоне[lo, hi];OnlyEnforceIf(lit)— включить ограничение, только если литерал истинен. Литерал — это булева переменная или её отрицание:xилиnot x. Есть и тяжёлая артиллерия — интервалы без пересечений (AddNoOverlap, для расписаний), маршруты (AddCircuit, для коммивояжёра), ресурсы (AddCumulative). Для расчёта отпуска они не нужны.
Подключаем
#r "nuget: Google.OrTools, 9.15.6755"
Модель для солвера: Переменные, ограничения, цель.
Переменные булевы:
start[t]— 14-дневный кусок начинается в деньt;single[d]— деньdвзят отдельно.
Ограничения:
начало ровно одно (
AddExactlyOne);отдельных дней ровно 14, в праздники их брать нельзя, и день не может быть одновременно в куске и отдельным (
AddAtMostOne).
Деньги — линейная сумма: каждая переменная умножается на заранее посчитанные копейки. Календарь целиком уходит в коэффициенты, модель остаётся линейной.
Главная фишка: модель ничего не знает про лето, выходные и ваши хотелки. Она спрашивает у двух предикатов DateOnly -> bool:
type Rules = { Chunk: DateOnly -> bool // каким дням разрешено быть в 14-дневном куске Single: DateOnly -> bool // какие дни можно брать отдельными Budget: decimal option } // деньги за отпуск не меньше стольких рублей
Денежная часть модели
type VacationModel(rules: Rules) = let model = CpModel() let start, single = Dictionary<int, BoolVar>(), Dictionary<int, BoolVar>() let takes = Array.init Cal.days (fun _ -> ResizeArray<ILiteral>()) // день календаря -> какими кусками отпуска он забирается let terms = ResizeArray<LinearExpr>() let mayChunk = Array.zeroCreate<bool> Cal.days do // 14 дней подряд: ровно одно начало; кусок разрешён, если разрешён каждый его день for t in 0 .. Cal.days - 1 do match Cal.chunk t with | Some c when c |> Array.forall (fun d -> rules.Chunk (Cal.date d)) -> start[t] <- model.NewBoolVar $"start{t}" for d in c do takes[d].Add start[t] // если день d забирается 14-дневным куском начатым в t, то добавляем переменную в список mayChunk[d] <- true terms.Add(start[t] * Array.sumBy (fun d -> Plan.MoneyPerDay[d]) c) | _ -> () model.AddExactlyOne(Seq.cast start.Values) |> ignore // 14 отдельных дней: в праздники нельзя (ст. 120), остальное решает предикат for d in 0 .. Cal.days - 1 do if not Cal.holiday[d] && rules.Single (Cal.date d) then single[d] <- model.NewBoolVar $"single{d}" takes[d].Add single[d] terms.Add(single[d] * Plan.MoneyPerDay[d]) model.AddLinearConstraint(LinearExpr.Sum single.Values, 14L, 14L) |> ignore // день — либо в куске, либо отдельный, не оба сразу for t in takes do model.AddAtMostOne t |> ignore let money = LinearExpr.Sum terms do rules.Budget |> Option.iter (fun b -> model.AddLinearConstraint(money, int64 (b * 100m), Int64.MaxValue) |> ignore)
Доверяй, но проверяй. После каждого решения план пересчитывается обычным F#, без солвера. Не сошлось — исключение.
let plan = { m.Read solver with Optimal = (status = CpSolverStatus.Optimal); Seconds = sw.Elapsed.TotalSeconds } if solver.Value m.Money <> int64 (plan.Money * 100m) || solver.Value m.Joy <> int64 plan.Joy then failwith "солвер и проверка разошлись"
Ни разу не сработало, но спать спокойнее.
Хочу максимум денег
let anyDays = { Chunk = (fun _ -> true); Single = (fun _ -> true); Budget = None } let moneyPlan = show "Деньги" (solve anyDays (fun m -> m.Money))
11,3 секунды, оптимум доказан — и +85 570 ₽ за год.
Кусок 21 февраля — 7 марта, внутри три праздника. А оставшиеся 14 дней солвер разложил… по субботам и воскресеньям. Каждая по +5 119 ₽.
По документам я в отпуске 28 дней. По факту — 16. Руководитель в восторге, бухгалтерия в замешательстве, я в унынии.
Солвер продал выходные, так дорого, как смог.
И он прав: я просил деньги — я получил деньги. Солвер не жульничает, он находит дыры в ограничениях. Значит, надо научить его, что такое отдых.
Кайф = Σ len²
Просто посчитать дни отдыха нельзя: 14 отдельных пятниц с прилипшими выходными — это 42 дня отдыха. Ровно как шесть недель подряд. Но любой, кто был в отпуске, скажет: это совершенно разные 42 дня.
Поэтому я взял сумму квадратов длин кусков отдыха и назвал её кайфом.
Склейка всегда выгодна: (a + b)² = a² + b² + 2ab. Две недели по 10 дней — кайф 200, одна по 20 — 400.
Каждый следующий день дороже: десятый день подряд приносит 19 кайфа, двадцатый — 39. 42 дня одним куском — 1 764, 14 кусков по три — 126. В 14 раз меньше.
И важная оговорка: кусок без единого рабочего дня отпуска не считается. Выходной в отпуске — это не отдых, а бухгалтерская запись.
member p.Joy = p.Runs |> Seq.sumBy (fun (a, b) -> (b - a + 1) * (b - a + 1))
Как засунуть квадрат в линейную модель
У CP-SAT только линейные выражения. А у меня квадрат.
Первая попытка — честная. Заводим на каждый день целую переменную len[d] — сколько дней подряд я отдыхаю к концу дня d. Правило простое:
отдыхаю —
len[d] = len[d−1] + 1;работаю —
len[d] = 0.
А где квадрат? Его не нужно считать. Сумма первых L нечётных чисел — это L²: 1 + 3 + 5 + … + (2L − 1) = L². Значит, день, который отдыхаем, приносит 2·len[d] − 1 кайфа, а рабочий — ноль. И это одна линейная формула на все дни: 2·len[d] − rest[d]. Для рабочего дня там 0 − 0.
В CP-SAT условие «если — то» записывается через OnlyEnforceIf: ограничение включается, только когда литерал истинен. Условный код (без правила «кусок из одних выходных не считается», чтобы не загромождать):
let maxLen = 28 + Cal.days - Cal.workdays.Length // 136: длиннее отдых не бывает let len = Array.init Cal.days (fun d -> model.NewIntVar(0L, int64 maxLen, $"len{d}")) // rest[d] — отдыхаю ли в день d let rest = Array.init Cal.days (fun d -> let r = model.NewBoolVar $"rest{d}" if Cal.off[d] then model.AddLinearConstraint(r, 1L, 1L) |> ignore // выходной — всегда else model.AddLinearConstraint(r - LinearExpr.Sum takes[d], 0L, 0L) |> ignore // будний — если в отпуске r) for d in 0 .. Cal.days - 1 do let prev = if d > 0 then len[d - 1] :> LinearExpr else LinearExpr.Constant 0L model.AddLinearConstraint(len[d] - prev, 1L, 1L).OnlyEnforceIf(rest[d]) |> ignore // отдыхаю: len[d] = len[d−1] + 1 model.AddLinearConstraint(len[d], 0L, 0L).OnlyEnforceIf(rest[d].Not()) |> ignore // работаю: len[d] = 0 // 1 + 3 + 5 + … + (2L − 1) = L² let joy = LinearExpr.Sum [ for d in 0 .. Cal.days - 1 -> len[d] * 2L - rest[d] ]
Две переменные на день, всё линейно. Красиво! И не работает… За минуту солвер нашёл кайф 1 296, а верхняя оценка застряла на 6 288 — в пять раз выше. Оптимум не доказан. Почему так?
Вещественное ослабление, или как солвер врёт себе во благо
Найти хороший план — полдела. Чтобы сказать «лучше нельзя», солвер должен доказать, что ни один из 10²⁴ вариантов не даёт больше. Перебирать их он не будет. Он схитрит.
Хитрость называется LP-ослабление (LP relaxation). Солвер на время забывает, что переменные целые: булева переменная может быть не только 0 или 1, а, скажем, 0,3. «Взять в отпуск треть понедельника». Такая вещественная задача — обычное линейное программирование (LP), решается быстро. А её оптимум не меньше настоящего: вещественных планов больше, чем целых, но все целые среди них. Это и есть верхняя оценка — те самые 6 288.
Дальше солвер ветвится: берет одну задачу ограничением «пусть понедельник 3 мая — 1», вторую такую же, но с нулем. Решает ослабление в каждой ветке и отрезает ветки, где оценка уже хуже найденного плана. Когда оценка опускается до найденного плана — оптимум доказан. Чем ближе ослабление к правде, тем быстрее сходится. Если ослабление врёт в пять раз — ждать придётся долго.
А цепочка len[d] врёт безбожно. В LP нет никакого OnlyEnforceIf, условие превращается в неравенство с большим коэффициентом (big-M), примерно так:
len[d] ≤ 136 · rest[d] // работаю → len = 0 len[d] ≤ len[d−1] + 1 + 136 · (1 − rest[d]) // отдыхаю → не больше вчера + 1
Подставим вещественное число. Беру в отпуск понедельник на 10%: rest = 0,1. Первое неравенство разрешает len до 13,6. Второе при 1 − rest = 0,9 вообще ничего не запрещает. Получаем: десятая часть дня отпуска даёт кусок длиной 13,6 и почти 27 кайфа. В реальном (целочисленном в нашем случае) мире за такой кусок пришлось бы отдать десяток дней отпуска. В вещественном — десятую часть понедельника. Размажьте 14 дней тонким слоем по всем будням года — и оценка улетает в космос. По сути солвер начинает тупо перебирать варианты, как наш код до него.
Квадрата не существует
(Есть ощущение, что никто не понял отсылки к Матрице)
Кусок отдыха однозначно задаётся своими рабочими днями: выходные и праздники прилипают сами. Значит, можно заранее перечислить все отрезки рабочих дней W[k..m] длиной до 28, для каждого заранее знать длину куска — а значит, и квадрат. Квадрат становится константой.
Три ограничения связывают куски с отпуском: слева рабочий день не в отпуске, справа тоже, каждый рабочий день отпуска накрыт ровно одним куском. Всё. Склейки получаются сами, правило «выходной — не отдых» — тоже само, без дополнительных ограничений.
let W = Cal.workdays let possible (d: int) = rules.Single (Cal.date d) || mayChunk[d] let runs = [| for k in 0 .. W.Length - 1 do for m in k .. min (k + 27) (W.Length - 1) do if W[k..m] |> Array.forall possible then let from = if k > 0 then W[k - 1] + 1 else 0 let until = if m < W.Length - 1 then W[m + 1] - 1 else Cal.days - 1 k, m, until - from + 1, model.NewBoolVar $"run{k}_{m}" |] do let covering = Array.init W.Length (fun _ -> ResizeArray<ILiteral>()) for k, m, _, run in runs do for i in k .. m do covering[i].Add run if k > 0 then model.AddAtMostOne(Seq.append takes[W[k - 1]] [ run ]) |> ignore // слева пусто if m < W.Length - 1 then model.AddAtMostOne(Seq.append takes[W[m + 1]] [ run ]) |> ignore // справа пусто for i in 0 .. W.Length - 1 do // ровно один кусок на рабочий день model.AddLinearConstraint(LinearExpr.Sum covering[i] - LinearExpr.Sum takes[W[i]], 0L, 0L) |> ignore let joy = LinearExpr.WeightedSum(seq { for _, _, _, run in runs -> run }, seq { for _, _, len, _ in runs -> int64 (len * len) })
Плюс 6 538 новых переменных — по одной на каждый возможный кусок. И то, что не доказывалось за минуту, доказывается за 11–36 секунд — смотрите тайминги ниже.
У такого подхода нет проблемы, что LP-ослабление врет. Если run[k, m] = 0,1, то и кайфа он даёт ровно 0,1 · len², и будни покрывает на 0,1. Дробный план не умеет получать больше, чем платит. Ослабление почти совпадает с правдой — и доказательство сходится за секунды.
Больше переменных — быстрее решение. Для солвера важно не количество переменных, а сложность ограничений. Не что вы считаете, а как. Это самый ценный урок данной статьи.
Не беру отпуск в выходные
Закрываем лазейку. Модель не трогаем — меняем предикат:
let workday (x: DateOnly) = not (Cal.isOff x) let weekdays = show "Деньги без выходных" (solve { anyDays with Single = workday } (fun m -> m.MoneyThenJoy))
MoneyThenJoy — сначала деньги, при равных деньгах — кайф. Зачем? Будни одного месяца стоят одинаково, планов с максимумом денег много, и дни могут лечь вразнобой, через день. Солверу всё равно, мне — нет.
Но цель у CP-SAT одна. Выход — упаковать две цели в одно число, как телефонная книга сортирует по фамилии, а при равной фамилии — по имени: деньги × N + кайф. Если N больше любого возможного кайфа, то копейка денег всегда перевесит весь кайф, и кайф решает только при равных деньгах.
Осталось найти N. Подбирать на глаз не надо — его даёт календарь: отдыхающих дней максимум 136 (28 отпуска + 108 выходных и праздников), значит, кайф не больше 136². Берём N = 136² + 1. Сначала я взял вес с запасом — «дней в году²». И солвер перестал доказывать оптимум за две минуты: лишние порядки в коэффициентах ему только мешают. Минимально достаточный вес — лучший.
13,3 секунды. Результат: −9 885 ₽ и 36 дней отдыха подряд, с 21 февраля по 28 марта.
Почему март? Потому что будний день в марте — самый дешёвый в году. Солвер не любит море в марте. Солвер любит скидки. А кайф склеил дни в одну полосу.
Хочу максимум кайфа
let joyPlan = show "Кайф" (solve anyDays (fun m -> m.JoyThenMoney))
20,1 секунды. 39 дней подряд, с 6 февраля по 16 марта. Кайф 1 521. Цена — −42 182 ₽.
Кайф дороговат.
Кайф в бюджете
Хочу максимум кайфа, но потерять не больше 9 885 ₽ — как в варианте «без выходных». Выходные брать можно.
Результат: те же 36 дней, те же деньги. Доказанный оптимум за 31,7 секунды. Почему? Суббота ради денег занимает место будня в длинном куске: 35 дней вместо 36 — минус 71 кайф. Не окупается.
Ладно, до −20 000 ₽. 32,5 секунды — 37 дней, −16 703 ₽. 37-й день стоит 6 818 ₽. Запомним эту цифру.
Хочу к морю
Март — оптимально по деньгам, но я хочу летом. Одна строка:
let isSummer (x: DateOnly) = x.Month >= 5 && x.Month <= 9 let summer = { anyDays with Chunk = isSummer }
Цель |
Любые дни |
Солвер |
Лето |
Солвер |
|---|---|---|---|---|
Деньги |
+85 570 ₽, отдых 16 дней |
11,3 с |
+80 187 ₽, отдых 16 дней |
4,4 с |
Деньги без выходных |
−9 885 ₽, 36 дней подряд |
13,3 с |
−15 268 ₽, 34 дня подряд |
8,3 с |
Кайф |
39 дней подряд, −42 182 ₽ |
20,1 с |
38 дней подряд, −34 287 ₽ |
11,8 с |
Кайф в бюджете «без выходных» |
36 дней, −9 885 ₽ |
31,7 с |
34 дня, −15 268 ₽ |
21,1 с |
Кайф при потере до 20 000 ₽ |
37 дней, −16 703 ₽ |
32,5 с |
34 дня, −15 268 ₽ |
36,3 с |
Все десять оптимумов доказаны. Самая долгая задача — 36 секунд. Лето решается быстрее: вариантов 14-дневного куска меньше, искать проще. А дольше всего солвер думает, когда есть бюджет: ограничение на деньги сталкивает две цели лбами.
Солвер ставит кусок на 2–16 мая — ловит 9-е и перенесённое 10-е. «Лето» по версии солвера — это майские. И стоит оно 5 383 ₽ сверху.
Хочу только август? Chunk = fun x -> x.Month = 8. Пять секунд — кусок 16–29 августа, +75 163 ₽. Море, дача, сессия, «только не в отчётный период» — всё это предикаты. Модель не меняется ни на строчку. Вот ради этого всё и затевалось.
Все планы на одной картинке

Синий — будни в отпуске, голубой — выходные внутри отдыха, оранжевый — выходные, проданные ради денег. Денежные планы рассыпаются оранжевой сыпью по субботам. Всё остальное собирается в одну синюю полосу.
Сколько стоит кайф
Самое интересное. Прогоняем бюджет от +80 000 до −45 000 ₽ и для каждого ищем максимум кайфа. 28 задач, каждая — от долей секунды до 93 секунд, все оптимумы доказаны:

Без потерь — 32 дня подряд. Бесплатно. Просто правильно расставить даты.
Лучшие 10 тысяч в жизни — сразу за нулём: −9 885 ₽ дают 36 дней. +4 дня за десятку.
Дальше — грабёж. 37 дней — при −20 тысячах, 38 — при −30, 39 — при −45.
Лето — до трёх дней меньше при том же бюджете, потолок — 38 дней.
Глаза — свидетель ненадёжный, нужен второй. Возьмём точки, где я уже теряю деньги, и проведём через них параболу методом наименьших квадратов (Fit.Polynomial из Math.NET): дни ≈ 32,43 + 0,315·x − 0,004·x², где x — потеря в тысячах. R² = 0,95 — парабола объясняет 95% разброса точек, то есть кривая и правда загибается, а не мерещится.
Производная параболы — сколько дней даёт следующая тысяча. Перевернём её — и получим цену следующего дня: у нуля ~3 200 ₽, на −20 тысячах ~6 500 ₽, у самого потолка — под 13 600 ₽.
#r "nuget: MathNet.Numerics, 5.0.0" let coef = Fit.Polynomial(xs, ys, 2) for x in [ 0.0; 10.0; 20.0; 30.0 ] do printfn $"потеря {x:F0} тыс ₽: следующий день отдыха ≈ {1000.0 / (coef[1] + 2.0 * coef[2] * x):F0} ₽"

«Максимум кайфа» — плохая цель. Хорошая цель — точка, где кривая загибается. У меня это −10 тысяч и 36 дней подряд.
Выводы
Солвер найдёт лазейку лучше вас. Если он отправил вас в отпуск по субботам — это баг ваших ограничений, а не солвера.
Формулировка важнее железа. Одна и та же сумма квадратов: «не доказал за минуту» против «доказал за 11–36 секунд».
Проверяйте солвер независимым кодом. Всегда.
CP-SAT — обычный NuGet-пакет. Булевы переменные,
AddExactlyOne,AddAtMostOne, линейная цель. Никакой магии.
Солвер нашёл оптимум. Осталось одно ограничение, которое на F# не выражается:
model.Add(manager.Approves plan)
Все графики построены с помощью Plotly.NET.Interactive
#r "nuget: Plotly.NET.Interactive, 5.0.0"
Код — в ноутбуке https://gist.github.com/gandjustas/52aadbf30d82ceed4b4cf0139157ac50. Вариант на C# для тех, кто не знает F# —https://gist.github.com/gandjustas/d8ce2d36df5633bec703c0b04adb23d3.
Мои ограничения чуть сложнее и модель тоже, но первый отпуск я уже планирую на март 2027 года. А вы уже сдали даты? Признавайтесь в комментариях, сколько денег оставили на столе.
Даты в статье — не финансовая рекомендация. Перед отпуском проконсультируйтесь с бухгалтерией.
Заключение
Солверы (CP, LP, SAT) — очень крутые инструменты, о которых программисты почти забыли в эпоху ML и AI. Но они все еще могут решать множество задач гораздо эффективнее, чем рукопашный код и не требуют обучения или валидации человеком.

В каждом пакетном менеджере прячется небольшой CP-солвер, который решает какие версии пакетов подобрать чтобы учесть все ограничения. А вывод типов, как в F#, TypeScript или Rust — это уже готовый солвер.
С помощью солверов можно решать бизнес-задачи: не только составление расписаний и графиков, но и оптимизация маршрутов (Задача коммивояжёра), распределение бюджетов, оптимизация производства. Добавьте этот инструмент в свою копилку.
Спасибо всем кто дочитал до конца.
Комментарии (6)

adrozhzhov
20.10.2026 09:26Альфа-Банкс лета обещает расчёт отпускных сделать для зарплатников в приложении, но все никак не осилит. Может прочитают формулы, наложат на свои данные по поступлениям седств, и хотя бы попытаются.
Но расчёт отпускных, даже для банка, это, почему-то, очень заутанная задача. Хотя формулы все простые

gandjustas Автор
20.10.2026 09:26Я как-то занимался расчетом отпускных, но это задача не для сорвера, там нет линейных ограничений и целевых функций. Но там надо иметь историю всех начислений и удержаний за прошлые 12 месяца до отпуска, с учетом всех условий труда. Это реализовано в 1С ЗУП и это чуть ли не самые сложные расчеты во всей экосистеме 1С.
В этой статье я специально убрал "за скобки" эту сложность, приняв начисления за прошлый год равными 12*месячный оклад.
adrozhzhov
Ну и эта сферическая модель для расчетов отпусков в вакууме, конечно,
неправильная.непрактичная и не применимая к суровой реальностиОтпускные считаются по среднему за последний год.
И выходить после 1 квартала, когда у многих приходит годовая премия, сильно выгоднее, чем в феврале-марте, когда такая премия влиять на размер выплат не будет.
(немножко знаю про отпуска и расчет в разных условиях, для уменьшения финансовой нагрузки организации на одной из работ организовывали мини-отпуска пятница-воскресенье для сотрудников, у которых накопилось много отпуска, есть
уникальныйредкий опыт анализа расчётного листка для случая когда вызвали из отпуска для направления в командировку на прохождение интервью в посольстве; и выход на больничный в отпуске тоже был)По итогу оптимизация применяется и по степени фана и срокам отдыха.
Это разные оптимизации.
Степень фана может быть привязана к календарным событиям (сезон охоты-рыбалки, прилёт на гнездование птиц, цветение сакуры), к внешним плавающим датам (ежегодная гонка любимой гоночной серии, интересный музыкальный фестиваль или концерт), окну возможностей, зависящему от других (осенние и весенние каникулы в школах)
gandjustas Автор
Вы дочитали статью хотя бы до середины?
adrozhzhov
Да, статью читал. Вы, надеюсь, прочитали комментарий? И у меня обоснованные сомнения про сферическую вакуумность первой, самую денежной стратегии февраль-март.
Остальное рассуждения про стратегии подбора окошек для отпуска IRL, в привязке к реальному опыту (и это без планирования с учетом отпусков коллег).
Если цель получить максимум выплат, то больничные выплачиваются за календарные дни. И болеть выгоднее всего с 31 декабря по условно 9 января (берегите здоровье, но это такой вот бонус от системы компенсации нетрудоспособности).
Получаете больничный, когда другие виды оплат (ЗП, отпускные) не применяются.
gandjustas Автор
Пример в статье надуманный, чтобы показать:
Как превратить требования законов в модель задачи
Почему перебор на такой задаче быстро выдыхается (а принципиально другого способа решения нет)
Что такое солвер и какие ограничения (constraints) можно задать солверу для решения такой задачи
Как преодолеть ограничения (limitations) ограничений (constraints) и целевых функций: квадратичный "кайф" и комбинация с деньгами
Как комбинировать солвер с другими алгоритмами
Как делать модели на основе внешних предикатов
Цель статьи статьи вообще не в том, чтобы продать свои субботы подороже. Цель в том, что показать как такие задачи решать программисту.
Все что вы написали про стратегию конечно же правильно, и это можно добавить в модель, посчитав оптимум. В том числе даже целого отдела или даже организации.