Списки в Python: изменение элементов, срезы и устройство list

Обновлено:

Мы уже умеем:

  • создавать списки;
  • получать элементы по индексам;
  • использовать срезы.

Теперь начинаем работать со списком как с изменяемым объектом.


1. Основные свойства списка

Список:

numbers = [10, 20, 30]

является:

  • изменяемым объектом;
  • итерируемым объектом;
  • упорядоченной последовательностью.

Изменяемый

Элементы списка можно изменять без создания нового списка.

Итерируемый

Позже мы сможем обходить элементы:

for ...

Упорядоченный

Элементы сохраняют порядок, в котором находятся в списке:

['Python', 'SQL', 'Django']

Индексы:

0 → Python
1 → SQL
2 → Django

Изменение элемента по индексу

2. Присваивание нового значения

technologies = [
    'Python',
    'Java',
    'SQL',
]

technologies[1] = 'Django'

print(technologies)

Результат:

['Python', 'Django', 'SQL']

Мы не создали новый список.

Мы изменили существующий объект.


3. Значение можно заменить объектом другого типа

data = [
    'Python',
    'SQL',
    'Django',
]

data[1] = 100

print(data)

Результат:

['Python', 100, 'Django']

Список может хранить ссылки на объекты разных типов.


4. Несуществующий индекс нельзя использовать для добавления

numbers = [10, 20, 30]

numbers[3] = 40

Возникнет:

IndexError: list assignment index out of range

Индексы списка:

0
1
2

Индекса 3 пока не существует.

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

Для добавления элементов позже познакомимся со специальными методами.


Удаление по индексу

5. del

Удалить элемент можно с помощью:

del

Например:

technologies = [
    'Python',
    'SQL',
    'Django',
]

del technologies[1]

print(technologies)

Результат:

['Python', 'Django']

После удаления оставшиеся элементы сдвигаются.


6. Отрицательные индексы тоже работают

numbers = [10, 20, 30, 40]

del numbers[-1]

print(numbers)

Результат:

[10, 20, 30]

Удаление срезом

7. Сразу несколько элементов

numbers = [
    10,
    20,
    30,
    40,
    50,
    60,
]

del numbers[1:4]

print(numbers)

Результат:

[10, 50, 60]

Удалены индексы:

1
2
3

8. Начало списка

numbers = [10, 20, 30, 40, 50]

del numbers[:2]

print(numbers)

Результат:

[30, 40, 50]

9. Конец списка

numbers = [10, 20, 30, 40, 50]

del numbers[-2:]

print(numbers)

Результат:

[10, 20, 30]

Изменение среза

10. Замена нескольких элементов

numbers = [
    10,
    20,
    30,
    40,
    50,
]

numbers[1:4] = [
    200,
    300,
    400,
]

print(numbers)

Результат:

[10, 200, 300, 400, 50]

Мы заменили три элемента на три новых.


Количество элементов может измениться

11. Три элемента заменить одним

numbers = [
    10,
    20,
    30,
    40,
    50,
]

numbers[1:4] = [999]

print(numbers)

Результат:

[10, 999, 50]

Список стал короче.


12. Один элемент заменить несколькими

numbers = [
    10,
    20,
    30,
]

numbers[1:2] = [
    100,
    200,
    300,
]

print(numbers)

Результат:

[10, 100, 200, 300, 30]

Список стал длиннее.

При обычном срезе:

list[start:stop]

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


Справа нужен итерируемый объект

13. Это не обязательно список

Например, кортеж:

numbers = [10, 20, 30, 40]

numbers[1:3] = (
    100,
    200,
)

print(numbers)

Результат:

[10, 100, 200, 40]

14. Можно передать строку

Строка тоже является итерируемым объектом:

letters = [
    'A',
    'B',
    'C',
]

letters[1:2] = 'XYZ'

print(letters)

Результат:

['A', 'X', 'Y', 'Z', 'C']

Строка обходится посимвольно.


15. Неитерируемый объект

numbers = [10, 20, 30]

numbers[1:2] = 100

Возникнет:

TypeError

Потому что справа ожидается итерируемый объект.


Список остаётся тем же объектом

16. id() не меняется

numbers = [
    10,
    20,
    30,
]

print(id(numbers))

numbers[1] = 200

print(id(numbers))

id() списка останется тем же.

То же относится к:

del numbers[0]

и:

numbers[1:2] = [100, 200, 300]

Мы изменяем содержимое существующего списка.

Сам объект list остаётся тем же.


Срезы с шагом

17. Удаление через один элемент

numbers = [
    0,
    1,
    2,
    3,
    4,
    5,
    6,
    7,
    8,
    9,
]

