Чому петлі швидші, ніж рекурсія?


18

На практиці я розумію, що будь-яка рекурсія може бути записана у циклі (і навпаки (?)), І якщо ми вимірюємо з реальними комп'ютерами, то виявляємо, що петлі швидші, ніж рекурсії для тієї ж проблеми. Але чи є якась теорія, що зумовлює це, чи це в основному емпіричне?


9
Виглядає лише швидше, ніж рекурсія в мовах, які їх погано реалізують. Мовою з відповідною рекурсією Tail, рекурсивні програми можуть бути переведені в петлі за лаштунками, і в цьому випадку різниці не буде, оскільки вони однакові.
jmite

3
Так, і якщо ви використовуєте мову, яка її підтримує, ви можете використовувати (хвостову) рекурсію, не маючи жодних негативних наслідків на продуктивність.
jmite

1
@jmite, хвостова рекурсія, яку насправді можна оптимізувати в циклі, надзвичайно рідкісна, набагато рідша, ніж ви думаєте. Особливо в мовах, які керували типами, такими як посилання, що перераховуються.
Йохан - відновити Моніку

1
Оскільки ви включили часову складність тегу, я вважаю, що слід додати, що алгоритм із циклом має таку саму часову складність, як алгоритм з рекурсією, але з останньою, час, який потрібно, буде більшим на деякий постійний коефіцієнт, залежно від кількість накладних витрат на рекурсію.
Lieuwe Vinkhuijzen

2
Гей, оскільки ви додали щедрості з великою кількістю хороших відповідей, що майже вичерпували всі можливості, чи є щось більше, що вам потрібно, або відчуваєте, що щось слід уточнити? Мені додати не багато, я можу відредагувати якусь відповідь або залишити коментар, тож це загальне (не особисте) питання.
Зло

Відповіді:


17

Причина того, що петлі швидші, ніж рекурсія, є легкою.
Петля виглядає так у зборі.

mov loopcounter,i
dowork:/do work
dec loopcounter
jmp_if_not_zero dowork

Один умовний стрибок та деяка бухгалтерія для петлі лічильника.

Рекурсія (коли її немає або не може бути оптимізована компілятором) виглядає так:

start_subroutine:
pop parameter1
pop parameter2
dowork://dowork
test something
jmp_if_true done
push parameter1
push parameter2
call start_subroutine
done:ret

Це набагато складніше, і ви отримуєте щонайменше 3 стрибки (1 тест, щоб побачити, чи були зроблені, один дзвінок та один повернення).
Також у рекурсії параметри потрібно налаштовувати та вибирати.
Жоден із цих матеріалів не потрібен у циклі, оскільки всі параметри вже налаштовані.

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

Відмінності між викликом і jmp
Пара повернення дзвінків не набагато дорожча, ніж jmp. Пара займає 2 цикли, а jmp займає 1; ледве помітний.
При виклику конвенцій, які підтримують параметри реєстру, накладні витрати на параметри мінімальних, але навіть параметрів стека є дешевими , доки буфери процесора не переповнюються .
Саме накладні витрати на налаштування виклику, продиктовані умовою виклику та обробкою параметрів, що використовуються, сповільнюють рекурсію.
Це дуже залежить від впровадження.

Приклад поганої обробки рекурсії Наприклад, якщо параметр передається, який посилається на підрахунок (наприклад, параметр типу, який не керується const), він додасть 100 циклів, роблячи заблоковану корекцію опорного числа, що повністю знищує продуктивність проти циклу.
У мовах, налаштованих на рекурсію, ця погана поведінка не зустрічається.

Оптимізація процесора
Інша причина, чому рекурсія повільніша, полягає в тому, що вона працює проти механізмів оптимізації процесора.
Повернення можна передбачити правильно, лише якщо їх не так вже й багато підряд. ЦП має буфер зворотного стека з (кількома) жменями записів. Як тільки ці закінчуються, кожен додатковий прибуток буде непередбачувано спричиняти величезні затримки.
У будь-якому процесорі, що використовує рекурсію на основі буфера зворотного зв'язку стека, що перевищує розмір буфера, найкраще уникати.

Про тривіальні приклади коду з використанням рекурсії
Якщо ви використовуєте тривіальний приклад рекурсії, наприклад, генерація чисел Фібоначчі, таких ефектів не виникає, тому що будь-який компілятор, який «знає» про рекурсію, перетворить її в цикл, як і будь-який програміст, який вартує своєї солі б.
Якщо ви запускаєте ці тривіальні приклади в оточенні, яке не оптимізується належним чином, ніж стек викликів (без потреби) виросте за межі.

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

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

