Хранение элементов в массиве в Python: сравнение list, array и NumPy для больших данных | AdminWiki

Хранение элементов в массиве в Python: сравнение list, array и NumPy для больших данных

22 сентября 2026 13 мин. чтения

Какую структуру данных выбрать в Python: краткий ответ

Для больших однотипных числовых наборов берите NumPy: он держит сырые значения в непрерывном буфере и выполняет операцию над всем массивом на C, без разбора байткода на каждом шаге. Когда нужна компактность без сторонних пакетов, а данные — однотипные числа, подходит array из стандартной библиотеки. Разнородные значения, строки логов, конфиги и небольшие объёмы остаются за list.

Ориентировочные цифры для 10 млн целых: list занимает порядка 360 МБ, array('i') и NumPy int32 — около 40 МБ, NumPy int64 — около 80 МБ. Поэлементное умножение тех же данных через цикл или list comprehension идёт сотни миллисекунд, векторный вариант NumPy — заметно быстрее. Точный разрыв зависит от версии Python, версии NumPy, CPU и поддержки SIMD, поэтому проверяйте его на своём окружении.

Таблица ниже даёт быстрый ориентир по четырём критериям.

КритерийlistarrayNumPy (ndarray)
Допустимые типылюбые объекты Python, смешение типов в одном спискетолько числовые typecode: 'b', 'i', 'q', 'f', 'd' и другиефиксированный dtype: целые, float, bool, complex, object
Память на элемент8 байт указателя плюс 24–28 байт объекта1, 2, 4 или 8 байт по typecode1, 2, 4 или 8 байт по dtype
Многомерностьвложенные списки, без векторизациинетесть, плюс broadcasting
Скорость массовых операцийцикл Python, интерпретация на каждом шагецикл Python, выигрыш только по памятивекторный код на C, кратный выигрыш
Зависимостивстроеновстроенонужна установка пакета numpy

Три структуры — три сценария

list в CPython — динамический массив указателей PyObject*. Сам блок ссылок лежит непрерывно, а объекты, на которые он указывает, разбросаны по куче. Отсюда два следствия: каждый проход перебирает ссылки с разыменованием, кэш процессора работает хуже, чем на плотном буфере, а память тратится на оба уровня. Как устроен непрерывный блок и адресная арифметика, разобрано в материале о раскладке массивов в памяти.

array из стандартной библиотеки хранит значения одного числового типа в непрерывном буфере, как C-массив. Многомерности и векторизации нет: поэлементная операция требует цикла на Python. Взамен — предсказуемый размер и полное отсутствие зависимостей.

NumPy хранит ndarray как непрерывный буфер плюс заголовок с dtype, shape и strides. Операции уходят в циклы на C, поэтому data * 2 или data.sum() работают над всем массивом целиком.

Когда list избыточен

Если элементы однотипные и их больше сотни тысяч, посчитайте память до запуска задачи. Для 10 млн целых list даёт порядка 360 МБ против 40 МБ у array('i') и NumPy int32, то есть разницу примерно в девять раз. На мелких объёмах этой разницы не видно, и list удобнее. Характерный пример: список из 10 случайных чисел с подсчётом чётных элементов циклом (разбор такой задачи) — здесь list полностью уместен, а накладные расходы на NumPy превысили бы выигрыш.

Массивы в Python для больших данных почти всегда означают NumPy или array, и переход стоит делать именно на этом пороге: сотни тысяч однотипных чисел и выше.

Допустимые типы данных: list, array и NumPy

list: любые объекты, но с ценой

list принимает что угодно: целые, строки, None, вложенные списки, словари, функции. Пример смешанного списка: [1, 'log', None, 3.14, [2, 3]]. Плата за гибкость в том, что каждый элемент — отдельный объект в куче со своим заголовком, а в списке лежит только ссылка на него. Для int в CPython объект занимает около 28 байт (по результату sys.getsizeof(int())), для float около 24 байт, ссылка — 8 байт на 64-битной платформе. Плюс сам список держит буфер с запасом под будущие вставки, поэтому фактический размер больше суммы ссылок.

array: только числа и typecode

