Split Array Largest Sum
Дан массив nums неотрицательных целых чисел и целое число k. Разделите nums ровно на k частей, каждая из которых представляет собой непустой непрерывный отрезок значений, при этом порядок частей должен сохраняться. У каждой части есть сумма, а стоимость разбиения равна наибольшей из этих сумм.
Верните минимальную стоимость, достижимую при любом разбиении на k частей.
Функция
- numsinteger-array
- неотрицательные значения по порядку
- kinteger
- количество смежных частей, на которые их нужно разделить
- Возвращаетinteger
- наименьшее возможное значение суммы наибольшей части
Ограничения
1 ≤ nums.length ≤ 50000 ≤ nums[i] ≤ 1051 ≤ k ≤ nums.length- Каждая часть содержит как минимум одно значение. Сумма значений части, в которой все значения равны 0, равна 0, и это допустимо.
Примеры
- Ввод
- nums = [6, 2, 9, 4, 7, 3]k = 3
- Вывод
- 13
- Пояснение
- Разбиение
[6, 2],[9, 4],[7, 3]даёт суммы 8, 13 и 10, поэтому его стоимость равна 13. Ни одно разбиение не имеет стоимость 12: если упаковывать части слева направо так, чтобы каждая сумма была не больше 12, получатся[6, 2],[9],[4, 7],[3]— четыре части, хотя разрешено только три.
- Ввод
- nums = [8, 1, 1, 1, 5]k = 2
- Вывод
- 8
- Пояснение
- 8 находится в одной из частей, поэтому ни одно разбиение не может стоить меньше 8.
[8]и[1, 1, 1, 5]в сумме дают 8, значит, значение 8 достижимо.
- Ввод
- nums = [3, 0, 4]k = 3
- Вывод
- 4
- Пояснение
- Три значения и три части оставляют по одному значению на часть, суммы равны 3, 0 и 4. Сумма средней части равна 0, и это нормально: часть должна содержать только одно значение.
+20 скрытых тестов при отправке
Дополнительный вопрос
Каждая жадная проверка считывает все значения n. С помощью префиксных сумм проверка может вместо этого находить конец каждой части бинарным поиском. Насколько быстрее становится весь метод, когда k мало, а nums — длинный?
Подсказки
Открывайте по одной. Каждая подсказывает чуть больше.
Предположим, кто-то обещает, что сумма наибольшей части может быть не больше
c. Можешь ли ты быстро определить, достаточно лиkчастей?Заполняй части слева направо и закрывай часть, только когда следующее значение вытолкнет её за пределы
c. Так получится минимальное количество частей, и при большемcих никогда не потребуется больше.Выполните бинарный поиск значения
cмежду наибольшим значением и общей суммой. Если жадный подсчёт не превышаетk, ответ равенcили меньше; в противном случае он больше.
Решение
Эти два требования противоречат друг другу: нужно использовать ровно k частей, и при этом хочется, чтобы самая большая часть была как можно меньше. Перебор всех мест для k-1 разрезов быстро становится слишком затратным, а динамическое программирование по префиксам снижает сложность до O(k·n²), но этого всё ещё слишком медленно для 5000 значений. Быстрая идея — перевернуть вопрос. Вместо поиска наилучшего разбиения зададим верхнюю границу и проверим, можно ли уложить в неё k частей. На это отвечает один жадный проход; при увеличении границы ответы меняются только один раз, а бинарный поиск находит этот переход примерно за 29 проходов.
Динамическое программирование по префиксам
Верно, но не успевает на самых больших тестах
Идея
Рассмотрим последнюю часть разбиения. Если первые j значений образуют p частей, последняя часть — это некоторый отрезок nums[i..j-1], а первые i значений образуют остальные p-1 частей. Стоимость — это большее из двух чисел: стоимость этих p-1 частей и сумма последнего отрезка. Какой бы ни была последняя часть, первые i значений нужно разбить с минимально возможной стоимостью, и это оптимальное разбиение не зависит от того, что находится правее. Поэтому его можно вычислить один раз и использовать повторно.
Обозначим через best[p][j] минимальную стоимость разбиения первых j значений на p частей. Для одной части выбора нет: best[1][j] — это сумма первых j значений. Для большего числа частей переберём каждую начальную позицию i последней части: best[p][j] = min over i of max(best[p-1][i], prefix[j] - prefix[i]), где prefix[j] — сумма первых j значений. Позиция i начинается с p-1, потому что для непустых p-1 частей нужно как минимум p-1 значений, и заканчивается на j-1, потому что последняя часть должна содержать хотя бы одно значение. Ответ — best[k][n]. Строка p использует только строку p-1, поэтому достаточно двух строк длины n+1.
В первом примере разбиение [6, 2, 9, 4] на две части может завершить первую часть после 6 (стоимость max(6, 15) = 15), после 2 (max(8, 13) = 13) или после 9 (max(17, 4) = 17), поэтому best[2][4] = 13. Затем best[3][6] рассматривает последнюю часть [7, 3] и получает max(13, 10) = 13; ни один другой вариант начала не даёт меньшую стоимость.
Проблема — в объёме вычислений. Есть k строк, n конечных позиций в каждой строке и до n начальных позиций для каждой конечной позиции: до k·n²/2 шагов. При n = 5000 и k = 2500 внутренний цикл выполняется около 1.8 × 10^10 раз: 18 секунд даже при скорости 10^9 простых операций в секунду. Эту динамику всё равно полезно знать: она не предполагает, что значения неотрицательны, поэтому продолжает работать там, где быстрый метод не справляется.
Алгоритм
- Постройте
prefix, гдеprefix[j]— сумма первыхjзначений. - Задайте строку для одной части:
best[j] = prefix[j]. - Для каждого количества частей
pот 2 доkи каждого концаjотpдоnнайдите минимум поiотp-1доj-1выраженияmax(best[i], prefix[j] - prefix[i]). - Сохраните эти минимумы в новой строке и сделайте её
best. - Верните
best[n].
def splitArray(nums, k):
n = len(nums)
# prefix[j] is the sum of the first j values
prefix = [0] * (n + 1)
for i, x in enumerate(nums):
prefix[i + 1] = prefix[i] + x
# best[j]: the smallest largest part when the first j values form one part
best = prefix[:]
for parts in range(2, k + 1):
nxt = [0] * (n + 1)
for j in range(parts, n + 1):
lowest = prefix[j] # never worse than one part holding everything
for i in range(parts - 1, j):
# the first i values form parts-1 parts, nums[i..j-1] is the last part
worst = max(best[i], prefix[j] - prefix[i])
if worst < lowest:
lowest = worst
nxt[j] = lowest
best = nxt
return best[n]Двоичный поиск на наибольшую сумму
Идея
Поставим вопрос иначе. Выберем предел c и спросим: можно ли разбить nums на k частей так, чтобы сумма каждой части была не больше c? Ответ на задачу — наименьший предел, для которого ответ положительный. На этот вопрос ответить гораздо проще, чем на исходный, по двум причинам.
Во-первых, ответ даёт один жадный проход. Идём слева направо и добавляем значения в текущую часть, пока её сумма не превышает c; когда следующее значение превысит этот предел, завершаем часть и начинаем новую с этого значения. При таком разбиении используется минимально возможное число частей. Сравним его с любым другим допустимым разбиением — часть за частью. Обе первые части начинаются с первого значения, а жадный алгоритм останавливается, только когда следующее значение уже не помещается, поэтому его первая часть заканчивается не раньше. Тогда вторая часть жадного разбиения начинается не раньше второй части другого разбиения. Значения жадной части до конца той второй части составляют её фрагмент, а при отсутствии отрицательных значений сумма фрагмента не может превышать сумму целого, поэтому они помещаются, и жадный алгоритм снова доходит как минимум до того же места. Жадный алгоритм никогда не отстаёт, поэтому ему никогда не требуется больше частей.
Во-вторых, меньшее число частей, чем k, так же хорошо, как ровно k. Если жадному алгоритму требуется m < k частей, разделим на две части ту, в которой два или более значений. Суммы этих частей не превышают сумму исходной части, поскольку отрицательных значений нет, а так как n ≥ k, такая часть всегда найдётся, пока число частей не достигнет k. Значит, проверка имеет вид partsNeeded(c) ≤ k.
Теперь ключевое свойство: проверка монотонна. Если подходит предел c, то подходит и c+1, поскольку то же разбиение укладывается в больший предел. Для пределов от max(nums) до sum(nums) ответы выглядят так: нет, нет, ..., нет, да, да, ..., да, и нужно найти первое «да». Диапазон безопасен с обеих сторон: никакой предел меньше max(nums) не вместит это значение, а общая сумма всегда помещается в одну часть. Первое «да» также является реальной стоимостью, а не просто границей: если сумма ни одной части разбиения не равна c, то подошёл бы и предел c-1.
Проследим за первым примером: [6, 2, 9, 4, 7, 3] при k = 3. Пределы перебираются от 9 до 31. При пределе 20 получаем [6, 2, 9], [4, 7, 3]: 2 части, да, значит, диапазон становится от 9 до 20. При пределе 14 получаем [6, 2], [9, 4], [7, 3]: 3 части, да, диапазон — от 9 до 14. При пределе 11 получаем [6, 2], [9], [4, 7], [3]: 4 части, нет, диапазон становится от 12 до 14. При пределе 13 требуется 3 части, да, диапазон — от 12 до 13. При пределе 12 требуется 4 части, нет, поэтому ответ — 13.
Каждый проход читает n значений, а диапазон каждый раз уменьшается вдвое. При общей сумме S до 5 × 10^8 потребуется около 29 проходов по 5000 значений, то есть примерно 150000 шагов.
Алгоритм
- Задай
lo = max(nums)иhi = sum(nums). - Пока
lo < hi, вычисляйmid = lo + (hi - lo) / 2. - Подсчитай количество частей, которое жадный алгоритм использует при ограничении
mid: начни с 1 части и текущей суммы 0; если добавление значения превыситmid, добавь часть и начни сумму заново с этого значения. - Если количество не превышает
k, задайhi = mid; иначе задайlo = mid + 1. - Верни
lo.
def splitArray(nums, k):
def parts_needed(cap):
# Fill each part left to right and start a new one only when the next value would pass cap.
parts, current = 1, 0
for x in nums:
if current + x > cap:
parts += 1
current = x
else:
current += x
return parts
lo, hi = max(nums), sum(nums) # one value per part at best, everything in one part at worst
while lo < hi:
mid = (lo + hi) // 2
if parts_needed(mid) <= k:
hi = mid # mid works, so the answer is mid or smaller
else:
lo = mid + 1 # mid needs more than k parts, so the answer is larger
return lo
Ловушки и крайние случаи
Поиск здесь короткий, поэтому ошибки кроются в жадной проверке и границах.
- Начало с
loменьшеmax(nums). Жадная проверка помещает значение, превышающее ограничение, в отдельную часть и продолжает работу, поэтому считает ограничение 5 допустимым для[1, 9]приk = 2. Начинай с наибольшего значения или сделай так, чтобы проверка завершалась неудачей, если одно значение превышает ограничение. - Проверка
partsNeeded(c) == k. Жадный алгоритм часто требует меньше частей, чемk: для[3, 0, 4]иk = 3ограничение 4 позволяет разбить массив на[3, 0]и[4]. При==ни одно ограничение не будет подходящим. Меньшее число частей всегда можно разделить ещё, поэтому проверяй условие≤ k. - Подсчёт частей, начиная с 0. Первая часть существует ещё до того, как какое-либо значение переполнит её, поэтому счётчик начинается с 1.
- Присваивание
hi = mid - 1, когдаmidподходит. Так можно пропустить сам ответ. Оставьhi = midи выполняй цикл, покаlo < hi. - Начало DP с
i, равного 0. Ячейкаbest[i]приi < p-1соответствует меньшему числу значений, чем частей, а такое разбиение невозможно; в строке, заполненной нулями, её значение читается как стоимость 0. Для[100, 1, 1]приk = 3DP тогда выдаёт 2 вместо 100. Начинайiсp-1. - Переполнение при больших ограничениях. Здесь сумма не превышает
5 × 10^8, поэтому для неё достаточно 32-разрядных целых чисел. Если значения достигают10^6, уже 2148 таких значений превысят2^31-1, поэтому используй 64-разрядные суммы.
Частые вопросы4
Какова временная сложность задачи «Разделение массива с минимизацией наибольшей суммы»?
Бинарный поиск выполняется за время O(n log S), где n — длина nums, а S — её сумма. Каждая жадная проверка представляет собой один проход по массиву, а диапазон ограничений после каждой проверки сокращается вдвое: около 29 проверок при S = 5 × 10^8. Алгоритм использует дополнительную память объёма O(1). ДП выполняется за время O(k·n²) и использует память объёма O(n).
Почему проверка реализуемости монотонна?
Если сумма каждой части некоторого разбиения не превышает c, то для того же разбиения сумма каждой части также не превышает c+1. Поэтому, если ограничение подходит, подходят и все большие ограничения, а если ограничение не подходит, не подходят и все меньшие. Сначала в ответах идёт последовательность «нет», за которой следует последовательность «да» — именно такую границу и нужно найти бинарному поиску.
Почему жадная проверка находит наименьшее количество частей?
Жадный алгоритм продолжает добавлять значения в часть, пока следующее значение не превысит ограничение. Сравним его с любым допустимым разбиением, часть за частью. Каждая часть жадного алгоритма начинается не раньше части другого разбиения с тем же номером, поэтому её значения до конца этой части составляют фрагмент части, сумма которой не превышает ограничение. Ни одно значение не является отрицательным, значит, сумма фрагмента тоже не превышает ограничение, а жадный алгоритм продвигается как минимум так же далеко. Жадный алгоритм никогда не отстаёт, поэтому он охватывает весь массив с таким же или меньшим количеством частей, чем любое разбиение.
Работает ли бинарный поиск с отрицательными числами?
Нет. При отрицательных значениях добавление значения может уменьшить сумму, поэтому жадный алгоритм может закрыть часть слишком рано и пропустить подходящее разбиение. Разбиение части также может привести к тому, что сумма одного фрагмента превысит сумму целой части, поэтому наличие менее k частей больше не означает, что подойдут k частей. ДП не делает ни одного из этих предположений и остаётся корректным при времени работы O(k·n²).
Похожие задачи
Задачи на те же идеи. Решите две или три, и приём запомнится.
Python
def splitArray(nums, k):
# Напишите код здесьСлучай 1
Случай 2
Случай 3
Ввод
nums = [6, 2, 9, 4, 7, 3] k = 3
Ожидается
13