在這個 AI 的時代,大多數的資料結構或演算法可以很容易透過 AI 生成。但我發現 lock-free 演算法/資料結構是個例外,AI 的生成結果經常會有 deadlock 產生,所以我打算自己試著把常見的資料結構改成 Concurrent 的形式。
我自己沒有寫註解的習慣,程式碼中所有註解是 AI 加上去的。測試我也只讓 AI 隨意生成測試程式而已,但邏輯上應該沒問題,如果有人能幫我測過我會很感激的。
在這個 AI 的時代,大多數的資料結構或演算法可以很容易透過 AI 生成。但我發現 lock-free 演算法/資料結構是個例外,AI 的生成結果經常會有 deadlock 產生,所以我打算自己試著把常見的資料結構改成 Concurrent 的形式。
我自己沒有寫註解的習慣,程式碼中所有註解是 AI 加上去的。測試我也只讓 AI 隨意生成測試程式而已,但邏輯上應該沒問題,如果有人能幫我測過我會很感激的。
給定兩個數列 $A = (a_0, a_1, \dots a_{n-1}),\ B = (b_0, b_1, \dots, b_{m-1})$,
求兩數列的離散捲積 $C = (c_0, c_1, \dots, c_{n+m-2})$,其中 $$c_k = \sum_{i + j = k}a_ib_j$$
我們可以將數列轉換成多項式:
$$\begin{align} A(x)=a_0+a_1x+a_2x^2+\dots+a_{n-1}x^{n-1}\\B(x)=b_0+b_1x+b_2x^2+\dots+b_{m-1}x^{m-1}\end{align}$$
這樣一來,$c_i = (A * B)(x)$ 在 $x^i$ 項的係數,如果用最 naive 的做法,總共要花 $O(n\times m)$ 的時間。
這裡的目標是要在 $O((n+m)\log (n+m))$ 的時間算出 $C$。
對於一個 $n-1$ 次多項式 $F(x)=a_0+a_1x+a_2x^2+\dots+a_{n-1}x^{n-1}$,
我們可以用 Coefficient Representation 來表示他:
$F(x) := [a_0, a_1, \dots, a_{n - 1}]$
除此之外,令 $x_0, x_1, \dots, x_{n-1}$ 為 $n$ 個不同的數字,
我們也能用這些點在 $F$ 中的取值來表示他
令 $y_i = F(x_i),\ i = 0, 1, \dots, n-1$,
則 $F(x):= [y_0, y_1, \dots, y_{n - 1}]$
這種表示法又叫做 Point-value Representation
給定 Coefficient Representation,我們現在只會 $O(n\times m)$ 來做多項式乘法。
那如果換成 Point-value Representation 呢?
$C(x_i) = A(x_i) \times B(x_i),~i = 0, 1, \dots, n+m-2$
我們只要能抓 $n+m-1$ 個不同數字的取值,最後一個對一個再相乘起來就好了!只需要 $O(n+m)$ 的時間。
我們可以把計算多項式乘法的任務轉換成:
只要好好的選擇 $X$,就可以用分治法 (divide and conquer) 加速步驟 2, 4。
當 $n=2^r,r\ge 0$ 的時候,假設有個 $\omega(n)$ 函數有以下性質:
設 $$X=(x_0, x_1, \dots, x_{n-1}),~x_i=\omega(n)^i$$ 則原本是 Coefficient Representation 的多項式 $$F(x) := [a_0, a_1, \dots, a_{n - 1}]$$ 其 Point-value Representation $$F(x) := [y_0, y_1, \dots, y_{n - 1}],~y_i=F(x_i)$$
透過 $\omega(n)$ 函數的性質可以利用分治法在 $O(n \log n)$ 的時間遞迴求出來。
若 $n$ 不是 2 的冪次,我們可以找到一個 $n'=2^r,n'>n$,將 $a_n,a_{n+1},...,a_{n'-1}$ 都設為 0,則 $$F'(x)=a_0+a_1x+...+a_{n'-1}x^{n'-1}$$ 就能滿足使用分治法的條件。
假設有個函數 $DC(F, n)$ 輸入一個 $n-1$ 次多項式 $F(x)$ 的係數表示法,回傳 $[F(\omega(n)^0), F(\omega(n)^1),..., F(\omega(n)^{n-1})]$。
設 $$\begin{align}
G(x)=a_0+a_2x+a_4x^2+...+a_{n-2}x^{\frac{n}{2}-1}\\
H(x)=a_1+a_3x+a_5x^2+...+a_{n-1}x^{\frac{n}{2}-1}
\end{align}$$ 由 $F$ 的係數得到 $H,G$ 的係數只需要 $O(n)$ 的時間。
我們可以把 $F(x)$ 用 $G(x)$ 和 $H(x)$ 表示:
$$F(x)=G(x^2)+x\times H(x^2)$$ 透過 $DC$ 函數可以遞迴得到 $$\begin{align}
DC(H,n/2)&=[H(\omega(n/2)^0), H(\omega(n/2)^1),..., H(\omega(n/2)^{n/2-1})]\\
DC(G,n/2)&=[G(\omega(n/2)^0), G(\omega(n/2)^1),..., G(\omega(n/2)^{n/2-1})]
\end{align}$$
對於 $0\le k<\frac{n}{2}$,透過性質 2, 3, 4 可以知道:
$\begin{align}
F(\omega(n)^k)&=G(\omega(n)^{2k})+\omega(n)^k\times H(\omega(n)^{2k})\\
&=G(\omega(n/2)^k)+\omega(n)^k\times H(\omega(n/2)^k)\\
\\
F(\omega(n)^{\frac{n}{2}+k})&=G(\omega(n)^{n+2k})+\omega(n)^{\frac{n}{2}+k}\times H(\omega(n)^{n+2k})\\
&=G(\omega(n)^{2k})-\omega(n)^k\times H(\omega(n)^{2k}) \\
&=G(\omega(n/2)^k)-\omega(n)^k\times H(\omega(n/2)^k)
\end{align}$
這樣有了 $DC(H,n/2),DC(G,n/2)$ 就可以在 $O(n)$ 的時間做出 $DC(F,n)$ 的結果。得到遞迴的時間複雜度 $T(n)=O(n) + 2T(n/2) + O(n) = O(n\log n)$。
舉例來說 $n=8$
$F(x)=a_0+a_1x+a_2x^2+...+a_7x^7$
$$\begin{align}
G(x)=a_0+a_2x+a_4x^2+a_6x^3\\
H(x)=a_1+a_3x+a_5x^2+a_7x^3
\end{align}$$
想要用遞迴方法求出 $F(\omega(8)^0), F(\omega(8)^1),..., F(\omega(8)^7)$。
首先可以遞迴求出
$$\begin{align}
G(\omega(4)^0), G(\omega(4)^1), G(\omega(4)^2), G(\omega(4)^3)\\
H(\omega(4)^0), H(\omega(4)^1), H(\omega(4)^2), H(\omega(4)^3)
\end{align}$$
接著可以在 $O(n)$ 得到:
程式碼的部分等講完逆變換後在介紹。
設 $(y_0, y_1, \dots, y_{n - 1}),~y_i=F(x_i)$,令多項式 $Z(x)=y_0+y_1x+y_2x^2+y_{n-1}x^{n-1}$,也就是將 $F(x)$ 的 Point-value Representation 作為多項式 $Z(x)$ 的 Coefficient Representation。
將 $\omega(n)^k$ 帶入 $Z(x)$ 可以發現
$$\begin{align}
Z(\omega(n)^k)&=\sum_{i=0}^{n-1} F(\omega(n)^i)\omega(n)^{ik} \\
&=\sum_{i=0}^{n-1} \left(\left(\sum_{j=0}^{n-1} a_j\omega(n)^{ij}\right)\omega(n)^{ik}\right)\\
&=\sum_{j=0}^{n-1}a_j\left(\sum_{i=0}^{n-1} \left(\omega(n)^{j+k}\right)^i\right)
\end{align}$$
這裡等比數列的和只有兩種可能
$$\sum_{i=0}^{n-1} (\omega(n)^{j+k})^i = \left\{
\begin{aligned}
&n&,&~~~j+k\equiv 0\ (mod\ n) \\
&\frac{\omega(n)^{n(j+k)}-1}{\omega(n)^{j+k} -1} = 0&, &~~~\text{else}
\end{aligned}
\right.$$
因此得到結論
這表示我們可以將 $y_0\sim y_{n-1}$ 使用同樣的分治法輕鬆地在 $O(n \log n)$ 得到原本多項式 $F(x)$ 的係數 $a_0\sim a_{n-1}$
由於我們還不知道 $\omega(n)$ 究竟是個怎樣的函數,實作使用 template 的方式,使用者要將與 $\omega(n)$ 有關的操作寫成 class 後填入 `Policy` 這個欄位。
可以觀察到 $\omega(n)^k$ 有非常明顯的循環性質,這在一般人常見的實數領域中很少見,有這種性質的東西經常出現在:
設 $\omega(n)=e^{i\frac{2\pi}{n}}$。透過 Euler's formula 可以知道 $e^{i\frac{2\pi}{n}}=\cos(\frac{2\pi}{n})+i\sin(\frac{2\pi}{n})$
這樣 $\omega(n)$ 的數學含意就是複數的 $n$ 次單位根。
複數以及 `exp` 函數都是 C++ STL 有提供的東西:
不過使用 FFT 計算多項式乘法會產生浮點數誤差,因此有些人會考慮使用待會會介紹的 FNTT
設 $\omega(n)=g^{\frac{P-1}{n}}\mod P$,這裡的 $P$ 是滿足某性質的質數且 $g$ 是$\mod P$ 的原根。因此首先我們要來認識什麼是原根。
假設 $g, m$ 互質, 使得 $g^d \equiv 1\ (mod\ m)$ 成立的最小正整數 $d$ 定義為 $\delta_m(g)$。
根據歐拉定理 $\delta_m(g)|\phi(m)$,若 $\delta_m(g) = \phi(m)$ ,則稱 $g$ 是$\mod m$ 的原根 (primitive root)。
如果 $m$ 是個質數,則最小的 $g$ 通常是個很小的數字 ($g\ll P^{5/\log\log P}$ by Least Prime Primitive Roots),zerojudge 上剛好有一題 [b435. 尋找原根]。
對於任意質數 $P>2$ 其原根 $g$ 有一些直觀的性質:
若 $P-1$ 可以被 $n$ 整除,則所有 $\omega(n)$ 的性質都能滿足(所有運算皆是同餘運算):
為了滿足 $P-1$ 可以被 $n$ 整除,因為 $n$ 是 2 的冪次,FNTT 需要一個特殊構造的質數 $P=r\times 2^k+1,~2^k\ge n$,已經有中國人整理出一些常用的質數:
$P=998244353=7\times 17\times 2^{23}+1$ 是個經常被使用的質數,其原根 $g=3$。
這樣我們就可以輕鬆地根據定義寫出 FNTT 的實作:
注意 FNTT 的所有運算皆是同餘運算,也就是說 FNTT 的計算多項式乘法的結果是原本的數字$\mod P$ 的值,因此若需要得到精確的結果需要用不同質數執行多次 FNTT 使用中國剩餘定理將結果合併。
假設有個 $n-1$ 次多項式要和一個 $m-1$ 次多項式做乘法,這兩個多項式的所有係數皆小於一個正整數 $q$。
那麼這樣任何多項式係數的範圍就是 $[0,q-1]$,係數兩兩相乘不會超過 $(q-1)^2$,一共最多 $\min(n,m)$ 項相加,不會超過 $\min(n,m)\times(q-1)^2$。
我們可以選 $k$ 個可以進行 FNTT 的不同質數使得以下條件成立:
$$\prod_{i=1}^{k}p_i>\min(n,m)\times(q-1)^2$$
這樣分別使用這些質數執行 FNTT 後再使用中國剩餘定理將結果合併就可以得到完全精確的係數,但要注意計算範圍可能會超過 `long long`,甚至有可能會需要 `__int128_t`。
我們將係數遞迴的狀況畫出來,注意到葉節點係數的順序會是 $(0, 4, 2, 6, 1, 5, 3, 7)$:
觀察這棵樹,由上往下的第 $i$ 次分層時,是按照其 index 在第 $i$ 個 bit 的奇偶分兩邊的,並且第 $i$ 次分層會決定其最後位子的第 $\log_2 n - i - 1$ 個 bit。
可以推論出,index $i$ 的換置後的位子就會將是 $i$ 的 binary representation 給 reverse。
遞推建表法,建立 $O(n)$ 大小表,總時間複雜度也是 $O(n)$
直接換置法,一次反轉一個數字 $n$,只要 $O(1)$ 空間,但時間複雜度是 $O(\log\log n)$
如果 index $i$ 的位置是 $j$,那麼 index $j$ 的位置也會是 $i$。
想要節省空間的話,可以考慮用直接換置法 in-place 進行換置:
我們一開始就把係數的順序透過 bit reverse 換置,可以寫出非遞迴版本的程式碼:
將計算流程畫成圖形,可以看到有很多長得像蝴蝶的形狀,因此被稱之為蝶形網路:
Output:
5 16 34 60 70 70 59 36
(5.0,0.0) (16.0,0.0) (34.0,0.0) (60.0,-0.0) (70.0,-0.0) (70.0,-0.0) (59.0,-0.0) (36.0,0.0)
5 16 34 60 70 70 59 36
Counting Sort是一種效率很高的排序方式,複雜度為$\ord{n+k}$,其中$k$是Bucket的大小,由此可知僅限於整數且數字範圍不能太大。根據觀察在很多應用中會有對物件以編號大小進行排序的行為,在這方面應該能做到很大的加速。
另外一個問題是Counting Sort雖然簡單,很多人甚至可以自己想到實作方法,但這也導致了標準的作法常常被人忽略。因此這裡就來給大家展示標準的Counting sort:
參數的解釋如下:
離散化是演算法競賽常用的操作,在各種實際問題上也能看到其應用。最基本的情況,是對於n個可排序的元素,製造一個map使得它們可以和自己的名次一一對應,但通常的應用中這n個元素確定之後就不太會有增減的動作,因此可以存到vector中排序去除重複的部分,搜索的部分就用二分搜尋來取代。
基本上這個問題就是給你一些線段(格式通常為兩個端點),你要找出這些線段的交點。直觀的做法兩兩進行計算會花上$\ord{n^2}$的時間,但大多數的情況下交點不會很多。為了解決這個問題,修改自Shamos–Hoey演算法的Bentley–Ottmann演算法可以在$\ord{(n+k)\log n}$的時間內找出所有交點,其中$k$是交點數量。
這裡附上實作時需要用到的基本資料結構:
演算法使用掃描線進行。掃描線是一條垂直線從左邊掃到右邊(有些實作是水平線從上面掃到下面),並且在遇到事件點的時候進行相關處理。
線段的兩端點以及交點都作為事件點被紀錄在最終結果中。對於每個事件點$P$,我們會計算三個集合:
10-2 7 2 0-2 7 -2 0-2 6 2 5-2 6 2 2-2 4 2 7-2 4 2 2-2 4 4 1-2 0 2 20 1 0 10 3 4 1
最後附上測試程式碼,需要的話可以自己執行看看: