Практикум: выбор решения под ограничения
Одна задача — десять рабочих решений. Спрашиваем ограничения и выбираем то, которое под них подходит.
Что делаем в этом модуле
На лекции разбирали, как найти ошибку: уточнить условие, подобрать тесты, пройти код вручную. Здесь ошибок нет. В архиве две задачи, и к каждой лежит файл с десятью решениями. Все десять дают правильный ответ, пока соблюдены их требования к входу — это проверяется запуском, и проверка приложена.
Тогда чем они отличаются? Временем, дополнительной памятью и тем, что каждое требует от входа: одно портит исходный список, другому нужен узкий диапазон значений, третьему — уже отсортированный вход. Какое решение подходит, зависит от ограничений задачи. А ограничений в условии не будет: их узнают вопросами, как на секции.
Заполненный бланк на две задачи: пять вопросов к условию, карта из десяти вариантов со временем и памятью, выбранный вариант под каждый из пяти заказов с объяснением и список вариантов, которые не подойдут никому.
Порядок работы
- Прочитайте условие задачи и запишите не меньше пяти вопросов, ответы на которые меняют выбор решения. Файл с вариантами пока не открывайте: вопросы задают до того, как увидели код.
- Откройте файл задачи и заполните карту вариантов: время, дополнительная память, требования к входу. Всё это есть в докстроке каждой функции — читать нужно именно её, а не угадывать по названию.
- Разберите заказы. Заказ — это набор ограничений: размер входа, диапазон значений, можно ли менять список, сколько памяти. Под каждый выберите один вариант и запишите, почему он, а не соседний по таблице.
- Назовите варианты, которые не подошли ни одному заказу, и условия, при которых они были бы уместны. Таких в каждой задаче два-три.
- Запустите проверку — она подтвердит, что дело не в правильности, а в ограничениях.
- Сверьтесь с парой и разберите расхождения в выборе.
Работают парами: участник 1 берёт задачу A, участник 2 — задачу B. Через десять минут меняются задачами, ещё три минуты — на сверку выбора. Разбор на странице открывается через минуту после того, как блок появился на экране: это время на собственный ответ.
Задача A. k-й по величине элемент
Условие дают в таком виде, и больше в нём ничего нет:
«Верните k-й по величине элемент списка целых чисел».
Отсюда уже видно, чего не хватает: неизвестен размер списка, неизвестно, какие бывают значения, не сказано, считать ли повторы и можно ли менять исходный список. Каждый такой пробел меняет выбор варианта, поэтому с вопросов и начинают.
Вопросы к условию задачи A
Запишите в бланк не меньше пяти вопросов, ответы на которые меняют выбор решения. Файл zadacha_a_kth.py пока не открывайте.
Открыть разбор
- Насколько большим бывает список и насколько большим бывает k?
- Повторы считаются? Для
[10, 10, 5]и k = 2 ответ 10 или 5. - Можно ли менять исходный список или он нужен дальше в прежнем порядке?
- Какие бывают значения: только целые неотрицательные или любые?
- Сколько памяти можно занять сверх самого списка?
- Гарантируется ли, что 1 ≤ k ≤ длины списка, и что делать, если нет?
Теперь файл. В нём десять функций с одинаковой сигнатурой (numbers, k); ниже три из них, чтобы было видно, насколько они разные:
def v01_sort_kopii(numbers, k):
"""Сортировка копии по убыванию. O(n log n) времени, O(n) памяти."""
return sorted(numbers, reverse=True)[k - 1]
def v04_kucha_iz_k(numbers, k):
"""Своя куча из k наибольших. O(n log k) времени, O(k) памяти.
Подходит, когда весь список сразу в память не помещается."""
heap = []
for value in numbers:
if len(heap) < k:
heapq.heappush(heap, value)
elif value > heap[0]:
heapq.heapreplace(heap, value)
return heap[0]
def v09_dve_peremennye(numbers, k):
"""Один проход с двумя переменными. O(n) времени, O(1) памяти.
Работает только при k = 2: для другого k нужен другой код."""
if k != 2:
raise ValueError('вариант v09 написан только для k = 2')Первый вариант не требует от входа ничего и тратит O(n) памяти на копию. Второй экономит память, но платит логарифмом от k на каждом элементе. Третий быстрее обоих и при этом бесполезен, если k не равно двум. Такую же строку нужно написать про каждый из десяти вариантов — в файле для этого есть докстроки.
| Вариант | Что делает |
|---|---|
| v01_sort_kopii | сортирует копию по убыванию и берёт элемент по индексу |
| v02_sort_na_meste | сортирует сам переданный список |
| v03_nlargest | берёт k наибольших готовой функцией heapq.nlargest |
| v04_kucha_iz_k | держит кучу из k наибольших вручную |
| v05_k_prohodov | k раз ищет максимум и выбрасывает его из копии |
| v06_quickselect | быстрый выбор с опорным элементом |
| v07_schetchik | считает, сколько раз встретилось каждое значение |
| v08_bisect_top_k | держит отсортированный список из k наибольших |
| v09_dve_peremennye | один проход с двумя переменными |
| v10_razlichnye | сначала убирает повторы, потом берёт k-е значение |
Заказы
Пять наборов ограничений. Это те самые ответы, которые кандидат получил бы на свои вопросы. Под каждый заказ подходит ровно один вариант из таблицы.
| № | Ограничения заказа |
|---|---|
| A1 | 800 оценок, k любое, список нужен дальше в исходном порядке, память не ограничена |
| A2 | 10 000 000 чисел читаются по одному, нужны 10 наибольших, памяти мало |
| A3 | баллы теста — целые от 0 до 100, n = 1 000 000, ответ нужен быстро |
| A4 | n = 200 000, дополнительной памяти почти нет, исходный порядок дальше не нужен |
| A5 | нужна вторая по величине зарплата, причём одинаковые зарплаты считаются одной |
Выбор варианта под каждый заказ A
Заполните в бланке колонки «вариант» и «почему он». Затем назовите варианты, которые не подошли ни одному заказу.
Открыть разбор
| № | Вариант | Почему он |
|---|---|---|
| A1 | v01_sort_kopii | 800 элементов сортируются мгновенно, а копия сохраняет исходный порядок. Памяти не жалко, поэтому усложнять нечем. |
| A2 | v04_kucha_iz_k | Числа приходят по одному, и держать все 10 000 000 негде. Куча из десяти элементов занимает O(k) памяти и обновляется на каждом числе. |
| A3 | v07_schetchik | Значений всего 101, поэтому массив счётчиков даёт ответ за один проход: O(n + m) вместо O(n log n) у сортировки. |
| A4 | v02_sort_na_meste | Исходный порядок не нужен, значит сортировку можно делать на месте и не тратить O(n) памяти на копию. |
| A5 | v10_razlichnye | «Одинаковые считаются одной» — это другой контракт: k-е различное значение. Остальные девять вариантов ответят на другой вопрос. |
Не подошли ни одному заказу. v05_k_prohodov и v08_bisect_top_k стоят O(n · k): они уместны только при очень маленьком k, и даже тогда v03 и v04 дешевле. v06_quickselect в среднем O(n), но в худшем случае O(n²), а выигрыш перед v03 в этих заказах не нужен. v09_dve_peremennye работает только при k = 2 — он подошёл бы к заказу A2, если бы там просили не десять наибольших, а второе по величине число.
Проверьте себя ещё раз: к заказу A3 подошёл бы и v01, он тоже вернёт правильный ответ. Вопрос в цене: сортировка миллиона чисел — это O(n log n), а счётчик по 101 значению — один проход.
Задача B. Есть ли значение в списке
Вторая задача устроена так же: условие без ограничений и десять рабочих решений в файле zadacha_b_poisk.py.
«Отвечайте на запросы, есть ли число x в списке».
Здесь появляется ограничение, которого не было в задаче A: запросов может быть много. Один поиск и двести тысяч поисков по одному и тому же списку — разные задачи, хотя условие у них одно. Функция получает список и список запросов, а возвращает список ответов True/False.
Вопросы к условию задачи B
Запишите в бланк не меньше пяти вопросов. Один из них важнее остальных — попробуйте понять, какой.
Открыть разбор
- Сколько будет запросов? Главный вопрос: подготовка данных окупается, только если запросов много.
- Отсортирован ли список по условию?
- Какие бывают значения: узкий диапазон вроде номеров аудиторий или любые целые?
- Можно ли менять исходный список?
- Сколько памяти можно занять сверх списка?
- Нужно ли знать, сколько раз встречается значение, или только «есть ли»?
def v03_set_zaranee(numbers, zaprosy):
"""Множество строится один раз до запросов.
O(n + q) времени в среднем, O(n) памяти."""
vidennye = set(numbers)
return [x in vidennye for x in zaprosy]
def v08_set_kazhdyy_raz(numbers, zaprosy):
"""Множество строится внутри каждого запроса. Проверка быстрая,
но построение стоит O(n), поэтому всего снова O(n * q)."""
return [x in set(numbers) for x in zaprosy]Эти две функции отличаются одним словом в строке, и обе используют set. Но первая строит множество один раз, а вторая — на каждый запрос, и вся выгода пропадает. Такие пары и есть то, ради чего читают код целиком, а не по названиям.
| Вариант | Что делает |
|---|---|
| v01_lineyno_kazhdyy | на каждый запрос свой проход по списку |
| v02_operator_in | то же самое через оператор in |
| v03_set_zaranee | строит множество один раз до запросов |
| v04_sort_kopii_bisect | сортирует копию и ищет двоичным поиском |
| v05_sort_na_meste_bisect | сортирует сам список и ищет двоичным поиском |
| v06_flagi | держит массив флагов по значениям |
| v07_slovar_kolichestv | считает, сколько раз встретилось каждое значение |
| v08_set_kazhdyy_raz | строит множество внутри каждого запроса |
| v09_binarnyy_poisk_svoy | двоичный поиск без сортировки |
| v10_sliyanie_zaprosov | сортирует и список, и запросы, проходит по обоим один раз |
Заказы
| № | Ограничения заказа |
|---|---|
| B1 | один запрос к списку из 100 000 чисел, порядок произвольный |
| B2 | 200 000 запросов к списку из 1 000 000 чисел, памяти достаточно |
| B3 | список уже отсортирован по условию, 50 000 запросов, лишней памяти нет |
| B4 | значения — номера аудиторий от 0 до 999, запросов много |
| B5 | нужно знать не только «есть ли», но и сколько раз встречается |
Выбор варианта под каждый заказ B
Заполните бланк и назовите варианты, которые не подошли ни одному заказу.
Открыть разбор
| № | Вариант | Почему он |
|---|---|---|
| B1 | v01_lineyno_kazhdyy или v02_operator_in | Один проход стоит O(n). Готовить данные ради единственного поиска невыгодно: сортировка или множество сами обойдутся дороже прохода. |
| B2 | v03_set_zaranee | Множество строится один раз за O(n), дальше каждый из 200 000 запросов стоит O(1) в среднем. Плата — O(n) памяти, и она разрешена. |
| B3 | v09_binarnyy_poisk_svoy | Список уже отсортирован, значит сортировать не нужно, а двоичный поиск даёт O(log n) на запрос и не требует памяти. |
| B4 | v06_flagi | Значений всего тысяча, поэтому массив флагов занимает O(m) и отвечает за O(1) без хеширования. |
| B5 | v07_slovar_kolichestv | Словарь «значение → сколько раз» отвечает и на «есть ли», и на «сколько». Множество второй ответ дать не может. |
Не подошли ни одному заказу. v08_set_kazhdyy_raz выглядит как B2, но строит множество заново на каждый запрос и оказывается медленнее обычного прохода: он полезен только как пример ошибки. v05_sort_na_meste_bisect портит исходный список, и ни один заказ этого не разрешает. v04_sort_kopii_bisect и v10_sliyanie_zaprosov рабочие, но в каждом заказе проигрывают: где памяти хватает, дешевле множество, а где список уже отсортирован — не нужна и сортировка.
Проверьте себя ещё раз: в заказе B2 множество выгоднее сортировки с двоичным поиском, потому что 200 000 запросов по O(1) дешевле 200 000 запросов по O(log n), а подготовка у множества тоже дешевле.
Проверка запуском
Выбор варианта — это утверждение о цене, а не о правильности. Чтобы это было видно, в архиве лежит проверка: она прогоняет все варианты на общих входах и сверяет ответы с эталоном, а для вариантов с особыми требованиями проверяет ещё и эти требования. Запускают её из папки praktikum:
test_obshchiy_kontrakt (test_variantov.ZadachaA.test_obshchiy_kontrakt)
Восемь вариантов работают на любом допустимом входе. ... ok
test_v02_menyaet_vhod (test_variantov.ZadachaA.test_v02_menyaet_vhod)
v02 сортирует переданный список: это его цена за O(1) памяти. ... ok
test_v07_trebuet_neotricatelnyh (test_variantov.ZadachaA.test_v07_trebuet_neotricatelnyh)
v07 строит массив по значениям, поэтому отрицательные ему не подходят. ... ok
test_v09_tolko_k2 (test_variantov.ZadachaA.test_v09_tolko_k2)
v09 отвечает верно при k = 2 и отказывается при другом k. ... ok
test_v10_drugoy_kontrakt (test_variantov.ZadachaA.test_v10_drugoy_kontrakt)
v10 отвечает на другой вопрос: k-е различное значение. ... ok
test_obshchiy_kontrakt (test_variantov.ZadachaB.test_obshchiy_kontrakt)
Восемь вариантов работают на любом списке целых чисел. ... ok
test_v05_menyaet_vhod (test_variantov.ZadachaB.test_v05_menyaet_vhod)
v05 сортирует переданный список, v04 работает с копией. ... ok
test_v06_trebuet_neotricatelnyh (test_variantov.ZadachaB.test_v06_trebuet_neotricatelnyh)
v06 держит флаги по значениям: нужен непустой список от 0 до m. ... ok
test_v09_trebuet_sortirovki (test_variantov.ZadachaB.test_v09_trebuet_sortirovki)
v09 верен на отсортированном входе и ошибается на неотсортированном. ... ok
----------------------------------------------------------------------
Ran 9 tests in 0.001s
OKДевять проверок проходят, и это ожидаемо: все варианты правильные. Два теста при этом фиксируют цену — что v02 и v05 меняют переданный список, — а ещё два показывают, где вариант откажется работать: отрицательные значения для счётчика и неотсортированный вход для двоичного поиска.
Если хочется увидеть разницу во времени своими числами, в архиве есть zamer.py: он берёт несколько вариантов, прогоняет их на списках разного размера и печатает медиану трёх запусков, как в теме 4. Своих чисел здесь не приводим — их печатает ваш компьютер.
Что сдать
Работа сдаётся одним бланком на пару. В нём должно быть заполнено:
- по пять вопросов к условию каждой задачи;
- карта из десяти вариантов для своей задачи: время, дополнительная память, требования к входу;
- выбранный вариант под каждый из пяти заказов и строка «почему он, а не соседний»;
- варианты, не подошедшие ни одному заказу, и условия, при которых они были бы уместны;
- итог запуска проверки и, если запускали, результат замера;
- расхождения с парой после обмена задачами.
Бланк есть в архиве в виде blank.md и в PDF для печати — там же напечатан код всех двадцати вариантов, чтобы заполнять карту можно было без экрана.
Готовую работу кладут в репозиторий в папку lesson_05: заполненный бланк, файл vybor.py с выбранными функциями и номером заказа в комментарии над каждой, и файл с выводом проверки.
Как оценивается
| Критерий | Готово, если… | Баллы |
|---|---|---|
| Вопросы к условию | На каждую задачу записано не меньше пяти вопросов, и для каждого сказано, что меняет ответ. | 2 |
| Карта вариантов | Для всех десяти вариантов своей задачи заполнены время, дополнительная память и требования к входу. | 2 |
| Выбор под заказы | Под каждый из пяти заказов выбран вариант и объяснено, почему он, а не соседний по таблице. | 2 |
| Отсеянные варианты | Названы варианты, не подошедшие ни одному заказу, и условия, при которых они были бы уместны. | 2 |
| Проверка и сверка | Проверка запущена и её вывод сохранён; задачи в паре обменяны, расхождения в выборе разобраны. | 2 |
Всего 10 баллов. Баллы даются за то, что выбор объяснён ограничениями, а не за совпадение с ключом. Шкалу перевода в отметку задаёт преподаватель.