Tôi sẽ đi xung quanh bụi cây một lúc, nhưng có một điểm.
Nhóm bán kết
Câu trả lời là, thuộc tính kết hợp của hoạt động giảm nhị phân .
Đó là khá trừu tượng, nhưng phép nhân là một ví dụ tốt. Nếu x , y và z là một số số tự nhiên (hoặc số nguyên, hoặc số hữu tỉ, hoặc số thực, hoặc số phức, hoặc N × N ma trận, hoặc bất kỳ của toàn bộ một loạt những thứ khác), sau đó x × y là cùng loại của số là cả x và y . Chúng tôi đã bắt đầu với hai số, vì vậy, đó là một hoạt động nhị phân và có một số, vì vậy chúng tôi đã giảm số lượng chúng tôi có một, làm cho điều này trở thành một hoạt động giảm. Và ( x × y ) × z luôn giống với x × ( y ×z ), đó là tài sản liên kết.
(Nếu bạn đã biết tất cả những điều này, bạn có thể bỏ qua phần tiếp theo.)
Một vài điều nữa bạn thường thấy trong khoa học máy tính hoạt động theo cùng một cách:
- thêm bất kỳ loại số nào thay vì nhân
- chuỗi nối (
"a"+"b"+"c"
là "abc"
cho dù bạn bắt đầu bằng "ab"+"c"
hoặc "a"+"bc"
)
- Nối hai danh sách với nhau.
[a]++[b]++[c]
tương tự [a,b,c]
hoặc từ sau ra trước hoặc trước ra sau.
cons
trên đầu và đuôi, nếu bạn nghĩ về đầu như một danh sách đơn. Đó chỉ là kết hợp hai danh sách.
- lấy liên minh hoặc giao điểm của bộ
- Boolean và, Boolean hoặc
- bitwise
&
, |
và^
- Thành phần của các hàm: ( f ∘ g ) ∘ h x = f ( g ∘ h ) x = f ( g ( h ( x )))
- tối đa và tối thiểu
- bổ sung modulo p
Một số điều không nên:
- phép trừ, vì 1- (1-2) (1-1) -2
- x ⊕ y = tan ( x + y ), vì tan (π / 4 + π / 4) không xác định
- phép nhân trên các số âm, vì -1 × -1 không phải là số âm
- phân chia số nguyên, có tất cả ba vấn đề!
- logic không, bởi vì nó chỉ có một toán hạng, không phải hai
int print2(int x, int y) { return printf( "%d %d\n", x, y ); }
, như print2( print2(x,y), z );
và print2( x, print2(y,z) );
có đầu ra khác nhau.
Đó là một khái niệm đủ hữu ích mà chúng tôi đặt tên cho nó. Một tập hợp với một hoạt động có các thuộc tính này là một nửa nhóm . Vì vậy, các số thực dưới cấp số nhân là một nửa nhóm. Và câu hỏi của bạn hóa ra là một trong những cách loại trừu tượng này trở nên hữu ích trong thế giới thực. Tất cả các hoạt động của Semigroup đều có thể được tối ưu hóa theo cách bạn yêu cầu.
Thử cái này ở nhà
Theo như tôi biết, kỹ thuật này được mô tả lần đầu tiên vào năm 1974, trong bài viết của Daniel Friedman và David Wise, Hồi phục được cách điệu hóa thành Iterations , mặc dù họ cho rằng một số thuộc tính nhiều hơn mức cần thiết.
Haskell là một ngôn ngữ tuyệt vời để minh họa điều này, bởi vì nó có kiểu chữ Semigroup
trong thư viện tiêu chuẩn của nó. Nó gọi hoạt động của một Semigroup
toán tử chung <>
. Vì các danh sách và chuỗi là các thể hiện của Semigroup
, nên các thể hiện của chúng được định nghĩa <>
là toán tử nối ++
, chẳng hạn. Và với nhập khẩu đúng, [a] <> [b]
là một bí danh cho [a] ++ [b]
, đó là [a,b]
.
Nhưng, những con số thì sao? Chúng tôi chỉ thấy rằng các loại số là nửa nhóm dưới dạng cộng hoặc nhân! Vì vậy, cái nào sẽ được <>
cho một Double
? Vâng, một trong hai! Haskell định nghĩa các loại Product Double
, where (<>) = (*)
(đó là định nghĩa thực tế trong Haskell), và cũng Sum Double
, where (<>) = (+)
.
Một nếp nhăn là bạn đã sử dụng thực tế rằng 1 là danh tính nhân. Một nửa nhóm có một danh tính được gọi là một monoid và được định nghĩa trong gói Haskell Data.Monoid
, gọi phần tử nhận dạng chung của một kiểu chữ mempty
. Sum
, Product
Và danh sách từng có một yếu tố bản sắc (0, 1 và []
, tương ứng), vì vậy họ là trường hợp của Monoid
cũng như Semigroup
. (Đừng nhầm lẫn với một đơn nguyên , vì vậy hãy quên tôi thậm chí đã đưa những người đó lên.)
Đó là đủ thông tin để dịch thuật toán của bạn thành hàm Haskell bằng cách sử dụng các đơn sắc:
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.
Điều quan trọng, lưu ý rằng đây là modulo đệ quy đuôi: mỗi trường hợp là một giá trị, một cuộc gọi đệ quy đuôi hoặc sản phẩm semigroup của cả hai. Ngoài ra, ví dụ này tình cờ sử dụng mempty
cho một trong các trường hợp, nhưng nếu chúng tôi không cần điều đó, chúng tôi có thể thực hiện nó với kiểu chữ chung hơn Semigroup
.
Hãy tải chương trình này lên trong GHCI và xem nó hoạt động như thế nào:
*StylizedRec> getProduct $ pow 2 4
16
*StylizedRec> getProduct $ pow 7 2
49
Hãy nhớ làm thế nào chúng ta tuyên bố pow
cho một chung chung Monoid
, loại mà chúng ta đã gọi a
? Chúng tôi đã GHCI đủ thông tin để suy ra rằng các loại a
ở đây là Product Integer
, đó là một instance
số Monoid
người có <>
hoạt động là nguyên nhân. Vì vậy, pow 2 4
mở rộng đệ quy đến 2<>2<>2<>2
, đó là 2*2*2*2
hoặc 16
. Càng xa càng tốt.
Nhưng chức năng của chúng tôi chỉ sử dụng các hoạt động monoid chung. Trước đây, tôi đã nói rằng có một thể hiện của Monoid
gọi Sum
, mà <>
hoạt động là +
. Chúng ta có thể thử nó không?
*StylizedRec> getSum $ pow 2 4
8
*StylizedRec> getSum $ pow 7 2
14
Việc mở rộng tương tự bây giờ cho chúng ta 2+2+2+2
thay vì 2*2*2*2
. Phép nhân là phép cộng như phép nhân là phép nhân!
Nhưng tôi đã đưa ra một ví dụ khác về một monoid Haskell: danh sách, có hoạt động là nối.
*StylizedRec> pow [2] 4
[2,2,2,2]
*StylizedRec> pow [7] 2
[7,7]
Viết [2]
cho trình biên dịch rằng đây là một danh sách, <>
trong danh sách là ++
, vì vậy [2]++[2]++[2]++[2]
là [2,2,2,2]
.
Cuối cùng, một thuật toán (Hai, trong thực tế)
Bằng cách đơn giản thay thế x
bằng [x]
, bạn chuyển đổi thuật toán chung sử dụng modulo đệ quy một nửa nhóm thành một nhóm tạo ra một danh sách. Danh sách nào? Danh sách các yếu tố thuật toán áp dụng <>
cho. Bởi vì chúng tôi chỉ sử dụng các hoạt động semigroup mà danh sách cũng có, danh sách kết quả sẽ là đẳng cấu với tính toán ban đầu. Và vì hoạt động ban đầu là kết hợp, chúng ta có thể đánh giá tốt các yếu tố từ sau ra trước hoặc từ trước ra sau.
Nếu thuật toán của bạn từng đạt đến trường hợp cơ sở và chấm dứt, danh sách sẽ không trống. Vì trường hợp đầu cuối trả về một cái gì đó, đó sẽ là phần tử cuối cùng của danh sách, vì vậy nó sẽ có ít nhất một phần tử.
Làm thế nào để bạn áp dụng một hoạt động giảm nhị phân cho mọi yếu tố của danh sách theo thứ tự? Đúng vậy, một nếp gấp. Vì vậy, bạn có thể thay thế [x]
cho x
, có được một danh sách các yếu tố để giảm <>
, và sau đó, hoặc phải gấp hoặc trái gấp danh sách:
*StylizedRec> getProduct $ foldr1 (<>) $ pow [Product 2] 4
16
*StylizedRec> import Data.List
*StylizedRec Data.List> getProduct $ foldl1' (<>) $ pow [Product 2] 4
16
Phiên bản foldr1
thực sự tồn tại trong thư viện tiêu chuẩn, như sconcat
cho Semigroup
và mconcat
cho Monoid
. Nó không lười biếng gấp trong danh sách. Đó là, nó mở rộng [Product 2,Product 2,Product 2,Product 2]
đến 2<>(2<>(2<>(2)))
.
Điều này không hiệu quả trong trường hợp này vì bạn không thể làm bất cứ điều gì với các điều khoản riêng lẻ cho đến khi bạn tạo ra tất cả chúng. (Tại một thời điểm tôi đã có một cuộc thảo luận ở đây về việc khi nào nên sử dụng nếp gấp bên phải và khi nào nên sử dụng nếp gấp bên trái nghiêm ngặt, nhưng nó đã đi quá xa.)
Phiên bản với foldl1'
một nếp gấp bên trái được đánh giá nghiêm ngặt. Đó là để nói, một chức năng đệ quy đuôi với một bộ tích lũy nghiêm ngặt. Điều này đánh giá (((2)<>2)<>2)<>2
, tính toán ngay lập tức và không muộn hơn khi cần thiết. (Ít nhất, không có sự chậm trễ trong bản thân gấp:. Danh sách được gấp lại được tạo ra ở đây bởi một chức năng có thể chứa đánh giá lười biếng) Vì vậy, sẽ tính toán lần (4<>2)<>2
, sau đó ngay lập tức tính toán của 8<>2
, sau đó 16
. Đây là lý do tại sao chúng tôi cần hoạt động để được kết hợp: chúng tôi chỉ thay đổi nhóm các dấu ngoặc đơn!
Nếp gấp bên trái nghiêm ngặt tương đương với những gì GCC đang làm. Số ngoài cùng bên trái trong ví dụ trước là bộ tích lũy, trong trường hợp này là một sản phẩm đang chạy. Ở mỗi bước, nó nhân với số tiếp theo trong danh sách. Một cách khác để diễn đạt nó là: bạn lặp lại các giá trị sẽ được nhân, giữ sản phẩm đang chạy trong bộ tích lũy và trên mỗi lần lặp, bạn nhân số tích lũy với giá trị tiếp theo. Đó là, đó là một while
vòng lặp ngụy trang.
Nó đôi khi có thể được thực hiện như là hiệu quả. Trình biên dịch có thể tối ưu hóa cấu trúc dữ liệu danh sách trong bộ nhớ. Về lý thuyết, nó có đủ thông tin tại thời điểm biên dịch để tìm ra nó nên làm như vậy ở đây: [x]
là một singleton, [x]<>xs
cũng giống như vậy cons x xs
. Mỗi lần lặp của hàm có thể có thể sử dụng lại cùng khung stack và cập nhật các tham số tại chỗ.
Một nếp gấp bên phải hoặc nếp gấp bên trái nghiêm ngặt có thể phù hợp hơn, trong một trường hợp cụ thể, vì vậy hãy biết bạn muốn cái nào. Ngoài ra còn có một số điều chỉ có một nếp gấp đúng có thể làm (chẳng hạn như tạo đầu ra tương tác mà không cần chờ tất cả đầu vào và hoạt động trên một danh sách vô hạn). Mặc dù vậy, ở đây, chúng tôi đang giảm một chuỗi các hoạt động xuống một giá trị đơn giản, do đó, một nếp gấp nghiêm ngặt là điều chúng tôi muốn.
Vì vậy, như bạn có thể thấy, có thể tự động tối ưu hóa modulo đệ quy đuôi bất kỳ nửa nhóm nào (một ví dụ trong số đó là bất kỳ kiểu số thông thường nào trong phép nhân) thành nếp gấp bên phải lười biếng hoặc nếp gấp trái nghiêm ngặt, trong một dòng Haskell.
Tổng quát hóa hơn nữa
Hai đối số của hoạt động nhị phân không phải cùng loại, miễn là giá trị ban đầu là cùng loại với kết quả của bạn. (Tất nhiên, bạn luôn có thể lật các đối số để khớp với thứ tự của kiểu gấp mà bạn đang thực hiện, trái hoặc phải.) Vì vậy, bạn có thể liên tục thêm các bản vá vào một tệp để nhận tệp cập nhật hoặc bắt đầu với giá trị ban đầu là 1.0, chia cho số nguyên để tích lũy kết quả dấu phẩy động. Hoặc thêm các yếu tố vào danh sách trống để có được danh sách.
Một kiểu khái quát hóa khác là áp dụng các nếp gấp không cho các danh sách mà cho các Foldable
cấu trúc dữ liệu khác . Thông thường, một danh sách liên kết tuyến tính bất biến không phải là cấu trúc dữ liệu bạn muốn cho một thuật toán nhất định. Một vấn đề tôi không gặp phải ở trên là việc thêm các yếu tố vào phía trước danh sách sẽ hiệu quả hơn rất nhiều và khi hoạt động không giao hoán, áp dụng x
ở bên trái và bên phải của hoạt động giống nhau. Vì vậy, bạn sẽ cần sử dụng một cấu trúc khác, chẳng hạn như một cặp danh sách hoặc cây nhị phân, để biểu diễn một thuật toán có thể áp dụng x
ở bên phải <>
cũng như bên trái.
Cũng lưu ý rằng thuộc tính kết hợp cho phép bạn tập hợp lại các hoạt động theo các cách hữu ích khác, chẳng hạn như phân chia và chinh phục:
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)
Hoặc tự động song song, trong đó mỗi luồng giảm một phân nhóm thành một giá trị sau đó được kết hợp với các giá trị khác.
if(n==0) return 0;
(không trả về 1 như trong câu hỏi của bạn).x^0 = 1
Vì vậy, đó là một lỗi. Tuy nhiên, điều đó không quan trọng đối với phần còn lại của câu hỏi; asm lặp đi lặp lại kiểm tra trường hợp đặc biệt đó trước tiên. Nhưng kỳ lạ thay, việc thực hiện lặp đi lặp lại giới thiệu nhiều bội số1 * x
không có trong nguồn, ngay cả khi chúng tôi tạo mộtfloat
phiên bản. gcc.godbolt.org/z/eqwine (và gcc chỉ thành công với-ffast-math
.)