«Связанные списки — это goto структур данных.», — авторство приписывают разным системным программистам.

Оглавление

Глава 1: Разрыв в производительности

Глава 2: Иерархия памяти

Глава 3: Бенчмаркинг и профилирование

Глава 4: Массивы и локальность кэша

Глава 5: Связанные списки — убийцы кэша

История из учебника

Все студенты, изучающие computer science, узнают о связанных списках на первом курсе по структурам данных. Их описание звучит привлекательно:

Преимущества (согласно учебникам):

  • Вставки и удаления за O(1) в известных позициях

  • Динамический размер: увеличиваются и уменьшаются согласно необходимости

  • Пространство не тратится впустую: можно распределять ровно столько, сколько нужно

  • Гибкость: простота реализации стеков, очередей и других структур

Недостатки (согласно учебникам):

  • Поиск за O(n): необходим обход, начиная с головы списка

  • Лишняя память: указатели добавляют оверхед

  • Невозможность произвольного доступа: нельзя выполнять переходы в произвольные позиции

Вывод из учебника: «Используйте связанные списки, когда требуются частые вставки/удаления и не нужен произвольный доступ».

Вроде бы звучит разумно?

Проверка реальностью

А вот, чего учебники нам не говорят: связанные списки — это почти всегда плохой выбор.

Не потому, что ошибочен анализ «О» большого, в нём всё правильно, а потому, что он неполон. Он забывает про оборудование.

Давайте проведём простой эксперимент: сравним три операции с 100000 элементов:

  1. Последовательный обход: посещение каждого элемента

  2. Произвольный доступ: доступ к элементам в произвольном порядке

  3. Вставки: добавление элементов по одному

Протестируем и массивы, и связанные списки. Вот результаты:

=== Последовательный обход ===
Массив:              70 мкс
Связанный список:   179 мкс
Победитель: массив (быстрее в 2,5 раза)

=== Произвольный доступ ===
Массив:              95 мкс
Связанный список:  2847 мкс
Победитель: массив (быстрее в 30 раз!)

=== Вставки (в конец) ===
Массив:              42 мкс
Связанный список:  1234 мкс
Победитель: массив (быстрее в 29 раз!)

Что? Массив быстрее при вставках? Но они должны быть O(n) для массивов и O(1) для связанных списков!

Добро пожаловать в реальность современного оборудования.

Почему связанные списки медленные

Проблема заключается в следовании по указателям. Каждый раз, когда вы переходите по указателю, вы, скорее всего, промахнётесь мимо кэша.

Сравнение схем памяти:

Поведение кэша при обходе:

Разница огромна:

Шаг 1: доступ к узлу A
  CPU: "Получить адрес 0x1000"
  Кэш: ПРОМАХ (100 тактов)
  Память: возвращает узел A + 63 байта соседних данных
  
Шаг 2: доступ к узлу B (через A->next)
  CPU: "Получить адрес 0x5000"  (в произвольном местоположении)
  Кэш: ПРОМАХ (100 тактов)
  Память: возвращает узел B + 63 байта соседних данных
  
Шаг 3: доступ к узлу C (через B->next)
  CPU: "Получить адрес 0x2000"  (в произвольном местоположении)
  Кэш: ПРОМАХ (100 тактов)
  Память: возвращает узел C + 63 байта соседних данных

Каждый доступ к узлу — это промах кэша. Каждый промах кэша стоит примерно 100 тактов.

При 100000 узлов 10 миллионов тактов тратятся просто на ожидание памяти.

Сравним это с массивом:

Шаг 1: доступ к array[0]
  CPU: "Получить адрес 0x1000"
  Кэш: ПРОМАХ (100 тактов)
  Память: возвращает 64 байта (16 integer)
  
Шаги 2-16: доступ к значениям с array[1] по array[15]
  CPU: "Получить адреса 0x1004, 0x1008, ..."
  Кэш: ПОПАДАНИЕ (3 такта каждое)
  
