Search in Rotated Sorted Array
Список различных целых чисел был отсортирован по возрастанию, а затем циклически сдвинут: некоторое количество элементов, возможно ноль, взяли из начала и переместили в конец в том же порядке. Например, после сдвига на 4 список [2, 5, 8, 11, 15, 19, 23] становится [15, 19, 23, 2, 5, 8, 11]. Дан сдвинутый список nums и целое число target. Верните индекс target в nums, считая с 0, или -1, если его там нет, за время O(log n).
Функция
- numsinteger-array
- повёрнутый отсортированный список различных целых чисел
- targetinteger
- значение, которое нужно найти
- Возвращаетinteger
- индекс target в nums или -1, если он отсутствует
Ограничения
1 ≤ nums.length ≤ 5000-104 ≤ nums[i], target ≤ 104- Все значения в
numsразличны. nums— это возрастающий список, повёрнутый на некоторое значениеk, где0 ≤ k < nums.length; приk = 0он остаётся неповёрнутым.
Примеры
- Ввод
- nums = [15, 19, 23, 2, 5, 8, 11]target = 5
- Вывод
- 4
- Пояснение
- 5 находится по индексу 4. В первом среднем элементе, по индексу 3, находится 2, поэтому правая половина
[2, 5, 8, 11]отсортирована, а 5 находится между 2 и 11. В следующем среднем элементе, по индексу 5, находится 8; отсортированная левая часть[5, 8]содержит 5, что приводит к индексу 4.
- Ввод
- nums = [40, 50, 60, 70, 10, 20, 30]target = 65
- Вывод
- -1
- Пояснение
- 65 должно находиться между 60 и 70, и ни один элемент не содержит его. Первый средний элемент, 70 с индексом 3, помещает 65 внутрь отсортированной левой части
[40, 50, 60, 70]. Диапазон сужается внутри этой последовательности, пока не станет пустым, поэтому функция возвращает-1.
- Ввод
- nums = [8, 13, 21, 1, 3, 5]target = 13
- Вывод
- 1
- Пояснение
- Первый средний элемент с индексом 2 содержит 21. Левая часть
[8, 13, 21]отсортирована, и 13 находится между 8 и 21, поэтому вся правая часть отбрасывается. Затем поиск находит 13 с индексом 1.
+23 скрытых тестов при отправке
Дополнительный вопрос
Если nums может содержать дубликаты, ни один алгоритм не может гарантировать O(log n). Можешь это доказать? Составь повёрнутый список из единиц со спрятанным в нём единственным нулём, так чтобы при любом поиске нуля приходилось просматривать каждый элемент.
Подсказки
Открывайте по одной. Каждая подсказывает чуть больше.
Выберите любой средний индекс и рассмотрите две половины по обе стороны от него. При повороте образовалось одно место, где значения убывают от наибольшего к наименьшему. Может ли это место убывания находиться в обеих половинах?
Как минимум одна половина всегда отсортирована, и сравнение
nums[lo]сnums[mid]показывает, какая именно. Для отсортированной половины можно за один шаг проверить, находится лиtargetмежду её первым и последним значениями.Оставляй
loиhiвокруг той части, в которой ещё может находитьсяtarget. На каждом шаге, если диапазон значений отсортированной половины содержитtarget, оставляй эту половину; иначе оставляй другую. Остановись, когда найдёшьtargetили диапазон опустеет.
Решение
Повернутый отсортированный список состоит из двух отсортированных последовательностей, расположенных одна за другой: [15, 19, 23], а затем [2, 5, 8, 11]. Обычный бинарный поиск здесь не работает, потому что сравнение target со средним значением больше не показывает, в какой половине находится target. Решение основано на одном факте: при любом разделении списка хотя бы одна из двух половин полностью отсортирована, и для отсортированной половины одним сравнением можно определить, может ли в ней находиться target.
Просканируй каждый элемент
Идея
Проверяйте каждый индекс по порядку и возвращайте первый, значение которого равно target. Если цикл заканчивается без совпадения, верните -1. Значения различны, поэтому первое совпадение — единственное, и такой просмотр корректен для любого списка, повёрнутого или нет.
Этот подход игнорирует всё, что сообщает вам условие задачи. Список состоит из двух отсортированных последовательностей, но при просмотре приходится считывать до всех 5000 элементов, тогда как для бинарного поиска нужно около 13 сравнений. С ростом входных данных разрыв увеличивается: для миллиона элементов требуется миллион сравнений, а бинарному поиску — около 20. В задаче требуется O(log n), поэтому этот подход — исходный вариант, который нужно улучшить, а не ответ.
Алгоритм
- Для каждого индекса
iот 0 доn-1сравнитеnums[i]сtarget. - Если они равны, верните
i. - После цикла верните
-1.
def search(nums, target):
for i, value in enumerate(nums):
if value == target:
return i
return -1Найди точку поворота, затем выполни бинарный поиск
Идея
Повернутый список состоит из двух отсортированных участков, и второй начинается с наименьшего значения. Обозначим его индекс через k. Зная k, мы сводим задачу к обычному бинарному поиску: nums[k..n-1] отсортирован и содержит значения от nums[k] до nums[n-1], а nums[0..k-1] отсортирован и содержит все значения больше. Одно сравнение target с nums[k] и nums[n-1] позволяет выбрать участок для поиска.
Чтобы найти k, выполним бинарный поиск места разрыва. Сравним среднее значение с последним значением диапазона — nums[hi]. Если nums[mid] > nums[hi], где-то после mid значения убывают, значит, наименьшее значение находится справа: зададим lo = mid + 1. В противном случае nums[mid..hi] возрастает без разрыва, поэтому наименьшее значение находится на позиции mid или левее: зададим hi = mid, оставив mid в диапазоне. Когда lo сравняется с hi, этот индекс и будет k.
Проследим за первым примером: [15, 19, 23, 2, 5, 8, 11] с target = 5. Среднее значение 2 не больше 11, поэтому hi становится равным 3; затем 19 больше 2, поэтому lo становится равным 2; затем 23 больше 2, поэтому lo становится равным 3, и k = 3. Поскольку 5 находится между nums[3] = 2 и nums[6] = 11, ищем в индексах от 3 до 6, где бинарный поиск находит 5 по индексу 4. Два бинарных поиска требуют примерно 2 log2 n шагов.
Алгоритм
- Задай
lo = 0иhi = n-1. Покаlo < hi, вычисляйmid; еслиnums[mid] > nums[hi], задайlo = mid + 1, иначе задайhi = mid. - Назови итоговый индекс
k: в нём находится наименьшее значение. - Если
nums[k] ≤ target ≤ nums[n-1], ищи по индексам отkдоn-1; иначе ищи по индексам от 0 доk-1. - Выполни обычный бинарный поиск в этом диапазоне и верни индекс
targetили-1, если диапазон опустеет.
def search(nums, target):
n = len(nums)
# 1. Find k, the index of the smallest value, where the second run starts.
lo, hi = 0, n - 1
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] > nums[hi]:
lo = mid + 1 # the drop is right of mid
else:
hi = mid # mid is in the low run: the minimum is at mid or left of it
k = lo
# 2. nums[k..n-1] and nums[0..k-1] are sorted: search the one whose range holds target.
if nums[k] <= target <= nums[n - 1]:
lo, hi = k, n - 1
else:
lo, hi = 0, k - 1
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1Один бинарный поиск по отсортированной половине
Идея
Не нужно знать, где находится точка поворота. Сохраняется обычное обещание бинарного поиска: если target есть в списке, его индекс находится между lo и hi. Посмотри на средний индекс mid. Значения уменьшаются только один раз во всём списке, поэтому это уменьшение встречается не более чем в одной из двух половин относительно mid, а другая половина отсортирована.
Найди отсортированную половину с помощью одного сравнения. Если nums[lo] ≤ nums[mid], левая половина nums[lo..mid] не содержит уменьшения и отсортирована. Поскольку ты уже знаешь, что nums[mid] не равно target, target может находиться в этой половине, только если nums[lo] ≤ target < nums[mid]. Если это так, установи hi = mid - 1; если нет, target может находиться только в другой половине, поэтому установи lo = mid + 1. Когда nums[lo] > nums[mid], уменьшение находится слева, правая половина nums[mid..hi] отсортирована, и решение определяет зеркальная проверка nums[mid] < target ≤ nums[hi]. Ты никогда не рассуждаешь напрямую о неотсортированной половине: target попадает в неё ровно тогда, когда не может находиться в отсортированной половине.
Проследи за первым примером: [15, 19, 23, 2, 5, 8, 11] с target = 5. Диапазон от 0 до 6 имеет середину 3 со значением 2. Поскольку 15 больше 2, правая половина [2, 5, 8, 11] отсортирована, и в ней есть 5, поэтому lo становится равным 4. У диапазона от 4 до 6 середина 5 со значением 8. Теперь nums[4] = 5 ≤ 8, левая половина [5, 8] отсортирована и содержит 5, поэтому hi становится равным 4. По индексу 4 находится 5: верни 4.
На каждом шаге диапазон сокращается вдвое, как при обычном бинарном поиске, поэтому цикл выполняется не более примерно log2(n) + 1 раз: 13 шагов для 5000 элементов, с использованием дополнительной памяти для двух индексов.
Алгоритм
- Установи
lo = 0иhi = n-1. - Пока
lo ≤ hi, вычисляйmid. Еслиnums[mid]равноtarget, верниmid. - Если
nums[lo] ≤ nums[mid], левая половина отсортирована: еслиnums[lo] ≤ target < nums[mid], установиhi = mid - 1, иначе установиlo = mid + 1. - Иначе отсортирована правая половина: если
nums[mid] < target ≤ nums[hi], установиlo = mid + 1, иначе установиhi = mid - 1. - Когда цикл завершится, верни
-1.
def search(nums, target):
lo, hi = 0, len(nums) - 1 # target, if present, sits in nums[lo..hi]
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
if nums[lo] <= nums[mid]:
# nums[lo..mid] is sorted: target is in it only if it fits its range
if nums[lo] <= target < nums[mid]:
hi = mid - 1
else:
lo = mid + 1
else:
# nums[mid..hi] is sorted
if nums[mid] < target <= nums[hi]:
lo = mid + 1
else:
hi = mid - 1
return -1
Ловушки и крайние случаи
Однопроходный поиск короткий, и почти все ошибки связаны с оператором сравнения.
- Написать
nums[lo] < nums[mid]вместо≤. Когда остаются два элемента,midравенlo, а левая половина состоит из одного элемента и отсортирована. При строгой проверке[9, 4]иtarget = 4ошибочно считают[9, 4]отсортированной правой половиной, ищут 4 вне диапазона от 9 до 4 и возвращают-1. - Сначала сравнивать
targetсnums[mid], как в обычном бинарном поиске. В массиве[15, 19, 23, 2, 5, 8, 11]приtarget = 19среднее значение 2 меньше 19, поэтому поиск перемещается вправо и не доходит до индекса 1. - Проверять только один конец отсортированной половины. В массиве
[40, 50, 60, 70, 80, 10, 20]приtarget = 80среднее значение равно 70, а левая половина[40, 50, 60, 70]отсортирована. Одна лишь проверкаtarget ≥ nums[lo]направляет поиск влево, потому что 80 больше 40, но 80 также больше 70, поэтому оно находится в правой половине. Проверяйте оба конца. - Забыть о случае без поворота в двухэтапном подходе. Когда
k = 0, второй проход пуст, а его диапазон — от0до-1. Для знаковых индексов это нормально, но для беззнаковых (usizeв Rust)k - 1вызывает переполнение вниз, поэтому в коде Rust используются полуоткрытые диапазоны. - Возвращать саму позицию в Lua и R. Их списки начинаются с 1, поэтому перед возвратом вычтите 1.
Частые вопросы4
Какова временная сложность поиска в повёрнутом отсортированном массиве?
Время O(log n) и дополнительная память O(1). На каждом шаге сохраняется половина текущего диапазона, как и при обычном бинарном поиске, поэтому для списка из 5000 элементов требуется не более 13 шагов. Двухэтапный вариант, который сначала находит точку поворота, также имеет сложность O(log n) и требует примерно вдвое больше шагов.
Как определить, какая половина повёрнутого массива отсортирована?
Сравни nums[lo] с nums[mid]. Значения уменьшаются только один раз во всём списке. Если nums[lo] ≤ nums[mid], этот перепад находится не между lo и mid, поэтому левая половина отсортирована. В противном случае перепад находится в левой половине, а значит, в правой половине, от mid до hi, его нет и она отсортирована.
Работает ли алгоритм, если массив содержит повторяющиеся элементы?
Не в таком виде. В [1, 0, 1, 1, 1] значения nums[lo], nums[mid] и nums[hi] равны 1, поэтому нельзя доказать, что какая-либо из половин отсортирована. Обычно в этом случае увеличивают lo на единицу, если nums[lo], nums[mid] и nums[hi] равны. Это сохраняет правильность ответа, но в худшем случае приводит к сложности O(n).
Сначала найти точку поворота или выполнить поиск за один проход?
Оба работают за O(log n). Сначала поиск индекса минимального элемента разбивает задачу на два обычных двоичных поиска, поэтому в каждой части повторно используется код, которому ты уже доверяешь. Поиск за один проход выполняет ту же задачу в одном цикле с меньшим количеством шагов, и именно эту версию ожидает большинство интервьюеров.
Похожие задачи
Задачи на те же идеи. Решите две или три, и приём запомнится.
Python
def search(nums, target):
# Напишите код здесьСлучай 1
Случай 2
Случай 3
Ввод
nums = [15, 19, 23, 2, 5, 8, 11] target = 5
Ожидается
4