Find the Duplicate Number
Тебе дан массив nums из n+1 целых чисел, каждое из которых находится в диапазоне от 1 до n. Ровно одно значение встречается более одного раза, возможно, много раз, и нужно вернуть это значение.
Реши задачу, не изменяя nums и используя лишь постоянный объём дополнительной памяти.
Функция
- numsinteger-array
- n+1 целых чисел, каждое от 1 до n
- Возвращаетinteger
- значение, которое встречается более одного раза
Ограничения
1 ≤ n ≤ 104nums.length == n+11 ≤ nums[i] ≤ n- Ровно одно значение встречается два или более раз; каждое другое значение встречается не более одного раза.
Примеры
- Ввод
- nums = [2, 5, 1, 3, 5, 4]
- Вывод
- 5
- Пояснение
- Здесь
nравно 5, а число 5 стоит на позициях 1 и 4, поэтому ответ — 5. Все остальные значения от 1 до 5 встречаются один раз.
- Ввод
- nums = [4, 2, 4, 1, 4]
- Вывод
- 4
- Пояснение
- 4 встречается три раза — в позициях 0, 2 и 4, а 3 не встречается вовсе. Повторяющееся значение может заменить несколько недостающих значений, поэтому ответ — 4.
+17 скрытых тестов при отправке
Дополнительный вопрос
The двоичный поиск по значениям сохраняет оба правила за время O(n log n). Сможешь обеспечить для них время O(n)?
Подсказки
Открывайте по одной. Каждая подсказывает чуть больше.
Каждое значение находится в диапазоне от 1 до
n, а в массиве есть позиции от 0 доn. Поэтому каждое значение также является допустимой позицией. Начни с позиции 0, перейди к позицииnums[0], затем к позиции, на которую указывает это значение, и так далее. Что должно произойти с этим проходом?Обход никогда не останавливается и может посетить только
n+1позиций, поэтому он зацикливается. Позиция, в которой он входит в цикл, достигается из двух разных позиций, и обе они содержат эту позицию в качестве своего значения.Найдите точку входа в цикл с помощью двух указателей, начав с позиции 0: один перемещается на один шаг за раунд, другой — на два, пока они не окажутся на одной позиции. Затем верните один из указателей в позицию 0 и перемещайте оба на один шаг за раз. Они встретятся в точке входа, которая и является ответом.
Решение
Хеш-множество или сортировка сразу находят повторяющееся значение, но оба способа нарушают правила: множеству нужна память для каждого значения, а сортировка изменяет nums. Решение кроется в числах. Каждое значение находится между 1 и n, поэтому оно также является допустимой позицией в массиве. Считай каждое значение ссылкой на другую позицию, и следование по ссылкам от позиции 0 всегда приводит к циклу, вход в который и есть дубликат. Быстрый и медленный указатели Флойда находят этот вход, используя два целых числа.
Сравните каждую пару
Верно, но не успевает на самых больших тестах
Идея
Повторяющееся значение находится как минимум в двух позициях i < j. Сравни каждую позицию со всеми позициями после неё; первая пара с равными значениями и даёт ответ. В первом примере в позиции 1 находится 5, а при просмотре позиций начиная с 2 обнаруживается ещё одна 5 в позиции 4.
Так соблюдаются оба правила: ничего не записывается, а единственная используемая память — два счётчика циклов. Это медленно, потому что приходится сравнивать пары. При n+1 = 10,001 значениях, если оба экземпляра находятся ближе к концу, проверяется около 5 × 10^7 пар.
Алгоритм
- Для каждой позиции
iот 0 до конца: - Для каждой позиции
jпослеiсравнитеnums[i]сnums[j]. - При первом совпадении верните
nums[i].
def findDuplicate(nums):
n = len(nums)
for i in range(n):
for j in range(i + 1, n):
if nums[i] == nums[j]:
return nums[i]
return -1 # unreachable: the input always holds a repeatБинарный поиск по значению
Идея
Выполняйте поиск по диапазону значений, а не позиций. Выберите порог m и подсчитайте, сколько элементов nums не превышают m.
Если повторяющееся значение d больше m, значения от 1 до m встречаются не более одного раза каждое, поэтому количество не превышает m. Если d не превышает m, каждое значение больше m встречается не более одного раза, поэтому выше m находится не более n-m элементов, а не менее m+1 элементов не превышают m. Поэтому проверка «count > m» ложна для каждого m меньше d и истинна начиная с d. Бинарный поиск находит первое m, для которого она становится истинной, и это d.
Во втором примере n равно 4. При m = 2 элементы 2 и 1 дают количество 2, то есть не больше 2, поэтому ответ больше 2. При m = 3 количество по-прежнему равно 2, поэтому ответ — 4. На каждом шаге весь массив просматривается один раз, а диапазон делится пополам, поэтому объём работы составляет O(n log n): около 14 проходов по 10,001 значениям.
Алгоритм
- Установи
low= 1 иhigh=n, длинаnumsминус один. - Пока
low < high, возьмиmidпосередине между ними. - Посчитай количество элементов
nums, которые не большеmid. - Если количество больше
mid, установиhigh=mid; иначе установиlow=mid+1. - Верни
low.
def findDuplicate(nums):
low, high = 1, len(nums) - 1
while low < high:
mid = (low + high) // 2
# How many values fall in 1..mid?
count = 0
for x in nums:
if x <= mid:
count += 1
if count > mid:
high = mid # 1..mid holds more values than it has room for
else:
low = mid + 1 # the repeat is above mid
return lowОбнаружение цикла Флойда в цепочках значений
Идея
Рассматривайте массив как набор ссылок: позиция i указывает на позицию nums[i]. У каждой позиции от 0 до n есть ровно одна исходящая ссылка, и каждая ссылка ведёт куда-то от 1 до n. В первом примере ссылки такие: 0 → 2, 1 → 5, 2 → 1, 3 → 3, 4 → 5 и 5 → 4.
Начните с позиции 0 и следуйте по ссылкам. Путь не может оборваться, потому что у каждой позиции есть ссылка, а всего позиций n+1, поэтому рано или поздно вы вернётесь к уже посещённой позиции. После этого путь будет бесконечно ходить по кругу. Путь состоит из хвоста и цикла, напоминая букву ρ. В первом примере путь такой: 0, 2, 1, 5, 4, 5, 4 и так далее: хвост — 0, 2, 1, а цикл — 5, 4. Позиция 3 указывает сама на себя, но путь до неё никогда не доходит, и это не мешает.
Вход в цикл — это дубликат. Путь дважды входит в позицию 5 из разных мест: один раз с конца хвоста (позиции 1, потому что nums[1] равно 5) и один раз с конца цикла (позиции 4, потому что nums[4] равно 5). В двух разных позициях хранится значение 5, поэтому 5 повторяется. Хвост всегда содержит позицию 0, потому что ни одно значение не равно 0 и ни одна ссылка никогда не ведёт обратно к ней, поэтому у входа всегда есть эти два разных пути. Повторяется ровно одно значение, значит, вход соответствует этому значению.
Теперь найдите вход с помощью двух указателей, как при обнаружении цикла в связном списке. На первом этапе slow проходит по одной ссылке за раунд, а fast — по двум, пока они не окажутся в одной позиции где-то в цикле. В первом примере они встречаются в позиции 4. На втором этапе верните slow в позицию 0, оставьте fast на месте и передвигайте оба указателя на одну ссылку за раунд. Они встретятся на входе в цикл.
Почему работает второй этап: пусть хвост состоит из T ссылок до входа в цикл, а в цикле C позиций. Когда указатели встретились, slow прошёл s шагов, а fast — 2s. Они стояли в одной точке, значит, дополнительные s шагов fast составили целое число кругов цикла. Ещё через T шагов slow достигнет входа в цикл из позиции 0, а fast окажется там же, где был бы путь из позиции 0 через s+T шагов, поскольку дополнительные круги ничего не меняют. Это T шагов до входа плюс s шагов — целое число кругов, — поэтому fast тоже окажется на входе. Раньше они встретиться не могут, потому что slow всё ещё находится в хвосте, а fast не выходит из цикла. В первом примере slow проходит позиции 2, 1, 5, а fast — 5, 4, 5, и они встречаются в позиции 5 через T = 3 шага.
Каждый этап занимает O(n) шагов, для работы нужны только две позиции памяти, а массив nums никогда не изменяется.
Алгоритм
- Считай каждую позицию
iузлом, который ссылается на позициюnums[i], и установи оба указателя на позицию 0. - Фаза 1: перемещай
slowнаnums[slow], аfast— наnums[nums[fast]], пока они не совпадут. - Фаза 2: установи
slowобратно на 0. - Перемещай оба указателя на один переход за раз:
slowнаnums[slow], аfast— наnums[fast], пока они не совпадут. - Верни эту позицию: это повторяющееся значение.
def findDuplicate(nums):
# Treat each index i as a node with one link, to nums[i].
# Phase 1: slow moves one link, fast moves two, until they meet in the cycle.
slow = fast = 0
while True:
slow = nums[slow]
fast = nums[nums[fast]]
if slow == fast:
break
# Phase 2: restart one pointer at index 0 and move both one link at a time.
# They meet at the cycle's entrance, the index two positions link to.
slow = 0
while slow != fast:
slow = nums[slow]
fast = nums[fast]
return slow
Ловушки и крайние случаи
Большинство неверных ответов возникает из-за путаницы между позициями и значениями или из-за того, что алгоритм Флойда останавливают на одну фазу раньше.
- Возвращение точки встречи на фазе 1. Это какая-то позиция в цикле, не обязательно его начало. В первом примере указатели встречаются на позиции 4, но ответ — 5.
- Проверка
slow == fastдо первого перемещения. Оба указателя начинают с позиции 0, поэтому цикл сразу же завершится. Сначала переместите указатели, а затем сравните их или задайте им начальные позиции на одно и два звена вперёд. - Начало обхода не с позиции 0. Ни одна ссылка не указывает на позицию 0, поскольку ни одно значение не равно 0, и именно это гарантирует наличие хвоста. Если начать с другой позиции, можно оказаться в цикле, в который нельзя попасть извне, как в случае с позицией 3 в первом примере: его вход ничего не доказывает.
- Предположение, что дубликат встречается ровно дважды. Трюк с суммой — общая сумма минус
1 + 2 + ... + n— даёт 15 минус 10 = 5 во втором примере, но правильный ответ — 4. То же относится и к трюкам с XOR. - Бинарный поиск по позициям вместо значений или проверка
count >= mid. Количество значений, не превышающихm, в точности равноm, если ни одно значение от 1 доmне повторяется и ни одно не пропущено, поэтому только>позволяет различить две стороны. - Отметка посещённых значений с помощью отрицания
nums[x]или перестановки значений на нужные места. Оба способа работают, но оба изменяют массив, что запрещено условием задачи.
Частые вопросы4
Какова временная сложность задачи «Найти повторяющееся число»?
Алгоритм Флойда для обнаружения циклов работает за время O(n) и использует O(1) дополнительной памяти: каждая из двух его фаз проходит не более нескольких кратных n ссылок. Бинарный поиск по значениям занимает время O(n log n) и использует O(1) памяти. Сравнение каждой пары занимает O(n²).
Почему алгоритм обнаружения цикла Флойда находит повторяющееся число?
Если читать каждое значение как ссылку из его позиции на позицию, которую оно называет, обход из позиции 0 должен завершиться циклом, потому что он никогда не останавливается и в его распоряжении всего n+1 позиций. Позиция, в которой он входит в цикл, достигается из двух разных позиций — одной на хвосте и одной в цикле, — поэтому два элемента содержат это значение. Метод Флойда находит вход в цикл с помощью двух указателей, поэтому он находит повторяющееся значение.
Почему бы не использовать хеш-множество или не отсортировать массив?
Оба находят ответ за время O(n) или O(n log n), и в реальной программе подошёл бы любой из этих вариантов. В условии их намеренно запрещают: хеш-множество использует дополнительную память объёмом O(n), а сортировка либо изменяет nums, либо требует полной копии. Именно эти ограничения подталкивают вас к представлению задачи в виде циклов.
Почему формула суммы не работает для задачи Find the Duplicate Number?
Вычитание 1 + 2 + ... + n из суммы массива дает дубликат только тогда, когда он встречается ровно дважды, а каждое другое значение — один раз. Здесь повторяющееся значение может встречаться много раз и заменять отсутствующие значения. В [4, 2, 4, 1, 4] разность составляет 15 минус 10 = 5, которого даже нет в массиве.
Похожие задачи
Задачи на те же идеи. Решите две или три, и приём запомнится.
Python
def findDuplicate(nums):
# Напишите код здесьСлучай 1
Случай 2
Ввод
nums = [2, 5, 1, 3, 5, 4]
Ожидается
5