CSC 401
CSC 401
Overview
Algorithm as you have been taught in earlier is the step by step of problem solving. In this course
we are going to study the basic algorithm analysis and different strategies of algorithm. The aim
of this study is to be equipped with knowledge on how to develop good algorithms for best solution
to problems. Algorithm Analysis is at the heart of computer science, serving as a toolset that
allows you to evaluate and compare the performance of different algorithms in solving specific
tasks. It can be defined as the study of the computational complexity of algorithms which helps in
minimizing the resources required by a program, thereby improving overall program efficiency.
Algorithm analysis is something designed to compare two algorithms at the idea level — ignoring
low-level details such as the implementation programming language, the hardware the algorithm
runs on, or the instruction set of the given CPU. The algorithms are compared in terms of just what
they are: Ideas of how something is computed. If our algorithm takes 1 second to run for an input
of size 1000, how will it behave if the input size is doubled? Will it run just as fast, half as fast, or
four times slower? In practical programming, this is important as it allows us to predict how our
algorithm will behave when the input data becomes larger. For example, if we've made an
algorithm for a web application that works well with 1000 users and measure its running time,
using algorithm complexity analysis we can have a pretty good idea of what will happen once we
get 2000 users instead.
Analysis of Algorithm
The analysis is a process of estimating the efficiency of an algorithm, that is, trying to know how
good or bad an algorithm could be.
There are two main parameters based on which an algorithm can be analyzed:
• Space Complexity: The space complexity is concerned with the amount of space required
by an algorithm to run to completion.
• Time Complexity: Time complexity is a function of input size n that refers to the execution
time of an algorithm as n increases..
Running Time
Number Of Times
Execution Time For Basic Operation Is
Basic Operation Executed
Worst-case time complexity: For a given input size ‘n’, the worst-case time complexity
can be defined as the maximum amount of time needed by an algorithm to complete its
execution. It is simply a function defined by the maximum number of steps performed on
an instance having an input size of n. We are more interest in this for algorithm analysis.
Average case time complexity: For 'n' input size, the average case time complexity can be
defined as the average amount of time needed by an algorithm to complete its execution.
It is simply a function defined by the average number of steps performed on an instance
having an input size of n.
Best case time complexity: For 'n' input size, the best-case time complexity can be defined
as the minimum amount of time needed by an algorithm to complete its execution. It is
simply a function defined by the minimum number of steps performed on an instance
having an input size of n. This can be used to determine inefficient algorithm.
Algorithm Complexity
The algorithm complexity measures the number of steps required by the algorithm to
solve a given problem. It evaluates the order of count of basic operations executed by an
algorithm as a function of input data size.
To assess the complexity, the order (approximation) of the count of operation is always
considered instead of counting the exact steps.
OC(n) also known as Asymptotic notation represents the complexity of an algorithm,
which or "Big O" notation. Here the f(n) corresponds to the function whose size is the
same as that of the input data. The complexity of the asymptotic computation O(n)
determines in which order the resources such as CPU time, memory, etc. are consumed
by the algorithm that is articulated as a function of input data size.
The complexity can be found in any form such as constant, logarithmic, linear, n*log(n),
quadratic, cubic, exponential, etc. It is nothing but the order of constant, logarithmic,
linear and so on, the number of steps encountered for the completion of a particular
algorithm. The complexity of an algorithm is also known as "running time".
For example,
// Pseudocode for checking if a number is even or odd
function isEvenOrOdd(number) {
return (number % 2 == 0) ? 'Even' : 'Odd'
}
Logarithmic Complexity
Imposes a complexity of O (log(N)). An algorithm Complexity if its runtime is proportional to
the logarithm of the input size. To perform operations on N elements, it often takes the
logarithmic base as 2. For N = 1,000,000, an algorithm that has a complexity of O(log(N)) would
undergo 20 steps (with a constant precision). Here, the logarithmic base does not hold a
necessary consequence for the operation count order, so it is usually omitted.
Example algorithm
Def binary_search(arr, target);
Low = 0
High = len(arr) -1
while low <= high
mid = (low + high)//2
if arr(mid)= = target;
return mid
elif arr[md] < target;
low = mid + 1
else
high = mid – 1
return -1
arr = [2,5,8,12,16,23,38]
target =16
index = binary_search(arr, target)
if index != -1
print(“Element”, target, “found at index”, index)
else
print(“Element”, target, “not found in the list.”)
Linear Complexity
Imposes a complexity of O (N). An algoritm whose time execution is dependent on the input size
(n) is said to have linear complexity.
For example, if there exist 1000 elements, then it will take about 1000 steps. Basically, in linear
complexity, the number of elements linearly depends on the number of steps. For example, the
number of steps for N elements can be N/2 or 3*N. For instance, if the statements within a loop
run with 1unit of time, so if the loop runs for n time, its complexity is 1*O(n) = O(n)
Lets take a practical example;
Function sum (n){
Let sum = 0 // 1 unit of time i.e O(1)
For (let I =1; I,= n: 1++){ //loop repeat upto n times
Sum += I; // 1 unit of time *n
}
Return sum; //1 unit of time}
Linearithmic Complexity
It also imposes a run time of O(n*log(n)). This similar to logarithmic has an extra
dependency on input size. It uses the principle of Divide and Conquer and undergoes the
execution of the order n*log(n) on n number of elements to solve the given problem. For
a given 1000 elements, the linearithmic complexity will execute 10,000 steps for solving
a given problem. Good example is Merge sort as shown below:
Def merge_sort(arr)
If len(arr) <= 1
return arr
mid = len(arr)//2
left = merge_sort(arr[:mid])
right = merge_sort(arr[:mid])
return merge(left, right)
def merge(left, right);
merge = []
i=0
j=0
while i < len (left) and j < len(right);
if left[i] < = right [j];
[Link](left[i])
i += 1
else
[Link](right[j])
j+=1
[Link](left[i:])
[Link](left[j:])
return merged
arr = [8, 3, 1, 7, 4, 6, 2, 5]
sorted_arr = merge_sort(arr)
print(sorted_arr)
The algorithm has an array of 8 elements which is split into two continuously until it
cannot be split any more. The arrays are then merged to give a sorted array.
Quadratic Complexity
It imposes a complexity of O (n2). If an algorithm running time is directly proportional to
the squared size of the input size (n), it is said to have quadratic complexity. If N = 100, it
will endure 10,000 steps. In other words, whenever the order of operation tends to have a
quadratic relation with the input data size, it results in quadratic complexity.
For example, for N number of elements, the steps are found to be in the order of 3*N2/2.
Practical example: an algorithm to check for duplicate in array
Function checkforDuplicate(Array){
For (i=0; i<[Link]: i++) // the loop repeats until n times
Const tempArray = [Link](i+1): // O(1*n) times; the statement
runs for O(1) times n repetition
If ([Link](Array[i] !==-1{ // O(n*n)// the function
indexoff will repeat n times for tempArray and also loop for n times of the above
for loop, therefore if statement will run for total of O(n*n)
Return true://O(1*n), runs constant time O(1) but repeats n times
because of for loop
}
}
Return false:// O(1); repeated only 1 time because it is outside loop
Calculating the time complexity of the above code, we have:
C(n) = O(1*n) + )(n*n) + O(1*n) + O(1)
= O(n*n)
=O(n2) ignoring all the constants and lower terms.
Cubic Complexity
It imposes a complexity of O (n3). This complexity increases even faster than quadratic
complexity For N input data size, it executes the order of N3 steps on N elements to solve
a given problem. It is similar to quadratic complexity, instead of having two nested loops,
it has three. If there exist 100 elements, it is going to execute 1,000,000 steps. Greater
polynomial complexities should be avoided where possible.
Floyd Warshall algorithm is an example of cubic complexity:
Def Floyd_warshall(graph)
N=len(graph)
Distances = [Link]()
For k in range(n);
For I in range(n)
For j in range(n)
Distances[i][j] = min(distances[i][j], distances[i][k] + distances[k][j])
Return distances
Inf= float(ínf’)
Graph = [ 0, inf, -2. Inf],
[4, 0, 3 inf],
[inf, inf, 0,2],
[inf, -1, inf, 0]
Exponential Complexity
It imposes a complexity of O(2n), O(N!), O(nk), . this simply means the number of operations
doubles as the input increases. This can be illustrated using Fibonacci process:
Def Fibonacci(n)
If n<= 1;
Return n
Else
Return Fibonacci(n-1) + Fibonacci(n-2)
Result = Fibonacci(5)
Print result
Factorial Complexity
This imposes a complexity of O(N!). The factorial of a number is the multiplication of every
integer that comes before it plus itself. For instance, factorial of 5 is 1*2*3*4*5 which equal to
120. Factorial complexity growth rate is so huge, so it is rarely use in everyday programming.
However generating all the permutations in a string or the possible combinations of individual
values is an example of factorial complexity process.
def generate_permutations(elements);
if len(elements) = = 1:
return [elements]
permutations = []
for i in range(len(elements));
remaining =elements[:i] + elements[i +1:]
sub_permutations = generate_permutations(remaining)
for perm in sub_permutations
[Link]([elements[i]] + perm)
return permutations
elements = [1,2,3,4]
permutations = generate_permutations(elements)
for perm in permutations
print(perm)
here we try to find possible permutation of the elements [1, 2, 3, 4]. Recursively each element is
picked to be the starting element and permutations are calculated. For this we receive 12
permutations because there are four elements.
In this function, the n2 term dominates the function, that is when n gets sufficiently large.
In function reduction, we are interested in dominate terms, because they determine the
function growth rate. Thus; we ignore all constants and coefficient and look at the highest
order term concerning n.
Asymptotic analysis
It is a technique of representing limiting behavior which can be used to analyze the
performance of an algorithm for some large data set.
In algorithms analysis (considering the performance of algorithms when applied to very
large input datasets), The simplest example is a function
ƒ(n) = n2+3n,
the term 3n becomes insignificant compared to n2 when n is very large. The function "ƒ (n)
is said to be asymptotically equivalent to n2 as n → ∞", and here is written symbolically
as
ƒ (n) ~ n2.
Asymptotic notations are used to write fastest and slowest possible running time for an
algorithm also known as 'best case' and 'worst case' scenarios respectively.
In asymptotic notations, we derive the complexity concerning the size
of the input in terms of n . These notations enable us to estimate the complexity of
algorithms without expanding its running cost. These notations compare functions,
ignoring constant factors and small input sizes.
For Example:
3n+2= θ (n) as 3n+2≥3n and 3n+2≤ 4n, for n
k1=3,k2=4, and n0=2
Hence, the complexity of f (n) can be represented as θ (g(n)).
The Theta Notation is more precise than both the big-oh and Omega notation. The function f (n) =
θ (g (n)) if g(n) is both an upper and lower bound.
Brute Force
This is a simple technique with naïve approach. It relies on huge processing power and testing of
all possibilities to improve efficiency. A scenario where a brute force search can be used; suppose
you forgot the combination of a 4-digit padlock and still want to use it, the padlock can be opened
by trying all possible 4-digit combinations from0 to 9 to unlock it. That combination could be
anything between 0000 to 9999, hence there are 10,000 combinations. So we can say that in the
worst case, for you to find the actual combination, you have up to 10, 000 possibilities.
The time complexity of brute force is O(mn), which can also be written as O(n*m). This means
that if we need to search a string of n characters in a string of m characters, the no of turns should
be n*m times.
Divide and Conquer solve each subproblem recursively, so each subproblem will be the smaller
original problem. Example is shown in Figure2.
Examples of some standard algorithms that are of the Divide and Conquer algorithms variety.
a. Binary Search: a searching algorithm. ...
b. Quicksort: sorting algorithm. ...
c. Merge Sort : sorting algorithm. ...
d. Closest Pair of Points: The problem is to find the closest pair of points in a set of
points in x-y plane.
Figure 4: Divide and Conquer algorithm
Greedy Algorithm
This is algorithm strategy that builds up a solution piece by piece, always choosing the next piece
that offers the most obvious and immediate benefit. It is used to solve optimization problems in
which a set of input values given, are required either to be increased or decreased according to the
objective. Greedy Algorithm solves problems always by the choice of the option, which appears
to be the best at the moment (hence the name Greedy). It may not always give the optimized
solution.
Two stages to solve a problem using greedy algorithm:
a) Examining the list of Items.
b) Optimization
This means that a greedy algorithm selects the best immediate option without proper
reconsideration of its decisions. When it comes to optimizing a solution, this simply implies that
the greedy solution will seek out local optimum solutions, which could be multiple, and may skip
a global optimal solution. For example, greedy algorithm in animation below aims to locate the
path with the largest sum.
Figure 5: Greedy Algorithm
With a goal of reaching the largest sum, at each step, the greedy algorithm will choose what
appears to be the optimal immediate choice, so it will choose 12 instead of 3 at the second step
and will not reach the best solution, which contains 99.
Dynamic Programming
Dynamic Programming (DP) is an algorithmic technique for solving optimization problems by
breaking them into simpler sub-problems and storing each sub-solution for reuse. For instance
when using this technique to figure out all possible results from a set of numbers, the solution
obtained from first calculation is saved and put into the equation later instead of being recalculated,
so it is used for complicated equations and processes, thus it is both a mathematical optimization
method and a computer programming method. The sub-problems are optimized to find the overall
solution which usually has to do with finding the maximum and minimum range of algorithmic
query. DP can be used in calculation of Fibonacci Series in which each number is the sum of the
two preceding numbers. Suppose the first two numbers of the series are 0,1.
To solve the problem of finding the nth number of the series, the overall problem i.e., Fib(n), we
can be tackled by breaking it down into two smaller sub-problems i.e.; Fib(n-1) and Fib(n-2).
Hence, we can use Dynamic Programming to solve above mentioned problem, which is
elaborated in more detail in the following Figure 3:
Figure 6: Fibonacci Series using Dynamic Programming
Firstly, a rooted decision tree where the root node represents the entire search space is built. Each
child node is a part of the solution set and is a partial solution. Based on the optimal solution, we
set an upper and lower bound for a given problem before constructing the rooted decision tree
and we need to make a decision about which node to include in the solution set at each level. It is
very important to find upper and lower bound and to find upper bound any local optimization
method can be used. It can also be found by picking any point in the search space and convex
relaxation. Whereas, duality can be used for finding lower bound.
Randomized Algorithm
Randomized Algorithm strategy uses random numbers to determine the next line of action at any
point in its logic. In a standard algorithm, it is usually used to reduce either the running time, or
time complexity, or the memory used, or space complexity. The algorithm works by creating a
random number, r, from a set of numbers and making decisions based on its value. This algorithm
could assist in making a decision in a situation of doubt by flipping a coin or drawing a card from
a deck.
Input Output
Algorithm
Random Number
Figure 8: Randomized Algorithm Flowchart
The output of a randomized algorithm on a given input is a random variable. Thus,
there may be a positive probability that the outcome is incorrect. As long as the
probability of error is small for every possible input to the algorithm, this is not a
problem
When utilizing a randomized method, keep the following two considerations in mind:
It takes source of random numbers and makes random choices during execution along with the
input. Behavior of an algorithm varies even on fixed inputs.
Two main types of randomized algorithms:
a. Las Vegas algorithms
b. Monte-Carlo algorithms.
Backtracking Algorithms
This technique steps backward to try another option if current solution fails. It is a method for
resolving issues recursively by attempting to construct a solution incrementally, one piece at a
time, discarding any solutions that do not satisfy the problem’s constraints at any point in time. It
ca be said to use brute force approach which resolves problems with multiple solutions. It finds a
solution by building a solution step by step, increasing levels over time, using recursive calling. A
search tree known as the statespace tree is used to find these solutions. Each branch in a state-
space tree represents a variable, and each level represents a solution.
A backtracking algorithm uses the depth-first search method. When the algorithm begins to
explore the solutions, the abounding function is applied so that the algorithm can determine
whether the proposed solution satisfies the constraints. If it does, it will keep looking. If it does
not, the branch is removed, and the algorithm returns to the previous level.
In any backtracking algorithm, the algorithm seeks a path to a feasible solution that includes some
intermediate checkpoints. If the checkpoints do not lead to a viable solution, the problem can return
to the checkpoints and take another path to find a solution.
The algorithm works as follows:
Given a problem:
\Backtrack(s) if is not a solution return false if is a new solution add to list of
solutions backtrack (expand s)
For example, if we want to find all the possible ways of arranging 2 boys and 1 girl on 3 benches
with a constraint that Girl should not be on the middle bench. So there will be 3! = 6 (3x2x1)
possibilities to solve this problem. All possible ways should be tried recursively to get the required
solution as shown:
Instances of Recursion
There are two main instances of recursion.
• Recursion as a technique in which a function makes one or more calls to itself.
• A data structure using smaller instances of the exact same type of data structure when it
represents itself.
Importance of Recursion
• It provides an alternative for performing repetitions of the task in which a loop is not
ideal.
• It serves as a great tool for building out particular data structures.
Uses of Recursion
A real world problem solve by recursion
Stacks of documents are sorted using recursive method. Assuming you have about 200
documents with names on them to sort, first is to place the documents into piles according to
their first letters, then sort each pile.
In software engineering, recursion function can be used for sorting and searching data structures
like linked-lists, binary trees, and graphs. They are also used for string manipulation. It can also
be used in traversing complex data structures such as JSON objects directory trees in file system.
Most programming problems can be solved without recursion, recursion is better use only for
repetitive tasks where loop is not ideal. Recursion should not be used for every problem.
Types of Recursion
Recursion are mainly of two types:
i. Direct Recursion: when a function calls itself from within itself
ii. Indirect Recursion: When more than one function call one another mutually.
Direct Recursion
These can be further categorized into four types:
a. Tail Recursion:
If a recursive function calling itself and that recursive call is the last statement in the function
then it’s known as Tail Recursion. After that call the recursive function performs nothing. The
function has to process or perform any operation at the time of calling and it does nothing at
returning time.
Example:
// Code Showing Tail Recursion
#include <iostream>
using namespace std;
// Recursion function
void fun(int n)
{
if (n > 0) {
cout << n << " ";
// Last statement in the function
fun(n - 1);
}
}
// Driver Code
int main()
{
int x = 3;
fun(x);
return 0;
}
Output:
321
Time Complexity For Tail Recursion : O(n)
Space Complexity For Tail Recursion : O(n)
Lets us convert Tail Recursion into Loop and compare each other in terms of Time & Space
Complexity and decide which is more efficient.
// Converting Tail Recursion into Loop
#include <iostream>
using namespace std;
void fun(int y)
{
while (y > 0) {
cout << y << " ";
y--; }}
// Driver code
int main()
{
int x = 3;
fun(x);
return 0;
}
Output
321
Time Complexity: O(n)
Space Complexity: O(1)
So it was seen that in case of loop the Space Complexity is O(1) so it was better to write code in
loop instead of tail recursion in terms of Space Complexity which is more efficient than tail
recursion.
b. Head Recursion:
If a recursive function calling itself and that recursive call is the first statement in the function
then it’s known as Head Recursion. There’s no statement, no operation before the call. The
function doesn’t have to process or perform any operation at the time of calling and all
operations are done at returning time.
Example:
// C++ program showing Head Recursion
#include <bits/stdc++.h>
using namespace std;
// Recursive function
void fun(int n)
{
if (n > 0) {
// First statement in the function
fun(n - 1);
cout << " "<< n;
}
}
// Driver code
int main()
{
int x = 3;
fun(x);
return 0;
}
Output:
123
Time Complexity For Head Recursion: O(n)
Space Complexity For Head Recursion: O(n)
c. Tree Recursion:
To understand Tree Recursion let’s first understand Linear Recursion. If a recursive function
calling itself for one time then it’s known as Linear Recursion. Otherwise if a recursive
function calling itself for more than one time then it’s known as Tree Recursion.
Example:
// C++ program to show Nested Recursion
#include <iostream>
using namespace std;
int fun(int n)
{
if (n > 100)
return n - 10;
// A recursive function passing parameter
// as a recursive call or recursion inside
// the recursion
return fun(fun(n + 11));
}
// Driver code
int main()
{
int r;
r = fun(95);
cout << " " << r;
return 0;
}
Output:
91
Indirect Recursion:
In this recursion, there may be more than one functions and they are calling one another in a
circular manner. From the diagram below fun(A) is calling for fun(B), fun(B) is calling for
fun(C) and fun(C) is calling for fun(A) and thus it makes a cycle.
Example:
// C++ program to show Indirect Recursion
#include <iostream>
using namespace std;
void funB(int n);
void funA(int n)
{
if (n > 0) {
cout <<" "<< n;
// Fun(A) is calling fun(B)
funB(n - 1);
}
}
void funB(int n)
{
if (n > 1) {
cout <<" "<< n;
// Fun(B) is calling fun(A)
funA(n / 2);
}
}
// Driver code
int main()
{
funA(20);
return 0;
}
Output:
20 19 9 8 4 3 1
Features Recursion
• Recursion uses selection structure.
• Infinite recursion occurs if the recursion step does not reduce the problem in a manner
that converges on some condition (base case) and Infinite recursion can crash the system.
• Recursion terminates when a base case is recognized.
• Recursion is usually slower than iteration due to the overhead of maintaining the stack.
• Recursion uses more memory than iteration.
• Recursion makes the code smaller.
Features of Iteration
• Iteration uses repetition structure.
• An infinite loop occurs with iteration if the loop condition test never becomes false and
Infinite looping uses CPU cycles repeatedly.
• An iteration terminates when the loop condition fails.
• An iteration does not use the stack so it's faster than recursion.
• Iteration consumes less memory.
• Iteration makes the code longer.
Algorithm Fib(n) {
if (n < 2) return 1
else return Fib(n-1) + Fib(n-2)
}
The above recursion is called binary recursion since it makes two recursive calls instead of one.
How many number of calls are needed to compute the kth Fibonacci number? Let nk denote the
number of calls performed in the execution.
n0 = 1
n1 = 1
n2 = n1 + n0 + 1 = 3 > 21
n3 = n2 + n1 + 1 = 5 > 22
n4 = n3 + n2 + 1 = 9 > 23
n5 = n4 + n3 + 1 = 15 > 23
...
nk > 2k/2
This means that the Fibonacci recursion makes a number of calls that are exponential in k. In
other words, using binary recursion to compute Fibonacci numbers is very inefficient. Compare
this problem with binary search, which is very efficient in searching items, why is this binary
recursion inefficient? The main problem with the approach
above, is that there are multiple overlapping recursive calls.
We can compute F(n) much more efficiently using linear recursion. One way to accomplish this
conversion is to define a recursive function that computes a pair of consecutive Fibonacci
numbers F(n) and F(n-1) using the convention F(-1) = 0.
Algorithm LinearFib(n) {
Input: A nonnegative integer n
Output: Pair of Fibonacci numbers (Fn, Fn-1)
if (n <= 1) then
return (n, 0)
else
(i, j) <-- LinearFib(n-1)
return (i + j, i)
}
Since each recursive call to LinearFib decreases the argument n by 1, the original call results in a
series of n-1 additional calls. This performance is significantly faster than the exponential time
needed by the binary recursion. Therefore, when using binary recursion, we should first try to
fully partition the problem in two or, we should be sure that overlapping recursive calls are really
necessary.
Example
𝑛
Consider 𝑇(𝑛) = 2𝑇 ( ) + 𝑛2
2
We have to obtain the asymptotic bound using recursion tree method.
Solution:
The Recursion tree for the above recurrence is shown in Figure12. For input size n,
𝑛
there are 2 subproblems, each of size. 𝑛2 is the f(n) that splits the problem into
2
subproblems and recombine the result.
𝑛
Figure 12: Recursion tree for 𝑇(𝑛) = 2𝑇 ( ) + 𝑛2
2
𝑛2 𝑛2 𝑛2
𝑇(𝑛) = 𝑛2 + + + … log 𝑛 𝑡𝑖𝑚𝑒𝑠
2 4 8
2 ∑∞ 1
≤𝑛 𝑖=0(2𝑖 )
1
≤ 𝑛2 ( 1)
1− 2
2
≤ 2𝑛
T(n) = Ꝋ(𝑛2 )
Exercise
Consider the following recurrence and obtain the asymptotic bound using recursion tree
method.
𝑛 2𝑛
𝑇(𝑛) = 𝑇( ) + 𝑇( ) + 𝑛
3 4