Практика · Тема 06 · Списки¶

Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md

Наскрізний приклад той самий, що й у лекції, — список покупок.

Що зробимо:

  1. зберемо робочий список покупок: додамо, знайдемо, приберемо;
  2. на власні очі побачимо різницю append проти extend і sort() проти sorted();
  3. зловимо пастку копії на вкладеному списку й полагодимо її;
  4. перевіримо кожен висновок через assert — він мовчить, коли все правильно, і зупиняє зошит, коли ні.

Циклів (for) тут немає навмисно — їх ще не було в курсі (тема 12). Усе, що нижче, робиться без жодної ітерації.

1 · Створюємо список¶

Список — упорядкований набір значень під одним іменем. Квадратні дужки — літерал, кома розділяє елементи. len() каже, скільки в ньому елементів.

In [1]:
# наскрізний приклад усієї практики
покупки = ["хліб", "молоко", "яблука"]

print("список:", покупки)
print("довжина:", len(покупки))
print("тип:", type(покупки))
список: ['хліб', 'молоко', 'яблука']
довжина: 3
тип: <class 'list'>

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

In [2]:
# один рядок чека: назва, кількість, ціна — і список характеристик усередині
товар = ["молоко", 1, 32.50, ["1 л", "3.2%"]]

print(товар)
print("типи елементів:", type(товар[0]), type(товар[1]), type(товар[2]), type(товар[3]))
['молоко', 1, 32.5, ['1 л', '3.2%']]
типи елементів: <class 'str'> <class 'int'> <class 'float'> <class 'list'>

2 · Індекс і зріз¶

Синтаксис той самий, що в рядків з теми 05: нумерація з нуля, відʼємний індекс рахує з кінця, зріз [start:stop] бере stop не включно.

In [3]:
покупки = ["хліб", "молоко", "яблука", "мед"]

print("перший:      ", покупки[0])
print("останній:    ", покупки[-1])
print("зріз [1:3]:  ", покупки[1:3])
print("кожен другий:", покупки[::2])
print("навпаки:     ", покупки[::-1])
перший:       хліб
останній:     мед
зріз [1:3]:   ['молоко', 'яблука']
кожен другий: ['хліб', 'яблука']
навпаки:      ['мед', 'яблука', 'молоко', 'хліб']

Важлива різниця між індексом і зрізом: індекс суворий, зріз поблажливий. Звернення до неіснуючого номера зупиняє програму, а зріз за межі просто віддає те, що є.

In [4]:
# зріз за межі списку не падає — він обрізається сам
print("покупки[2:99] =", покупки[2:99])
print("покупки[10:20] =", покупки[10:20], "— порожній список, і це не помилка")
покупки[2:99] = ['яблука', 'мед']
покупки[10:20] = [] — порожній список, і це не помилка

А тепер навмисна помилка. Traceback — теж навчальний матеріал: прочитай його уважно, саме так виглядає вихід за межі списку.

In [5]:
# ↓ ця клітинка МАЄ впасти: у списку всього 4 елементи, номера 9 не існує
покупки[9]
---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
Cell In[5], line 2
      1 # ↓ ця клітинка МАЄ впасти: у списку всього 4 елементи, номера 9 не існує
----> 2 покупки[9]

IndexError: list index out of range

3 · Головна відмінність від рядка: список змінюваний¶

Рядок незмінний — «змінити» його означає створити новий обʼєкт. Список змінюється на місці: обʼєкт лишається той самий, а всередині нього стає інакше. Найпростіший спосіб це довести — подивитись на id() до і після.

In [6]:
покупки = ["хліб", "молоко", "яблука"]
номер_до = id(покупки)          # «особистість» обʼєкта, поки він живий

покупки[0] = "булка"            # присвоєння в елемент — те, чого рядок не вміє
номер_після = id(покупки)

print("список після зміни:", покупки)
print("id не змінився:", номер_до == номер_після)