Вплив середовища програмування
Якщо ваша мова налаштована на оптимізацію рекурсії, то будь-якими способами слід вперед та використовувати рекурсії при кожній нагоді. У більшості випадків мова перетворить вашу рекурсію в якусь цикл.
У тих випадках, коли це не вдається, програміст також буде сильно натиснутий. Якщо ваша мова програмування не налаштована на рекурсію, її слід уникати, якщо домен не підходить для рекурсії.
На жаль, багато мов не справляються з рекурсією добре.

Неправильне використання рекурсії
Не потрібно обчислювати послідовність Фібоначчі за допомогою рекурсії, насправді це патологічний приклад.
Рекурсію найкраще використовувати на мовах, які явно підтримують її, або в областях, де рекурсія світиться, як обробка даних, що зберігаються в дереві.

Я розумію, що будь-яка рекурсія може бути записана як цикл

Так, якщо ви готові поставити візок перед конем.
Усі випадки рекурсії можуть бути записані у циклі, деякі з цих екземплярів вимагають використання явного стека, як сховище.
Якщо вам потрібно закатати власний стек просто для перетворення рекурсивного коду в цикл, ви також можете використовувати звичайну рекурсію.
Якщо, звичайно, у вас є особливі потреби, такі як використання перелічників у структурі дерева, і ви не маєте належної мовної підтримки.


16

Ці інші відповіді дещо оманливі. Я погоджуюся, що вони містять деталі щодо імплементації, які можуть пояснити цю невідповідність, але вони завищують цю справу. Як правильно запропонував jmite, вони орієнтовані на реалізацію на розбиті реалізації викликів / рекурсії функцій. Багато мов реалізують цикли за допомогою рекурсії, тому певи явно не будуть швидшими в цих мовах. Рекурсія нічим не менш ефективна, ніж циклічне (якщо обидва застосовні) теоретично. Дозвольте мені навести конспект до статті Гая Стіла 1977 року, що розкриває міф про "дорогий процедурний дзвінок" або, що впровадження процедури вважається шкідливим або, Лямбда: кінцева ГОТА

Фольклор стверджує, що заяви GOTO є "дешевими", а виклики процедур - "дорогими". Цей міф значною мірою є результатом погано розроблених мовних реалізацій. Розглянуто історичне зростання цього міфу. Обговорюються як теоретичні ідеї, так і існуюча реалізація, що розкриває цей міф. Показано, що необмежене використання процедурних викликів дозволяє велику стилістичну свободу. Зокрема, будь-яка блок-схема може бути записана як "структурована" програма без введення додаткових змінних. Складність із заявою GOTO та викликом процедури характеризується як конфлікт між абстрактними поняттями програмування та конкретними мовними конструкціями.

"Конфлікт між поняттями абстрактного програмування та конкретними мовними конструкціями" видно з того, що більшість теоретичних моделей, наприклад, нетипізованого числення лямбда , не мають стека . Звичайно, цей конфлікт не є необхідним, як показано у статті, і це також демонструється мовами, які не мають іншого механізму ітерації, крім рекурсії, наприклад, Haskell.

fixfix f x = f (fix f) x(λх.М)NМ[N/х][N/х]хМN

Тепер для прикладу. Визначте factяк

fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1

Ось оцінка fact 3, де для компактності я буду використовувати gяк синонім для fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)), тобто fact = g 1. Це не впливає на мій аргумент.

fact 3 
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3 
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6

Ви можете бачити з форми, навіть не дивлячись на деталі, що немає зростання і для кожної ітерації потрібно однакова кількість місця. (Технічно, числовий результат зростає, що неминуче і так само справедливо для whileциклу.) Я спростовую вас, щоб вказати на безмежно зростаючий "стек" тут.

Здається, архетипна семантика обчислення лямбда вже робить те, що прийнято називати «оптимізацією хвостових викликів». Звичайно, ніякої «оптимізації» тут не відбувається. Тут немає спеціальних правил для "хвостових" дзвінків на відміну від "звичайних" дзвінків. З цієї причини важко дати "абстрактну" характеристику того, що робиться "оптимізація" хвостового виклику, як у багатьох абстрактних характеристиках семантики виклику функцій, для "оптимізації" хвостового виклику немає нічого!

Те, що аналогічне визначення factбагатьох мов "переповнення стека", є невдачею цих мов правильно реалізувати семантику виклику функцій. (У деяких мовах є виправдання.) Ситуація приблизно аналогічна мовній реалізації, яка реалізує масиви із пов'язаними списками. Індексація в такі "масиви" буде операцією O (n), яка не відповідає очікуванню масивів. Якби я зробив окрему реалізацію мови, яка використовувала справжні масиви замість пов'язаних списків, ви б не сказали, що я здійснив "оптимізацію доступу до масиву", ви б сказали, що я виправив зламану реалізацію масивів.

Отже, відповідаючи на відповідь Ведрака. Стеки не є "основоположними" для рекурсії . У міру того, як поведінка типу «стека» виникає в ході оцінювання, це може статися лише у випадках, коли цикли (без допоміжної структури даних) не були застосовні в першу чергу! Інакше кажучи, я можу реалізувати петлі з рекурсією з абсолютно однаковими характеристиками. Дійсно, схема і SML містять циклічні конструкції, але обидва вони визначають ті, що стосуються рекурсії (і, принаймні, у схемі, doчасто реалізується як макрос, який переростає в рекурсивні дзвінки.) Так само, за відповідь Йохана, нічого не говорить про компілятор повинен випустити збірку, описану Йоханом для рекурсії. Дійсно,точно така ж збірка, чи використовуєте ви циклі або рекурсію. Єдиний раз, коли компілятор зобов’язаний буде випускати збірку, як те, що описує Йохан, - це коли ти робиш щось, що не може бути виразним циклом. Як викладено в статті Стіла та продемонстровано фактичною практикою таких мов, як Haskell, Scheme та SML, не "надзвичайно рідко", що хвостові дзвінки можна "оптимізувати", вони завжди можутьбути "оптимізованим". Від того, чи буде певне використання рекурсії працювати в постійному просторі, залежить від того, як це написано, але обмеження, які потрібно застосувати, щоб зробити це можливим, - це обмеження, які вам знадобляться, щоб відповідати вашій проблемі у формі циклу. (Насправді вони менш суворі. Існують проблеми, такі як машини кодування, які більш чітко та ефективно обробляються за допомогою викликів хвостів на відміну від циклів, які потребують допоміжних змінних.) Знову ж таки, єдиний час рекурсії вимагає більше роботи коли ваш код все одно не є циклом.

Думаю, Йоган має на увазі компілятори C, які мають довільні обмеження щодо того, коли він буде виконувати хвостовий виклик "оптимізація". Іохан також, мабуть, посилається на такі мови, як C ++ та Rust, коли він говорить про "мови з керованими типами". RAII ідіома від C ++ і присутній в іржі , а також роблять речі , які зовні виглядають як хвостові виклики, а НЕ хвіст викликів (бо «руйнівники» ще потрібно назвати). Були пропозиції використовувати інший синтаксис, щоб увімкнути дещо іншу семантику, яка дозволила б рекурсувати хвіст (а саме деструктори виклику ранішеостаточний виклик хвоста і очевидно забороняють доступ до "знищених" об'єктів). (Збір сміття не має такої проблеми, і всі Haskell, SML та Scheme - це зібрані сміття мови.) У зовсім іншому ключі деякі мови, такі як Smalltalk, виставляють "стек" як першокласний об'єкт у цих випадків "стек" більше не є детальною інформацією про реалізацію, хоча це не виключає наявності окремих типів викликів з різною семантикою. (Java каже, що це не може через те, що він обробляє деякі аспекти безпеки, але це насправді помилково .)

На практиці поширеність порушених реалізацій викликів функцій відбувається з трьох основних факторів. По-перше, багато мов успадковують зламану реалізацію від мови їхньої реалізації (зазвичай, C). По-друге, детерміноване управління ресурсами є приємним і робить проблему складнішою, хоча це пропонує лише декілька мов. По-третє, і, на мій досвід, більшість людей переймається тим, що вони хочуть слідів стека, коли виникають помилки з метою налагодження. Тільки друга причина - це теоретично мотивована.


Я використовував "основоположне", щоб вказати на найосновнішу причину того, що твердження є правдивим, а не на те, чи логічно це має бути таким чином (що, очевидно, не так, оскільки дві програми є ідентично однаковими). Але я не згоден з вашим коментарем в цілому. Використання лямбда-числення не видаляє стек настільки, як приховує його.
Ведрак

Ваша заява "Єдиний раз, коли компілятор буде зобов'язаний випустити збірку, як те, що описує Йохан, - це коли ти робиш щось, що не можна висловити циклом". також досить дивно; компілятор (як правило) здатний створити будь-який код, що дає однаковий вихід, тому ваш коментар є в основному тавтологією. Але на практиці компілятори створюють різний код для різних еквівалентних програм, і питання було про те, чому.
Ведрак

О(1)

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

Дуже цікава відповідь. Навіть незважаючи на те, що це звучить трохи як зухвала :-). Отримано, бо я дізнався щось нове.
Йохан - відновити Моніку

2

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

Якщо порівняти замість циклів, що працюють вручну на виділеному стеку (наприклад, через вказівник на накопичувач пам'яті), зазвичай ви знайдете їх не швидше або навіть повільніше, ніж використання апаратного стеку.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.