Я собираюсь биться вокруг куста некоторое время, но есть смысл.
Полугруппы
Ответ - ассоциативное свойство операции двоичной редукции .
Это довольно абстрактно, но умножение - хороший пример. Если x , y и z являются некоторыми натуральными числами (или целыми числами, или рациональными числами, или действительными числами, или комплексными числами, или матрицами N × N , или любой из множества других вещей), то x × y будет того же вида числа как и х и у . Мы начали с двух чисел, так что это бинарная операция, и получили одну, поэтому мы уменьшили количество чисел, которое у нас было, на единицу, сделав эту операцию сокращения. И ( x × y ) × z всегда совпадает с x × ( y ×я ), который является ассоциативным свойством.
(Если вы уже знаете все это, вы можете перейти к следующему разделу.)
Еще несколько вещей, которые вы часто видите в информатике, которые работают так же:
- добавив любой из этих видов чисел вместо умножения
- конкатенации строк (
"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 ( x )))
- максимум и минимум
- сложение по модулю р
Некоторые вещи, которые не:
- вычитание, потому что 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
. Каждая итерация функции может повторно использовать один и тот же кадр стека и обновлять параметры на месте.
Либо правая, либо строгая левая складка может быть более подходящей, в конкретном случае, поэтому знайте, какой вы хотите. Есть также некоторые вещи, которые может сделать только правый сгиб (например, генерировать интерактивный вывод, не дожидаясь всего ввода, и работать с бесконечным списком). Здесь, однако, мы сводим последовательность операций к простому значению, поэтому нам нужно строгое сгибание влево.
Таким образом, как вы можете видеть, можно автоматически оптимизировать хвостовую рекурсию по модулю любой полугруппы (одним примером которой является любой из обычных числовых типов при умножении) либо в ленивую правую складку, либо в строгую левую складку в одну строку Haskell.
Обобщая дальше
Два аргумента двоичной операции не обязательно должны быть одного типа, если только начальное значение того же типа, что и ваш результат. (Конечно, вы всегда можете перевернуть аргументы в соответствии с порядком сгибания, который вы делаете, влево или вправо.) Таким образом, вы можете многократно добавлять патчи в файл, чтобы получить обновленный файл, или начинать с начального значения 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
это ошибка. Не то чтобы это имело значение для остальной части вопроса, хотя; итерационный асм сначала проверяет этот особый случай. Но как ни странно, итеративная реализация вводит множество того,1 * x
чего не было в источнике, даже если мы делаемfloat
версию. gcc.godbolt.org/z/eqwine (и gcc только преуспевает-ffast-math
.)