Два указателя
Накратко: Два указателя, които тръгват от двата края на подреден масив и вървят един към друг, намират двойка с дадена сума за O(n) вместо O(n²).
Проблем
Подаръчната карта и всички двойки
Клиентът има подаръчна карта и иска да я похарчи до цент: два продукта, които заедно струват точно колкото картата. Сайтът ги търси с два вложени цикъла, като проверява всяка двойка:
checks = 0
def all_pairs(prices, card): global checks for i in range(len(prices)): for j in range(i + 1, len(prices)): checks += 1 if prices[i] + prices[j] == card: return (i, j) return NoneЦеноразписът е 1000 продукта по 2, 4, …, 2000 €, а картата е за 2001 €. Сборът на две четни цени никога не е нечетен, значи двойка няма. Това е най-лошият случай: циклите стигат до края.
n = 1000prices = [2 * (i + 1) for i in range(n)]card = 2 * n + 1pair = all_pairs(prices, card)print("Двойка: " + ("няма" if pair is None else "има"))print(f"Проверки: {checks}")Двойка: нямаПроверки: 499500Почти половин милион проверки за 1000 продукта. Двойките са n(n − 1) / 2, затова това е O(n²): при 100 000 продукта стават около 5 милиарда.
А ценоразписът е подреден по цена, и циклите не се възползват от това.
Идеята
Единият отляво, другият отдясно
Сложи един пръст на най-евтината цена, а друг на най-скъпата. Пръстите са индекси: lo и hi. Събери двете цени:
- ако сборът е колкото картата, готово;
- ако е малко, евтината цена отпада: дори с най-скъпата, която още е в играта, не стига.
loотива една цена надясно; - ако е много, скъпата отпада: тя е много дори с най-евтината.
hiотива една цена наляво.
Всяка стъпка изхвърля една цена завинаги, и никоя двойка с отговора не се губи. Когато lo и hi се срещнат, цените са свършили. Най-много n − 1 стъпки: O(n).
Това се казва два указателя (two pointers): два индекса, които вървят по масива, а всяко сравнение казва кой от тях да мръдне. Тук те вървят един към друг. Познаваш идеята от двоичното търсене, където lo и hi пазят обхвата. Там всяка проба изхвърля половината, тук изхвърля една цена.
Условието е същото: масивът трябва да е подреден. Само тогава „надясно е по-скъпо“ е вярно.
Виж го
lo и hi в ценоразписа
def find_pair(prices, card): lo = 0 hi = len(prices) - 1 while lo < hi: total = prices[lo] + prices[hi] if total == card: return (lo, hi) elif total < card: lo += 1 else: hi -= 1 return Noneprices = [3, 5, 8, 12, 15, 19, 24, 27, 31, 36]pair = find_pair(prices, 50)if pair is None: print("Няма двойка за 50 €")else: lo, hi = pair print(f"{prices[lo]} € + {prices[hi]} €")19 € + 31 €def find_pair(prices, card): lo = 0 hi = len(prices) - 1 while lo < hi: total = prices[lo] + prices[hi] if total == card: return (lo, hi) elif total < card: lo += 1 else: hi -= 1 return NoneСтъпка по стъпка, с думи
- Ценоразписът е подреден: 10 цени. Картата е 50 €. lo е на най-евтината цена, hi на най-скъпата.
- 3 € + 36 € = 39 € < 50 €: малко е. На 3 € не стига дори най-скъпата цена, значи 3 € отпада: lo отива надясно.
- 5 € + 36 € = 41 € < 50 €: малко е. На 5 € не стига дори най-скъпата цена, значи 5 € отпада: lo отива надясно.
- 8 € + 36 € = 44 € < 50 €: малко е. На 8 € не стига дори най-скъпата цена, значи 8 € отпада: lo отива надясно.
- 12 € + 36 € = 48 € < 50 €: малко е. На 12 € не стига дори най-скъпата цена, значи 12 € отпада: lo отива надясно.
- 15 € + 36 € = 51 € > 50 €: много е. С 36 € е много дори с най-евтината цена, значи 36 € отпада: hi отива наляво.
- 15 € + 31 € = 46 € < 50 €: малко е. На 15 € не стига дори най-скъпата цена, значи 15 € отпада: lo отива надясно.
- 19 € + 31 € = 50 €: точно колкото картата. findPair връща индексите 5 и 8.
- 7 сбора вместо 45 двойки. Всяка стъпка изхвърля една цена завинаги, затова стъпките са най-много n − 1.
None. Сборът е в total, не в sum: sum е вградена функция и не е добре да я скриваш с променлива.По-точно, за любопитните
Защо не се губи двойка. Да кажем, че отговорът е двойката на индекси i и j, i < j. Докато lo ≤ i и hi ≥ j, указателите не могат да прескочат отговора:
- ако
lo< i, сборът наloиhiе най-много сборът на i иhi. Ако е малко,loс право отива надясно, без да мине i; - ако
hi> j, по същия начинhiс право отива наляво, без да мине j.
Когато lo стигне i и hi стигне j, сборът е точно картата. Ако двойките са няколко, указателите намират най-външната: тази с най-евтината цена.
Пробвай
Предвиди коя двойка
Картата е 43 €. В ценоразписа има две двойки с такъв сбор: 12 € + 31 € и 19 € + 24 €. Какво печата програмата?
def find_pair(prices, card): lo = 0 hi = len(prices) - 1 while lo < hi: total = prices[lo] + prices[hi] if total == card: return (lo, hi) elif total < card: lo += 1 else: hi -= 1 return None
prices = [3, 5, 8, 12, 15, 19, 24, 27, 31, 36]pair = find_pair(prices, 43)if pair is None: print("Няма двойка за 43 €")else: lo, hi = pair print(f"{prices[lo]} € + {prices[hi]} €")39 € е малко: lo отива на 5 €. 41 € е малко: lo отива на 8 €. 44 € е много: hi отива на 31 €. 8 € + 31 € = 39 € е малко: lo отива на 12 €. 12 € + 31 € = 43 €, готово. Указателите идват отвън навътре, затова намират външната двойка, а 19 € + 24 € е вътре в нея.
Пробвай
Попълни
Сборът е по-малък от картата. Кой указател мърда и накъде?
def find_pair(prices, card):
lo = 0
hi = len(prices) - 1
while lo < hi:
total = prices[lo] + prices[hi]
if total == card:
return (lo, hi)
elif total < card:
else:
hi -= 1
return NoneСборът трябва да порасне, а единственият начин е по-скъпа цена отляво: lo отива надясно. hi наляво би намалило сбора още.
Пробвай
Намери грешката
Картата е 50 €, а 19 € + 31 € е в ценоразписа. Функцията казва, че двойка няма:
prices = [3, 5, 8, 12, 15, 19, 24, 27, 31, 36]pair = find_pair(prices, 50)if pair is None: print("Няма двойка за 50 €")else: lo, hi = pair print(f"{prices[lo]} € + {prices[hi]} €")Няма двойка за 50 €Избери реда с грешката.
Когато сборът е малък, кодът мести hi наляво. Така сборът само намалява: 3 € + 36 €, после 3 € + 31 €, 3 € + 27 € и така нататък, докато hi не стигне lo. lo изобщо не мърда. Поправката е lo надясно, и функцията връща 19 € + 31 €.
Виж го
Колко стъпки
Същият ценоразпис от 1000 продукта и картата за 2001 €, а функцията брои стъпките на цикъла:
steps = 0
def find_pair(prices, card): global steps lo = 0 hi = len(prices) - 1 while lo < hi: steps += 1 total = prices[lo] + prices[hi] if total == card: return (lo, hi) elif total < card: lo += 1 else: hi -= 1 return Nonen = 1000prices = [2 * (i + 1) for i in range(n)]card = 2 * n + 1pair = find_pair(prices, card)print("Двойка: " + ("няма" if pair is None else "има"))print(f"Стъпки: {steps}")Двойка: нямаСтъпки: 999999 стъпки вместо 499 500 проверки. Двете функции дават един и същ отговор, а при 100 000 продукта разликата е 99 999 стъпки срещу около 5 милиарда.
Пусни ги в браузъра. Третата крива е за неподреден ценоразпис: първо merge sort, после двата указателя.
- Всички двойки
O(n²) - Два указателя (подреден списък)
O(n) - Подреди, после два указателя
O(n log n)
| Алгоритъм | n = 10 | n = 1000 | n = 10 000 | n = 100 000 |
|---|---|---|---|---|
| Всички двойки | 45 | 499 500 | 49 995 000 | ≈ 4 999 500 000 |
| Два указателя (подреден списък) | 9 | 999 | 9999 | 99 999 |
| Подреди, после два указателя | 31 | 9692 | 130 424 | 1 636 539 |
def find_pair(prices, card): lo = 0 hi = len(prices) - 1 while lo < hi: total = prices[lo] + prices[hi] if total == card: return (lo, hi) elif total < card: lo += 1 else: hi -= 1 return NoneВсички двойки над 10 000 не пускаме: те са ≈. Подреждането струва повече от самите указатели, но кривата му е O(n log n) и пак е далеч под O(n²).
Идеята
Подреден ли е списъкът
Двата указателя искат подреден масив. Когато не е:
- Подреди първо. Подреждането е O(n log n), указателите O(n), общо O(n log n). При 100 000 цени това са около 1,6 милиона операции срещу около 5 милиарда. Подреждането печели почти винаги.
- Ако търсиш много пъти в същия списък (всеки клиент с различна карта), подреждаш веднъж, и всяко търсене после е O(n).
- Индексите се сменят. След подреждане
prices[5]вече не е петият продукт от каталога. Ако ти трябват продуктите, подреди двойки (цена, продукт) или обекти по цена.
prices = [27, 3, 36, 15, 8, 31, 19, 5, 24, 12]prices.sort()print(prices)pair = find_pair(prices, 50)if pair is None: print("Няма двойка за 50 €")else: lo, hi = pair print(f"{prices[lo]} € + {prices[hi]} €")[3, 5, 8, 12, 15, 19, 24, 27, 31, 36]19 € + 31 €Има и начин без подреждане: минаваш веднъж по цените и пазиш видените в множество. За всяка цена питаш дали картата − цената вече е видяна. Това е O(n) средно, с hash таблица и с n допълнителна памет. Двата указателя са без допълнителна памет, но искат подреден масив.
Виж го
Същата идея: сливане и повторения
Двата указателя не винаги вървят един към друг. Понякога вървят в една посока.
Сливане на два подредени списъка. Поръчките от сайта и от телефона са номерирани от един брояч, но се пазят поотделно, всеки списък подреден. Отчетът за деня иска един подреден списък. i върви по първия, j по втория, и всеки път по-малкият номер отива в резултата. Това е сливането от merge sort:
def merge(a, b): result = [] i = 0 j = 0 while i < len(a) and j < len(b): if a[i] <= b[j]: result.append(a[i]) i += 1 else: result.append(b[j]) j += 1 result.extend(a[i:]) result.extend(b[j:]) return resultweb = [1003, 1007, 1012, 1020]phone = [1001, 1009, 1010, 1031, 1044]print(merge(web, phone))[1001, 1003, 1007, 1009, 1010, 1012, 1020, 1031, 1044]Повторенията на място. Цените от импорта са подредени, но с повторения, а филтърът трябва да покаже всяка цена веднъж. read чете всяка цена, а write е мястото на следващата нова. Всичко преди write е готово и без повторения:
def unique(prices): if not prices: return 0 write = 1 for read in range(1, len(prices)): if prices[read] != prices[write - 1]: prices[write] = prices[read] write += 1 return writeprices = [5, 5, 8, 12, 12, 12, 19, 24, 24]k = unique(prices)del prices[k:]print(f"Различни цени: {k}")print(prices)Различни цени: 5[5, 8, 12, 19, 24]del prices[k:] маха всичко след първите k.И двете са O(n): всеки указател минава по веднъж.
Виж го
Огледален промокод
Кампанията „Огледален код“ приема само промокодове, които се четат еднакво от двете страни. Указателите тръгват от двата края и сравняват знак по знак, докато се срещнат:
def is_mirror(code): lo = 0 hi = len(code) - 1 while lo < hi: if code[lo] != code[hi]: return False lo += 1 hi -= 1 return Trueprint(is_mirror("AB7CC7BA"))print(is_mirror("AB7CD7BA"))TrueFalseПо-точно, за любопитните
Същото се пише и на един ред, като сравниш кода с обърнато копие:
code = "AB7CC7BA"print(code == code[::-1])Truecode[::-1] е срез с крачка −1: нов низ със същата дължина.Отговорът е същият и пак е O(n). Разликата: копието е още n знака памет и винаги се прави цялото, а указателите спират на първата разлика. За промокод от 8 знака това е без значение. За проверка на милиони редове в лог вече има значение.
Със същите указатели се обръща масив на място: размени lo и hi, мръдни ги навътре.
Упражнение
Най-близо до картата
Реши задачата в редактора си.
Най-близо до картата. Точна двойка невинаги има. Тогава магазинът предлага двата продукта, които заедно са най-близо до картата, без да я надминат. Ценоразписът е 3, 5, 8, 12, 15, 19, 24, 27, 31 и 36 €, картата е 45 €. С двата указателя: ако сборът е до картата, запомни го, ако е по-добър от досегашния, и мръдни lo. Иначе мръдни hi.
Най-близо до 45 €: 8 € + 36 € = 44 €Още задачи
- Огледален код с тирета. Промокодовете се печатат с тирета:
AB7-CC-7BA. Пропускай тиретата: всеки указател ги прескача, преди да сравнява. - По две на курс. Пратките са подредени по тегло, а един курс носи най-много две пратки до 30 кг общо. Колко курса трябват най-малко? Сложи най-леката с най-тежката, ако се събират, иначе най-тежката тръгва сама.
- Общи продукти. Два подредени списъка с номера на продукти: от склада и от поръчката. Намери кои номера има и в двата, с два указателя в една посока.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Двоично търсене · Merge sort и „разделяй и владей“ · Стабилно сортиране и вграденият sort · Хеш таблица
- Продължава в: Плъзгащ прозорец · Хеш таблица като брояч
- Задачи по темата: Техники за задачи