Aku akan bertele-tele sebentar, tapi ada benarnya.
Semigroup
Jawabannya adalah, properti asosiatif dari operasi reduksi biner .
Itu cukup abstrak, tetapi penggandaan adalah contoh yang bagus. Jika x , y dan z adalah beberapa nomor alami (atau bilangan bulat, atau bilangan rasional, atau bilangan real, atau bilangan kompleks, atau N × N matriks, atau salah satu dari sejumlah hal yang lebih), maka x × y adalah jenis yang sama nomor sebagai x dan y . Kami mulai dengan dua angka, jadi ini operasi biner, dan mendapat satu, jadi kami mengurangi jumlah angka yang kami miliki dengan satu, menjadikan ini operasi pengurangan. Dan ( x × y ) × z selalu sama dengan x × ( y ×z ), yang merupakan properti asosiatif.
(Jika Anda sudah mengetahui semua ini, Anda dapat langsung ke bagian selanjutnya.)
Beberapa hal lagi yang sering Anda lihat dalam ilmu komputer yang bekerja dengan cara yang sama:
- menambahkan salah satu dari angka-angka itu alih-alih mengalikan
- string bersambung (
"a"+"b"+"c"
adalah "abc"
apakah Anda mulai dengan "ab"+"c"
atau "a"+"bc"
)
- Menyambung dua daftar bersama.
[a]++[b]++[c]
sama [a,b,c]
dari belakang ke depan atau depan ke belakang.
cons
pada kepala dan ekor, jika Anda menganggap kepala sebagai daftar tunggal. Itu hanya menyatukan dua daftar.
- mengambil persatuan atau persimpangan set
- Boolean dan, Boolean atau
- bitwise
&
, |
dan^
- komposisi fungsi: ( f ∘ g ) ∘ h x = f ∘ ( g ∘ h ) x = f ( g ( h ( x ))))
- maksimum dan minimum
- Selain modulo p
Beberapa hal yang tidak:
- pengurangan, karena 1- (1-2) ≠ (1-1) -2
- x ⊕ y = tan ( x + y ), karena tan (π / 4 + π / 4) tidak terdefinisi
- perkalian atas angka negatif, karena -1 × -1 bukan angka negatif
- pembagian bilangan bulat, yang memiliki ketiga masalah!
- logis tidak, karena hanya memiliki satu operan, bukan dua
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, sebagai print2( print2(x,y), z );
dan print2( x, print2(y,z) );
memiliki output yang berbeda.
Itu adalah konsep yang cukup berguna yang kami beri nama. Satu set dengan operasi yang memiliki properti ini adalah semigroup . Jadi, bilangan real di bawah perkalian adalah semigroup. Dan pertanyaan Anda ternyata menjadi salah satu cara abstraksi semacam ini menjadi berguna di dunia nyata. Operasi semi-grup semua dapat dioptimalkan seperti yang Anda tanyakan.
Coba Ini Di Rumah
Sejauh yang saya tahu, teknik ini pertama kali dijelaskan pada tahun 1974, dalam makalah Daniel Friedman dan David Wise, "Lipat Rekur Bergaya menjadi Iterasi" , meskipun mereka mengasumsikan beberapa properti lebih daripada yang mereka butuhkan.
Haskell adalah bahasa yang bagus untuk menggambarkan hal ini, karena memiliki Semigroup
typeclass di perpustakaan standarnya. Itu panggilan operasi generik Semigroup
operator <>
. Karena daftar dan string adalah instance dari Semigroup
, instance mereka mendefinisikan <>
sebagai operator gabungan ++
, misalnya. Dan dengan impor yang tepat, [a] <> [b]
adalah alias untuk [a] ++ [b]
, yaitu [a,b]
.
Tapi, bagaimana dengan angka? Kami hanya melihat bahwa jenis numerik yang semigroups bawah baik Selain atau perkalian! Jadi mana yang akan menjadi <>
untuk Double
? Yah, salah satunya! Haskell mendefinisikan jenis Product Double
, where (<>) = (*)
(yaitu definisi yang sebenarnya di Haskell), dan juga Sum Double
, where (<>) = (+)
.
Satu kerutan adalah Anda menggunakan fakta bahwa 1 adalah identitas multiplikatif. Semigroup dengan identitas disebut monoid dan didefinisikan dalam paket Haskell Data.Monoid
, yang memanggil elemen identitas umum dari sebuah typeclass mempty
. Sum
, Product
dan daftar masing-masing memiliki elemen identitas (0, 1 dan []
, masing-masing), sehingga mereka adalah contoh Monoid
dan juga Semigroup
. (Jangan bingung dengan monad , jadi lupakan saja aku yang membawanya.)
Itu informasi yang cukup untuk menerjemahkan algoritme Anda menjadi fungsi Haskell menggunakan monoids:
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.
Yang penting, perhatikan bahwa ini adalah semigroup modulo rekursi ekor: setiap case dapat berupa nilai, panggilan rekursif ekor, atau produk semigroup dari keduanya. Juga, contoh ini kebetulan digunakan mempty
untuk salah satu kasus, tetapi jika kami tidak membutuhkannya, kami bisa melakukannya dengan typeclass yang lebih umum Semigroup
.
Mari kita muat program ini dalam GHCI dan lihat cara kerjanya:
*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49
Ingat bagaimana kami menyatakan pow
untuk obat generik Monoid
, yang jenisnya kami panggil a
? Kami memberikan GHCI informasi yang cukup untuk menyimpulkan bahwa jenis a
di sini adalah Product Integer
, yang merupakan instance
dari Monoid
yang <>
operasi perkalian bilangan bulat. Jadi pow 2 4
mengembang secara rekursif ke 2<>2<>2<>2
, yang 2*2*2*2
atau 16
. Sejauh ini baik.
Tetapi fungsi kami hanya menggunakan operasi monoid generik. Sebelumnya, saya mengatakan bahwa ada contoh lain yang Monoid
dipanggil Sum
, yang <>
operasinya +
. Bisakah kita mencobanya?
*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14
Ekspansi yang sama sekarang memberi kita 2+2+2+2
alih-alih 2*2*2*2
. Perkalian adalah untuk menambah karena eksponensial adalah untuk perkalian!
Tetapi saya memberikan satu contoh lain dari monoid Haskell: daftar, yang operasinya adalah gabungan.
*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]
Menulis [2]
memberitahu kompiler bahwa ini adalah daftar, <>
pada daftar adalah ++
, begitu [2]++[2]++[2]++[2]
juga [2,2,2,2]
.
Akhirnya, sebuah Algoritma (Dua, Faktanya)
Dengan hanya menggantinya x
dengan [x]
, Anda mengonversi algoritma umum yang menggunakan modul rekursi semigroup menjadi semigroup yang membuat daftar. Daftar yang mana? Daftar elemen yang diterapkan algoritma <>
. Karena kami hanya menggunakan operasi semi-grup yang memiliki daftar juga, daftar yang dihasilkan akan isomorfik dengan perhitungan aslinya. Dan karena operasi asli adalah asosiatif, kita dapat mengevaluasi elemen dari belakang ke depan atau dari depan ke belakang.
Jika algoritme Anda pernah mencapai casing dasar dan berakhir, daftar tersebut akan kosong. Karena kasing terminal mengembalikan sesuatu, itu akan menjadi elemen terakhir dari daftar, sehingga ia akan memiliki setidaknya satu elemen.
Bagaimana Anda menerapkan operasi reduksi biner ke setiap elemen daftar secara berurutan? Itu benar, lipatan. Jadi Anda dapat mengganti [x]
untuk x
, mendapatkan daftar elemen untuk mengurangi oleh <>
, dan kemudian kanan kali lipat atau kiri-lipat daftar:
*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16
Versi dengan foldr1
sebenarnya ada di pustaka standar, seperti sconcat
untuk Semigroup
dan mconcat
untuk Monoid
. Itu melipat kanan malas pada daftar. Artinya, itu diperluas [Product 2,Product 2,Product 2,Product 2]
ke 2<>(2<>(2<>(2)))
.
Ini tidak efisien dalam hal ini karena Anda tidak dapat melakukan apa pun dengan persyaratan individual hingga Anda menghasilkan semuanya. (Pada satu titik saya memiliki diskusi di sini tentang kapan harus menggunakan lipatan kanan dan kapan harus menggunakan lipatan kiri yang ketat, tetapi terlalu jauh.)
Versi dengan foldl1'
adalah lipatan kiri yang dievaluasi secara ketat. Artinya, fungsi ekor-rekursif dengan akumulator yang ketat. Ini mengevaluasi (((2)<>2)<>2)<>2
, dihitung segera dan tidak lebih lambat saat dibutuhkan. (Setidaknya, tidak ada penundaan dalam lipatan itu sendiri: daftar yang dilipat dihasilkan di sini oleh fungsi lain yang mungkin berisi evaluasi malas.) Jadi, lipatan menghitung (4<>2)<>2
, lalu segera menghitung 8<>2
, lalu 16
. Inilah sebabnya kami membutuhkan operasi untuk menjadi asosiatif: kami baru saja mengubah pengelompokan tanda kurung!
Lipatan kiri yang ketat sama dengan apa yang dilakukan GCC. Angka paling kiri pada contoh sebelumnya adalah akumulator, dalam hal ini produk yang sedang berjalan. Pada setiap langkah, itu dikalikan dengan angka berikutnya dalam daftar. Cara lain untuk mengekspresikannya adalah: Anda mengulangi nilai yang akan dikalikan, menjaga produk yang berjalan dalam akumulator, dan pada setiap iterasi, Anda mengalikan akumulator dengan nilai berikutnya. Artinya, itu adalah while
lingkaran yang menyamar.
Kadang-kadang dapat dibuat sama efisiennya. Kompiler mungkin dapat mengoptimalkan struktur data daftar dalam memori. Secara teori, ia memiliki informasi yang cukup pada waktu kompilasi untuk mencari tahu itu harus dilakukan di sini: [x]
adalah singleton, jadi [x]<>xs
sama dengan cons x xs
. Setiap iterasi fungsi mungkin dapat menggunakan kembali bingkai tumpukan yang sama dan memperbarui parameter yang ada.
Entah lipatan kanan atau lipatan kiri ketat bisa lebih tepat, dalam kasus tertentu, jadi ketahuilah mana yang Anda inginkan. Ada juga beberapa hal yang hanya dapat dilakukan oleh flip kanan (seperti menghasilkan output interaktif tanpa menunggu semua input, dan beroperasi pada daftar yang tak terbatas). Namun, di sini, kami mengurangi urutan operasi ke nilai sederhana, jadi lipatan kiri yang ketat adalah yang kami inginkan.
Jadi, seperti yang Anda lihat, dimungkinkan untuk secara otomatis mengoptimalkan modul rekursi ekor setiap semigroup (salah satu contohnya adalah salah satu dari jenis numerik yang biasa di bawah penggandaan) baik ke lipatan kanan malas atau lipatan kiri ketat, dalam satu baris Haskell.
Generalisasi Lebih Lanjut
Dua argumen operasi biner tidak harus tipe yang sama, asalkan nilai awal adalah tipe yang sama dengan hasil Anda. (Tentu saja Anda selalu dapat membalik argumen agar sesuai dengan urutan jenis lipatan yang Anda lakukan, kiri atau kanan.) Jadi, Anda mungkin berulang kali menambahkan tambalan ke file untuk mendapatkan file yang diperbarui, atau mulai dengan nilai awal dari 1.0, bagi dengan integer untuk mengakumulasikan hasil floating-point. Atau tambahkan elemen ke daftar kosong untuk mendapatkan daftar.
Jenis generalisasi lain adalah menerapkan lipatan bukan untuk daftar tetapi untuk Foldable
struktur data lainnya . Seringkali, daftar tertaut linear tidak berubah bukan struktur data yang Anda inginkan untuk algoritma yang diberikan. Satu masalah yang tidak saya bahas di atas adalah jauh lebih efisien untuk menambahkan elemen ke depan daftar daripada ke belakang, dan ketika operasi tidak komutatif, menerapkan x
di sebelah kiri dan kanan operasi tidak sama. Jadi Anda perlu menggunakan struktur lain, seperti sepasang daftar atau pohon biner, untuk mewakili suatu algoritma yang dapat diterapkan x
di sebelah kanan <>
maupun ke kiri.
Perhatikan juga bahwa properti asosiatif memungkinkan Anda untuk mengelompokkan kembali operasi dengan cara lain yang bermanfaat, seperti membagi-dan-taklukkan:
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)
Atau paralelisme otomatis, di mana setiap utas mengurangi subrange ke nilai yang kemudian dikombinasikan dengan yang lain.
if(n==0) return 0;
(tidak mengembalikan 1 seperti dalam pertanyaan Anda).x^0 = 1
, jadi itu bug. Namun, itu tidak penting untuk sisa pertanyaan; aserative itm memeriksa untuk kasus khusus terlebih dahulu. Tapi anehnya, implementasi berulang memperkenalkan banyak1 * x
yang tidak ada di sumbernya, bahkan jika kita membuatfloat
versi. gcc.godbolt.org/z/eqwine (dan gcc hanya berhasil dengan-ffast-math
.)