Важно: страницата е преместена ето тук.
Тази страница е запазена единствено като архив. Ползвайте новата страница за последна версия на проблемите
(с корекции в условията и решенията), по-четим фонт, а също така и много нови задачи!
Задачи
Disclaimer: Copy-pase-ът вкара някакви шпации на произволни места, уби super- и subscript-ите, а също така и обърка част от пълните и непълните членове (wasntme).
- Сливащи се свързани списъци
- Рандом генератор чрез монета
- Задачата „Нагоре, надолу”
- Задача за разделянето на тортата
- Подинтервал с максимална сума
- Има ли цикъл?
- Blind Guardians
- Тройка с нулева сума
- Повтарящ се елемент в масив
- Неповтарящ се елемент в масив, v1.0
- Неповтарящ се елемент в масив, v2.0
- Задача за трите мравки
- Фалшиви хапчета
- Задача за 12-те монети
- Сложно уравнение
- Точна степен на две
- Lowest bit
- Нула или едно
- Бързо умножение по седем
- Random Shuffle
- Разбъркване на тесте карти
- Japanese Riddle
- Странна редица
- Битови подмаски
- Бързо вдигане на степен
- Точно едно повтарящо се число
- Точно едно липсващо число
- Омагьосани предмети
- Game 21
- Първо липсващо естествено число
- Липсващо число в сортиран масив
- Cast
- Statement vs. Block
- Method vs. Function
- Logical vs. Binary operators
- Unique
- Задачата с крушките
- Задачата с прасетата и отровата
- Най-големи числа с поне 2 повторения
- Структура данни за медиана
- Произведения на всички числа без едно в масив
- Въпрос за Живота, Вселената и Всичко Останало
- Разделяне на Тортата на 8 Парчета
- Ъгъл Между Стрелките на Часовник
- Всички Пермутации на String
- Игра на Кръгла Маса
- Triminoes
- Избор на Град Според Популацията Му
- Appointment Problem
- Произволен Елемент от Генератор
- Стъпала
- Петата Карта
1. Сливащи се свързани списъци
Дадени са ни указатели към първите елементи на два ациклични едносвързани списъка. Можете ли с константна памет и линейно време да определите дали в някой елемент единият списък не се "слива" във втория (тоест двата да завършват в един и същ елемент). Ако да, можете ли да намерите кой е първият им общ елемент? Опишете алгоритъма си.
Очевидно можем да следваме връзките на всеки от списъците докато стигнем последните им елементи. Ако те са един и същ елемент (тоест с един и същ адрес) значи двата списъка се сливат. По-сложното е да разберем къде точно се сливат. Подмамващото в задачата е, че решението е по-просто отколкото изглежда. По-интуитивните начини за решаване водят до квадратни по време решения (или изискват линейна памет). Трябва да направим наблюдението, че ако тръгнем от подходящ елемент в някой от списъците и от първия елемент на другия списък и мърдаме с по един елемент на всяка стъпка, като проверяваме дали сме се "слели" след всяка стъпка, ще можем да го намерим. Как да определим от кой-списък и от кой негов елемент да започнем? Просто - разглеждаме дължините на двата списъка (преброяваме елементите им при първата итерация, в която проверяваме дали завършват в един и същ елемент). В по-дългия от двата итерираме толкова на брой елементи, колкото е абсолютната стойност на разликата им.
Тоест ако eдиният е имал 7 елемента, а другия - 5, то трябва да започнем от 3тия елемент на първия и от 1вия елемент на втория списък. Оттам нататък итерираме двата списъка едновремено докато намерим първия им общ елемент.
2. Рандом генератор чрез монета
Имайки на разположение генератор на случайни булеви величини (true, false), напишете функция, която генерира случайни числа в интервала [0, 1].
Докажете, че генерираните от нея числа са равномерно разпределени.
Задачата има две решения, като де факто едното е еквивалентно на другото, макар и да не изглежда така. Първото е следното – правим „двоично търсене” в интервала. На всяка стъпка разделяме останалия ни интервал на две равни части, и в зависимост от това дали се падне ези или тура запазваме само лявата или само дясната част. Така след определен брой хвърляния останалия ни интервал ще е толкова малък, че можем да го счетем за едно единствено число. Нека, например, при ези взимаме лявата страна, а при тура взимаме дясната. Нека също така са се паднали в този ред: ези, тура, ези, ези, тура, ези, тура, тура, тура, ези, тура. Така нашият алгоритъм би стеснявал интервала по следния начин: [0, 1] -> [0, 0.5] -> [0.25, 0.5] -> [0.25, 0.375] -> [0.25, 0.3125] -> [0.28125, 0.3125] -> [0.296875, 0.3125] -> [0.3046875, 0.3125] -> [0.3046875, 0.30859375] -> [0.306640625, 0.30859375].
В крайна сметка можем да вземем просто средата на останалия интервал, в случая 0.3076171875 и да кажем, че това е нашето рандом число. Разбира се, прилагайки повече итерации бихме постигнали значително по-голяма точност. Този метод можем да го разгледаме по следния начин. В началото образуваме редицата 0.5, 0.25, 0.125, 0.0625, 0.03125, … За всеки член от тази редица хвърляме монетата и ако се падне тура, го добавяме в сумата (която първоначално е била нула). Ако тази редица е безкрайна резултатното число би било напълно случайно, равномерно разпределено в интервала [0, 1].
Второто решение е като образуваме двоично число с n бита (при n хвърляния на монетата). Тъй като максималното число, което би могло да се получи при такова генериране е 2^n - 1, ако полученото от нас число е било x, то връщайки x / (2^n - 1) ние получаваме именно число между 0 и 1, включително. От компютърна гледна точка това е същото като горния метод, с тази разлика, че ако ползваме double за горния модел ще стигнем до ограничение на типа, докато дълги числа във втория вариант биха ни позволили произволна точност.
3. Задачата „Нагоре, надолу” (от шоуто на Къци)
По случаен начин е избрано цяло число num между 1 и 1000. Имате функция guess(x), която връща 0, ако num == x, -1 ако x < num, и +1 ако x > num. Изградете алгоритъм, който, познава числото num с минимален брой викания на функцията guess(x). Определете броя извиквания при най-лошия избор на num за вашия алгоритъм.
Отново ще ползваме техниката „разделяй и владей” в нейния най-разпространен вариант – двоично търсене (trivia: http://en.wikipedia.org/wiki/Divide_and_conquer_algorithm). На всяка стъпка ще разделяме интервала на две равни части, като в зависимост от отговора „нагоре” или „надолу” ще знаем в коя половина да търсим числото. Така постигаме най-много логаритъм от големината на интервала на брой питания, тоест в случая 10 (тъй като двоичният логаритъм от 1000 е малко по-малко от 10).
Нека, например съм си намислил числото 42. Така питанията ни ще са (интервал, питане): ([1, 1000], 500) -> ([1, 499], 250) -> ([1, 249], 125) -> ([1, 124], 62) -> ([1, 61], 31) -> ([32, 61], 46) -> ([32, 45], 38) -> ([39, 45], 42) -> BINGO!
Може да ви накарат да напишете код на алгоритъма си. Той би бил нещо от сорта на:
int left = 1, right = 1000;
while (left <= right) {
int mid = (left + right) / 2;
int res = guess(mid); // -1 if less, 0 if correct, 1 if greater
if (res == 0) return mid;
if (res < 0) right = mid - 1;
else left = mid + 1;
}
4. Задача за разделянето на тортата
Даден е бял правоъгълник, във вътрешността на който е нарисуван друг, черен правоъгълник, чиито страни може да не са успоредни на тези на белия. Може ли с една единствена права линия да се раздели остатъка от белия правоъгълник на части с равна площ?
Тук трябва да направим следното наблюдение – ако просто имаме правоъгълна торта (от която не е махнато парче) и искаме да я разделим на две равни части, можем да го направим по безброй начини – някои от които са : по хоризонтала през средата, по вертикала през средата, през някой от диагоналите... Общото на всички тези разрези е, че всеки един от тях минава през центъра на правоъгълника (тоест пресечната точка на диагоналите му). Всъщност се оказва, че това е необходимо и достатъчно условие един разрез да разполовява правоъгълник на две абсолютно равни части. А как да се справим с изрязаното парче? Очевидно за да може след разреза тортата да е по равно, можем да направим такъв разрез, който да разполовява както оригиналната торта, така и липсващото парче. Този разрез би минал през центровете на големия правоъгълник (тортата) и вътрешния правоъгълник (липсващото парче). Така гарантираме, че след разреза двете парчета ще са равни.
По време на лекцията беше предложено и алтернативно (изключително хитро!) решение, което определено би обрало точките на истинско интервю (даже би могло да бъде по-добре прието от оригиналното решение, поради своята креативност). То е просто да разрежем тортата напреко!
5. Подинтервал с максимална сума
Даден е масив с N числа. Изградете алгоритъм, който намира негов подинтервал с максимална сума. Подинтервал е поредица от последователни негови елементи.
Едно очевидно решение е да проверим всеки подинтервал на масива и да изведем този с най-голяма сума. Това решение за съжаление е O(N^2) по време , което е точно нещото, за което ще следят по време на интервюто. Бързодействието в този тип задачи е жизнено важно, затова ще изградим алгоритъм, който решава задачата за оптималното линейно време (тоест О(N)). Тук трябва да направим наблюдението, че един подинтервал е потенциално оптимален, ако няма негов префикс или суфикс, който да е с отрицателна сума. Exempli gratia , ако имаме подинтервала {1, 2, -5, 6, 3, -1, 4, -2, 3, 3, -2, 3}, то той има префикс с отрицателна сума.
Този префикс е именно {1, 2, -5}. Наистина, в случая би било по-оптимално да вземем само подинтервала {6, 3, -1, 4, -2, 3, 3, -2, 3}. Същото важи ако имаме и отрицателен суфикс (просто си представете същия пример, обърнат наобратно). Така нашият алгоритъм ще се стреми алчно да избира така подинтервала, че да няма отрицателен префикс, и да тества всички възможни суфикси, така че дори да има отрицателен такъв, да гарантираме, че сме тествали и подинтервала без него (trivia: http://en.wikipedia.org/wiki/Greedy_algorithm).
Впрочем (trivia: http://en.wikipedia.org/wiki/Trivia).
Оттук нататък алгоритъмът е както следва: почвайки с празен интервал, на всяка стъпка разширяваме надясно интервала със следващото число в редицата. Проверяваме дали сумата в този интервал е по-добра от най-добрата до сега намерена (ако да, запазваме подинтервала). След като евентуално обновим отговора, проверяваме дали пък сумата не е станала отрицателна (тоест това би било отрицателен префикс, на по-нататък разширения подинтервал). Ако наистина сумата е отрицателна, премахваме всички числа от интервала и започваме отново с празен интервал (разбира се, продължавайки от следващото недобавено число в редицата).
Ето и пример:
Вход: {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
Инициализация: Интервал {}, сума 0, отговор -безкрайност.
Стъпка 1: Интервал {1}, сума 1, индекс 0, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
(обновяваме отговора на 1 с текущия интервал)
Стъпка 2: Интервал {1, 2}, сума 3, индекс 1, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
(обновяваме отговора на 3 с текущия интервал)
Стъпка 3: Интервал {1, 2, -5}, сума -3, индекс 2, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
(интервалът става с отрицателна сума, следователно го изчистваме)
Стъпка 4: Интервал {6}, сума 6, индекс 3, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
(обновяваме отговора на 6 с текущия интервал)
Стъпка 5: Интервал {6, -1}, сума 5, индекс 4, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
Стъпка 6: Интервал {6, -1, 4}, сума 9, индекс 5, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
(обновяваме отговора на 9 с текущия интервал)
Стъпка 7: Интервал {6, -1, 4, -2}, сума 7, индекс 6, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
Стъпка 8: Интервал {6, -1, 4, -2, 3}, сума 10, индекс 7, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
(обновяваме отговора на 10 с текущия интервал)
Стъпка 9: Интервал {6, -1, 4, -2, 3, -40}, сума -30, индекс 8, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
(интервалът става с отрицателна сума, следователно го изчистваме)
Стъпка 10: Интервал {-2}, сума -2, индекс 9, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
(интервалът става с отрицателна сума, следователно го изчистваме)
Стъпка 11: Интервал {3}, сума 3, индекс 10, {1, 2, -5, 6, -1, 4, -2, 3, -40, -2, 3}
Отговор: 10 с подинтервал {6, -1, 4, -2, 3}.
6. Има ли цикъл?
Даден е указател към първия елемент на (потенциално много много дълъг) едносвързан списък, чиито брой елементи не знаем. Възможно е той да се зацикля (тоест връзката на някой от елементите му да сочи към предходен такъв). Можете ли да измислите начин за проверка с константна памет и линейно време дали списъкът се зацикля или не? Нямаме право да променяме елементите на списъка по какъвто и да било начин.
Както може би се вижда не можем просто да вървим по връзките на листа докато стигнем неговия край. Ако съществува цикъл никога няма да стигнем до края на листа, от друга страна няма и да сме сигурни, че има цикъл, тъй като просто може броят елементи на листа да е по-голям от досега изминатите.
Изискването за константна допълнителна памет и това да не променяме елементите на листа значително намаляват възможностите ни. Тук трябва да приложим метода на "заека и костенурката". Вместо да вървим по списъка с един указател, ще вървим с два такива. С първия ще правим по две стъпки на ход (тоест ще минаваме по два елемента), а с втория - по един. Ако листът няма цикъл, то по-бързият указател (заекът) рано или късно ще стигне до края и ще може да установим това. Ако ли пък има цикъл, рано или късно и двата указателя ще стигнат до него, и по някое време по-бързият ще "настигне" по-бавния, при което ще установим, че има цикъл. Тоест трябва да въртим цикъл, в който на всяка итерация:
a) местим единия указател с два елемента напред (като следим за край на листа или "задминаване" на другия указател);
б) местим другия указател с един елемент напред.
Очевидно сложността по памет е О(1) (пазим единствено два указателя). Можем също така лесно да покажем, че заекът ще измине елементите на листа най-много два пъти (преди да настигне костенурката), а тя, от своя страна ще ги извърви най-много веднъж, тоест общо 3 пъти, което е О(N).
7. Blind Guardians
Ели се намира в стая, която има 2 врати: едната води към нейните най-големи кошмари (място, където ноктите й се чупят по-често, косата й не е толкова блестяща, не всички я харесват, и всеки ден има изпити по анализ), а другата - към нейния рай (всъщност не сме много сигурни какво е това, но бихме могли да предположим, че Станчо се намира там). Всяка от вратите се охранява от пазач, единият от които винаги казва истината, а другият - винаги казва лъжата. Ели има право на един единствен въпрос и след това трябва да избере врата, през която да мине. Какъв би могъл да бъде този въпрос?
Ели може да попита (когото и да е от двамата пазачи): "Ако попитам другия пазач коя врата трябва да отворя за да оцелея, той коя ще ми посочи?" - след това ще мине през другата врата. Тъй като чрез този въпрос де факто и двамата биват попитани по нещо, то сме сигурни, че отговорът ще е лъжа независимо кого сме попитали.
8. Тройка с нулева сума
Даден ви е масив с N цели числа (всяко от тях положително, отрицателно или нула). Изградете алгоритъм, който намира дали съществува тройка негови числа със сума нула. Разгледайте решения със сложности O(N**3), O(N**2 * logN), O(N**2). Можете ли да модифицирате алгоритъма си така, че да брои колко различни такива тройки има?
Най-простият алгоритъм би бил да въртим три вложени цикъла, като проверим всички тройки. Това е решението със сложност O(N**3) и не би зарадвало интервюиращите.
Ако фиксираме две от числата (тоест въртим само два вложени цикъла) които да кажем са A и B, то има ли бърз начин да проверим дали има трето, което е -(A + B)? Всъщност има, даже повече от един такъв. По-стандартният от тях е да сортираме предварително числата (със сложност O(N * logN), а дори и O(N * N) не разваля сложността), след което фиксираме всички двойки числа и с двоично търсене търсим дали масивът съдържа третото. Този метод води до сложност O(N * logN + N * N * logN), тоест О(N * N * logN).
Ако имаме право на O(N) допълнителна памет, можем в хеш таблица (hashset в случая) да пазим всички числа и търсенето ни за число да става константно, вместо логаритмично по време. Това би довело до решение със сложност O(N * N), но O(N) откъм допълнителна памет.
Най-доброто решение ползва отново O(N * N) време, но O(1) допълнителна памет. Отново е нужно да сортираме елементите. След което въртим цикъл по един от елементите на тройката. Нека този елемент е с индекс i. Фиксираме два указателя (в смисъл на нещо, което ни сочи позиция в масива, не на указател към памет) към елементите с индекси i + 1 и N (които ще наричаме, съответно, "ляв" и "десен" указател). Проверяваме каква е сумата на трите елемента. Ако тя е нула - супер. Ако тя е отрицателна, то, очевидно, е в наш интерес да я увеличим. Тъй като сме фиксирали първия елемент и можем да местим само втория и третия указател, то единственото, което можем да направим, за да увеличим сумата, е да преместим левия указател с един елемент надясно. Ако ли пък сумата е положителна, за да я намалим ще преместим десния указател с един елемент наляво. След всяко такова преместване проверяваме отново каква е сумата на трите елемента (фиксирания и двата, които сочим в момента) и действаме адекватно. Тъй като на всяка
стъпка или местим левия надясно, или десния наляво, те рано или късно ще се срещнат. Нещо повече, те ще се срещнат след не повече от N такива премествания. Така ако можем да фиксираме първия елемент по N начина, и за всяко фиксиране правим по не повече от N местения, сложността на този алгоритъм е O(N * N). Защо работи и не пропуска ли той решения? С малко мислене можем да се убедим, че той е еквивалентен на алгоритъма с binary search, само че правим търсенето по-умно и спираме по-рано фиксирането на втория елемент. Не е толкова лесно да се види защо той не изпуска решения. Нека разгледаме едно решение (в сортиран масив). То представлява три числа, които можем да наричаме "ляво", "средно" и "дясно" (тъй като, очевидно, едното ще е най-малко (или равно на (някое от) останалите), другото ще е най-голямо (или равно на (някое от) останалите) и третото ще е между тях (или равно на (някое от) тях)). Фиксираното от нас във външния цикъл число е "лявото" (демек най-малкото). Тъй като в хода на вътрешния
цикъл двата индекса се срещат, то със сигурност на някоя стъпка левият индекс е минал през "средното", а десният индекс е минал през "дясното". Ако някой от индексите е преминал през двете числа, а другият през нито едно от тях, то тогава на някоя стъпка от алгоритъма сме преместили неправилния индекс, което е противоречие с алгоритъма или с това, че разглежданата тройка е решение.
Тъй като алгоритъмът не е особено интуитивен и не е лесен за асимилиране, ще дадем и пример:
Дадени са ни числата {7, -5, 2, 3, -4, -4, 2, 0, 1, -6}.
Като първа стъпка от алгоритъма ги сортираме. Получаваме {-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}.
Въртим цикъл за всяко от тях, като казваме, че то ще е най-малкото ("най-лявото") от тройката. При фиксиране на -6 за такова, ето стъпките, които ще се изпълнят във вътрешния цикъл:
{ -6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -4, следователно местим левия индекс.
{ -6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -3, следователно местим левия индекс.
{ -6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -3, следователно местим левия индекс.
{ -6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума +1, следователно местим десния индекс.
{ -6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -3, следователно местим левия индекс.
{ -6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -2, следователно местим левия индекс.
{ -6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -1, следователно местим левия индекс.
{ -6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -1, следователно местим левия индекс.
Индексите се срещат, следователно няма тройка със сума 0, чието най-малко число е -6.
Продължаваме нататък, като фиксираме следващото число, -5.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -2, следователно местим левия индекс.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -2, следователно местим левия индекс.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума +2, следователно местим десния индекс.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -2, следователно местим левия индекс.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -1, следователно местим левия индекс.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума 0, намерихме отговор!
Ако усложним задачата, като искаме да преброим колко са всички тройки с нулева сума, трябва леко да модифицираме алгоритъма (но той си остава със сложност O(N * N)). Трябва да внимаваме правилно да имплементираме вътрешния цикъл да продължава дори след като сме намерили решение, тъй като може да има повече от една валидна тройка с дадено най-малко число.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума 0, намерихме отговор!
На следващата стъпка указателите се срещат, така че прекратяваме вътрешния цикъл.
Понякога може да имаме повече от един отговор с дадено минимално число дори като не ползваме последователни повтарящи се числа.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -1, следователно местим левия индекс.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума +3, следователно местим десния индекс.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума -1, следователно местим левия индекс.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума 0, намерихме отговор! 1 != 2 и 2 != 3 (тоест следващото ляво и следващото десно число са различни от, съответно, текущото ляво и текущото дясно), така че можем да преместим и двата индекса.
{-6, -5, -4, -4, 0, 1, 2, 2, 3, 7}, със сума 0, намерихме отговор!
На следващата стъпка указателите се срещат, така че прекратяваме вътрешния цикъл.
9. Повтарящ се елемент в масив
Даден е масив с N цели числа, като всяко от тях е между 1 и N, включително. Измислите алгоритъм, който проверява дали има повтарящо се число. Имате право да променяте елементите на масива. Каква най-добра сложност по време и по памет можете да направите?
Може би най-лесното решение е да сортираме числата и да обходим масива, като проверяваме дали два съседни елемента са равни. Това решение е със сложност O(N * logN) и ползва константа допълнителна памет.
Друго очевидно решение е да заделим един нов масив с N елемента и да обходим дадените ни числа, като броим всяко колко пъти се среща. Този подход е O(N) откъм време, но също така О(N) откъм памет.
Най-доброто решение е O(N) откъм време и О(1) откъм памет. Тъй като имаме N числа от 1 до N и няма повтарящо се, то те образуват пермутация. Всяка пермутация може да бъде разбита на непресичащи се цикли. Точно тези цикли ние ще се опитаме да намерим.
В една променлива пазим от коя позиция започва текущия цикъл. На първата итерация (тоест при първия цикъл) тази позиция ще е едно. Ако цикълът е окей, то той трябва да завърши в същата позиция. На всяка стъпка:
Проверяваме дали текущия елемент на масива е -1 (или някаква друга невалидна стойност, която сме си избрали).
- Ако да, проверяваме дали текущата позиция е позицията, която сме си записали в допълнителната променлива.
- Ако да, всичко е окей (тоест сме започнали и завършили цикъла в една и съща точка). Отиваме до най-малкия индекс в масива, който не е -1, и продължаваме същия алгоритъм.
- Ако не, казваме, че има повтарящ се елемент. При това повтарящото се число е индексът, на който се намираме в момента (индексирано от 1).
- Ако не, записваме в допълнителна променлива текущото число. Променяме текущото число на -1. Отиваме на индекс числото, което току-що записахме в допълнителната променлива.
Всеки от елементите на масива променяме на -1 максимум по веднъж. Освен това намирането на най-малкия елемент с -1 обикаля елементите също точно веднъж. Така сложността по време е O(N). Имаме само 2 допълнителни променливи, така че сложността по памет е О(1).
Нека за показно на алгоритъма разгледаме два примера - където няма повтарящ се елемент и където има повтарящ се елемент.
Пример 1: {5, 3, 1, 4, 2, 7, 6}
{ 5, 3, 1, 4, 2, 7, 6}, начална позиция 1.
{-1, 3, 1, 4, 2, 7, 6}, начална позиция 1.
{-1, 3, 1, 4, -1, 7, 6}, начална позиция 1.
{-1, -1, 1, 4, -1, 7, 6}, начална позиция 1.
{ -1, -1, -1, 4, -1, 7, 6}, начална позиция 1. Достигнахме -1 на позиция 1 при начална позиция 1, следователно обходихме един цикъл. Нямаме противоречие, продължаваме нататък.
{-1, -1, -1, 4, -1, 7, 6}, начална позиция 4.
{-1, -1, -1, -1, -1, 7, 6}, начална позиция 4. Достигнахме -1 на позиция 4 при начална позиция 4. Нямаме противоречие, продължаваме нататък.
{-1, -1, -1, -1, -1, 7, 6}, начална позиция 6.
{-1, -1, -1, -1, -1, -1, 6}, начална позиция 6.
{-1, -1, -1, -1, -1, -1, -1}, начална позиция 6. Достигнахме -1 на позиция 6 при начална позиция 6. Нямаме противоречие, продължаваме нататък.
Няма повече необходени елементи в масива, следователно прекратяваме алгоритъма. Не стигнахме до противоречие, следователно няма повтарящо се число.
Пример 2: {5, 3, 1, 4, 2, 3, 6}
{ 5, 3, 1, 4, 2, 3, 6}, начална позиция 1.
{-1, 3, 1, 4, 2, 3, 6}, начална позиция 1.
{-1, 3, 1, 4, -1, 3, 6}, начална позиция 1.
{-1, -1, 1, 4, -1, 3, 6}, начална позиция 1.
{ -1, -1, -1, 4, -1, 3, 6}, начална позиция 1. Достигнахме -1 на позиция 1 при начална позиция 1, следователно обходихме един цикъл. Нямаме противоречие, продължаваме нататък.
{-1, -1, -1, 4, -1, 3, 6}, начална позиция 4.
{-1, -1, -1, -1, -1, 3, 6}, начална позиция 4. Достигнахме -1 на позиция 4 при начална позиция 4. Нямаме противоречие, продължаваме нататък.
{-1, -1, -1, -1, -1, 3, 6}, начална позиция 6.
{-1, -1, -1, -1, -1, -1, 6}, начална позиция 6. Достигнахме -1 на позиция 3, при начална позиция 6. Това е противоречие, следователно прекратяваме алгоритъма и твърдим, че има повтарящо се число. При това числото е текущият индекс (тоест 3).
10. Неповтарящ се елемент в масив, v1.0
Даден е масив с N (нечетен брой) цели числа. Всяко число се среща по два пъти с изключение на едно, което е уникално. Намерете това число.
Най-тъпото решение е да направите два цикъла и да проверите всяко число дали се повтаря или не. Това дори не си правете труда да го пишете на интервюта. Сложността му е О(N*N).
Малко по-добро решение е да сортираме масива и да търсим кое не се повтаря линейно в сортирания масив. Сложността на това решение е O(N*logN).
Използвайки хешове, задачата може да се реши с O(N) както откъм време, така и откъм допълнителна памет. Това е едно (що-годе) приемливо решение
Най-доброто решение е O(N) откъм време и О(1) откъм допълнителна памет. Първо трябва да се запознаете с операцията xor. Тя е имплементирана във всички процесори и я има в (почти) всички програмни езици. По дискретна математика трябва да сте я учили като "изключващо или". Ако вземем две числа в двоична бройна система и направим техния xor, на всяка позиция (в двоичния си запис), на която числата се различават, записваме 1, а на всяка позиция, на които не се различават пишем 0. Така получаваме ново двоично число. Примери:
- 101010 xor 011001 == 110011
- 111111 xor 000000 == 111111
- 101010 xor 101010 == 000000
Идеята на решението се базира на това, че бинарната операция xor на две числа дава нула, ако числата са равни, или число различно от нула, ако не са. Така:
- A xor A == 0
- A xor B != 0 (при А != B)
- A xor A xor B xor B == 0
- A xor B xor A xor B == 0
- A xor B xor B xor A == 0
- C xor 0 == C
От горните свойства на операцията xor можем да изведем, че ако всяко число се повтаря по два пъти (или по четен брой пъти) с изключение на едно, то xor-ът на всички числа ще бъде равен точно на уникалното число.
11. Неповтарящ се елемент в масив, v2.0
Даден е масив с N (четен брой) цели числа. Всяко число се среща по два пъти с изключение на две, които са уникални и различни помежду си. Намерете тези числа.
Както виждаме, задачата е модификация на предходната. Следователно е логично и решението да е модификация на предходното. Какво става обаче ако вземем xor-а на всички числа в масива? Получаваме xor-а на двете уникални числа. Тъй като те са различни, този xor е ненулев. Тъй като е ненулев, в него можем да намерим поне една позиция (в двоичния му запис) която е 1. Това означава, че едното от двете числа има 1 на тази позиция, а другото има 0. Нека отново обходим масива, като го разбием на две групи - в едната са числата, които имат 1 на тази позиция, а в другата - тези, които имат 0. Вижда се, че тези две множества от числа са непресичащи се, и в същото време съдържат всички числа от първоначалния масив.
Какво забелязваме? Едното множество съдържа едното от уникалните числа, а другото съдържа второто. Нещо повече, във всяко от множествата всички елементи се срещат по два пъти, с изключение на по едно уникално число. Това е точно предходната задача! Правим xor-овете на числата във всяко от подмножествата и намираме двете уникални числа.
12. Задача за трите мравки
В триъгълен съд има три мравки - всяка в един от ъглите на триъгълника. Едновременно всяка от тях тръгва към някой от другите ъгли. Какъв е шансът след като стигнат до ъгъла, към който са тръгнали, отново във всеки ъгъл да има точно по една мравка?
Тази задача е значително по-лесна от предходните. Просто трябва да направим наблюдението, че за да е изпълнено условието след прехода отново във всеки връх да има по една мравка, то трябва мравките да се движат в една и съща посока (или по часовниковата или по обратно на часовниковата стрелка). Това дава 2 благоприятни разположения. Тъй като всяка мравка има избор между два други ъгъла, то всички възможни крайни разположения са 2 * 2 * 2 == 8. Така можем да намерим, че шансът е 2/8 = 1/4 = 25%.
13. Фалшиви хапчета
Имате 10 буркана, пълни с хапчета. Казано ви е, че във всички буркани хапчетата тежат по 10 грама, с изключение на един, където хапчетата са по 9 грама. Разполагате с електронна везна, която прецизно измерва теглото на сложените върху нея обекти. Можете ли с едно единствено измерване на везната да определите в кой от бурканите хапчетата са по-леки?
Това е една от известните логически задачи. Тъй като имаме право на едно единствено теглене, трябва по такъв начин да подберем какво ще измерим, че везната да ни даде достатъчно информация за да определим в кой буркан са фалшивите хапчета. Ако от всеки буркан вземем по едно хапче, и ги сложим на везната, резултатът ще е 9 * 10 + 1 * 9, тъй като имаме 9 хапчета по 10 грама и едно по 9 грама. Ако сложим по две хапчета от всеки буркан, тогава ще имаме 18 * 10 + 2 * 9. Забелязваме, че ако от буркана с фалшивите хапчета сме взели x на брой, то сумата е с x по-малко отколкото би била ако всички хапчета бяха окей. Как може да ни помогне това?
Ако сложим 1 хапче от първия буркан, 2 хапчета от втория буркан, 3 хапчета от третия и т.н., то очакваната сума (ако всички хапчета бяха по 10 грама) би била 10 * 11 / 2 * 10 (сума на числата от 1 до 10 умножено по 10 грама). Обаче всъщност ще е по-малко, заради фалшивите. С колко по-малко? Ами с броя на фалшивите! Но тъй като взехме от всеки буркан по толкова хапчета, колкото е индекса на буркана, то показанието на везната ще е точно толкова по-малко от очакваното, колкото е и индекса на буркана с фалшивите хапчета.
14. Задача за 12-те монети
Дадени са ви 12 идентични монети, една от която е фалшива (тоест е направена от различен материал и съответно е по-лека или по-тежка). Дадена ви е и обикновена везна (на която от двете страни можете да сложите някакви обекти и тя ще ви покаже дали теглото им е равно, дали левите са по-леки или по-тежки). Можете ли с три измервания да определите коя е фалшивата монета и дали тя е по-лека или по-тежка?
Тази задача също е известна, но е значително по-сложна от предходната. Стандартния начин за решаване е да се разглеждат случаи (измерваме тези и тези, ако са равни, правим еди какво си, ако не са вземаме тези и тези и сравняваме тях и т.н.). Тъй като на всяко мерене имаме 3 варианта (лявата страна да е по-лека, равна на или по-тежка от дясната), дървото на възможностите се разклонява до 27 клона, което е сравнително много. Дори да правите някакви оптимизации (тоест да разглеждате само по 2 варианта (равна или различна)) на някои от стъпките, то пак става сравнително сложно. Въпреки всичко това е най-интуитивното и съответно най-често срещаните решения. Но е много трудно да се направи логика, която е вярна.
Вместо това ще разгледаме метод (базиран на decision trees) който задава три предопределени въпроса (тоест независимо какво покаже везната на вече направените измервания ние не променяме следващите си въпроси). Тези избори са така построени, че от отговорите на везната да може еднозначно да се определи коя е фалшивата монета и дали е по-лека или по-тежка във всички случаи.
Нека вместо 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 означим монетите с буквите A, C, D, E, F, I, K, L, M, N, O, T. Измерванията, които трябва да направим са:
- MADO vs. LIKE
- METO vs. FIND
- FAKE vs. COIN
Можете да пробвате да си избирате коя да е фалшивата монета и дали е по-лека или по-тежка и да проверявате, че с горните питания и малко логика можете лесно да познаете коя е.
15. Сложно уравнение
Дадено ви е, че A = 1, B = 2**A, C = 3**B, ..., Z = 26**Y, където операцията ** е вдигане на степен. Намерете (X - A) * (X - B) * (X - C) * ... * (X - Z) = ?.
Въпреки че уравнението изглежда непосилно за смятане на ръка (а дори и с компютър), решението е учудващо просто. В пред-пред-последната скоба имаме (X - X) = 0, следователно цялото произведение е равно на нула.
16. Точна степен на две
Дадено ви е число N и от вас се иска да напишете възможно най-прост if() (с възможно най-малко операции в него), с който да проверите дали числото е точна степен на 2 или не. Не са разрешени цикли или по-сложни конструкции.
Примерна реализация би била if (!(N ^ 1) || !(N ^ 2) || !(N ^ 4) || !(N ^ 8) || ...), но тя е много по-дълга от оптималната, а също така зависи колко битов е типа на N.
Както вече виждате, задачите с побитови операции са сравнително чести. Тях просто е хубаво да сте ги виждали, въпреки че тази специално не е толкова сложна и за измисляне. Едно от възможните решения тук е !(x & (x - 1)). Ако числото е било точна степен на две, то то има точно един сетнат бит в двоичния си запис. Изваждането на 1 би го направил нула, а всички останали битове след него - 1-ци. Но побитовия and би ги елиминирал (както и сетнатата единица). Така x & (x - 1) става нула, ако x е точна степен на две. Ако пък числото не е точна степен на 2, то ще има поне още една сетната единица, която ще се запази след операцията and.
17. Lowest bit
Дадено ви е положително цяло число N. Намерете неговия най-нисък ненулев бит (lowest bit) или по-точно числото, което представя той (ако индексът е 3, то върнете 2**3 = 8).
Примери: за числото 42 (101010) трябва да върнете 2, докато за 88 (1011000) трябва да върнете 8. Най-ниските битове са най-надясно в двоичния запис на числото.
Подобно на предходната задача, ще ползваме x & (x - 1) за да премахнем най-ниския бит. Така x - (x & (x - 1)) би ни дало разликата между числото с и без въпросния lowest bit, тоест неговата стойност. Това е и нещото, което искахме да намерим.
18. Нула или едно
По дадено цяло число x съставете израз само от побитови операции и х и скоби, който се евалюира до true, ако числото е нула или едно, и до false ако е което и да е друго. Побитови операции са +, -, ~, |, &, ^, !, <<, >> приложени върху аргумента или константи.
Тук даже не са нужни чак такива хакове: (!x | !(x - 1)) ни върши работа.
19. Умножение по седем
Покажете начин за целочислено умножение по 7 използвайки само побитови операции. Побитови операции са +, -, ~, |, &, ^, !, << (shift left), >> (shift right).
Да умножим числото по 7 е еквивалентно на това да го умножим по 8 и да го извадим веднъж. Тъй като имаме лесен начин за умножение по степени на две (shift left), бихме могли да представим 7 * x като (x << 3) - x.
20. Random Shuffle
Напишете алгоритъм, който разбърква по произволен начин елементите на дадено множество с N елемента. Покажете, че всяко разбъркване е възможно да се случи и докажете защо всеки от възможните изходи е с равен шанс (ако допуснете, че функцията rand() връща произволна стойност). Нямате право да ползвате вградената функция random_shuffle().
void randomShuffle(int* a, int n) {
for (int i = n - 1; i > 0; i--)
swap(a[i], a[rand() % (i + 1)]);
}
Горният алгоритъм итерира елементите на масива в обратен ред и разменя всеки елемент с някой от предходните или със самия себе си (тоест елементът остава на мястото си). Можем да докажем коректността на алгоритъма по индукция. С един елемент очевидно няма какво да правим. С два елемента бихме разменили 1-вия и 2-рия с шанс 1/2, което е и точно вероятността за всяка от двете пермутации на 2 елемента. Нека разгледаме как работи алгоритъма с 3 елемента. На първа стъпка разменяме 3-тия елемент с 1-вия със шанс 1/3, с 2-рия с шанс 1/3 и го оставяме на същото място също с шанс 1/3. Следващата итерация на цикъла решава същата задача, но с по-малко елементи (разглежда само първия и втория), за което вече показахме, че работи правилно. Допускаме, че работи за k елемента, и показваме, че с k+1 елемента в първата итерация шансът на всеки елемент да застане на последна позиция е 1/(k+1), като остатъкът от цикъла де факто е същият алгоритъм за k елемента.
21. Разбъркване на тесте карти
Колко е очакваният брой разбърквания на стандартно тесте с карти (с 52 карти), които трябва да направите, преди да постигнете уникално подреждане (което никога до сега не се е срещало преди в историята на човечеството). Допускаме, че при разбъркване на картите получаваме с еднаква вероятност всяка възможна тяхна подредба.
Допълнителна информация: 100 милиарда хора, живяли някога на планетата.
Да допуснем, че всеки човек, живял някога на планетата е направил по 1 милион разбърквания на тесте карти (доста пресилено, имайки предвид, че картите са изобретени едва 9-ти век и този брой е далеч по-голям от реалния). Възможните разбърквания на тесте карти са 52! = 8*10^67. Шансът произволно разбъркване да се е срещало е броят на вече направените разбърквания (в историята на човечеството) върху всички възможни разбърквания, тоест 100000000000 * 1000000 / 52! == 1*10^17 / 8*10^67 == 1 / 8*10^50 == 0. Тоест шансът с първото разбъркване да получите несрещано до сега такова е приблизително 100%.
22. Japanese Riddle
Дадени са ви:
- 42 -> 1
- 1337 -> 0
- 669 -> 3
- 1882 -> 4
- 688 -> 5
- 12345 -> 1
- 67890 -> 5
- 123 -> 0
- 456 -> 2
- 789 -> 3
Намерете 45678 -> ?
Трябва да направим наблюдението, че числото отдясно изразява колко заградени участъци има в цифрите отляво. Тоест 0 загражда 1 участък, 1, 2 и 3 не заграждат нито един, 4 загражда един, 5 не загражда нито един, 6 загражда един, 7 не загражда нито един, 8 загражда 2 и 9 загражда 1. Така отговорът за 45678 е 1 + 0 + 1 + 0 + 2 = 4.
23. Странна редица
Дадена ви е редицата 1, 11, 21, 1211, 111221, 312211, 13112221, 1113213211. Намерете следващия й член.
С изключение на първия член на редицата, всеки следващ "кодира" предходния - тоест го изразява по някакъв начин. В случая дадено число бива разбито на последователности от еднакви цифри (примерно 111221 бива разбито на 111 22 1) и следващото число кодира тези последователности като първо записва дължината на текущата такава и после записва от коя цифра е изграден тази последователност. В примера 111 22 1 ще запишем "три пъти 1, два пъти 2, един път 1", или 312211. На следващата стъпка разбиваме 312211 на 3 1 22 11 и го кодираме като 13112221. Следователно кодирането на предпоследния член 1113213211 би бил (след разбиване на 111 3 2 1 3 2 11) 31131211131221.
24. Битови подмаски
Инфо:
За да записваме подмножества на някакво множество със сравнително малък брой елементи (до 32 или до 64) понякога е по-удобно (по-бързо и по-икономично откъм памет) вместо да записваме елементите в масив, да ги запазваме в едно единствено 32 (или 64) битово число. Ако i-тият елемент присъства в подмножеството, то числото ще има 1-ца на i-та позиция в двоичния си запис и обратно - ако не присъства ще има 0 на съответната позиция. Този метод се нарича ползване на битови маски (тъй като маската от битовете на числото определя кои елементи присъстват и кои не). Ето и пример за това. Да кажем в един учебен клас има 25 ученика. Учителят може да записва отсъстващите ученици в даден ден в един единствен int. Например 1050784, чието двоично записване е 100000000100010100000, би означавало, че 6, 8, 12 и 21-ви номер са отсъствали в дадения ден.
Понякога се налага да изброим (итерираме) всички подмножества на дадено множество. Подмножество на дадено множество е множеството от някои (потенциално всички или нито един) от елементите на първоначалното множеството.
Задача:
Напишете функция, която генерира всички непразни подмножества на дадено множество, зададено чрез битова маска. Тоест генерирайте всички негови ненулеви битови подмаски.
Итеративно решение:
void iterative(unsigned mask) {
for (unsigned cmask = mask; cmask > 0; cmask = ((cmask - 1) & mask))
fprintf(stdout, "%u\n", cmask);
}
Рекурсивно решение:
void recurse(int idx, unsigned cmask, unsigned mask) {
if ((1LL << idx) > mask) {
if (cmask != 0)
fprintf(stdout, "%u\n", cmask);
return;
}
recurse(idx + 1, cmask, mask);
if (mask & (1u << idx))
recurse(idx + 1, cmask | (1u << idx), mask);
}
void recursive(int mask) {
recurse(0, 0, mask);
}
25. Бързо вдигане на степен
Напишете функция, която вдига число a на степен p. Има ли по-бързо от линейно решение?
Първото нещо, което трябва да направим е да попитаме интервюиращия какво ще е числото (реално, цяло?), каква ще е степента (реална, цяла, положителна?). В най-честия случай числото ще е или реално или цяло, а степента ще е цяла и неотрицателна. В този случай бихме могли да напишем някои от двата варианта, дадени по-долу (разбира се за предпочитане е вторият).
Линейно решение:
int slowPow(double a, int p) {
double ret = 1;
for (; p > 0; p--)
ret *= a;
return ret;
}
Логаритмично решение:
int fastPow(double a, int p) {
double ret = 1;
for (; p > 0; p >>= 1) {
if (p & 1) ret *= a;
a *= a;
}
return ret;
}
26. Точно едно повтарящо се число
Дадени са ви N числа между 1 и N-1, включително. Точно едно от тях се повтаря. Намерете кое е то.
Решението на тази задача (която ужасно напомня на друга, която сме решавали) е значително по-просто поради допълнителното условие, че ТОЧНО едно число се повтаря. Така можем да сумираме дадените ни N числа и от тях да извадим очакваната сума ако нямаше повтарящо се (която е (N - 1) * N / 2). Така ще намерим именно повтарящото се.
27. Точно едно липсващо число
Дадени са ви N-1 числа между 1 и N, включително, като точно едно от тях липсва (и няма повтарящи се). Намерете кое е то.
Решението като идея е абсолютно същото като на предходната задача - намираме сумата на дадените ни числа, намираме сумата, която бихме имали ако нямаше липсващо (този път N * (N + 1) / 2) и изваждаме от нея намерената сума на дадените ни числа.
28. Омагьосани предмети
В редица са наредени N абсолютно еднакво-изглеждащи предмета, като един от тях е истински, а останалите са негови холограми. На всеки ход можем да пробваме да докоснем точно един от тях. Ако той е бил истинският, холограмите изчезват и ние печелим. Ако не е бил, то истинският разменя мястото си с холограмата от лявата му страна (ако има такава, тоест ако не е в най-лявата позиция) или с холограмата от дясната му страна (отново ако има такава). Измислете стратегия, с която със сигурност да уловите истинския предмет, независимо от това как той се е движил и къде се е намирал в началото.
Тъй като нямаме информация по какъв начин ще се движи предметът и също така къде се е намирал в началото, трябва да измислим стратегия, която да го улавя независимо от тези две неща. Нека за простота номерираме позициите от 1 до N, като 1 e най-лявата, а N е най-дясната. Една интуитивна стратегия би била първо да докоснем 1, после 2 и т.н. докато стигнем до N. Това би работело, ако в началото истинският предмет се е намирал на нечетна позиция, но забележете, че ако е бил на четна (например на 2-ра), след като сме докоснали холограмата на 1-ва позиция и тя не е била истинският предмет, той може да се премести на 1-ва позиция (а ние повече няма да я докоснем), така че не бихме го уловили.
Трябва да използваме това, че на всяка стъпка истинският предмет отива или на лявата му съседна позиция, или на дясната - тоест със сигурност сменя четността си - ако е бил на четна позиция отива на нечетна, и обратно - ако е бил на нечетна отива на четна. Както вече казахме, ако в началото четността му е била нечетна, с горната стратегия бихме успели да го хванем. Ако сме стигнали до N и не сме го хванали, то знаем, че в началото е бил на четна позиция и някъде се е разминал с нас. Забележете, че през цялото време разстоянието между индекса, който докосваме и индекса, на който се намира истинският предмет е с една и съща четност (ако не сме го хванали четността на разстоянието е нечетна). За да го хванем, трябва да направим това разстояние четно. Затова след като сме стигнали до N можем да докоснем на следваща стъпка отново N. Така истинският предмет ще се премести и ще смени четността си, докато ние няма. Така съответно ще се промени и четността на разстоянието до истинския предмет.
Сега започваме обратно обхождане (от N-1 към 1), като вече сме гарантирали, че предметът е на правилната четност от нас за да го хванем. Тоест обхождането би било 1, 2, ..., N-1, N, N, N-1, ... 2. Забележете, че не е нужно да стигаме до 1, тъй като четността ни гарантира, че ще сме го хванали в най-лошия случай на позиция 2.
29. Game 21
Играта 21 е известна игра, която се играе от двама души. В началото пред тях има купчинка с 21 монети. Играчите се редуват, започвайки от единия. Играчът, който е на ход, има право да махне 1, 2 или 3 монети от купчината (ако има толкова останали в нея). Играчът, който не може да направи ход (тоест не са останали монети) губи. Има ли стратегия, при която първият може да спечели, независимо от ходовете на втория? Опишете я.
Тази игра е една от най-базовите в Game Theory (което се различава от "Теория на Игрите", която се преподава във ФМИ). За да решим поставената задача ще тръгнем отзад напред. Първо, ако са останали 0 монети, то играчът, който е на ход, не може да направи нищо и губи (тоест обявяваме 0 за губеща позиция). Ако има 1, 2 или 3 монети, то текущият играч може да ги вземе всички и да остави на другия играч 0 (което, както вече казахме, е губеща позиция). Тоест 1, 2 и 3 са печеливши позиции. Нека разгледаме случая, в който в купчината има 4 монети. Независимо дали играчът, който е на ход, вземе 1, 2 или 3 монети, винаги в купчината остават 3, 2 или 1 монета, съответно, тоест след този ход другият играч може да спечели. Така намерихме, че 4 е губеща позиция.
Като цяло в такива игри правим следните стъпки:
- Разглеждаме играта отзад напред (тоест от по-малки купчини към по-големи)
- Разделяме позициите на печеливши и губещи, спрямо това дали от една позиция можем да спечелим при оптимална игра.
- Ако от дадена позиция можем да вкараме другия играч във вече доказано губеща позиция, то текущата позиция е печеливша.
- Ако всички възможни ходове водят до печеливши позиции, то текущата позиция е губеща.
Така намираме, че 22-те позиции (от 0 до 21, включително) са: Г, П, П, П, Г, П, П, П, Г, П, П, П, Г, П, П, П, Г, П, П, П, Г, П. Тоест 21 е печеливша позиция, като от нея трябва да вземем 1 монета (за да вкараме противника в губеща). Като цяло, в така зададената игра всяка позиция, която се дели на 4 е губеща.
* Забележете, че този начин на определяне на позициите работи не само за тази игра. Например ако имахме право да взимаме 1, 3 или 4 монети (вместо 1, 2 или 3) позициите биха били: Г, П, Г, П, П, П, П, Г, П, Г, П, П, П, П, Г, П, Г, П, П, П, П, Г. Забележете, че в тази игра първоначалната позиция е губеща (тоест ако противникът играе оптимално, ние не можем да спечелим по какъвто и начин да играем).
30. Първо липсващо естествено число
Даден ви е масив с N естествени числа, които (потенциално) могат да бъдат много големи. Пита се кое е първото естествено число, което не фигурира в масива. За тази задача приемете, че естествените числа са целите, положителни числа.
Най-очевидното решение е със сложност О(N^2) - като за всяко естествено число линейно проверим дали то присъства или не. Това е много бавно.
Възможна оптимизация е да сортираме масива, и после линейно да проверим кое е първото, което липсва. Сложността на това решение е O(N * logN), което е по-приемливо, но все пак бавно.
Получената сложност O(N * logN) дойде от сортирането, но можем да я оптимизираме, като забележим, че можем спокойно да игрнорираме всички числа, които са по-големи от N. Наистина, ако има число в масива, което е по-голямо от N, то със сигурност отговорът ще е по-малък от него. Така разглеждаме само числа между 1 и N, включително. Можем да заделим втори масив, като в него направим Counting Sort на числата от входния масив, които са между 1 и N. Така ги "преброяваме" в новия масив, като, ако сме срещнали числото x, увеличаваме с 1-ца x-тата му позиция. Реално даже не ни интересува колко точно пъти се среща дадено число, а само дали се среща или не. Така втория масив можем да инициализираме с 0 и да записваме 1 на x-та позиция, всеки път като срещнем число x. След като обходим входния масив и запишем кои числа се срещат, обхождаме втория масив и търсим първата позиция, на която имаме 0.
Ако има таква позиция, то тя е отговорът на задачата. Ако пък няма, то отговорът е N+1 (тъй като явно всички числа от 1 до N се срещат). Това решение е линейно както по време така и по допълнителна памет. Можем да го оптимизираме още малко, като вместо да пазим втори масив, ползваме първия. Очевидно не можем да затриваме числата, но пък тъй като ни трябва точно един бит допълнителна информация, можем да ги правим отрицателни. Тоест отрицателно число на i-та позиция би означавало, че във входния масив сме срещнали число i. Тук трябва да внимаваме да вземаме от входния масив вече не самите му елементи, а техните абсолютни стойности (тъй като е възможно да сме ги променили). Също така, когато искаме да направим отрицателна i-та позиция трябва да внимаваме дали тя вече не е отрицателна. Това решение е със сложност по време O(N) и сложност по (допълнителна) памет O(1).
На интервю също така можете просто да кажете, че ще ползвате хешове, като така ще имате O(N) сложност по време и O(N) сложност откъм допълнителна памет. Това в общия случай също ще е приемливо.
31. Лиспващо число в сортиран масив
Даден ви е сортиран масив с N числа между 0 и N, включително, без повтарящи се числа, сортирани в нарастващ ред. Как бихте намерили лиспващото число?
Тази задача почти крещи "binary search, binary search". Винаги, когато имате нещо сортирано, погледнете защо ви го дават сортирано. Една от възможностите е да приложите двоично търсене (както е и в случая). На всяка стъпка от двоичното търсене ще проверявате дали на m-та позиция (където m е средата на текущо-разглеждания интервал) стои числото m. Ако не, то липсващото число е или на тази позиция, или наляво. Ако ли пък е, то значи липсващото число е на индекс, по-голям от m.
32. Cast
За какво се използва термина "cast"? Защо се използва?
Това е пример за теоретична задача. Терминът "cast" се използва, когато искаме да превърнем някаква променлива в тип, различен от оригиналния й. Например ако искаме да ползваме нецелочислено, вместо целочислено деление на две цели числа, трябва да cast-нем едното към нецелочислен тип. Например int a = 42, b = 5. a / b би дало 8, но (double)a / b би дало 8.4. Тук "cast"-ваме integer-а a към double. Cast често се ползва, когато при някакви сметки има шанс да overflow-нем даден тип (обикновено кастваме int към long long). Също така има употреба в ООП, когато искаме да кастнем наследен клас към базов или обратно. Когато не знаем какъв тип данни получаваме, обикновено подаваме void*, който в последствие cast-ваме към правилния тип.
33. Statement vs. Block
Каква е разликата между statement и block?
Statement обикновено изразява единична операция, например присвояване на стойност, логически оператор (if без тялото му), цикъл без тялото му (тоест само for() или while() без следващия ги код, който всъщност се изпълнява). От друга страна за block се счита група от statement-и, най-често оградена по някакъв начин (с къдрави скоби в C/C++ и Java; чрез индентация в Python).
1. int gcd(int a, int b) {
2. if (b > a)
3. swap(a, b);
4. while (b) {
5. a %= b;
6. swap(a, b);
7. }
8. return a;
9. }
В горния пример ред 2 (сам по себе си) е statement, както и ред 4. Ред 5 също е statement, но редовете 5 и 6 заедно образуват block (тялото на while цикъла). Редове от 2 до 8 също образуват block (тялото на функцията).
34. Method vs. Function
Каква е разликата между метод и функция?
Функция е парче код, което се извиква чрез неговото име (име на функцията). Възможно е да му се подадат данни, върху които то да работи (аргументи или параметри на функцията), и също така то да върне някакви данни. Всичките данни, които се подават на функцията се подават експлицитно.
Метод, от друга страна, е парче код, което се извиква чрез своето име и е асоциирано с някакъв обект (клас). В повечето аспекти методът е идентичен на функцията, с изключение на две важни разлики:
- Имплицитно му се подава обекта, за който е бил извикан даденият метод (указателят this).
- Може да оперира върху данни, съдържани в класа.
35. Logical vs. Binary operators
Каква е разликата между & и &&, както и между | и ||? Дайте пример, в който те не са взаимно-заменяеми.
Операторите & и | са бинарни оператори (изпълняват операции върху двоични числа), докато && и || са логически оператори (изпълняват операции върху обекти, които се евалюират до true или false).
Пример, в който не са взаимно-заменяеми е if(1 & 2) и if(1 && 2): докато първият if не би се изпълнил, то вторият би. За OR можем да дадем следните примери: int a = (5 | 3) би дало стойност на а равна на 7, докато int a = (5 || 3) би дала стойност на а равна на 1.
36. Unique
Дадено ви е мултимножество от числа. Върнете множеството от същите числа, но без повторения. Разгледайте случая, в който началното множество е сортирано.
Можем да ползваме хеш-таблица, като за всяко число първо проверяваме дали вече сме го срещали, и ако не сме го добавяме в множеството, което ще върнем, както и в хеш-таблицата (с цел да не го добавяме повече от веднъж). Сложността на това решение е линейна по броя елементи в началното множество.
Случая, в който началното множество вече е сортирано също се решава с O(N), но за него не ни е нужна хеш-таблица. Примерна реализация би била:
/**
* Removes duplicate elements from a sorted array.
* @return Тhe number of unique elements.
*/
int unique(int* a, int n) {
int idx = 0;
for (int i = 1; i < n; i++) {
if (a[i] != a[idx])
a[++idx] = a[i];
}
return idx + 1;
}
37. Задачата с крушките
Намирате се в изолирана стая с три ключа за осветление - контролиращи три крушки в съседна стая, която не можете да видите. Как бихте могли да разберете кой ключ коя крушка контролира, ако веднъж отишли в стаята с крушките не можете да се върнете в стаята с ключовете?
Известна логическа задача (малко стара, ще видите защо). Решението е да включите единия ключ за 5-10 минути, след това да го изключите, да включите втория и да отидете в стаята с крушките. Докосвате двете несветещи крушки - една от тях ще е топла (тъй като е светила до сега) - тя отговаря на първия ключ. Светещата крушка отговаря на втория ключ. Несветещата, студена крушка отговаря на третия. Това решение като че ли не работи много добре с халогенни крушки =/
38. Задачата с прасетата и отровата
Готвачът на краля приготви 1000 гозби за неговата сватба! Но кралските шпиони му съобщиха, че някой е сипал отрова в точно една от гозбите му. Отровата започва да се забелязва 2 часа след поемане на храната, а до сватбения пир остават... малко повече от два часа - тоест има време точно за една проба. За щастие, готвачът има на разположение много прасета, върху които може да експериментира - да им забърка смесица (от една или повече от гозбите) и да им я даде да я изядат. Така ако до два часа прасето умре, то в някоя от гозбите, които е изяло, е имало отрова. Колко най-малко прасета е нужно да са на разположение на готвача, за да може да определи в коя гозба е отровата в рамките на тези 2 часа? Примерно решение е да даде на 1000 прасета по точно една от гозбите, но има решение с много по-малко на брой. Тоест той гледа да минимизира не броя умрели прасета, а броя ползвани такива.
Отговорът е 10 прасета (двоичен логаритъм от 1000). На първото прасе ще даде от всяка от пърата половина от гозбите. На второто прасе ще даде от всяка от първата и третата четвъртина от гозбите. На третото ще даде от 1-вата, 3-тата, 5-тата и 7мата осмина от гозбите и т.н. Визулно можем да представим на кое прасе от кои гозби даваме чрез битови маски (например ако имаше 100 прасета):
- 1111111111111111111111111111111111111111111111111100000000000000000000000000000000000000000000000000
- 1111111111111111111111111000000000000000000000000011111111111111111111111110000000000000000000000000
- 1111111111111000000000000111111111111100000000000011111111111110000000000001111111111111000000000000
- 1111111000000111111000000111111100000011111100000011111110000001111110000001111111000000111111000000
- 1111000111000111000111000111100011100011100011100011110001110001110001110001111000111000111000111000
- 1100110110110110110110110110011011011011011011011011001101101101101101101101100110110110110110110110
- 1010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010101010
Може би по-очевиден е пример с точна степен на 2 прасета (в случая 32):
- 11111111111111110000000000000000
- 11111111000000001111111100000000
- 11110000111100001111000011110000
- 11001100110011001100110011001100
- 10101010101010101010101010101010
Забележете, че разпределяйки гозбите по този начин може да се определи точно коя от тях е отровна. Това, което направихме, е един вид двоично търсене, само че го извършихме "наведнъж" -- тоест паралелно двоично търсене :)
39. Най-големи числа с поне 2 повторения
Дадена ви е редица от числа. Намерете двете най-големи от тях, които се срещат поне 2 пъти.
Това е поредния пример за задача от интервюта, която може да се реши с хешове. В случая можем да ползваме hashmap и за O(N) да намерим колко пъти се среща всяко от числата, където N е броят числа в началната редица. При добавянето на всеки елемент проверяваме дали неговите срещания са станали 2 и ако това е така, update-ваме най-големия или втория най-голям намерени елементи (с поне по 2 срещания). Това решение всъщност работи за задачата "Кои са 2-та най-големи елементи, които се срещат поне K пъти". Като (малка) оптимизация можем да ползваме hashset вместо hashmap за задачата с 2 срещания (като просто в него пазим кои елементи сме срещали до сега, и при попадане на елемент, който вече присъства в hashset-а да update-ваме отговора (ако се налага), тъй като щом текущия елемент е вече в сет-а, то значи това му срещане е поне 2-ро.
40. Структура данни за медиана
Опишете структура данни, която поддържа бързо добавяне на число и бързо питане за медианата на досега добавените.
Най-тривиалното решение би било да пазим досега вкараните елементи сортирани и питането ни за медиана да става константно. Така, обаче, вкарването на нов елемент ще е с линейна сложност, което не можем да дефинираме като "бързо". Разбира се, ако пазим числата в двоично наредено дърво (binary search tree) бихме постигнали логаритмична сложност (ако дървото е балансирано) както за вкарване на нов елемент, така и за питане за медианата. За съжаление така трябва за всеки връх да пазим и колко наследници има той (за да можем да намираме медианата). Повечето вградени дървета не поддържат това нещо (питане за k-ти по големина елемент), така че трябва ние сами да напишем балансираните дървета (не можем да ползваме вградени в езика имплементации). Това е сравнително сложно, особено ако трябва да се имплементира на интервю.
Вместо това ще покажем друг подход, който не само е значително по-лесен за имплементиране, ами предоставя и по-добра сложност при питане за медианата. Нека имаме две приоритетни опашки (пирамиди, heaps, priority_queue) - едната пазеща числата в прав ред (най-голямото е най-отгоре), а другата - в обратен ред (пазеща най-малкото най-отгоре). Първата от тях ще наричаме "лява", а втората - "дясна". Ще гледаме да ги държим с равен брой елементи (тоест едната ще има най-много с 1 елемент повече от другата). Нека разгледаме как добавяме елемент: нека елементът е със стойност X. Проверяваме дали X е по-малък от най-големия елемент на лявата пирамида. Ако да, го вкарваме в нея, иначе го вкарваме в дясната. В този момент е възможно една от пирамидите да има с 2 елемента повече от другата (ако е имала 1 елемент повече и сме добавили новия елемент в нея). Ако това е така, местим най-големия елемент от лявата пирамида в деясната (ако лявата е с повече елементи),
или най-малкия елемент на дясната в лявата (ако дясната пирамида е с повече елементи). Така след всяко добавяне броят елементи в двете пирамиди се различава най-много с 1. Питането за медианата също е тривиално. Ако едната пирамида има повече елементи, то медианата е най-големият елемент от лявата пирамида (ако тя е с повече елементи) или най-малкият елемент на дясната в противен случай. Ако пък двете пирамиди са с равен брой елементи, то медианата е средното аритметично на най-големия елемент на лявата и най-малкия елемент на дясната. Каква сложност постигнахме в така имплементираната структура данни? Добавянето на нов елемент в най-лошия случай се изразява в едно добавяне, едно изваждане и още едно добавяне на елемент в пирамида. Всяка от тези операции е O(logN), така че добавянето ни е със сложност O(logN). Питането за max от дясната пирамида и за min от лявата става константно, така че сложността за питане е O(1).
Съществува и трето решение, ползващо структурата данни Tiered Vector, при която добавянето е със сложност O(srqt(N)), докато питането за медианата е O(1).
41. Произведения на всички числа без едно в масив
Имате масив с N числа и искате да върнете нов масив, в който на i-та позиция има произведението на всички числа с изключение на i-тото от първоначалния масив. Как бихте решили задачата, ако нямахте деление?
Едно интуитивно решение би било да намерим произведението на всички числа от първоначалния масив (нека то бъде productAll), и после с второ обхождане да запълним масива, който връщаме като result[i] = productAll / input[i].
Ако нямаме деление, обаче, задачата не е толкова лесна. Съществуват няколко подхода, като най-хубавият както откъм ефективност така и откъм леснота на писане е следния. Създаваме два помощни масива fromLeft[N] и fromRight[N]. Първия от тях запълваме, като на i-та позиция пазим произведението на всички числа до i-тото (невключително) от входния масив. Тоест fromLeft[i] = input[0] * input[1] * ... * input[i-1]. Забележете, че запълването на масива може да стане линейно, като забележим, че: fromLeft[0] = 1; fromLeft[i] = fromLeft[i - 1] * input[i - 1], за i > 0.
Аналогично, масива fromRight запълваме по такъв начин, че на i-та позиция пазим произведението на всички числа от входния масив с индекси от i+1 до N-1. Тоест fromRight[N-1] = 1; fromRight[i] = fromRight[i + 1] * input[i + 1], за i по-малко от N-1. Лесно намираме резултата, като забележим, че result[i] = fromLeft[i] * fromRight[i]. Както създаването на масивите, така и намирането на отговора става линейно (по показания начин), затова и цялото решение е линейно. Също така забележете, че не ви трябват 2 допълнителни масива - всъщност не ви трябва нито един! Можете да изчислите fromLeft в result, и после с второ обхождане (отдясно наляво) директно да намерите отговорите (ползвайки една допълнителна променлива, в която пазите досегашното произведение на срещнатите числа). Така намерихме решение със сложност по време O(N) и сложност по (допълнителна) памет O(1), ако считаме, че масива, който връщаме (result) не се включва (тъй като той не може да бъде избегнат).
42. Въпросът за Живота, Вселената и Всичко Останало
Намерете отговора на Живота, Вселената и Всичко Останало.
42
43. Разделяне на Тортата на 8 Парчета
Дадена ви е кръгла торта. Как можете да я разделите на 8 еднакви парчета с 3 прави разреза?
Два разреза под 90 градуса, и един напречен. Интересното е, че ако и трите разреза са отгоре на тортата няма как да я разделим на повече от 7 парчета.
44. Ъгъл Между Стрелките на Часовник
По даден час и минути определете какъв е (по-малкият) ъгъл между стрелките на аналогов часовник.
Можем да определим ъгъла на малката и голямата стрелка спрямо някаква отправна точка (например 12:00), като след това намирането на ъгъла между двете стрелки става лесно. Нека даденият ни час е hh:mm и ако hh е 12 да го считаме като hh = 0. Тъй като цялото завъртане е 360 градуса, то малката стрелка се завърта hh/12 * 360, или hh * 30 градуса за всеки час. Но тъй като и минутите влияят на нейното завъртане, то към ъгълът, който определихме от часовете, трябва да прибавим още малко отместване за минутите, което е mm / 60 * 30 или mm / 2. Така цялото отместване на малката стрелка е hh * 30 + mm / 2 градуса. Отместването на голямата стрелка е доста по-просто - то е само mm / 60 * 360 или mm * 6.
Нека сме намерили angleHour = hh * 30 + mm / 2 и angleMinute = mm * 6. Тогава по-малкият ъгъл между двете стрелки е min(abs(angleMinute - angleHour), 360 - abs(angleMinute - angleHour)).
45. Всички Пермутации на String
Напишете функция, която печата всички пермутации на зададен string.
Това е пример за изцяло кодерска задача.
Решение 1 (cheat):
void printAllPerms(char* str, int n)
{
sort(str, str + n);
do {
cout << str << endl;
} while (next_permutation(str, str + n));
}
Решение 2:
void printAllPerms(char* str, int n)
{
sort(str, str + n);
while (1)
{
cout << str << endl;
// Find rightmost index that has inversion
int idx = n - 1;
while (idx > 0 && str[idx] <= str[idx - 1]) idx--;
// If there is no such index we have printed all permutations
if (idx == 0) break;
// Find minimal letter to the right of that index,
// that is greater than it
int swp = idx;
for (int i = idx; i < n; i++)
if (str[i] > str[idx - 1] && str[i] < str[swp]) swp = i;
// Swap it with the one at that index and sort all letters
// to the right of it
swap(str[swp], str[idx - 1]);
sort(str + idx, str + n);
}
}
Вторият алгоритъм би могъл да бъде оптимизиран на няколко места. Например търсенето на най-малката буква, която е по-голяма от тази на индекса с инверсията може да стане с binary search, тъй като буквите надясно са сортирани (макар и в обратен ред). Също така при внимателно имплементиране на swap-овете не се налага да сортираме буквите надясно всеки път - както казахме, те са сортирани, но в обратен ред. Можем просто да ги reverse-ваме, което става със сложност O(N) вместо O(N * logN).
46. Игра на Кръгла Маса
Дадена е кръгла маса с радиус 10 сантиметра. Двама играчи се редуват и слагат по една монета с радиус 1 сантиметър на масата така, че да не застъпва никоя от досега сложените монети и да не пада от масата. Играчът, който не може да сложи момента губи. Има ли стратегия за първия играч, при която той винаги печели? Каква е тя?
След малко мислене можем да забележим, че единственото по-специално място на масата е нейният център. Как ни помага това? Нека (като първи) сложим монета точно в центъра на масата. Където и да сложи монета вторият играч, ние можем да сложим точно от другата страна на масата симетрично спрямо центъра. Така вторият играч отново е в ситуация, където всяка точка от масата е еднаква с точката симетрино спрямо центъра. Наистина, центърът на масата е единствената точка, която няма симетрична спрямо центъра. Следователно ако другият играч е сложил монета някъде, ние винаги можем да сложим симетрично на него. Така рано или късно за него няма да остане място къде да сложи монета и ще загуби.
47. Triminoes
Даден е квадрат със страна 2**N, разделен на малки квадратчета със страна 1. Едно от тях е оцветено в черно, всички останали в бяло. На всеки ход ние можем да поставим trimino, успоредно на страните на квадрата, ако то е разположено само върху бели плочки. Trimino е плочка от три квадратчета под формата на Г (тоест квадрат 2 на 2, на който липсва една от плочките). След поставянето му, трите бели плочки, върху които лежи стават черни. Възможно ли е да се оцвети целият квадрат в черно с поставяне на trimino-та? Как?
Това е една интересна задача, която се решава чрез техниката "Разделяй и Владей". Ако дъската е 1 на 1 очевидно има решение (без нито едно тримино). Ако дъската е 2 на 2 то също очевидно има решение. Нека разгледаме случая, в който дъската е 4 на 4.
.... => 1122 или .... => 5544 или #... => #122
.#.. => 1#32 .... => 5334 .... => 1132
.... => 4335 .... => 2311 .... => 4335
.... => 4455 ..#. => 22#1 .... => 4455
Намерихме решение на три от възможните начални разположения. След известно наблюдение се забелязва, че всяка дъска с размер 4 на 4 има решение чрез ротация и/или симетрия на някоя от горните три дъски. Бихме могли да предположим, че ВСЯКА дъска със страна 2**N има решение. Сега да намерим стратегия и как да намираме самите рещения.
Нека разделим началната дъска на 4 равни части (два разреза: един през средата по X и един през средата по Y). В една от тези 4 е първоначалното черно квадратче, a останалите 3 са изцяло бели. Но ако сложим едно тримино в центъра (там, където двата разреза се пресичат) така че липсващата му плочка да застъпва частта, която съдържа началната черна плочка, то вече всяка от четирите части е с размер 2**(N-1) и има по една черна плочка. Но това е същата задача с по-малък размер! Следователно можем да решим рекурсивно със същия алгоритъм всяка от 4-те подзадачи и да "съединим" решенията, получавайки решение и за големия квадрат. Дъното на рекурсията може да бъде дъска с размер 1. Нека видим как би изглеждало първото разрязване на дъска с размер 8 на 8:
........ ........
.#...... .#......
........ ........ .... .... ...1 1...
........ ....1... .#.. .... .... ....
........ => ...11... => .... , .... , .... , ....
........ ........ .... 1... .... ....
........ ........
........ ........
48. Избор на Град Според Популацията Му
Напишете функция, която по дадено множество от градове и тяхната популация, избира някой от тях, като шансът даден град да бъде избран е пропорционален на броя жители в него. Например ако градовете са София (1,263,328 жители), Стара Загора (150,081 жители), Варна (350,064 жители) би избрала София с шанс 71.638%, Стара Загора с шанс 8.511% и Варна с шанс 19.851%.
За целта ще ползваме функциите за псевдо-рандом числа в езика, на който пишем. Те дават достатъчно добро приближение за целите на задачата.
string chooseCity(string* names, int* population, int n)
{
long long sum = 0;
for (int i = 0; i < n; i++)
sum += population[i];
// Make sure random() returns large enough numbers
long long num = random() % sum;
for (int i = 0; i < n; i++)
{
num -= population[i];
if (num < 0) return names[i];
}
return "Something went terribly wrong.";
}
49. Appointment Problem
Зададено е множество от събития, зададени чрез тяхното начално време и крайно време. Изберете максимално (по брой) тяхно подмножество, така че никои две събития да не се застъпват (но може да се "докосват", тоест единото да почва точно когато свършва предходното).
Това е известна задача в програмирането, която се решава чрез алчна стратегия (Greedy). Нека например имаме event-ите { [0, 13], [2, 5], [4, 6], [5, 9], [8, 14], [11, 12], [15, 20] }. Ако просто ги взимаме в хронологичен ред (от тези, които започват по-рано към тези, които започват по-късно) не бихме постигнали оптимален резултат. Например за даденото множество бихме намерили резултат 2: {[0, 13], [15, 20]}, което очевидно не е оптимално. Ако пък взимаме с приоритет по-къси мероприятия, бихме получили {[4, 6], [11, 12], [15, 20]}. Този път намерихме отговор 3, но и той не е оптимален. Оптимален отговор получаваме ако сортираме евентите по техния КРАЙ и ги взимаме в този ред (като гледаме всеки следващ да не се застъпва с някой от досега взетите). Така получаваме подмножеството {[2, 5], [5, 9], [11, 12], [15, 20]}.
50. Произволен Елемент от Генератор
Даден ви е (краен) генератор на числа (списък с неизвестен брой елементи, които НЕ можем да индексираме, единствено можем да искаме следващия елемент докато изчерпим всички). Измислете как с константна допълнителна памет и линейно (по броя генерирани елементи) време да изберете случаен елемент от него. Например ако генерираните елементи са били {3, 1, 5, 1, 5, 2, 7, 5}, То 2, 3 и 7 трябва да бъдат избрани с шанс 1/8, 1 да бъде избрано с шанс 1/4 и 5 да бъде избрано с шанс 3/8.
Това, че не знаем колко елемента има в генератора е голям проблем (иначе има няколко лесни решения). Също така не можем и да намерим този брой елементи, тъй като а) можем да генерираме елементите само веднъж и б) не можем да запазим елементите (искаме константна допълнителна памет). Следователно трябва да измислим нещо по-хитро. Реално решението не е МНОГО по-хитро, просто е малко по-сложно да се види защо е вярно.
В началото инициализираме брояч на досега генерираните елементи counter = 0 и заделяме памет за върнатия елемент ret. Очевидно това е О(1) откъм памет. След всеки изтеглен елемент:
- Увеличаваме брояча
- С шанс 1/counter присвояваме на ret стойността на току-що изтегления елемент
Ако в генератора има само един елемент то той е в ret с шанс 1/1. Ако в генератора е имало два елемента, то всеки от тях е в ret с шанс 1/2 (тъй като на първата стъпка сме взели първия, а на втората с шанс 1/2 сме го сменили с втория елемент). По индукция можем да докажем, че ако в генератора е имало N елемента, след изпълнението на този алгоритъм всеки от тях е в ret с шанс 1/N. Наистина, нека допуснем, че алгоритъмът работи вярно за K елемента (можем да вземем база примерите с 1 и 2 елемента). Тогава, при добавяне на K+1-ви елемент шансът да вземем него е 1/(K+1) (колкото трябва), а шансът да вземем всеки от предходните е шансът да не вземем текущия (K/(K+1)) умножено по шансът му до сега (1/K), което е точно K/(K+1) * 1/K = 1/(K+1).
51. Стъпала
Човек изкачва стълбище с N стъпала. На всяка стъпка той или се качва на следващото стъпало, или изкачва две наведнъж (прескача едно). По колко различни начина може да изкачи стълбището? А ако броят на стъпалата е много голям (1,000,000,000)?
Тази задача може да бъде решена по много начини, като може би най-простият е да разгледаме отговорите за малки N:
- Едно стъпало може да бъде изкачено само по един начин
- Ако имаме две стъпала можем или да ги качим двете наведнъж или едно по едно (два начина)
- Ако имаме три стъпала можем или да ги качим поединично, или да качим две на веднъж и после 1, или първо едно и после две (три начина)
- Вариантите са {(1, 1, 1, 1), (1, 1, 2), (1, 2, 1), (2, 1, 1), (2, 2)} (пет начина)
- Вариантите са {(1, 1, 1, 1, 1), (1, 1, 1, 2), (1, 1, 2, 1), (1, 2, 1, 1), (2, 1, 1, 1), (1, 2, 2), (2, 1, 2), (2, 2, 1)} (осем начина)
Почти всички от вас трябва вече да са видели, че това е редицата на Фибоначи. Наистина, има логика да е тя, тъй като на K-тото стъпало или сме се изкачили от K-1-вото или от K-2-рото, което е точно рекурентната зависимост на числата на Фибоначи.
Окей, измислихме лесен начин да смятаме броят възможни начини, но нещо, което повечето хора не знаят е как могат да се смятат те по бърз начин. Във ФМИ често се дава тази редица като пример (за рекурсия, итерация, динамично програмиране, дървета и още какво ли не), та сигурно сте видели поне няколко начина да ги намирате. Едва ли обаче някой ви е показал, че има и логаритмичен начин за намиране на N-тия член на редицата.
По-интересното е, че дори логаритмиянит начин има няколко варианта. Единият е с формула, а другият с по-generic метод за решаване на рекурентни зависимости. Тоест можем просто да вдигнем матрица 2 на 2 на N-та степен за да намерим N-тото число на Фибоначи. Вече знаем, че можем да вдигаме число на степен с логаритмично време. Защо същият метод да не работи и за матрици? Наистина, той работи.
В случая с числата на Фибоначи, трябва да вдигнем следната матрица на степен:
|1 1| ** N == |F(N+1) F(N)|
|1 0| |F(N) F(N-1)|
58. Петата Карта
От стандартно тесте карти с 52 карти биват изтеглени 5 произволни карти. Искаме да дадем 4 от тях на друг човек, така че по тях той да може да познае 5-тата. Каквъв "протокол" (стратегия) на комуникация биха могли да имат двамата човека за да определят еднозначно 5-тата карта по дадените 4? Картите се дават наредени (тоест ordered set), но не може да се подсказва по друг начин (като например някоя карта да е обърната или завъртяна).
Преди да започнем с решението трябва да въведем малко правила. Тъй като всяка карта е различна от останалите можем да направим "наредба" на картите - например спатиите са по-малки от карите, карите са по-малки от купите, и купите са по-малки от пиките. Картите от една и съща боя също могат да бъдат подредени: 2, 3, 4, 5, 6, 7, 8, 9, 10, J, Q, K, A. Така за всяка двойка карти може да се каже коя е по-малката и коя е по-голямата.
Сега, при даване на 4 карти можем да ги дадем в различен ред, и тъй като четирите карти имат наредба и са различни имаме 4! възможни подредби, тоест само чрез подредбата на картите можем да изразим число от 1 до 24, включително. Тъй като сме дали 4 карти, знаем, че 5-тата не е никоя от тях, тоест 5-тата е една от 52 - 4 = 48. За съжаление така можем да изразим само число между 1 и 24, а не 1 и 48 (тоест не ни достига един бит информация). Тази идея (май) може да се доразвие до работещо решение, но става сложно (тоест дори по-сложно от решението, което ще покажем малко по-долу).
Можем да направим наблюдението, че при 5 карти и 4 бои поне една боя ще се среща поне два пъти (принцип на чекмеджетата). Така боята на първата карта от четворката може да показва боята на 5тата карта. Тъй като вече една карта със същата боя е била дадена (като първа), остават 12 възможни карти с тази боя. Остават три карти, които трябва да ни определят коя от тези 12 е петата. Според пермутацията на тези три карти можем да определим число от 1 до 6. Отново не ни достига един бит информация (можем да определим число от 1 до 6 а ни трябва от 1 до 12).
За разлика от варианта с пермутацията с 4 карти, обаче, тук имаме допълнително информация, която можем да прехвърлим чрез първата карта, тъй като тя е от същата боя като петата. Нека считаме, чe картите от една боя не са редица ами кръг (тоест след A следва отново 2). Така разликата между две карти от една боя е най-много 6 (избирайки по-късото разстояние). Както казахме, чрез пермутиране на останалите 3 карти в четворката можем да изразим именно число между 1 и 6 - тоест можем да кажем колко е това разстояние! Така ако преномерираме картите от една боя от 2, 3, ..., 10, J, Q, K, A, на 0, 1, 2, ..., 12, то винаги можем да изберем първата карта (нека нейния номер след преномерацията е firstCard) по такъв начин, че петата карта да е от същата боя и да е с номер (firstCard + dist) % 13, където dist е по-малкото разстояние, зададено чрез пермутацията от останалите 3 карти в четворката.
Нека например петте избрани карти са KC, 6D, 3C, QH, 4S (поп пика, шестица каро, тройка пика, дама купа, четворка спатия). Виждаме, че имаме две карти от цвета пика, следователно една от тях ще е първа. Разликата (нагоре) от 3 до поп е 10 (4, 5, 6, 7, 8, 9, 10, J, Q, K), докато от поп към 3 е 3 (А, 2, 3). Следователно първата карта, която ще дадем, е поп пика. С останалите три карти (6 каро, дама купа, 4 спатия) трябва да образуваме третата пермутация. Тъй като 4S е по-малка от 6D, която от своя страна е по-малка от QH, тo третата пермутация би била (6D, 4S, QH). Така давайки (KC, 6D, 4S, QH) другият би могъл да познае, че петата карта е 3C.
|