Объясню структуры данных через очередь в поликлинике, а потом покажу, где эта аналогия ломается: почему связный список с «вставкой за O(1)» в прикладном Go обычно проигрывает обычному массиву.
Спойлер: асимптотика здесь не ошибается. Ошибается вывод, который мы из неё делаем.
Статья для тех, кто асимптотику знает, но не проверял её замером.
Очередь
Сидишь в очереди к врачу. Номерка нет, ты знаешь одно: за кем занимать.
Это связный список. У элемента ссылка на следующего, и больше ничего:
type patient struct { name string next *patient // «а я за вами» }
Найти в такой очереди конкретного человека можно только пройдя её от соседа к соседу: двадцать человек — двадцать вопросов «вы последний?». Это O(n). Запомнил ещё и того, кто занял после тебя, — получился двусвязный список: ходить можно в обе стороны, и человека можно выдернуть, не обходя очередь заново, — но только если ты уже стоишь рядом с ним. Найти его всё равно придётся обходом.
Стулья
В коридоре стоят стулья, и они пронумерованы. «Третий стул» — идёшь и садишься, никого не спрашивая.
Это массив — в аналогии. В Go на практике здесь обычно будет слайс, элементы которого лежат в непрерывном backing array, и всё сказанное дальше про локальность относится именно к нему. Адрес элемента считается арифметикой: начало плюс номер, умноженный на размер. Один переход, что для третьего стула, что для три тысячи двести седьмого.
seats := make([]string, 40) seats[3] = "Иванов" who := seats[3] // сразу, без обхода
Цена — в том, что стулья прикручены к полу: посадить кого-то в середину ряда можно только сдвинув всех, кто правее.
Бабуля
А потом заходит бабуля.
Она помнит всех. Кто в синей куртке, кто с папкой, кто отошёл покурить, кто «я только спросить». Спрашиваешь «а Петрова кто?» — отвечает сразу, не пересчитывая очередь.
Бабуля — это hash map. Ключ (примета) превращается в число, число указывает, где искать, дальше остаётся проверить пару кандидатов.
byName := make(map[string]*patient, len(all)) for _, p := range all { byName[p.name] = p } p := byName["Петров"] // сразу, без прохода по очереди
В поликлинике |
В коде |
|---|---|
пронумерованные стулья |
массив, доступ по индексу |
«я за вами» |
связный список |
помнишь и переднего, и заднего |
двусвязный список |
приметы человека |
ключ |
полка, куда бабуля кладёт по примете |
группа слотов |
двое с одинаковыми приметами |
коллизия |
людей стало больше, чем полок |
рост карты |

Пока ты бежишь по очереди от соседа к соседу за O(n), бабуля уже всё знает.
Где Big O перестаёт помогать
В учебнике написано: вставка в связный список — O(1), в массив — O(n). Вывод как будто очевиден: вставляем часто — берём список.
Я такой вывод делал. Для прикладного Go он часто оказывается неверным.
Асимптотика отвечает на вопрос, как растёт время с размером данных. Она не говорит, сколько стоит одна операция. А разница в цене между «сдвинуть непрерывный кусок памяти» и «перейти по указателю в непредсказуемое место» — та часть, которой в формуле нет вообще.
Соседи
Процессор не читает память по одному байту. Он тянет её блоками — кеш-линиями. Размер линии зависит от микроархитектуры, а не от системы команд. На машине, где сделаны замеры (Apple M3 Pro), sysctl hw.cachelinesize отвечает 128 байт; на большинстве x86-64 будет 64. Стенд целиком — в конце статьи.
Для массива это подарок. Элементы лежат вплотную, поэтому одна загруженная линия содержит сразу шестнадцать int64 — и последовательный обход ими воспользуется, не запрашивая память заново на каждом шаге.
Для связного списка — наоборот. Узел такой формы на 64-битной платформе занимает 16 байт:
type node struct { val int64 // 8 next *node // 8 } // unsafe.Sizeof(node{}) == 16
В кеш-линию их влезает восемь. Но лежат ли рядом с нужным узлом те семь, которые понадобятся дальше по обходу, — зависит от того, как узлы распределились по куче. Если список собирали по одному узлу вперемешку с другими аллокациями, соседи по памяти и соседи по цепочке — разные узлы.
И главное: cur = cur.next — цепочка зависимых обращений. Адрес следующего узла становится известен только после того, как приехал предыдущий, и это резко ограничивает memory-level parallelism. Последовательный обход массива процессор хорошо предсказывает и подгружает линии вперёд; с разбросанным списком подгружать заранее ему значительно сложнее.
Промах кеша с походом в оперативную память стоит десятки наносекунд, то есть сотни тактов; точное число зависит от уровня кеша, памяти и процессора. Формула O(n) этой цены не содержит.
Проверяем. Одинаковые данные, одинаковая работа — сложить все значения:
// массив sum := 0 for _, v := range s { sum += v } // список sum := 0 for cur := head; cur != nil; cur = cur.next { sum += cur.val }

Медианы пяти прогонов, -count=5. Все варианты содержат одни и те же значения, тест сверяет их сумму:
Элементов |
Массив |
Список: узлы подряд |
Список вразброс: один блок |
Список вразброс: отдельные аллокации |
|---|---|---|---|---|
1 000 |
480 нс |
1.61 мкс (×3.3) |
1.67 мкс (×3.5) |
1.70 мкс (×3.5) |
10 000 |
4.46 мкс |
16.8 мкс (×3.8) |
46.1 мкс (×10) |
46.0 мкс (×10) |
100 000 |
49.4 мкс |
178.5 мкс (×3.6) |
1.17 мс (×24) |
1.31 мс (×26) |
1 000 000 |
491 мкс |
1.77 мс (×3.6) |
158.2 мс (×322) |
146.7 мс (×299) |
Цена одного элемента на миллионе:
массив: 491 мкс / 1e6 ≈ 0.49 нс на элемент список подряд: 1.77 мс / 1e6 ≈ 1.8 нс на узел список вразброс: 158.2 мс / 1e6 ≈ 158 нс на узел
158 нс на один зависимый переход по указателю — ровно тот порядок, которого стоит обращение к памяти мимо кеша. Асимптотика у обоих обходов O(n).
Этой цифре я сначала не поверил. Разброшенный список отличался от плотного двумя вещами сразу: узлы выделены по одному через &node{}, и связи перемешаны. Значит ×322 могли объясняться вовсе не локальностью, а поведением аллокатора. Так появился четвёртый вариант: узлы в том же одном блоке make([]node, n), что и у плотного, но связаны в случайном порядке. Отличие от плотного ровно одно — порядок связей.
Он дал 158.2 мс против 146.7 мс у отдельных аллокаций. Разница 7% при отставании от массива в три сотни раз: случайные связи внутри одного блока уже дают те же ~150 мс. Похоже, почти вся разница здесь именно в порядке обхода. Пять прогонов не позволяют сказать, что способ аллокации не влияет вообще, но рядом с ×300 его вклад небольшой.
У этой машины L1d — 64 КБ, L2 — 4 МБ. Теперь посмотрим на размер рабочего набора:
Элементов |
Массив |
Список |
Где помещается |
|---|---|---|---|
1 000 |
8 КБ |
16 КБ |
в L1 |
10 000 |
80 КБ |
160 КБ |
в L2 |
100 000 |
800 КБ |
1.6 МБ |
в L2 |
1 000 000 |
8 МБ |
16 МБ |
больше L2 |
Даже плотный список стоит примерно ×3.6, и эта надбавка почти одинакова на всех размерах: ×3.3, ×3.8, ×3.6, ×3.6. Из чего она складывается, один этот бенчмарк не разделяет — у узла 16 байт против 8 у элемента массива, то есть вдвое больший рабочий набор, плюс чтение next и зависимость шага от предыдущего. Важно, что от размера данных она не зависит.
А цена перестановки связей от размера зависит резко. Если считать не от массива, а от плотного списка: ×1.0, ×2.8, ×6.6, ×89. На тысяче элементов разницы почти нет — всё помещается в L1. Когда рабочий набор перестаёт помещаться в L2, та же перестановка стоит в девяносто раз.
list_dense — это список сразу после того, как его аккуратно собрали в цикле. Замерь только его, и вывод получится «медленнее, но терпимо». Бывают нагрузки, где список таким и остаётся, но после череды вставок и удалений рассчитывать на это уже нельзя.
Вставка
Хорошо, обход у списка медленнее. Но вставка-то O(1)?
O(1) — это только момент перецепления указателей. До нужного места ещё надо дойти:
// список: сначала дойти, потом вставить prev := head for k := 0; k < i-1; k++ { // вот где появляется O(n), если известен индекс, а не узел prev = prev.next } prev.next = &node{val: 42, next: prev.next}
// массив: сдвинуть хвост s = append(s, 0) copy(s[i+1:], s[i:]) s[i] = 42
Обе операции линейные. Но copy работает с непрерывным диапазоном памяти: компилятор сводит его к специализированному копированию блока, которое читает и пишет подряд и хорошо использует пропускную способность памяти. А проход по указателям — та самая цепочка зависимых обращений, каждое из которых ждёт предыдущего.
Элементов |
Массив (сдвиг) |
Список (дойти и вставить) |
Список (узел уже в руках) |
|---|---|---|---|
1 000 |
145 нс |
397 нс (×2.7 хуже) |
3.4 нс |
100 000 |
18.9 мкс |
51.5 мкс (×2.7 хуже) |
3.6 нс |
Список с обходом проигрывает массиву в 2.7 раза на обоих размерах — при том что «по учебнику» у него вставка O(1), а у массива O(n).
Вот в третьей колонке у списка действительно O(1): 3.4 нс на тысяче элементов и 3.6 нс на ста тысячах. Именно такую картину и ожидаешь от O(1) — пропал обход, и стоимость почти не изменилась. Нужный узел для этого должен уже лежать в руках.
Список имеет смысл там, где указатель на нужный узел уже есть и добывать его обходом не надо. Классический пример — LRU-кеш: map даёт указатель на узел за один шаг, список переставляет его в начало за одну операцию. Ни одного обхода.
type lru struct { order *list.List // порядок использования index map[string]*list.Element // ключ → узел в списке } func (c *lru) Get(key string) (any, bool) { el, ok := c.index[key] if !ok { return nil, false } c.order.MoveToFront(el) // указатель уже есть — O(1) без обхода return el.Value, true }
Ещё один настоящий случай — когда нужны стабильные адреса. append может выделить новый backing array и скопировать туда данные. Взятый раньше указатель остаётся валидной памятью, но ссылается на старый массив: через слайс вы уже видите новый, и запись по старому указателю в него не попадёт. Узлы списка с места не двигаются.
Переезд
У бабули тоже есть цена, и первая — рост.
У классической хеш-таблицы это выглядит так. Записей стало больше, чем позволяет заполнение, — выделяется массив вдвое больше, и все записи перекладываются туда. Для таблицы на гигабайт это значит, что одной из вставок придётся выполнить работу по росту всей таблицы. Остальные — наносекунды, а эта надолго, и создаёт выброс в хвосте распределения задержек: заметит его тот запрос, которому не повезло.
А теперь что делает Go. Начиная с версии 1.24 встроенный map реализован по схеме Swiss Tables, и команда Go в блоге называет причину прямо: Go часто используют для серверов, чувствительных к задержкам, поэтому операции над встроенными типами не должны произвольно влиять на tail latency.
Как это сделано, по описанию из того же блога:
хранилище разбито на группы по 8 слотов, к каждой группе прицеплено 64-битное control word — по байту на слот;
байт говорит, пуст слот, удалён или занят, и если занят — содержит младшие 7 бит хеша ключа (
h2);поиск сравнивает искомый
h2со всеми восемью байтами control word одной операцией вместо восьми последовательных сравнений ключей (по описанию в том же блоге, на amd64 для этого используются SIMD-инструкции). Сравниваются не ключи, а метаданные, поэтому совпадение — это ещё не ответ, а кандидат: 7 бит совпадают у разных ключей примерно в одном случае из 128, и полный ключ всё равно проверяется;большая карта разбита на независимые таблицы, каждая до 1024 записей; старшие биты хеша выбирают таблицу. Переполнилась одна — делится она, остальных это не касается.
Проверяем, замеряя каждую из миллиона вставок по отдельности:
d := make([]time.Duration, n) // место под отсчёты — заранее m := make(map[int]int) for i := 0; i < n; i++ { start := time.Now() m[i] = i d[i] = time.Since(start) }
Это демонстрационный эксперимент, а не бенчмарк цены mapassign. Одна вставка быстрее вызова time.Now, поэтому прибор — заметная часть измеряемого: пустой замер (только time.Now и time.Since) даёт p50 = 41 нс. Читать медиану как чистую стоимость вставки нельзя. Что этой методикой видно хорошо — выбросы: вставка, которая стоит многократно дороже соседних, из-под накладных расходов таймера торчит.

Миллион вставок, GC отключён на время замера:
пустой замер (только таймер): p50 = 41 нс p50 = 167 нс p99 = 875 нс p99.9 = 32.7 мкс max = 2.01 мс ← ≈12 000× от измеренной медианы дороже 100 × p99 (87.5 мкс): 98 вставок из 1 000 000
Здесь рассказ пришлось править по факту. Худшая вставка заняла 2 мс. Выбросы кучкуются примерно между #838 000 и #926 000 и на степени двойки не похожи. GC я на время теста отключил, так что это не он.
Почему именно они возникают, из этого теста я не знаю. Можно подозревать выделение новых таблиц или первые обращения к свежим страницам памяти, но таймер вокруг m[k] = v этого не доказывает. Тут уже нужен профиль.
Локализация роста избавляет от копирования всей карты, но выбросы в хвосте остаются. Если у вас в SLA стоят миллисекунды на хвосте, «в Go теперь хорошая карта» — не аргумент.
Работу при росте можно частично или полностью не делать, если размер известен заранее:
m := make(map[string]int) // размер неизвестен — карта растёт по ходу дела m := make(map[string]int, len(rows)) // размер известен
Второй аргумент make — это подсказка, а не фиксированная ёмкость: рост он не отменяет. Но позволяет выделить место сразу под ожидаемое количество записей вместо того, чтобы приходить к нему через несколько промежуточных ростов.
Записей |
Без подсказки |
С подсказкой |
Разница |
|---|---|---|---|
10 000 |
424 мкс · 591 КБ · 79 allocs |
134 мкс · 296 КБ · 33 allocs |
×3.2 по времени, ×2 по памяти |
1 000 000 |
101 мс · 75.6 МБ · 8208 allocs |
88 мс · 37.8 МБ · 4097 allocs |
×1.15 по времени, ×2 по памяти |
На десяти тысячах записей подсказка даёт втрое по времени, а на миллионе — всего 15%. Зато память ровно вдвое на обоих размерах, и вдвое меньше аллокаций. На этом стенде подсказка оказалась в первую очередь про память и аллокатор; с другими типами ключей и значений картина может отличаться.
Ещё несколько свойств map
Второе, за что бабуля берёт плату: на маленьких наборах её работа заметна.
Посчитать хеш, выбрать группу, сравнить control word, проверить ключ целиком — это работа. Перебрать несколько элементов подряд в массиве — тоже работа, но она вся в кеше и без хеширования.
Ключей |
|
Перебор слайса |
Кто быстрее |
|---|---|---|---|
4 |
10.2 нс |
10.2 нс |
поровну |
8 |
11.8 нс |
19.9 нс |
map ×1.7 |
16 |
18.5 нс |
44.0 нс |
map ×2.4 |
32 |
18.9 нс |
81.5 нс |
map ×4.3 |
64 |
18.3 нс |
146.9 нс |
map ×8.0 |
128 |
18.7 нс |
312.5 нс |
map ×16.7 |
С восьми ключей map уже впереди, и дальше отрыв растёт линейно, потому что у него время почти не меняется — 18-19 нс от шестнадцати ключей и до ста двадцати восьми.
Про методику: ищется последний ключ из присутствующих, то есть худший случай для перебора при попадании. Ключи одинаковой длины — иначе сравнение строк отбрасывало бы кандидатов по длине, не сравнивая байты, и перебор выглядел бы лучше, чем есть. Промах (ключа нет вовсе) для перебора ещё дороже: он обходит всё до конца, тогда как у map промах не превращается в полный линейный обход всех элементов. Цифры в таблице — оценка сверху для перебора на попаданиях, и переносить их на нагрузку с частыми промахами нельзя.
На этом стенде точка равенства оказалась между четырьмя и восьмью ключами — намного раньше, чем я ожидал. То есть «на маленьких наборах перебор быстрее» — правда, но «маленький» здесь означает не десяток, а буквально несколько.
Три грабли
Ещё три свойства map, на которые натыкаются в проде.
Конкурентная запись убивает процесс. Не паникой, которую можно поймать:
fatal error: concurrent map writes
recover не поможет — это fatal error, а не panic: процесс умирает целиком, вместе со всеми остальными запросами. Лечится обычным sync.RWMutex рядом с картой; sync.Map — не универсальная замена: её документация прямо называет два сценария, под которые она оптимизирована, — ключ записывается один раз и читается много (write-once, read-many) и наборы ключей у горутин не пересекаются. В остальных случаях map под мьютексом обычно проще и понятнее. Но первый вопрос — зачем карта вообще разделяется между горутинами.
Порядок обхода не определён. Спецификация языка говорит прямо: порядок итерации по карте не задан и не гарантируется одинаковым от одной итерации к другой.
for k, v := range m { // порядок не определён; полагаться на него нельзя fmt.Println(k, v) }
Ловится обычно тестом, который зелёный локально и красный в CI — или наоборот. Нужен порядок — собирайте ключи в слайс и сортируйте.
Память после удаления. Что происходит с занятой картой памятью, когда из неё удалили все ключи, — вопрос к реализации, а не к спецификации, и по одной версии Go отвечать за другую нельзя. Поэтому замер:
heap до карты: 0.2 МБ миллион записей: 36.3 МБ (+36.1 МБ) после delete всех: 36.3 МБ (len = 0) после clear: 36.3 МБ удержано: 100%
В этом эксперименте на Go 1.24.7 результат однозначный: ни delete всех ключей, ни clear не вернули ни одного мегабайта. Карта на миллион записей заняла 36 МБ и держит их, пока сама достижима.
Если после пика важно избавиться от удержанной ёмкости, карту придётся заменить новой: clear очищает содержимое, но не ёмкость.
Как замерялось
go version : go1.24.7 GOOS/GOARCH : darwin/arm64 CPU : Apple M3 Pro, GOMAXPROCS=12 cache line : 128 байт (sysctl hw.cachelinesize) L1d / L2 : 64 КБ / 4 МБ прогонов : 5 (-count=5), в таблицах медианы
Числа с ARM-машины, и на x86-64 с 64-байтной линией абсолютные значения будут другими. Механизм — нет: он про то, что происходит, когда рабочий набор перестаёт помещаться в кеш.
Обход я гонял дважды. В первом прогоне разброс был такой, что на тысяче элементов разброшенный список местами выходил быстрее плотного — похоже, машина была занята чем-то ещё. В статье числа из второго прогона.
Код замеров открыт, гоняется одной командой, и в нём закрыты три ловушки, из-за которых замеры такого рода обычно врут: мёртвый код (компилятор выбрасывает обход, результат которого не используется), выгодное для одной из сторон начальное состояние (отсюда три варианта списка, один из них — контрольный, отделяющий порядок обхода от способа аллокации) и проверка, что сравниваемые структуры вообще содержат одинаковые данные.
Что выбрать
Ни одна из этих структур не «лучше» другой. Они отвечают на разные вопросы:
Что нужно |
С чего начинать |
|---|---|
перебирать всё подряд, считать, суммировать |
слайс |
доступ по номеру |
слайс |
поиск по ключу |
|
совсем маленький набор полей |
слайс пар — на моём стенде до четырёх ключей та же скорость, и это проще |
часто вставлять и удалять, указатель на место уже есть |
список, обычно вместе с |
стабильные адреса элементов |
список |
важен порядок обхода |
слайс, или ключи из |
Универсальных порогов в таблице намеренно нет. Число, на котором map начинает выигрывать у перебора, зависит от типа ключа, размера значения, доли промахов и процессора — у меня оно одно, у вас будет другое. Надёжный способ его узнать — замерить свой случай.
Код и замеры из статьи лежат на backendstart.ru — там же есть другие разборы backend-задач в таком формате. Всё можно прогнать у себя.
Что забрать с собой
Список берут за то, что указатель на узел уже есть или нужны стабильные адреса. Не за «O(1) на вставке».
Замеряете список — гоняйте оба состояния, узлы подряд и узлы вразброс. Первое польстит.
Знаете размер карты — скажите его в
make. На моём тесте это вдвое меньше памяти и аллокаций.Худшая из миллиона вставок в карту заняла у меня 2 мс. Критична tail latency — смотрите свой request path.
Карта между горутинами без синхронизации убивает процесс целиком, и
recoverне спасёт.
В таблице сложности у массива и списка по-прежнему написано O(n). На моём M3 Pro между ними получилось ×322.
Big O не соврал. Просто про разницу в ×322 он ничего не обещал.
Комментарии (10)

dixmod Автор
21.08.2026 06:09Пошёл мерить, и вы правы, случай зря пропущен.
100k элементов: слайсу на вставку в начало нужен memmove всего массива, 15.9 мкс. Списку две записи указателя, 0.5 нс, и от размера это не зависит. Аллокацию узла мерю отдельно, там первая версия бенчмарка сама села в лужу: escape analysis утащил узел на стек, и получилось 0.3 нс при 0 allocs/op, то есть аллокация «быстрее» обычного присваивания. Переписал. Но даже десятки наносекунд тут картину не меняют.
Дальше я собирался возразить, что выигрыш съест обход, список-то из вставок в начало это ровно тот самый разбросанный. Померил: не съест. Обход миллиона 1.90 мс, у плотного 1.77 мс, у разбросанного 158 мс. Ведёт себя как плотный. Дошло почему: последовательные аллокации кладут узлы по возрастающим адресам, а обход идёт по убывающим. Шаг регулярный, этого хватает.
Так что возражения нет, вставка в начало - нормальный случай для списка, без оговорок. Узлы разъедутся только если между вставками аллоцируется что-то ещё, вот тогда обход и поедет в сторону тех самых 158 мс.
Замеры добавил в модуль:
BenchmarkInsertFrontиBenchmarkTraversePrepended.
wataru
21.08.2026 06:09По поводу аллокаций - нормальные списки делаются поверх массива (вместо указателей используются индексы в массиве). Тогда все элементы списка, хоть и идут не по-порядку, но лежат кучно. Независимо от того, как и что вы там аллоцируете параллельно. Если вы еще и часто по списку проходитесь, то можно его периодически "сортировать" - во время прохода перепешите его в новый массив по порядку и все следующие проходы будут удобны для кэша и сильно быстрее. Эти оптимизации делают список гораздо выгоднее и лучше массива для меньших n и с большим соотношением поиска/вставки.

dixmod Автор
21.08.2026 06:09Померил SoA-вариант, vals []int64 плюс next []int32. Результат интереснее
Обход миллиона по порядку: указатели 1.77 мс, индексы 2.43 мс, то есть индексы на 38% хуже. Обход того же миллиона вразброс: указатели 158.2 мс, индексы 67.9 мс, индексы в 2.3 раза лучше. На ста тысячах то же направление, слабее.
Получается, индексы помогают там, где локальность уже потеряна, и мешают там, где она есть. На упорядоченном обходе узел из 16 байт читается одним потоком, а SoA требует двух независимых чтений плюс проверку границ. На разбросанном выигрывает меньший рабочий набор связей: 4 МБ
nextпротив 16 МБ узлов.Разрыв с массивом при этом сокращается вдвое, но не закрывается - ×138 вместо ×322 на миллионе. И цена случайного порядка обхода падает с ×89 до ×28 относительно упорядоченного варианта, то есть остаётся главным фактором.
Так что представление на индексах работает, но как средство от плохой локальности, а не как способ её получить. Получить её даёт та самая компактизация, про которую вы написали: 1.77 мс против 158 мс - это ×89, и никакая смена представления столько не даёт.

wataru
21.08.2026 06:09O(1) — это только момент перецепления указателей. До нужного места ещё надо дойти:
Как обычно, грубейшая ошибка в сравнении. Вы сравниваете O(n) в массиве и O(n) в списке и удивляетесь, что список медленный. Поиск в массиве сильно быстрее поиска в списке. Поэтому поиск+вставка в массиве часто выигрывают поиску+вставке в списке.
Бесполезно применять список вот так. Он выигрывает, когда вы уже знаете место, куда вставляете. Это значит, что вставка тут не отдельная абстрактная операция, а часть алгоритма.
Один такой пример - структура данных skip list. Попробуйте реализовать ее поверх массива, она для весьма маленьких n станет медленнее списоков. Даже при учете хорошего для кэшей обращения к памяти.
Другой пример: обратная операция - не вставка, а удаление из середины списка. Тут ассимптотика у списка тоже O(1) против O(n) в массиве. Примером тут будет LRU кэш.
Да недружественность к памяти дает весьма большую константу в этом O(1) и для выигрыша перед массивами нужно n заметно больше, чем люди себе представляют. Но если у вас миллион объектов, то список будет выгоднее.

dixmod Автор
21.08.2026 06:09Померил
Удаление из середины, 100 000 элементов:массив, сдвиг хвоста - 17.4 мкс
список с обходом - 28.8 мкс
список с готовым указателем - 1.02 нс×17 000 в пользу списка, и от размера не зависит: 1.04 нс на тысяче, 1.02 нс на ста тысячах. Здесь вы правы полностью, и в статье этой операции действительно не было.
Заодно закрыл аллокацию узла из соседней ветки: вставка в начало через &node{} - 20 нс, 16 B/op, 1 allocs/op. Против 15.9 мкс у сдвига слайса это ×784.
Но средняя строка таблицы - про то, о чём я писал выше: как только за узлом надо идти обходом, список проигрывает массиву даже на удалении, где у него O(1) против O(n). 28.8 против 17.4 мкс.
То есть граница ровно там, где вы её и провели. Указатель в руках - список вне конкуренции. Указатель надо искать - выигрывает массив.
Асимптотика на этой границе не меняется, меняется только то, кто добывает узел

ilih
21.08.2026 06:09В учебнике написано: вставка в связный список — O(1), в массив — O(n). Вывод как будто очевиден: вставляем часто — берём список.
О-нотация описывает сложность алгоритма - зависимость потребления ресурсов (время работы, потребляемая память) от размера данных: константа, линейная, квадратичная, экспоненциальная.
Она позволяет грубо оценить скорость работы на большом объеме данных по скорости на небольшом тестовом объеме.
В общем случае их нельзя сравнивать, конкретные числа неизвестны: O(1) может быть сутки, O(n) секунда/элемент или час/элемент.
unreal_undead2
А если побенчмаркать вставку в начало (может это самый частый случай в конкретной задаче) ? ;)
dixmod Автор
Справедливо, этого случая в статье нет. Вставка в начало - как раз то, где у списка обхода нет вообще: голова всегда под рукой, а слайсу нужен memmove всего массива. Тут список должен выигрывать, и заметно.
Добавил замер в модуль, прогоню и вернусь с цифрами. Заодно интересно посмотреть на второй шаг: список, собранный вставками в начало - это ровно тот самый разбросанный список из статьи. Если по нему потом хоть иногда ходят, выигрыш на вставке может съесться на обходе. Вот это и хочу замерить, а не угадать.
unreal_undead2
Да и заголовок обманчив - вставка по индексу (с получением элемента обходом с начала) в списке это таки O(n), в тексте это всё таки сказано. Если надо вставлять в середину, но элемент списка индентифицируется указателем на него - тогда, как и со вставкой в начало, будет O(1) и опять же ожидается выигрыш от списка.
dixmod Автор
Принимаю. По индексу у обоих O(n), и массив выигрывает на константах. «O(1)» - учебное утверждение, а не мой замер.
Про указатель - согласен, это и есть вывод статьи: третья колонка, 3.4-3.6 нс против ~19 мкс.
Вставку в начало из соседнего комментария тоже померил: 15.9 мкс против 0.5 нс на 100k.
Одна поправка к полному счёту: указатель надо откуда-то взять, обычно из map плюс её память и lookup. И если по списку иногда ходят, возвращается ×322, потому что после серии вставок узлы разъезжаются.