Шаг 17: доступ к array[16]
  CPU: "Получить адрес 0x1040"
  Кэш: ПРОМАХ (100 тактов)
  Память: возвращает 64 байта (ещё 16 integer)

Всего один промах кэша на 16 элементов, то есть 6250 промахов кэша на 100000 элементов.

10 миллионов тактов против 625000 тактов. Из-за одного лишь поведения кэша массив быстрее в 16 раз.

Оверхед памяти

Кроме того, связанные списки впустую тратят память. Много памяти.

Рассмотрим простой узел связанного списка, хранящий 32-битный integer:

typedef struct node {
    int value;        // 4 байта
    struct node *next; // 8 байт (в 64-битных системах)
} node_t;             // Итого: 12 байт + 4 байта заполнения = 16 байт

Для 4-байтного integer мы используем 16 байт. Четырёхкратный оверхед.

Массив из 100000 integer:

  • Массив: 400 КБ

  • Связанный список: 1,6 МБ

Связанный список использует в 4 раза больше памяти и он в 2,5 раза медленнее. Это ужасно.

Затраты на распределение

Есть и ещё одни скрытые затраты: на распределение памяти.

Для создания связанного списка необходимо вызывать malloc() для каждого узла:

// Связанный список: 100000 вызовов malloc
for (int i = 0; i < 100000; i++) {
    node_t *node = malloc(sizeof(node_t));  // Очень затратно!
    node->value = i;
    node->next = head;
    head = node;
}

Каждый вызов malloc():

  • Выполняет поиск по списку свободной памяти

  • Обновляет метаданные

  • Потенциально выполняет вызов ядра, чтобы запросить ещё памяти

  • Фрагментирует кучу

Для создания массива требуется одно распределение:

// Массив: 1 вызов malloc
int *array = malloc(100000 * sizeof(int));  // Быстро!
for (int i = 0; i < 100000; i++) {
    array[i] = i;
}

В наших бенчмарках создание связанного списка занимало 1234 мкс, а массива — 42 мкс. Разница в 29 раза.

Когда имеет смысл использовать связанные списки

Так когда же нужно использовать связанные списки? Редко.

Когда стоит задуматься о применении связанных списков:

Существует несколько вполне допустимых сценариев их использования:

1. Интрузивные списки в ядрах

В ядре Linux активно используются связанные списки, только не их версия из учебника. Там применяются интрузивные списки:

struct list_head {
    struct list_head *next, *prev;
};

struct task_struct {
    // ... данные задачи ...
    struct list_head tasks;  // Встроенный узел списка
};

Узел списка встраивается в структуру данных, а не распределяется отдельно. Благодаря этому:

  • Устраняется оверхед распределения

  • Повышается локальность кэша (данные и ссылки находятся рядом)

  • Один объект может находиться в нескольких списках

2. Алгоритмы без блокировки

В некоторых структурах данных без блокировки используются связанные списки, потому что:

  • Атомарные обновления указателей выполнять проще, чем обновления массивов

  • Нет необходимости изменения размера (которое бы потребовало блокировку)

Пример: стек без блокировки (стек Трейбера):

typedef struct node {
    int value;
    struct node *next;
} node_t;

void push(node_t **head, node_t *node) {
    do {
        node->next = *head;
    } while (!atomic_compare_exchange(head, &node->next, node));
}

Но даже здесь следует использовать пул памяти, чтобы избежать оверхеда распределения.

3. Редкие вставки в большие датасеты

Если вы работаете с большим и по большей степени статическим датасетом, куда иногда выполняются вставки, то связанный список, возможно, имеет смысл.

Но если говорить откровенно, обычно лучше динамический массив с амортизированными вставками за O(1).

Стратегии оптимизации

Если обязательно нужно использовать связанный список, то его можно сделать менее ужасным:

Стратегия 1: пулы памяти

