Звідки ви знаєте, коли використовувати складну ліву та коли використовувати складну праву?


99

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

Отже, що я хочу знати:

  • Назвіть кілька основних правил для визначення того, чи потрібно складати ліворуч чи згинати праворуч?
  • Як я можу швидко визначити, який тип складки використовувати, враховуючи проблему, з якою я стикаюся?

У Scala за прикладом (PDF) є приклад використання складання для написання функції під назвою flatten, яка об'єднує список списків елементів у єдиний список. У цьому випадку правильний вибір - правильний вибір (враховуючи спосіб з’єднання списків), але мені довелося трохи подумати над цим, щоб дійти цього висновку.

Оскільки складання - це така поширена дія у (функціональному) програмуванні, я хотів би мати можливість приймати подібні рішення швидко та впевнено. Отже ... якісь поради?



1
Це питання більш загальне, ніж те, що стосувалося Хаскелла. Лінь робить велику різницю у відповіді на запитання.
Кріс Конвей,

Ой. Дивно, я якось подумав, що побачив тег Haskell в цьому питанні, але, мабуть, ні ...
ephemient

Відповіді:


105

Ви можете перенести складку в нотацію оператора інфіксації (між ними):

Цей приклад скласти за допомогою функції акумулятора x

fold x [A, B, C, D]

таким чином дорівнює

A x B x C x D

Тепер вам просто доведеться міркувати про асоціативність вашого оператора (вводячи дужки!).

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

((A x B) x C) x D

Тут ви використовуєте ліву складку . Приклад (псевдокод у стилі haskell)

foldl (-) [1, 2, 3] == (1 - 2) - 3 == 1 - 2 - 3 // - is left-associative

Якщо ваш оператор має право-асоціативний характер ( правий склад ), круглі дужки будуть встановлені так:

A x (B x (C x D))

Приклад: Мінуси-Оператор

foldr (:) [] [1, 2, 3] == 1 : (2 : (3 : [])) == 1 : 2 : 3 : [] == [1, 2, 3]

Взагалі арифметичні оператори (більшість операторів) лівоасоціативні, тому foldlє більш поширеними. Але в інших випадках нотація інфіксу + дужки є досить корисною.


6
Ну, те, що ви описали, є насправді foldl1і foldr1в Haskell ( foldlі foldrприймайте зовнішнє початкове значення), а "мінуси" Haskell називається (:)не (::), але в іншому випадку це правильно. Ви можете додати, що Haskell додатково надає foldl'/ foldl1'які є суворими варіантами foldl/ foldl1, оскільки лінива арифметика не завжди бажана.
ефеміент

Вибачте, я подумав, що побачив тег "Haskell" у цьому питанні, але його там немає. Мій коментар насправді не має такого великого сенсу, якщо це не Haskell ...
ephemient

@ephemient Ви це бачили. Це "псевдокод у стилі haskell". :)
laughing_man

Найкраща відповідь, яку я коли-небудь бачив, пов'язана з різницею між складками.
AleXoundOS

60

Олін Шиверс диференціював їх, сказавши "foldl - це основний ітератор списку", а "foldr - оператор рекурсії основного списку". Якщо ви подивитеся, як працює foldl:

((1 + 2) + 3) + 4

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

1 + (2 + (3 + 4))

де ви можете побачити обхід базового випадку 4 та збільшити результат звідти.

Тому я маю на увазі правило: якщо це виглядає як ітерація списку, те, що було б просто написати в хвостово-рекурсивній формі, foldl - це шлях.

Але насправді це буде, мабуть, найбільш очевидним із асоціативності операторів, якими ви користуєтесь. Якщо вони ліво-асоціативні, використовуйте foldl. Якщо вони мають правильний асоціативний характер, використовуйте foldr.


29

Інші афіші дали хороші відповіді, і я не повторюю те, що вони вже говорили. Оскільки у вашому запитанні ви наводили приклад Scala, я наведу конкретний приклад Scala. Як вже було сказано в трюках , foldRightпотрібно зберегти n-1кадри стека, де nдовжина вашого списку, і це може легко призвести до переповнення стека - і навіть не рецидивування хвоста може врятувати вас від цього.

A List(1,2,3).foldRight(0)(_ + _)зменшиться до:

1 + List(2,3).foldRight(0)(_ + _)        // first stack frame
    2 + List(3).foldRight(0)(_ + _)      // second stack frame
        3 + 0                            // third stack frame 
// (I don't remember if the JVM allocates space 
// on the stack for the third frame as well)

при цьому List(1,2,3).foldLeft(0)(_ + _)зменшиться до:

(((0 + 1) + 2) + 3)

що можна ітераційно обчислити, як це робиться при здійсненніList .

У строго оціненій мові як Scala, a foldRightможе легко підірвати стек для великих списків, тоді як foldLeftне буде.

Приклад:

scala> List.range(1, 10000).foldLeft(0)(_ + _)
res1: Int = 49995000

scala> List.range(1, 10000).foldRight(0)(_ + _)
java.lang.StackOverflowError
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRig...

Моє правило - для операторів, які не мають певної асоціативності, завжди використовуйте foldLeft, принаймні, у Scala. В іншому випадку перейдіть з іншими порадами, наведеними у відповідях;).


13
Це було правдою раніше, але в сучасних версіях Scala, foldRight змінено, щоб застосувати foldLeft до зворотної копії списку. Наприклад, у 2.10.3, github.com/scala/scala/blob/v2.10.3/src/library/scala/… . Здається, що ця зміна була здійснена на початку 2013 року - github.com/scala/scala/commit/… .
Dhruv Kapoor

4

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

foldl: (((1 + 2) + 3) + 4)може обчислити кожну операцію і перенести накопичене значення вперед

foldr: (1 + (2 + (3 + 4)))потрібен кадр стека, який слід відкрити для 1 + ?і 2 + ?перед розрахунком 3 + 4, тоді він повинен повернутися назад і зробити розрахунок для кожного.

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


1
Додаткові кадри стека, безумовно, змінять великі списки. Якщо ваші кадри стека перевищують розмір кешу процесора, то ваші пропуски кешу вплинуть на продуктивність. Якщо список не є подвійним зв'язком, складно зробити складку хвостово-рекурсивною функцією, тому слід використовувати foldl, якщо немає причин цього не робити.
А. Леві

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

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