Модуль array принимает единственный числовой typecode, заданный при создании: 'b' и 'B' для знаковых и беззнаковых байтов, 'h' и 'H' для short, 'i' и 'I' для int, 'l' и 'L' для long, 'q' и 'Q' для long long, 'f' и 'd' для float и double. Создание выглядит так: array('i', [1, 2, 3]) или array('d', [1.0, 2.5]). Строка внутри вызовет TypeError, а значение вне диапазона typecode, например array('b', [200]), даст OverflowError. Многомерность не поддерживается, векторизации нет, срез возвращает новый array с копией данных.

NumPy: dtype и многомерность

dtype фиксируется на весь массив: np.array([1, 2, 3], dtype=np.int32) и np.array([1.0, 2.0], dtype=np.float64). Доступны int8, int16, int32, int64 и беззнаковые варианты, float16, float32, float64, bool, complex64, complex128, а также object. Форма задаётся через shape: np.zeros((1000, 1000), dtype=np.float32) занимает 4 МБ под данные. Порядок обхода, C или Fortran, влияет на скорость циклов, детали в статье о row-major и column-major.

dtype=object хранит ссылки на объекты Python и сохраняет гибкость, но теряет плотность буфера и векторизацию: операции идут через интерпретатор, как в обычном list. Для разнородных данных NumPy с object не даёт ни экономии памяти, ни ускорения.

Потребление оперативной памяти: list против array и NumPy

Каждый элемент в непрерывном буфере лежит по адресу base + i * size, поэтому объём массива считается как число элементов, умноженное на размер одного значения. У list схема другая: непрерывный только блок ссылок, подробнее в разборе модели памяти и вычисления адреса по индексу.

Сколько байт на элемент в каждой структуре

СтруктураБайт на элементЧто именно хранится
list с int8 + около 28указатель плюс объект int в куче, итого около 36 байт
list с float8 + около 24указатель плюс объект float в куче
array('b'), array('i'), array('d')1, 4, 8сырое значение без объекта Python
NumPy int8, int16, int32, int641, 2, 4, 8сырое значение, один буфер на весь массив
NumPy float32, float644, 8сырое значение, частый выбор для метрик

Заголовок контейнера на 64-битной сборке CPython занимает десятки байт: у array и ndarray это тоже десятки байт. На миллионах элементов этим можно пренебречь, на десятках элементов — нельзя. Отдельно стоит помнить про запас ёмкости списка: CPython увеличивает буфер с избытком пропорционально размеру списка (mild over-allocation), чтобы обеспечить амортизированное линейное время для последовательности append(); шаблон роста: 0, 4, 8, 16, 24, 32, 40, 52, 64, 76, ... Поэтому фактическая память под большой list выше суммы ссылок, а при уменьшении размера, если новый размер опускается ниже половины выделенного, выполняется realloc() для сжатия списка. Точный процент запаса зависит от истории вставок и удалений, поэтому проверяйте его на своём сценарии.

Практический расчёт для больших данных

Структура для 10 млн целыхОриентировочный объёмКомментарий
listоколо 360 МБ80 МБ ссылок плюс около 280 МБ объектов int
array('i')около 40 МБ4 байта на значение
NumPy int32около 40 МБ4 байта на значение
NumPy int64около 80 МБ8 байт на значение; numpy.int_ совместим с C long, на Linux x86_64 алиасится на int64
NumPy int8около 10 МБтолько если диапазон значений доказанно укладывается в -128..127

Проверять объём в рантайме удобно тремя вызовами: sys.getsizeof(lst) показывает сам список без объектов, на которые он ссылается, sys.getsizeof(arr) для array даёт буфер плюс заголовок, а у ndarray атрибут nbytes возвращает чистый объём данных. Как отделить расчётный объём от фактического и найти раздутые буферы, разобрано в статье об измерении памяти под массивы.

Отдельная ловушка — конвертация. Вызов np.array(lst) для 10 млн целых выделяет новый буфер до 40 МБ, пока исходный list на 360 МБ ещё жив, поэтому пиковая память процесса складывается из двух структур.

Цифры в таблицах выше — оценки для 64-битной сборки CPython со стандартным аллокатором. Фактический объём зависит от версии Python, платформы, фрагментации кучи и настроек аллокатора, поэтому перед переносом продакшн-скрипта проверяйте значения на своём окружении через sys.getsizeof, атрибут nbytes и профилировщик памяти.

Скорость выполнения операций: где NumPy даёт выигрыш

Векторизация против циклов Python

