9. Squares of Sorted Array
«Вместо того чтобы двигаться от начала к концу, порой достаточно взглянуть на концы задачи и идти внутрь».
— Брайан Керниган
LeetCode 977. Squares of a Sorted Array
Сложность: Easy
Тема: Встречные указатели, обратное заполнение (Reverse Filling), сортировка за $O(N)$
Ссылки: Теория: Два указателя, Варианты применения, Merge Sorted Array
1. Введение и физическая интуиция
Дан отсортированный по неубыванию массив чисел: например, [-4, -1, 0, 3, 10].
Если мы просто возведем каждое число в квадрат, то получим: [16, 1, 0, 9, 100].
Сортировка нарушилась: отрицательные числа с большими по модулю значениями дали огромные квадраты в начале среза!
Математически функция $f(x) = x^2$ представляет собой параболу с вершиной в точке $0$. Значения убывают при приближении к нулю слева и возрастают при удалении от нуля вправо.
Это значит, что самые большие квадраты гарантированно находятся на краях массива — либо у крайнего левого отрицательного числа, либо у крайнего правого положительного.
Отсюда рождается элегантнейший алгоритм:
Мы ставим указатели на оба конца массива (left = 0, right = n - 1) и начинаем заполнять результирующий срез с самого конца (pos = n - 1):
- Сравниваем квадраты чисел на концах: $nums[left]^2$ против $nums[right]^2$.
- Тот, чей квадрат больше, забирает почетное место в конце среза.
- Соответствующий указатель сдвигается внутрь массива.
Никаких логарифмических сортировок за $O(N \log N)$! Мы получаем идеальный отсортированный результат за строго один линейный проход $O(N)$.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
A["nums = [-4, -1, 0, 3, 10]"]:::entry --> B["res = make([]int, n)<br>left=0, right=4, pos=4"]:::process
B --> C{"pos >= 0?"}:::warning
C -->|"Да"| D{"nums[left]^2 > nums[right]^2?"}:::warning
D -->|"Да"| E["res[pos] = nums[left]^2<br>left++"]:::process
D -->|"Нет"| F["res[pos] = nums[right]^2<br>right--"]:::process
E --> G["pos--"]:::process
F --> G
G --> C
C -->|"Нет"| H["Возвращаем res"]:::success
2. Формулировка задачи и вопросы интервьюеру
Дан целочисленный массив nums, отсортированный по неубыванию. Вернуть массив квадратов каждого числа, также отсортированный по неубыванию. Решение должно работать за $O(N)$ по времени.
Вопросы интервьюеру (Senior-стиль):
- Каков размер входных данных?
«$1 \le len(nums) \le 10^4$, значения в диапазоне $[-10^4, 10^4]$. Квадрат максимального числа равен $10^8$, что с огромным запасом помещается в стандартный целочисленный тип.» - Можно ли использовать вспомогательный массив?
«Да, возвращаемое значение[]intтребует новой памяти $O(N)$. Попытка перезаписать входной срез in-place разрушит еще не обработанные значения.» - Может ли массив содержать только положительные или только отрицательные числа?
«Да, алгоритм двух указателей обязан корректно отрабатывать любые граничные комбинации знаков.»
3. Разбор подходов
Подход 1: Возведение в квадрат + сортировка (Наивный)
func sortedSquaresNaive(nums []int) []int {
res := make([]int, len(nums))
for i, v := range nums {
res[i] = v * v
}
slices.Sort(res)
return res
}
- Сложность: $O(N \log N)$ по времени, $O(N)$ по памяти.
- Вердикт: Решение работает, но полностью игнорирует главное свойство — отсортированность входа. На собеседовании такое решение показывает непонимание задачи.
Подход 2: Встречные указатели с обратным заполнением (Эталонный)
Заполняем результирующий буфер с конца, выбирая максимальный квадрат за один шаг. Сложность строго $O(N)$ по времени и $O(1)$ вспомогательной памяти (за исключением возвращаемого результата).
4. Эталонная реализация на Go
package main
// SortedSquares возвращает срез квадратов элементов, отсортированный по неубыванию.
// Временная сложность: O(N) — каждый элемент просматривается ровно один раз.
// Пространственная сложность: O(N) под результирующий срез (0 лишних аллокаций).
func SortedSquares(nums []int) []int {
n := len(nums)
result := make([]int, n)
left := 0
right := n - 1
// Заполняем массив с конца: от самого большого квадрата к наименьшему
for pos := n - 1; pos >= 0; pos-- {
leftSquare := nums[left] * nums[left]
rightSquare := nums[right] * nums[right]
if leftSquare > rightSquare {
result[pos] = leftSquare
left++
} else {
result[pos] = rightSquare
right--
}
}
return result
}
5. Пошаговая трассировка (Walkthrough)
Вход: nums = [-4, -1, 0, 3, 10] ($n = 5$).
| Шаг | left (nums[l]) |
right (nums[r]) |
lSquare |
rSquare |
Победитель | pos |
Состояние result |
|---|---|---|---|---|---|---|---|
| 1 | 0 (-4) |
4 (10) |
16 | 100 | right (100) |
4 | [0, 0, 0, 0, 100] |
| 2 | 0 (-4) |
3 (3) |
16 | 9 | left (16) |
3 | [0, 0, 0, 16, 100] |
| 3 | 1 (-1) |
3 (3) |
1 | 9 | right (9) |
2 | [0, 0, 9, 16, 100] |
| 4 | 1 (-1) |
2 (0) |
1 | 0 | left (1) |
1 | [0, 1, 9, 16, 100] |
| 5 | 2 (0) |
2 (0) |
0 | 0 | right (0) |
0 | [0, 1, 9, 16, 100] |
Ровно 5 итераций. Массив безупречно отсортирован!
6. Mechanical Sympathy: предвыделение памяти и кэш
- Единственная аллокация
make([]int, n):
Мы создаем срез точного размераn. В коде нет ни единого вызоваappend(), а значит:- Нет проверок вместимости (
cap). - Нет повторных реаллокаций и копирований буфера в куче.
- Исключена фрагментация памяти кучи.
- Нет проверок вместимости (
- Аппаратный Prefetcher при обратной записи:
Современные процессоры одинаково эффективно предугадывают как прямой последовательный доступ (pos++), так и обратный (pos--). Запись в непрерывный буферresult[pos]наполняет кэш-линию L1d последовательно справа налево, минимизируя задержки шины памяти.
7. Вопросы для собеседования
8. Инварианты и чек-лист самопроверки
- [x] Размер среза:
make([]int, n)строго равен длине входа. - [x] Условие цикла:
pos >= 0выполняется ровно $N$ раз. - [x] Случай одного элемента: при $n = 1$ цикл срабатывает на
pos = 0, корректно записывая квадрат единственного числа.
Переходим к финальной задаче кластера «Два указателя» — избирательному реверсу гласных символов в строке: 10. Reverse Vowels of String.