Вместо вызова malloc() для каждого узла распределяйте узлы из пула:

#define POOL_SIZE 10000
node_t node_pool[POOL_SIZE];
int pool_index = 0;

node_t *alloc_node(void) {
    if (pool_index >= POOL_SIZE) return NULL;
    return &node_pool[pool_index++];
}

Преимущества:

  • Повышение скорости распределения: нет оверхеда malloc

  • Повышенная локальность: узлы находятся по соседству

  • Предсказуемая память: отсутствие фрагментации

Результаты бенчмарков:

Связанный список (malloc):  1234 мкс
Связанный список (пул):      287 мкс
Массив:                       42 мкс

Пул в 4,3 раза быстрее, чем malloc, но всё равно в 6,8 раза медленнее, чем массив.

Стратегия 2: развёрнутые связанные списки

Храните в каждом узле несколько элементов:

#define ELEMENTS_PER_NODE 16

typedef struct node {
    int values[ELEMENTS_PER_NODE];
    int count;
    struct node *next;
} unrolled_node_t;

Преимущества:

  • Оптимизация использования кэша: 16 элементов на промах кэша вместо одного

  • Меньше оверхед указателей: 1 указатель на 16 элементов

  • Меньшее количество распределений: 1/16 количества вызовов malloc

Результаты бенчмарков:

Стандартный связанный список:  179 мкс
Развёрнутый связанный список:   45 мкс
Массив:                         70 мкс

Что? Развёрнутый список быстрее, чем массив? Не совсем — это справедливо только для последовательного обхода. При произвольном доступе массив всё равно побеждает.

Стратегия 3: XOR связанных списков

Экономьте память, выполняя XOR предыдущего и следующего указателей:

typedef struct node {
    int value;
    struct node *prev_xor_next;  // prev XOR next
} xor_node_t;

Для обхода:

node_t *prev = NULL;
node_t *curr = head;
while (curr) {
    node_t *next = (node_t *)((uintptr_t)prev ^ (uintptr_t)curr->prev_xor_next);
    prev = curr;
    curr = next;
}

Преимущества:

  • На 50% меньше памяти под указатели: один указатель вместо двух

  • Те же затраты на обход: по-прежнему один промах кэша на узел

Недостатки:

  • Более сложный код: логика XOR неочевидна

  • Отсутствие обхода назад от произвольного узла: нужны и prev, и curr

  • Кошмар при отладке: исследовать указатели напрямую невозможно

Вердикт: в большинстве случаев не стоит того. Экономия памяти мала, а сложность высока.

Исследование реального примера: списки задач RTOS

Давайте рассмотрим реальный сценарий применения во встраиваемых системах: планирование задач в операционной системе реального времени (ОСРВ, RTOS).

Сценарии: FreeRTOS управляет готовыми задачами в списках с приоритетом.

Требования:

  • Вставка задачи, когда она готова (O(1) или O(n))

  • Удаление задачи с наибольшим приоритетом (O(1))

  • Изменение приоритетов (O(n))

Решение FreeRTOS: массив связанных списков, по одному на каждый уровень приоритета.

#define MAX_PRIORITIES 32

typedef struct {
    struct list_head ready_tasks[MAX_PRIORITIES];
    int highest_priority;
} scheduler_t;

Почему это работает:

  • Малые списки: обычно по 1-5 задач на каждый уровень приоритета

  • Узлы со встроенными списками: нет оверхеда распределения

  • Удобство для кэша: структура задачи и узел списка находятся вместе

  • Операции O(1): вставка/удаление с известным приоритетом

Бенчмарк (на ARM Cortex-M4):

Вставка задачи:          0,8 мкс
Удаление задачи:         0,6 мкс
Поиск следующей задачи:  0,3 мкс

Это обеспечивает достаточную скорость для планировщика, работающего с частотой 1 кГц (период 1000 мкс).