Цикл for в CPython на каждой итерации разбирает байткод, проверяет типы и создаёт новые объекты. NumPy выносит цикл в C и работает с сырыми числами: истинная векторизация встроенными операциями выполняется в оптимизированном C/Fortran коде и обрабатывает массивы целиком. Поэтому выражение data * 2 на большом массиве обгоняет [x * 2 for x in data] кратно, без ручного распараллеливания.

Важная оговорка: np.vectorize — не истинная векторизация. Это удобная обёртка, которая итерирует по элементам массива в Python и применяет функцию по одному элементу, наследуя накладные расходы Python-цикла; для пользовательских функций она часто оказывается медленнее чистых Python-циклов. Ускорение дают именно встроенные операции NumPy, а не обёртка с похожим названием.

Для калибровки порядка величин полезен микробенчмарк из документации: timeit для операции «5 * x» над numpy.arange(1000) даёт около 16.6 usec на цикл (100000 loops, best of 3). Это не прямое сравнение с list на 10 млн элементов, но показывает, что векторная арифметика измеряется микросекундами на тысячах элементов.

Операция на 10 млн целыхlist (цикл Python)NumPy
Поэлементное умножение на 2сотни миллисекунд и большедесятки миллисекунд и меньше
Сумма элементовдесятки-сотни миллисекундединицы-десятки миллисекунд

Значения ориентировочные: они зависят от версии Python, версии NumPy, тактовой частоты CPU и поддержки SIMD. Для честного сравнения на своей машине запускайте один и тот же код через timeit на одинаковых данных. array из стандартной библиотеки экономит память, но векторизации не даёт, и поэлементные операции в нём всё равно требуют цикла Python, поэтому кратного ускорения от перехода с list на array ждать не стоит.

Когда разница несущественна

До примерно 10 000 элементов разница между list и NumPy теряется на фоне накладных расходов: импорт numpy занимает заметное время при старте скрипта, а конвертация списка в массив проходит все элементы по одному. Если данные читаются один раз, обрабатываются одной операцией и результат сразу уходит в файл или JSON, переписывание на NumPy не окупается. Оптимизацию стоит начинать, когда объём данных или частота операций делают накладные расходы незаметными на фоне полезной работы.

Практические примеры: создание, индексация и изменение

Создание и индексация

list: lst = [1, 2, 3], доступ lst[0] возвращает 1, срез lst[1:3] возвращает новый список [2, 3].

array: from array import array, a = array('i', [1, 2, 3]), доступ a[0] даёт 1, срез a[1:3] возвращает новый array с копией значений.

NumPy: import numpy as np, arr = np.array([1, 2, 3], dtype=np.int32), доступ arr[0] возвращает скаляр np.int32, а срез arr[1:3] отдаёт представление на тот же буфер.

Разница в срезах критична при работе с большими данными: list и array копируют значения, NumPy делит буфер с исходным массивом. Проверить связь можно функцией np.shares_memory(arr, arr[1:3]). Механика представлений, алиасинга и времени жизни буфера разобрана в статье о срезах и view без копирования данных.

Изменение и добавление элементов

list: lst[0] = 10, lst.append(4), lst.extend([5, 6]), lst.insert(0, 0). Все операции меняют исходный объект, память под новые элементы выделяется с запасом.

array: a[0] = 10, a.append(4), a.extend([5, 6]), a.insert(0, 0). Присваивание проходит ту же проверку диапазона, что и создание: значение вне границ typecode вызовет OverflowError.

NumPy: arr[0] = 10 меняет значение в буфере. Добавление работает иначе: np.append(arr, 4) создаёт новый массив и не меняет исходный. Вызов np.append в цикле даёт квадратичную сложность, потому что каждый шаг копирует весь массив. Правильные подходы: заранее выделить np.empty(n) и заполнять по индексу либо собрать значения в list и один раз выполнить np.array(data, dtype=np.int32).

Типичные ошибки при работе с массивами в Python

Переполнение типов

Узкий dtype ломает значения. В NumPy 2.0 преобразование целого Python, выходящего за границы dtype, приводит к OverflowError: например, деление np.uint8 на Python int 256 даёт «OverflowError: Python integer 256 out of bounds for uint8», а преобразование Python integer 128 в int8 — «OverflowError: Python integer 128 out of bounds for int8». В предыдущих версиях такой ошибки не было: release notes 1.24.0 указывают, что конвертация Python integer в NumPy value теперь всегда проверяет представимость результата, а release notes 2.0 говорят о «conversion of out of bounds python integers to integer arrays». Проверьте поведение на своей версии перед переносом кода. В стандартной библиотеке array('b', [200]) всегда завершается OverflowError, скрытого искажения не будет.

Вторая ловушка — арифметика. Умножение двух массивов int8 даёт int8, и результат может обернуться без предупреждения. Если значения могут выйти за границы, приводите данные к int64 через astype или сразу выбирайте широкий dtype. Правило для новых задач: int64 по умолчанию, int32 для идентификаторов и счётчиков, узкие типы только там, где диапазон доказан.

Views и копии в NumPy

Базовый срез возвращает view: после b = arr[1:3] и b[0] = 99 изменится и arr. Fancy indexing вида arr[[0, 2]] и выборка по булевой маске возвращают копию. Отсюда два класса ошибок: случайная порча исходных данных через view и лишние копии больших массивов там, где хватило бы представления. Для независимой копии вызывайте .copy(), для проверки связи — np.shares_memory.

Ещё три ошибки, которые дорого стоят на больших объёмах:

  • Хранить разнородные данные в NumPy через dtype=object и ждать ускорения. Такой массив ведёт себя как list и по памяти, и по скорости.
  • Набирать массив через np.append или np.concatenate в цикле вместо предварительного выделения буфера.
  • Держать миллионы однотипных чисел в list и не замечать рост RSS процесса. Проверяйте объём до инцидента: sys.getsizeof для контейнера, nbytes для ndarray.

Какую структуру выбрать в DevOps-задачах

Парсинг логов и хранение строк: list, потому что строки разнородны по длине и требуют объектов Python. Хранение метрик и временных рядов на миллионы точек: NumPy, а для сложных преобразований — pandas поверх него. Компактное хранение однотипных чисел в скрипте на сервере без внешних пакетов: array. Обработка конфигов YAML и JSON: dict и list. Агрегации в ETL: NumPy, если данные уже в числовом виде, иначе этап разбора на list с последующей конвертацией. Для потоковой обработки больших файлов применяйте генераторы и обработку порциями: держать весь набор в памяти не нужно, если его можно читать блоками.

Чек-лист выбора структуры

  1. Данные разнородные, есть строки или None? Берите list.
  2. Однотипные числа, объём до сотен тысяч, NumPy недоступен по политике окружения? Берите array.
  3. Однотипные числа, объём большой, нужны вычисления или агрегации? Берите NumPy.
  4. Нужна многомерность, broadcasting или матричные операции? Только NumPy.
  5. Критична память, но вычислений мало? array или NumPy с узким dtype и проверкой диапазонов.
  6. Данные приходят потоком и целиком не нужны? Генератор вместо любой из трёх структур.

Когда NumPy не нужен

NumPy добавляет зависимость в образ и в требования к развёртыванию: пакет ставится через pip, занимает десятки мегабайт и требует совместимой сборки под платформу. Для скрипта на 500 строк, который раз в сутки разбирает файл на несколько тысяч записей, выигрыша не будет, а усложнение окружения и времени сборки образа останется. Если данных мало, операции редкие, а разнородность высокая, list закрывает задачу полностью.

Итог: ориентиры для выбора структуры

КритерийlistarrayNumPy
Типы данныхлюбые объекты Pythonчисловые typecodeфиксированный dtype
Память на одно целоеоколо 36 байт4 байта при typecode 'i'4 байта при int32
Скорость массовых операцийбазовая, цикл Pythonкак у list, выигрыш только по памятикратно выше на больших массивах
Многомерностьвложенные спискинетесть, плюс broadcasting
Зависимостинетнетпакет numpy

Ключевые цифры для быстрой оценки: около 36 байт на целое в list против 4 байт в array('i') и NumPy int32, разница по памяти примерно в девять раз на 10 млн значений, разница по скорости массовых операций — до двух порядков на подходящих операциях. Перед переносом рабочего скрипта посчитайте ожидаемый объём умножением числа элементов на байты из таблицы, сравните с доступной памятью хоста или лимитом контейнера и только после этого меняйте структуру. Если расчёт укладывается в лимит с запасом и операций мало, оставьте list: простота чтения кода тоже стоит времени команды.

Материалы по теме

Поделиться:
Сохранить гайд? В закладки браузера