Массив — самая простая структура данных: пронумерованные ячейки, лежащие в памяти подряд. С него удобно начинать, потому что на нём видны все главные компромиссы сразу: что-то массив делает молниеносно, а что-то — медленно. А заодно на массиве проще всего понять, как вообще измеряют скорость алгоритмов — через O-нотацию. Если ещё не читали вводную статью раздела, загляните туда за общими терминами.
Что массив умеет и что нет
У массива есть одно суперумение: доступ по индексу за один шаг. Если известно, что нужный элемент лежит в ячейке 5, программа берёт его мгновенно, не пробегая остальные, — потому что адрес ячейки вычисляется по индексу напрямую.
Есть и вторая причина скорости, которую O-нотация не видит. Ячейки массива лежат в памяти подряд, и процессор, прочитав одну, заодно подтягивает в свой кеш соседние: обход массива почти не ходит в медленную оперативную память. Структуры из отдельных объектов, связанный список и дерево, разбросаны по памяти, и каждый шаг по ссылке это ожидание. На одинаковом O(N) обход массива быстрее обхода списка в разы, и этот довод ещё вернётся, когда связанный список проиграет динамическому массиву там, где по таблице должен выигрывать.
Массив бывает и двумерным: grid из трёх массивов по четыре числа, ячейка grid[i][j], строки могут быть разной длины. Так хранят таблицу продаж по дням и складам, матрицу смежности графа и двумерные префиксные суммы дальше в разделе; в памяти это уже не сплошной блок, а массив ссылок на строки, и обход по строкам быстрее обхода по столбцам.
А вот с остальными операциями сложнее:
- Вставка в конец — быстрая: положили значение в первую свободную ячейку.
- Поиск по значению — медленный: если мы не знаем индекс, приходится проверять ячейки одну за другой.
- Удаление — медленное: после того как элемент убрали, остаётся «дыра», и следующие элементы приходится сдвигать, чтобы её закрыть.
Ещё у обычного массива фиксированный размер: сколько ячеек выделили, столько и есть.
Вставка в конец это две операции, а вставка в середину добавляет сдвиг хвоста, и именно он делает её O(N).
Упорядоченный массив
Массив можно держать отсортированным: элемент с наименьшим значением в ячейке 0, дальше по возрастанию. За это приходится платить при вставке — новый элемент нельзя просто бросить в конец, надо найти ему место и сдвинуть всех, кто больше. Зато взамен — радикально более быстрый поиск.
Линейный поиск
Простейший способ найти элемент — линейный поиск: идём с начала и сравниваем каждую ячейку с искомым значением, пока не найдём или не дойдём до конца. В упорядоченном массиве есть маленький бонус: как только встретили значение больше искомого, дальше искать бессмысленно — можно остановиться. Но в среднем всё равно приходится проверить половину массива. На миллионе элементов это до полумиллиона сравнений — медленно.
Двоичный поиск
В упорядоченном массиве работает куда более быстрый двоичный поиск. Его логика — как в детской игре «угадай число»: один загадывает число от 1 до 100, второй угадывает, а первый отвечает «больше» или «меньше».
Как угадать за минимум попыток? Называть середину оставшегося диапазона. Первый вопрос — 50. «Меньше» → число в диапазоне 1–49, следующий вопрос — 25. «Больше» → диапазон 26–49, следующий — 37. Каждая попытка делит диапазон пополам, поэтому число от 1 до 100 угадывается максимум за 7 попыток, а не за 100.
Двоичный поиск делает ровно это с массивом: держим границы диапазона, где может лежать элемент, смотрим на средний элемент и по сравнению с искомым отбрасываем половину. Счётчик steps считает сравнения — так видно, во что обошёлся поиск:
живой пример
public class BinarySearchDemo {
static int steps = 0;
static int binarySearch(int[] a, int key) {
int lower = 0;
int upper = a.length - 1;
while (lower <= upper) {
steps++;
int mid = (lower + upper) >>> 1;
if (a[mid] == key) return mid;
if (a[mid] < key) lower = mid + 1;
else upper = mid - 1;
}
return -1;
}
public static void main(String[] args) {
int[] a = {3, 7, 11, 18, 23, 29, 34, 41, 52, 63, 70};
int found = binarySearch(a, 63);
System.out.println("63 лежит в ячейке " + found);
System.out.println("сравнений: " + steps + ", ячеек в массиве: " + a.length);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
var steps = 0
func binarySearch(a []int, key int) int {
lower, upper := 0, len(a)-1
for lower <= upper {
steps++
mid := int(uint(lower+upper) >> 1)
switch {
case a[mid] == key:
return mid
case a[mid] < key:
lower = mid + 1
default:
upper = mid - 1
}
}
return -1
}
func main() {
a := []int{3, 7, 11, 18, 23, 29, 34, 41, 52, 63, 70}
found := binarySearch(a, 63)
fmt.Println("63 лежит в ячейке", found)
fmt.Printf("сравнений: %d, ячеек в массиве: %d\n", steps, len(a))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
let steps = 0;
function binarySearch(a, key) {
let lower = 0;
let upper = a.length - 1;
while (lower <= upper) {
steps++;
const mid = (lower + upper) >>> 1;
if (a[mid] === key) return mid;
if (a[mid] < key) lower = mid + 1;
else upper = mid - 1;
}
return -1;
}
const a = [3, 7, 11, 18, 23, 29, 34, 41, 52, 63, 70];
const found = binarySearch(a, 63);
console.log(`63 лежит в ячейке ${found}`);
console.log(`сравнений: ${steps}, ячеек в массиве: ${a.length}`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
steps = 0
def binary_search(a: list[int], key: int) -> int:
global steps
lower, upper = 0, len(a) - 1
while lower <= upper:
steps += 1
mid = (lower + upper) // 2
if a[mid] == key:
return mid
if a[mid] < key:
lower = mid + 1
else:
upper = mid - 1
return -1
a = [3, 7, 11, 18, 23, 29, 34, 41, 52, 63, 70]
found = binary_search(a, 63)
print(f"63 лежит в ячейке {found}")
print(f"сравнений: {steps}, ячеек в массиве: {len(a)}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Одна деталь в этом коде стоит объяснения: почему середина считается не привычным делением суммы на два. Пока массив небольшой, разницы нет. Но если он огромный и оба индекса подбираются к двум миллиардам, их сумма перестаёт помещаться в 32-битное целое и становится отрицательной — а дальше программа лезет по отрицательному индексу и падает. В Java и JavaScript спасает оператор >>>: он сдвигает биты вправо, то есть делит на два, но без знака, и переполнение ему не мешает. В Go сумму на всякий случай считают в беззнаковом uint, а в Python целые не переполняются вовсе, и там пишут обычное // 2. Эта ошибка много лет жила в стандартной библиотеке Java, пока её не нашли, — так что попасться на ней не стыдно.
Каждый проход цикла отсекает половину оставшихся элементов. Поэтому поиск в 100 элементах — около 7 сравнений, в 1000 — около 10, в миллионе — около 20. Сравните с линейным поиском: 500 000 против 20 на миллионе. Это огромная разница, и она — весь смысл держать массив упорядоченным.
Смотрим середину активного диапазона (5 → 8 → 9). Искомое больше — отбрасываем левую половину. Три шага вместо перебора одиннадцати — ровно то, что печатает пример выше.
Компромисс упорядоченного массива
Итак, за быстрый поиск упорядоченный массив расплачивается медленной вставкой (надо сдвигать элементы, освобождая место) и по-прежнему медленным удалением. Вывод практический:
- упорядоченный массив хорош, когда поиск выполняется часто, а вставки и удаления редки. Пример — справочник сотрудников: читают и ищут постоянно, а нанимают и увольняют редко;
- упорядоченный массив плох, когда вставки и удаления идут потоком. Пример — складской учёт, где товары приходят и уходят каждую минуту.
Это первый пример главного принципа раздела: структуру выбирают под то, какие операции у вас частые.
Логарифмы и O-нотация
Мы сказали «около 20 сравнений на миллионе». Двоичный поиск делит диапазон пополам, пока не останется один элемент, а число делений пополам, за которое из N получается 1, это логарифм N по основанию 2: log₂(1 000 000) ≈ 20. Что такое логарифм и O-нотация, разобрано в математике для O-нотации; здесь важны три случая, которые уже встретились. Доступ по индексу и вставка в конец от N не зависят, это O(1). Линейный поиск растёт вместе с N, это O(N). Двоичный поиск растёт как логарифм, то есть очень медленно, это O(log N).
Почему не только массивы
Если упорядоченный массив так быстро ищет, зачем вообще остальные структуры? Из-за той самой платы: вставка и удаление у него O(N), а размер фиксирован. Как только в задаче много вставок и удалений, массив становится узким местом. Дальше в разделе мы увидим структуры, которые ищут почти так же быстро, но при этом быстро вставляют и удаляют — связанные списки, деревья, хеш-таблицы. За удобство они расплачиваются сложностью устройства.
Как это сделано в стандартной библиотеке
Свой массив с ручным ростом писать почти никогда не нужно — всё это давно обкатано в стандартной библиотеке. Но выбирать структуру всё равно приходится, а для этого надо знать, что у неё внутри.
Эталонная реализация массива в Java — ArrayList. Слово «list» в названии сбивает с толку: никаких узлов и ссылок внутри нет, это самый обычный массив объектов, который класс держит в поле и подменяет на больший, когда места перестаёт хватать.
Рост устроен так: когда массив заполнен, ArrayList заводит новый — примерно в полтора раза длиннее — и переписывает туда старые элементы. Такое копирование стоит O(N), но случается тем реже, чем список длиннее. Если размазать эту цену по всем добавлениям, каждое add в конец обходится в O(1) амортизированно: почти всегда один шаг, изредка — дорогой переезд.
Выделенные строки это редкий переезд в массив в полтора раза длиннее, между ними каждое add стоит один шаг, и его цена размазывается по дешёвым вставкам.
Переездов можно избежать, если размер известен заранее: new ArrayList<>(100_000) выделяет массив нужной длины сразу, а ensureCapacity(n) делает то же для уже существующего списка перед массовым добавлением. На сотне тысяч элементов это экономит полтора десятка копирований; в хеш-таблице тот же приём, задать ёмкость заранее, разобран в своей статье и там он важнее, потому что перестройка дороже.
Вторая цена ArrayList, о которой молчит слово «массив», это упаковка. Хранить в нём можно только объекты, поэтому ArrayList<Integer> держит не числа, а ссылки на объекты Integer: четыре байта ссылки плюс шестнадцать байт объекта против четырёх байт в int[], в пять раз больше памяти, и обход прыгает по объектам вместо чтения подряд. Для чисел в горячем коде берут int[] или long[], а ArrayList<Integer> там, где важнее удобство.
Остальная сложность — ровно как у массива из статьи:
get(i)иset(i, v)—O(1), адрес ячейки считается по индексу;add(v)в конец —O(1)амортизированно;add(i, v)иremove(i)в середину —O(N): соседей приходится сдвигать. Делает этоSystem.arraycopy— быстрая операция копирования памяти, но работы всё равно линейно;containsиindexOf—O(N), это тот самый линейный поиск.
Двоичный поиск руками тоже писать не надо: для отсортированного массива есть Arrays.binarySearch, для отсортированного списка — Collections.binarySearch. Внутри у них тот же цикл с делением диапазона пополам, включая безопасный сдвиг >>> вместо деления на два.
Грабля здесь одна, зато злая: binarySearch не проверяет, отсортированы ли данные. На неупорядоченном массиве он не упадёт и никак не пожалуется — просто вернёт неверный ответ. И возвращаемое значение надо читать внимательно: когда элемент не найден, метод отдаёт не -1, а отрицательное число, в котором закодирована позиция для вставки — -(позиция) - 1. Все три случая сразу:
Вторая грабля живёт в самом ArrayList<Integer>, и попадаются на неё все. У списка два метода remove: remove(int index) удаляет по номеру, remove(Object o) по значению. Для List<Integer> вызов list.remove(1) выбирает первый, потому что литерал 1 это int, и удаляет элемент с индексом 1, а не число 1. Чтобы удалить значение, его упаковывают явно: list.remove(Integer.valueOf(1)). Ошибка тихая: список стал короче, исключения нет, просто пропало не то.
живой пример
import java.util.Arrays;
public class JdkBinarySearch {
public static void main(String[] args) {
int[] sorted = {3, 7, 11, 18, 23};
System.out.println("11 -> " + Arrays.binarySearch(sorted, 11));
System.out.println("12 -> " + Arrays.binarySearch(sorted, 12));
int[] unsorted = {23, 3, 18, 7, 11};
System.out.println("11 в неотсортированном -> " + Arrays.binarySearch(unsorted, 11));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка — честная находка в ячейке 2. Вторая: числа 12 нет, а -4 читается как «вставлять надо в позицию 3». Третья — ловушка: 11 в массиве есть, но массив не отсортирован, и метод молча отвечает «не нашёл». Отвечает по тому же правилу: -1 здесь — это -(0) - 1, то есть «вставлять надо в позицию 0». Просто на этот раз совет бесполезен, потому что данные не отсортированы.
Свой массив с ручным ростом в Go писать не нужно: он встроен в язык и называется срезом. Слово «срез» намекает на устройство: []int это не сами данные, а маленькая структура из трёх полей: указатель на настоящий массив, длина len и ёмкость cap, то есть сколько ячеек выделено за спиной.
Рост устроен так: append пишет в свободную ячейку, пока len меньше cap. Когда ячейки кончились, среда выполнения выделяет новый массив, до 256 элементов вдвое больше, дальше примерно в 1,25 раза, и копирует туда старые элементы. Копирование стоит O(N), но случается тем реже, чем срез длиннее, поэтому append в конец обходится в O(1) амортизированно.
Из этого устройства растёт главная грабля срезов: после переезда старый и новый срез смотрят на разные массивы. Функция, которая делает s = append(s, x) со своей копией среза, может изменить массив вызывающего, а может и не изменить, смотря хватило ли ёмкости. Поэтому результат append всегда присваивают обратно, а срез, который отдают наружу, при необходимости копируют через slices.Clone.
Переездов можно избежать, если размер известен заранее: make([]int, 0, 100_000) выделяет массив нужной ёмкости сразу. На сотне тысяч элементов это экономит два десятка копирований и заметно снижает нагрузку на сборщик мусора.
Второй платы, упаковки, у срезов чисел нет: []int хранит сами числа подряд, по восемь байт на элемент, и обход читает память последовательно. Расплата появляется у []any и у среза указателей: там лежат ссылки, а значения разбросаны по куче. Для чисел в горячем коде берут срез конкретного типа, а []any только там, где без него никак.
Остальная сложность — ровно как у массива из статьи:
s[i]чтение и запись —O(1), адрес ячейки считается по индексу;append(s, v)в конец —O(1)амортизированно;slices.Insert(s, i, v)иslices.Delete(s, i, i+1)в середину —O(N): соседей сдвигаетcopy, быстрая операция копирования памяти, но работы всё равно линейно;slices.Containsиslices.Index—O(N), это тот самый линейный поиск.
Двоичный поиск руками тоже писать не надо: в пакете slices есть BinarySearch, а в sort старый SearchInts и универсальный sort.Search по любому условию. Внутри у них тот же цикл с делением диапазона пополам, только середина считается через беззнаковое uint(i+j) >> 1.
Грабля здесь одна, зато злая: BinarySearch не проверяет, отсортированы ли данные. На неупорядоченном срезе он не упадёт и никак не пожалуется, просто вернёт неверный ответ. И возвращаемое значение читают внимательно: функция отдаёт два значения, позицию и признак found. Когда элемента нет, позиция это место, куда его надо вставить, чтобы порядок сохранился, а не -1. У sort.SearchInts признака нет вовсе: он всегда возвращает точку вставки, и «нашёл или нет» проверяют сами по i < len(s) && s[i] == x. Все три случая сразу:
живой пример
package main
import (
"fmt"
"slices"
)
func main() {
sorted := []int{3, 7, 11, 18, 23}
i, found := slices.BinarySearch(sorted, 11)
fmt.Println("11 ->", i, found)
i, found = slices.BinarySearch(sorted, 12)
fmt.Println("12 ->", i, found)
unsorted := []int{23, 3, 18, 7, 11}
i, found = slices.BinarySearch(unsorted, 11)
fmt.Println("11 в неотсортированном ->", i, found)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка — честная находка: позиция 2 и found равен true. Вторая: числа 12 нет, found равен false, а позиция 3 это место для вставки. Третья — ловушка: 11 в срезе есть, но срез не отсортирован, и функция молча отвечает «нет, вставлять в позицию 2». Ошибки никакой, просто ответ неверный, потому что данные не отсортированы.
Свой массив с ручным ростом в JavaScript писать не нужно: Array уже динамический. Под капотом движок V8 держит для массива непрерывное хранилище с запасом и следит, что именно в нём лежит: массив из одних небольших целых хранится плотно, как настоящий массив чисел; стоит положить в него дробь, строку или объект, и представление меняется на более общее и медленное. Дырки (delete a[3], запись далеко за конец) переводят массив в «разреженный» режим, где доступ по индексу уже не один шаг.
Рост устроен так: push пишет в свободную ячейку, а когда запас кончился, V8 выделяет хранилище примерно в полтора раза больше и переписывает элементы. Копирование стоит O(N), но случается тем реже, чем массив длиннее, поэтому push в конец обходится в O(1) амортизированно.
Задать размер заранее можно через new Array(n), но это создаёт как раз дырявый массив, и выигрыш легко потерять. Для больших массивов чисел берут типизированные массивы: new Int32Array(100_000) выделяет ровно 400 килобайт подряд, без упаковки и без переездов, только размер у него фиксированный.
Вторая цена обычного Array, о которой молчит название, это упаковка. В общем режиме массив хранит не числа, а ссылки на объекты в куче, и обход прыгает по памяти вместо чтения подряд. Пока в массиве только целые, V8 обходится без этого; типизированный массив гарантирует плотность всегда.
Остальная сложность — ровно как у массива из статьи:
a[i]чтение и запись —O(1)в плотном массиве;push(v)в конец —O(1)амортизированно;splice(i, 0, v)иsplice(i, 1)в середину —O(N): соседей приходится сдвигать;shiftиunshiftс начала стоят столько же;includesиindexOf—O(N), это тот самый линейный поиск.
Двоичного поиска в стандартной библиотеке JavaScript нет: indexOf идёт перебором и не знает, что массив отсортирован. Функцию пишут руками, десять строк, или берут пакет вроде binary-search.
Грабля у самодельного поиска та же, что у библиотечного в других языках: он не проверяет, отсортированы ли данные. На неупорядоченном массиве он не упадёт и никак не пожалуется, просто вернёт неверный ответ. Все случаи сразу:
живой пример
function binarySearch(a, key) {
let lower = 0;
let upper = a.length - 1;
while (lower <= upper) {
const mid = (lower + upper) >>> 1;
if (a[mid] === key) return mid;
if (a[mid] < key) lower = mid + 1;
else upper = mid - 1;
}
return -1;
}
const sorted = [3, 7, 11, 18, 23];
console.log("11 ->", binarySearch(sorted, 11));
console.log("12 ->", binarySearch(sorted, 12));
console.log("11 через indexOf ->", sorted.indexOf(11), "(перебор, а не деление пополам)");
const unsorted = [23, 3, 18, 7, 11];
console.log("11 в неотсортированном ->", binarySearch(unsorted, 11));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка — честная находка в ячейке 2. Вторая: числа 12 нет, ответ -1. Третья показывает, что indexOf находит то же самое, только за N шагов вместо трёх. Четвёртая — ловушка: 11 в массиве есть, но массив не отсортирован, и функция молча отвечает «не нашёл».
Свой массив с ручным ростом в Python писать не нужно: он и есть list. Слово «список» сбивает с толку: никаких узлов и ссылок на следующий элемент внутри нет, это обычный массив указателей на объекты, который интерпретатор держит в памяти подряд и подменяет на больший, когда места перестаёт хватать.
Рост устроен так: когда массив заполнен, list выделяет новый с запасом примерно в одну восьмую сверх нужного и переписывает туда старые указатели. Запас скромный, поэтому переезды случаются чаще, чем при росте вдвое, но каждый дешевле. Копирование стоит O(N), но случается тем реже, чем список длиннее, и append в конец обходится в O(1) амортизированно.
Переездов можно избежать, если размер известен заранее: [0] * 100_000 выделяет список нужной длины сразу, а [None] * n заводит пустые ячейки под заполнение по индексу. Метода «зарезервировать ёмкость» у списка нет.
Вторая цена list, о которой молчит слово «массив», это упаковка, и в Python она обязательна: список хранит не числа, а ссылки на объекты int, по 28 байт на каждое небольшое число плюс 8 байт указателя. Для числовых данных в горячем коде берут array('i') из стандартной библиотеки или массивы NumPy: там числа лежат подряд по четыре или восемь байт, и обход в разы быстрее.
Остальная сложность — ровно как у массива из статьи:
a[i]чтение и запись —O(1), адрес ячейки считается по индексу;append(v)в конец —O(1)амортизированно;insert(i, v),pop(i)иdel a[i]в середину —O(N): соседей приходится сдвигать;pop(0)с начала стоит столько же, для очереди берутdeque;inиindex—O(N), это тот самый линейный поиск.
Двоичный поиск руками тоже писать не надо: модуль bisect даёт bisect_left и bisect_right для отсортированного списка. Внутри тот же цикл с делением диапазона пополам, а переполнения индексов в Python не бывает: целые растут, сколько нужно.
Грабля здесь одна, зато злая: bisect не проверяет, отсортированы ли данные. На неупорядоченном списке он не упадёт и никак не пожалуется, просто вернёт неверный ответ. И возвращаемое значение надо читать внимательно: bisect_left возвращает не «нашёл или нет», а позицию, куда элемент надо вставить, чтобы порядок сохранился. Нашёлся ли он, проверяют сами: i < len(a) and a[i] == x. Все три случая сразу:
живой пример
from bisect import bisect_left
def index_of(a: list[int], key: int) -> int:
i = bisect_left(a, key)
return i if i < len(a) and a[i] == key else -1
sorted_values = [3, 7, 11, 18, 23]
print("11 ->", index_of(sorted_values, 11))
print("12 ->", index_of(sorted_values, 12), "(bisect_left предлагает позицию", bisect_left(sorted_values, 12), ")")
unsorted = [23, 3, 18, 7, 11]
print("11 в неотсортированном ->", index_of(unsorted, 11))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка — честная находка в ячейке 2. Вторая: числа 12 нет, index_of отвечает -1, а сам bisect_left подсказывает, что вставлять надо в позицию 3. Третья — ловушка: 11 в списке есть, но список не отсортирован, и поиск молча отвечает «не нашёл».
Вторая грабля живёт в удалении, и на неё попадаются пришедшие из Java: a.remove(1) удаляет значение 1, первое встреченное, а не ячейку с индексом 1; по индексу удаляют через del a[1] или a.pop(1). Ошибка тихая: список стал короче, исключения нет, просто пропало не то.
Коротко
- Массив мгновенно берёт элемент по индексу (
O(1)), но медленно ищет по значению и медленно удаляет (O(N)). - Упорядоченный массив позволяет двоичный поиск — деление диапазона пополам,
O(log N): на миллионе элементов около 20 шагов вместо полумиллиона. - Платит за это медленной вставкой: элементы приходится сдвигать. Хорош, когда поиск частый, а вставки и удаления редки.
- O-нотация описывает, как растёт число операций с объёмом данных:
O(1)— постоянно,O(log N)— очень медленно,O(N)— пропорционально. Это общая мерка скорости для всего раздела. - Массив быстр не только по O: ячейки лежат подряд и попадают в кеш процессора пачками, поэтому обход массива обгоняет обход списка в разы; двумерный массив это массив строк.
- В Java массив из статьи — это
ArrayList:get/setзаO(1),addв конец заO(1)амортизированно (рост примерно в полтора раза), вставка и удаление в середину заO(N). Arrays.binarySearchне проверяет сортировку и на неупорядоченных данных молча врёт; «не нашёл» он возвращает как-(позиция вставки) - 1, а не как-1.- Размер известен заранее — задайте его в конструкторе или через
ensureCapacity;ArrayList<Integer>хранит ссылки на объекты и стоит впятеро большеint[]. - У
List<Integer>remove(1)удаляет по индексу, а не значение 1: для значения пишутremove(Integer.valueOf(1)).
- В Go массив из статьи — это срез:
s[i]заO(1),appendв конец заO(1)амортизированно (рост вдвое, после 256 элементов примерно в 1,25 раза),slices.Insertиslices.Deleteв середину заO(N). slices.BinarySearchне проверяет сортировку и на неупорядоченных данных молча врёт; возвращает позицию и признакfound, «не нашёл» это позиция для вставки, а не-1.- Размер известен заранее — задайте ёмкость в
make([]T, 0, n); результатappendвсегда присваивают обратно, иначе после переезда данные разъедутся.
- В JavaScript массив из статьи — это
Array:a[i]заO(1),pushзаO(1)амортизированно (рост примерно в полтора раза),spliceв середину иshiftс начала заO(N). - Двоичного поиска в стандартной библиотеке нет,
indexOfидёт перебором; самодельный поиск сортировку не проверяет и на неупорядоченных данных молча врёт. - Плотный массив чисел быстрее общего: дырки и смешанные типы переводят его в медленный режим; для больших числовых данных берут
Int32ArrayилиFloat64Array.
- В Python массив из статьи — это
list:a[i]заO(1),appendзаO(1)амортизированно (запас около одной восьмой),insertиpop(i)в середину заO(N),pop(0)тожеO(N). bisectне проверяет сортировку и на неупорядоченных данных молча врёт; возвращает позицию для вставки, а не «нашёл или нет», проверкуa[i] == xпишут сами.listхранит ссылки на объектыintпо 28 байт; для числовых данных берутarrayили NumPy.a.remove(1)удаляет значение 1, а не ячейку 1; по индексу —del a[1].
Что почитать дальше
- Математика для O-нотации — откуда берутся логарифмы и почему
log₂ N— это число делений пополам. - Простая сортировка — три способа привести массив в порядок, без которого двоичный поиск не работает.
- Связанные списки — структура с обратным компромиссом: вставка за
O(1), зато поиск только перебором. - Двоичный поиск по ответу — тот же приём, но по диапазону значений, а не по массиву.