-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathindex.xml
627 lines (445 loc) · 28.6 KB
/
index.xml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>Software and Math on Software and Math</title>
<link>https://vchernoy.xyz/</link>
<description>Recent content in Software and Math on Software and Math</description>
<generator>Hugo -- gohugo.io</generator>
<language>en-us</language>
<lastBuildDate>Wed, 20 Apr 2016 00:00:00 +0000</lastBuildDate>
<atom:link href="/" rel="self" type="application/rss+xml" />
<item>
<title>Cracking Multivariate Recursive Equations Using Generating Functions</title>
<link>https://vchernoy.xyz/post/two-var-recursive-func/</link>
<pubDate>Thu, 06 Jul 2017 07:29:43 +0000</pubDate>
<guid>https://vchernoy.xyz/post/two-var-recursive-func/</guid>
<description>
<p>In this post, we return back to the combinatorial problem discussed in <a href="https://vchernoy.xyz/post/intro-to-dp/">Introduction to Dynamic Programming and Memoization</a> post.
We will show that generating functions may work great not only for single variable case (see <a href="https://vchernoy.xyz/post/gen-func-art/">The Art of Generating Functions</a>),
but also could be very useful for hacking two-variable relations (and of course, in general for multivariate case too).</p>
<p>For making the post self-contained, we repeat the problem definition here.</p>
<h2 id="the-problem">The Problem</h2>
<blockquote>
<p>Compute the number of ways to choose $m$ elements from $n$ elements such that selected elements in one combination are not adjacent.</p>
</blockquote>
<p>For example, for $n=4$ and $m=2$, the answer is $3$, since from the $4$-element set: $\lbrace 1,2,3,4 \rbrace$,
there are three feasible $2$-element combinations: $\lbrace 1,4 \rbrace$, $\lbrace 2,4 \rbrace$, $\lbrace 1,3 \rbrace$.</p>
<p>Another example: for $n=5$ and $m=3$, there is only one $3$-element combination: $\lbrace 1,3,5 \rbrace$.</p>
<p>As we discussed in the first post, there is a nice recursive relation for this problem:</p>
<p>$$ F_{n, m} = F_{n - 1, m} + F_{n - 2, m - 1} + [n=m=0] + [n=m=1] $$</p>
<p>We assume that for any $n &lt; 0$ or $m &lt; 0$, $F_{n,m} = 0$.
The indicator $[P]$ gives $1$ if the predicate $P$ is true.</p>
<h2 id="the-generating-function-for-f-n-m">The Generating Function for $F_{n,m}$</h2>
<p>Let&rsquo;s introduce the generating function $\Phi(x,y)$ of two (floating point) variables $x$ and $y$:</p>
<p>$$\Phi(x,y) = \sum_{n,m} F_{n,m} x^n y^m$$</p>
<p>Substituting the definition of $F_{n,m}$ and simplifying the sums, we will get:</p>
<p>$ \Phi(x,y) $
$ = \sum_{n,m} F_{n,m} x^n y^m $
$ = \sum_{n,m} \left(F_{n - 1, m} + F_{n - 2, m - 1} + [n=m=1] + [n=m=0] \right) x^n y^m$
$ = \sum_{n,m} F_{n - 1, m} x^n y^m + \sum_{n,m} F_{n - 2, m - 1} x^n y^m + x \cdot y + 1 $
$ = x \sum_{n,m} F_{n - 1, m} x^{n-1} y^m + x^2 y \sum_{n,m} F_{n - 2, m - 1} x^{n-2} y^{m-1} + x \cdot y + 1 $
$ = x \Phi(x,y) + x^2 y \Phi(x,y) + x \cdot y + 1 $</p>
<p>We have just found the simple representation for the generating function:</p>
<p>$$\Phi(x, y) = \frac{1 + x \cdot y}{1 - x - x^2 y}$$</p>
<h2 id="the-closed-form-for-f-n-m">The Closed Form for $F_{n,m}$</h2>
<p>Using the infinite series: $ \frac{1}{1-z} = \sum_{k\geq 0} z^k $,
we can make the following transforms:</p>
<p>$\Phi(x, y) $
$ = \frac{1 + x \cdot y}{1 - x - x^2 y}$
$ = (1 + x \cdot y) \sum_{k\geq 0} (x + x^2 y)^k $
$ = (1 + x \cdot y) \sum_{0 \leq i \leq k} {k \choose i} x^{k+i} y^i $
$ = \sum_{0 \leq i \leq k} {k \choose i} x^{k+i} y^i + \sum_{0 \leq i \leq k} {k \choose i} x^{k+i+1} y^{i+1}$</p>
<ol>
<li><p>Introducing the new variables: $n=k+i, m=i$, we cat transform the first expression as follows:
$ \sum_{0 \leq i \leq k} {k \choose i} x^{k+i} y^i $
$ = \sum_{m \geq 0, n \geq 2m} {n-m \choose m} x^{n} y^m $.</p></li>
<li><p>And introducing the new variables: $n=k+i+1, m=i+1$, we can transform the second expression similarly:
$ \sum_{0 \leq i \leq k} {k \choose i} x^{k+i+1} y^{i+1} $
$ = \sum_{m \geq 1, n \geq 2m-1} {n-m \choose m-1} x^n y^m $.</p></li>
</ol>
<p>The last two transformations give us the closed form:</p>
<p>$F_{n, m} $
$ = {n - m \choose m} + {n - m \choose m - 1}$.</p>
<p>Which actually equals to</p>
<p>$$ F_{n, m} = {n - m + 1 \choose m} $$</p>
<h2 id="fast-solutions-based-on-binomials">Fast Solutions Based on Binomials</h2>
<p>Now we can reflect this idea in very trivial Python code:</p>
<pre><code class="language-Python">import math
def f_binom(n, m):
assert n &gt;= 0 and m &gt;= 0
if n + 1 &lt; 2*m:
return 0
return binom(n - m + 1, m)
def binom(n, m):
assert 0 &lt;= m &lt;= n
return math.factorial(n) // math.factorial(m) // math.factorial(n - m)
</code></pre>
<p>This implementation overperforms significantly the initial DP and memoization solutions.
A naive implementation of <code>math.factorial()</code> might make $n$ multiplications.
This could still be faster than doing $\Theta(n)$ additions in DP approach.</p>
<p>The actual implementation of <code>math.factorial()</code> is written in C
and probably has precomputed results for some range of $n$
and might even cache the results for bigger $n$.</p>
<h2 id="implementations-based-on-scipy-and-sympy-libraries">Implementations Based on <code>scipy</code> and <code>sympy</code> Libraries</h2>
<p>Several third party libraries provide a functionality to compute binomial coefficients.
Let&rsquo;s take a look at <code>scipy</code> and <code>sympy</code>.</p>
<p>We can install both of them using <code>pip</code>-package manager:</p>
<pre><code class="language-bash">pip install scipy sympy
</code></pre>
<p>We can easily write two implementations of $F_{n,m}$ which will call <code>scipy.special.comb()</code> or <code>sympy.binomial()</code> functions:</p>
<pre><code class="language-Python">import scipy.special
def f_sci(n, m):
assert n &gt;= 0 and m &gt;= 0
if n + 1 &lt; 2*m:
return 0
return scipy.special.comb(n - m + 1, m, exact=True)
</code></pre>
<p>The second one is very similar to the first one:</p>
<pre><code class="language-Python">import sympy
def f_sym(n, m):
assert n &gt;= 0 and m &gt;= 0
if n + 1 &lt; 2*m:
return 0
return sympy.binomial(n - m + 1, m)
</code></pre>
<p>We can use the same <code>test()</code> helper function that we defined in <a href="https://vchernoy.xyz/post/intro-to-dp/">Introduction to Dynamic Programming and Memoization</a>.
Let&rsquo;s run it on all the 5 implementatnions:</p>
<pre><code class="language-python">funcs = [f_mem, f_dp, f_binom, f_sci, f_sym]
test(6000, 2000, funcs)
</code></pre>
<p>It will print something similar to following output:</p>
<pre><code>f(6000,2000): 192496093
f_mem: 6.7195 sec, x 4195.10
f_dp: 5.3249 sec, x 3324.43
f_binom: 0.0016 sec, x 1.00
f_sci: 0.0021 sec, x 1.32
f_sym: 0.0043 sec, x 2.69
</code></pre>
<p>The first two methods, which are based on memoization and DP, are much slower than the last three,
which are based on the binomial coefficients.</p>
<h2 id="the-intuition-for-the-time-complexity-analysis">The Intuition for the Time Complexity Analysis</h2>
<p>DP and memoization makes $O(n^2)$ of &ldquo;addition&rdquo; operations over long integers.
The long integers are bounded by $F_{n,m}$ value, which could be bounded by $2^n$.
One &ldquo;addition&rdquo; operation takes $O(N)$ time for $N$-digit integer input.
For our case $N$ could be bounded by $ O(\log 2^n)$ $ = O(n)$.
So the total time complexity is bounded by
$ O(n^2 \cdot N) $
$ = O(n^2 \cdot \log 2^n) $
$ = O(n^2 \cdot n) $
$ = O(n^3)$.</p>
<p>The binomial based solutions make $O(n)$ &ldquo;multiplication&rdquo; opearations over long integers
The long integers could be bounded by $O(n!)$ $ = O(n^n)$.
The &ldquo;multiplication&rdquo; opertion could be implemented in a naive way which runs $O(N^2)$ in time and is used for a small input.
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.
Note that here $N$ denotes the number of digits in the input long integers for multiplication.
In this case, $N$ is bounded by
$ O(\log n!) $
$ = O(\log (n^n)) $
$ = O(n \log n) $.
The total time complexity of the binomial based implementations is bounded by
$ O\left(n \cdot N^{\log_2 3}\right) $
$ = O\left(n \cdot (\log n!)^{\log_2 3}\right)$
$ = O\left(n \cdot (n \log n)^{1.59}\right)$
$ = O\left(n^{2.59} \cdot (\log n)^{1.59}\right)$.</p>
<p>This is not really a formal proof, but it gives some intuition why the last approach overperforms the former one.
In practice, the results of factorial computation are cached,
therefor we observe even bigger gap in performance (yeahh again not formal claim, just an intuition).</p>
<p>You can play with running tests on different $n$ and $m$.
What I saw that actually there is no clear winner between the last 3 implementations.
Probably, the most of the time is spent on the long arithmetic computation.</p>
<h2 id="modular-arithmetics">Modular Arithmetics</h2>
<p>In questions where it is required to count some objects, not rearly the answer might be very big even on very small input.
In such case, typically it is asked to print the answer modulo some big prime integer, let&rsquo;s say, $M=1000^3+7$.
Since Python has built-in long arithmetics, we can apply modulo on the final result,
but executing the entire agorithm with long arithmetics while knowing that only small part of it is really important is very costly,
and of course, not that efficient.</p>
<p>Let&rsquo;s look, briefly, at very simple change we can do for <code>f_binom</code> function that will speed up the computation significantly:</p>
<pre><code class="language-python">def f_binom_mod(n, m):
assert n &gt;= 0 and m &gt;= 0
if n + 1 &lt; 2*m:
return 0
return binom_mod(n - m + 1, m)
def binom_mod(n, m):
assert 0 &lt;= m &lt;= n
return ((fact_mod(n) * inv_mod(fact_mod(m))) % M * inv_mod(fact_mod(n - m))) % M
@functools.lru_cache(maxsize=None)
def fact_mod(m):
if m &lt;= 1:
return 1
return (m * fact_mod(m - 1)) % M
def inv_mod(x):
return pow(x, M - 2, M)
</code></pre>
<p>As we can see, all the operations are computed modulo $M$.
The function <code>fact_mod</code> is recursive but uses Memoization.
The most tricky part is how to implemenent modular-division.
From <a href="https://en.wikipedia.org/wiki/Fermat%27s_little_theorem" target="_blank">Fermat&rsquo;s little theorem</a>,
we know that if $M$ is prime and $0 &lt; x &lt; M$, then $x^{-1} \equiv x^{M-2} \pmod M$.
This allows to compute the multiplicative inverse of $x$ using the Python&rsquo;s built-in function
<a href="https://docs.python.org/3/library/functions.html#pow" target="_blank">pow</a>.</p>
<p>Let&rsquo;s test the new approach against other implementations:</p>
<pre><code class="language-python">fact_mod(10000) # for caching factorials
funcs = [f_binom_mod, f_binom, f_sci, f_sym]
test(10000, 1000, funcs)
test(10000, 2000, funcs)
test(10000, 3000, funcs)
</code></pre>
<p>It is not a surprise that taking the benefits of modular computations results in the huge speedup in running-time:</p>
<pre><code>f(10000,1000): 450169549
f_binom_mod: 0.0000 sec, x 1.00
f_binom: 0.0073 sec, x 337.60
f_sci: 0.0011 sec, x 49.33
f_sym: 0.0076 sec, x 353.22
f(10000,2000): 75198348
f_binom_mod: 0.0000 sec, x 1.00
f_binom: 0.0063 sec, x 368.94
f_sci: 0.0026 sec, x 153.33
f_sym: 0.0053 sec, x 308.93
f(10000,3000): 679286557
f_binom_mod: 0.0000 sec, x 1.00
f_binom: 0.0060 sec, x 361.12
f_sci: 0.0056 sec, x 338.13
f_sym: 0.0053 sec, x 319.02
</code></pre>
</description>
</item>
<item>
<title>The Art of Generating Functions</title>
<link>https://vchernoy.xyz/post/gen-func-art/</link>
<pubDate>Wed, 05 Jul 2017 07:29:43 +0000</pubDate>
<guid>https://vchernoy.xyz/post/gen-func-art/</guid>
<description>
<p>The notion of generating functions and its application to solving recursive equations are very well-known.
For reader who did not have a chance to get familiar with the topics,
I recommend to take a look at very good book:
<a href="https://en.wikipedia.org/wiki/Concrete_Mathematics" target="_blank">Concrete Mathematics: A Foundation for Computer Science, by Ronald L. Graham, Donald E. Knuth, Oren Patashnik</a>.</p>
<p>Generating functions are usually applied to single variable recursive equations.
But actually, the technique may be extended to multivariate recursive equations, or even to a system of recursive equations.
Readers who are familiar with one-variable case, may jump directly to the next post:
<a href="https://vchernoy.xyz/post/two-var-recursive-func/">Cracking Multivariate Recursive Equations Using Generating Functions</a>.</p>
<h2 id="the-generating-function-for-fibonacci-sequence">The Generating Function for Fibonacci Sequence</h2>
<p>Let&rsquo;s consider the generating function&rsquo;s application to Fibonacci numbers.
The Fibonacci numbers could defined by the recursive expression:</p>
<p>$$f_n = f_{n-1} + f_{n-2} + [n=0]$$</p>
<p>We assume that for any $n &lt; 0$, $f_n = 0$.
The indicator $[n=0]$ equals to $1$ only if $n=0$.
This definition produces the following sequence of the Fibonacci numbers: $1$, $1$, $2$, $3$, $5$, $8$, $13$, $\dots$.
Note that sometime, the Fibonacci sequence is defined to start from 0, but it is really not important for the perpose of our discussion.</p>
<p>The generating function $\Phi(x)$ on a floating point variable $x$ is defined as the infinite sum:</p>
<p>$$\Phi(x) = \sum_{n\geq 0} f_n x^n$$</p>
<p>Usually, it is hard to develop an intuition why it is defined that way and why it could be useful.
So let&rsquo;s just focuse on what we can do with this,
and let&rsquo;s start from substituting the defintion of Fibonacci recursion into the formular of generating function:</p>
<p>$ \Phi(x) $
$ = \sum_{n\geq 0} f_n x^n $
$ = \sum_n f_n x^n $
$ = \sum_n (f_{n-1} + f_{n-2} + [n=0]) x^n $
$ = \sum_n f_{n-1} x^n + \sum_n f_{n-2} x^n + \sum_n [n=0] x^n $
$ = x \sum_n f_{n-1} x^{n-1} + x^2 \sum_n f_{n-2} x^{n-2} + 1 x^0 $
$ = x \sum_n f_n x^n + x^2 \sum_n f_n x^n + 1 $
$ = x \Phi(x) + x^2 \Phi(x) + 1 $</p>
<p>And we can obtain the generating function for $f_n$:</p>
<p>$$\Phi(x) = \frac{1}{1 - x - x^2}$$</p>
<h2 id="two-closed-forms-for-fibonacci-relation">Two Closed Forms for Fibonacci Relation</h2>
<p>Nice expression, but what can we do with this &ldquo;magic&rdquo; formula?
We can hack it in two different ways, and depending on our next step, we can get different closed forms for $f_n$.</p>
<h3 id="the-1st-closed-form">The 1st closed form</h3>
<p>One of the standard ways is to split the fraction into two simple ones of the form: $\frac{A}{x+B}$.
Note that the roots of the quadratic equation: $1 - x - x^2 = 0$ are $x_0=-\phi$ and $x_1=\phi^{-1}$,
where $\phi$ is the <em>Golden Ratio</em>:</p>
<p>$ \phi = \frac{1 + \sqrt{5}}{2} = 1.618\dots $.</p>
<p>Let&rsquo;s do a quick test:</p>
<p>$ -(x-x_0) (x-x_1) $
$ = -(x+\phi) \left(x-\phi^{-1}\right) $
$ =-x^2 - x\cdot\left(\phi - \phi^{-1}\right) + 1 $
$ = -x^2 - x + 1 $.</p>
<p>Now we can transform the generating function $\Phi(x)$ as follows:</p>
<p>$\Phi(x)$
$ = \frac{1}{1-x-x^2}$
$ = -\frac{1}{(x+\phi) \left(x-\phi^{-1}\right)}$
$ = \frac{A}{x+\phi} - \frac{A}{x-\phi^{-1}}$
$ = A\cdot\phi^{-1} \frac{1}{1+x\cdot\phi^{-1}} + A\cdot\phi \frac{1}{1 - x\cdot\phi}$,</p>
<p>where $A=\frac{1}{\phi+\phi^{-1}}$.</p>
<p>The next trick is to apply the infinite series $\frac{1}{1-z} = \sum_n z^n$ to each of two expressions and to simplify the sums:</p>
<p>$\Phi(x) $
$ = A\cdot\phi^{-1} \sum_n \left(-x\cdot \phi^{-1}\right)^n + A\cdot\phi \sum_n (x\cdot\phi)^n $
$ = A\cdot\phi^{-1} \sum_n (-\phi)^{-n} x^n + A\cdot\phi \sum_n \phi^n x^n $
$ = \sum_n A\cdot\left(\phi^{-1} (-\phi)^{-n} + \phi\cdot \phi^n \right) x^n $
$ = \sum_n A\cdot\left(\phi^{n+1} - (-\phi)^{-n-1}\right) x^n $
$ = \sum_n \frac{\phi^{n+1} - (-\phi)^{-n-1}}{\phi+\phi^{-1}} x^n $.</p>
<p>The term before $x^n$ is nothing but $f_n$, which gives the first closed form for the Fibonacci relation:</p>
<p>$$f_n=\frac{\phi^{n+1} - (-\phi)^{-n-1}}{\phi+\phi^{-1}}$$</p>
<h3 id="the-2nd-closed-form">The 2nd closed form</h3>
<p>Another way to crack the generating function is to apply the infinite series $\frac{1}{1-z} $ $= \sum_n z^n$ directly to the generating function:</p>
<p>$ \Phi(x) $
$ = \frac{1}{1 - x - x^2} $
$ = \frac{1}{1 - (x+x^2)}$
$ = \sum_n (x+x^2)^n $
$ = \sum_n (1+x)^n x^n $</p>
<p>Now we can use the Binomial formula: $(a + b)^n = \sum_{0\leq k\leq n} {n \choose k} a^{n-k} b^k$ in order to expand $(1+x)^n$:</p>
<p>$ = \sum_{0\leq k \leq n} {n \choose k} x^{n+k} $.</p>
<p>By introducing the new variable $t=n+k$, we obtain</p>
<p>$ = \sum_{t/2 \leq n \leq t} {n \choose t-n} x^t $
$ = \sum_t \sum_{n=t/2}^t {n \choose t-n} x^t $</p>
<p>Which gives us another closed form for the Fibonacci numbers:</p>
<p>$$f_t = \sum_{n=t/2}^t {n \choose t-n}$$</p>
<h2 id="conclusion">Conclusion</h2>
<p>Believe it or not, but the two forms define the same Fibonacci sequence.</p>
<p>$$ f_n = \frac{\phi^{n+1} - (-\phi)^{-n-1}}{\phi+\phi^{-1}} $$</p>
<p>$$ f_n = \sum_{i=n/2}^n {i \choose n-i} $$</p>
<p>Just notice how powerful the generating functions are!
An interested reader can try to prove by induction the correctness of the relations above.
If you liked the topic, you are wellcome to take a look at the next post where we extend the tools to mutivariate recursions.</p>
</description>
</item>
<item>
<title>Introduction to Dynamic Programming and Memoization</title>
<link>https://vchernoy.xyz/post/intro-to-dp/</link>
<pubDate>Tue, 04 Jul 2017 07:29:43 +0000</pubDate>
<guid>https://vchernoy.xyz/post/intro-to-dp/</guid>
<description>
<p>In the post, we discuss the basics of <em>Recursion</em>, <em>Dynamic Programming</em> (DP), and <em>Memoization</em>.
As an example, we take a combinatorial problem, which has very short and clear description.
This allows us to focus on DP and memoization.
Note that the topics are very popular in coding interviews.
Hopefully, this article will help to somebody to prepare for such types of questions.</p>
<p>In the next posts,
we consider more advanced topics, like
<a href="https://vchernoy.xyz/post/gen-func-art/">The Art of Generating Functions</a> and
<a href="https://vchernoy.xyz/post/two-var-recursive-func/">Cracking Multivariate Recursive Equations Using Generating Functions</a>.
The methods can be applied to the same combinatorial question.
Let&rsquo;s start from presenting the problem.</p>
<h2 id="the-problem">The Problem</h2>
<blockquote>
<p>Compute the number of ways to choose $m$ elements from $n$ elements such that selected elements in one combination are not adjacent.</p>
</blockquote>
<p>For example, for $n=4$ and $m=2$, the answer is $3$, since from the $4$-element set: $\lbrace 1,2,3,4 \rbrace$,
there are three feasible $2$-element combinations: $\lbrace 1,4 \rbrace$, $\lbrace 2,4 \rbrace$, $\lbrace 1,3 \rbrace$.</p>
<p>Another example: for $n=5$ and $m=3$, there is only one $3$-element combination: $\lbrace 1,3,5 \rbrace$.</p>
<p>If you are asked such question during coding interview, interviewer is, probably, expecting to cover with you the following topics:</p>
<ol>
<li><em>Brute Force</em> approach that generates and counts all the feasible combinations. It will work slow even for small input.</li>
<li>Recursive solution which counts the combination without generating them. It will work faster but still has exponential time ecomplexity.</li>
<li>Use DP or memoization techniques. In both cases, the time complexity becomes linear in $n$ and $m$.</li>
<li>Corner cases, recursion termination, call stack, testing.</li>
</ol>
<p>Let&rsquo;s talk about most important topics: building a recursive solution and optimize it using DP or memoization.</p>
<h2 id="recursive-relation">Recursive Relation</h2>
<p>Let&rsquo;s define $F_{n, m}$ to be the function that computes the answer for given $n$ and $m$.
Let&rsquo;s look at the $n, m$-task. We have two non-overlapping sub-tasks (or cases):</p>
<ul>
<li>Skip the $n$-th element, then $ F_{n, m} = F_{n-1, m} $.</li>
<li>Pick the $n$-th element, then $ F_{n, m} = F_{n-2, m-1} $.</li>
</ul>
<p>From the above, we can define the solution in the recursive form:</p>
<p>$ F_{n, m} $ $ = F_{n - 1, m} + F_{n - 2, m - 1} $,</p>
<p>let&rsquo;s not forget to write down the corner cases:</p>
<p>$F_{0, 0}$ $= F_{1, 1}$ $= 1$.</p>
<p>Basically, we can combine the general and the corner cases into one expression:</p>
<p>$$ F_{n, m} = F_{n - 1, m} + F_{n - 2, m - 1} + [n=m=0] + [n=m=1] $$</p>
<p>It is very common to define $F_{n,m} = 0$ for any negative $n$ or $m$.
The indicator $[P]$ gives $1$ if the predicate $P$ is true.
But what about special cases, like $n=1$ and $m=0$?
Actually, the relation covers this:</p>
<p>$F_{1,0}$ $= F_{0,0} + F_{-1,-1} + [1=0=1] + [1=m=1]$ $=F_{0,0} + 0 + 0 + 0$ $=[0=0=0]$ $=1$.</p>
<p>It is non-intuitive, but there is no need to add extra corner cases!</p>
<h2 id="memoization">Memoization</h2>
<p>The function $F_{n,m}$ has a straighforward recursive implementation in any programming language.
But the naive recursive solution will have exponential in $n$ time complexity and will be very slow.
Let&rsquo;s look at the following <code>Python 3</code> code snippet that uses memoization technique:</p>
<pre><code class="language-python">import functools
import sys
sys.setrecursionlimit(100000)
@functools.lru_cache(maxsize=None)
def f_mem(n, m):
if n &lt; 0 or m &lt; 0:
return 0
if n + 1 &lt; 2*m:
return 0
if n == m == 0:
return 1
if n == m == 1:
return 1
return f_mem(n - 1, m) + f_mem(n - 2, m - 1)
</code></pre>
<p>Nice <a href="https://docs.python.org/3/library/functools.html#functools.lru_cache" target="_blank">@functools.lru_cache</a>
notation creates a wrapper on the <code>f_mem</code> function and internally caches the results of all calls.
Such caching (or memoization) significantly improves the speed of the recursion,
and basically reduces the number of calls to something like $O(n \cdot m)$.
The recursion still may fail on the stack overflow even on relatively small values of $n$.
That is why we increase the stack size for the Python interpreter by calling
<a href="https://docs.python.org/3/library/sys.html#sys.setrecursionlimit" target="_blank">sys.setrecursionlimit()-method</a>.</p>
<h2 id="dynamic-programming-dp">Dynamic Programming (DP)</h2>
<p>The next iterative implementation uses DP.
Basically, it fills out the $n \times m$ table starting from the low values of $n$ and $m$.</p>
<pre><code class="language-python">def f_dp(n, m):
assert n &gt;= 0 and m &gt;= 0
if n + 1 &lt; 2*m:
return 0
table = [[0] * (m + 1) for _ in range(n + 1)]
table[0][0] = 1
if n &gt;= 1:
table[1][0] = 1
if m &gt;= 1:
table[1][1] = 1
for i in range(2, n+1):
table[i][0] = 1
for j in range(1, min(m, (i + 1) // 2) + 1):
table[i][j] = table[i-1][j] + table[i-2][j-1]
return table[n][m]
</code></pre>
<p>The time and space complexities are $O(n\cdot m) $.
More accurately, time complexity might be bounded by $\Theta(n\cdot \min(n,m))$.
It is not hard to notice that the space consumption could be reduced to $O(\min(n, m))$.</p>
<h2 id="testing">Testing</h2>
<p>In order to measure the time and to check the correctness, let&rsquo;s write the following helper function:</p>
<pre><code class="language-python">import timeit
M = 1000**3 + 7
def test(n, m, funcs, number=1, module=__name__):
f_mem.cache_clear()
results = []
func_times = []
for func in funcs:
stmt='{}({},{})'.format(func.__name__, n, m)
setup='from {} import {}'.format(module, func.__name__)
t = timeit.timeit(stmt=stmt, setup=setup, number=number)
func_times.append(t)
results.append(func(n, m) % M)
assert len(set(results)) &lt;= 1
if results:
print('f({},{}): {}'.format(n, m, results[0]))
best_time = min(func_times)
for i, func in enumerate(funcs):
func_time = func_times[i]
print('{:&gt;13}: {:8.4f} sec, x {:.2f}'.format(func.__name__, func_time, func_time/best_time))
</code></pre>
<p>We can run it as following:</p>
<pre><code class="language-python">test(n=6000, m=2000, funcs=[f_mem, f_dp])
</code></pre>
<p>Which prints output similar to this one:</p>
<pre><code>f(6000,2000): 192496093
f_mem: 6.5852 sec, x 1.28
f_dp: 5.1507 sec, x 1.00
</code></pre>
<p>The function <code>test()</code> computes $F_{6000, 2000}$ using two different ways.
It validates that all the solutions are consistent and produce the same result.
It also measures the time it takes to execute each method.
For each run, it also prints the relative factor computed based on the fastest solution.
The function&rsquo;s result is printed modulo $M=1000^3+7$.</p>
<p>Memoization and DP techniques have $\Theta(n \cdot m)$ time complexity.
Note that we ignore here a lot of interesting details, for example,
Python has built-in long arithmetics for integers, which is used here and definetely not cheap.</p>
<p>Testing the implementations on different parameters,
we may notice that there is no clear winner between the two algorithms.
Probably, most of the time is spent on the long arithmetic computation.</p>
<p>Can we do even better?
Definitely, we can!
The table (or cache) could be preprocessed.
Then computing the function $F_{n,m}$ will require just one query from the table (or from the cache, for memoization solution).
As we mentioned before, such approach requires $\Theta(n\cdot m)$ space, which could be huge for $n=10000$.
Actually, there is a way, how we can remain in very low memory consuption and still get very fast solution.
We will discuss this in the next two posts.</p>
</description>
</item>
</channel>
</rss>