assert номер_до == номер_після, "список мав лишитись ТИМ САМИМ обʼєктом"
print("✅ обʼєкт той самий, змінився лише його вміст")
список після зміни: ['булка', 'молоко', 'яблука']
id не змінився: True
✅ обʼєкт той самий, змінився лише його вміст

Та сама дія на рядку — помилка. Це не примха мови, а прямий наслідок незмінності: рядку просто немає куди записати новий символ.

In [7]:
# ↓ ця клітинка МАЄ впасти: рядок не підтримує присвоєння в елемент
назва = "хліб"
назва[0] = "Б"
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[7], line 3
      1 # ↓ ця клітинка МАЄ впасти: рядок не підтримує присвоєння в елемент
      2 назва = "хліб"
----> 3 назва[0] = "Б"

TypeError: 'str' object does not support item assignment

Рядок доводиться збирати заново — і виходить уже інший обʼєкт з іншим id. Порівняй із поведінкою списку вище.

In [8]:
назва = "хліб"
номер_рядка_до = id(назва)

назва = "Б" + назва[1:]         # єдиний шлях: склеїти новий рядок
номер_рядка_після = id(назва)

print("рядок:", назва)
print("id змінився:", номер_рядка_до != номер_рядка_після)

assert номер_рядка_до != номер_рядка_після, "рядок мав стати НОВИМ обʼєктом"
print("✅ у рядка кожна «зміна» — це новий обʼєкт")
рядок: Бліб
id змінився: True
✅ у рядка кожна «зміна» — це новий обʼєкт

4 · append проти extend¶

Найчастіша плутанина теми. append додає один елемент — навіть якщо цей елемент сам є списком. extend розкриває передану послідовність і додає кожен її елемент окремо. Порахуємо довжини й переконаємось.

In [9]:
через_append = ["хліб", "молоко", "яблука"]
через_extend = ["хліб", "молоко", "яблука"]

через_append.append(["сир", "мед"])     # один елемент, і цей елемент — список
через_extend.extend(["сир", "мед"])     # два окремі елементи

print("append:", через_append, "→ len =", len(через_append))
print("extend:", через_extend, "→ len =", len(через_extend))

assert len(через_append) == 4, "append мав додати РІВНО ОДИН елемент"
assert len(через_extend) == 5, "extend мав додати два елементи окремо"
assert isinstance(через_append[3], list), "четвертий елемент — вкладений список"
print("✅ 4 проти 5: різниця саме там, де її й чекали")
append: ['хліб', 'молоко', 'яблука', ['сир', 'мед']] → len = 4
extend: ['хліб', 'молоко', 'яблука', 'сир', 'мед'] → len = 5
✅ 4 проти 5: різниця саме там, де її й чекали

І класична пастка: методи, які змінюють список на місці, повертають None. Присвоїти результат такого виклику назад в імʼя — означає втратити список.

In [10]:
покупки = ["хліб", "молоко"]
що_повернув_append = покупки.append("мед")

print("сам список:      ", покупки)
print("append повернув: ", що_повернув_append)

assert що_повернув_append is None, "append нічого не повертає"
print("✅ тому пиши покупки.append(...) окремим рядком, без присвоєння")
сам список:       ['хліб', 'молоко', 'мед']
append повернув:  None
✅ тому пиши покупки.append(...) окремим рядком, без присвоєння

5 · Робочий список покупок: додати, знайти, прибрати¶

Зберемо все разом. insert вставляє за номером, in перевіряє наявність, index каже номер, count — скільки разів, remove прибирає за значенням, pop знімає й повертає елемент.

In [11]:
покупки = ["хліб", "молоко", "яблука"]

покупки.insert(1, "сир")            # сир має стати елементом з номером 1
покупки.append("мед")
print("після додавання:", покупки)

# перевіряємо наявність ПЕРЕД видаленням — remove на відсутньому значенні падає
print('"молоко" у списку?', "молоко" in покупки)
print('номер "молоко":    ', покупки.index("молоко"))
print('скільки "хліб":    ', покупки.count("хліб"))
після додавання: ['хліб', 'сир', 'молоко', 'яблука', 'мед']
"молоко" у списку? True
номер "молоко":     2
скільки "хліб":     1
In [12]:
покупки.remove("молоко")            # прибирає ПЕРШЕ входження, повертає None
останній = покупки.pop()            # знімає останній І віддає його

print("після видалення:", покупки)
print("що зняв pop():  ", останній)

assert "молоко" not in покупки, "молоко мали прибрати"
assert останній == "мед", "pop() знімає з КІНЦЯ"
print("✅ список покупок працює")
після видалення: ['хліб', 'сир', 'яблука']
що зняв pop():   мед
✅ список покупок працює

6 · Присвоєння зрізу: наша реалізація = бібліотечна¶

Зріз можна поставити ліворуч від =. Зокрема, зріз нульової довжини вставляє, нічого не видаляючи, — тобто робить рівно те саме, що insert. Це наша нагода перевірити бібліотечний метод власною реалізацією.

In [13]:
через_метод = ["хліб", "молоко", "яблука"]
через_зріз  = ["хліб", "молоко", "яблука"]

через_метод.insert(2, "сир")        # бібліотечний спосіб
через_зріз[2:2] = ["сир"]           # наш: замінити порожню ділянку на один елемент

print("insert:      ", через_метод)
print("зріз [2:2] =:", через_зріз)

assert через_метод == через_зріз, "розрахунок розійшовся!"
print("✅ збігається — insert(i, x) це і є присвоєння в зріз [i:i]")
insert:       ['хліб', 'молоко', 'сир', 'яблука']
зріз [2:2] =: ['хліб', 'молоко', 'сир', 'яблука']
✅ збігається — insert(i, x) це і є присвоєння в зріз [i:i]

Так само перевіримо перевертання: наш зріз [::-1] проти вбудованої reversed().

In [14]:
покупки = ["хліб", "молоко", "яблука", "мед"]

наш_варіант        = покупки[::-1]
бібліотечний       = list(reversed(покупки))

print("зріз [::-1]:", наш_варіант)
print("reversed():  ", бібліотечний)
print("оригінал цілий:", покупки)

assert наш_варіант == бібліотечний, "розрахунок розійшовся!"
print("✅ збігається — і жоден із двох способів не зіпсував оригінал")
зріз [::-1]: ['мед', 'яблука', 'молоко', 'хліб']
reversed():   ['мед', 'яблука', 'молоко', 'хліб']
оригінал цілий: ['хліб', 'молоко', 'яблука', 'мед']
✅ збігається — і жоден із двох способів не зіпсував оригінал

7 · Сортування: sort() проти sorted() і ключ¶

sort() — метод: сортує на місці, повертає None, вихідний порядок втрачено. sorted() — функція: повертає новий список, оригінал не чіпає.

In [15]:
оригінальний_порядок = ["сир", "хліб", "яблука", "молоко", "мед", "кава", "гречка", "сіль"]

через_функцію = sorted(оригінальний_порядок)        # новий список
через_метод   = list(оригінальний_порядок)          # спершу копія, щоб було що псувати
що_повернув   = через_метод.sort()                  # сортує на місці

print("оригінал після sorted():", оригінальний_порядок)
print("результат sorted():     ", через_функцію)
print("список після sort():    ", через_метод)
print("sort() повернув:        ", що_повернув)

assert що_повернув is None, "sort() нічого не повертає"
assert через_функцію == через_метод, "результат має бути однаковий"
print("✅ різниця не в результаті, а в тому, що псується")
оригінал після sorted(): ['сир', 'хліб', 'яблука', 'молоко', 'мед', 'кава', 'гречка', 'сіль']
результат sorted():      ['гречка', 'кава', 'мед', 'молоко', 'сир', 'сіль', 'хліб', 'яблука']
список після sort():     ['гречка', 'кава', 'мед', 'молоко', 'сир', 'сіль', 'хліб', 'яблука']
sort() повернув:         None
✅ різниця не в результаті, а в тому, що псується

Тепер ключ. Замість самих елементів Python порівнює те, що видає функція key. Візьмемо вбудовану len — і подивимось на найцікавіше: що станеться з елементами, у яких ключ однаковий.

In [16]:
за_довжиною = sorted(оригінальний_порядок, key=len)

print("вихідний порядок:", оригінальний_порядок)
print("key=len:         ", за_довжиною)
print("їхні довжини:    ",
      len(за_довжиною[0]), len(за_довжиною[1]), len(за_довжиною[2]), len(за_довжиною[3]),
      len(за_довжиною[4]), len(за_довжиною[5]), len(за_довжиною[6]), len(за_довжиною[7]))
вихідний порядок: ['сир', 'хліб', 'яблука', 'молоко', 'мед', 'кава', 'гречка', 'сіль']
key=len:          ['сир', 'мед', 'хліб', 'кава', 'сіль', 'яблука', 'молоко', 'гречка']
їхні довжини:     3 3 4 4 4 6 6 6

Сортування Python стабільне: елементи з однаковим ключем зберігають той порядок, у якому були. Перевіримо це прямо: у групі довжини 6 має лишитись порядок «яблука → молоко → гречка» — тобто той, що був у вихідному списку, а зовсім не абетковий.

In [17]:
група_шести = за_довжиною[5:]        # три останні — усі завдовжки 6

print("група довжини 6 після сортування:", група_шести)
print("вони ж у вихідному списку:        ",
      [оригінальний_порядок[2], оригінальний_порядок[3], оригінальний_порядок[6]])

assert група_шести == ["яблука", "молоко", "гречка"], "стабільність порушено!"
assert група_шести != sorted(група_шести), "якби сортування було нестабільним, тут була б абетка"
print("✅ рівні за ключем не помінялись місцями — це і є стабільність")
група довжини 6 після сортування: ['яблука', 'молоко', 'гречка']
вони ж у вихідному списку:         ['яблука', 'молоко', 'гречка']
✅ рівні за ключем не помінялись місцями — це і є стабільність

8 · Копія, якої немає¶

Головна пастка теми. b = a не створює нічого — це другий ярлик на той самий обʼєкт. Спершу переконаємось у цьому на плоскому списку.

In [18]:
a = ["хліб", "молоко"]
b = a                       # НЕ копія: другий ярлик на той самий обʼєкт

b.append("мед")             # чіпаємо тільки b

print("b =", b)
print("a =", a, "← ми його не чіпали")
print("a is b:", a is b)

assert a is b, "b = a не створює нового обʼєкта"
assert a == ["хліб", "молоко", "мед"], "зміна через b видна через a"
print("✅ пастку спіймано: обʼєкт один, ярликів два")
b = ['хліб', 'молоко', 'мед']
a = ['хліб', 'молоко', 'мед'] ← ми його не чіпали
a is b: True
✅ пастку спіймано: обʼєкт один, ярликів два

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

In [19]:
import copy

початковий = ["хліб", "молоко"]

через_зріз        = початковий[:]
через_конструктор = list(початковий)
через_метод_copy  = початковий.copy()
через_модуль      = copy.copy(початковий)

print("усі чотири:", через_зріз, через_конструктор, через_метод_copy, через_модуль)
print("жоден не є тим самим обʼєктом:",
      через_зріз is not початковий,
      через_конструктор is not початковий,
      через_метод_copy is not початковий,
      через_модуль is not початковий)

assert через_зріз == через_конструктор == через_метод_copy == через_модуль
assert через_зріз is not початковий, "копія має бути ІНШИМ обʼєктом"
print("✅ чотири способи, один результат — поверхнева копія")
усі чотири: ['хліб', 'молоко'] ['хліб', 'молоко'] ['хліб', 'молоко'] ['хліб', 'молоко']
жоден не є тим самим обʼєктом: True True True True
✅ чотири способи, один результат — поверхнева копія

А тепер найважливіше. Слово «поверхнева» означає: скопійовано комірки, а не обʼєкти, на які вони вказують. Поки в списку лежать рядки, різниці не видно — вони незмінні. Щойно всередині опиниться інший список, копія протікає.

In [20]:
оригінал = [["хліб", "молоко"], ["яблука"]]
копія    = оригінал[:]                 # поверхнева копія

копія.append(["сир"])                  # зміна ВЕРХНЬОГО рівня
print("після append у копію:")
print("  копія   =", копія)
print("  оригінал =", оригінал, "← верхній рівень захищено")

assert len(оригінал) == 2 and len(копія) == 3, "зовнішні комірки в копії власні"
print("✅ на верхньому рівні зріз рятує")
після append у копію:
  копія   = [['хліб', 'молоко'], ['яблука'], ['сир']]
  оригінал = [['хліб', 'молоко'], ['яблука']] ← верхній рівень захищено
✅ на верхньому рівні зріз рятує
In [21]:
копія[0].append("кава")                # зміна ВКЛАДЕНОГО списку

print("після append у копія[0]:")
print("  копія   =", копія)
print("  оригінал =", оригінал, "← а ось тут протекло")
print("  копія[0] is оригінал[0]:", копія[0] is оригінал[0])

assert копія[0] is оригінал[0], "вкладений список у поверхневої копії СПІЛЬНИЙ"
assert "кава" in оригінал[0], "тому зміна видна і в оригіналі"
print("⚠️ пастка спрацювала: поверхнева копія захищає лише один рівень")
після append у копія[0]:
  копія   = [['хліб', 'молоко', 'кава'], ['яблука'], ['сир']]
  оригінал = [['хліб', 'молоко', 'кава'], ['яблука']] ← а ось тут протекло
  копія[0] is оригінал[0]: True
⚠️ пастка спрацювала: поверхнева копія захищає лише один рівень

Ліки — глибока копія: copy.deepcopy обходить структуру рекурсивно й створює новий обʼєкт на кожному рівні вкладеності.

In [22]:
оригінал = [["хліб", "молоко"], ["яблука"]]
глибока  = copy.deepcopy(оригінал)

глибока[0].append("кава")

print("глибока :", глибока)
print("оригінал:", оригінал, "← цілий")
print("глибока[0] is оригінал[0]:", глибока[0] is оригінал[0])

assert глибока[0] is not оригінал[0], "deepcopy має скопіювати і вкладені списки"
assert оригінал == [["хліб", "молоко"], ["яблука"]], "оригінал не мав змінитись"
print("✅ ось тепер це справжня копія")
глибока : [['хліб', 'молоко', 'кава'], ['яблука']]
оригінал: [['хліб', 'молоко'], ['яблука']] ← цілий
глибока[0] is оригінал[0]: False
✅ ось тепер це справжня копія

9 · Вкладені списки й пастка множення¶

Оператор * повторює список — але повторює він посилання, а не самі обʼєкти. На плоскому списку з чисел це безпечно, на списку зі списків — ні.

In [23]:
поле_зламане = [[0] * 3] * 3          # внутрішній список створено ОДИН раз

print("виглядає правильно:", поле_зламане)
поле_зламане[0][0] = 9
print("після поле[0][0] = 9:", поле_зламане, "← змінились усі три рядки")

assert поле_зламане[0] is поле_зламане[1], "усі три рядки — один і той самий обʼєкт"
assert поле_зламане[1][0] == 9, "тому зміна першого рядка видна в усіх"
print("⚠️ три рядки таблиці виявились одним рядком, показаним тричі")
виглядає правильно: [[0, 0, 0], [0, 0, 0], [0, 0, 0]]
після поле[0][0] = 9: [[9, 0, 0], [9, 0, 0], [9, 0, 0]] ← змінились усі три рядки
⚠️ три рядки таблиці виявились одним рядком, показаним тричі

Правильно таку таблицю будують списковим включенням — це тема 13, і тут ми просто користуємось готовим рецептом. Важливо зрозуміти причину: вираз [0] * 3 усередині обчислюється заново на кожен рядок, тому й списки виходять різні.

In [24]:
поле = [[0] * 3 for _ in range(3)]    # забігаємо наперед: тема 13

поле[0][0] = 9
print("після поле[0][0] = 9:", поле)

