У вас есть список оценок студентов: 5, 3, 4, 5, 2. Нужно добавить ещё одну оценку в конец. Вы храните их в обычном массиве int[].

Задача: попробуйте написать код, который добавляет элемент в массив. Что не так с таким подходом?

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

Помните задачу из разведки? У вас есть массив оценок студента: 5, 3, 4, 5, 2. Нужно добавить ещё одну оценку в конец. Казалось бы — что тут сложного? Но если вы работаете с обычным массивом int[], код получается таким:

int[] grades = { 5, 3, 4, 5, 2 };

// Хотим добавить оценку 4 в конец — но массив уже заполнен!
int[] newGrades = new int[grades.Length + 1];

for (int i = 0; i < grades.Length; i++)
{
    newGrades[i] = grades[i];
}

newGrades[newGrades.Length - 1] = 4;
grades = newGrades;

Шесть строк ради одной оценки. Создаём новый массив побольше, копируем все старые элементы, вставляем новый. А если оценок не 5, а 500? Каждое добавление — полное копирование массива. Это медленно, громоздко и скучно. А что если нужно добавить 100 оценок подряд? Сто раз создаём новый массив и копируем всё содержимое — сначала 5 элементов, потом 6, потом 7... В итоге 5 + 6 + 7 + ... + 104 = больше 5000 операций копирования. Для сотни вставок. Именно эту проблему решают динамические массивы — List<T> в C#. Но прежде чем к ним перейти, разберёмся, как вообще устроен массив изнутри.

Представьте спортивную раздевалку. Ряд пронумерованных шкафчиков: 0, 1, 2, 3, 4. Все одного размера, стоят вплотную друг к другу. Вы знаете номер шкафчика — подходите и открываете его за секунду. Не нужно проверять все подряд: третий шкафчик — это третий шкафчик, он всегда на одном месте. Не нужно спрашивать у охранника, не нужно обходить все предыдущие. Знаете номер — знаете, где искать.

Но есть ограничение: шкафчиков ровно столько, сколько установили при строительстве. Хотите ещё один? Придётся сносить весь ряд и строить заново — длиннее.

Массив в памяти компьютера работает точно так же.

