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:

  1. Can the problem be expressed using smaller instances of itself?

  2. Are the resulting subproblems independent?

  3. 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!\).

  1. Write the base case, the recursive case.

  2. Implement and test.

Correction
  1. \[\begin{split}\begin{cases} 0! = 1 \\ N! = (N-1)! \, N \end{cases}\end{split}\]
  2. 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\).

\[\left( \underbrace{ \left( \underbrace{ \left( \underbrace{A_n} \right) \cdot x + A_{n-1} } \right) \cdot x + A_{n-2} }\cdots \right) \cdot x + A_0\]
  1. Write the base case, the recursive case.

  2. 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
  1. The sequence of coefficients is noted \(s\)

\[\begin{split}\begin{cases} \textrm{horner}([\emptyset], x) = 0 \\ \textrm{horner}(s_{\textrm{without last}} ,x) \cdot x + s_{\textrm{last}} \end{cases}\end{split}\]
  1. Two possible implementations:

src
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
tests
 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    )
src
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()
tests
 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:

\[\det(A)=\sum _{j=1}^{n}(-1)^{i+j}a_{ij}M_{ij}\]

If you do not understand this definition, take a look at this example.

  1. Write the base case, the recursive case.

  2. Implement and test.

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

Laplace expansion

Determinant by Laplace expansion

Correction
\[\begin{split}\begin{cases} \det({\textrm{scalar}}) = \textrm{scalar} \\ \det(A) = \sum _{j=0}^{n-1}(-1)^{j}a_{0j} \det( A_{\textrm{without row 0 and column j}} ) \end{cases}\end{split}\]
import numpy as np
import numpy.typing as npt

type Matrix = npt.NDArray
src
 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
tests
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)
  1. 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.

  1. Write the base case, the recursive case.

  2. 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
  1. 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.

src
 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
tests
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
src
 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)
tests
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([[]])) == []