|
| 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