ОП.04 · ЗАНЯТИЕ 72026 / 2027

Массив: индексы,
проходы, границы

Формат
новая тема + практика по стартовому набору
Объём
одна пара, 90 минут
Результат
три функции, тесты и заметка о сложности
Файлы
lesson_07/ · solution.py · tests.py · complexity_note.mdlesson_07_cs/ · Solution.cs · Tests.cs · complexity_note.md

нажмите любую клавишу, чтобы начать ▸

01/55

Занятие 7 · раздел 2 · массивы, строки и последовательные техники

Массив:
индексы, проходы,
границы

Как список Python хранит элементы в памяти, где проходят границы среза, как изменить список на месте и почему append в среднем стоит O(1).Как массив и List<T> хранят элементы в памяти, где проходят границы диапазона, как изменить список на месте и почему Add в среднем стоит O(1). Три задачи на один проход с инвариантом: максимальная серия, разворот участка, удаление на месте.

3 задачи5 демонстраций27 тестовlesson_07

план пары

  1. 1массив в памяти
  2. 2границы и срезы
  3. 3инвариант прохода, задача 1
  4. 4два указателя, задача 2
  5. 5копия или на месте
  6. 6удаление на месте, задача 3
  7. 7ёмкость и appendAdd
  8. 8практика и сдача
07

Вход · задача «удалить лишние элементы из списка»

Две ошибочные версии удаления спама

Занятие начинается с кода, который часто пишут первым. В списке четыре комментария, в двух есть ссылка — это спам. Обе версии удаляют элементы из списка во время прохода по нему. Первая оставляет в списке один спам-комментарий, вторая завершается с IndexError. Вывод настоящий, Python 3.12.

moderation_v1.pyremove() в цикле for
def is_spam(text):
    return "http" in text

comments = ["Отличный урок", "http://win.ru",
            "http://prize.ru", "Спасибо"]
for text in comments:
    if is_spam(text):
        comments.remove(text)
print(comments)
$ python3 moderation_v1.py3.12.12
['Отличный урок', 'http://prize.ru', 'Спасибо']
moderation_v2.pydel по индексу
comments = ["Отличный урок", "http://win.ru",
            "http://prize.ru", "Спасибо"]
for i in range(len(comments)):
    if is_spam(comments[i]):
        del comments[i]
print(comments)
$ python3 moderation_v2.py3.12.12
Traceback (most recent call last):
  File "moderation_v2.py", line 6, in <module>
    if is_spam(comments[i]):
               ~~~~~~~~^^^
IndexError: list index out of range

Чтобы объяснить обе ошибки, нужны понятия индекса, границы и инварианта прохода. Занятие вводит их по порядку, и в части 6 этот код разбирается по шагам.

Вход · задача «удалить лишние элементы из списка»

Две ошибочные версии удаления спама

Занятие начинается с кода, который часто пишут первым. В списке четыре комментария, в двух есть ссылка — это спам. Обе версии удаляют элементы из списка во время прохода по нему. Первая завершается с InvalidOperationException, вторая оставляет в списке один спам-комментарий. Вывод настоящий, .NET 10.

moderation_v1.csRemove() в цикле foreach
var comments = new List<string> { "Отличный урок", "http://win.ru",
                                  "http://prize.ru", "Спасибо" };
foreach (var text in comments)
{
    if (IsSpam(text))
        comments.Remove(text);
}
Console.WriteLine(string.Join(", ", comments));

