Recursion¶
This section and the following ones develop three fundamental approaches to algorithm design:
recursion
divide and conquer
dynamic programming
The emphasis is on algorithmic reasoning rather than programming syntax:
identify recursive structures
formulate appropriate base cases
distinguish recursion from divide and conquer
recognize overlapping subproblems
define dynamic programming states
reason about algorithmic complexity
A useful guiding principle is to ask:
Can the problem be expressed using smaller instances of itself?
Are the resulting subproblems independent?
Do identical subproblems occur repeatedly?
The answers often suggest the appropriate algorithmic paradigm.
Definition¶
A recursive algorithm solves a problem by reducing it to one or more smaller instances of the same problem.
Every recursive algorithm requires:
a base case, solved directly;
a recursive case, reducing the problem size.
Correct recursive algorithms require both progress and termination.
During the descent phase, the problem is decomposed. During the return phase, partial solutions are combined.
Exercise (easy)
The canonical problem to introduce recursion is the computation of \(N!\).
Write the base case, the recursive case.
Implement and test.
Correction
- \[\begin{split}\begin{cases} 0! = 1 \\ N! = (N-1)! \, N \end{cases}\end{split}\]
- src¶
1def factorial(number: int) -> int: 2 if number < 0: 3 raise ValueError("negative in factorial") 4 if number == 0: 5 return 1 6 return number * factorial(number - 1)
tests¶1@pytest.mark.parametrize("number", (0, 3, 9)) 2def test_factorial(number: int) -> None: 3 assert factorial(number) == math.factorial(number)
tests¶1def test_factorial_error() -> None: 2 with pytest.raises(ValueError): 3 factorial(-1)
Exercise (easy)
A method to evaluate the numerical value of a polynomial is described below. A sequence contains the polynomial coefficients. The coefficients are sorted in descending powers of the polynomial: the first coefficient \(A_n\) is for the highest power term \(x^n\).
Write the base case, the recursive case.
Implement and test.
Hint
The underbrace indicates successive return values of the function.
The function np.polyval does the same job as Horner’s method.
Correction
The sequence of coefficients is noted \(s\)
Two possible implementations:
1def horner(coefficients: list[float], abscissa: float) -> float:
2 if not coefficients:
3 return 0.0
4 coefficients_next: list[float] = coefficients.copy()
5 last: float = coefficients_next.pop()
6 return horner(coefficients_next, abscissa) * abscissa + last
1@pytest.mark.parametrize(
2 "coefficients, abscissa",
3 (
4 ([], 5.0),
5 ([3.0, 5.0, -1.0], 3.0),
6 ([-2.0, 7.0, 1.0, -1.0], 4.0),
7 ),
8)
9def test_horner(coefficients: list[float], abscissa: float) -> None:
10 assert pytest.approx(horner(coefficients, abscissa)) == np.polyval(
11 coefficients, abscissa
12 )
1def horner_no_copy(coefficients_: list[float], abscissa: float) -> float:
2 def horner_nested() -> float:
3 if not coefficients:
4 return 0.0
5 last: float = coefficients.pop()
6 return horner_nested() * abscissa + last
7
8 coefficients = coefficients_.copy()
9 return horner_nested()
1@pytest.mark.parametrize(
2 "coefficients, abscissa",
3 (
4 ([], 5.0),
5 ([3.0, 5.0, -1.0], 3.0),
6 ([-2.0, 7.0, 1.0, -1.0], 4.0),
7 ),
8)
9def test_horner_no_copy(coefficients: list[float], abscissa: float) -> None:
10 assert pytest.approx(horner_no_copy(coefficients, abscissa)) == np.polyval(
11 coefficients, abscissa
12 )
Exercise (intermediate)
The Laplace expansion expresses the determinant of a matrix in terms of its minors. The minor \(M_{i,j}\) is defined to be the determinant of the \((n-1) \times (n-1)\) matrix that results from \(A\) by removing the \(i\) th row and the \(j\) th column.
For every \(i\), so just choose one arbitrarily the first row, one has the equality:
If you do not understand this definition, take a look at this example.
Write the base case, the recursive case.
Implement and test.
Complexity. Assuming it takes in average 2 s to compute the determinant of a matrix of size 10. How long will it take for a matrix of size 20?
Hint
Careful, the math index notation starts at 1 whereas python indexes start at 0.
The function np.linalg.det computes the determinant.
Determinant by Laplace expansion¶
Correction
import numpy as np
import numpy.typing as npt
type Matrix = npt.NDArray
1def determinant(matrix: Matrix) -> float:
2 """determinant by laplace expansion on the first row"""
3 nb_rows, nb_columns = matrix.shape
4 if nb_rows != nb_columns:
5 raise ValueError("non square matrix in determinant")
6 if nb_rows == 1:
7 return matrix[0, 0]
8 det: float = 0.0
9 for ind_col in range(nb_rows):
10 left: Matrix = matrix[1:nb_rows, :ind_col]
11 right: Matrix = matrix[1:nb_rows, (ind_col + 1) :]
12 det += (
13 (-1) ** ind_col * matrix[0, ind_col] * determinant(np.hstack((left, right)))
14 )
15 return det
1def test_determinant() -> None:
2 nb_rows = 5
3 matrix = np.random.random((nb_rows, nb_rows))
4 assert pytest.approx(determinant(matrix)) == np.linalg.det(matrix)
Remember that:
\[e - 1 = \sum_{i=1}^\infty \cfrac{1}{i!}\]Let’s define the costly operation as the computation of \((-1)^{i+j}a_{ij}\) times the scalar value of whatever is the results of \(M_{ij}\). This does not include all the operations required to get this \(M_{ij}\). Here, \(M_{ij}\) is just seen as a scalar. This costly operation has a constant time which does not depend on the problem size. The idea is now to count as function of \(n\) the number of times this operation occurs.
For a \(n\times n\) matrix, the first level of recursion makes \(n\) calls. At every call, the costly operation takes place. The second level of recursion makes \(n-1\) calls for each case of the first level of recursion. The third level makes \(n-2\) calls per case of level \(n-1\), and so on.
At the deepest level of recursion the determinant of a scalar is the scalar itself. It terminates the chain of recursion. At this bottom level, \(M_{ij}\) is truly a scalar (the determinant of a scalar is himself).
So, all things considered, you can just count the number of costly operations:
\[\begin{split}\mathcal{n} &= n + n(n-1) + n(n-1)(n-2) + \cdots + n(n-1)\cdots4\times3 + n! \\ &= n! \underbrace{\left(\cfrac{1}{(n-1)!} + \cfrac{1}{(n-2)!} + \cfrac{1}{(n-3)!} + \cdots + \cfrac{1}{2} + 1\right)}_{ \textrm{asymptotically } e - 1}\end{split}\]It leads to a number of costly operations proportional to \(n!\). This would lead to a catastrophic explosion of computing time for larger and larger matrices.
\[2\textrm{s} \times \cfrac{20!}{10!}\approx 40\,000 \textrm{years}\]
Exercise (intermediate)
Obtain all possible permutations of a sequence.
Write the base case, the recursive case.
Implement and test.
Hint
The recursive case needs some thinking. Our permutate function is expected to return
every permutation of a given sequence.
Thus, this function is able to return every permutation of the sequence without its last element.
Assuming that your function works, and that you have at your disposal all the permutations of
the sequence without its last element,
how can you produce all the permutations possible with the last element?
Correction
The answer to the question in the hint above is: you just have to insert this element at every location possible, for every permutated sequence without the last element:
initial sequence [1, 2, 3]
all permutations without last elt [[1, 2]; [2, 1]]
all sequences produced by inserted 3 at every location [[3, 1, 2], [1, 3, 2], [1, 2, 3]; [3, 2, 1], [2, 3,1], [2, 1, 3]]
The base case is obvious, for a empty sequence or a singleton, there is a single “permutation”: the original sequence.
1def permutate[T](seq: list[T]) -> list[list[T]]:
2 """all permutations of a sequence obtained:
3
4 [x, y, z] drop last
5
6 [ [x, y], [y, x] ] get every permutation of remaining
7
8 insert last at every possible location
9 [
10 [z, x, y], [x, z, y], [x, y, z],
11 [z, y, x], [y, z, x], [y, x, z]
12 ]"""
13 nb_elts: int = len(seq)
14 if nb_elts <= 1:
15 return [seq]
16
17 seq_without_last_elt: list[T] = seq.copy()
18 elt: T = seq_without_last_elt.pop()
19 all_permutated_seqs_with_elt_at_every_location: list[list[T]] = []
20 all_permutated_seqs_with_last_elt_missing: list[list[T]] = permutate(
21 seq_without_last_elt
22 )
23 for permutated_seq in all_permutated_seqs_with_last_elt_missing:
24 nb_insert_locations: int = len(permutated_seq) + 1
25 for ind in range(nb_insert_locations):
26 permutated_seq_with_elt_inserted = (
27 permutated_seq[:ind] + [elt] + permutated_seq[ind:]
28 )
29 all_permutated_seqs_with_elt_at_every_location.append(
30 permutated_seq_with_elt_inserted
31 )
32 return all_permutated_seqs_with_elt_at_every_location
1def test_permutate() -> None:
2 seq: list[int] = list(range(5))
3 assert {tuple(permutated_seq) for permutated_seq in permutate(seq)} == set(
4 permutations(seq)
5 )
6 assert permutate([]) == [[]]
Exercise (intermediate)
Create a generator of the cartesian product of a sequence of iterables. Test it.
Correction
1def cartesian_product[T](iterables: Sequence[Iterable[T]]) -> Generator[list[T]]:
2 """yields every combination of element picked in an arbitrary number of iterables:
3 [[1, 2], [3, 4]]
4
5 [1, 3]; [1, 4]; [2, 3]; [2, 4]"""
6 nb_iterables: int = len(iterables)
7
8 def _nested_cartesian_product(
9 product: list[T], index_iterable: int
10 ) -> Generator[list[T]]:
11 """product is one of the future yielded iterable under construction,
12 an item is picked in the first, then assembled to an item picked in the second, etc
13
14 until an item is picked in the last iterable, then the product can be yielded"""
15 if index_iterable == nb_iterables:
16 yield product
17 else:
18 for item in iterables[index_iterable]:
19 yield from _nested_cartesian_product(
20 product + [item], index_iterable + 1
21 )
22
23 return _nested_cartesian_product([], 0)
1def test_cartesian_product() -> None:
2 seqs: list[list[int]] = [list(range(5)), list(range(3)), list(range(4))]
3 obtained = {tuple(seq) for seq in cartesian_product(seqs)}
4 expected = {tuple(seq) for seq in product(*seqs)}
5 assert obtained == expected
6 assert list(cartesian_product([[]])) == []