私はしばらく茂みの周りを打つつもりですが、ポイントがあります。
セミグループ
答えは、バイナリリダクション演算の連想プロパティです。
それはかなり抽象的なことですが、乗算は良い例です。場合のx、yのとzは、いくつかの自然数(または整数、または有理数、または実数、または複素数、またはあるN × Nの行列、または全体の束のいずれかのより多くの物事が)、その後のx × yが同じ種類でありますxとyの両方としての数。2つの数値から始めたので、2項演算であり、1つを得たので、持っていた数値の数を1つ減らして、これを簡約演算にしました。そして(x × y)× zは常にx ×(y ×z)、これは連想プロパティです。
(これらすべてを既に知っている場合は、次のセクションにスキップできます。)
同じように機能する、コンピューターサイエンスでよく見られるもの:
- 掛けるのではなく、これらの種類の数字を追加する
- 文字列を連結することは(
"a"+"b"+"c"
ある"abc"
あなたがで始まるかどう"ab"+"c"
か"a"+"bc"
)
- 2つのリストをつなぎ合わせます。
[a]++[b]++[c]
同様に[a,b,c]
、背面から前面または前面から背面のいずれかです。
cons
頭をシングルトンリストと考える場合は、頭と尻尾で。これは、2つのリストを連結しただけです。
- 集合または集合の交差を取る
- ブールand、ブールor
- ビット単位
&
、|
および^
- 機能の組成:(F ∘ G)∘ H 、X = F ∘(G ∘ H)X = F(G(H(X)))
- 最大および最小
- pを法とする加算
しないこと:
- 1-(1-2)≠(1-1)-2であるため、減算
- X ⊕ Y =黄褐色(X + Y)、日焼けので(π/ 4 +π/ 4)が定義されていません
- -1×-1は負の数ではないため、負の数に対する乗算
- 整数の除算。これには3つの問題がすべてあります。
- 論理的ではありません。2つではなく1つのオペランドしかありません。
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
、as print2( print2(x,y), z );
およびprint2( x, print2(y,z) );
は異なる出力を持ちます。
それは私たちがそれを命名したほど有用な概念です。これらのプロパティを持つ操作を持つセットは、セミグループです。したがって、乗算下の実数は半群です。そして、あなたの質問は、この種の抽象化が現実の世界で役立つ方法の1つであることがわかりました。 セミグループ操作はすべて、あなたが尋ねている方法で最適化できます。
自宅で試してみてください
私の知る限り、この手法は1974年にDaniel FriedmanとDavid Wiseの論文「Folding Stylized Recursions into Iterations」で初めて説明されましたが、必要以上にいくつかのプロパティを想定していました。
Haskellは、Semigroup
標準ライブラリにタイプクラスがあるため、これを説明するのに最適な言語です。ジェネリックSemigroup
演算子の操作を呼び出します<>
。リストと文字列はのインスタンスであるためSemigroup
、それらのインスタンスは、たとえば<>
連結演算子として定義されます++
。正しいインポートで[a] <> [b]
は、のエイリアス[a] ++ [b]
は[a,b]
です。
しかし、数字はどうですか?数値型は、加算または乗算のいずれかのセミグループであることがわかりました!それで<>
、Double
どっちがいいの?まあ、どちらか!Haskellはタイプを定義しProduct Double
、where (<>) = (*)
また(すなわち、ハスケルの実際の定義である)、そしてSum Double
、where (<>) = (+)
。
しわの1つは、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
そのケースの1つに使用されましたが、それが必要なければ、より一般的なtypeclassでそれを行うことができましたSemigroup
。
このプログラムをGHCIにロードして、どのように機能するかを見てみましょう。
*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49
どの型を呼び出しpow
たジェネリックを宣言したか覚えていますか?私たちは、タイプすることを推測するGHCiの十分な情報を与え、ここである、とその操作は整数乗算です。したがって、に再帰的に展開します。これはまたはです。ここまでは順調ですね。Monoid
a
a
Product Integer
instance
Monoid
<>
pow 2 4
2<>2<>2<>2
2*2*2*2
16
しかし、この関数は一般的なモノイド操作のみを使用します。以前、私は、操作がであるMonoid
calledの別のインスタンスがあると言いました。それを試してもいいですか?Sum
<>
+
*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14
同じ拡張により、2+2+2+2
ではなくが得られ2*2*2*2
ます。乗算は乗算であり、べき乗は乗算です!
しかし、Haskellモノイドのもう1つの例を挙げました。その操作は連結です。
*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]
書き込みは[2]
、これはリストであることをコンパイラに伝え<>
ているリストに++
、そう[2]++[2]++[2]++[2]
です[2,2,2,2]
。
最後に、アルゴリズム(2つ、事実)
単に置き換えることによってx
で[x]
、あなたはリストを作成し、1に、一般的なアルゴリズムその用途再帰剰余A半群を変換します。どのリスト? アルゴリズムが適用される要素のリスト<>
。 リストにも含まれるセミグループ演算のみを使用したため、結果のリストは元の計算と同型になります。また、元の操作は関連性があるため、要素を後ろから前へ、または前から後ろへ等しく等しく評価できます。
アルゴリズムがベースケースに達して終了すると、リストは空になりません。ターミナルケースが何かを返したので、それがリストの最後の要素になるため、少なくとも1つの要素があります。
リストのすべての要素にバイナリリダクション演算をどのように順番に適用しますか?そうです、折り目です。したがって、の代わり[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
for Semigroup
およびmconcat
forのように、実際には標準ライブラリに存在しMonoid
ます。リスト上で怠laな右折りを行います。つまり、に展開さ[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行で自動的に最適化することができますハスケル。
さらに一般化する
初期値が結果と同じ型である限り、2項演算の2つの引数は同じ型である必要はありません。(もちろん、あなたが行っているフォールドの種類の順序に合わせて引数をいつでも左右に反転させることができます。)更新されたファイルを取得するためにファイルにパッチを繰り返し追加したり、 1.0、整数で除算して浮動小数点の結果を累積します。または、空のリストに要素を追加してリストを取得します。
別のタイプの一般化は、リストではなく他のFoldable
データ構造にフォールドを適用することです。多くの場合、不変の線形リンクリストは、特定のアルゴリズムに必要なデータ構造ではありません。上記に入らなかった問題の1つは、要素をリストの前に追加する方が後よりもはるかに効率的であり、操作が可換でない場合、操作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は.sでのみ成功し-ffast-math
ます)