Практика · Тема 06 · Списки¶
Лекція: lecture.html · Тест: quiz.html · ДЗ: homework.md
Наскрізний приклад той самий, що й у лекції, — список покупок.
Що зробимо:
- зберемо робочий список покупок: додамо, знайдемо, приберемо;
- на власні очі побачимо різницю
appendпротиextendіsort()протиsorted(); - зловимо пастку копії на вкладеному списку й полагодимо її;
- перевіримо кожен висновок через
assert— він мовчить, коли все правильно, і зупиняє зошит, коли ні.
Циклів (for) тут немає навмисно — їх ще не було в курсі (тема 12).
Усе, що нижче, робиться без жодної ітерації.
1 · Створюємо список¶
Список — упорядкований набір значень під одним іменем. Квадратні дужки — літерал,
кома розділяє елементи. len() каже, скільки в ньому елементів.
# наскрізний приклад усієї практики
покупки = ["хліб", "молоко", "яблука"]
print("список:", покупки)
print("довжина:", len(покупки))
print("тип:", type(покупки))
список: ['хліб', 'молоко', 'яблука'] довжина: 3 тип: <class 'list'>
Елементи не зобовʼязані бути одного типу: у комірці списку лежить посилання на обʼєкт, а посилання всі однакові за розміром. Тому поруч можуть стояти рядок, число й навіть інший список.
# один рядок чека: назва, кількість, ціна — і список характеристик усередині
товар = ["молоко", 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 не включно.
покупки = ["хліб", "молоко", "яблука", "мед"]
print("перший: ", покупки[0])
print("останній: ", покупки[-1])
print("зріз [1:3]: ", покупки[1:3])
print("кожен другий:", покупки[::2])
print("навпаки: ", покупки[::-1])
перший: хліб останній: мед зріз [1:3]: ['молоко', 'яблука'] кожен другий: ['хліб', 'яблука'] навпаки: ['мед', 'яблука', 'молоко', 'хліб']
Важлива різниця між індексом і зрізом: індекс суворий, зріз поблажливий. Звернення до неіснуючого номера зупиняє програму, а зріз за межі просто віддає те, що є.
# зріз за межі списку не падає — він обрізається сам
print("покупки[2:99] =", покупки[2:99])
print("покупки[10:20] =", покупки[10:20], "— порожній список, і це не помилка")
покупки[2:99] = ['яблука', 'мед'] покупки[10:20] = [] — порожній список, і це не помилка
А тепер навмисна помилка. Traceback — теж навчальний матеріал: прочитай його уважно, саме так виглядає вихід за межі списку.
# ↓ ця клітинка МАЄ впасти: у списку всього 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() до і після.
покупки = ["хліб", "молоко", "яблука"]
номер_до = id(покупки) # «особистість» обʼєкта, поки він живий
покупки[0] = "булка" # присвоєння в елемент — те, чого рядок не вміє
номер_після = id(покупки)
print("список після зміни:", покупки)
print("id не змінився:", номер_до == номер_після)
assert номер_до == номер_після, "список мав лишитись ТИМ САМИМ обʼєктом"
print("✅ обʼєкт той самий, змінився лише його вміст")
список після зміни: ['булка', 'молоко', 'яблука'] id не змінився: True ✅ обʼєкт той самий, змінився лише його вміст
Та сама дія на рядку — помилка. Це не примха мови, а прямий наслідок незмінності: рядку просто немає куди записати новий символ.
# ↓ ця клітинка МАЄ впасти: рядок не підтримує присвоєння в елемент
назва = "хліб"
назва[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.
Порівняй із поведінкою списку вище.
назва = "хліб"
номер_рядка_до = id(назва)
назва = "Б" + назва[1:] # єдиний шлях: склеїти новий рядок
номер_рядка_після = id(назва)
print("рядок:", назва)
print("id змінився:", номер_рядка_до != номер_рядка_після)
assert номер_рядка_до != номер_рядка_після, "рядок мав стати НОВИМ обʼєктом"
print("✅ у рядка кожна «зміна» — це новий обʼєкт")
рядок: Бліб id змінився: True ✅ у рядка кожна «зміна» — це новий обʼєкт
4 · append проти extend¶
Найчастіша плутанина теми. append додає один елемент — навіть якщо цей
елемент сам є списком. extend розкриває передану послідовність і додає
кожен її елемент окремо. Порахуємо довжини й переконаємось.
через_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.
Присвоїти результат такого виклику назад в імʼя — означає втратити список.
покупки = ["хліб", "молоко"]
що_повернув_append = покупки.append("мед")
print("сам список: ", покупки)
print("append повернув: ", що_повернув_append)
assert що_повернув_append is None, "append нічого не повертає"
print("✅ тому пиши покупки.append(...) окремим рядком, без присвоєння")
сам список: ['хліб', 'молоко', 'мед'] append повернув: None ✅ тому пиши покупки.append(...) окремим рядком, без присвоєння
5 · Робочий список покупок: додати, знайти, прибрати¶
Зберемо все разом. insert вставляє за номером, in перевіряє наявність,
index каже номер, count — скільки разів, remove прибирає за значенням,
pop знімає й повертає елемент.
покупки = ["хліб", "молоко", "яблука"]
покупки.insert(1, "сир") # сир має стати елементом з номером 1
покупки.append("мед")
print("після додавання:", покупки)
# перевіряємо наявність ПЕРЕД видаленням — remove на відсутньому значенні падає
print('"молоко" у списку?', "молоко" in покупки)
print('номер "молоко": ', покупки.index("молоко"))
print('скільки "хліб": ', покупки.count("хліб"))
після додавання: ['хліб', 'сир', 'молоко', 'яблука', 'мед'] "молоко" у списку? True номер "молоко": 2 скільки "хліб": 1
покупки.remove("молоко") # прибирає ПЕРШЕ входження, повертає None
останній = покупки.pop() # знімає останній І віддає його
print("після видалення:", покупки)
print("що зняв pop(): ", останній)
assert "молоко" not in покупки, "молоко мали прибрати"
assert останній == "мед", "pop() знімає з КІНЦЯ"
print("✅ список покупок працює")
після видалення: ['хліб', 'сир', 'яблука'] що зняв pop(): мед ✅ список покупок працює
6 · Присвоєння зрізу: наша реалізація = бібліотечна¶
Зріз можна поставити ліворуч від =. Зокрема, зріз нульової довжини
вставляє, нічого не видаляючи, — тобто робить рівно те саме, що insert.
Це наша нагода перевірити бібліотечний метод власною реалізацією.
через_метод = ["хліб", "молоко", "яблука"]
через_зріз = ["хліб", "молоко", "яблука"]
через_метод.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().
покупки = ["хліб", "молоко", "яблука", "мед"]
наш_варіант = покупки[::-1]
бібліотечний = list(reversed(покупки))
print("зріз [::-1]:", наш_варіант)
print("reversed(): ", бібліотечний)
print("оригінал цілий:", покупки)
assert наш_варіант == бібліотечний, "розрахунок розійшовся!"
print("✅ збігається — і жоден із двох способів не зіпсував оригінал")
зріз [::-1]: ['мед', 'яблука', 'молоко', 'хліб'] reversed(): ['мед', 'яблука', 'молоко', 'хліб'] оригінал цілий: ['хліб', 'молоко', 'яблука', 'мед'] ✅ збігається — і жоден із двох способів не зіпсував оригінал
7 · Сортування: sort() проти sorted() і ключ¶
sort() — метод: сортує на місці, повертає None, вихідний порядок втрачено.
sorted() — функція: повертає новий список, оригінал не чіпає.
оригінальний_порядок = ["сир", "хліб", "яблука", "молоко", "мед", "кава", "гречка", "сіль"]
через_функцію = 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 — і подивимось на найцікавіше: що станеться
з елементами, у яких ключ однаковий.
за_довжиною = 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 має лишитись порядок «яблука → молоко → гречка» — тобто той, що був у вихідному списку, а зовсім не абетковий.
група_шести = за_довжиною[5:] # три останні — усі завдовжки 6
print("група довжини 6 після сортування:", група_шести)
print("вони ж у вихідному списку: ",
[оригінальний_порядок[2], оригінальний_порядок[3], оригінальний_порядок[6]])
assert група_шести == ["яблука", "молоко", "гречка"], "стабільність порушено!"
assert група_шести != sorted(група_шести), "якби сортування було нестабільним, тут була б абетка"
print("✅ рівні за ключем не помінялись місцями — це і є стабільність")
група довжини 6 після сортування: ['яблука', 'молоко', 'гречка'] вони ж у вихідному списку: ['яблука', 'молоко', 'гречка'] ✅ рівні за ключем не помінялись місцями — це і є стабільність
8 · Копія, якої немає¶
Головна пастка теми. b = a не створює нічого — це другий ярлик на той самий
обʼєкт. Спершу переконаємось у цьому на плоскому списку.
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 ✅ пастку спіймано: обʼєкт один, ярликів два
Справжню копію треба замовити явно. Способів чотири, і всі роблять одне й те саме — перевіримо це замість того, щоб вірити на слово.
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 ✅ чотири способи, один результат — поверхнева копія
А тепер найважливіше. Слово «поверхнева» означає: скопійовано комірки, а не обʼєкти, на які вони вказують. Поки в списку лежать рядки, різниці не видно — вони незмінні. Щойно всередині опиниться інший список, копія протікає.
оригінал = [["хліб", "молоко"], ["яблука"]]
копія = оригінал[:] # поверхнева копія
копія.append(["сир"]) # зміна ВЕРХНЬОГО рівня
print("після append у копію:")
print(" копія =", копія)
print(" оригінал =", оригінал, "← верхній рівень захищено")
assert len(оригінал) == 2 and len(копія) == 3, "зовнішні комірки в копії власні"
print("✅ на верхньому рівні зріз рятує")
після append у копію: копія = [['хліб', 'молоко'], ['яблука'], ['сир']] оригінал = [['хліб', 'молоко'], ['яблука']] ← верхній рівень захищено ✅ на верхньому рівні зріз рятує
копія[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 обходить структуру рекурсивно й
створює новий обʼєкт на кожному рівні вкладеності.
оригінал = [["хліб", "молоко"], ["яблука"]]
глибока = 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 · Вкладені списки й пастка множення¶
Оператор * повторює список — але повторює він посилання, а не самі
обʼєкти. На плоскому списку з чисел це безпечно, на списку зі списків — ні.
поле_зламане = [[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 усередині обчислюється заново на кожен рядок, тому й списки виходять різні.
поле = [[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 · Ціна операцій: кінець дешевий, початок дорогий¶
Список — суцільний шматок памʼяті. Додати в кінець майже нічого не коштує, а вставити на початок означає зсунути всі інші елементи. Порахуємо цю роботу власними руками й звіримо з формулою з лекції.
довжина = 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).
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 (а не тільки через ==), яка показує, що вкладені списки поверхневої
копії — ті самі обʼєкти, що й в оригіналі.