In the previous chapter, we introduced the concept of recursion and saw how functions can call themselves to solve problems. In this chapter, we’ll explore a few more problems that can be solved using recursion, which will help us develop our ability to “think recursively.”
Thinking Recursively¶
Solving problems using recursion requires a shift in how we look at problems. Instead of thinking about all the steps needed to reach the solution, we focus on:
How to express the problem in terms of a smaller version of the same problem
When to stop the recursion (base case)
Let’s explore four problems that illustrate the power of recursion well: the staircase problem, exponentiation by squaring, computing a square root using Heron’s method, and the binomial coefficient.
The Staircase Problem¶
Imagine a staircase with steps. You can climb 1 or 2 steps at a time. In how many different ways can you reach the top of the staircase?
For example, if we have a staircase with 3 steps, there are 3 ways to climb it:
Take three steps of 1: (1, 1, 1)
Take a step of 1 followed by a step of 2: (1, 2)
Take a step of 2 followed by a step of 1: (2, 1)
How can we think about this problem recursively? To reach step , we must have come from step (by taking a step of 1) or from step (by taking a step of 2). Therefore, the total number of ways to reach step is the sum of the number of ways to reach step and step .
Our base cases are:
If (no steps): there is 1 way (don’t climb at all)
If (one step): there is 1 way (take a step of 1)
Let’s implement this solution:
function maneiras_subir_escada(n)
# Casos base
if n == 0 || n == 1
return 1
else
# Caso recursivo: soma das maneiras de chegar a partir de n-1 e n-2
return maneiras_subir_escada(n - 1) + maneiras_subir_escada(n - 2)
end
endmaneiras_subir_escada (generic function with 1 method)Let’s test our code for different numbers of steps:
for i in 1:10
println("Escada com $i degraus: $(maneiras_subir_escada(i)) maneiras diferentes")
endEscada com 1 degraus: 1 maneiras diferentes
Escada com 2 degraus: 2 maneiras diferentes
Escada com 3 degraus: 3 maneiras diferentes
Escada com 4 degraus: 5 maneiras diferentes
Escada com 5 degraus: 8 maneiras diferentes
Escada com 6 degraus: 13 maneiras diferentes
Escada com 7 degraus: 21 maneiras diferentes
Escada com 8 degraus: 34 maneiras diferentes
Escada com 9 degraus: 55 maneiras diferentes
Escada com 10 degraus: 89 maneiras diferentes
Note that we’re using a different structure here, for. Don’t worry about the details of this structure for now, but know that we’re simply repeating the execution of a block of code.
If you look closely at this sequence of results, you’ll notice that it corresponds to the famous Fibonacci sequence: . This is no coincidence. The staircase problem and the Fibonacci sequence share the same recursive structure.
Exponentiation by Squaring¶
When we want to compute powers like , the simplest approach would be to multiply by itself times. For example, to compute 28, we would do , performing 7 multiplications. But there is a much more efficient approach using recursion.
The exponentiation-by-squaring method we’re about to present can compute the same value using only about operations. For example, to compute 28, we would need only 3 multiplications. For larger numbers, this difference is even more significant: computing 21000 would require 999 multiplications with the simple method, but only about 10 multiplications with our recursive method.
The idea is based on the following mathematical properties:
If is negative: (we invert the result of the positive power)
If is even: (we compute “half” the power and square it)
If is odd: (we multiply by an even power, which we know how to compute)
How does this translate into recursive thinking?
If we want to compute and is even:
First we compute (a smaller problem)
Then we multiply that result by itself
If we want to compute and is odd:
First we compute (which is even, and we know how to solve it using the previous case)
Then we multiply that result by
If we want to compute and is negative:
We compute (we know how to solve this using the previous cases)
Then we compute
Our base cases (where the recursion stops) are:
If , then (any number raised to 0 is 1)
If , then (any number raised to 1 is itself)
Let’s implement this solution:
function potenciacao(base, expoente)
# Resolvemos o expoente negativo primeiro
if expoente < 0
return 1 ÷ potenciacao(base, -expoente)
end
if expoente == 0
return 1
elseif expoente == 1
return base
end
# Se o expoente for par
if expoente % 2 == 0
temp = potenciacao(base, expoente ÷ 2)
return temp * temp
else
# Se o expoente for ímpar
return base * potenciacao(base, expoente - 1)
end
endpotenciacao (generic function with 1 method)Let’s test our function:
println(potenciacao(2, 10)) # Deve retornar 1024
println(potenciacao(3, 5)) # Deve retornar 2431024
243
To better understand how this approach saves operations, let’s trace the execution of potenciacao(2, 10):
Step 1: potenciacao(2, 10)
expoente = 10is evenWe need to compute
potenciacao(2, 5)^2
Step 2: potenciacao(2, 5)
expoente = 5is oddWe need to compute
2 * potenciacao(2, 4)
Step 3: potenciacao(2, 4)
expoente = 4is evenWe need to compute
potenciacao(2, 2)^2
Step 4: potenciacao(2, 2)
expoente = 2is evenWe need to compute
potenciacao(2, 1)^2
Step 5: potenciacao(2, 1)
expoente = 1is oddWe need to compute
2 * potenciacao(2, 0)
Step 6: potenciacao(2, 0)
base case, returns
1
Returned values:
potenciacao(2, 0)returns1potenciacao(2, 1)returns2 * 1 = 2potenciacao(2, 2)returns2^2 = 4potenciacao(2, 4)returns4^2 = 16potenciacao(2, 5)returns2 * 16 = 32potenciacao(2, 10)returns32^2 = 1024
For computing enthusiasts, when we talk about complexity we’re referring to the efficiency of an algorithm in terms of the number of operations performed. To represent the amount of operations, we use Big-O notation, symbolized as .
In this context, the approach we used manages to reduce the complexity from (where the number of operations grows linearly with the size of the input) to (where the number of operations grows logarithmically, making the algorithm much more efficient for large inputs).
Computing the Square Root (Heron’s Method)¶
Heron’s method (also known as the Babylonian method) is an ancient algorithm for computing approximations of square roots. The idea is to start with an estimate and progressively improve it.
Let’s compute an approximation for . If is our initial estimate, we can improve our estimate using the following iterative formula:
Let be the error in our estimate of . Then, . Expanding the binomial, we get
We can solve the equation above for .
Thus, we can compensate for the error and update our old estimate as
Since the computed error was not exact, this is not the final answer, but it becomes our new estimate to use in the next iteration. The update process is repeated until the desired precision is achieved.
Let’s implement this method recursively:
function raiz_quadrada(S, xₙ = S / 2, ε = 0.0001)
xₙ₊₁ = (xₙ + S / xₙ) / 2
# Verificamos se a diferença entre as estimativas é menor que a precisão desejada
if abs(xₙ₊₁ - xₙ) < ε
return xₙ₊₁
else
return raiz_quadrada(S, xₙ₊₁, ε)
end
endraiz_quadrada (generic function with 3 methods)The function takes three parameters:
S: the number whose square root we wantxₙ: our current estimate (by default, we start with half the number)ε: how close two consecutive estimates must be for us to consider that we’ve found the answer (by default, we’re using0.0001)
Let’s test our function:
println(raiz_quadrada(25)) # Deve ser próximo de 5
println(raiz_quadrada(2)) # Deve ser próximo de 1.4142...5.000000000016778
1.4142135623746899
Notice what happens to the estimate’s value when computing the square root of 25:
First call:
x₀ = 25/2 = 12.5Second call:
x₁ = (12.5 + 25/12.5)/2 = 7.25Third call:
x₂ = (7.25 + 25/7.25)/2 = 5.35Fourth call:
x₃ = (5.35 + 25/5.35)/2 = 5.01Fifth call:
x₄ = (5.01 + 25/5.01)/2 = 5.0
Binomial Coefficient¶
The binomial coefficient (read as “n choose k”) represents the number of ways to choose elements from a set of elements, without regard to order. For example, is the number of ways to choose 2 elements from a set of 5 elements.
The binomial coefficient has the following recursive definition (for ):
This formula can be derived by splitting the problem into two complementary cases. Consider a specific element among the available options. We can classify all possible subsets of elements into two categories:
Subsets that include : In this case, since is already selected, we only need to choose additional elements from the remaining elements. This corresponds to possibilities.
Subsets that do not include : In this case, we must select all elements from the remaining elements (excluding ). This corresponds to possibilities.
The total number of possible subsets is the sum of these two cases, which justifies the recursive formula presented above.
We can determine the base cases from the following properties:
for any (there is only one way to choose 0 elements)
for any (there is only one way to choose all the elements)
Let’s implement this solution:
function coeficiente_binomial(n, k)
if k == 0 || k == n
return 1
else
return coeficiente_binomial(n - 1, k - 1) + coeficiente_binomial(n - 1, k)
end
endcoeficiente_binomial (generic function with 1 method)Let’s test our function:
println(coeficiente_binomial(5, 2)) # Deve retornar 10
println(coeficiente_binomial(10, 4)) # Deve retornar 21010
210