Я буду деякий час бити навколо куща, але є сенс.
Напівгрупи
Відповідь - асоціативна властивість операції бінарного скорочення .
Це досить абстрактно, але множення - хороший приклад. Якщо x , y і z - це натуральні числа (або цілі числа, або раціональні числа, або дійсні числа, або складні числа, або матриці N × N , або будь-яка ціла купа більше речей), то x × y - це той самий вид числа як x, так і y . Ми почали з двох чисел, тож це двійкова операція, і отримали одну, тому ми зменшили кількість чисел, які ми мали на одиницю, зробивши це операцією скорочення. І ( x × y ) × z завжди те саме, що x × ( y ×) z), що є асоціативною властивістю.
(Якщо ви вже все це знаєте, ви можете перейти до наступного розділу.)
Ще кілька речей, які ви часто бачите в інформатиці, які працюють однаково:
- додавання будь-якого з цих чисел, а не множення
- конкатенації рядків (
"a"+"b"+"c"
це "abc"
почати чи з "ab"+"c"
або"a"+"bc"
)
- Склеювання двох списків разом.
[a]++[b]++[c]
аналогічно [a,b,c]
або ззаду спереду, або спереду назад.
cons
на голову та хвіст, якщо ви думаєте про голову як про однотонний список. Це просто об'єднання двох списків.
- прийняття союзу або перетину множин
- Булева і, булева або
- розрядно
&
, |
і^
- склад функцій: ( f ∘ g ) ∘ h x = f ∘ ( g ∘ h ) x = f ( g ( h ( h ( x )))
- максимум і мінімум
- додавання модуля p
Деякі речі, які не:
- віднімання, тому що 1- (1-2) ≠ (1-1) -2
- x ⊕ y = tan ( x + y ), тому що tan (π / 4 + π / 4) не визначений
- множення на від’ємні числа, тому що -1 × -1 не є від’ємним числом
- ділення цілих чисел, яке має всі три проблеми!
- логічно ні, оскільки він має лише один операнд, а не два
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, як print2( print2(x,y), z );
і print2( x, print2(y,z) );
мають різний вихід.
Це досить корисна концепція, що ми її назвали. Набір з операцією, яка має ці властивості, є напівгрупою . Отже, реальні числа при множенні є напівгрупою. І ваше запитання виявляється одним із способів такого роду абстрагування стати корисним у реальному світі. Операції напівгрупи можна оптимізувати так, як ви просили.
Спробуйте це вдома
Наскільки мені відомо, ця методика була вперше описана в 1974 році в роботі Даніеля Фрідмана та Девіда Уайза «Складання стилізованих рекурсій в ітерації». , хоча вони припускали ще кілька властивостей, ніж виявляється, що їм потрібно.
Haskell є чудовою мовою для того, щоб проілюструвати це, оскільки він має Semigroup
клас класів у своїй стандартній бібліотеці. Він називає роботу загального Semigroup
оператора <>
. Оскільки списки та рядки є екземплярами, наприклад Semigroup
, їх екземпляри визначають <>
як оператор конкатенації ++
, наприклад. І при правильному імпорті [a] <> [b]
є псевдонімом [a] ++ [b]
, який є[a,b]
.
Але, як щодо цифр? Ми тільки що бачили , що числові типи є напівгрупами під або того чи множення! Так хто добирається , щоб бути <>
для Double
? Ну, або один! Haskell визначає типи Product Double
, where (<>) = (*)
(тобто фактичне визначення в Haskell), а також Sum Double
,where (<>) = (+)
.
Одна зморшка полягає в тому, що ви використали той факт, що 1 - це мультиплікативна ідентичність. Напівгрупа з ідентичністю називається моноїдом і визначається в пакеті Haskell Data.Monoid
, який називає загальний елемент ідентичності типу класу mempty
. Sum
, Product
І кожен з них має список одиничний елемент (0, 1 і []
, відповідно), так що вони є екземплярами Monoid
, а також Semigroup
. (Не плутати з монадою , тому просто забудь, я навіть виховував їх.)
Цього достатньо інформації, щоб перевести алгоритм у функцію Haskell за допомогою моноїдів:
module StylizedRec (pow) where
import Data.Monoid as DM
pow :: Monoid a => a -> Word -> a
{- Applies the monoidal operation of the type of x, whatever that is, by
- itself n times. This is already in Haskell as Data.Monoid.mtimes, but
- let’s write it out as an example.
-}
pow _ 0 = mempty -- Special case: Return the nullary product.
pow x 1 = x -- The base case.
pow x n = x <> (pow x (n-1)) -- The recursive case.
Важливо відзначити, що це модульна напівгрупа рекурсії хвоста: кожен випадок є або значенням, хвостово-рекурсивним викликом, або продуктом напівгрупи обох. Також цей приклад трапився mempty
для одного із випадків, але якби це нам не було потрібно, ми могли б зробити це з більш загальним класом Semigroup
.
Давайте завантажимо цю програму в GHCI і подивимося, як вона працює:
*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49
Пам’ятаєте, як ми оголосили pow
для родового Monoid
, чий тип ми назвали a
? Ми дали GHCI достатньо інформації , щоб зробити висновок , що тип a
тут Product Integer
, який є instance
з Monoid
якого <>
операція є цілочисельне множення. Так pow 2 4
розширюється рекурсивно до того 2<>2<>2<>2
, що є 2*2*2*2
або 16
. Все йде нормально.
Але наша функція використовує лише загальні моноїдні операції. Раніше я говорив, що є ще один екземплярMonoid
виклику Sum
, чия <>
операція є +
. Чи можемо ми спробувати це?
*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14
Те саме розширення дає нам 2+2+2+2
замість цього2*2*2*2
. Множення - це додавання, оскільки експоненція - це множення!
Але я навев ще один приклад моноїда Хаскелла: списки, операція яких є конкатенацією.
*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]
Написання [2]
повідомляє компілятору, що це список, <>
у списках є++
, так [2]++[2]++[2]++[2]
це[2,2,2,2]
.
Нарешті, алгоритм (два, фактично)
Просто замінивши x
на [x]
, ви перетворюєте загальний алгоритм, який використовує модуль рекурсії, напівгрупу в такий, який створює список. Який список? Перелік елементів, які застосовує алгоритм<>
. Оскільки ми використовували лише операції напівгрупи, у яких також є списки, отриманий список буде ізоморфним для початкових обчислень. А оскільки оригінальна операція була асоціативною, ми можемо однаково добре оцінити елементи спереду або спереду назад.
Якщо ваш алгоритм коли-небудь досягне базового випадку і припиняється, список буде порожнім. Оскільки термінальний випадок повернув щось, це буде завершальним елементом списку, тому він матиме принаймні один елемент.
Як ви застосовуєте порядок бінарного скорочення до кожного елемента списку в порядку? Правильно, складка. Таким чином , ви можете замінити [x]
на x
, отримати список елементів , щоб скоротити <>
, а потім або правої згин або лівий складіть список:
*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16
Версія з foldr1
фактично існує в стандартній бібліотеці, так як sconcat
для Semigroup
і mconcat
для Monoid
. Це ледачий правий склад у списку. Тобто вона розширюється [Product 2,Product 2,Product 2,Product 2]
до 2<>(2<>(2<>(2)))
.
Це не ефективно в цьому випадку, оскільки ви не можете нічого зробити з окремими умовами, поки не згенеруєте їх усі. (Якось у мене тут обговорювалося питання про те, коли використовувати праві складки та коли використовувати строгі ліві складки, але це зайшло занадто далеко.)
Версія з foldl1'
- це суворо оцінена ліва складка. Тобто, хвостово-рекурсивна функція із суворим акумулятором. Це оцінюється до (((2)<>2)<>2)<>2
, обчислюється негайно, а не пізніше, коли це потрібно. (Принаймні, у самій складці немає затримок. Складений список генерується тут іншою функцією, яка може містити ледачу оцінку.) Отже, складка обчислює (4<>2)<>2
, потім відразу обчислює 8<>2
, потім16
. Ось чому нам потрібна була операція, щоб вона була асоціативною: ми просто змінили групування дужок!
Сувора ліва складка є еквівалентом того, що робить GCC. Найменше ліве число в попередньому прикладі - це акумулятор, в даному випадку працює продукт. На кожному кроці його множать на наступне число у списку. Ще один спосіб виразити це: ви повторюєте значення, які потрібно помножити, зберігаючи працюючий продукт у акумуляторі, і на кожній ітерації ви помножуєте акумулятор на наступне значення. Тобто це while
петля в маскуванні.
Іноді це можна зробити так само ефективно. Компілятор, можливо, зможе оптимізувати структуру даних списку в пам'яті. Теоретично, вона має достатньо інформації під час компіляції, щоб зрозуміти, що вона повинна робити це тут: [x]
синглтон, так [x]<>xs
само як cons x xs
. Кожна ітерація функції може мати можливість повторно використовувати один і той же кадр стека та оновити параметри на місці.
Права складка або сувора ліва складка можуть бути доречнішими в конкретному випадку, тому знайте, який саме ви хочете. Також є деякі речі, які може виконати лише правий склад (наприклад, генерувати інтерактивний висновок, не чекаючи всіх вхідних даних, та працювати в нескінченному списку). Тут ми зводимо послідовність операцій до простого значення, тому сувора ліва складка - те, що ми хочемо.
Отже, як ви бачите, можна автоматично оптимізувати модуль рекурсії хвоста будь-якої напівгрупи (одним із прикладів є будь-який із звичайних числових типів при множенні) до ледачої правої складки або суворої лівої складки в одному рядку Хаскелл.
Узагальнення далі
Два аргументи двійкової операції не повинні бути однотипними, доки початкове значення є тим самим типом, що і ваш результат. (Звичайно, ви завжди можете гортати аргументи, щоб відповідати порядку вибору складки, зліва чи справа.) Отже, ви можете неодноразово додавати патчі до файлу, щоб отримати оновлений файл, або починаючи з початкового значення 1,0, розділити на цілі числа, щоб накопичити результат з плаваючою комою. Або додайте елементи до порожнього списку, щоб отримати список.
Іншим типом узагальнення є застосування складок не до списків, а до інших Foldable
структур даних. Часто незмінний лінійний зв'язаний список не є структурою даних, яку потрібно для заданого алгоритму. Одне питання, про яке я не звертався вище, - це те, що набагато ефективніше додавати елементи в передню частину списку, ніж в задню, і коли операція не є комутативною, застосовуючи x
ліворуч і праворуч операції, це не той самий. Отже, вам потрібно буде використовувати іншу структуру, таку як пара списків або двійкове дерево, щоб представити алгоритм, який може застосовуватися x
праворуч <>
і ліворуч.
Також зауважте, що асоціативна властивість дозволяє перегрупувати операції іншими корисними способами, такими як поділ і перемога:
times :: Monoid a => a -> Word -> a
times _ 0 = mempty
times x 1 = x
times x n | even n = y <> y
| otherwise = x <> y <> y
where y = times x (n `quot` 2)
Або автоматичний паралелізм, коли кожен потік зменшує піддіапазон до значення, яке потім поєднується з іншими.
if(n==0) return 0;
(не повертайте 1, як у вашому запитанні).x^0 = 1
, то це помилка. Не те, що для решти питання це важливо; ітеративний asm спочатку перевіряє цей особливий випадок. Але як не дивно, ітеративна реалізація вводить множину того,1 * x
що не було в джерелі, навіть якщо ми робимоfloat
версію. gcc.godbolt.org/z/eqwine (і gcc досягає успіху лише-ffast-math
)