Skip to content

Commit a21636f

Browse files
committed
split into two posts
1 parent d415222 commit a21636f

67 files changed

Lines changed: 6153 additions & 30063 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
+++
2+
date = "2017-07-07T07:29:43Z"
3+
highlight = true
4+
math = true
5+
tags = ["math", "python", "binomial", "combinatorics", "programming", "dynamic programming", "memoization", "coding interview"]
6+
title = "Efficient Implementation of the Non-Adjacent Selection Formula"
7+
8+
[header]
9+
caption = ""
10+
image = ""
11+
12+
+++
13+
14+
In the [previous post][two-var-recursive], we derived the closed form for the non-adjacent selection problem:
15+
16+
$$ F_{n, m} = {n - m + 1 \choose m} $$
17+
18+
Now we discuss how to implement this efficiently in Python—from a simple factorial-based solution to library implementations and modular arithmetic for competitive programming.
19+
20+
## Fast Solutions Based on Binomials
21+
22+
We can reflect the closed form in very trivial Python code:
23+
24+
```Python
25+
import math
26+
27+
def f_binom(n, m):
28+
assert n >= 0 and m >= 0
29+
30+
if n + 1 < 2*m:
31+
return 0
32+
33+
return binom(n - m + 1, m)
34+
35+
def binom(n, m):
36+
assert 0 <= m <= n
37+
38+
return math.factorial(n) // math.factorial(m) // math.factorial(n - m)
39+
```
40+
41+
This implementation overperforms significantly the initial DP and memoization solutions from [Introduction to Dynamic Programming and Memoization][intro-to-dp].
42+
A naive implementation of `math.factorial()` might make $n$ multiplications.
43+
This could still be faster than doing $\Theta(n)$ additions in DP approach.
44+
45+
The actual implementation of `math.factorial()` is written in C
46+
and probably has precomputed results for some range of $n$
47+
and might even cache the results for bigger $n$.
48+
49+
## Implementations Based on `scipy` and `sympy` Libraries
50+
51+
Several third party libraries provide a functionality to compute binomial coefficients.
52+
Let's take a look at `scipy` and `sympy`.
53+
54+
We can install both of them using `pip`-package manager:
55+
56+
```bash
57+
pip install scipy sympy
58+
```
59+
60+
We can easily write two implementations of $F_{n,m}$ which will call `scipy.special.comb()` or `sympy.binomial()` functions:
61+
62+
```Python
63+
import scipy.special
64+
65+
def f_sci(n, m):
66+
assert n >= 0 and m >= 0
67+
68+
if n + 1 < 2*m:
69+
return 0
70+
71+
return scipy.special.comb(n - m + 1, m, exact=True)
72+
```
73+
74+
The second one is very similar to the first one:
75+
76+
```Python
77+
import sympy
78+
79+
def f_sym(n, m):
80+
assert n >= 0 and m >= 0
81+
82+
if n + 1 < 2*m:
83+
return 0
84+
85+
return sympy.binomial(n - m + 1, m)
86+
```
87+
88+
We can use the same `test()` helper function that we defined in [Introduction to Dynamic Programming and Memoization][intro-to-dp].
89+
Let's run it on all the 5 implementations:
90+
91+
```python
92+
funcs = [f_mem, f_dp, f_binom, f_sci, f_sym]
93+
test(6000, 2000, funcs)
94+
```
95+
96+
It will print something similar to following output:
97+
98+
```
99+
f(6000,2000): 192496093
100+
f_mem: 6.7195 sec, x 4195.10
101+
f_dp: 5.3249 sec, x 3324.43
102+
f_binom: 0.0016 sec, x 1.00
103+
f_sci: 0.0021 sec, x 1.32
104+
f_sym: 0.0043 sec, x 2.69
105+
```
106+
107+
The first two methods, which are based on memoization and DP, are much slower than the last three,
108+
which are based on the binomial coefficients.
109+
110+
## The Intuition for the Time Complexity Analysis
111+
112+
DP and memoization makes $O(n^2)$ of "addition" operations over long integers.
113+
The long integers are bounded by $F_{n,m}$ value, which could be bounded by $2^n$.
114+
One "addition" operation takes $O(N)$ time for $N$-digit integer input.
115+
For our case $N$ could be bounded by $ O(\log 2^n)$ $ = O(n)$.
116+
So the total time complexity is bounded by
117+
$ O(n^2 \cdot N) $
118+
$ = O(n^2 \cdot \log 2^n) $
119+
$ = O(n^2 \cdot n) $
120+
$ = O(n^3)$.
121+
122+
The binomial based solutions make $O(n)$ "multiplication" operations over long integers
123+
The long integers could be bounded by $O(n!)$ $ = O(n^n)$.
124+
The "multiplication" operation could be implemented in a naive way which runs $O(N^2)$ in time and is used for a small input.
125+
But it also has a more advanced implementation, which takes $O(N^{\log_2 3})$ $ = O(N^{1.59})$ and is used on big integers.
126+
Note that here $N$ denotes the number of digits in the input long integers for multiplication.
127+
In this case, $N$ is bounded by
128+
$ O(\log n!) $
129+
$ = O(\log (n^n)) $
130+
$ = O(n \log n) $.
131+
The total time complexity of the binomial based implementations is bounded by
132+
$ O\left(n \cdot N^{\log_2 3}\right) $
133+
$ = O\left(n \cdot (\log n!)^{\log_2 3}\right)$
134+
$ = O\left(n \cdot (n \log n)^{1.59}\right)$
135+
$ = O\left(n^{2.59} \cdot (\log n)^{1.59}\right)$.
136+
137+
This is not really a formal proof, but it gives some intuition why the last approach overperforms the former one.
138+
In practice, the results of factorial computation are cached,
139+
therefore we observe even bigger gap in performance (yeahh again not formal claim, just an intuition).
140+
141+
You can play with running tests on different $n$ and $m$.
142+
What I saw that actually there is no clear winner between the last 3 implementations.
143+
Probably, the most of the time is spent on the long arithmetic computation.
144+
145+
## Modular Arithmetics
146+
147+
In questions where it is required to count some objects, not rarely the answer might be very big even on very small input.
148+
In such case, typically it is asked to print the answer modulo some big prime integer, let's say, $M=1000^3+7$.
149+
Since Python has built-in long arithmetics, we can apply modulo on the final result,
150+
but executing the entire algorithm with long arithmetics while knowing that only small part of it is really important is very costly,
151+
and of course, not that efficient.
152+
153+
Let's look, briefly, at very simple change we can do for `f_binom` function that will speed up the computation significantly:
154+
155+
```python
156+
import functools
157+
158+
M = 10**9 + 7
159+
160+
def f_binom_mod(n, m):
161+
assert n >= 0 and m >= 0
162+
163+
if n + 1 < 2*m:
164+
return 0
165+
166+
return binom_mod(n - m + 1, m)
167+
168+
def binom_mod(n, m):
169+
assert 0 <= m <= n
170+
171+
return ((fact_mod(n) * inv_mod(fact_mod(m))) % M * inv_mod(fact_mod(n - m))) % M
172+
173+
@functools.lru_cache(maxsize=None)
174+
def fact_mod(m):
175+
if m <= 1:
176+
return 1
177+
178+
return (m * fact_mod(m - 1)) % M
179+
180+
def inv_mod(x):
181+
return pow(x, M - 2, M)
182+
```
183+
184+
As we can see, all the operations are computed modulo $M$.
185+
The function `fact_mod` is recursive but uses Memoization.
186+
The most tricky part is how to implement modular-division.
187+
From [Fermat's little theorem](https://en.wikipedia.org/wiki/Fermat%27s_little_theorem),
188+
we know that if $M$ is prime and $0 < x < M$, then $x^{-1} \equiv x^{M-2} \pmod M$.
189+
This allows to compute the multiplicative inverse of $x$ using the Python's built-in function
190+
[pow](https://docs.python.org/3/library/functions.html#pow).
191+
192+
Let's test the new approach against other implementations:
193+
194+
```python
195+
fact_mod(10000) # for caching factorials
196+
197+
funcs = [f_binom_mod, f_binom, f_sci, f_sym]
198+
199+
test(10000, 1000, funcs)
200+
test(10000, 2000, funcs)
201+
test(10000, 3000, funcs)
202+
```
203+
204+
It is not a surprise that taking the benefits of modular computations results in the huge speedup in running-time:
205+
206+
```
207+
f(10000,1000): 450169549
208+
f_binom_mod: 0.0000 sec, x 1.00
209+
f_binom: 0.0073 sec, x 337.60
210+
f_sci: 0.0011 sec, x 49.33
211+
f_sym: 0.0076 sec, x 353.22
212+
213+
f(10000,2000): 75198348
214+
f_binom_mod: 0.0000 sec, x 1.00
215+
f_binom: 0.0063 sec, x 368.94
216+
f_sci: 0.0026 sec, x 153.33
217+
f_sym: 0.0053 sec, x 308.93
218+
219+
f(10000,3000): 679286557
220+
f_binom_mod: 0.0000 sec, x 1.00
221+
f_binom: 0.0060 sec, x 361.12
222+
f_sci: 0.0056 sec, x 338.13
223+
f_sym: 0.0053 sec, x 319.02
224+
```
225+
226+
[intro-to-dp]: /post/intro-to-dp/
227+
[two-var-recursive]: /post/two-var-recursive-func/

0 commit comments

Comments
 (0)