Asd Chap 01 2024
Asd Chap 01 2024
CHAPTER 1
Algorithm Analysis
I.1. Introduction
This chapter focuses on the analysis of execution time as well as the smooth running of algorithms.
Analysis of an algorithm is the same thing as estimating the efficiency of the algorithm. There are
two fundamental parameters based on which we can analyze the algorithm and they are Space and
Time Complexity. There is also the concept in time complexity of estimating the running time of
an algorithm and we have the Worst-case, Average-case, and Best-case. We will introduce several
tools to accomplish such an analysis: asymptotic notation (O, Θ, Ω). These notions will be
accompanied by examples.
• Linear data structures: Elements are accessed in a sequential order but it is not
compulsory to store all elements sequentially (say, Linked Lists). Examples: Linked
Lists, Stacks and Queues.
Integer
Real
Primitive data
structure
Character
Arrays
Boolean
Data structure Linked
Linear data
structures
Stacks
Non-primitive data
Queues
structures
Trees
Non-linear data
structures
Graphs
Figure 1.1 : Classifications of Data Structures.
I.3. What is an Algorithm?
Algorithm is a step-by-step procedure to solve a problem, where each step indicates an intermediate
task. Algorithm contains a finite number of instructions to be executed in a certain order to get the
desired output. Algorithms are generally created independent of underlying languages, i.e., an
algorithm can be implemented in more than one programming language.
• Scalability: Good algorithms can handle varying input sizes without a significant increase
in resources.
I.3.3. Disadvantages of Algorithms
• Complexity: Designing and implementing algorithms can be complex, especially for
intricate problems.
• Branching and Looping statements are difficult to show in Algorithms.
Usually, program execution time (program run-time) is referred to as time complexity, denoted by
𝑻𝒑 (instance characteristics). This is the sum of the execution times of all program instructions.
Accurately estimating the execution time is a complex task, since the number of instructions
executed depends on the input data. What's more, the various instructions take varying amounts
of time to execute. So, when estimating time complexity, we only count the number of program
steps.
Let 𝓐 be an algorithm and 𝒇 the function such that 𝒇(𝒙) denotes the number of elementary
operations performed by 𝓐 on input 𝒙. The execution time of 𝓐 in the worst case is the function
𝑻𝒎𝒂𝒙 ∶ ℕ → ℕ such that:
In other words, 𝑻(𝒏) indicates the largest number of operations performed among all inputs of
size 𝒏.
Example:
The code below finds the sum of 𝒏 numbers. Determine the number of steps in this program.
We consider the following operations to be elementary: assignment, comparison, addition and
access to an element of a sequence.
return s;} 0 / - 0
Intuitively, then, Algorithm (function) works in linear time concerning n, i.e., whatever the input,
the execution time is approximately n except for certain constants.
For some algorithms, the "size" of an input depends on several parameters, e.g., the number of
rows 𝒎 and columns 𝒏 of a matrix; the number of elements 𝒎 of a sequence and the number of
bits 𝒏 of its elements; the number of vertices 𝒎 and edges 𝒏 of a graph, and so on. For these
algorithms, the notion of time is naturally extended:
Example:
The code below calculates the addition of two matrices of size 𝒎 × 𝒏. Determine the number of
steps in this program. We consider the following operations to be elementary: assignment,
comparison, addition and access to an element of a matrix.
𝑶(𝒇) notation represents the complexity of an algorithm, which is also termed an Asymptotic
notation or "Big O" notation. Here, the 𝒇 corresponds to the function whose size is the same as
that of the input data. The complexity of the asymptotic computation 𝑶(𝒇) determines in which
order the resources such as CPU time, memory, etc. are consumed by the algorithm that is
articulated as a function of the size of the input data.
The complexity can be found in any form such as constant, logarithmic, linear, 𝒏 ∗ 𝒍𝒐𝒈(𝒏),
quadratic, cubic, exponential, factorial etc. To make it even more precise, we often call the
complexity of an algorithm "running time".
A problem may have various algorithmic solutions. In order to choose the best algorithm for a
particular process, you must be able to judge the time taken to run a particular solution. More
precisely, you must be able to judge the time it takes to run two solutions and choose the best one.
To select the best algorithm, it is necessary to check the efficiency of each algorithm. The efficiency
of each algorithm can be verified by calculating its time complexity. Asymptotic notations are used
to represent time complexity in an abbreviated form. It can generally be represented as the fastest
possible, the slowest possible or the average possible.
The notations such as O (Big-O), Θ (Theta), and Ω (Omega) are called as asymptotic notations.
These are the mathematical notations that are used in three different cases of time complexity.
Big-O notation is a mathematical notation used to describe the upper bound or worst-case scenario
of the time complexity or space complexity of an algorithm in computer science. It provides a way
to classify algorithms based on how their runtime or space requirements grow as the input size
increases. Generally, it is represented as f(n) = O(g(n)). That means, at larger values of n, the
upper bound of f(n) is g(n).
Definition I.1
We consider functions from ℕ to ℝ that are eventually positive, i.e., functions belonging to:
ℱ = {𝑓: ℕ → ℝ ∶ ∃ 𝑚 ∈ ℕ, ∀𝑛 ≥ 𝑚 𝑓(𝑛) > 0}
Let 𝑔 ∈ ℱ. The set 𝓞(𝒈) is defined by:
𝒪(𝑔) = {𝑓 ∈ ℱ: ∃ 𝑐 ∈ ℝ + , ∃ 𝑛0 ∈ ℕ, ∀𝑛 ≥ 𝑛0 𝑓(𝑛) ≤ 𝑐. 𝑔(𝑛)}
➢ Intuitively, 𝒇 ∈ 𝓞(𝒈) indicates that 𝒇 increases as quickly or less quickly than the
function 𝒈.
➢ We call the values c and 𝒏𝟎 a multiplicative constant and a threshold.
The graphical representation of f(n) = O(g(n)) is shown in figure 1.2, where the running time
increases considerably when n increases.
Rate of Growth
Input Size
= 𝟕𝒏𝟐
Thus, taking 𝐜 = 𝟕 as a multiplicative constant and 𝒏𝟎 = 𝟒𝟎𝟎 as a threshold, we conclude that
𝒇 ∈ 𝓞(𝒏𝟐 ).
The most common complexity classes (in increasing order) are the following:
As a representative, we choose the function which gives the class its name e.g., for 𝓞(𝒏) we choose
the function 𝒇(𝒏) = 𝒏, for 𝓞(𝒍𝒐𝒈𝟐 𝒏) we choose 𝒇(𝒏) = 𝒍𝒐𝒈𝟐 𝒏, and so on. Let’s assume we
have algorithms with these functions describing their complexity. Table 1.1 lists how many
operations it will take them to deal with a problem of a given size:
𝒇(𝒏) 𝒏=𝟒 𝒏 = 𝟏𝟔 𝒏 = 𝟐𝟓𝟔 𝒏 = 𝟏𝟎𝟐𝟒 𝒏 = 𝟏𝟎𝟒𝟖𝟓𝟕𝟔
1 1 1 1 𝟏. 𝟎𝟎 × 𝟏𝟎𝟎 𝟏. 𝟎𝟎 × 𝟏𝟎𝟎
𝒍𝒐𝒈𝟐 𝒍𝒐𝒈𝟐 𝒏 1 2 3 𝟑. 𝟑𝟐 × 𝟏𝟎𝟎 𝟒. 𝟑𝟐 × 𝟏𝟎𝟎
𝒍𝒐𝒈𝟐 𝒏 2 4 8 𝟏. 𝟎𝟎 × 𝟏𝟎𝟏 𝟐. 𝟎𝟎 × 𝟏𝟎𝟏
𝒏 4 16 𝟐. 𝟓𝟔 × 𝟏𝟎𝟐 𝟏. 𝟎𝟐 × 𝟏𝟎𝟑 𝟏. 𝟎𝟓 × 𝟏𝟎𝟔
𝒏𝒍𝒐𝒈𝟐 𝒏 8 64 𝟐. 𝟎𝟓 × 𝟏𝟎𝟑 𝟏. 𝟎𝟐 × 𝟏𝟎𝟒 𝟐. 𝟏𝟎 × 𝟏𝟎𝟕
𝒏𝟐 16 256 𝟔. 𝟓𝟓 × 𝟏𝟎𝟒 𝟏. 𝟎𝟓 × 𝟏𝟎𝟔 𝟏. 𝟏𝟎 × 𝟏𝟎𝟏𝟐
𝒏𝟑 64 4096 𝟏. 𝟔𝟖 × 𝟏𝟎 𝟕
𝟏. 𝟎𝟕 × 𝟏𝟎𝟗 𝟏. 𝟏𝟓 × 𝟏𝟎𝟏𝟖
𝟐𝒏 16 65536 𝟏. 𝟏𝟔 × 𝟏𝟎𝟕𝟕 𝟏. 𝟖𝟎 × 𝟏𝟎𝟑𝟎𝟖 𝟔. 𝟕𝟒 × 𝟏𝟎𝟑𝟏𝟓𝟔𝟓𝟐
Table 1.1: Number of operations.
Some of these numbers are so large that it is rather difficult to imagine just how long a time span
they describe. Hence, Table 1.2 gives time spans rather than instruction counts, based on the
assumption that we have a computer which can operate at a speed of 1 MIPS (MIPS : a million
instructions per second):
𝒇(𝒏) 𝒏=𝟒 𝒏 = 𝟏𝟔 𝒏 = 𝟐𝟓𝟔 𝒏 = 𝟏𝟎𝟐𝟒 𝒏 = 𝟏𝟎𝟒𝟖𝟓𝟕𝟔
1 1 sec 1 sec 1 sec 𝟏 sec 𝟏 sec
𝒍𝒐𝒈𝟐 𝒍𝒐𝒈𝟐 𝒏 1 sec 2 sec 3 sec 𝟑. 𝟑𝟐 sec 𝟒. 𝟑𝟐 sec
𝒍𝒐𝒈𝟐 𝒏 2 sec 4 sec 8 sec 𝟏𝟎 sec 𝟐𝟎 sec
𝒏 4 sec 16 sec 𝟐. 𝟓𝟔 sec 𝟏. 𝟎𝟐 msec 𝟏. 𝟎𝟓 sec
𝒏𝒍𝒐𝒈𝟐 𝒏 8 sec 64 sec 𝟐. 𝟎𝟓 msec 𝟏𝟎. 𝟐 msec 𝟐𝟏 sec
𝒏𝟐 16 sec 256 sec 𝟔𝟓. 𝟓 msec 𝟏. 𝟎𝟓 sec 𝟏. 𝟖 wk
𝒏𝟑 64 sec 4.1 msec 𝟏𝟔. 𝟖 sec 𝟏𝟕. 𝟗 min 𝟑𝟔. 𝟓𝟓𝟗 yr
𝟐𝒏 16 sec 65.5 msec 𝟑. 𝟕 × 𝟏𝟎 yr 𝟓. 𝟕 × 𝟏𝟎𝟐𝟗𝟒 yr
𝟔𝟑
𝟐. 𝟏 × 𝟏𝟎𝟑𝟏𝟓𝟔𝟑𝟗 yr
Table 1.2: Time spent.
It is clear that, as the sizes of the problems get really big, there can be huge differences in the time
it takes to run algorithms from different complexity classes. For algorithms with exponential
complexity, 𝑶(𝟐𝒏 ), even modest-sized problems have run times that are greater than the age of
the universe (about 1,4 × 1010 years), and current computers rarely run uninterrupted for more
than a few years. This is why complexity classes are so important they tell us how feasible it is likely
to be to run a program with a particularly large number of data items. Typically, people do not
worry much about complexity for sizes below 10, or maybe 20, but the above numbers make it
clear why it is worth thinking about complexity classes where bigger applications are concerned.
To understand the concept of asymptotic notations fully, let us look at some of the general
properties of asymptotic notations.
b) Reflexive
Given 𝒇(𝒏), 𝒕𝒉𝒆𝒏 𝒇(𝒏) = 𝓞(𝒇(𝒏)) , since maximum value of 𝒇(𝒏) will be 𝒇(𝒏) itself, i.e., every
function is an upper-bound of itself.
c) Transitive
Big-O notation has certain limitations when it comes to expressing the complexity of algorithms.
These limitations are as follows:
• Uniform Cost Model: Assumes that all basic instruction (such as arithmetic, comparisons,
assignments, . . . ) have the same cost. This simplifies the analysis by treating each operation
as taking constant time, represented by the notation O(1).
• Input size: Analysis is generally performed based on the input size, often referred to as
“n”, which represents the number of elements or the size of the input data structure.
• Worst-case scenario: Complexity analysis often focuses on the worst-case input, i.e.,
assuming the input that leads to the maximum number of operations.
• Loops: each iteration of a loop adds the complexity of what is done in the loop body.
• Functions (procedures): each function call adds the complexity of that function to the
total complexity.
In algorithm analysis, particularly when using Big-O notation, there are simplification rules that are
commonly employed to analyze the time and space complexity of algorithms. Here are some
common simplification rules used in complexity analysis:
• Drop Constants: Constants in front of the dominant term are dropped in Big-O notation.
For example: 𝓞(𝟓𝒏𝟑 + 𝟒) simplifies to 𝓞(𝒏𝟑 ).
• Dominant Term Rule: only the term with the largest growth rate dominates in Big-O
notation. For example, in 𝓞(𝒏𝟐 + 𝒏), a dominant term is 𝒏𝟐 , so the complexity is 𝓞(𝒏𝟐 ).
• Additive Rule: When analyzing multiple operations in sequence, the complexities are
added. For example, if an algorithm has two separate loops with complexities 𝓞(𝒏𝟑 ) and
𝓞(𝒏), the total complexity is 𝓞(𝒏𝟑 ) + 𝓞(𝒏) = 𝓞(𝒏𝟑 + 𝒏) = 𝓞(𝒏𝟑 ).
• Multiplicative Rule: When analyzing nested operations, the complexities are multiplied.
For example, if an algorithm has nested loops with complexities 𝓞(𝒏) and 𝓞(𝒏), the total
complexity is 𝓞(𝒏) ∗ 𝓞(𝒏) = 𝓞(𝒏𝟐 ).
• Logarithmic Rules: Base of logarithms can be ignored in Big-O notation, as they
represent constant factors. Therefore, 𝓞(𝒍𝒐𝒈 𝒏) and 𝓞(𝒍𝒐𝒈𝟐 𝒏) are considered the same.
First, we need to understand the types of algorithms available to us. There are two types of
algorithms:
• Iterative algorithm: In the iterative approach, the function executes repeatedly until the
condition is met or it fails. It involves the construction of a loop.
• Recursive Algorithm: The function calls itself until the condition is met in the recursive
approach. It integrates the branching structure.
There are some general rules to help us determine the running time of iterative algorithms
For the purpose of this discussion, we define a simple statement as one that does not have any
control flow component (subprogram call, loop, selection, etc.) in it. An assignment is a typical
example of a simple statement.
Time taken by a simple statement is considered to be constant. Let us assume that a given simple
statement takes 𝑘 units of time. If we factor out the constant, we would be left with 1, yielding 𝑘
= 𝑂(1). That is, a constant amount of time is denoted by 𝑂(1). It may be noted that we are not
concerned with the value of the constant; as long as it is constant, we will have 𝑂(1).
As stated above, a simple statement takes a constant amount of time. Therefore, time taken by a
sequence of such statements will simply be the sum of time taken by individual statements, which
will again be a constant. Therefore, the time complexity of a sequence of simple statements will
also be 𝓞(𝟏).
Time taken by a sequence of statements is the sum of time taken by individual statements which is
the maximum of these complexities. As an example, consider the following sequence of statements:
statement1 // 𝓞(𝒏𝟐 )
statement2 // 𝓞(𝟏)
statement3 // 𝓞(𝒏)
statement4 // 𝓞(𝒏)
𝓞(𝒏𝟐 ) + 𝓞(𝟏) + 𝓞(𝒏) + 𝓞(𝒏) = 𝑴𝒂𝒙 (𝓞(𝒏𝟐 ) + 𝓞(𝟏) + 𝓞(𝒏) + 𝓞(𝒏)) = 𝓞(𝒏𝟐 )
I.7.4.4. Selection
A selection can have many branches and can be implemented with the help of 𝑖𝑓 or 𝑠𝑤𝑖𝑡ch
statement. In order to determine the time complexity of an 𝑖𝑓 or 𝑠𝑤𝑖𝑡ch statement, we first
independently determine the time complexity of each one of the branches separately. As has already
been mentioned, Big O provides us growth rate in the worst-case scenario. Since, in a selection,
each branch is mutually exclusive, the worst-case scenario would be the case of the branch which
required the largest amount of computing resources. Hence the Big O for an 𝑖𝑓 or a 𝑠𝑤𝑖𝑡𝑐h
statement would be equal to the maximum of the Big O of its individual branches. As an example,
consider the following code segment:
In C (C++) language 𝑓𝑜𝑟, 𝑤h𝑖𝑙𝑒, and 𝑑𝑜 − 𝑤h𝑖𝑙𝑒 statements are used to implement an iterative
step or loop. Complexity analysis of iterative statements is usually the most difficult of all the
statements. The main task involved in the analysis of an iterative statement is the estimation of the
number of iterations of the body of the loop. There are many different scenarios that need separate
attention and are discussed below.
We define a simple loop as a loop which does not contain another loop inside its body (that is no
nested loops). Analysis of a simple loop involves the following steps:
1. Determine the complexity of the code written in the loop body as a function of the problem
size. Let it be 𝓞(𝒇(𝒏)).
2. Determine the number of iterations of the loop as a function of the problem size. Let it be
𝓞(𝒈(𝒏)).
3. Then the complexity of the loop would be: 𝓞(𝒇(𝒏)) . 𝓞(𝒇(𝒏)) = 𝓞(𝒇(𝒏). 𝒈(𝒏))
Example:
for( i = 1; i <= n; i ++)
number of iterations is n, 𝓞(𝒏)
S = S + 2; // constants time, 𝓞(𝟏)
As stated above, we define a simple loop with uniform step size as the simple loop where the loop
control variable is incremented/decremented by a fixed amount. That is, in this case, the values of
the loop control variable follow an algebraic sequence. These are perhaps the most commonly
occurring loops in our programs. These are usually very simple and have a time complexity of 𝑂(𝑛).
They are also very simple to analyze.
Example: The following code for Linear Search elaborates this in more detail.
if (a[i] == key) {
number of iterations is n, 𝓞(𝒏)
index = i; // 𝓞(𝟏)
break; // 𝓞(𝟏)
} //for
We can easily see that:
In loops with variable step size, the loop control variable is increased or decreased by a variable
amount. In such cases, the loop control variable is usually multiplied or divided by a fixed amount,
thus it usually follows a geometric sequence.
As an example, let us consider the following code for the Binary Search Algorithm:
high = N-1;
low = 0;
index = -1;
while(high >= low)
{
mid = (high + low)/2;
if (key == a[mid]) { index = mid; break;}
else if (key > a[mid]) low = mid + 1;
else high = mid – 1;
}
Once again, we can easily see that the code written in the body of the loop has a complexity of
𝓞(𝟏). We now need to count the number of iterations.
For the sake of simplicity, let us assume that N is a power of 2. That is, we can write 𝑵 = 𝟐𝒌 where
k is a non-negative integer. Once again, the worst case will occur if the key is not found. In each
iteration, the search space spans from low to high. So, in the first iteration we have N elements in
this range. After the first iteration, the range is halved as either the low or the high is moved to
mid + 1 or mid - 1 respectively, effectively reducing the search space to approximately half the
original size. This pattern continues until we either find the key or the condition high >= low is
false, which is the worst-case scenario. This pattern is captured in this Table 1.3.
Iteration 1 2 3 … … … K+1
𝑁 𝑁
Search Size 𝑁 = 2𝑘 = 2𝑘−1 = 2𝑘−2 … … … 1 = 2𝑘−𝑘 = 20
2 4
Table 1.3: The number of iterations in binary search
That is, after k+1 steps the loop will terminate. Since 𝑵 = 𝟐𝒌 , by taking log base 2 of both sides
we get 𝒌 = 𝒍𝒐𝒈𝟐 𝑵. In general, when N cannot be written as power of 2, the number of iterations
will be given by the ceiling function 𝒍𝒐𝒈𝟐 𝑵. So, in the worst case, the number of iterations will be
given by 𝓞([𝒍𝒐𝒈𝟐 𝑵]). 𝑻𝒐𝒕𝒂𝒍 𝒕𝒊𝒎𝒆 𝑻(𝒏) = 𝓞(𝟏) × 𝓞(𝒍𝒐𝒈 𝒏) = 𝓞(𝒍𝒐𝒈 𝒏)
Before concluding this discussion on simple loops, let us look at a last, but quite deceiving,
Example: The following piece of code prints the table for number m.
for(int i=1; i<=10; i++)
This looks like an algorithm with time complexity 𝓞(𝑵) . However, since the number of iterations
is fixed, hence it is not dependent upon any input data size and therefore it will always take the
same amount of time. Therefore, its time complexity is 𝓞(𝟏).
For the purpose of this discussion, we can divide the nested loops in two categories:
1. Independent loops: the number of iterations of the inner loop are independent of any
variable controlled in the outer loop.
2. Dependent loops: the number of iterations of the inner loop are dependent upon some
variable controlled in the outer loop.
a) Independent Loops
Let us consider the following code segment written to add two matrices a, and b and store the
result in matrix c.
for (int i = 0; i < ROWS; i++)
b) Dependent Loops
When the number of iterations of the inner loop is dependent upon some variable controlled in
outer loop, we need to do a little bit more to find out the time complexity of the algorithm. What
we need to do is to determine the number of times the body of the inner loop will execute.
As can be clearly seen, the value of j is dependent upon i, and hence the number of iterations of
the inner loop depend upon the value of i which is controlled in the outer loop. In order to analyze
such cases, we can use a table like the one given in Table 1.4.
Value of i 0 1 2 … I … N-1
Number of iterations
𝑁 𝑁−1 𝑁−2 … 𝑁−𝑖 … 1
of the inner loop
𝑇(𝑁) = 𝑁 + (𝑁 − 1) + (𝑁 − 2) + ⋯ + (𝑁 − 𝑖) + ⋯ + 1
Total Number of
𝑁(𝑁 + 1)
times the body of the =
2
inner loop is executed
= 𝓞(𝑵𝟐 )
Table 1.4: Analysis of the algorithm.
As shown in the table, the time complexity of algorithm is thus 𝓞(𝑵𝟐 ).
Let us look at another interesting example.
k = 0;
for (i = 1; i < N; i++)
for (j = 1; j <= i; j = j * 2)
k++;
Once again, the number of iterations in the inner loop depend upon that value of i which is
controlled by the outer loop. Hence it is a case of dependent loops. Let us now use the same
technique to find the time complexity of this algorithm. The first thing to note here is that the inner
loop follows a geometric progression, going from 1 to i, with 2 as the common ratio between
consecutive terms. We have already seen that in such cases the number of iterations is given by
[𝒍𝒐𝒈𝟐 (𝒊 + 𝟏)]. Using this information, the time complexity is calculated as shown in Table 1.5.
Value of i 1 2 3 4 … N-1
Number of iterations 1 2 2 3
… [log(𝑁)]
of the inner loop = [log(2)] = [log(3)] = [log(4)] = [log(5)]
k++;
Now, in this case, if we calculated the complexity of each loop independently and then multiplied
the results, we would get the time complexity as 𝑂(𝑁 log 𝑁). This is very different from what we
get if we apply the approach used in the previous examples. The analysis is shown in Table 1.6.
Value of i 1 2 3 4 … N-1
Number of 𝑁 ≈ 2𝑘
iterations of the 1 = 20 2 = 21 4 = 22 8 = 23 … 𝑊ℎ𝑒𝑟𝑒
inner loop
𝑘 = [𝑙𝑜𝑔𝑁]
Total Number of 𝑇(𝑁) = 20 + 21 + 22 + 23 + ⋯ + 2𝑘
times the body
= 2𝑘+1 − 1 = 2 × 2𝑘 − 1
of the inner loop
is executed = 𝓞(𝟐𝒌 ) = 𝓞(𝑵)
float temp = 0;
return temp;
It is easy to see that this function has a linear time complexity, that is, 𝑂(𝑁).
Let us now use it to determine the time complexity of the following function:
We first determine the time complexity of each statement. Then we determine the complexity of
the entire function by simply using the method discussed earlier to determine the complexity of a
sequence of statements. This gives us the time complexity of O(n) for the function.
It is important to remember that if we have a function call inside the body of a loop, we need to
apply the guidelines for the loops. In that case we need to carefully examine the situation as
sometimes the time taken by the function call is dependent upon a variable controlled outside the
function and hence gives rise to a situation just like dependent loops discussed above. In such
cases, the complexity is determined with the help of techniques similar to the one used in the case
of dependent loops.
As an example, consider the following example:
int foo(int n) {
int count = 0;
count++;
return count;
}
As can be easily seen, if considered independently, this function has linear time complexity, i.e., it
is 𝑂(𝑁). Let us now use it inside another function:
int bar(int n) {
temp = 0;
temp1 = foo(i);
If we are not careful, we can be easily deceived by it. If we treat it casually, we can put the
complexity of the for loop to be 𝑂(log 𝑁) and we have already determined that the complexity of
the function foo is 𝑂(𝑁). Hence, the overall complexity would be calculated to be 𝑂(𝑁log 𝑁).
However, a more careful analysis would show that 𝑂(𝑁) is a tighter upper bound for this function
Example: Let's calculate the complexity of the following algorithm (n! : factorial of n) :
int factorial(int n) {
According to the rules seen above, the complexity is: 𝓞(𝟏) + 𝓞(𝒏) × 𝓞(𝟏) + 𝓞(𝟏) = 𝓞(𝒏)
In this section, we will see how to apply the general framework for analysis of algorithms to
recursive algorithms. Determining the running time of a function that calls itself recursively
requires more work than analyzing iterative (non-recursive) functions. A useful technique is to
describe the execution time using a recurrence relation. A recurrence relation, also known as a
difference equation, is an equation that defines a sequence recursively: each term in the sequence
is defined as a function of the preceding terms.
To analyze the time complexity of a recursive function, you can follow these steps:
• Determine the recurrence relation: Identify the recursive calls and their respective
inputs. Write an equation that expresses the time complexity of the function in terms of its
inputs.
• Solve the recurrence relation: Solve the equation to get a closed-form solution for the
time complexity.
• Analyze the solution: Determine the dominant term(s) in the closed-form solution to get
the time complexity of the function.
I.7.5.1. Recurrence Relation
In the following sections, we will discuss methods that can be used to find the time complexities
of recursive algorithms. Previously, we defined the runtime of an algorithm as a function 𝑻(𝒏) of
its input size 𝒏. We will still do this when dealing with recursive functions. However, because a
recursive algorithm calls itself with a smaller input size, we will write 𝑻(𝒏) as a recurrence
relation, or an equation that defines the runtime of a problem in terms of the runtime of a recursive
call on smaller input.
Example:
Compute the factorial function 𝑭(𝒏) = 𝒏 for an arbitrary nonnegative integer 𝒏. Since
and 𝟎! = 𝟏by definition, we can compute 𝑭(𝒏) = 𝑭(𝒏 − 𝟏). 𝒏 with the following recursive
algorithm.
int Fact(int n) {
(1) if (n==0);
else
Since there is only one function, fact, involved, we shall use 𝑻(𝒏) for the unknown running time
of this function. We shall use 𝒏, the value of the argument, as the size of the argument. Clearly,
recursive calls made by fact when the argument is 𝒏 have a smaller argument, (𝒏 − 𝟏) to be precise.
For the basis of the inductive definition of 𝑻(𝒏) we shall take 𝒏 = 𝟎, since no recursive call is
made by fact when its argument is 𝟎. With 𝒏 = 𝟎, the condition of line (𝟏) is true, and so the call
to fact executes lines (𝟏) and (𝟏). Each takes constant 𝒂 time i.e., 𝓞(𝟏) time, and so the running
time of 𝑭𝒂𝒄𝒕 in the basis case is 𝒂 time i.e., 𝓞(𝟏). That is, 𝑻(𝟎) is 𝓞(𝟏).
Now, consider what happens when 𝒏 > 𝟎. The condition of line (𝟏) is false, and so we execute
only lines (𝟏) and (𝟑). Line (𝟏) takes 𝒃 time i.e., 𝓞(𝟏) time, and line (𝟑) takes 𝒂 time for the
multiplication and assignment, plus 𝑻(𝒏 − 𝟏) for the recursive call to 𝑭𝒂𝒄𝒕. That is, for 𝒏 > 𝟎,
the running time of 𝑭𝒂𝒄𝒕 is 𝒃 + 𝑻(𝒏 − 𝟏). We can thus define 𝑻(𝒏) by the following recurrence
relation:
𝒂 𝒊𝒇 𝒏 = 𝟎 𝓞(𝟏) 𝒊𝒇 𝒏 = 𝟎
𝑻(𝒏) = { 𝑶𝑹 𝑻(𝒏) = {
𝑻(𝒏 − 𝟏) + 𝒃 𝒊𝒇 𝒏 > 𝟎 𝑻(𝒏 − 𝟏) + 𝓞(𝟏) 𝒊𝒇 𝒏 > 𝟎
Here, 𝑻(𝒏) represents the runtime of 𝑭𝒂𝒄𝒕(𝒏), 𝑻(𝒏 − 𝟏) represents the runtime of the recursive
call to 𝑭𝒂𝒄𝒕(𝒏 − 𝟏), and the additional 𝓞(𝟏) term represents the constant work we do outside
the recursive call. Since the recurrence relation will be used to calculate time complexities, having
an exact number for this constant term does not matter.
After we convert a recursive algorithm into a recurrence relation, we can use that recurrence
relation to determine its time complexity. There are many techniques for solving recurrence
relations. In this section, we will examine three methods, namely the iterative substitution method,
the recurrence tree method, and the master theorem to analyze recurrence relations. Solutions to
recurrence relations give the time complexity of the underlying algorithms.
After we have a recurrence relation, the steps of the iterative substitution method (also known as
the iteration method) are as follows:
Step 1) Write out the recursive terms (𝑻(𝒏 − 𝟏), 𝑻(𝒏 − 𝟐), … , 𝒆𝒕𝒄. ), as their own recurrence
relations and substitute their equations into the original 𝑻(𝒏) formula at each step.
Step 2) Look for a pattern that describes 𝑻(𝒏) at the 𝒌𝒕𝒉 step (for any arbitrary 𝒌), and express
it using a summation formula.
Step 3) Solve for 𝒌 such that the base case is the only recursive term that is present on the right-
hand side of the equation for 𝑻(𝒏). Determine the closed form solution by replacing
instances of the base case with its value (e.g., replacing 𝑻(𝟏) with 𝟏 if the base case is
𝑻(𝒏) = 𝓞(𝟏)).
Example 1:
𝓞(𝟏) 𝒊𝒇 𝒏 = 𝟎
Consider the Recurrence : 𝑻(𝒏) = {
𝑻(𝒏 − 𝟏) + 𝓞(𝟏) 𝒊𝒇 𝒏 > 𝟎
Solve the following recurrence relation using the iterative substitution method ?
Solution:
From now on, we will substitute 𝓞(𝟏) with the constant 𝟏. This simplifies our math without
changing the result of our analysis.
𝟏 𝒊𝒇 𝒏 = 𝟎
𝑻(𝒏) = {
𝑻(𝒏 − 𝟏) + 𝟏 𝒊𝒇 𝒏 > 𝟎
Now, let’s follow the procedure specified above.
𝑻(𝒏) = 𝑻(𝒏 − 𝟏) + 𝟏
= (𝑻(𝒏 − 𝟐) + 𝟏) + 𝟏 = 𝑻(𝒏 − 𝟐) + 𝟐
= (𝑻(𝒏 − 𝟑) + 𝟏) + 𝟐 = 𝑻(𝒏 − 𝟑) + 𝟑
⋮ ⋮ ⋮
= 𝑻(𝒏 − 𝒌) + 𝒌
To solve the generalized a last relation, we have to find the value of 𝒌. We note that 𝑻(𝟎), that is
the number of operations needed to raise a number 𝒙 to power 𝟏 needs just one operation.
𝑻(𝒏) = 𝒏 + 𝟏 = 𝓞(𝒏)
Example 2:
𝟐 𝒊𝒇 𝒏 = 𝟏
Consider the Recurrence : 𝑻(𝒏) = { 𝒏
𝟐𝑻 (𝟐) + 𝒏 𝒊𝒇 𝒏 > 𝟏
Solve the following recurrence relation using the iterative substitution method ?
Solution:
𝒏
𝑻(𝒏) = 𝟐𝑻 ( ) + 𝒏
𝟐
𝒏 𝒏 𝒏
= 𝟐[𝑻 ( ) + ] + 𝒏 = 𝟒𝑻 ( ) + 𝟐𝒏
𝟒 𝟐 𝟒
𝒏 𝒏 𝒏
= 𝟒[𝟐𝑻 ( ) + ] + 𝟐𝒏 = 𝟖𝑻 ( ) + 𝟑𝒏
𝟖 𝟒 𝟖
𝒏 𝒏 𝒏
= 𝟖[𝟐𝑻 ( ) + ] + 𝟑𝒏 = 𝟏𝟔𝑻 ( ) + 𝟒𝒏
𝟏𝟔 𝟖 𝟏𝟔
⋮ ⋮ ⋮
𝒏
= 𝟐𝒌 𝑻 ( ) + 𝒌. 𝒏 ; 𝟏 ≤ 𝒌 ≤ 𝒍𝒐𝒈𝟐 𝒏
𝟐𝒌
The maximum value of 𝒌 = 𝒍𝒐𝒈𝟐 𝒏 (then only we get 𝑻(𝒏))
𝒏
𝑻(𝒏) = 𝟐𝒍𝒐𝒈𝟐 𝒏 𝑻 ( ) + 𝒏. 𝒍𝒐𝒈𝟐 𝒏
𝟐𝒍𝒐𝒈𝟐 𝒏
= 𝒏. 𝑻(𝟏) + 𝒏. 𝒍𝒐𝒈𝟐 𝒏
= 𝟐𝒏 + 𝒏. 𝒍𝒐𝒈𝟐 𝒏
= 𝓞(𝒏 𝒍𝒐𝒈 𝒏)
Note: If you want to write a recurrence relation for 𝑻(𝒏 − 𝟏), you must replace ALL instances of
𝒏 with (𝒏 − 𝟏), even the terms outside the recursive call! A common mistake is only substituting
the 𝒏 in the recursive term.
Example 3:
𝟏 𝒊𝒇 𝒏 = 𝟎
𝑻(𝒏) = {
𝑻(𝒏 − 𝟏) + 𝒍𝒐𝒈 𝒏 𝒊𝒇 𝒏 > 𝟎
While the substitution method works well for many recurrence relations, it is not an appropriate
technique when recurrence relation model algorithms are based on the “divide and conquer”
paradigm. The recurrence tree method is a popular technique for solving such recurrence relations,
particularly for solving unbalanced recurrence relations.
• At each level, each node is assigned the total amount of work that is done outside the
recursive call(s).
• Each recursive call creates a branch to the next level of the tree.
Example:
𝒏 𝒏 𝒏 𝒏
𝑻( ) 𝑻( ) 𝑻( ) 𝑻( )
𝟐 𝟐 𝟐 𝟐
𝒏
How much work do these recursive calls to 𝑻 (𝟐) do?
𝒏 𝒏 𝒏
We know from our recurrence relation that 𝑻 (𝟐) = 𝟒𝑻 (𝟒) + 𝟐, where a recursive call with an
𝒏 𝒏 𝒏
input size of 𝟐 does 𝟐 work and makes two recursive calls, each with input size 𝟒. We can use this
to extend our tree down another level.
𝒏 𝒏 𝒏 𝒏
𝟐 𝟐 𝟐 𝟐
𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏
𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( ) 𝑻( )
𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒
By repeating this process to the base case, we can construct the full recursion tree.
𝒏
Level Recursion Tree for 𝑻(𝒏) = 𝟒𝑻 (𝟐) + 𝒏 Work
𝑻(𝒏) 𝒏 𝒏
𝒏 𝒏 𝒏 𝒏 𝒏 𝒏
𝑻( ) 𝟒(𝟐) = 𝟐n
𝒍𝒐𝒈𝟐 𝒏
𝟐 𝟐 𝟐 𝟐 𝟐
𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏 𝒏
𝑻( ) 𝟏𝟔( ) = 𝟒𝒏
𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒 𝟒
𝒏 ⋮ ⋮ 𝒏
𝑻 ( 𝒊) ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮ ⋮
𝟒𝒊 ( 𝒊 ) = 𝟐𝒊 𝒏
𝟐 𝟐
𝑻(𝟏) 𝑻(𝟏) 𝑻(𝟏) ⋯⋯⋯ ⋯⋯⋯⋯⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ ⋯ 𝑻(𝟏)
𝒏𝟐 𝑻(𝟏)
𝟒𝒍𝒐𝒈𝟐 𝒏 = 𝒏𝒍𝒐𝒈𝟐 𝟒 = 𝒏𝟐
𝑻𝒐𝒕𝒂𝒍 = 𝒏 + 𝟐𝒏 + 𝟒𝒏 + ⋯ + 𝟐𝒍𝒐𝒈𝟐 𝒏−𝟏 𝒏 + 𝒏𝟐 𝑻(𝟏)
= 𝒏[𝟏 + 𝟐 + 𝟒 + ⋯ + 𝟐𝒍𝒐𝒈𝟐 𝒏−𝟏 ] + 𝒏𝟐 𝑻(𝟏)
𝒍𝒐𝒈𝟐 𝒏−𝟏
= 𝒏 ∑ 𝟐𝒊 + 𝒏𝟐 𝑻(𝟏)
𝒊=𝟎
= 𝓞(𝒏𝟐 )
Summary:
𝒏
Let 𝑻(𝒏) is defined by the recurrence relation : 𝑻(𝒏) = 𝒂𝑻 (𝒃) + 𝒇(𝒏)
where 𝒂 ≥ 𝟏 and 𝒃 > 𝟏 are constants and 𝒇(𝒏) is an asymptotically positive function.
We conclude our work with these affirmations:
3) Master Theorem
The master method provides a “cookbook” method for solving recurrences of the form
𝒏
𝑻(𝒏) = 𝒂𝑻 ( ) + 𝒇(𝒏)
𝒃
where 𝒂 ≥ 𝟏 and 𝒃 > 𝟏 are constants and 𝒇(𝒏) is an asymptotically positive function
(Asymptotically positive means that the function is positive for all sufficiently large 𝒏.)
𝓞(𝒏𝒍𝒐𝒈𝒃(𝒂) ) 𝒊𝒇 𝒂 > 𝒃𝒄
𝑻(𝒏) = {𝓞(𝒏𝒄 𝒍𝒐𝒈𝒃 (𝒏)) 𝒊𝒇 𝒂 = 𝒃𝒄
𝓞(𝒏𝒄 ) 𝒊𝒇 𝒂 < 𝒃𝒄
Notes:
• The value 𝒃 represents the factor that the input is divided by with each recursive call,
• The value 𝒄 represents the highest power of the polynomial term 𝒇(𝒏).
• The Master Theorem can only be used if all these conditions are met.
Summary:
The steps for using the Master Theorem are as follows:
Solution:
As per 𝒄𝒂𝒔𝒆 𝟏 of the master theorem, the solution is : 𝑻(𝒏) = 𝓞(𝒏𝒍𝒐𝒈𝟐 (𝟒) ) = 𝓞(𝒏𝟐 )
𝒏
Example 2: Consider the following recurrence relation : 𝑻(𝒏) = 𝟐𝑻 (𝟐) + 𝒏
Solution:
Since 𝒂 = 𝒃𝒄 , [𝟐 = 𝟐𝟏 ]
As per 𝒄𝒂𝒔𝒆 𝟐 of the master theorem, the solution is : 𝑻(𝒏) = 𝓞(𝒏𝟏 𝒍𝒐𝒈𝟐 (𝒏)) = 𝓞(𝒏 𝐥𝐨𝐠 𝒏)
𝒏
Example 3: Consider the following recurrence relation : 𝑻(𝒏) = 𝟑𝑻 (𝟒) + 𝒏𝟐
Solution:
Solution:
For this recurrence, we have 𝒂 = 𝟐, 𝒃 = 𝟐, and 𝒇(𝒏) = 𝒏 𝒍𝒐𝒈 𝒏 = 𝓞(𝒏 𝒍𝒐𝒈 𝒏)
Does not satisfy either Case 1 or 2 or 3 of the Master’s theorem.
Here, the master theorem does not apply.
There is actually an extension of the Master Theorem that can be used if 𝒇(𝒏) is a polylogarithmic
function, but you will not need to know this for the class. Given a recurrence relation of the form
𝒏
𝑻(𝒏) = 𝒂𝑻 (𝒃) + 𝒇(𝒏), where 𝒇(𝒏) is of the form 𝑻(𝒏) = 𝓞(𝒏𝒄 𝒍𝒐𝒈𝒌 (𝒏)), the following are
true:
• Case 1: If 𝒂 > 𝒃𝒄 , then 𝑻(𝒏) = 𝓞(𝒏𝒍𝒐𝒈𝒃(𝒂) ).
• Case 2: If 𝒂 = 𝒃𝒄 , then
➢ If 𝒌 > −𝟏, then 𝑻(𝒏) = 𝓞(𝒏𝒍𝒐𝒈𝒃(𝒂) 𝒍𝒐𝒈𝒌+𝟏 (𝒏)).
➢ If 𝒌 = −𝟏, then 𝑻(𝒏) = 𝓞(𝒏𝒍𝒐𝒈𝒃(𝒂) 𝒍𝒐𝒈(𝒍𝒐𝒈(𝒏))).
➢ If 𝒌 < −𝟏, then 𝑻(𝒏) = 𝓞(𝒏𝒍𝒐𝒈𝒃(𝒂) ).
• Case 3: If 𝒂 < 𝒃𝒄 , then
➢ If 𝒌 ≥ 0, then 𝑻(𝒏) = 𝓞(𝒏𝑐 𝒍𝒐𝒈𝒌 (𝒏)).
➢ If 𝒌 < 0, then 𝑻(𝒏) = 𝓞(𝒏𝑐 ).
𝒏
Example 1: Consider the following recurrence relation : 𝑻(𝒏) = 𝟑𝑻 (𝟐) + 𝒍𝒐𝒈𝟐 𝒏
Solve the recurrence by using the master theorem or the extended master theorem?
Solution:
Here, the master theorem does not apply, but rather we apply the extended master theorem.
For this recurrence, we have 𝒂 = 𝟑, 𝒃 = 𝟐, and 𝒇(𝒏) = 𝓞( 𝒍𝒐𝒈 𝒏) ⟹ 𝒄 = 𝟎 & 𝒌 = 𝟏
Solve the recurrence by using the master theorem or the extended master theorem?
Solution:
Here, the master theorem does not apply, but rather we apply the extended master theorem.
For this recurrence, we have 𝒂 = 𝟐, 𝒃 = 𝟐, and 𝒇(𝒏) = 𝓞(𝒏 𝒍𝒐𝒈 𝒏) ⟹ 𝒄 = 𝟏 & 𝒌 = 𝟏
Since 𝒂 = 𝒃𝑐 , [𝟐 = 𝟐𝟏 ]
As per 𝒄𝒂𝒔𝒆 𝟐 of the extended master theorem, 𝒌 = 𝟏 ⟹ 𝒌 > −𝟏, the solution is :