assert поле[0] is not поле[1], "тепер це три різні обʼєкти"
assert поле[1] == [0, 0, 0], "інші рядки не постраждали"
print("✅ таблиця 3×3 нарешті поводиться як таблиця")
після поле[0][0] = 9: [[9, 0, 0], [0, 0, 0], [0, 0, 0]]
✅ таблиця 3×3 нарешті поводиться як таблиця

10 · Ціна операцій: кінець дешевий, початок дорогий¶

Список — суцільний шматок памʼяті. Додати в кінець майже нічого не коштує, а вставити на початок означає зсунути всі інші елементи. Порахуємо цю роботу власними руками й звіримо з формулою з лекції.

In [25]:
довжина = 200

зсувів_append   = 0                       # у кінець нічого зсувати не треба
зсувів_insert_0 = довжина                 # на початок — усі наявні комірки

# зібрати список довжини n по одному: 0+1+2+...+(n-1) зсувів
разом_insert_0 = довжина * (довжина - 1) // 2

print("один append:      ", зсувів_append, "зсувів")
print("один insert(0, x):", зсувів_insert_0, "зсувів")
print("зібрати", довжина, "елементів у кінець: ", довжина, "дій")
print("те саме з початку:                     ", разом_insert_0, "зсувів")

assert разом_insert_0 == 19900, "n*(n-1)/2 при n = 200"
print("✅ різниця не в рази, а в порядок величини")
один append:       0 зсувів
один insert(0, x): 200 зсувів
зібрати 200 елементів у кінець:  200 дій
те саме з початку:                      19900 зсувів
✅ різниця не в рази, а в порядок величини

Коли додавати треба з обох кінців, списку варто пошукати заміну. collections.deque — двобічна черга: у неї appendleft коштує O(1) замість O(n).

In [26]:
from collections import deque

черга = deque(["хліб", "молоко"])
черга.appendleft("мед")             # O(1), а не O(n)
черга.append("сир")

print("deque:", черга)
print("як список:", list(черга))

assert list(черга) == ["мед", "хліб", "молоко", "сир"]
print("✅ обидва кінці однаково дешеві — платимо доступом до середини")
deque: deque(['мед', 'хліб', 'молоко', 'сир'])
як список: ['мед', 'хліб', 'молоко', 'сир']
✅ обидва кінці однаково дешеві — платимо доступом до середини

Завдання¶

Роби їх у нових клітинках нижче. Перевіряй себе assert-ом — так само, як робили ми: спершу пиши, який результат очікуєш, і тільки потім запускай.

🟢 Рівень 1 — База¶

Збери свій список покупок із пʼяти назв. Додай шосту в кінець, одну встав на позицію 2, одну прибери за значенням і одну зніми через pop(). Після кожної дії друкуй список і його довжину.

Зроблено, якщо: фінальний список має рівно 5 елементів (5 + append + insert − remove − pop), і в коді є assert len(мій_список) == 5.

🟡 Рівень 2 — Плюс¶

Візьми список назв різної довжини. Отримай три речі й не зіпсуй оригінал: відсортований за абеткою, відсортований за довжиною назви й перевернутий. Потім зроби те саме через методи, що псують оригінал, і поясни в коментарі, у якому випадку який спосіб доречніший.

Зроблено, якщо: після всіх операцій assert підтверджує, що вихідний список дослівно збігається з тим, яким ти його створив.

🔴 Рівень 3 — Виклик¶

Побудуй таблицю 4×4 з нулів двома способами: через [[0] * 4] * 4 і через спискове включення. Обома постав одиницю в клітинку [0][0] і покажи assert-ами, чим результати відрізняються. Далі зроби поверхневу й глибоку копію правильної таблиці, зміни в кожній копії клітинку [1][1] і assert-ами доведи, яка з копій захистила оригінал.

Зроблено, якщо: у коді щонайменше чотири assert, і серед них є перевірка через is (а не тільки через ==), яка показує, що вкладені списки поверхневої копії — ті самі обʼєкти, що й в оригіналі.