static bool IsSpam(string text) => text.Contains("http");
$ dotnet run moderation_v1.cs.NET 10
Unhandled exception. System.InvalidOperationException:
Collection was modified; enumeration operation may not execute.
   at System.Collections.Generic.List`1.Enumerator.MoveNext()
   at Program.<Main>$(String[] args) in /private/tmp/l7cs/moderation_v1.cs:line 2
moderation_v2.csRemoveAt по индексу
var comments = new List<string> { "Отличный урок", "http://win.ru",
                                  "http://prize.ru", "Спасибо" };
for (int i = 0; i < comments.Count; i++)
{
    if (IsSpam(comments[i]))
        comments.RemoveAt(i);
}
Console.WriteLine(string.Join(", ", comments));
$ dotnet run moderation_v2.cs.NET 10
Отличный урок, http://prize.ru, Спасибо

Чтобы объяснить обе ошибки, нужны понятия индекса, границы и инварианта прохода. Занятие вводит их по порядку, и в части 6 этот код разбирается по шагам.

07

Часть 1 · массив в памяти · определение и демонстрация адреса

Массив и индекс

Все задачи занятия работают со списком Python, а список устроен как массив: элементы лежат в памяти подряд, одним непрерывным блоком, в ячейках одинакового размера. Поэтому адрес любой ячейки вычисляется по её номеру: адрес = начало + индекс × размер ячейки.

Индекс — смещение от начала блокаИндекс показывает, на сколько ячеек сдвинуться от начала. У первой ячейки сдвиг 0, поэтому нумерация начинается с нуля. К любому элементу можно обратиться по номеру, не просматривая предыдущие, — на этом держатся указатели, окна и проходы.

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

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

Часть 1 · массив в памяти · определение и демонстрация адреса

Массив и индекс

Все задачи занятия работают с массивом int[] и списком List<T>, а список устроен как массив: элементы лежат в памяти подряд, одним непрерывным блоком, в ячейках одинакового размера. Поэтому адрес любой ячейки вычисляется по её номеру: адрес = начало + индекс × размер ячейки. У int[] ячейка занимает 4 байта.

Индекс — смещение от начала блокаИндекс показывает, на сколько ячеек сдвинуться от начала. У первой ячейки сдвиг 0, поэтому нумерация начинается с нуля. К любому элементу можно обратиться по номеру, не просматривая предыдущие, — на этом держатся указатели, окна и проходы.

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

Адреса условные: настоящее начало блока в каждом запуске своё. Под ячейкой — индекс слева направо и индекс с конца ^k, о нём экран 6.

07

Часть 1 · список Python · определение

Список Python хранит ссылки

Формула адреса требует ячеек одного размера, а число, строка и другой список занимают разное место. Список Python решает это так: в ячейках лежат не сами объекты, а ссылки на них.

Ссылкаreference
Что это
Адрес объекта в памяти. На 64-битной системе занимает 8 байт независимо от того, на что указывает.
Как устроено
Ячейка списка хранит ссылку, сам объект лежит в другом месте памяти.
Зачем знать
Одинаковые ячейки дают доступ по индексу за O(1). Отсюда же поведение b = a на экране 30: копируется ссылка, список остаётся один.
0●1●2●3●4●
12000int'Спасибо'str3.5float[1, 2]listNoneNoneType
ячейки списка: по 8 байт каждаяобъекты: разного размера, в других местах памяти

Часть 1 · типы элементов · определение

Значимые и ссылочные типы в ячейках

Формула адреса требует ячеек одного размера. В C# размер ячейки задаёт тип элемента, объявленный при создании массива или списка. Что именно лежит в ячейке, зависит от вида этого типа.

Значимый и ссылочный типvalue · reference type
Значимый тип
int, double, bool, struct. Ячейка хранит само значение: в int[] — число, 4 байта.
Ссылочный тип
string, массивы, классы, List<T>. Ячейка хранит ссылку — адрес объекта, 8 байт на 64-битной системе. Сам объект лежит в другом месте памяти.
Зачем знать
В обоих случаях ячейки одного размера, поэтому доступ по индексу стоит O(1). Ссылка объясняет поведение var backup = feed на экране 30: копируется ссылка, список остаётся один.
01200013000211000
int[]: значения прямо в ячейках, по 4 байта
0●1●2●
"Спасибо"string"Вопрос"string"http://bot"string
string[]: в ячейках ссылки, строки в других местах памяти
07

Часть 1 · цена доступа

Доступ по индексу за O(1), поиск по значению за O(n)

Из формулы адреса следует цена операций. По индексу адрес вычисляется одним умножением и одним сложением при любой длине списка. По значению формулы нет: чтобы найти 10 500, элементы сравниваются по порядку.

ОперацияЧто выполняетсяВремя
steps[3]адрес по формуле, чтение одной ячейкиO(1)
steps[3] = 9000запись ссылки в одну ячейкуO(1)
len(steps)длина хранится в объекте списка, считать её заново не нужноO(1)
10500 in stepsсравнение с каждым элементом, пока не найдено совпадениеO(n)
steps.index(10500)тот же просмотр, возвращает позицию первого совпаденияO(n)

Решения раздела строятся на этом различии: алгоритм обращается к элементам по индексу, а поиск по значению внутри цикла не используется, потому что превращает O(n) в O(n²).

Часть 1 · цена доступа

Доступ по индексу за O(1), поиск по значению за O(n)

Из формулы адреса следует цена операций. По индексу адрес вычисляется одним умножением и одним сложением при любой длине массива. По значению формулы нет: чтобы найти 10 500, элементы сравниваются по порядку.

ОперацияЧто выполняетсяВремя
steps[3]адрес по формуле, чтение одной ячейкиO(1)
steps[3] = 9000запись в одну ячейкуO(1)
steps.Length, list.Countдлина хранится в объекте, считать её заново не нужноO(1)
steps.Contains(10500)сравнение с каждым элементом, пока не найдено совпадениеO(n)
list.IndexOf(10500)тот же просмотр, возвращает позицию первого совпаденияO(n)

Решения раздела строятся на этом различии: алгоритм обращается к элементам по индексу, а поиск по значению внутри цикла не используется, потому что превращает O(n) в O(n²).

07

Часть 1 · отрицательный индекс · выход за границы

Отрицательный индекс и IndexError

Индекс -k перед вычислением адреса заменяется на len(a) - k, поэтому a[-1] стоит столько же, сколько a[4]. У списка длины n допустимы индексы от -n до n - 1. Любой другой не соответствует ни одной ячейке: программа останавливается и показывает строку.

значение1020304050 индекс01234 отрицательный−5−4−3−2−1
python3 -i3.12.12
>>> a = [10, 20, 30, 40, 50]
>>> a[4], a[-1]
(50, 50)
>>> a[5]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range
Индекс для a длины 5Результат
a[0] … a[4]элементы 10 … 50
a[-5] … a[-1]те же элементы с конца
a[5], a[len(a)]IndexError
a[-6]IndexError

Где опасно: если индекс вычисляется в цикле и случайно стал −1, ошибки не будет — вернётся последний элемент. В ошибочной версии задачи 2 правый указатель доходит до −1, экран 27.

Часть 1 · индекс с конца · выход за границы

Индекс с конца ^k и IndexOutOfRangeException

Отрицательных индексов в C# нет. Для отсчёта от конца есть оператор ^: ^k заменяется на a.Length - k, поэтому a[^1] стоит столько же, сколько a[4]. Допустимы индексы от 0 до n - 1 и от ^n до ^1, на любом другом среда выполнения выбрасывает исключение.

значение1020304050 индекс01234 с конца^5^4^3^2^1
idx.cs.NET 10
int[] a = { 10, 20, 30, 40, 50 };
Console.WriteLine($"{a[4]} {a[^1]}");
Console.WriteLine(a[5]);
$ dotnet run idx.cs.NET 10
50 50
Unhandled exception. System.IndexOutOfRangeException:
Index was outside the bounds of the array.
   at Program.<Main>$(String[] args) in /private/tmp/l7cs/idx.cs:line 3
Обращение, длина 5Результат
a[0] … a[4]элементы 10 … 50
a[^5] … a[^1]те же элементы с конца
a[5], a[a.Length], a[-1], a[^0]IndexOutOfRangeException — ошибка видна сразу, в строке с обращением
list[5] у List<int>ArgumentOutOfRangeException
07

Часть 1 · выход за границы · срез

Срез за границами списка: без ошибки

Со срезом Python поступает иначе, чем с индексом. Границы среза обрезаются до диапазона [0, len(a)], и исключения нет. Поэтому ошибка в границе среза не останавливает программу: результат просто оказывается короче.

python3 -i3.12.12
>>> a = [10, 20, 30, 40, 50]
>>> a[1:3], a[3:10], a[10:20]
([20, 30], [40, 50], [])
СрезПосле обрезкиРезультат
a[1:3]a[1:3][20, 30]
a[3:10]a[3:5][40, 50] — два элемента вместо семи
a[10:20]a[5:5][] — пустой список

Поэтому ошибку границы в срезе обнаруживает только тест, который сравнивает ответ с ожидаемым.

Часть 1 · выход за границы · диапазон

Диапазон за границами: исключение

Диапазон a[l..r] выбирает часть массива, подробно он разобран на следующем экране. Границы диапазона проверяются так же строго, как индекс: если r больше длины, выбрасывается ArgumentOutOfRangeException, и программа останавливается в строке с ошибкой.

range.cs.NET 10
int[] a = { 10, 20, 30, 40, 50 };
Console.WriteLine(string.Join(", ", a[1..3]));
Console.WriteLine(string.Join(", ", a[3..10]));
$ dotnet run range.cs.NET 10
20, 30
Unhandled exception. System.ArgumentOutOfRangeException:
Specified argument was out of the range of valid values. (Parameter 'length')
ДиапазонРезультат
a[1..3]{ 20, 30 }
a[3..5]{ 40, 50 }
a[3..10]ArgumentOutOfRangeException
a[5..5]пустой массив
list.GetRange(3, 7)ArgumentException

В Python срез за границей не падает, а возвращает укороченный список. Поэтому код, который в C# остановится с исключением, в Python вернёт другой результат без сообщения об ошибке.

07

Часть 2 · границы · срез и полуинтервал

Срез и полуинтервал [l, r)

Срез a[l:r] — новый список из элементов с индексами от l до r - 1. Python копирует в него ссылки на элементы участка: время и память O(r − l). Поэтому в решениях на O(1) памяти срезы внутри цикла не используют. Левая граница входит в срез, правая нет — у такой записи есть название.

Полуинтервал[l, r)
Что это
Участок индексов l, l + 1, …, r − 1. Квадратная скобка — граница включена, круглая — не включена.
Как устроено
l — индекс первого элемента участка, r — индекс первого элемента после участка.
Пример
[2, 5) — индексы 2, 3, 4. range(2, 5) и a[2:5] дают ровно их.
Зачем в задаче
С полуинтервалом длина, склейка и пустой участок записываются без поправок на единицу — это три следствия на следующем экране.
Разбор на примере

Неделя: days = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"]. Срез days[1:4] — синие ячейки, красная граница 4 в участок не входит.

0Пн1Вт2Ср3Чт4Пт5Сб6Вс

>>> days[1:4] → ['Вт', 'Ср', 'Чт'] · days[4] = 'Пт' — первый день после участка

Часть 2 · границы · диапазон и полуинтервал

Диапазон и полуинтервал [l, r)

Диапазон a[l..r] — часть массива, списка или строки с индексами от l до r - 1. Для массива и строки он создаёт новый объект и копирует элементы: время и память O(r − l); list.GetRange(l, count) тоже копирует, а a.AsSpan(l, count) — окно на тот же массив без копии. Левая граница входит в участок, правая нет, как в цикле for (int i = l; i < r; i++).

Полуинтервал[l, r)
Что это
Участок индексов l, l + 1, …, r − 1. Квадратная скобка — граница включена, круглая — не включена.
Как устроено
l — индекс первого элемента участка, r — индекс первого элемента после участка.
Пример
[2, 5) — индексы 2, 3, 4. a[2..5] и for (int i = 2; i < 5; i++) дают ровно их.
Ловушка
Enumerable.Range(start, count) принимает количество: Enumerable.Range(2, 5) — это 2, 3, 4, 5, 6.
Разбор на примере

Неделя: string[] days = { "Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс" };. Диапазон days[1..4] — синие ячейки, красная граница 4 в участок не входит.

0Пн1Вт2Ср3Чт4Пт5Сб6Вс

days[1..4] → { Вт, Ср, Чт } · days[4] = "Пт" — первый день после участка

07

Часть 2 · полуинтервал · три следствия

Три следствия полуинтервала

Запись [l, r) выбрана не случайно: с ней три частых действия с участками записываются без поправок на единицу.

1 · Длина равна r − l

В a[2:5] индексы 2, 3, 4 — три элемента, 5 − 2. У отрезка [2, 5] с включённой правой границей было бы 5 − 2 + 1, и эту единицу часто забывают.

days[0:5]  # 5 − 0 = 5 дней
days[5:7]  # 7 − 5 = 2 дня
2 · Участки склеиваются

Граница k закрывает левый участок и открывает правый: a[k] попадает ровно в одну часть. Три части понадобятся в задаче 2.

a[:k] + a[k:] == a
a[:l] + a[l:r] + a[r:] == a
3 · При l = r участок пустой

Цикл не выполнится, срез вернёт пустой список — особый случай в коде не нужен.

days[3:3]          # []
list(range(3, 3))  # []
days[7:]           # []
Разбор на примере

>>> days[:5] + days[5:] == days → True · «Пн…Пт» + «Сб, Вс»
>>> days[:1] + days[1:5] + days[5:] == days → True

Ловушка. «С 1 по 4 включительно» — это 4 дня, и записывается days[1:5]: ['Вт', 'Ср', 'Чт', 'Пт']. Так же в задаче 2 «с l по r»: в срезе и в range правая граница — r + 1.

В задаче 3 после цикла выполняется del comments[w:]. Если спама не было, w = 5 при пяти комментариях, и del comments[5:] удаляет ноль элементов — отдельный if не нужен.

Часть 2 · полуинтервал · три следствия

Три следствия полуинтервала

Запись [l, r) выбрана не случайно: с ней три частых действия с участками записываются без поправок на единицу.

1 · Длина равна r − l

В a[2..5] индексы 2, 3, 4 — три элемента, 5 − 2. У отрезка [2, 5] с включённой правой границей было бы 5 − 2 + 1, и эту единицу часто забывают.

days[0..5]  // 5 − 0 = 5 дней
days[5..7]  // 7 − 5 = 2 дня
2 · Участки склеиваются

Граница k закрывает левый участок и открывает правый: a[k] попадает ровно в одну часть. Три части понадобятся в задаче 2.

a[..k]   a[k..]
a[..l]   a[l..r]   a[r..]
3 · При l = r участок пустой

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

days[3..3]   // { }
days[7..]    // { }
for (int i = 3; i < 3; i++)  // 0 раз
Разбор на примере

days[..5].Concat(days[5..]).SequenceEqual(days) → True · «Пн…Пт» + «Сб, Вс»

Ловушка. «С 1 по 4 включительно» — это 4 дня, и записывается days[1..5]: { Вт, Ср, Чт, Пт }. Так же в задаче 2 «с l по r»: в диапазоне правая граница — a[l..(r + 1)], в цикле — i <= r.

В задаче 3 после цикла выполняется comments.RemoveRange(w, comments.Count - w). Если спама не было, w = 5 при пяти комментариях, и RemoveRange(5, 0) удаляет ноль элементов — отдельная проверка не нужна.

07

Часть 2 · демонстрация · границы среза

Демонстрация: границы среза

Все три следствия видны, если нарисовать границы между ячейками. Граница 0 стоит перед первым элементом, граница n — после последнего. Нажмите на две границы: первая станет l, вторая r. Проверьте длину, склейку и пустой участок кнопкой a[3:3].

Часть 2 · демонстрация · границы диапазона

Демонстрация: границы диапазона

Все три следствия видны, если нарисовать границы между ячейками. Граница 0 стоит перед первым элементом, граница n — после последнего. Нажмите на две границы: первая станет l, вторая r. Проверьте длину, склейку и пустой участок кнопкой a[3..3].

07

Часть 2 · число итераций

Элементы, границы, пары, окна: сколько итераций

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

Что перебираемСколькоЦиклГде встречается
элементы a[i] — пролётыnfor i in range(n)сумма, счётчик, серия
границы, места для вставки — столбыn + 1for k in range(n + 1)разрез на a[:k] и a[k:], префиксные суммы (занятие 11)
пары соседей (a[i], a[i + 1])n − 1for i in range(n - 1)рост, сравнение соседних дней
окна длины k a[i:i + k]n − k + 1for i in range(n - k + 1)окна фиксированного размера (занятие 9)
Разбор на примере

У недели 7 дней и 8 границ: вставить день можно в 8 мест. Температура temps = [12, 15, 14, 18]: пара соседей — окно длины 2, поэтому пар и окон длины 2 поровну, n − 2 + 1 = n − 1.

>>> days.insert(0, "Отгул") — перед «Пн» · days.insert(7, "Отгул") — после «Вс» · границы 8 нет
пары соседей → (12, 15), (15, 14), (14, 18) · 3 = n − 1 · суммы окон длины 2 → 27, 29, 32 · 3 = n − k + 1

Если написать range(len(temps) - 2), последнее окно temps[2:4] с суммой 32 не будет просмотрено.

Часть 2 · число итераций

Элементы, границы, пары, окна: сколько итераций

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

Что перебираемСколькоЦиклГде встречается
элементы a[i] — пролётыnfor (int i = 0; i < n; i++)сумма, счётчик, серия
границы, места для вставки — столбыn + 1for (int k = 0; k <= n; k++)разрез на a[..k] и a[k..], префиксные суммы (занятие 11)
пары соседей (a[i], a[i + 1])n − 1for (int i = 0; i < n - 1; i++)рост, сравнение соседних дней
окна длины k a[i..(i + k)]n − k + 1for (int i = 0; i < n - k + 1; i++)окна фиксированного размера (занятие 9)
Разбор на примере

У недели 7 дней и 8 границ: вставить день можно в 8 мест. Температура int[] temps = { 12, 15, 14, 18 }; пара соседей — окно длины 2, поэтому пар и окон длины 2 поровну, n − 2 + 1 = n − 1.

days.Insert(0, "Отгул") — перед «Пн» · days.Insert(7, "Отгул") — после «Вс» · границы 8 нет
пары соседей → (12, 15), (15, 14), (14, 18) · 3 = n − 1 · суммы окон длины 2 → 27, 29, 32 · 3 = n − k + 1

Если написать i < temps.Length - 2, последнее окно temps[2..4] с суммой 32 не будет просмотрено.

07

Часть 2 · термины условия

Подмассив, подпоследовательность, подстрока

Участок [l, r) в условиях задач называют подмассивом, для строк — подстрокой. Рядом стоят похожие термины, и от термина зависит алгоритм. На собеседовании его уточняют до кода вопросом: «элементы должны идти подряд?»

ТерминПодряд?Порядок сохранён?Вариантов у длины nПримерКак решают
подмассив / подстрокададаn(n + 1) / 2[8, 1, 9]{ 8, 1, 9 }, "паси"проход, окно, префиксные суммы; серия в задаче 1, участок в задаче 2
подпоследовательностьнетда2ⁿ[3, 1, 4]{ 3, 1, 4 }, "спб"динамическое программирование (раздел 7), одного прохода мало
подмножествонетнет2ⁿбуквы {б, п, с}—
Разбор на примере

Для a = [3, 8, 1, 9, 4] подмассивы длины 3 — три участка подряд, n − k + 1 = 5 − 3 + 1. Элементы с индексами 0, 2 и 4 идут с пропусками — это подпоследовательность.

0318213944

a[0:3] → [3, 8, 1] · a[1:4] → [8, 1, 9] · a[2:5] → [1, 9, 4] · всех непустых подмассивов 5 · 6 / 2 = 15
[3, 1, 4] — подпоследовательность, но не подмассив · [1, 3] — нет, порядок нарушен · подпоследовательностей 2⁵ = 32

"паси" in "спасибо" даёт True, а "спб" in "спасибо" — False: буквы с, п, б идут по порядку, но не подряд.

Разбор на примере

Для int[] a = { 3, 8, 1, 9, 4 }; подмассивы длины 3 — три участка подряд, n − k + 1 = 5 − 3 + 1. Элементы с индексами 0, 2 и 4 идут с пропусками — это подпоследовательность.

0318213944

a[0..3] → { 3, 8, 1 } · a[1..4] → { 8, 1, 9 } · a[2..5] → { 1, 9, 4 } · всех непустых подмассивов 5 · 6 / 2 = 15
{ 3, 1, 4 } — подпоследовательность, но не подмассив · { 1, 3 } — нет, порядок нарушен · подпоследовательностей 2⁵ = 32

"спасибо".Contains("паси") даёт true, а "спасибо".Contains("спб") — false: буквы с, п, б идут по порядку, но не подряд.

07

Часть 2 · ошибки на единицу · определение

Ошибка на единицу

Теперь можно объяснить, откуда берутся ошибки на единицу в задачах на массивы. В записи участка встречаются n, n + 1 и n − 1, две договорённости о правой границе и отсчёт с нуля. Перепутать любую пару — значит сдвинуть границу на один элемент.

Ошибка на единицуoff-by-one
Что это
Цикл или срез проходит на один элемент больше или меньше нужного.
Откуда берётся
Путают включённую и невключённую границу, число элементов и число границ, длину и последний индекс.
Как проявляется
На обычных примерах код отвечает верно. Ошибка видна на крайнем элементе: IndexErrorIndexOutOfRangeException, пропущенный первый или последний элемент.
Как проверить
Подставить n = 0, 1 и 2 и посчитать итерации вручную. Если при n = 1 цикл по парам выполняется один раз, граница неверна.
Разбор на примере

Цены за три дня: prices = [100, 120, 90]. Цикл for i in range(len(prices)) даёт i = 0, 1, 2, а пар соседей всего две.

i = 0: prices[1] > prices[0] → 120 > 100 · i = 1: prices[2] > prices[1] → 90 > 120
i = 2: prices[3] → IndexError: list index out of range

Одна лишняя итерация — и программа останавливается на последнем элементе. Верная граница: range(len(prices) - 1).

Разбор на примере

Цены за три дня: int[] prices = { 100, 120, 90 }; Цикл for (int i = 0; i < prices.Length; i++) даёт i = 0, 1, 2, а пар соседей всего две.

i = 0: prices[1] > prices[0] → 120 > 100 · i = 1: prices[2] > prices[1] → 90 > 120
i = 2: prices[3] → IndexOutOfRangeException

Одна лишняя итерация — и программа останавливается на последнем элементе. Верная граница: i < prices.Length - 1.

07

Часть 2 · ошибки на единицу · в циклах, индексах и срезах

Шесть частых ошибок на единицу

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

КодЧто происходитИсправление
for i in range(len(a)):
  if a[i + 1] > a[i]:
пар n − 1, а итераций n; на последнем i индекс i + 1 равен len(a) → IndexErrorrange(len(a) - 1)
for i in range(len(a) - k):
  sum(a[i:i + k])
окон n − k + 1, а итераций n − k; последнее окно не просмотрено, ошибки нетrange(len(a) - k + 1)
for i in range(1, len(a)):
  total += a[i]
первый элемент не учтён, ошибки нет, ответ меньше верногоrange(len(a)) или for x in a
last = a[len(a)]len(a) — граница после последнего элемента, ячейки с таким индексом нет → IndexErrora[len(a) - 1] или a[-1]
условие «с l по r включительно» → a[l:r]срез не включает r, элемент a[r] потерянa[l:r + 1]
«последние n событий» → a[len(a) - n:]при n > len(a) начало отрицательное и считается от конца: вернётся часть спискаa[max(0, len(a) - n):]

Последняя строка — одна из задач домашнего задания: ошибка в ней не даёт исключения, её находит только тест с n больше длины.

Часть 2 · ошибки на единицу · в циклах, индексах и диапазонах

Шесть частых ошибок на единицу

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

КодЧто происходитИсправление
for (int i = 0; i < a.Length; i++)
  if (a[i + 1] > a[i]) …
пар n − 1, а итераций n; на последнем i индекс i + 1 равен a.Length → IndexOutOfRangeExceptioni < a.Length - 1
for (int i = 0; i < a.Length - k; i++)
  sums.Add(a[i..(i + k)].Sum());
окон n − k + 1, а итераций n − k; последнее окно не просмотрено, исключения нетi <= a.Length - k
for (int i = 1; i < a.Length; i++)
  total += a[i];
первый элемент не учтён, исключения нет, ответ меньше верногоint i = 0 или foreach
var last = a[a.Length];a.Length — граница после последнего элемента, ячейки с таким индексом нет → IndexOutOfRangeExceptiona[a.Length - 1] или a[^1]
условие «с l по r включительно» → a[l..r]диапазон не включает r, элемент a[r] потерянa[l..(r + 1)]
«последние n событий» →
events.GetRange(events.Count - n, n)
при n > Count начальный индекс отрицательный → ArgumentOutOfRangeExceptionint from = Math.Max(0, events.Count - n);
events.GetRange(from, events.Count - from)

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

07

Часть 2 · крайние входы

Пустой и короткий вход

Ошибки на единицу чаще всего проявляются на самых коротких списках. Таблица показывает, что делают частые выражения на пустом списке, на одном и на двух элементах.

Выражениеa = []a = [7]a = [7, 7]
a[0], a[-1]IndexError77
max(a)ValueError77
range(len(a) - 1)range(−1): пустоrange(0): пустоодна итерация
a[1:][][][7]
for x in aни одной итерацииоднадве

Пустой вход падает только там, где код обращается к конкретному элементу до цикла: a[0], a[-1], max(a). Циклы и срезы на пустом списке работают.

Часть 2 · крайние входы

Пустой и короткий вход

Ошибки на единицу чаще всего проявляются на самых коротких массивах. Таблица показывает, что делают частые выражения на пустом массиве, на одном и на двух элементах.

Выражениеa = { }a = { 7 }a = { 7, 7 }
a[0], a[^1]IndexOutOfRangeException77
a.Max()InvalidOperationException77
for (int i = 0; i < a.Length - 1; i++)условие 0 < −1 ложно: пусто0 < 0: пустоодна итерация
a[1..]ArgumentOutOfRangeExceptionпустой массив{ 7 }
foreach (var x in a)ни одной итерацииоднадве

На пустом массиве падают обращение к элементу, Max() и диапазон с началом больше длины. Циклы for и foreach на пустом массиве работают.

07

Часть 3 · инвариант прохода · определение и три проверки · возврат к занятию 5

Инвариант прохода

Инвариант прохода — утверждение о переменных цикла, которое верно перед первой итерацией и остаётся верным после каждой. Обычно он звучит так: «переменная X хранит такую-то величину для просмотренной части a[0:i]». Из инварианта следуют начальные значения, тело цикла и ответ. На занятии 5 инвариант проверялся на готовом коде, здесь по нему пишется код. Самый короткий пример — сумма цен в корзине.

сумма ценO(n) · O(1)
def total(prices):
    s = 0            # s == sum(prices[0:i])
    for i in range(len(prices)):
        s += prices[i]
    return s
ПроверкаВопросДля суммы
1 · Инициализацияверен ли инвариант до первой итерации?i = 0, prices[0:0] пуст, его сумма 0 — отсюда s = 0
2 · Сохранениеесли верен перед шагом, верен ли после?было sum(prices[0:i]), добавили prices[i] — стало sum(prices[0:i+1])
3 · Завершениечто даёт инвариант после цикла?i = n: s — сумма всего списка, это ответ
Разбор на примере

Прогон на prices = [100, 250, 90]: после каждого шага s равна сумме уже просмотренных цен.

iprices[i]s до шагаs после шагаинвариант
01000100100 = сумма первых одной цены
1250100350350 = 100 + 250
290350440440 = сумма всех трёх

>>> total([100, 250, 90]) → 440

Часть 3 · инвариант прохода · определение и три проверки · возврат к занятию 5

Инвариант прохода

Инвариант прохода — утверждение о переменных цикла, которое верно перед первой итерацией и остаётся верным после каждой. Обычно он звучит так: «переменная X хранит такую-то величину для просмотренной части a[0..i]». Из инварианта следуют начальные значения, тело цикла и ответ. На занятии 5 инвариант проверялся на готовом коде, здесь по нему пишется код. Самый короткий пример — сумма цен в корзине.

сумма ценO(n) · O(1)
static int Total(int[] prices)
{
    int s = 0;      // s — сумма prices[0..i]
    for (int i = 0; i < prices.Length; i++)
        s += prices[i];
    return s;
}
ПроверкаВопросДля суммы
1 · Инициализацияверен ли инвариант до первой итерации?i = 0, prices[0..0] пуст, его сумма 0 — отсюда s = 0
2 · Сохранениеесли верен перед шагом, верен ли после?было: сумма prices[0..i], добавили prices[i] — стало: сумма prices[0..(i+1)]
3 · Завершениечто даёт инвариант после цикла?i = n: s — сумма всего массива, это ответ
Разбор на примере

Прогон на int[] prices = { 100, 250, 90 };: после каждого шага s равна сумме уже просмотренных цен.

iprices[i]s до шагаs после шагаинвариант
01000100100 = сумма первых одной цены
1250100350350 = 100 + 250
290350440440 = сумма всех трёх

Total(new[] { 100, 250, 90 }) → 440

07

Часть 3 · задача 1 · условие и уточнение

Задача 1: максимальная серия

Трекер хранит число шагов по дням: steps[0] — первый день, steps[-1]steps[^1] — последний. Цель дня — goal шагов, по умолчанию 10 000. Нужно вернуть длину самой длинной серии дней подряд, в каждый из которых цель достигнута. Серия — подмассив. Функция max_streak(steps, goal=10_000)Метод MaxStreak(steps, goal: 10_000), время O(n), память O(1).

stepsОтветПочему
[12000, 3000, 11000, 10500, 10000]{ 12000, 3000, 11000, 10500, 10000 }3дни 2, 3, 4 подряд; день 0 — серия длины 1
[9999]{ 9999 }0цель не достигнута ни разу
[]{ }0дней нет
Вопрос до кодаОтветЧто это меняет в коде
Ровно 10 000 шагов — цель достигнута?дасравнение >=; тест test_streak_goal_is_inclusive
Что вернуть на пустом списке?0начальное значение ответа 0; тест test_streak_empty
Бывают ли пропущенные дни?нет, один элемент — один деньсоседние элементы — соседние дни
Можно ли менять список?нет, память O(1)без сортировки и без копий; тест test_streak_does_not_change_input
07

Часть 3 · задача 1 · решение

Решение задачи 1

В сумме хватало одной переменной, здесь нужны две: cur — длина серии, которая заканчивается на текущем дне, и best — лучшая серия среди просмотренных дней. На дне без цели cur обнуляется, а best помнит лучшую. Выделенные строки — обновление best сразу после роста серии.

solution.pyO(n) · O(1)
def max_streak(steps, goal=10_000):
    best = 0   # лучшая серия среди дней с 0 по i
    cur = 0    # серия, которая заканчивается на i
    for s in steps:
        if s >= goal:
            cur += 1
            if cur > best:
                best = cur
        else:
            cur = 0
    return best
Инвариант задачи 1После обработки дня i: cur — длина серии, которая заканчивается на дне i; best — длина лучшей серии среди дней с 0 по i.
Инициализациядней не просмотрено, серий нет: cur = best = 0
Сохранениецель достигнута — серия продлевается на 1, лучшая обновляется сразу; не достигнута — серия, оканчивающаяся на этом дне, пустая
Завершениепросмотрены все дни, по инварианту best — лучшая серия среди всех. Досчитывать после цикла нечего
Пустой входцикл не выполняется, возвращается начальное best = 0

Часть 3 · задача 1 · решение

Решение задачи 1

В сумме хватало одной переменной, здесь нужны две: cur — длина серии, которая заканчивается на текущем дне, и best — лучшая серия среди просмотренных дней. На дне без цели cur обнуляется, а best помнит лучшую. Выделенные строки — обновление best сразу после роста серии.

Solution.csO(n) · O(1)
public static int MaxStreak(IReadOnlyList<int> steps, int goal = 10_000)
{
    int best = 0;   // лучшая серия среди дней с 0 по i
    int cur = 0;    // серия, которая заканчивается на i
    foreach (int s in steps)
    {
        if (s >= goal)
        {
            cur++;
            if (cur > best)
                best = cur;
        }
        else
            cur = 0;
    }
    return best;
}
Инвариант задачи 1После обработки дня i: cur — длина серии, которая заканчивается на дне i; best — длина лучшей серии среди дней с 0 по i.
Инициализациядней не просмотрено, серий нет: cur = best = 0
Сохранениецель достигнута — серия продлевается на 1, лучшая обновляется сразу; не достигнута — серия, оканчивающаяся на этом дне, пустая
Завершениепросмотрены все дни, по инварианту best — лучшая серия среди всех. Досчитывать после цикла нечего
Пустой входцикл не выполняется, возвращается начальное best = 0
07

Часть 3 · демонстрация · проход по дням

Демонстрация: проход по дням

Код прогоняется на семи днях. Синяя рамка — текущая серия cur, жёлтая — лучшая best. Нажимайте «шаг» и сверяйте строку таблицы с инвариантом: после каждого дня отметка должна оставаться ✓.

07

Часть 3 · типичная ошибка

Ошибка: best обновляется только при обрыве серии

Частая версия переносит обновление best в ветку else: лучшая серия запоминается, когда текущая оборвалась. Если за последней серией нет дня без цели, ветка else для неё не выполнится.

ошибочная версиятест: best_series_at_the_end
for s in steps:
    if s >= goal:
        cur += 1
    else:
        best = max(best, cur)
        cur = 0
return best
Что нарушеноИнвариант говорит, что после каждого дня best — лучшая серия среди дней с 0 по i. В этой версии best отстаёт, пока серия продолжается.
Исправление после циклаСтрока return max(best, cur) даёт верный ответ, но инвариант внутри цикла остаётся нарушенным. Верная версия обновляет best на каждом шаге роста.

Часть 3 · типичная ошибка

Ошибка: best обновляется только при обрыве серии

Частая версия переносит обновление best в ветку else: лучшая серия запоминается, когда текущая оборвалась. Если за последней серией нет дня без цели, ветка else для неё не выполнится.

ошибочная версиятест: streak_best_series_at_the_end
foreach (int s in steps)
{
    if (s >= goal)
        cur++;
    else
    {
        best = Math.Max(best, cur);
        cur = 0;
    }
}
return best;
Что нарушеноИнвариант говорит, что после каждого дня best — лучшая серия среди дней с 0 по i. В этой версии best отстаёт, пока серия продолжается.
Исправление после циклаСтрока return Math.Max(best, cur); даёт верный ответ, но инвариант внутри цикла остаётся нарушенным. Верная версия обновляет best на каждом шаге роста.
07

Часть 3 · демонстрация · ошибочная версия

Демонстрация: где нарушается инвариант

Ошибочная версия на входе [12000, 3000, 11000, 10500, 10000]{ 12000, 3000, 11000, 10500, 10000 }. Строки таблицы, после которых best меньше лучшей серии, выделены красным, отметка инварианта — ✗. ФункцияМетод вернёт 1 вместо 3.

07

Часть 3 · задача 1 · тесты

Тесты к задаче 1

Публичные тесты из tests.pyTests.cs. Каждый проверяет один класс входа, и имя теста его называет: при падении по имени видно, какое допущение нарушено. Ошибочная версия с прошлого экрана роняет выделенный тест.

ТестВходЖдёмЧто проверяет
test_streak_empty[]{ }0пустой вход без отдельного if
test_streak_single_day_missed[9999]{ 9999 }0один элемент, цель не достигнута
test_streak_goal_is_inclusive[10000, 10000, 9999, 10000]{ 10000, 10000, 9999, 10000 }2сравнение >= на границе цели
test_streak_all_days_reachedчетыре дня выше цели4обрывов нет ни одного
test_streak_best_series_at_the_end[12000, 3000, 11000, 10500, 10000]{ 12000, 3000, 11000, 10500, 10000 }3серия заканчивается вместе со списком
test_streak_best_series_in_the_middleлучшая серия внутри3серия после обрыва начинается с нуля
test_streak_does_not_change_input[12000, 3000, 11000]{ 12000, 3000, 11000 }список тот жевход не изменён
07

Часть 4 · задача 2 · условие и уточнение

Задача 2: разворот участка

В задаче 1 был один индекс, который шёл слева направо. Во второй задаче индексов два, и они идут навстречу. В плейлисте нужно развернуть порядок треков с позиции l по позицию r, остальные треки остаются на местах. Функция меняет переданный список и ничего не возвращает.Метод меняет переданный список и ничего не возвращает.

Доl, rПосле
A B C D E F1, 4A E D C B F
A B C1, 1A B C
1 2 3 40, 34 3 2 1
1 2 32, 1ValueErrorArgumentException
Вопрос до кодаОтветЧто это меняет в коде
Правая граница включена?да, отрезок [l, r]правый указатель начинается с r; срезомдиапазоном участок был бы a[l:r + 1]a[l..(r + 1)]
Что при l > r или выходе за список?ValueErrorArgumentException, список не меняетсяпроверка 0 <= l <= r < len(tracks)0 <= l && l <= r && r < tracks.Count до цикла
Что при l == r?участок из одного трекани одного обмена, отдельный if не нужен
Вернуть новый список или изменить этот?изменить этот, вернуть Noneметод voidпамять O(1), срезыдиапазоны не используются
07

Часть 4 · два указателя · определение

Два указателя навстречу

Развернуть участок — значит поменять местами первый и последний элемент, затем второй и предпоследний и так далее к середине. Для этого нужны два индекса, которые двигаются друг к другу. Такие индексы называют указателями.

Указательpointer
Что это
Индекс в массиве, который двигается по правилу, заданному алгоритмом. В Python — обычная переменная-число.В C# — переменная типа int.
Навстречу
Левый i начинает с начала участка и идёт вправо, правый j — с конца и идёт влево. Шаг заканчивается, когда они встретились.
Пример
Участок B C D E: обмен B и E, затем C и D, затем i > j — готово.
Зачем в задаче
Каждый элемент участка обрабатывается один раз, дополнительная память — две переменные. Та же схема — в поиске пары с суммой на занятии 8.
Разбор на примере

Плейлист ["A", "B", "C", "D", "E", "F"], разворачиваем участок с 1 по 4. Указатели начинают с краёв участка: i = 1 («B»), j = 4 («E»).

0A1B2C3D4E5F

обмен 1: B ↔ E → A E C D B F · теперь i = 2, j = 3
обмен 2: C ↔ D → A E D C B F · теперь i = 3, j = 2 — необработанного участка нет

Разбор на примере

Плейлист { "A", "B", "C", "D", "E", "F" }, разворачиваем участок с 1 по 4. Указатели начинают с краёв участка: i = 1 («B»), j = 4 («E»).

0A1B2C3D4E5F

обмен 1: B ↔ E → A E C D B F · теперь i = 2, j = 3
обмен 2: C ↔ D → A E D C B F · теперь i = 3, j = 2 — необработанного участка нет

07

Часть 4 · задача 2 · решение

Решение задачи 2

Сначала проверка границ, потом цикл обменов. Инвариант описывает, что лежит снаружи необработанной середины: там всё уже на итоговых местах.

solution.pyO(r − l) · O(1)
def reverse_segment(tracks, l, r):
    if not (0 <= l <= r < len(tracks)):
        raise ValueError("неверные границы")
    i, j = l, r
    while i < j:
        tracks[i], tracks[j] = tracks[j], tracks[i]
        i += 1
        j -= 1
ИнвариантВсё, что лежит вне участка с i по j, уже стоит на итоговом месте. Участок с i по j — необработанная середина.
Инициализацияi = l, j = r: снаружи только треки вне участка, разворот их не касается
Сохранениеобмен ставит tracks[i] и tracks[j] на итоговые места, затем обе границы сдвигаются внутрь
Завершениеi ≥ j: в середине ноль или один трек, а один трек стоит на своём месте в любом случае

Часть 4 · задача 2 · решение

Решение задачи 2

Сначала проверка границ, потом цикл обменов. Инвариант описывает, что лежит снаружи необработанной середины: там всё уже на итоговых местах.

Solution.csO(r − l) · O(1)
public static void ReverseSegment<T>(List<T> tracks, int l, int r)
{
    if (!(0 <= l && l <= r && r < tracks.Count))
        throw new ArgumentException("неверные границы");
    int i = l, j = r;
    while (i < j)
    {
        (tracks[i], tracks[j]) = (tracks[j], tracks[i]);
        i++;
        j--;
    }
}
ИнвариантВсё, что лежит вне участка с i по j, уже стоит на итоговом месте. Участок с i по j — необработанная середина.
Инициализацияi = l, j = r: снаружи только треки вне участка, разворот их не касается
Сохранениеобмен ставит tracks[i] и tracks[j] на итоговые места, затем обе границы сдвигаются внутрь
Завершениеi ≥ j: в середине ноль или один трек, а один трек стоит на своём месте в любом случае

Запись (tracks[i], tracks[j]) = (tracks[j], tracks[i]) — обмен через кортеж, без временной переменной в коде.

07

Часть 4 · демонстрация · разворот tracks[1..6]

Демонстрация: разворот участка

Разворачивается участок с 1 по 6 в плейлисте из восьми треков. Серые ячейки — вне участка, зелёные — уже на итоговом месте, белые — необработанная середина между i и j. После каждого обмена зелёная зона растёт с двух сторон.

07

Часть 4 · условие остановки · нечётная и чётная длина

Условие остановки: i < j, а не i != j

При нечётной длине участка указатели приходят в один средний элемент — он в развороте не двигается, и цикл заканчивается при i == j. При чётной длине общего среднего нет: после последнего обмена i оказывается справа от j. Условие i < j в обоих случаях становится ложным, а i != j при чётной длине остаётся истинным, и цикл продолжает обмены.

5 треков, l = 0, r = 4, условие i < j
После шагаiji < j
до цикла04да
113да
222нет — встретились на среднем треке, два обмена
4 трека, l = 0, r = 3, условие i != j
После шагаijЧто происходит
до цикла03i != j — да
112да
221да — указатели разошлись, разворот уже готов
330обмен (2, 1) отменил шаг 2
44−1обмен (3, 0) отменил шаг 1, участок в исходном порядке
5——IndexErrorArgumentOutOfRangeException: tracks[4]

Вариант while i <= j тоже корректен: он добавляет один обмен среднего трека с самим собой. Условие i != j в тестах занятия роняет три теста, в том числе test_reverse_whole_list_even_length.

07

Часть 4 · сравнение способов

Способы развернуть участок

Тот же участок можно развернуть одной строкой со срезом. По времени способы одинаковы, по памяти — нет: срез создаёт копию участка, об этом был экран 8.

КодВремяДополнительная памятьЧто происходит
два указателя, while i < jO(k)O(1)k // 2 обменов в том же списке; k = r − l + 1
t[l:r + 1] = t[l:r + 1][::-1]O(k)O(k)срез справа создаёт копию участка, [::-1] — вторую, затем они записываются на место
t[l:r + 1] = reversed(t[l:r + 1])O(k)O(k)одна копия участка, reversed идёт по ней с конца
t.reverse()O(n)O(1)разворачивает весь список на месте; для участка не подходит

В рабочем коде строка со срезом читается проще, и при небольших k копия занимает мало памяти. На собеседовании условие «без срезов» ставят, чтобы проверить работу с индексами: где стоят границы, когда остановиться, что происходит при чётной длине.

Часть 4 · сравнение способов

Способы развернуть участок

В .NET есть встроенный разворот участка на месте, и он работает так же, как решение с двумя указателями. Способ с диапазоном и LINQ создаёт копии участка.

КодВремяДополнительная памятьЧто происходит
два указателя, while (i < j)O(k)O(1)k / 2 обменов в том же списке; k = r − l + 1
tracks.Reverse(l, r - l + 1)O(k)O(1)встроенный метод List<T>, разворот участка на месте; второй аргумент — количество
Array.Reverse(arr, l, r - l + 1)O(k)O(1)то же для массива
arr[l..(r + 1)].Reverse().ToArray()O(k)O(k)диапазон создаёт копию, ToArray() — вторую; исходный массив не меняется

В рабочем коде используют List<T>.Reverse(index, count). На собеседовании встроенный разворот запрещают, чтобы проверить работу с индексами: где стоят границы, когда остановиться, что происходит при чётной длине.

07

Часть 5 · копия или на месте · определение и сравнение

На месте или с копией

Разворот участка меняет переданный список и не создаёт нового. Такой способ называется изменением на месте (in-place): алгоритм меняет переданную структуру и использует O(1) дополнительной памяти, не считая самого входа, — например, reverse_segment, list.sort(), list.reverse()ReverseSegment, list.Sort(), list.Reverse(). Второй способ — собрать результат в новом объекте. Выбор задаёт контракт функцииметода, его уточняют вопросом «можно ли менять входной список?».

На местеС копией
Дополнительная памятьO(1)O(n)
Вход после вызоваизменён, исходные данные потерянытакой же, как до вызова
Проверка результатаисходный список нужно заранее скопировать в тестевход можно сравнить с результатом
Риск ошибкивыше: запись может затереть непрочитанный элемент, и повторить вычисление уже не на чемниже: чтение и запись идут в разные списки
Когда выбираютданных много, память ограничена, условие требует O(1)вход нужен после вызова, память не ограничена
Разбор на примере

Плейлист до и после вызова reverse_segment(tracks, 1, 4). Новый список не создаётся: меняется тот, который передали.

до: ['A', 'B', 'C', 'D', 'E', 'F']
после: ['A', 'E', 'D', 'C', 'B', 'F'] · функция вернула None · дополнительная память: i и j — O(1)

С копией то же сравнение выглядит так: copy = sorted(orig) оставляет orig прежним, и в памяти два списка.

Разбор на примере

Плейлист до и после вызова ReverseSegment(tracks, 1, 4). Новый список не создаётся: меняется тот, который передали.

до: { A, B, C, D, E, F }
после: { A, E, D, C, B, F } · метод объявлен как void · дополнительная память: i и j — O(1)

С копией то же сравнение выглядит так: var copy = orig.OrderBy(x => x).ToList(); оставляет orig прежним, и в памяти два списка.

07

Часть 5 · снимок Python Tutor

Присваивание не копирует список

В Python изменение на месте видно не только внутри функции. Переменная хранит ссылку, поэтому backup = feed копирует ссылку, и обе переменные указывают на один список. Функция получает ту же ссылку, удаляет последний элемент, и изменение видно через backup.

Python Tutor: переменные feed и backup указывают на один список, после drop_last в нём два элемента
Снимок pythontutor.com, последний шаг программы. Стрелки от feed и backup ведут к одному объекту.
СтрокаЧто происходит
4создан список из трёх комментариев, feed ссылается на него
5backup получает ту же ссылку
6drop_last получает ссылку в параметре comments и вызывает pop()
7печать backup — два элемента
8feed is backup — True: объект один

Часть 5 · запуск программы

Присваивание не копирует список

List<T> — ссылочный тип: переменная хранит ссылку на объект. Поэтому var backup = feed копирует ссылку, и обе переменные указывают на один список. Метод получает ту же ссылку, удаляет последний элемент, и изменение видно через backup.

alias.cs.NET 10
var feed = new List<string> { "Отличный урок", "Спасибо", "http://win.ru" };
var backup = feed;
DropLast(feed);
Console.WriteLine(string.Join(", ", backup));
Console.WriteLine(ReferenceEquals(feed, backup));

static void DropLast(List<string> comments) =>
    comments.RemoveAt(comments.Count - 1);
$ dotnet run alias.cs.NET 10
Отличный урок, Спасибо
True
СтрокаЧто происходит
1создан список из трёх комментариев, feed хранит ссылку на него
2backup получает ту же ссылку
3DropLast получает ссылку в параметре comments и вызывает RemoveAt
4печать backup — два элемента
5ReferenceEquals(feed, backup) — True: объект один
07

Часть 5 · копия списка

Как получить отдельный список

Если вход должен сохраниться, копию создают явно. Любой из способов ниже создаёт новый список со ссылками на те же элементы за O(n) времени и памяти.

КодНовый список?Где встречается
b = aнет, вторая ссылка на тот же списокпричина ошибки «список изменился сам»
f(a)нет, функция получает ссылкуфункция на месте меняет список вызывающего кода
b = a.copy(), a[:], list(a)да, O(n)сохранить вход перед вызовом функции на месте

В тестах занятия проверяется обратное: same = comments до вызова и same is comments после. Так тест убеждается, что функция изменила тот же объект и не подменила его новым.

Разбор на примере

Проверка на маленьком списке: копия живёт отдельно, вторая ссылка — нет.

>>> a = [1, 2] · b = a.copy() · b.append(3)
a → [1, 2] · b → [1, 2, 3] · a is b → False
>>> c = a · c.append(3) · a → [1, 2, 3] · a is c → True

Часть 5 · копия списка

Как получить отдельный список

Если вход должен сохраниться, копию создают явно. Любой из способов ниже создаёт новый список или массив за O(n) времени и памяти. Для ссылочных типов копируются ссылки, сами объекты остаются общими.

КодНовый объект?Где встречается
var b = a;нет, вторая ссылка на тот же списокпричина ошибки «список изменился сам»
F(a)нет, метод получает ссылкуметод на месте меняет список вызывающего кода
new List<T>(a), a.ToList()да, O(n)сохранить вход перед вызовом метода на месте
arr[..], (int[])arr.Clone()да, O(n)копия массива

В тестах занятия проверяется обратное: var same = c; до вызова и ReferenceEquals(same, c) после. Так тест убеждается, что метод изменил тот же объект и не подменил его новым.

Разбор на примере

Проверка на маленьком списке: копия живёт отдельно, вторая ссылка — нет.

var a = new List<int> { 1, 2 }; var b = new List<int>(a); b.Add(3);
a → 1, 2 · b → 1, 2, 3 · ReferenceEquals(a, b) → False
var c = a; c.Add(3); · a → 1, 2, 3 · ReferenceEquals(a, c) → True

07

Часть 5 · контракт функции

Контракт функции, которая меняет список

Раз функция может менять вход, в контракте записывают, что она возвращает. В Python и в задачах собеседований встречаются три варианта, и в трёх задачах занятия есть все три.

КонтрактПримерыКак проверяется тестом
меняет на месте, возвращает Nonelist.sort(), list.reverse(), reverse_segmentвызов, затем сравнение самого списка
не меняет, возвращает результатsorted(a), a[::-1], max_streakсравнение результата и проверка, что вход тот же
меняет на месте, возвращает число kremove_spam; LeetCode 27 Remove Elementсравнение k и первых k элементов
Соглашение PythonФункция, которая меняет аргумент на месте, возвращает None. Поэтому a = a.sort() записывает в a значение None. В remove_spam число возвращается по условию задачи.
Разбор на примере

Один и тот же список, два вида вызова. Настоящий вывод:

>>> b = [3, 1, 2] · print(b.sort()) → None · print(b) → [1, 2, 3]
>>> print(sorted([3, 1, 2])) → [1, 2, 3] — вход остался прежним

Поэтому запись b = b.sort() теряет список: в b окажется None.

Часть 5 · контракт метода

Контракт метода, который меняет список

Раз метод может менять вход, в контракте записывают, что он возвращает. В .NET и в задачах собеседований встречаются три варианта, и в трёх задачах занятия есть все три.

КонтрактПримерыКак проверяется тестом
меняет на месте, возвращает voidlist.Sort(), list.Reverse(), ReverseSegmentвызов, затем сравнение самого списка
не меняет, возвращает результатa.OrderBy(x => x).ToList(), arr[1..3], MaxStreakсравнение результата и проверка, что вход тот же
меняет на месте, возвращает числоRemoveSpam — число оставшихся; list.RemoveAll(…) — число удалённыхсравнение числа и содержимого списка
Соглашение .NETМетоды List<T>, которые меняют список (Sort, Reverse, Clear), возвращают void. Методы LINQ (OrderBy, Where, Reverse из System.Linq) источник не меняют и возвращают новую последовательность.
Разбор на примере

Один и тот же список, два вида вызова. Настоящий вывод:

var b = new List<int> { 3, 1, 2 }; b.Sort(); → b = 1, 2, 3 · метод ничего не возвращает
var sorted = orig.OrderBy(x => x).ToList(); → orig = 3, 1, 2 · sorted = 1, 2, 3

Поэтому var b = list.Sort(); даже не скомпилируется: у метода тип void.

07

Часть 6 · задача 3 · условие и ограничения

Задача 3: удаление спама на месте

Третья задача возвращает к коду из начала занятия. Модерация получает список комментариев, спам — комментарий со ссылкой, функция is_spam уже написана. Нужно удалить спам из того же списка, сохранить порядок остальных и вернуть их число. Ограничения исключают два очевидных решения: новый список нарушает требование к памяти, удаление по одному — к времени.

ДоОтветПосле
["Отличный урок", "http://win.ru", "http://prize.ru", "Спасибо"]2["Отличный урок", "Спасибо"]
["http://a.ru"]0[]
[]0[]
ОграничениеПричина
новый список не создаётсядополнительная память O(1)
нет remove() и del a[i] внутри циклакаждое удаление сдвигает хвост списка: n удалений дают O(n²)
один del a[k:] после цикла разрешёнобрезка хвоста за одну операцию
порядок сохраняется, вернуть число оставшихсялента идёт по времени; контракт «первые k элементов»

Часть 6 · задача 3 · условие и ограничения

Задача 3: удаление спама на месте

Третья задача возвращает к коду из начала занятия. Модерация получает список комментариев, спам — комментарий со ссылкой, метод IsSpam уже написан. Нужно удалить спам из того же списка, сохранить порядок остальных и вернуть их число. Ограничения исключают три очевидных решения: новый список нарушает требование к памяти, удаление по одному — к времени, встроенный RemoveAll решает задачу без вашего кода; как он устроен — экран 41.

ДоОтветПосле
{ "Отличный урок", "http://win.ru", "http://prize.ru", "Спасибо" }2{ "Отличный урок", "Спасибо" }
{ "http://a.ru" }0{ }
{ }0{ }
ОграничениеПричина
новый список не создаётся, LINQ не используетсядополнительная память O(1)
нет Remove и RemoveAt внутри циклакаждое удаление сдвигает хвост списка: n удалений дают O(n²)
нет RemoveAllметод делает всю задачу; цель занятия — написать этот проход самому
один RemoveRange(w, comments.Count - w) после цикла разрешёнобрезка хвоста за одну операцию
порядок сохраняется, вернуть число оставшихсялента идёт по времени; контракт «первые k элементов»
07

Часть 6 · разбор версии с del

Разбор версии с del по индексу

Теперь есть всё, чтобы объяснить вторую версию из начала занятия. range(len(comments)) вычисляется один раз до цикла, а длина списка после каждого del уменьшается. Индексы цикла и индексы элементов перестают совпадать.

1 · Сдвиг хвоста

del comments[i] сдвигает все элементы правее i на одну позицию влево. На место i приходит следующий элемент.

2 · Пропуск

Цикл переходит к i + 1. Элемент, пришедший на место i, не проверяется. Два спама подряд — второй остаётся.

3 · Выход за границу

range по-прежнему даёт исходное число индексов, а элементов стало меньше. Последний индекс выходит за список: IndexError.

Следующий экран показывает это на тех же четырёх комментариях. Нарушено утверждение «каждый элемент проверен ровно один раз».

Часть 6 · разбор версии с RemoveAt

Разбор версии с RemoveAt в цикле for

Теперь есть всё, чтобы объяснить вторую версию из начала занятия. Условие i < comments.Count проверяется перед каждым шагом, поэтому индекс не выходит за список и исключения нет. Но после RemoveAt(i) индексы цикла и индексы элементов перестают совпадать.

1 · Сдвиг хвоста

RemoveAt(i) сдвигает все элементы правее i на одну позицию влево. На место i приходит следующий элемент.

2 · Пропуск

Цикл выполняет i++. Элемент, пришедший на место i, не проверяется. Два спама подряд — второй остаётся.

3 · Если сохранить длину

Вариант int n = comments.Count; for (…; i < n; …) доходит до индексов за концом списка и падает с ArgumentOutOfRangeException.

Следующий экран показывает это на тех же четырёх комментариях. Нарушено утверждение «каждый элемент проверен ровно один раз».

07

Часть 6 · демонстрация · версия с del

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

Красная рамка — элемент, который сдвинулся на проверенную позицию и был пропущен. Следите за столбцом len: он уменьшается, а i продолжает расти до 3.

Часть 6 · демонстрация · версия с RemoveAt

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

Красная рамка — элемент, который сдвинулся на проверенную позицию и был пропущен. Следите за столбцом Count: он уменьшается, условие цикла это учитывает, но пропущенный элемент уже позади индекса i.

07

Часть 6 · разбор версии с remove()

Разбор версии с remove() в цикле for

Первая версия не использует индексы явно, но ошибается по той же причине. Цикл for text in comments хранит внутренний номер текущей позиции и на каждом шаге увеличивает его на 1. remove() сдвигает хвост, и следующий элемент оказывается на уже пройденной позиции.

Номер позиции в циклеСписок перед шагомtextДействие
0урок, win, prize, спасибоурокоставить
1урок, win, prize, спасибоwinremove → prize сдвигается на позицию 1
2урок, prize, спасибоспасибоprize на позиции 1 не проверен
3урок, prize, спасибо—позиций больше нет, цикл закончен

Итог совпадает с выводом на экране 2: ['Отличный урок', 'http://prize.ru', 'Спасибо']. Правило для обеих версий: список не меняют по длине во время прохода по нему.

Часть 6 · разбор версии с foreach

Разбор версии с Remove() в цикле foreach

Первая версия из начала занятия падает сразу после первого удаления. Цикл foreach работает через перечислитель списка. List<T> хранит счётчик изменений, а перечислитель на каждом шаге проверяет, что счётчик не изменился с начала обхода.

Шаг перечислителяtextДействиеСчётчик изменений
MoveNext · позиция 0урокоставитькак в начале обхода
MoveNext · позиция 1winRemove — хвост сдвинулсяувеличился на 1
MoveNext—счётчик отличается → InvalidOperationExceptionпроверка не прошла

Эта защита есть только у foreach. Цикл for по индексам её не имеет и ошибается молча — экран 34. Правило для обеих версий: список не меняют по длине во время прохода по нему.

07

Часть 6 · указатели чтения и записи · три зоны списка

Указатели чтения и записи

Обе ошибки возникают из-за удаления во время прохода. Верное решение ничего не удаляет в цикле: указатель чтения r проходит все индексы по одному разу, указатель записи w показывает, куда записать следующий обычный комментарий, и сдвигается только после записи. Всегда w ≤ r, поэтому запись попадает на уже прочитанную позицию и не затирает непрочитанное. Хвост обрезается один раз после цикла. Два указателя делят список на три зоны.

[0, w) · готово

Все обычные комментарии из просмотренной части, в исходном порядке. Эта зона и станет ответом.

[w, r) · мусор

Спам и старые копии уже перенесённых комментариев. Содержимое неважно, оно будет обрезано.

[r, n) · не просмотрено

Исходные элементы, к которым цикл ещё не дошёл. Запись сюда не попадает.

Инвариант задачи 3Перед шагом с номером r: comments[0:w]comments[0..w] — все обычные комментарии из comments[0:r]comments[0..r], в исходном порядке.
Разбор на примере

Лента ["Да", "http://a", "http://b", "Нет", "Ок"]. После трёх шагов r = 3, w = 1. Шаг r = 3: «Нет» — не спам → comments[1] = comments[3], w = 2:

0Да1Нет2http://b3Нет4Ок

готово [0, w) → «Да», «Нет» · мусор [w, r) → «http://b», «Нет» (старая копия) · не просмотрено [r, n) → «Ок»

Дубликат «Нет» в зоне мусора — нормально: эта зона будет обрезана.

Разбор на примере

Лента { "Да", "http://a", "http://b", "Нет", "Ок" }. После трёх шагов r = 3, w = 1. Шаг r = 3: «Нет» — не спам → comments[1] = comments[3], w = 2:

0Да1Нет2http://b3Нет4Ок

готово [0, w) → «Да», «Нет» · мусор [w, r) → «http://b», «Нет» (старая копия) · не просмотрено [r, n) → «Ок»

Дубликат «Нет» в зоне мусора — нормально: эта зона будет обрезана.

07

Часть 6 · демонстрация · remove_spam

Демонстрация: удаление на месте

Семь комментариев, три из них спам. Зелёная зона — готово, штриховка — мусор, бледные ячейки — не просмотрено. Обычный комментарий переписывается в позицию w, спам остаётся в мусоре. В последнем кадре хвост обрезается одной операцией.

Часть 6 · демонстрация · RemoveSpam

Демонстрация: удаление на месте

Семь комментариев, три из них спам. Зелёная зона — готово, штриховка — мусор, бледные ячейки — не просмотрено. Обычный комментарий переписывается в позицию w, спам остаётся в мусоре. В последнем кадре хвост обрезается одним вызовом RemoveRange.

07

Часть 6 · задача 3 · решение

Решение задачи 3

Код записывает демонстрацию: один проход указателем r, запись по w, одна обрезка хвоста после цикла. Три проверки инварианта — справа.

solution.pyO(n) · O(1)
def remove_spam(comments):
    w = 0
    for r in range(len(comments)):
        if not is_spam(comments[r]):
            comments[w] = comments[r]
            w += 1
    del comments[w:]
    return w
Инициализацияr = 0, w = 0: обе зоны пустые
Сохранениеобычный комментарий дописывается в конец зоны «готово», спам пропускается; порядок в зоне совпадает с порядком просмотра
Завершениеr = n: в [0:w] все обычные комментарии списка. del comments[w:] убирает мусор

Обрезка хвоста стоит O(n − w) и выполняется один раз. Если спама нет, w == n и участок [n:] пустой — экран 9.

Часть 6 · задача 3 · решение

Решение задачи 3

Код записывает демонстрацию: один проход указателем r, запись по w, одна обрезка хвоста после цикла. Три проверки инварианта — справа.

Solution.csO(n) · O(1)
public static int RemoveSpam(List<string> comments)
{
    int w = 0;
    for (int r = 0; r < comments.Count; r++)
        if (!IsSpam(comments[r]))
            comments[w++] = comments[r];
    comments.RemoveRange(w, comments.Count - w);
    return w;
}
Инициализацияr = 0, w = 0: обе зоны пустые
Сохранениеобычный комментарий дописывается в конец зоны «готово», спам пропускается; порядок в зоне совпадает с порядком просмотра
Завершениеr = n: в comments[0..w] все обычные комментарии списка. RemoveRange убирает мусор

comments[w++] = comments[r] записывает в позицию w, затем увеличивает w. Обрезка хвоста стоит O(n − w) и выполняется один раз; если спама нет, удаляется 0 элементов — экран 9.

07

Часть 6 · сравнение способов · оценка и замер · Python 3.12, медиана 7 запусков, 30 % спама

Сложность и замер способов удаления

Две версии из начала занятия дают неверный результат: remove() в for text in comments пропускает спам, del comments[i] в for i in range(len(...)) падает с IndexError. Верные способы сравнены по оценке и замером на списках из 10³, 10⁴ и 10⁵ элементов.

Верный способВремяПамятьn = 1 000n = 10 000n = 100 000
del a[i] в while с ручным i, который не растёт после удаленияO(n²)O(1)0,05 мс1,50 мс178,31 мс
генератор списка a[:] = [x for x in a if not is_spam(x)]O(n)O(n)0,01 мс0,09 мс0,83 мс
указатели чтения и записиO(n)O(1)0,02 мс0,24 мс2,43 мс
Рост времени при O(n²)При росте n в 10 раз время удаления через del выросло примерно в 30 и в 120 раз. Каждое удаление сдвигает весь хвост списка, а удалений около 0,3 · n.
Почему генератор быстрее указателейЦикл генератора выполняется внутри интерпретатора на C, цикл с указателями — на Python. Генератор выигрывает по времени и проигрывает по памяти: в момент присваивания в памяти два списка.

Замер на ноутбуке автора, time.perf_counter(), копия входа создаётся вне замера. На другой машине числа будут другими, соотношение сохранится.

Часть 6 · сравнение способов · оценка и замер · .NET 10, Release, медиана 7 запусков, 30 % спама

Сложность и замер способов удаления

Две версии из начала занятия дают неверный результат: Remove() в foreach падает с InvalidOperationException, RemoveAt(i) в for с i++ пропускает спам. Верные способы сравнены по оценке и замером на списках из 10³, 10⁴ и 10⁵ элементов.

Верный способВремяПамятьn = 1 000n = 10 000n = 100 000
RemoveAt(i) в while, i не растёт после удаленияO(n²)O(1)0,02 мс1,42 мс194,60 мс
LINQ: Where(…).ToList(), затем Clear и AddRangeO(n)O(n)0,01 мс0,15 мс0,89 мс
указатели чтения и записиO(n)O(1)0,01 мс0,04 мс0,35 мс
comments.RemoveAll(IsSpam)O(n)O(1)0,01 мс0,05 мс0,57 мс
Рост времени при O(n²)При росте n в 10 раз время удаления через RemoveAt выросло примерно в 70 и в 140 раз. Каждое удаление сдвигает весь хвост списка, а удалений около 0,3 · n.
Указатели и RemoveAllУказатели быстрее копии: цикл компилируется в машинный код, второго списка нет. RemoveAll делает тот же проход, но вызывает делегат-предикат для каждого элемента.

Замер на ноутбуке автора, Stopwatch, сборка Release, два первых запуска из девяти отброшены как прогрев, копия входа создаётся вне замера. На другой машине числа будут другими, соотношение сохранится.

07

Часть 6 · исходник · dotnet/runtime, List.cs

Как устроен RemoveAll

Встроенный RemoveAll в .NET написан тем же приёмом, что решение задачи 3. Поэтому его сложность O(n) по времени и O(1) по памяти, и поэтому в задаче он запрещён: это готовое решение.

Метод RemoveAll из файла List.cs репозитория dotnet/runtime, строки 960–988
github.com/dotnet/runtime · release/10.0 · List.cs, строки 962–997
В List.csВ решении задачи 3
freeIndex — «первая свободная ячейка»w, указатель записи
currentr, указатель чтения
строки 972–973: пропустить начало без удаленийзапись comments[w] = comments[r] при w == r
строка 984: _items[freeIndex++] = _items[current++]comments[w++] = comments[r]
Array.Clear хвоста и _size = freeIndexRemoveRange(w, comments.Count - w)
возвращает _size - freeIndex — число удалённыхвозвращает w — число оставшихся
07

Часть 6 · вариант условия

Удаление без сохранения порядка

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

вариант без сохранения порядкаO(n) · O(1)
def remove_spam_unordered(comments):
    i, n = 0, len(comments)
    while i < n:
        if is_spam(comments[i]):
            n -= 1
            comments[i] = comments[n]
            # i не двигается: на место i пришёл
            # непроверенный элемент
        else:
            i += 1
    del comments[n:]
    return n
Инвариантcomments[0:i] — проверенные обычные комментарии, comments[i:n] — непроверенные, comments[n:] — мусор.

В ветке if после замены i остаётся на месте. Если сдвинуть i, пришедший элемент не будет проверен — та же ошибка, что в версиях из начала занятия.

Часть 6 · вариант условия

Удаление без сохранения порядка

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

вариант без сохранения порядкаO(n) · O(1)
static int RemoveSpamUnordered(List<string> comments)
{
    int i = 0, n = comments.Count;
    while (i < n)
    {
        if (IsSpam(comments[i]))
            comments[i] = comments[--n];
            // i не двигается: на место i пришёл
            // непроверенный элемент
        else
            i++;
    }
    comments.RemoveRange(n, comments.Count - n);
    return n;
}
Инвариантcomments[0..i] — проверенные обычные комментарии, comments[i..n] — непроверенные, comments[n..] — мусор.

comments[--n] сначала уменьшает n, затем читает элемент. В ветке if после замены i остаётся на месте. Если сдвинуть i, пришедший элемент не будет проверен — та же ошибка, что в версиях из начала занятия.

07

Часть 7 · динамический массив · определение

Динамический массив: размер и ёмкость

Список умеет расти: append добавляет элемент в конец. Блок при этом должен остаться непрерывным, иначе формула адреса перестанет работать. Если за последней ячейкой места нет, выделяется блок больше и все ссылки копируются в него — это O(n). Если бы так было при каждом append, n добавлений стоили бы O(n²). Python этого избегает: память выделяется с запасом.

Динамический массивlen · capacity
Размер
Сколько элементов в списке. Возвращается len(a), в исходнике CPython — поле ob_size.
Ёмкость
Под сколько ячеек выделена память, всегда не меньше размера. Из Python напрямую не видна, в CPython — поле allocated. Пока запас capacity − len есть, append пишет в свободную ячейку за O(1).
Переезд
Запас кончился — блок заменяется большим с копированием всех ссылок. Новая ёмкость пропорциональна размеру, поэтому переезды становятся всё реже.
Где встречается
list в Python, std::vector в C++, ArrayList в Java. Отсюда append и pop() за O(1), а insert(0, x) и pop(0) — за O(n).
Разбор на примере

Десять вызовов append у пустого списка: ёмкость меняется трижды, остальные семь вызовов пишут в запас.

ёмкость по шагам: 4, 4, 4, 4, 8, 8, 8, 8, 16, 16 · переезды на шагах 1, 5 и 9
при len = 5 в списке 5 значений, а места выделено под 8: запас 3 ячейки

Часть 7 · динамический массив · определение

Динамический массив: размер и ёмкость

Массив int[] создаётся с фиксированной длиной, а List<T> умеет расти: Add добавляет элемент в конец. Внутри списка лежит обычный массив, и если в нём места нет, создаётся массив большей длины и все элементы копируются — это O(n). Если бы так было при каждом Add, n добавлений стоили бы O(n²). .NET этого избегает: массив выделяется с запасом.

Динамический массивCount · Capacity
Размер
Сколько элементов в списке — свойство Count, в исходнике поле _size.
Ёмкость
Длина внутреннего массива — свойство Capacity, всегда не меньше Count. Её можно задать заранее: new List<int>(1000). Пока запас Capacity − Count есть, Add пишет в свободную ячейку за O(1).
Переезд
Запас кончился — создаётся массив большей длины, элементы копируются через Array.Copy. Новая ёмкость пропорциональна текущей, поэтому переезды становятся всё реже.
Где встречается
List<T> в C#, list в Python, std::vector в C++, ArrayList в Java. Отсюда Add и RemoveAt(Count - 1) за O(1), а Insert(0, x) и RemoveAt(0) — за O(n).
Разбор на примере

Десять вызовов Add у пустого списка: ёмкость меняется трижды, остальные семь вызовов пишут в запас.

Capacity по шагам: 4, 4, 4, 4, 8, 8, 8, 8, 16, 16 · переезды на шагах 1, 5 и 9
при Count = 5 в списке 5 значений, а места выделено под 8: запас 3 ячейки

07

Часть 7 · эксперимент · sys.getsizeof

Замер ёмкости через sys.getsizeof

Ёмкость из Python не видна, но её можно вычислить: sys.getsizeof возвращает размер объекта списка — 56 байт заголовка и 8 байт на каждую выделенную ячейку, без самих объектов. Программа печатает строку, когда размер изменился.

capacity.pyPython 3.12
import sys

lst = []
prev = sys.getsizeof(lst)
print(0, prev)
for i in range(1, 33):
    lst.append(i)
    size = sys.getsizeof(lst)
    if size != prev:
        print(i, size, (size - 56) // 8)
    prev = size
$ python3 capacity.py3.12.12
0 56
1 88 4
5 120 8
9 184 16
17 248 24
25 312 32
len после appendбайтёмкость
0560
1–4884
5–81208
9–1618416
17–2424824
25–3231232

Ёмкость — (размер − 56) // 8. За 32 вызова append переездов было 5, остальные 27 вызовов записали ссылку в запас.

Часть 7 · эксперимент · List<T>.Capacity

Замер ёмкости через Capacity

В C# ёмкость читается прямо из свойства Capacity. Программа добавляет 32 элемента и печатает строку каждый раз, когда ёмкость изменилась.

capacity.cs.NET 10
var list = new List<int>();
int prev = list.Capacity;
Console.WriteLine($"0 {prev}");
for (int i = 1; i <= 32; i++)
{
    list.Add(i);
    if (list.Capacity != prev)
    {
        Console.WriteLine($"{i} {list.Capacity}");
        prev = list.Capacity;
    }
}
$ dotnet run capacity.cs.NET 10
0 0
1 4
5 8
9 16
17 32
Count после AddCapacity
00
1–44
5–88
9–1616
17–3232

Первая ёмкость — 4, дальше каждый переезд удваивает её. За 32 вызова Add массив выделялся 4 раза: при первом добавлении и три переезда с копированием; остальные 28 вызовов записали элемент в запас.

07

Часть 7 · исходник · CPython 3.12, Objects/listobject.c

Формула запаса в функции list_resize

Последовательность 4, 8, 16, 24, 32 из замера задана в исходнике интерпретатора. Функция list_resize вызывается при каждом изменении длины списка и решает, нужен ли переезд и какой будет новая ёмкость.

Фрагмент функции list_resize из файла Objects/listobject.c репозитория CPython, строки 51–79
github.com/python/cpython · 3.12 · listobject.c, строки 61–76
55–59если новая длина помещается в выделенную память и не меньше её половины, меняется только поле размера: переезда нет
67последовательность ёмкостей 0, 4, 8, 16, 24, 32, 40, 52… — та же, что в замере
71newsize + newsize / 8 + 6, округлённое вниз до кратного 4: запас около 12,5 % плюс константа
63–65комментарий разработчиков: запаса достаточно для линейного общего времени длинной серии append

Переезд выполняет PyMem_Realloc. Если за блоком свободно, система расширяет его без копирования; если нет — копирует в новый. Анализ считает худший случай, копирование.

Часть 7 · исходник · dotnet/runtime, List.cs

Формула роста в методе GetNewCapacity

Последовательность 4, 8, 16, 32 из замера задана в исходнике .NET. Метод GetNewCapacity вызывается, когда в массиве нет места, и вычисляет новую длину.

Метод GetNewCapacity из файла List.cs репозитория dotnet/runtime, строки 487–503
github.com/dotnet/runtime · release/10.0 · List.cs, строки 487–503
492пустой массив получает DefaultCapacity — константа 4 в строке 23 файла; иначе длина удваивается
496ёмкость не превышает Array.MaxLength, около 2 млрд элементов
500если нужно больше удвоенного, например при AddRange, берётся требуемая длина

Сеттер Capacity создаёт new T[value] и копирует элементы через Array.Copy, поэтому каждый переезд — копирование всех элементов.

07

Часть 7 · демонстрация · стоимость append

Демонстрация: стоимость append

Стоимость одного append считается в операциях: 1 запись, а при переезде ещё копирование всех элементов. Добавьте 64 элемента в каждом из трёх режимов и сравните линию среднего. При запасе, пропорциональном размеру, она выходит на константу. Без запаса переезд идёт на каждом вызове, и среднее растёт вместе с n.

Часть 7 · демонстрация · стоимость Add

Демонстрация: стоимость Add

Стоимость одного Add считается в операциях: 1 запись, а при переезде ещё копирование всех элементов. Добавьте 64 элемента в каждом из двух режимов и сравните линию среднего. В режиме List<T> она выходит на константу. Без запаса переезд идёт на каждом вызове, и среднее растёт вместе с n.

07

Часть 7 · амортизированная стоимость · определение

Амортизированная стоимость

На демонстрации отдельный appendAdd иногда стоил десятки операций, а среднее оставалось небольшим. Для такой оценки есть отдельный термин.

Амортизированная стоимостьamortized cost
Что это
Общая стоимость худшей последовательности из n операций, делённая на n.
Чем отличается от средней
Вероятности здесь нет: оценка гарантирована для любой последовательности вызовов, в том числе самой неудачной.
Пример
«appendAdd — O(1) амортизированно»: n вызовов подряд стоят O(n), хотя отдельный вызов с переездом стоит O(n).
Зачем в задаче
Решение, которое собирает ответ через appendAdd в цикле, остаётся O(n). Так его и оценивают на собеседовании.
Разбор на примере

64 вызова append у пустого списка, посчитано по формуле роста CPython:

переездов 7 · скопировано 176 элементов · записей 64 · всего 240 операций
в среднем 3,75 операции на вызов — это и есть амортизированная стоимость

Самый дорогой вызов стоил 53 операции, самый дешёвый — 1, но их сумма делится на 64 вызова.

Разбор на примере

64 вызова Add у пустого списка, посчитано по формуле роста List<T>:

переездов 4 · скопировано 60 элементов · записей 64 · всего 124 операции
в среднем 1,94 операции на вызов — это и есть амортизированная стоимость

Самый дорогой вызов стоил 33 операции, самый дешёвый — 1, но их сумма делится на 64 вызова.

07

Часть 7 · амортизированная стоимость · расчёт и сравнение режимов

Расчёт: удвоение, запас ⅛ и рост без запаса

Проще всего посчитать режим «удвоение»: ёмкость растёт 1, 2, 4, 8…, переезд происходит, когда размер достигает степени двойки, и копируется столько элементов, сколько было в списке. Числа совпадают с демонстрацией.

Что считаемДля n = 64В общем виде
записи новых элементов64n
копирования при переездах1 + 2 + 4 + 8 + 16 + 32 = 631 + 2 + … + n/2 < n — геометрическая прогрессия
всего операций127< 2n
в среднем на один append1,98< 2 — константа, O(1)
CPython · запас ⅛копирований около 8n

Ёмкость растёт примерно в 1,125 раза. Прогрессия тоже геометрическая, константа больше, чем при удвоении, но среднее ограничено: O(1). В демонстрации при n = 64 — 3,75 операции на вызов.

Без запаса · +10 + 1 + … + (n − 1) ≈ n² / 2

Переезд на каждом вызове, прогрессия арифметическая. Серия из n append стоит O(n²), в среднем O(n) на вызов. В демонстрации при n = 64 — 32,5 операции на вызов, и число растёт с n.

Для КТП достаточно объяснения: отдельный переезд дорогой, но запас растёт пропорционально размеру, поэтому переезды редкие и их суммарная цена распределяется по всем вызовам.

Часть 7 · амортизированная стоимость · расчёт и сравнение режимов

Расчёт для List<T> и рост без запаса

List<T> удваивает ёмкость: 4, 8, 16, 32, 64. Переезд происходит, когда список заполнен, и копируется столько элементов, сколько было в списке. Числа совпадают с демонстрацией в режиме List<T>.

Что считаемДля n = 64В общем виде
записи новых элементов64n
копирования при переездах4 + 8 + 16 + 32 = 604 + 8 + … + n/2 < n — геометрическая прогрессия
всего операций124< 2n
в среднем на один Add1,94< 2 — константа, O(1)
.NET · удвоение с 4копирований меньше n

Среднее меньше 2 операций на вызов: O(1). В CPython запас меньше, около ⅛, прогрессия тоже геометрическая, и среднее выше, но тоже ограничено.

Без запаса · +10 + 1 + … + (n − 1) ≈ n² / 2

Переезд на каждом вызове, прогрессия арифметическая. Серия из n Add стоит O(n²), в среднем O(n) на вызов. В демонстрации при n = 64 — 32,5 операции на вызов, и число растёт с n.

Если число элементов известно заранее, ёмкость задают в конструкторе: new List<int>(n). Переездов тогда не будет совсем.

07

Часть 8 · практика · 30 минут

Практика по стартовому набору lesson_07

Теория закончена, дальше три функции своими руками. В стартовом наборе те же задачи, что разобраны на экранах, и 27 тестов. Решение пишется по инварианту: сначала что хранить между итерациями, потом код.

  1. Скопировать lesson_07/ в portfolio/lesson_07/ личного репозитория, запустить python -m pytest tests.py -v. Все тесты падают с NotImplementedError.
  2. До кода записать в initial_attempt.txt вопросы к условию и три своих примера на задачу.
  3. Решить max_streak, запустить pytest -k streak. Затем remove_spam, pytest -k remove.
  4. Задача 2 reverse_segment — расширение, после двух обязательных.
  5. Для каждой функции записать инвариант в complexity_note.md.
Запрещено

Сортировка входа, новые списки в задаче 3, срезы и reversed() в задаче 2, ИИ и поиск до первой собственной версии.

Промежуточная проверка

На 15-й минуте преподаватель смотрит initial_attempt.txt и первую зелёную задачу.

30:00

Часть 8 · практика · 30 минут

Практика по стартовому набору lesson_07_cs

Теория закончена, дальше три метода своими руками. В стартовом наборе те же задачи, что разобраны на экранах, и 27 тестов. Решение пишется по инварианту: сначала что хранить между итерациями, потом код.

  1. Скопировать lesson_07_cs/ в portfolio/lesson_07/ личного репозитория, выполнить dotnet run. Все тесты падают с NotImplementedException.
  2. До кода записать в initial_attempt.txt вопросы к условию и три своих примера на задачу.
  3. Решить MaxStreak, запустить dotnet run -- streak. Затем RemoveSpam, dotnet run -- remove.
  4. Задача 2 ReverseSegment — расширение, после двух обязательных.
  5. Для каждого метода записать инвариант в complexity_note.md.
$ dotnet run -- streakдо решения
FAILED  streak_empty — NotImplementedException: …
FAILED  streak_single_day_reached — NotImplementedException: …
…
0 passed, 9 failed
30:00
07

Часть 8 · без ИИ и без запуска · 5 минут

Таблица состояния указателей на пяти шагах

Заполните таблицу для remove_spam(["Да", "http://a", "http://b", "Нет", "Ок"])RemoveSpam({ "Да", "http://a", "http://b", "Нет", "Ок" }) по коду с экрана 39. В строке — состояние после шага с номером r. Последний столбец — содержимое списка после шага.

rcomments[r]спамдействиеw после шагасписок после шага
0Данетa[0] = a[0], w += 11Да, http://a, http://b, Нет, Ок
1http://aдапропустить1Да, http://a, http://b, Нет, Ок
2http://bдапропустить1Да, http://a, http://b, Нет, Ок
3Нетнетa[1] = a[3], w += 12Да, Нет, http://b, Нет, Ок
4Окнетa[2] = a[4], w += 13Да, Нет, Ок, Нет, Ок

После цикла del comments[3:]comments.RemoveRange(3, 2) даёт ["Да", "Нет", "Ок"]{ "Да", "Нет", "Ок" }, функция возвращаетметод возвращает 3. В строках 3 и 4 в зоне мусора остаются старые копии «Нет» и «Ок» — это нормально.

07

Часть 8 · карточка изменения условия · после зелёных тестов задачи 3

Карточка изменения условия: split_spam

На собеседовании условие часто меняют после рабочего решения. Здесь изменение касается задачи 3: удалять нельзя, спам нужно сохранить. Меняется то, что лежит в зоне [w, r).

Напишите split_spam(comments) -> int. Функция переставляет элементы в том же списке: сначала обычные комментарии в исходном порядке, за ними весь спам в любом порядке. Длина списка не меняется, возвращается число обычных комментариев. Время O(n), память O(1).

ДоОтветПосле
["1", "http://x", "2", "http://y", "3"]3["1", "2", "3", …спам…]
  1. Записать, чем новый инвариант отличается от инварианта remove_spam: что теперь лежит в зоне [w, r).
  2. Добавить три теста: без спама, весь спам, спам в начале и в конце.
  3. Прогнать весь набор, включая старые тесты.
  4. Дописать строку в complexity_note.md.
10:00

Часть 8 · карточка изменения условия · после зелёных тестов задачи 3

Карточка изменения условия: SplitSpam

На собеседовании условие часто меняют после рабочего решения. Здесь изменение касается задачи 3: удалять нельзя, спам нужно сохранить. Меняется то, что лежит в зоне [w, r).

Напишите public static int SplitSpam(List<string> comments). Метод переставляет элементы в том же списке: сначала обычные комментарии в исходном порядке, за ними весь спам в любом порядке. Длина списка не меняется, возвращается число обычных комментариев. Время O(n), память O(1).

ДоОтветПосле
{ "1", "http://x", "2", "http://y", "3" }3{ "1", "2", "3", …спам… }
  1. Записать, чем новый инвариант отличается от инварианта RemoveSpam: что теперь лежит в зоне [w, r).
  2. Добавить три теста: без спама, весь спам, спам в начале и в конце.
  3. Прогнать весь набор, включая старые тесты: dotnet run.
  4. Дописать строку в complexity_note.md.
10:00
07

Часть 8 · проверка перед сдачей

Проверка перед сдачей и работа с ИИ

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

Разрешено

После собственной попытки: попросить модель найти крайние случаи, предложить дополнительные тесты или сделать review кода.

Проверить самим

Каждый совет модели — запуском тестов. Инвариант и сложность вы доказываете сами, на своём коде.

Недопустимо

Сдавать первый сгенерированный ответ, скрывать запрос и diff, принимать тесты без запуска, объяснять код словами модели.

Evidence

initial_attempt.txt, ai_usage_note.md при использовании, diff, вывод pytestdotnet run, исправление и объяснение решения.

Перед сдачейКоманда или файл
все тесты проходят одной командойpython -m pytest tests.py -vdotnet run
найденная ошибка воспроизведена отдельным тестомблок «Мои тесты» в tests.pyTests.cs
инвариант и сложность записаны своими словамиcomplexity_note.md
вопросы и примеры до кода сохраненыinitial_attempt.txt
07

Итог занятия · сравнительная таблица

Итог: сравнение трёх задач

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

ЗадачаУказателиИнвариантВремяПамятьГраничные тесты
максимальная серияicur — серия, оканчивающаяся на i; best — лучшая среди дней с 0 по iO(n)O(1)[]{ }, ровно цель, серия в конце
разворот участкаi → ← jвсё вне участка с i по j на итоговом местеO(k)O(1)l == r, чётная длина, неверные границы
удаление на местеw ≤ r, оба вправо[0, w) — обычные из [0, r) по порядкуO(n)O(1)весь спам, два подряд, спам по краям
appendAdd—len ≤ capacityCount ≤ Capacity, запас растёт с размеромO(1) аморт.запас до ⅛ n—

Следующее занятие — два указателя: пара с заданной суммой в отсортированном массиве, удаление дубликатов, сжатие последовательности. Указатели записи и чтения из части 6 используются там без изменений.

07

Контрольный вопрос · КТП, занятие 7

Какой инвариант остаётся истинным после каждого перемещения границы?

Ответ — для любой из трёх функций вашего solution.pyлюбого из трёх методов вашего Solution.cs: что хранится между итерациями, почему ни один элемент не пропущен и почему отдельный переезд списка дорогой, а серия appendAdd оценивается как O(n).

07

Домашнее задание № 7 · к занятию 8

Максим Николаевич не хочет задавать домашнее задание. Но очень хочет

  1. 1
    Довести solution.py

    Все 27 публичных тестов проходят. split_spam из карточки написана и покрыта тремя своими тестами. complexity_note.md заполнен по четырём функциям.

  2. 2
    Три ошибки на границе · bugs_07.py

    Для каждой функции: вход, на котором она ошибается, падающий тест, исправление одной строки, повторный прогон.

  3. 3
    Поворот плейлиста · rotate_right

    Сдвиг на k позиций вправо на месте тремя вызовами reverse_segment. Тесты для k = 0, k = len, k > len и пустого списка.

Кот перед консолью с подписью «устроился удалёнщиком (чё удалять, пока непонятно)»

Домашнее задание № 7 · к занятию 8

Максим Николаевич не хочет задавать домашнее задание. Но очень хочет

  1. 1
    Довести Solution.cs

    dotnet run показывает 27 passed. SplitSpam из карточки написан и покрыт тремя своими тестами. complexity_note.md заполнен по четырём методам.

  2. 2
    Три ошибки на границе · Bugs07.cs

    Для каждого метода: вход, на котором он ошибается, падающий тест, исправление одной строки, повторный прогон.

  3. 3
    Поворот плейлиста · RotateRight

    Сдвиг на k позиций вправо на месте тремя вызовами ReverseSegment. Тесты для k = 0, k = Count, k > Count и пустого списка.

Кот перед консолью с подписью «устроился удалёнщиком (чё удалять, пока непонятно)»
ДЗ

Домашнее задание № 7 · порядок сдачи

Куда положить и как сдать

Файлы

portfolio/lesson_07/: solution.py, tests.py, tests_bugs_07.py, bugs_07.py, bugs_run.txt, complexity_note.md.

Ветка и PR

Ветка hw-07, коммиты с содержательными сообщениями, Pull Request в собственную main.

Срок

До начала занятия 8. На нём указатели записи и чтения используются в задаче сжатия последовательности.

Критерии

Зелёный pytest по всем файлам, три падающих до исправления теста к bugs_07.py, пять тестов к rotate_right, заметка без пустых пунктов.

Про ИИ: код и тесты пишете сами — на занятии 8 решение объясняется без подсказок. Оформление .md и редактуру формулировок можно поручить модели. Я сам так делаю всегда, поэтому и вам запрещать не буду.

homework_07.md

Домашнее задание № 7 · порядок сдачи

Куда положить и как сдать

Файлы

portfolio/lesson_07/: Solution.cs, Tests.cs, Bugs07.cs, bugs_run.txt, complexity_note.md.

Ветка и PR

Ветка hw-07, коммиты с содержательными сообщениями, Pull Request в собственную main.

Срок

До начала занятия 8. На нём указатели записи и чтения используются в задаче сжатия последовательности.

Критерии

dotnet run без падений, три падающих до исправления теста к Bugs07.cs, пять тестов к RotateRight, заметка без пустых пунктов.

Про ИИ: код и тесты пишете сами — на занятии 8 решение объясняется без подсказок. Оформление .md и редактуру формулировок можно поручить модели. Я сам так делаю всегда, поэтому и вам запрещать не буду.

homework_07.md
ДЗ

ОП.04 · Основы алгоритмизации и программирования · 3 курс

Домашнее задание № 7

Занятие 7 — массив: индексы, проходы, границы

А/П·07

Максим Николаевич не хочет задавать вам домашнее задание. Но очень хочет.

Что нужно сделать

  1. Довести решения занятия. solution.py проходит все 27 публичных тестов. Функция split_spam из карточки изменения написана и покрыта тремя своими тестами: без спама, весь спам, спам в начале и в конце. complexity_note.md заполнен по max_streak, reverse_segment, remove_spam и split_spam.
  2. Три ошибки на границе. В bugs_07.py три функции: has_growth, window_sums, last_n. Каждая верно отвечает на обычных входах и ошибается на граничном. Для каждой найдите такой вход, запишите в tests_bugs_07.py падающий тест, исправьте одну строку и прогоните тесты снова. Вывод pytest сохраните в bugs_run.txt, в complexity_note.md добавьте три строки «функция → вход → что было нарушено».
  3. Поворот плейлиста. Функция rotate_right(tracks, k) сдвигает список на k позиций вправо на месте: последние k треков переходят в начало, порядок внутри частей сохраняется. Пример: A B C D E, k = 2 → D E A B C; при k = 7 ответ тот же. Решение — три вызова reverse_segment, время O(n), память O(1). Не меньше пяти своих тестов, среди них k = 0, k = len, k > len и пустой список. Учтите, что reverse_segment поднимает ValueError на пустом участке.

Тест к bugs_07.py засчитывается, если до исправления он падает, а после — проходит. Тест, который проходит и на ошибочной версии, ошибку не ловит.

Как сдавать

Файлыportfolio/lesson_07/: solution.py, tests.py, bugs_07.py, tests_bugs_07.py, bugs_run.txt, complexity_note.md.
Ветка и PRВетка hw-07, коммиты с содержательными сообщениями, Pull Request в собственную main.
СрокДо начала занятия 8.
ПроверяемЗелёный pytest по всем файлам, три теста к bugs_07.py, которые падают до исправления, пять тестов к rotate_right, заметка без пустых пунктов.
ИИКод и тесты пишете сами: на занятии 8 решение объясняется без подсказок. Оформление .md и редактуру формулировок можно поручить модели. Я сам так делаю всегда, поэтому и вам запрещать не буду.

Словарь занятия

Массив
Элементы лежат в памяти подряд, в ячейках одного размера; адрес элемента вычисляется по индексу за O(1).
Полуинтервал [l, r)
Участок с индексами l … r − 1: левая граница включена, правая нет. Так устроены срезы и range. Длина r − l.
Off-by-one
Ошибка, при которой цикл или срез проходит на один элемент больше или меньше нужного.
Подмассив
Участок подряд без пропусков. Подпоследовательность сохраняет порядок, но допускает пропуски.
Инвариант прохода
Утверждение о переменных цикла, верное до первой итерации и после каждой. Проверяется тремя шагами: инициализация, сохранение, завершение.
На месте (in-place)
Алгоритм меняет переданный список и использует O(1) дополнительной памяти.
Ёмкость
Число ячеек, под которые список уже выделил память. Всегда не меньше длины.
Амортизированная стоимость
Общая стоимость худшей последовательности из n операций, делённая на n. append — O(1) амортизированно: n вызовов стоят O(n).

Материалы занятия — в папке lesson_07 рядом с презентацией. Вопросы по заданию — на паре или в чате группы.

ОП.04 · Основы алгоритмизации и программирования · 3 курс · C#

Домашнее задание № 7

Занятие 7 — массив: индексы, проходы, границы

А/П·07

Максим Николаевич не хочет задавать вам домашнее задание. Но очень хочет.

Что нужно сделать

  1. Довести решения занятия. dotnet run показывает 27 passed, 0 failed. Метод SplitSpam из карточки изменения написан и покрыт тремя своими тестами: без спама, весь спам, спам в начале и в конце. complexity_note.md заполнен по MaxStreak, ReverseSegment, RemoveSpam и SplitSpam.
  2. Три ошибки на границе. В Bugs07.cs три метода: HasGrowth, WindowSums, LastN. Каждый верно отвечает на обычных входах и ошибается на граничном. Чтобы файл вошёл в сборку, удалите из Lesson07.csproj строку <Compile Remove="Bugs07.cs" />. Для каждого метода найдите такой вход, запишите в Tests.cs падающий тест, исправьте одну строку и прогоните тесты снова. Вывод dotnet run сохраните в bugs_run.txt, в complexity_note.md добавьте три строки «метод → вход → что было нарушено».
  3. Поворот плейлиста. Метод RotateRight<T>(List<T> tracks, int k) сдвигает список на k позиций вправо на месте: последние k треков переходят в начало, порядок внутри частей сохраняется. Пример: A B C D E, k = 2 → D E A B C; при k = 7 ответ тот же. Решение — три вызова ReverseSegment, время O(n), память O(1). Не меньше пяти своих тестов, среди них k = 0, k = Count, k > Count и пустой список. Учтите, что ReverseSegment выбрасывает ArgumentException на пустом участке.

Тест к Bugs07.cs засчитывается, если до исправления он падает, а после — проходит. Тест, который проходит и на ошибочной версии, ошибку не ловит.

Как сдавать

Файлыportfolio/lesson_07/: Solution.cs, Tests.cs, Bugs07.cs, bugs_run.txt, complexity_note.md.
Ветка и PRВетка hw-07, коммиты с содержательными сообщениями, Pull Request в собственную main.
СрокДо начала занятия 8.
Проверяемdotnet run без падений, три теста к Bugs07.cs, которые падают до исправления, пять тестов к RotateRight, заметка без пустых пунктов.
ИИКод и тесты пишете сами: на занятии 8 решение объясняется без подсказок. Оформление .md и редактуру формулировок можно поручить модели. Я сам так делаю всегда, поэтому и вам запрещать не буду.

Словарь занятия

Массив
Элементы лежат в памяти подряд, в ячейках одного размера; адрес элемента вычисляется по индексу за O(1).
Полуинтервал [l, r)
Участок с индексами l … r − 1: левая граница включена, правая нет. Так устроены диапазоны a[l..r] и цикл for (i = l; i < r; i++). Длина r − l.
Off-by-one
Ошибка, при которой цикл или диапазон проходит на один элемент больше или меньше нужного.
Подмассив
Участок подряд без пропусков. Подпоследовательность сохраняет порядок, но допускает пропуски.
Инвариант прохода
Утверждение о переменных цикла, верное до первой итерации и после каждой. Проверяется тремя шагами: инициализация, сохранение, завершение.
На месте (in-place)
Алгоритм меняет переданный список и использует O(1) дополнительной памяти.
Ёмкость
Длина внутреннего массива List<T>, свойство Capacity. Всегда не меньше Count.
Амортизированная стоимость
Общая стоимость худшей последовательности из n операций, делённая на n. Add — O(1) амортизированно: n вызовов стоят O(n).

Материалы занятия — в папке lesson_07_cs рядом с презентацией. Вопросы по заданию — на паре или в чате группы.