del numbers[::2]

print(numbers)

Удаляются индексы:

0
2
4
6
8

Результат:

[1, 3, 5, 7, 9]

18. Удаление нечётных индексов

numbers = [
    0,
    1,
    2,
    3,
    4,
    5,
    6,
    7,
    8,
    9,
]

del numbers[1::2]

print(numbers)

Результат:

[0, 2, 4, 6, 8]

Присваивание расширенному срезу

19. Замена элементов через один

numbers = [
    0,
    1,
    2,
    3,
    4,
    5,
    6,
    7,
    8,
    9,
]

numbers[::2] = [
    'a',
    'b',
    'c',
    'd',
    'e',
]

print(numbers)

Результат:

['a', 1, 'b', 3, 'c', 5, 'd', 7, 'e', 9]

Заменены элементы под индексами:

0
2
4
6
8

20. Важное правило расширенного среза

Когда используется шаг, отличный от 1:

numbers[::2]

количество новых элементов должно совпадать с количеством элементов выбранного среза.

Например:

numbers[::2] = ['a', 'b']

для списка из десяти элементов приведёт к:

ValueError

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

Это отличается от обычного среза:

numbers[1:4] = [...]

где количество элементов может изменяться.


Работа двух срезов

21. Копирование значений нечётных индексов в чётные

arr = [
    0,
    1,
    2,
    3,
    4,
    5,
    6,
    7,
    8,
    9,
]

arr[::2] = arr[1::2]

print(arr)

Сначала справа вычисляется:

arr[1::2]

Получаем:

[1, 3, 5, 7, 9]

Затем эти значения записываются в позиции:

0
2
4
6
8

Результат:

[1, 1, 3, 3, 5, 5, 7, 7, 9, 9]

Одновременное присваивание

22. Обмен групп элементов

arr = [
    0,
    1,
    2,
    3,
    4,
    5,
    6,
    7,
    8,
    9,
]

arr[::2], arr[1::2] = (
    arr[1::2],
    arr[::2],
)

print(arr)

Результат:

[1, 0, 3, 2, 5, 4, 7, 6, 9, 8]

Сначала Python вычисляет правую часть:

arr[1::2]
arr[::2]

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

[1, 3, 5, 7, 9]
[0, 2, 4, 6, 8]

И только после этого происходит присваивание.

В результате соседние элементы меняются местами попарно.


Что представляет собой list внутри CPython

Теперь немного посмотрим на внутреннее устройство списка.

Это детали реализации CPython, а не обязательные требования языка Python.


23. Список основан на динамическом массиве

В CPython список реализован на основе структуры, похожей на динамический массив ссылок на Python-объекты.

Упрощённо:

list
↓
[reference][reference][reference][reference]
     ↓          ↓          ↓          ↓
   object     object     object     object

Сам список не хранит внутри себя полное содержимое каждого Python-объекта.

Он хранит ссылки на объекты.

Именно поэтому один список может содержать:

data = [
    10,
    'Python',
    3.14,
    None,
    [1, 2, 3],
]

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


24. Почему доступ по индексу быстрый

Динамический массив хранит ссылки последовательно в памяти.

Упрощённо можно представить:

index 0 → reference
index 1 → reference
index 2 → reference
index 3 → reference
...

Чтобы получить:

numbers[3]

реализации не нужно обходить:

0 → 1 → 2 → 3

Она может вычислить позицию нужной ссылки непосредственно по индексу.

Поэтому доступ к элементу списка по индексу имеет сложность:

O(1)

То есть время доступа не растёт линейно вместе с длиной списка.


Big O

25. Что означает O(1)

Пока достаточно понимать:

O(1)

как:

Операция выполняется за постоянное время относительно количества элементов.

А:

O(n)

как:

Количество работы может расти вместе с количеством элементов.

Подробнее Big O и структуры данных будут изучаться отдельно.


Размер и вместимость списка

26. Список хранит резерв

Динамический массив должен уметь увеличиваться.

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

Поэтому CPython обычно выделяет память с запасом.

Упрощённо:

используется:
[A][B][C][D][E]

выделено:
[A][B][C][D][E][ ][ ][ ]

Часть позиций используется, а часть зарезервирована для будущих добавлений.

Конкретный размер этого запаса является деталью реализации CPython и может меняться между версиями.

Поэтому не следует рассчитывать на правило вроде:

5 элементов → обязательно 8 ячеек
8 элементов → обязательно 16 ячеек

Важно само устройство:

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


Удаление последнего элемента

27. Почему конец списка удобен

Представим:

[A][B][C][D]

Удаляем последний элемент:

[A][B][C]

Элементы перед ним перемещать не требуется.

Поэтому удаление последнего элемента списка является очень эффективной операцией.

В следующем уроке познакомимся с методом:

pop()

который особенно часто используется именно для работы с концом списка.


Удаление из начала

28. Почему это дороже

Представим:

[A][B][C][D][E]

Удаляем элемент под индексом 0:

[A][B][C][D][E]
 ^
 удалить

После удаления последовательность должна стать:

[B][C][D][E]

Оставшиеся ссылки необходимо сдвинуть:

B: 1 → 0
C: 2 → 1
D: 3 → 2
E: 4 → 3

Количество такой работы зависит от числа элементов после удаляемой позиции.

Поэтому удаление из начала списка:

del numbers[0]

имеет линейную сложность:

O(n)

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

Например:

[A][B][C][D][E][F]
       ^
       удалить

После удаления:

[A][B][D][E][F]

Нужно сдвинуть только элементы, находившиеся справа:

D
E
F

Поэтому стоимость зависит от позиции удаления.

Чем ближе элемент к концу, тем меньше элементов требуется сдвинуть.


После удаления не нужен специальный объект-пустышка

Логическая длина списка уменьшается.

Например:

numbers = [10, 20, 30]

del numbers[-1]

print(len(numbers))

Результат:

2

Для программы список теперь содержит только:

[10, 20]

Во внутреннем выделенном буфере CPython может сохраняться дополнительная вместимость для будущих элементов, но она не является элементами списка и недоступна по индексам.

То есть нельзя обращаться:

numbers[2]

только потому, что внутри реализации могла остаться резервная память.

Получим:

IndexError

Добавление элементов

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


30. Добавление в конец

Если во внутреннем массиве есть свободная вместимость:

[A][B][C][ ][ ]

новую ссылку можно поместить в следующую позицию:

[A][B][C][D][ ]

Это очень быстрая операция.

Для метода:

append()

обычно говорят об амортизированной сложности:

O(1)

Амортизированное O(1)

31. Иногда массив приходится увеличивать

Представим, что резерв закончился:

[A][B][C][D]

а нужно добавить:

E

CPython должен получить буфер большей вместимости.

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

Упрощённо:

старый буфер:
[A][B][C][D]

новый буфер:
[A][B][C][D][E][ ][ ]

Такое увеличение значительно дороже обычного добавления.

Но происходит оно не при каждом append().

Поэтому средняя стоимость серии добавлений в конец остаётся очень хорошей, и используется термин:

amortized O(1)

или:

амортизированное O(1).


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

32. Почему она дорогая

Если требуется добавить:

X

в начало:

[A][B][C][D]

нельзя просто занять позицию перед A внутри логической структуры списка.

Сначала существующие элементы нужно сдвинуть вправо:

A: 0 → 1
B: 1 → 2
C: 2 → 3
D: 3 → 4

И только затем:

[X][A][B][C][D]

Поэтому вставка в начало списка имеет сложность:

O(n)

Выбор структуры данных

33. Список хорош не для каждой операции

Список особенно удобен, когда нам нужны:

  • быстрый доступ по индексу;
  • добавления в конец;
  • удаления с конца;
  • последовательный обход элементов.

Если алгоритм постоянно требует:

удалять первый элемент
вставлять новый элемент в начало

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

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


Краткое сравнение операций

Для списка CPython полезно запомнить:

получить элемент по индексу       → O(1)

изменить элемент по индексу      → O(1)

добавить в конец                 → amortized O(1)

удалить последний элемент        → O(1)

вставить в начало                → O(n)

удалить первый элемент           → O(n)

вставить/удалить в середине      → O(n)

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


Главное по теме

Список — наш первый изучаемый изменяемый тип данных.

Элемент можно заменить:

numbers[index] = new_value

Удалить:

del numbers[index]

Изменять и удалять можно также целые срезы:

del numbers[start:stop]

numbers[start:stop] = iterable

При обычном срезе количество новых элементов может отличаться:

numbers[1:4] = [100]

или:

numbers[1:2] = [
    100,
    200,
    300,
]

Если используется расширенный срез с шагом:

numbers[::2] = iterable

количество новых элементов должно совпадать с количеством выбранных позиций.

Все эти операции изменяют существующий список:

id(numbers)

остаётся идентификатором того же объекта.

В CPython list построен на основе динамического массива ссылок.

Поэтому:

доступ по индексу → очень быстрый
работа с концом → обычно эффективная
вставка/удаление в начале → требует сдвига элементов

Подробно динамические массивы, Big O и другие структуры данных будут изучаться позже.