Два указателя
Наименьший неупорядоченный подмассив
Найти наименьший подмассив, после сортировки которого весь массив станет отсортированным
Постановка задачи
Описание (LeetCode)
Дан целочисленный массив nums. Необходимо найти непрерывный подмассив,
после сортировки которого весь массив станет отсортированным в порядке
неубывания.
Найдите наименьший по длине такой подмассив и верните его длину.
Дополнительный вопрос:
Сможете ли вы решить эту задачу за время O(n)?
Пример 1:
nums = [2, 6, 4, 8, 10, 9, 15]
Результат:
5
Пояснение:
Чтобы весь массив стал отсортированным, нужно отсортировать подмассив [6, 4, 8, 10, 9].
Пример 2:
nums = [1, 2, 3, 4]
Результат:
0
Пример 3:
nums = [1]
Результат:
0
Ограничения:
1 <= nums.length <= 10⁴-10⁵ <= nums[i] <= 10⁵
Решение
На первый взгляд задача кажется не очень сложной - можно просто сравнить массив
с его отсортированной версией. Такое решение годится и пройдет тесты на
LeetCode, но интервьюер тут же задаст вам дополнительный вопрос: "Сможете ли вы
решить эту задачу за время O(n)?".
Прежде чем читать дальше, подумайте немного над возможным решением этой задачи, если у вас есть один или два прохода по исходному массиву.
Если решение еще не пришло, тогда давайте разбираться.
Идея решения:
Сначала подробнее рассмотрим решение через сортировку. Мы создаем копию массива, сортируем ее и сравниваем с исходным массивом, чтобы найти первые элементы слева и справа, которые стоят не на своих местах.
Вот как это может выглядеть в коде:
function findUnsortedSubarray(nums) {
let nums2 = [...nums].sort((a, b) => a - b);
let i = 0;
while (i < nums.length && nums2[i] === nums[i]) {
i++;
}
let j = nums.length - 1;
while (j >= 0 && nums2[j] === nums[j]) {
j--;
}
if (j < i) {
return 0;
}
return j - i + 1;
}Мы можем заметить, что у нас есть два указателя, один слева массива, другой справа, двигая их мы и находим границы подмассива.
Что если использовать два указателя сразу в исходном массиве, чтобы найти границы подмассива, нарушающего порядок?
Чтобы с этим разобраться, задайте себе следующие вопросы:
- Что можно сказать про ответ, если исходный массив уже отсортирован?
- А если он почти отсортирован, но есть один элемент, нарушающий порядок?
Это приводит нас к следующему наблюдению. В исходном массиве условно должны быть три отрезка, но некоторые из них могут быть пустыми:
- Отрезок в начале массива, где все числа стоят по неубыванию.
- Отрезок в середине массива, где порядок нарушен.
- Отрезок в конце массива, где все числа снова стоят в порядке неубывания.
Если это так, мы могли бы пропустить первый отрезок в начале массива, затем пропустить третий отрезок от конца массива и так найти отрезок в середине.
Тут есть сложность. Если мы будем сдвигать левый указатель, пока соблюдается порядок неубывания, то найдем первый элемент, нарушающий порядок в начале массива. Аналогично при сдвиге правого указателя от конца массива мы найдем первый элемент, нарушающий порядок справа.
Проблема в том, что мы не можем просто взять эти элементы как границы. Если вы
поняли почему - отлично. Если нет, вот пример: [1, 2, 5, 3, 4, 10, 11]. Здесь
видно, что отрезок в начале массива 1, 2, 5 упорядочен при движении слева, а
отрезок 3, 4, 10, 11 упорядочен при движении справа. То есть первый элемент
слева, нарушающий порядок - это 3, а элемент справа - 5. Но 5, 3 не будет
правильным ответом. Правильный ответ: 5, 3, 4. Дело в том, что максимальный
элемент из отрезка в начале массива 5 больше минимального элемента 3 из
отрезка в конце массива, а также больше следующего минимального элемента 4.
Так мы видим, что после нахождения отрезков 1 и 3 нужно сдвинуть их края. Край первого отрезка нужно сдвинуть влево, а край третьего отрезка - вправо. После этого оставшийся максимальный элемент в первом отрезке не должен быть больше оставшегося минимального элемента в третьем.
Это помогает нам лучше понять, что определяет границы подмассива, нарушающего порядок. Левая граница - это не просто место, где числа перестали расти. Это место, левее которого все элементы меньше минимума неупорядоченной части. Аналогично, правая граница - это место, правее которого все элементы больше максимума неупорядоченной части.
Отсюда рождается более изящная идея: вместо того чтобы сначала искать границы отрезков 1 и 3, а потом их сдвигать, мы можем сдвигать правую границу при проходе слева, а левую границу при проходе справа. Так мы найдем левую и правую границы подмассива.
Рассмотрим правую границу подмассива, нарушающего порядок. Как мы уже поняли выше, это то место, правее которого все элементы больше максимума неупорядоченной части. Мы можем найти такой элемент при движении слева от начала массива и отслеживании текущего максимума. Как только мы видим элемент, который меньше текущего максимума, правая граница точно не может быть левее этого элемента.
Если массив отсортирован, то каждый элемент при проходе слева направо должен
быть максимумом относительно всех предыдущих. Если же мы встречаем число,
которое меньше текущего максимума, значит, оно стоит не на месте и сдвигает
правую границу подмассива дальше. Например, в [1, 2, 5, 3, 4, 10, 11], когда
мы доходим до 3, а затем и 4, мы видим, что они меньше текущего максимума
5. Значит, правая граница должна быть как минимум на позиции 4.
Аналогично, при проходе справа налево каждый элемент должен быть минимумом. Если мы видим число, которое больше текущего минимума, оно нарушает порядок и определяет левую границу подмассива.
В итоге после прохода с двух сторон мы получим две точки: последний
неупорядоченный элемент справа, границу r, и последний неупорядоченный элемент
слева, границу l. Расстояние между ними и будет длиной наименьшего
неупорядоченного подмассива. При этом нам не нужно ничего сортировать или
хранить лишние копии данных.
Основные шаги:
Вот основные шаги оптимизированного алгоритма в один проход:
-
Инициализация.
- Устанавливаем
l = -1иr = -2. Если массив отсортирован, формула `r - l- 1
сразу вернет0` без дополнительных условий.
- 1
currentMaxравен первому элементу, а начальный минимумcurrentMinпоследнему.
- Устанавливаем
-
Цикл: мы проходим по массиву один раз, используя индекс
iдля движения слева направо и вычисляемый индексj = n - 1 - iдля движения справа налево.- Если элемент
nums[i]меньше максимума, значит, он нарушает порядок и должен быть включен в подмассив. Мы обновляемr = i. В противном случае обновляемcurrentMax. - Если элемент
nums[j]больше минимума, значит, он стоит не на месте относительно правой части. Мы обновляемl = j. В противном случае обновляемcurrentMin.
- Если элемент
-
Завершение цикла.
- После завершения цикла
lуказывает левую границу, аr- правую. Длина подмассива вычисляется простым вычитанием:r - l + 1.
- После завершения цикла
Вот как это работает на примере:
nums = `[1, 2, 5, 3, 4, 10, 11]`
- Инициализация:
l = -1,r = -2currentMax = 1,currentMin = 11
-
Цикл:
2.1
i = 0,j = 6- Слева:nums[0] = 1. Не меньшеcurrentMax(1). ОбновляемcurrentMax = 1. - Справа:nums[6] = 11. Не большеcurrentMin(11). ОбновляемcurrentMin = 11.
2.2 i = 1, j = 5
- Слева:
nums[1] = 2. БольшеcurrentMax(1). ОбновляемcurrentMax = 2. - Справа:
nums[5] = 10. МеньшеcurrentMin(11). ОбновляемcurrentMin = 10.
2.3 i = 2, j = 4
- Слева:
nums[2] = 5. БольшеcurrentMax(2). ОбновляемcurrentMax = 5. - Справа:
nums[4] = 4. МеньшеcurrentMin(10). ОбновляемcurrentMin = 4.
2.4 i = 3, j = 3
- Слева:
nums[3] = 3. МеньшеcurrentMax(5). Ставим правую границуr = 3. - Справа:
nums[3] = 3. МеньшеcurrentMin(4). ОбновляемcurrentMin = 3.
2.5 i = 4, j = 2
- Слева:
nums[4] = 4. МеньшеcurrentMax(5). Ставим правую границуr = 4. - Справа:
nums[2] = 5. БольшеcurrentMin(3). Ставим левую границуl = 2.
2.6 i = 5, j = 1
- Слева:
nums[5] = 10. БольшеcurrentMax(5). ОбновляемcurrentMax = 10. - Справа:
nums[1] = 2. МеньшеcurrentMin(3). ОбновляемcurrentMin = 2.
2.7 i = 6, j = 0
- Слева:
nums[6] = 11. БольшеcurrentMax(10). ОбновляемcurrentMax = 11. - Справа:
nums[0] = 1. МеньшеcurrentMin(2). ОбновляемcurrentMin = 1.
- Завершение цикла.
- Получили границы:
l = 2иr = 4 - Вычисляем длину:
4 - 2 + 1 = 3
- Получили границы:
Итог: 3 (подмассив [5, 3, 4]).
Оценка сложности
Временная сложность
Временная сложность равна O(n), так как мы делаем один проход по массиву.
Пространственная сложность
Мы не создаем никаких дополнительных структур данных, кроме переменных для
указателей, поэтому пространственная сложность равна O(1).
Код решения
Приведем код решения.
func findUnsortedSubarray(nums []int) int {
n := len(nums)
l := -1
r := -2
currentMax := nums[0]
currentMin := nums[n - 1]
for i := 0; i < n; i++ {
if nums[i] < currentMax {
r = i
} else {
currentMax = nums[i]
}
j := n - 1 - i
if nums[j] > currentMin {
l = j
} else {
currentMin = nums[j]
}
}
return r - l + 1
}Мы рекомендуем вам самостоятельно решить следующие две задачи на два указателя:
Подсказка: для решения первой задачи два указателя двигаются снаружи внутрь массива. Сдвигаем правый указатель, если сумма больше нужной, и левый, если сумма меньше нужной. Вторую задачу сводим к Two Sum: из нужной суммы вычитаем текущий элемент и получаем сумму двух, а ее мы уже умеем искать.
Итоги
Вот основные итоги по задаче, которые раскрывают ее внутреннюю логику и важные детали:
-
Поиск границ подмассива. Границы определяются глобальными экстремумами: правая граница сдвигается, когда число меньше накопленного максимума слева, а левая - когда число больше накопленного минимума справа.
-
Инициализация границ значениями
l = -1иr = -2. Она позволяет избежать дополнительных проверок в конце. Если массив изначально отсортирован, переменные не обновятся, и формулаr - l + 1автоматически выдаст0. -
Симметрия двух указателей. Использование двух встречных указателей внутри одного цикла позволяет одновременно искать обе границы. При проходе слева мы фиксируем правую границу, а при проходе справа - левую.
-
Граничный случай. Алгоритм устойчив к повторяющимся числам, например
[1, 2, 2, 2]. Благодаря строгим знакам сравнения (<и>), дубликаты не считаются нарушением порядка и не расширяют границы подмассива.