Объясню структуры данных через очередь в поликлинике, а потом покажу, где эта аналогия ломается: почему связный список с «вставкой за 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
}
Одна кеш-линия — шестнадцать int64
Одна кеш-линия — шестнадцать int64

Медианы пяти прогонов, -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, проверить ключ целиком — это работа. Перебрать несколько элементов подряд в массиве — тоже работа, но она вся в кеше и без хеширования.

Ключей

map

Перебор слайса

Кто быстрее

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

совсем маленький набор полей

слайс пар — на моём стенде до четырёх ключей та же скорость, и это проще

часто вставлять и удалять, указатель на место уже есть

список, обычно вместе с map

стабильные адреса элементов

список

важен порядок обхода

слайс, или ключи из map с сортировкой

Универсальных порогов в таблице намеренно нет. Число, на котором map начинает выигрывать у перебора, зависит от типа ключа, размера значения, доли промахов и процессора — у меня оно одно, у вас будет другое. Надёжный способ его узнать — замерить свой случай.

Код и замеры из статьи лежат на backendstart.ru — там же есть другие разборы backend-задач в таком формате. Всё можно прогнать у себя.

Что забрать с собой

  1. Список берут за то, что указатель на узел уже есть или нужны стабильные адреса. Не за «O(1) на вставке».

  2. Замеряете список — гоняйте оба состояния, узлы подряд и узлы вразброс. Первое польстит.

  3. Знаете размер карты — скажите его в make. На моём тесте это вдвое меньше памяти и аллокаций.

  4. Худшая из миллиона вставок в карту заняла у меня 2 мс. Критична tail latency — смотрите свой request path.

  5. Карта между горутинами без синхронизации убивает процесс целиком, и recover не спасёт.

В таблице сложности у массива и списка по-прежнему написано O(n). На моём M3 Pro между ними получилось ×322.

Big O не соврал. Просто про разницу в ×322 он ничего не обещал.

Комментарии (10)


  1. unreal_undead2
    21.08.2026 06:09

    O(1) — это только момент перецепления указателей. До нужного места ещё надо дойти:

    А если побенчмаркать вставку в начало (может это самый частый случай в конкретной задаче) ? ;)


    1. dixmod Автор
      21.08.2026 06:09

      Справедливо, этого случая в статье нет. Вставка в начало - как раз то, где у списка обхода нет вообще: голова всегда под рукой, а слайсу нужен memmove всего массива. Тут список должен выигрывать, и заметно.

      Добавил замер в модуль, прогоню и вернусь с цифрами. Заодно интересно посмотреть на второй шаг: список, собранный вставками в начало - это ровно тот самый разбросанный список из статьи. Если по нему потом хоть иногда ходят, выигрыш на вставке может съесться на обходе. Вот это и хочу замерить, а не угадать.


      1. unreal_undead2
        21.08.2026 06:09

        Да и заголовок обманчив - вставка по индексу (с получением элемента обходом с начала) в списке это таки O(n), в тексте это всё таки сказано. Если надо вставлять в середину, но элемент списка индентифицируется указателем на него - тогда, как и со вставкой в начало, будет O(1) и опять же ожидается выигрыш от списка.


        1. dixmod Автор
          21.08.2026 06:09

          Принимаю. По индексу у обоих O(n), и массив выигрывает на константах. «O(1)» - учебное утверждение, а не мой замер.

          Про указатель - согласен, это и есть вывод статьи: третья колонка, 3.4-3.6 нс против ~19 мкс.

          Вставку в начало из соседнего комментария тоже померил: 15.9 мкс против 0.5 нс на 100k.

          Одна поправка к полному счёту: указатель надо откуда-то взять, обычно из map плюс её память и lookup. И если по списку иногда ходят, возвращается ×322, потому что после серии вставок узлы разъезжаются.


  1. 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.


    1. wataru
      21.08.2026 06:09

      По поводу аллокаций - нормальные списки делаются поверх массива (вместо указателей используются индексы в массиве). Тогда все элементы списка, хоть и идут не по-порядку, но лежат кучно. Независимо от того, как и что вы там аллоцируете параллельно. Если вы еще и часто по списку проходитесь, то можно его периодически "сортировать" - во время прохода перепешите его в новый массив по порядку и все следующие проходы будут удобны для кэша и сильно быстрее. Эти оптимизации делают список гораздо выгоднее и лучше массива для меньших n и с большим соотношением поиска/вставки.


      1. 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, и никакая смена представления столько не даёт.


  1. wataru
    21.08.2026 06:09

    O(1) — это только момент перецепления указателей. До нужного места ещё надо дойти:

    Как обычно, грубейшая ошибка в сравнении. Вы сравниваете O(n) в массиве и O(n) в списке и удивляетесь, что список медленный. Поиск в массиве сильно быстрее поиска в списке. Поэтому поиск+вставка в массиве часто выигрывают поиску+вставке в списке.

    Бесполезно применять список вот так. Он выигрывает, когда вы уже знаете место, куда вставляете. Это значит, что вставка тут не отдельная абстрактная операция, а часть алгоритма.

    Один такой пример - структура данных skip list. Попробуйте реализовать ее поверх массива, она для весьма маленьких n станет медленнее списоков. Даже при учете хорошего для кэшей обращения к памяти.

    Другой пример: обратная операция - не вставка, а удаление из середины списка. Тут ассимптотика у списка тоже O(1) против O(n) в массиве. Примером тут будет LRU кэш.

    Да недружественность к памяти дает весьма большую константу в этом O(1) и для выигрыша перед массивами нужно n заметно больше, чем люди себе представляют. Но если у вас миллион объектов, то список будет выгоднее.


    1. 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 мкс.

      То есть граница ровно там, где вы её и провели. Указатель в руках - список вне конкуренции. Указатель надо искать - выигрывает массив.

      Асимптотика на этой границе не меняется, меняется только то, кто добывает узел


  1. ilih
    21.08.2026 06:09

    В учебнике написано: вставка в связный список — O(1), в массив — O(n). Вывод как будто очевиден: вставляем часто — берём список.

    О-нотация описывает сложность алгоритма - зависимость потребления ресурсов (время работы, потребляемая память) от размера данных: константа, линейная, квадратичная, экспоненциальная.

    Она позволяет грубо оценить скорость работы на большом объеме данных по скорости на небольшом тестовом объеме.

    В общем случае их нельзя сравнивать, конкретные числа неизвестны: O(1) может быть сутки, O(n) секунда/элемент или час/элемент.