Важный вывод: здесь связанный список подходит, потому что:

  1. Списки маленькие (удобные для кэша)

  2. Узлы встроены (распределение не требуется)

  3. Операции просты (нет сложного обхода)

Встраиваемые системы

Во встраиваемых системах связанные списки ещё более проблематичны:

Проблема 1: фрагментация

Многократные malloc/free приводят к фрагментации кучи:

Исходная куча: [----------------свободно----------------]
После 1000 распределений и 500 очисток:
[используется][свободно][используется][свободно][используется][свободно][используется]...

Рано или поздно выполнять распределения не получится, даже если места достаточно.

Решение: использовать пулы памяти или полностью отказаться от динамического распределения.

Проблема 2: непредсказуемые тайминги

Из-за промахов кэша обход связанного списка оказывается непредсказуемым:

Наилучший случай:  все узлы находятся в кэше → 50 мкс
Наихудший случай:  все узлы находятся в DRAM  → 500 мкс

В системах реального времени десятикратный разброс неприемлем.

Решение: использовать массивы с предсказуемыми паттернами доступа.

Проблема 3: оверхед памяти

В системе с 64 КБ ОЗУ связанный список из 1000 элементов занимает:

  • Данные: 4 КБ (1000 × 4 байта)

  • Указатели: 8 КБ (1000 × 8 байт)

  • Оверхед malloc: ~2 КБ (метаданные)

  • Всего: 14 КБ (22% ОЗУ!)

Массив занимал бы 4 КБ (6% ОЗУ).

Решение: использовать массивы или развёрнутые списки.

Советы по проектированию

Вот дерево принятия решений для выбора между массивами и связанными списками:

Эмпирическое правило: если вы думаете об использовании связанного списка, попробуйте сначала динамический массив. Вероятно, он понравится вам больше.

Бенчмаркинг связанных списков

Давайте проведём подробный бенчмарк по сравнению массивов и связанных списков при выполнении различных операций:

Параметры теста

  • 100000 элементов

  • Система x86_64, кэш L1 32 КБ

  • Оптимизация GCC -O2

Результаты

Операция

Массив

Связанный список

Разница

Последовательный обход

70 мкс

179 мкс

2,5×

Произвольный доступ

95 мкс

2847 мкс

30×

Вставка в конец

42 мкс

1234 мкс

29×

Вставка в начало

0,01 мкс

0,02 мкс

Удаление из середины

45 мкс

1150 мкс

25×

Поиск элемента

82 мкс

2234 мкс

27×

Важнейшие выводы:

  1. Массивы выигрывают почти во всём с перевесом в 2-30 раз

  2. Единственное исключение: вставка в начало (но кто этим занимается?)

  3. Важнее всего поведение кэша: для списков произвольный доступ в 30 раз медленнее

Анализ кэша

Используем perf для измерения поведения кэша:

$ perf stat -e cache-references,cache-misses ./benchmark

Array traversal:
  423,156 cache-references
   89,234 cache-misses (21.1% miss rate)

Linked list traversal:
  1,247,832 cache-references
    892,441 cache-misses (71.5% miss rate)

У связанного списка в 3,4 раза больше промахов кэша. И поэтому он такой медленный.

Подведём итог

Рассказы о связанных списках из учебников расходятся с реальностью. Массивы побеждают связанные списки во всех бенчмарках. Такой разрыв в производительности объясняет частота промахов кэша: у связанного списка — 71,5%, у массива — 20,9%. Поведение кэша перевешивает алгоритмическую сложность.

История из учебника:

  • Связанные списки: вставки за O(1), гибкость, динамичность

  • Массивы: вставки за O(n), фиксированный размер, отсутствие гибкости

Реальность:

  • Связанные списки: медленные из-за промахов кэша, оверхед памяти, затраты на распределение

  • Массивы: быстрые, удобные для кэша, предсказуемые