Три ключевых свойства в этом определении. Фиксированное количество — размер задаётся один раз при создании и больше не меняется. Одного типа — нельзя смешать целые числа и строки в одном массиве (в C# точно нельзя). Непрерывный блок памяти — элементы лежат один за другим, без промежутков, без пропусков.

Именно непрерывность даёт массиву его суперспособность. Компьютер знает адрес первого элемента и размер каждого. Чтобы найти элемент с индексом i, он делает простую арифметику:

адрес_элемента = начальный_адрес + i * размер_элемента

Никакого перебора, никакого поиска — одно умножение и одно сложение. Именно поэтому доступ к элементу по индексу занимает O(1) — константное время, независимо от размера массива. Неважно, 10 элементов или 10 миллионов — третий элемент находится мгновенно. Это как найти дом по адресу на карте: вы не обходите все дома на улице, а сразу идёте к нужному номеру.

Массив int[] grades в памяти (каждая ячейка = 4 байта) 5 3 4 5 2 [0] [1] [2] [3] [4] 0x100 0x104 0x108 0x10C 0x110

На схеме видно: каждый int занимает 4 байта, ячейки идут подряд. Чтобы найти grades[3], компьютер берёт адрес начала 0x100, прибавляет 3 * 4 = 12 и попадает ровно в 0x10C. Никуда ходить не надо — чистая математика. В C# статический массив создаётся через оператор new с указанием размера. Вот два способа:

// Способ 1: создаём пустой массив, потом заполняем
int[] scores = new int[5];
scores[0] = 95;
scores[1] = 82;
scores[2] = 74;
scores[3] = 91;
scores[4] = 88;

// Способ 2: сразу с инициализацией
int[] scores = { 95, 82, 74, 91, 88 };

В первом случае массив создаётся на 5 ячеек, каждая по умолчанию равна 0 (для int). Потом мы вручную заполняем каждую. Второй способ — короче и удобнее, когда значения известны заранее. Компилятор сам определяет размер массива по количеству элементов в фигурных скобках.

Работать с элементами просто — обращаемся по индексу в квадратных скобках:

int[] scores = { 95, 82, 74, 91, 88 };

Console.WriteLine(scores[0]);       // 95 — первый элемент
Console.WriteLine(scores[4]);       // 88 — последний
Console.WriteLine(scores.Length);   // 5 — количество элементов

// Изменение элемента
scores[2] = 100;
Console.WriteLine(scores[2]);       // 100

Свойство Length возвращает количество элементов. Обратите внимание: индексы начинаются с 0, поэтому в массиве из 5 элементов последний — scores[4], а не scores[5]. Это не причуда C# — так устроено во всех языках, унаследовавших индексацию от C. Причина как раз в формуле адреса: первый элемент смещён на 0 позиций от начала, второй — на 1, и так далее.

А что будет, если попросить scores[5]?

Console.WriteLine(scores[5]);  // IndexOutOfRangeException!

Бум! Программа падает с ошибкой IndexOutOfRangeException. C# строго проверяет границы массива — выйти за пределы не даст. И это хорошо: лучше упасть сразу и увидеть ошибку, чем прочитать мусор из чужой памяти и потом часами искать, откуда в программе взялось число 847291638 (привет, C и C++, где такое запросто).

Теперь про сложность операций. Доступ по индексу — O(1), это мы уже разобрали. А что с остальным?

Поиск элемента. Если нужно выяснить, есть ли в массиве оценка 74, придётся пройтись по всем элементам. Массив не отсортирован, данные никак не упорядочены — нет способа угадать, где лежит нужное значение:

int[] scores = { 95, 82, 74, 91, 88 };

bool found = false;
for (int i = 0; i < scores.Length; i++)
{
    if (scores[i] == 74)
    {
        found = true;
        break;  // Нашли — можно не продолжать
    }
}
// В лучшем случае — первый элемент, в худшем — последний

В лучшем случае нужный элемент окажется первым — повезло. В худшем — последним или его вообще нет, и мы проверим все n элементов. Средний случай — n/2 проверок. Всё это O(n).

Вставка в середину — тоже O(n). Допустим, нужно вставить оценку 77 на позицию 2. Все элементы правее должны сдвинуться на одну позицию вправо, чтобы освободить место. А если массив заполнен — сначала нужно создать новый побольше, скопировать элементы, и уже потом сдвигать.

Удаление — зеркальная ситуация. Удалили элемент из середины — образовалась «дыра». Все элементы правее нужно сдвинуть влево, чтобы её закрыть. Снова O(n).

Итого для статического массива:

// Сложность операций статического массива
// Доступ по индексу:   O(1)  — мгновенно
// Изменение элемента:  O(1)  — мгновенно (знаем адрес)
// Поиск элемента:      O(n)  — линейный перебор
// Вставка:             O(n)  — сдвиг + возможно копирование
// Удаление:            O(n)  — сдвиг элементов

Видите картину? Доступ — шикарный, всё остальное — медленное. Для задач, где размер коллекции заранее известен и не меняется, это идеально. Дни недели — их ровно 7, и это не изменится. Месяцы — 12. RGB-каналы пикселя — 3. Но если данные приходят постепенно (оценки студентов, заказы в магазине, логи сервера, сообщения в чате), фиксированный массив становится обузой. Каждое добавление — создание нового массива и полное копирование. И вот тут появляется идея, простая и элегантная: а что если выделять массив с запасом?

Допустим, нам нужно хранить 3 элемента. Вместо массива на 3 ячейки выделяем на 4. Добавляем четвёртый — массив заполнился. Что делаем? Создаём новый массив вдвое больше (на 8 ячеек), копируем старые элементы и продолжаем работать. Следующее расширение будет на 16, потом на 32, и так далее. Каждый раз удваиваем.

Да, одно конкретное добавление, которое вызывает расширение, стоит O(n) — нужно скопировать все элементы в новый массив. Но вот в чём фокус: расширение происходит всё реже и реже. После удвоения до 8 ячеек — следующие 4 добавления пройдут мгновенно. После удвоения до 16 — следующие 8 добавлений бесплатны. После удвоения до 1024 — целых 512 добавлений без копирования.

Если посчитать среднюю стоимость добавления на длинной дистанции, получается амортизированное O(1) — как будто каждая вставка стоит одинаково мало.

Представьте, что вы кладёте монетки в копилку. 99 раз просто бросаете монетку — мгновенно. На сотый раз копилка полная — вы пересыпаете всё в банку побольше. Долго? Да. Но если разделить общее время на 100 операций, каждая «стоила» почти ничего. Амортизация — это усреднение: редкие дорогие операции «размазываются» по множеству дешёвых.

В C# динамический массив — это List<T>, где T — тип элементов. Вот как решается задача из разведки:

List<int> grades = new List<int> { 5, 3, 4, 5, 2 };

grades.Add(4);  // Готово. Одна строка.

Console.WriteLine(grades.Count);  // 6
Console.WriteLine(grades[5]);     // 4

Сравните с шестью строками копирования из начала урока. List<T> сам заботится о расширении — вы просто вызываете Add и не думаете о размерах. Внутри List<T> лежит обычный массив, но список управляет им за вас: следит за заполненностью, расширяет когда нужно, копирует данные.

Вот основные операции List<T> на примере списка игроков:

List<string> playerNames = new List<string>();

// Добавление в конец — амортизированное O(1)
playerNames.Add("Алиса");
playerNames.Add("Борис");
playerNames.Add("Вера");

// Доступ по индексу — O(1), как у обычного массива
Console.WriteLine(playerNames[1]);  // Борис

// Количество элементов
Console.WriteLine(playerNames.Count);  // 3

// Вставка по индексу — O(n), элементы правее сдвигаются
playerNames.Insert(1, "Глеб");
// Теперь: Алиса, Глеб, Борис, Вера

// Удаление — O(n), элементы сдвигаются
playerNames.Remove("Борис");
// Теперь: Алиса, Глеб, Вера

// Проверка наличия — O(n)
bool hasVera = playerNames.Contains("Вера");  // true

Обратите внимание на важное отличие: у массива — свойство Length, у списка — Count. Не путайте, это частый источник ошибок у новичков.

А ещё у List<T> есть свойство Capacity — сколько ячеек выделено «под капотом» внутреннего массива. Count — сколько элементов реально хранится. Count всегда меньше или равен Capacity. Посмотрим, как они меняются:

List<int> numbers = new List<int>();
Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");
// Count: 0, Capacity: 0

numbers.Add(1);
Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");
// Count: 1, Capacity: 4  (начальная ёмкость)

numbers.Add(2);
numbers.Add(3);
numbers.Add(4);
Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");
// Count: 4, Capacity: 4  (заполнено под завязку)

numbers.Add(5);  // Пятый элемент — расширение!
Console.WriteLine($"Count: {numbers.Count}, Capacity: {numbers.Capacity}");
// Count: 5, Capacity: 8  (ёмкость удвоилась)

Видите? Первое добавление сразу выделило массив на 4 ячейки. Четыре элемента помещаются свободно. Но пятый уже не влезает — List<T> создаёт новый массив на 8 ячеек, копирует туда первые четыре и добавляет пятый. Следующее расширение будет на 16, потом 32, и так далее. Всё прозрачно — вы этого не видите в обычном коде, но понимать полезно, особенно когда работаете с большими объёмами данных.

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

// Ожидаем около 1000 заказов — сразу выделяем место
List<double> orderPrices = new List<double>(1000);

Это не ограничивает список — он всё равно расширится, если элементов окажется больше. Но первые 1000 добавлений пройдут без единого копирования. Окей, когда что использовать? Сведём все сложности в одну таблицу:

Операция int[] List<T>
Доступ по индексу O(1) O(1)
Изменение элемента O(1) O(1)
Поиск элемента O(n) O(n)
Добавление в конец O(n)* O(1) аморт.
Вставка в середину O(n) O(n)
Удаление O(n) O(n)

* Для int[] добавление — это создание нового массива и полное копирование, отсюда O(n).

Обе структуры одинаково быстры в доступе по индексу — внутри List<T> лежит тот же самый массив. Но List<T> выигрывает на добавлении: вместо ручного копирования — один вызов Add с амортизированным O(1). Вставка в середину и удаление требуют сдвига элементов в обоих случаях — от этого никуда не деться, пока данные хранятся в непрерывном блоке.

А что с памятью? List<T> расходует её чуть больше, чем массив: внутренний массив выделяется с запасом, и часть ячеек пустует. В худшем случае — почти половина ёмкости не используется (сразу после удвоения). Но на практике этот перерасход мизерный. Миллион int — это 4 мегабайта. Даже с двойным запасом — 8 мегабайт. На фоне гигабайтов оперативной памяти современных машин — капля в море.

Массив (Array) — структура данных, которая хранит фиксированное количество элементов одного типа в непрерывном блоке памяти. Доступ по индексу — O(1), потому что адрес элемента вычисляется арифметически: начало + индекс * размер.

Статический массив (int[] в C#) — массив фиксированного размера. Задаётся при создании и не меняется. Добавление и удаление элементов требует создания нового массива и копирования данных — O(n).

Динамический массив (List<T> в C#) — обёртка над обычным массивом, которая автоматически увеличивает ёмкость при заполнении (обычно вдвое). Добавление в конец — амортизированное O(1). Внутри — тот же массив, поэтому доступ по индексу остаётся O(1).

Амортизированная сложность — средняя стоимость операции на большом количестве вызовов. Отдельное расширение стоит O(n), но оно происходит всё реже, и в среднем каждое добавление обходится в O(1).

У массива — свойство Length, у List<T>Count (текущее количество элементов) и Capacity (выделенная ёмкость внутреннего массива).

Правило выбора: List<T> по умолчанию, int[] — только когда размер точно известен и фиксирован.

Обсуждение урока

0
Комментарии видны всем. Чтобы участвовать в обсуждении, войдите или зарегистрируйтесь.
Модерация сообщества

Пожаловаться на комментарий

Расскажите модераторам, что именно требует внимания.