Когда использовать связанные списки:

  1. Интрузивные списки в ядрах (встроенные узлы)

  2. Алгоритмы без блокировок (с пулами памяти)

  3. Маленькие списки (<100 элементов) с редкими вставками

  4. Когда вы провели бенчмарки и доказали, что они быстрее (но такое бывает редко!)

Когда использовать массивы:

  1. Почти всегда

  2. Серьёзно, просто используйте массивы

  3. Или динамические массивы, если нужно их увеличивать

  4. Я уже говорил о массивах?

Стратегии оптимизации (если вы вынуждены использовать связанные списки):

  1. Пулы памяти для распределения

  2. Развёрнутые списки для улучшения использования кэша

  3. Встроенные узлы, чтобы избежать отдельного распределения

  4. Списки должны быть маленькими

Встроенные системы:

  • Избегайте связанных списков из-за фрагментации, непредсказуемых таймингов и оверхеда памяти

  • Используйте массивы или пулы памяти

  • Профилируйте и измеряйте всё

Главный выводсвязанные списки — это goto структур данных; используйте их только в самых крайних случаях.

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


  1. wataru
    23.02.2026 10:50

    Что? Массив быстрее при вставках? Но они должны быть O(n) для массивов и O(1) для связанных списков!

    Вставки в конец! Вставляли бы там в середину, ситуация бы сильно поменялась. Потому что вставка в конец в массивах все та же O(1) за счет простого трюка: когда место в массиве кончается, его размер удваивается. Можно не обязательно в 2 раза увеличивать, можно и в 1.5 - это позвояет балансировать лишнее использование памяти или более хорошую константу в O(1). Чем больше множитель, тем меньше константа и тем больше расход памяти.

    Там у автора все сравнения идут реализаций с одинаковой ассимптотикой. Естественно, там кеш играет огромную роль.

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

    И там никакой кеш не покроет разницу между O(n) и O(1).

    Да, граница на n, начиная с которой ассимптотика выигрывает гораздо выше, чем можно было бы подумать. Десятки тысяч, наверное.

    Затраты на распределение

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


    1. unreal_undead2
      23.02.2026 10:50

      если у очень много элементов и идет постоянные вставки/удаления из середины. При этом операции идут по ключу, а не индексу

      А какое нибудь дерево тогда не лучше?


      1. wataru
        23.02.2026 10:50

        Нет, дерево тут не подходит. Если дерево по ключу, то что вообще значит "удалить из середины"? У объектов должен быть какой-то дополнительный порядок в задаче. Например, если это LRU кеш, то у нас есть ключи и "время доступа", ищем мы по ключу, а порядок поддерживаем по времени доступа.

        Дерево поиска по самим ключам вообще никак этот дополнительный порядок вообще не поддерживает.

        Если же делать дерево по неявному ключу, или по дополнительному ключу "порядок", то дерево тут строго хуже списка, ибо искать в нем предется по каким-то лишним с точки зрения ключа данным, во внешней структуре данных. Дерево тут оказывается лишь ненужным усложнением, ибо ничего кроме порядка, вы от дерева не используете. Список тоже поддерживает порядок, но на порядки проще в реализации.


        1. unreal_undead2
          23.02.2026 10:50

          Деревья умеют перебалансироваться при удалении произвольного элемента

          У объектов должен быть какой-то дополнительный порядок в задаче

          Это уже изменение задачи на ходу )


          1. wataru
            23.02.2026 10:50

            Деревья умеют перебалансироваться при удалении произвольного элемента

            При чем тут это?

            Это уже изменение задачи на ходу )

            В задаче сказано "вставка в середину". Середину чего? В задаче уже изначально есть какой-то порядок элементов. Но ничего не сказано, что они в массиве/списке упорядочены по возрастанию. Этот второй порядок есть. Ключи вообще могут не иметь порядка.


            1. unreal_undead2
              23.02.2026 10:50

              Ok, я "из середины" проинтерпретировал более широко как "случайный элемент". Если есть второй порядок - согласен, лучше бы его учесть в структуре данных.


    1. domix32
      23.02.2026 10:50

      Можно не обязательно в 2 раза увеличивать, можно и в 1.5 - это позвояет балансировать лишнее использование памяти или более хорошую константу в O(1). Чем больше множитель, тем меньше константа и тем больше расход памяти.

      Это неверено. В бинарном мире идеальная плотность информации обычно приближается к числу эйлера, то бишь 2.71... Поэтому в идеальном варианте фактор роста должен равняться этому числу. Если же вы начнёте уменьшать его, то получите больше реаллокаций, чем увеличите аммортизацию. Оно всё ещё будет амортизированно константным, но более худшим чем просто удвоение.


      1. wataru
        23.02.2026 10:50

        Вообще не согласен. Если каждый раз увеличивать в k раз, то при очередном увеличении при N элементах вы сделаете N + N/k + N/k^2 + N/k^3 + ... ~= Nk/(k-1) суммарных перемещений элементов. Вот при последнем увеличении вы переместили все N элементов. В прошлом увеличении было в k раз меньше элементов, вот и слагаемое N/k. И так далее.

        Чем больше k, тем меньше это общее число перемешений (оно стремится к N). При этом вы тратите в k раз больше памяти в худшем случае.

        Плотность информации тут вообще не при чем.


        1. domix32
          23.02.2026 10:50

          Не забывайте, что уменьшение k приводит к большему количеству слагаемых при равных же N, что фактически отражается в финальной константе и при k < 2 "балансировка лишнего использования памяти" наоборот ухудшится вместе с ней.


          1. wataru
            23.02.2026 10:50

            уменьшение k приводит к большему количеству слагаемых при равных же N

            Вы с чем тут спорите? Да, именно поэтому при уменьшении K получается больше копирований. Ровно как я и говорил. Ровно как формула kN/(k-1) и показывает: чем больше k, тем множитель ближе к 1, тем меньше сумма. И наоборот - чем меньше k, тем больше сумма.

            что фактически отражается в финальной константе и при k < 2 "балансировка лишнего использования памяти" наоборот ухудшится вместе с ней.

            Вы это GPT-чатиком сгенерировали? Я вообще тут не вижу никакой логики и связи с прошлым текстом.

            Лишнее использование памяти, потому что вам надо место под N+1 элемент, а вы выделяете kN - примерно в k раз больше. Чем больше k, тем больше лишней памяти у вас будет.

            Возвращаясь к сказанному выше: чем меньше k, тем меньше копирований. Т.е. увеличивая k вы уменьшаете потребление памяти и увеличиваете количество копирований. Вот та самая балансировка - вы можете выбирать, что вам важнее и, крутя k, разменивать лишнюю память на скорость.


            1. domix32
              23.02.2026 10:50

              см табличку ниже.

              чем меньше k, тем меньше копирований.

              собственнно с этим и спорю. в реалистичных сценариях этого примерно никогда не случится. статистика по копированиям будет расти заметно быстрее. Смотрел на оценки N до миллиона элементов. Может где-то выше и найдётся sweet spot, но я до него так и не добрался.


              1. wataru
                23.02.2026 10:50

                Блин, запутался и опечатался. В начале правильно же написано:

                при уменьшении K получается больше копирований. Ровно как я и говорил.

                Именно так было и в начальном комментарии, на который вы стали спорить:

                Чем больше множитель, тем меньше константа

                Зафиксируем, что тут мы согласны.


          1. domix32
            23.02.2026 10:50

            коротенькая статистика для 100 элеменотов для k=1.5 и k=2

            | k   | Реаллокации | Копирований | Вместимость | Небаланс памяти|
            |-----|-------------|-------------|-------------|----------------|
            | 1.5 | 11          | 272         | 140         | 28.57%         |
            | 2.0 | 7           | 127         | 128         | 21.875%        |

            В итоге меньший k привел почти к вдвое большему количеству обращений к памяти. Да и "сэкономленной" памяти так и не дождались. С ростом N статистика не сказать чтобы хоть как-то принципиально улучшается. Так что если это не какие-то конкретные данные подходящие под асимптотику шансов наэкономить не много.


            1. wataru
              23.02.2026 10:50

              Что у вас там за "небаланс памяти" и как оно считается?


              1. domix32
                23.02.2026 10:50

                Соотношение неиспользованной памяти в массиве к выделенному на финальном шаге. 40/140 и 28/128 соотвественно. Думал что когда речь шла о "балансе" памяти оно где-то около этих значений скакало.

                В естественном виде ни одна из колонок не будет давать преимущества над стандартным k=2. Если где-то перед этим был сделан reserve, то в такой ситуации ещё можно что-то пошаманить и получить профит. В общем виде - нет.


                1. wataru
                  23.02.2026 10:50

                  А вот теперь расмотрите не 100 элементов, а 140. У вас в одном случае будет вообще 0%. А теперь возьмите 128, и у вас в другом будет 0%.

                  Нельзя смотреть на одно конкретное N, Вам надо считать среднее по всем N. Для больших N можно вывести формулу average wasted space = 1/2-1/2k. Чем больше k, тем больше лишнего места. Не бесконечно, стримиться к 1/2, да, но все-же растет.


                  1. domix32
                    23.02.2026 10:50

                    Собственно, сопоставьте aws c количеством копирований и получится, что для k=1.5 цифры примерно в два-три раза хуже стандарта, то есть если это примерный фактор замедления алгоритма и увеличения размера memory footprint. Сдаётся мне, что некоторый константный прирост памяти даст заметно более приятную асимптотику, если уж очень хочется поменьше памяти использовать.


                    1. wataru
                      23.02.2026 10:50

                      Нет. Для 1.5 там больше копирований (Почти в 2 раза), но меньше неиспользованной памяти (почти в 2 раза):

                      2.0   1048575      40.39%
                      1.5   2099719      22.60%
                      Код
                      import math
                      
                      def Test(k):
                          n = 1000000
                          total_moves = 0
                          total_memory = 0
                          capacity = 1
                          for i in range(1, n+1):
                              if i > capacity:
                                  total_moves += capacity
                                  capacity = math.ceil(capacity * k)
                              total_memory += (capacity - i)/i
                          total_memory /= n
                          total_memory *= 100
                          print(f"{k:10}{total_moves:10} {total_memory:10.2f}%")
                          
                          
                      Test(2.0)
                      Test(1.5)


  1. Artaus47
    23.02.2026 10:50

    Спасибо, было интересно


  1. Dhwtj
    23.02.2026 10:50

    Удивлённо тру глаза и кручу текст вверх и вниз много раз.

    Где код того что мы измеряем? Где вставка элемента в конец списка? Даже наивный realloc оптимизирован так что это не копирование массива каждый раз.

    Для C/C++ и Rust (unmanaged куча) аллокатор расширит блок in-place, если за ним есть свободная память, а для больших кусков ОС перемапит виртуальные страницы без физического копирования. Для C# несколько по другому, но мы сейчас не про него.


    1. Dhwtj
      23.02.2026 10:50

      Добавлю ещё частый случай. Строка.

      В C# (и Python) string неизменяем. Любое добавление (s += "text") это всегда выделение новой памяти под всю длину и полное копирование обеих частей. В цикле это даст O(N^2).

      Для частых добавлений используют StringBuilder. В современном .NET это не единый удваивающийся массив (как List), а связный список чанков (массивов). Когда чанк заполняется, выделяется новый. Это сделано, чтобы большие строки не требовали огромных непрерывных кусков памяти и не забивали LOH (Large Object Heap).

      В Rust иначе: String мутабелен. Под капотом это вектор Vec, он работает по правилам Capacity и realloc, которые я написал выше.

      В C++ std::string изменяемый, как и в Rust. Под капотом работает аналогично вектору: удваивает capacity при нехватке места.

      Ключевая архитектурная фишка: SSO (Small String Optimization). Короткие строки (обычно до 15-22 символов) вообще не выделяют память в куче. Данные лежат прямо внутри самого объекта на стеке.

      Почему мутабелен: в C++ по умолчанию семантика значений (value semantics). При передаче куда-либо строка полностью копируется.
      Если вы передаете ее по ссылке для скорости, компилятор не защитит вас от проблем с конкурентным доступом или инвалидацией ключей словаря (в отличие от Rust). Вся ответственность за использование const std::string& и синхронизацию висит на программисте.

      Кажется, я написал комментарий лучше чем статья


      1. Astrowalk
        23.02.2026 10:50

        И мимоходом поправили перевод: действительно, в русском языке более принят термин "связные списки". Связанные – это что-то из BDSM :).

        Хотел отправить это сообщение автору приватно, чтоб не засорять информационное пространство, но оказывается, что для этого требуется пройти капчу, что для такого ничтожного повода "ту мач" :).


  1. FD4A
    23.02.2026 10:50

    Если максимальное число элементов влезает во что-то меньшее указателя, можно использовать индекс в пуле (по сути массиве), вместо указателей. Например два указателя по 8 байт или два индекса по 2 байта, если число элементов меньше 2^16-1.


  1. Siemargl
    23.02.2026 10:50

    Такое впечатление, что автор - новичок в программировании.

    И переводчик только зря тратит байты на перевод "откровений"

    Что там у него написано в профиле на Линкедин - 20 лет или больше?


  1. ahabreader
    23.02.2026 10:50

    С XOR ведь проблема, что с точки зрения стандарта оно не обязано работать:

    2.3.5 Q11. Is the XOR linked list idiom supported?
    Our reading of C11 and proposal for C2x: C11: the text does not clearly specify. C2x proposal: no. C2x proposal (no-provenance option): yes
    N2263 (2018)

    Хоть вроде передумали и планируют дать гарантии:

    So the overall conclusion was not to ban such usage of pointers through integers.
    https://gustedt.wordpress.com/2025/06/30/the-provenance-memory-model-for-c/

    Если вспомнить отношение комитета к оптимизациям и alias analysis - это была не пустая угроза. В предыдущий раз его увлечение оптимизациями здесь привело к расхождению со стандартом операционных систем и браузеров (в C89 появился strict aliasing, который в рамках стандарта решили сделать неотключаемым; против стандарта идти несложно (-fno-strict-aliasing), но до этого не надо было доводить).


  1. tamgasoft
    23.02.2026 10:50

    В выводах не не затронут размер элементов списка. Если один элемент занимает 100 кб (json какой нибудь) то размер массива будет играть сущесвенный роль. Например мы заране не знаем сколько элементов будет? Тогда для массива будем каждый раз увеличивать размер массива (при этом создавать новый под размер массив, копировать туда старую и удалять его) это тоже время и тормоза неожиданные. А при использование связанного списка кол-во элементов списка без проблем можем постепенно увеличивать сколько можно не создавая массив в памяти сразу.


  1. SIISII
    23.02.2026 10:50

    Замечу, что на микроконтроллерах -- в частности, на упомянутом ядре Cortex-M4 -- обычно нет проблем с промахами мимо кэша за отсутствием самого кэша. Он имеется у наиболее мощных микроконтроллеров (например, обычно присутствует у Cortex-M7), но у более слабых его наличие -- редкость.

    Ну а само наличие (или отсутствие) кэшей и их влияние на производительность учитывать точно стоит. В частности, именно из-за них неверно игнорировать размеры программ и данных по принципу "у современных ПК 100500 Гбайт ОЗУ, так что будем отводить под каждый bool не 1 бит, а 4 байта".