Algorithm Basics Csi
Algorithm Basics Csi
By the end of the course, students are expected to develop strong problem-solving
skills, understand how to design efficient algorithms, and apply analytical techniques
to evaluate algorithmic performance in real-world applications.
What is an Algorithm? Algorithm Basics
The word Algorithm means ”A set of finite rules or instructions to be followed in
calculations or other problem-solving operations ” Or ” A procedure for solving a
programming problem in a finite number of steps that frequently involves recursive
operations”.
It can be understood by taking the example of cooking a new recipe. To cook a new
recipe, one reads the instructions and steps and executes them one by one, in the
given sequence. The result thus obtained is the new dish is cooked perfectly. Every
time you use your phone, computer, laptop, or calculator you are using Algorithms.
Similarly, algorithms help to do a task in programming to get the expected output.
The Algorithm designed are language-independent, i.e. they are just plain
instructions that can be implemented in any language, and yet the output will be the
same, as expected.
What is the need for algorithms?
1. Algorithms are necessary for solving complex problems efficiently and
effectively.
2. They help to automate processes and make them more reliable, faster, and
easier to perform.
3. Algorithms also enable computers to perform tasks that would be difficult or
impossible for humans to do manually.
4. They are used in various fields such as mathematics, computer science,
engineering, finance, and many others to optimize processes, analyze data,
make predictions, and provide solutions to problems.
As one would not follow any written instructions to cook the recipe, but only the
standard one, similarly, not all written instructions for programming is an
algorithms. In order for some instructions to be an algorithm, it must have the
following characteristics:
• Clear and Unambiguous: The algorithm should be clear and unambiguous.
Each of its steps should be clear in all aspects and must lead to only one meaning.
• Well-Defined Inputs: If an algorithm says to take inputs, it should be well-
defined inputs. It may or may not take input.
• Well-Defined Outputs: The algorithm must clearly define what output will be
yielded and it should be well-defined as well. It should produce at least 1 output.
• Finite-ness: The algorithm must be finite, i.e. it should terminate after a finite
time.
• Feasible: The algorithm must be simple, generic, and practical, such that it can
be executed with the available resources. It must not contain some future
technology or anything.
• Language Independent: The Algorithm designed must be language-
independent, i.e. it must be just plain instructions that can be implemented in any
language, and yet the output will be the same, as expected.
• Input: An algorithm has zero or more inputs. Each that contains a fundamental
operator must accept zero or more inputs.
• Output: An algorithm produces at least one output. Every instruction that
contains a fundamental operator must accept zero or more inputs.
• Definiteness: All instructions in an algorithm must be unambiguous, precise, and
easy to interpret. By referring to any of the instructions in an algorithm one can
clearly understand what is to be done. Every fundamental operator in instruction
must be defined without any ambiguity.
• Finiteness: An algorithm must terminate after a finite number of steps in all test
cases. Every instruction which contains a fundamental operator must be
terminated within a finite amount of time. Infinite loops or recursive functions
without base conditions do not possess finiteness.
• Effectiveness: An algorithm must be developed by using very basic, simple, and
feasible operations so that one can trace it out by using just paper and pencil.
Properties of Algorithm:
• It should terminate after a finite time.
• It should produce at least one output.
• It should take zero or more input.
• It should be deterministic means giving the same output for the same input case.
• Every step in the algorithm must be effective i.e. every step should do some work.
Types of Algorithms:
There are several types of algorithms available. Some important algorithms are:
1. Brute Force Algorithm: It is the simplest approach for a problem. A brute force
algorithm is the first approach that comes to finding when we see a problem.
2. Recursive Algorithm: A recursive algorithm is based on recursion. In this case,
a problem is broken into several sub-parts and called the same function again and
again.
3. Backtracking Algorithm: The backtracking algorithm basically builds the
solution by searching among all possible solutions. Using this algorithm, we keep
on building the solution following criteria. Whenever a solution fails we trace back
to the failure point and build on the next solution and continue this process till we
find the solution or all possible solutions are looked after.
4. Searching Algorithm: Searching algorithms are the ones that are used for
searching elements or groups of elements from a particular data structure. They can
be of different types based on their approach or the data structure in which the
element should be found.
5. Sorting Algorithm: Sorting is arranging a group of data in a particular manner
according to the requirement. The algorithms which help in performing this function
are called sorting algorithms. Generally sorting algorithms are used to sort groups of
data in an increasing or decreasing manner.
6. Hashing Algorithm: Hashing algorithms work similarly to the searching
algorithm. But they contain an index with a key ID. In hashing, a key is assigned to
specific data.
7. Divide and Conquer Algorithm: This algorithm breaks a problem into sub-
problems, solves a single sub-problem and merges the solutions together to get the
final solution. It consists of the following three steps:
• Divide
• Solve
• Combine
8. Greedy Algorithm: In this type of algorithm the solution is built part by part. The
solution of the next part is built based on the immediate benefit of the next part. The
one solution giving the most benefit will be chosen as the solution for the next part.
9. Dynamic Programming Algorithm: This algorithm uses the concept of using
the already found solution to avoid repetitive calculation of the same part of the
problem. It divides the problem into smaller overlapping subproblems and solves
them.
10. Randomized Algorithm: In the randomized algorithm we use a random number
so it gives immediate benefit. The random number helps in deciding the expected
outcome.
To learn more about the types of algorithms refer to the article about “Types of
Algorithms“.
Advantages of Algorithms:
• It is easy to understand.
• An algorithm is a step-wise representation of a solution to a given problem.
• In Algorithm the problem is broken down into smaller pieces or steps hence, it is
easier for the programmer to convert it into an actual program.
Disadvantages of Algorithms:
• Writing an algorithm takes a long time so it is time-consuming.
• Understanding complex logic through algorithms can be very difficult.
• Branching and Looping statements are difficult to show in Algorithms(imp).
How to Design an Algorithm?
In order to write an algorithm, the following things are needed as a pre-requisite:
1. The problem that is to be solved by this algorithm i.e. clear problem definition.
2. The constraints of the problem must be considered while solving the problem.
3. The input to be taken to solve the problem.
4. The output to be expected when the problem is solved.
5. The solution to this problem is within the given constraints.
Then the algorithm is written with the help of the above parameters such that it solves
the problem.
Example: Consider the example to add three numbers and print the sum.
// algorithm
#include <bits/stdc++.h>
int main()
// the 3 numbers
int sum;
// Take the 3 numbers as input
<< sum;
return 0;
C
Java
Python3
C#
Javascript
Output
Enter the 1st number: 0
1. Priori Analysis: “Priori” means “before”. Hence Priori analysis means checking
the algorithm before its implementation. In this, the algorithm is checked when
it is written in the form of theoretical steps. This Efficiency of an algorithm is
measured by assuming that all other factors, for example, processor speed, are
constant and have no effect on the implementation. This is done usually by the
algorithm designer. This analysis is independent of the type of hardware and
language of the compiler. It gives the approximate answers for the complexity of
the program.
2. Posterior Analysis: “Posterior” means “after”. Hence Posterior analysis means
checking the algorithm after its implementation. In this, the algorithm is checked
by implementing it in any programming language and executing it. This analysis
helps to get the actual and real analysis report about correctness(for every
possible input/s if it shows/returns correct output or not), space required, time
consumed etc. That is, it is dependent on the language of the compiler and the
type of hardware used.
What is Algorithm complexity and how to find it?
An algorithm is defined as complex based on the amount of Space and Time it
consumes. Hence the Complexity of an algorithm refers to the measure of the Time
that it will need to execute and get the expected output, and the Space it will need to
store all the data (input, temporary data and output). Hence these two factors define
the efficiency of an algorithm.
• Fixed Part: This refers to the space that is definitely required by the algorithm.
For example, input variables, output variables, program size, etc.
• Variable Part: This refers to the space that can be different based on the
implementation of the algorithm. For example, temporary variables, dynamic
memory allocation, recursion stack space, etc.
Therefore Space complexity S(P) of any algorithm P is S(P) = C + SP(I), where
C is the fixed part and S(I) is the variable part of the algorithm, which depends
on instance characteristic I.
Example: Consider the below algorithm for Linear Search
Step 1: START
Step 2: Get n elements of the array in arr and the number to be searched in x
Step 3: Start from the leftmost element of arr[] and one by one compare x with
each element of arr[]
Step 4: If x matches with an element, Print True.
Step 5: If x doesn’t match with any of the elements, Print False.
Step 6: END
Here, There are 2 variables arr[], and x, where the arr[] is the variable part of n
elements and x is the fixed part. Hence S(P) = 1+n. So, the space complexity
depends on n(number of elements). Now, space depends on data types of given
variables and constant types and it will be multiplied accordingly.
As we know that all programming languages share basic code constructs like loops
(do, for, while), flow-control (if-else), etc. These common constructs can be used
to write an algorithm.
Example
Problem − Design an algorithm to add two numbers and display the result.
Step 1 − START
Step 2 − declare three integers a, b & c
Step 3 − define values of a & b
Step 4 − add values of a & b
Step 5 − store output of step 4 to c
Step 6 − print c
Step 7 − STOP
Algorithms tell the programmers how to code the program. Alternatively, the
algorithm can be written as −
In design and analysis of algorithms, usually the second method is used to describe
an algorithm. It makes it easy for the analyst to analyze the algorithm ignoring all
unwanted definitions. He can observe what operations are being used and how the
process is flowing.
Algorithm Analysis
We shall learn about a priori algorithm analysis. Algorithm analysis deals with the
execution or running time of various operations involved. The running time of an
operation can be defined as the number of computer instructions executed per
operation.
Algorithm Complexity
Suppose X is an algorithm and n is the size of input data, the time and space used
by the algorithm X are the two main factors, which decide the efficiency of X.
The complexity of an algorithm f(n) gives the running time and/or the storage space
required by the algorithm in terms of n as the size of input data.
Space Complexity
• A fixed part that is a space required to store certain data and variables, that
are independent of the size of the problem. For example, simple variables and
constants used, program size, etc.
Space complexity S(P) of any algorithm P is S(P) = C + SP(I), where C is the fixed
part and S(I) is the variable part of the algorithm, which depends on instance
characteristic I. Following is a simple example that tries to explain the concept −
Algorithm: SUM(A, B)
Step 1 - START
Step 2 - C ← A + B + 10
Step 3 - Stop
Here we have three variables A, B, and C and one constant. Hence S(P) = 1 + 3.
Now, space depends on data types of given variables and constant types and it will
be multiplied accordingly.
Time Complexity
For example, addition of two n-bit integers takes n steps. Consequently, the total
computational time is T(n) = c ∗ n, where c is the time taken for the addition of two
bits. Here, we observe that T(n) grows linearly as the input size increases.
1.1 What is an algorithm?
An algorithm is an explicit, precise, unambiguous, mechanically-executable
sequence of elementary instructions.
The clearest way to present an algorithm is using pseudocode. Pseudocode uses the
structure of formal programming languages and mathematics to break algorithms
into primitive steps; but the primitive steps themselves may be written using
mathematics, pure English, or an appropriate mixture of the two. Well-written
pseudocode reveals the internal structure of the algorithm but hides irrelevant
implementation details, making the algorithm much easier to understand, analyze,
debug, and implement.
The precise syntax of pseudocode is a personal choice, but the overriding goal
should be clarity and precision. Ideally, pseudocode should allow any
competent programmer to implement the underlying algorithm, quickly and
correctly, in their favorite programming language, without understanding why
the algorithm works. Here are the guidelines to follow and it is strongly
recommended
:
• Be consistent!
• Use standard imperative programming keywords (if/then/else, while, for,
repeat/until, case, return) and notation (variable value, Array[index],
function(argument), bigger > smaller, etc.). Keywords should be standard English
words: write ‘else if’ instead of ‘elif’.
• Indent everything carefully and consistently; the block structure should be visible
from across the room. This rule is especially important for nested loops and
conditionals. Don’t add unnecessary syntactic sugar like braces or begin/end tags;
careful indentation is almost always enough.
• Direct Recursion.
• Indirect Recursion.
• Tail Recursion.
• No Tail/ Head Recursion.
• Linear recursion.
• Tree Recursion.
For new computer science students, the concept of recursive programming is often
difficult. Recursive thinking is difficult because it almost seems like circular
reasoning. It’s also not an intuitive process; when we give instructions to other
people, we rarely direct them recursively.
For those of you who are new to computer programming, here’s a simple definition
of recursion: Recursion occurs when a function calls itself directly or indirectly.
The problem with this function, however, is that it would run forever because there
is no place where it stops. The function would continually call factorial. There is
nothing to stop it when it hits zero, so it would continue calling factorial on zero
and the negative numbers. Therefore, our function needs a condition to tell it when
to stop.
Since factorials of numbers less than 1 don’t make any sense, we stop at the number
1 and return the factorial of 1 (which is 1). Therefore, the real factorial function
will look like this:
Listing 2. Actual factorial function
int factorial (int n)
{
if(n == 1)
{
return 1;
}
else
{
return n * factorial(n - 1);
}
}
As you can see, as long as the initial value is above zero, this function will
terminate. The stopping point is called the base case. A base case is the bottom
point of a recursive program where the operation is so trivial as to be able to return
an answer directly. All recursive programs must have at least one base case and
must guarantee that they will hit one eventually; otherwise the program would run
forever or until the program ran out of memory or stack space.
1. Initialize the algorithm. Recursive programs often need a seed value to start
with. This is accomplished either by using a parameter passed to the function
or by providing a gateway function that is non-recursive but that sets up the
seed values for the recursive calculation.
2. Check to see whether the current value(s) being processed match the base
case. If so, process and return the value.
3. Redefine the answer in terms of a smaller or simpler sub-problem or sub-
problems.
4. Run the algorithm on the sub-problem.
5. Combine the results in the formulation of the answer.
6. Return the results.
Sometimes when writing recursive programs, finding the simpler sub-problem can
be tricky. Dealing with inductively-defined data sets, however, makes finding the
sub-problem considerably easier. An inductively-defined data set is a data structure
defined in terms of itself -- this is called an inductive definition.
For example, linked lists are defined in terms of themselves. A linked list consists
of a node structure that contains two members: the data it is holding and a pointer
to another node structure (or NULL, to terminate the list). Because the node
structure contains a pointer to a node structure within it, it is said to be defined
inductively.
With inductive data, it is fairly easy to write recursive procedures. Notice how like
our recursive programs, the definition of a linked list also contains a base case -- in
this case, the NULL pointer. Since a NULL pointer terminates a list, we can also
use the NULL pointer condition as a base case for many of our recursive functions
on linked lists.
Linked list example
1. Initialize the algorithm. This algorithm’s seed value is the first node to
process and is passed as a parameter to the function.
2. Check for the base case. The program needs to check and see if the current
node is the NULL list. If so, we return zero because the sum of all members
of an empty list is zero.
3. Redefine the answer in terms of a simpler sub-problem. We can define the
answer as the sum of the rest of the list plus the contents of the current node.
To determine the sum of the rest of the list, we call this function again with
the next node.
4. Combine the results. After the recursive call completes, we add the value of
the current node to the results of the recursive call.
Here is the pseudo-code and the real code for the function:
The pseudo-code for this program almost identically matches its Scheme
implementation.
You may be thinking that you know how write this program to perform faster or
better without recursion. We will get to the speed and space issues of recursion
later on. In the meantime, let’s continue our discussion of recursing of inductive
data sets.
Suppose we have a list of strings and want to see whether a certain string is
contained in that list. The way to break this down into a simpler problem is to look
again at the individual nodes.
The sub-problem is this: “Is the search string the same as the one in this node?” If
so, you have your solution; if not, you are one step closer. What’s the base case?
There are two:
• If the current node has the string, that’s a base case (returning “true”).
• If the list is empty, then that’s a base case (returning “false”).
This program won’t always hit the first base case because it won’t always have the
string being searched for. However, we can be certain that if the program doesn’t
hit the first base case it will at least hit the second one when it gets to the end of
the list.
Listing 6. Scheme code for determining if a given list contains a given string
(define is-in-list
(lambda (the-list the-string)
;;Check for base case of "list empty"
(if (null? the-list)
#f
;;Check for base case of "found item"
(if (equal? the-string (car the-list))
#t
;;Run the algorithm on a smaller problem
(is-in-list (cdr the-list) the-string)))))
Show more
This recursive function works fine, but it has one main shortcoming -- every
iteration of the recursion will be passing the same value for the-string. Passing the
extra parameter can increase the overhead of the function call.
However, we can set up a closure at the beginning of the function to keep the string
from having to be passed on each call:
Using a closure instead of passing the parameter doesn’t make a lot of difference
in this trivial example, but it can save a lot of typing, a lot of errors, and a lot of
overhead involved in passing variables in more complex functions.
The way of making recursive closures used in this example is a bit tedious. This
same pattern of creating a recursive closure using letrec and then calling it with an
initial seed value occurs over and over again in recursive programming.
Named let‘s are fairly confusing to talk about, so take a look at the following code
and compare it with the code in Listing 7.
The named let cuts down considerably on the amount of typing and mistakes made
when writing recursive functions. If you are still having trouble with the concept
of named lets, I suggest that you thoroughly compare every line in the above two
programs (as well as look at some of the documents in the resouces on the right
side of this article).
Our next example of a recursive function on lists will be a little more complicated.
It will check to see whether or not a list is in ascending order. If the list is in
ascending order, the function will return #t; otherwise, it will return #f. This
program will be a little different because in addition to having to examine the
current value, we will also have to remember the last value processed.
The first item on the list will have to be processed differently than the other items
because it won’t have any items preceding it. For the remaining items, we will need
to pass the previously examined data item in the function call. The function looks
like this:
This program begins by first checking a boundary condition -- whether or not the
list is empty. An empty list is considered ascending. The program then seeds the
recursive function with the first item on the list and the remaining list.
Next, the base case is checked. The only way to get to the end of the list is if
everything so far has been in order, so if the list is empty, the list is in ascending
order. Otherwise, we check the current item.
If the current item is in ascending order, we then have only a subset of the problem
left to solve -- whether or not the rest of the list is in ascending order. So we recurse
with the rest of the list and try it again.
Notice in this function how we maintained state through function calls by passing
the program forward. Previously we had just passed the remainder of the list each
time. In this function though, we needed to know a little bit more about the state of
the computation. The result of the present computation depended on the partial
results before it, so in each successive recursive call, we pass those results forward.
This is a common pattern for more complex recursive procedures.
Bugs are a part of the daily life of every programmer because even the smallest
loops and the tiniest function calls can have bugs in them. And while most
programmers can examine code and test code for bugs, they do not know how to
prove that their programs will perform the way they think they will. With this in
mind, we are going to examine some of the common sources of bugs and then
demonstrate how to make programs which are correct and can be proven so.
One of the primary sources of bugs occurs when variables change states. You might
think that the programmer would be keenly aware of exactly how and when a
variable changes state. This is sometimes true in simple loops, but usually not in
complex ones. Usually within loops, there are several ways that a given variable
can change state.
For example, if you have a complicated if statement, some branches may modify
one variable while others modify other variables. On top of that, the order is usually
important but it is difficult to be absolutely sure that the sequence coded is the
correct order for all cases. Often, fixing one bug for one case will introduce other
bugs in other cases because of these sequencing issues.
So how does a person program without modifying variables? Let’s look at several
situations in which variables are often modified and see if we can get by without
doing so:
• Reusing a variable.
• Conditional modification of a variable.
• Loop variables.
Let’s examine the first case, reusing a variable. Often, a variable is reused for
different, but similar, purposes. For example, sometimes if part of a loop needs an
index to the current position in the first half of a loop and the index immediately
before or after for the rest of the loop, many programmers use the same variable
for both cases, just incrementing it in the middle. This can easily cause the
programmer to confuse the two uses as the program is modified. To prevent this
problem, the best solution is to create two separate variables and just derive the
second from the first the same way you would do so if you were just writing to the
same variable.
Once we have rid ourselves of all variable state changes, we can know that when
we first define our variable, the definition of our variable will hold for as long as
the function lasts. This makes sequencing orders of operations much easier,
especially when modifying existing code. You don’t have to worry about what
sequence a variable might have been modified in or what assumptions were being
made about its state at each juncture.
When a variable cannot change state, the full definition of how it is derived is
illustrated when and where it is declared! You never have to go searching through
code to find the incorrect or misordered state change again!
Now, the question is how to do loops without assignment? The answer lies
in recursive functions. Take a look at the properties of loops and see how they
compare with those of recursive functions in Table 1.
As you can see, recursive functions and loops have quite a bit in common. In fact,
loops and recursive functions can be considered interchangeable. The difference is
that with recursive functions, you rarely have to modify any variable -- you just
pass the new values as parameters to the next function call. This allows you to keep
all of the benefits of not having an updateable variable while still having repetitive,
stateful behavior.
Converting a common loop to a recursive function
Let’s take a look at a common loop for printing reports and see how it can convert
into a recursive function.
• This loop will print out the page number and page headers at each page
break.
• We will assume that the report lines are grouped by some numeric criteria
and we will pretend there is some total we are keeping track of for these
groups.
• At the end of each grouping, we will print out the totals for that group.
For demonstration purposes, we’ve left out all of the subordinate functions,
assuming that they exist and that they perform as expected. Here is the code for our
report printer:
print_headings(page_number);
for(current_line = 0; current_line < num_lines; current_line++)
{
num_lines_this_page++;
if(num_lines_this_page == LINES_PER_PAGE)
{
page_number++;
page_break();
print_headings(page_number);
}
current_group = get_group(report_lines[current_line]);
if(current_group != previous_group)
{
print_totals_for_group(group_total);
group_total = 0;
}
print_line(report_lines[current_line]);
group_total += get_line_amount(report_lines[current_line]);
}
}
Show more
Several bugs have been intentionally left in the program. See if you can spot them.
Because we are continually modifying state variables, it is difficult to see whether
or not at any given moment they are correct. Here is the same program done
recursively:
/ initialize /
print_headings(page_number);
2. Backtracking Algorithms
Backtracking is an algorithmic technique where the goal is to get all solutions to a
problem using the brute force approach. It consists of building a set of all the
solutions incrementally. Since a problem would have constraints, the solutions that
fail to satisfy them will be removed.
- A backtracking algorithm is a problem-solving algorithm that uses a brute
force approach for finding the desired output. The Brute force approach tries
out all the possible solutions and chooses the desired/best solutions.
- Backtracking is a general algorithm for finding solutions to some
computational problems, notably constraint satisfaction problems that
incrementally builds candidates to the solutions, and abandons a candidate
("backtracks") as soon as it determines that the candidate cannot possibly be
completed to a valid solution.
if (found a solution) :
solutionsFound = solutionsFound + 1;
displaySolution();
[Link](0);
return
if (isValid(val, n)) :
applyValue(val, n);
removeValue(val, n);
if (found a solution) :
displaySolution();
return true;
if (isValid(val, n)) :
applyValue(val, n);
return true;
removeValue(val, n);
return false;
Let us try to solve a standard Backtracking problem, N-Queen Problem.
The N Queen is the problem of placing N chess queens on an N×N chessboard so
that no two queens attack each other. For example, following is a solution for 4
Queen problem.
The expected output is a binary matrix which has 1s for the blocks where queens
are placed. For example, following is the output matrix for the above 4 queen
solution.
{ 0, 1, 0, 0}
{ 0, 0, 0, 1}
{ 1, 0, 0, 0}
{ 0, 0, 1, 0}
return true
3) Try all rows in the current column. Do following for every tried
row.
a) If the queen can be placed safely in this row then mark this
return true.
4) If all rows have been tried and nothing worked, return false to
trigger backtracking.
The following are some standard algorithms that follow Divide and Conquer
algorithm.
Binary Search is a searching algorithm. In each step, the algorithm compares the
input element x with the value of the middle element in the array. If the values
match, return the index of the middle. Otherwise, if x is less than the middle
element, then the algorithm recurs for the left side of the middle element, else
recurs for the right side of the middle element. Contrary to popular belief, this is
not an example of Divide and Conquer because there is only one sub-problem in
each step (Divide and conquer requires that there must be two or more sub-
problems) and hence this is a case of Decrease and Conquer.
if(small(a, i, j))
return(Solution(a, i, j))
else
m = divide(a, i, j) // f1(n)
return(d)
Example:
To find the maximum and minimum element in a given array.
Input: { 70, 250, 50, 80, 140, 12, 14 }
Output: The minimum number in a given array is : 12
The maximum number in a given array is : 250
Approach: To find the maximum and minimum element from a given array is an
application for divide and conquer. In this problem, we will find the maximum and
minimum elements in a given array. In this problem, we are using a divide and
conquer approach(DAC) which has three steps divide, conquer and combine.
For Maximum:
In this problem, we are using the recursive approach to find maximum where we
will see that only two elements are left and then we can easily using condition i.e.
if(a[index] > a[index+1].)
In a program line a[index] and a[index+1])condition will ensure only two
elements in left.
if(index >= l-2)
{
if(a[index]>a[index+1])
{
// (a[index]
// Now, we can say that the last element will be maximum in a given array.
}
else
{
//(a[index+1]
// Now, we can say that last element will be maximum in a given array.
}
}
In the above condition, we have checked the left side condition to find out the
maximum. Now, we will see the right side condition to find the maximum.
Recursive function to check the right side at the current index of an array.
Now, we will compare the condition and check the right side at the current index
of a given array.
In the given program, we are going to implement this logic to check the condition
on the right side at the current index.
For Minimum:
In this problem, we are going to implement the recursive approach to find the
minimum no. in a given array.
int DAC_Min(int a[], int index, int l)
//Recursive call function to find the minimum no. in a given array.
if(index >= l-2)
// to check the condition that there will be two-element in the left
then we can easily find the minimum element in a given array.
{
// here we will check the condition
if(a[index]<a[index+1])
return a[index];
else
return a[index+1];
}
Now, we will check the condition on the right side in a given array.
Now, we will check the condition to find the minimum on the right side.
// Driver code
int main()
{
int arr[] = {120, 34, 54, 32, 58, 11, 90};
int n = sizeof(arr) / sizeof(arr[0]);
int max, min;
max = DAC_Max(arr, 0, n);
min = DAC_Min(arr, 0, n);
cout << "Maximum: " << max << endl;
cout << "Minimum: " << min << endl;
return 0;
}
In divide and conquer approach, the problem in hand, is divided into smaller sub-
problems and then each problem is solved independently. When we keep on
dividing the subproblems into even smaller sub-problems, we may eventually reach
a stage where no more division is possible. Those "atomic" smallest possible sub-
problem (fractions) are solved. The solution of all sub-problems is finally merged
in order to obtain the solution of an original problem.
Broadly, we can understand divide-and-conquer approach in a three-step process.
Divide/Break
This step involves breaking the problem into smaller sub-problems. Sub-problems
should represent a part of the original problem. This step generally takes a recursive
approach to divide the problem until no sub-problem is further divisible. At this
stage, sub-problems become atomic in nature but still represent some part of the
actual problem.
Conquer/Solve
Merge/Combine
When the smaller sub-problems are solved, this stage recursively combines them
until they formulate a solution of the original problem. This algorithmic approach
works recursively and conquer & merge steps works so close that they appear as
one.
Examples
• Merge Sort
• Quick Sort
• Binary Search
• Strassen's Matrix Multiplication
• Closest pair (points)
There are various ways available to solve any computer problem, but the mentioned
are a good example of divide and conquer approach.
Greedy Algorithms
Greedy is an algorithmic paradigm that builds up a solution piece by piece, always choosing the next
piece that offers the most obvious and immediate benefit. So the problems where choosing locally
optimal also leads to global solution are best fit for Greedy.
For example consider the Fractional Knapsack Problem. The local optimal strategy is to choose the
item that has maximum value vs weight ratio. This strategy also leads to global optimal solution
because we allowed to take fractions of an item.
Analyzing algorithms
It’s not enough just to write down an algorithm and say ‘Behold!’ We must also
convince our audience (and ourselves!) that the algorithm actually does what it’s
supposed to do, and that it does so efficiently.
Algorithms describe processes that run on real computers with finite resources.
Processes consume two resources: processing time and space or memory.
When run with the same problems or data sets, processes that consume less of these
two resources are of higher quality than processes that consume more, and so are the
corresponding algorithms. In this chapter, we introduce tools for complexity
analysis—for assessing the run-time performance or efficiency of algorithms. We
also apply these tools to search algorithms and sort algorithms.
Correctness
In some application settings, it is acceptable for programs to behave correctly most
of the time, on all ‘reasonable’ inputs. Not in this class; we require algorithms that
are correct for all possible
inputs. Moreover, we must prove that our algorithms are correct; trusting our
instincts, or trying a few test cases, isn’t good enough. Sometimes correctness is
fairly obvious, especially for algorithms you’ve seen in earlier courses. On the other
hand, ‘obvious’ is all too often a synonym for ‘wrong’. Many of the algorithms we
will discuss in this course will require extra work to prove correct. Correctness
proofs almost always involve induction. We like induction. Induction is our friend.¹²
But before we can formally prove that our algorithm does what it’s supposed to do,
we have to formally state what it’s supposed to do! Algorithmic problems are usually
presented using standard English, in terms of real-world objects, not in terms of
formal mathematical objects. It’s
up to us, the algorithm designers, to restate these problems in terms of mathematical
objects that we can prove things about—numbers, arrays, lists, graphs, trees, and so
on. We must also determine if the problem statement carries any hidden
assumptions, and state those assumptions explicitly. (For example, in the song “n
Bottles of Beer on the Wall”, n is always a positive integer.)
Restating the problem formally is not only required for proofs; it is also one of the
best ways to
really understand what a problem is asking for. The hardest part of answering any
question is figuring out the right way to ask it!
It is important to remember the distinction between a problem and an algorithm. A
problem is a task to perform, like “Compute the square root of x” or “Sort these n
numbers” or “Keep n
algorithms students awake for t minutes”. An algorithm is a set of instructions for
accomplishing
such a task. The same problem may have hundreds of different algorithms; the same
algorithm may solve hundreds of different problems.
Running time
The most common way of ranking different algorithms for the same problem is by
how quickly
they run. Ideally, we want the fastest possible algorithm for any particular problem.
In many application settings, it is acceptable for programs to run efficiently most of
the time, on all ‘reasonable’ inputs. Not in this class; we require algorithms that
always run efficiently, even in the worst case.
But how do we measure running time? As a specific example, how long does it take
to sing the song BottlesOfBeer(n)? This is obviously a function of the input value n,
but it also depends on how quickly you can sing. Some singers might take ten
seconds to sing a verse; others might take twenty. Technology widens the
possibilities even further. Dictating the song over a telegraph using Morse code
might take a full minute per verse. Downloading an mp3 over the Web might take a
tenth of a second per verse. Duplicating the mp3 in a computer’s main memory might
take only a few microseconds per verse.
Algorithm Analysis
We shall learn about a priori algorithm analysis. Algorithm analysis deals with the
execution or running time of various operations involved. The running time of an
operation can be defined as the number of computer instructions executed per
operation.
Algorithm Complexity
Suppose X is an algorithm and n is the size of input data, the time and space used by
the algorithm X are the two main factors, which decide the efficiency of X.
- Time Factor – Time is measured by counting the number of key operations
such as comparisons in the sorting algorithm.
- Space Factor − Space is measured by counting the maximum memory space
required by the algorithm.
There are different types of time complexities, so let's check the most basic ones.
Complexity of algorithms
The complexity of an algorithm f(n) gives the running time and/or the storage space
required by the algorithm in terms of n as the size of input data.
Space Complexity
Space complexity of an algorithm represents the amount of memory space required
by the algorithm in its life cycle. The space required by an algorithm is equal to the
sum of the following two components –
- A fixed part, that is, a space required to store certain data and variables, that
are independent of the size of the problem. For example, simple variables and
constants used, program size, etc.
- A variable part is a space required by variables, whose size depends on the
size of the problem. For example, dynamic memory allocation, recursion stack
space, etc.
Space complexity S(P) of any algorithm P is S(P) = C + SP(I), where C is the fixed
part and S(I) is the variable part of the algorithm, which depends on instance
characteristic I
Algorithm: SUM(A, B)
Step 1 - START
Step 2 - C ← A + B + 10
Step 3 - Stop
Here we have three variables A, B, and C and one constant. Hence S(P) = 1+3. Now,
space depends on data types of given variables and constant types and it will be
multiplied accordingly.
Time Complexity
For example, addition of two n-bit integers takes n steps. Consequently, the total
computational time is T(n) = c*n, where c is the time taken for the addition of two
bits. Here, we observe that T(n) grows linearly as the input size increases.
Example
What is time complexity of fun()?
int fun(int n)
{
int count = 0;
for (int i = n; i > 0; i /= 2)
for (int j = 0; j < i; j++)
count += 1;
return count;
}
(A) O(n^2)
(B) O(nLogn)
(C) O(n)
(D) O(nLognLogn)
Answer: (C)
if (n <= 2)
return 1;
else
(A) (n)
(B) (nlogn)
(C) (logn)
(D) (loglogn)
(A) A
(B) B
(C) C
(D) D
Answer: (D)
T(n) = T( ) + C1 if n > 2
We have ignored the floor() part as it doesn’t matter here if it’s a floor or ceiling.
S(m) = O(logm)
T(n) = S(m)
= O(LogLogn)
Asymptotic Analysis
Asymptotic analysis of an algorithm refers to defining the mathematical
boundation/framing of its run-time performance. Using asymptotic analysis, we can
very well conclude the best case, average case, and worst case scenario of an
algorithm. Asymptotic analysis is input bound i.e., if there's no input to the
algorithm, it is concluded to work in a constant time. Other than the "input" all other
factors are considered constant.
Asymptotic Notations
Following are the commonly used asymptotic notations to calculate the running time
complexity of an algorithm.
- Ο Notation
- Ω Notation
- θ Notation
Big Oh Notation, Ο
The notation Ο(n) is the formal way to express the upper bound of an algorithm's
running time. It measures the worst case time complexity or the longest amount of
time an algorithm can possibly take to complete.
Omega Notation, Ω
The notation Ω(n) is the formal way to express the lower bound of an algorithm's
running time. It measures the best case time complexity or the best amount of time
an algorithm can possibly take to complete.
For example, for a function f(n)
Ω(f(n)) ≥ { g(n) : there exists c > 0 and n0 such that g(n) ≤ c.f(n) for all n > n0. }
Theta Notation, θ
The notation θ(n) is the formal way to express both the lower bound and the upper
bound of an algorithm's running time. It is represented as follows −
θ(f(n)) = { g(n) if and only if g(n) = Ο(f(n)) and g(n) = Ω(f(n)) for all n > n0. }
Common Asymptotic Notations
Following is a list of some common asymptotic notations:
Constant - Ο(1)
Logarithmic - Ο(log n)
linear - Ο(n)
nlogn - Ο(n log n)
quadratic - O(n2)
cubic - O(n3)
Polynomial - nΟ(1)
exponential 2Ο(n)
The very first thing that a good developer considers while choosing between different
algorithms is how much time will it take to run and how much space will it need. In
this article we are going to talk about why considering time complexity is important
and also what are some common time complexities.
Take an example of Google maps, you would want the shortest path from A to B as
fast as possible. Or in case of Data Analysis, you would want the analysis to be done
as fast as possible. So, to get desired results from the algorithm in optimum amount
of time, we take time complexity into consideration.
There are three types of asymptotic notations used to calculate the running time
complexity of an algorithm:
1) Big-O
2) Big Omega
3) Big theta
It describes the limiting behavior of a function, when the argument tends towards a
particular value or infinity. It tells the lower bound of an algorithm’s running time. It
measure’s the best case or best amount of time an algorithm can possibly take to
complete.
For example: We have an algorithm that has Ω(n²) running time complexity, then it
is also true that the algorithm has an Ω(n) or Ω(log n) or Ω(1) time complexity.
It describes the limiting behavior of a function, when the argument tends towards a
particular value or infinity. It tells both the lower bound and the upper bound of an
algorithm’s running time.
Big-O notation:
It describes the limiting behavior of a function, when the argument tends towards a
particular value or infinity. It tells the upper bound of an algorithm’s running time. It
measure’s the worst case or the longest amount of time an algorithm can possibly
take to complete.
For example: We have an algorithm that has O(n²) as time complexity, then it is also
true that the algorithm has O(n³) or O(n⁴) or O(n⁵) time complexity.
When the algorithm doesn’t depend on the input size then it is said to have a constant
time complexity.
Other example can be when we have to determine whether the number is odd or even.
For all these examples the time complexity is O(1) as it is independent of input size.
When the size of input is reduced in each step then the algorithm is said to have
Logarithmic time complexity. The common example for logarithmic time complexity
is binary search. As we know binary search tree is a sorted or ordered tree. The left
node is always a lesser number than the right node.
Let us take an example of binary search where we need to find the position of an
element in sorted list.
def bin_search(data, value): n = len(data)
#get length of list
left = 0
#initiate left node
right = n – 1
#make right node as n-1 while left <= right:
#when left node <= to right node
mid=(left + right)//2 #divide in two equal parts
if value < data[mid]: #if value less than middle number
right = mid – 1 #then mid–1value in right node
elif value > data[mid]: #if value greater than middle number
left = mid + 1 #then left node is mid+1
else:
return mid
#else return middleif __name__ == ‘__main__’:
data = [10, 20, 30, 40, 50, 60, 70, 80, 90]
print(bin_search(data, 8))
Here you can see in the code that we are dividing the input size at each step in two
parts, hence we can conclude that the time complexity here is O(log n).
When the time complexity increases linearly with the input size then the algorithm is
supposed to have a Linear time complexity. Consider that we have an algorithm, and
we are calculating the time it takes to sort items.
For example, consider an unsorted list and we want to find out the maximum number
in the list
Numbers = [10,20,300,40.5,50]
maximum = Numbers[0]
for num in Numbers:
if num > maximum:
maximum = num
print(maximum)
In this example we need to look through all the values of list and check whether the
number is greater than the previous number which is stored in the variable
‘maximum’. Hence, the searching through each value in list makes it a time
complexity of O(n), as you are repeating the same action for each number using ‘for’
loop. And inside the for loop it is a checking whether a condition is true or not only
once, hence the time complexity is O(1). Therefore, the overall time complexity
becomes O(n).
When each operation in input data have a logarithm time complexity then the
algorithm is said to have quasilinear time complexity.
You can compare this with Linear time complexity, just like in linear complexity
where each input had O(1) time complexity resulting in O(n) time complexity for ’n’
inputs. Similarly here, each input has O(log n) and there are such ’n’ inputs hence the
resulting time complexity is O(n log n).
When the algorithm performs linear operation having O(n) time complexity for each
value in input data, which has ’n’ inputs, then it is said to have a quadratic time
complexity.
For example, we can say whenever there is a nested ‘for’ loop the time complexity is
going to be quadratic time complexity. Because we are iterating through all the values
for each value in the list making it O(n) * O(n) i.e. O(n²) time complexity.
for i in list:
for j in list:
print(i, j)
Few examples of quadratic time complexity are bubble sort, insertion sort, etc.
In this ‘c’ is any constant. Let’s consider c=2 for our article. Therefore, the time
complexity becomes O(2^n).
When the time required by the algorithm doubles then it is said to have exponential
time complexity.
Some of the examples for exponential time complexity are calculating Fibonacci
numbers, solving traveling salesman problem with dynamic programming, etc.
For calculating Fibonacci numbers, we use recursive function, which means that the
function calls itself in the function. Hence the time complexity depends on how many
times does the function calls itself and also on the time complexity of function.
def fibonacci(n):
if n <=1
return n
return Fibonacci(n-1) + Fibonacci(n-2) #function calls itself
7) Factorial [O(n!)]:
When the algorithm grows in a factorial way based on the input size, we can say that
the algorithm has factorial time complexity.
Simple example for this can be finding the factorial of given number.
def factorial(n):
for i in range(n):
print(n)
factorial(n-1)
The best case in this example would be when the number that we have to search is
the first number in the array i.e. 12. The number would be found out in one iteration
because the number is at an index 0 hence it becomes the best-case scenario, as it
requires least amount of time to search for number in the array, resulting in giving
optimum time complexity of O(1).
The worst case in the above-mentioned example would be when the number to be
searched is at the end of the array i.e. if we are searching for number 0 in the given
example.
Here the number zero is at an index 6 and we have to traverse through the whole array
to find it. Therefore, the algorithm takes the longest time to search for a number in
the array, resulting in increasing the time complexity. O(n) becomes the time
complexity.
There can be another worst-case scenario when the number to be searched is not in
the given array.
Conclusion
From above observations we can say that algorithms with time complexity such as
O(1), O(log n) and O(n) are considered to be fast. Whereas, algorithms with time
complexity of O(n log n) can also be considered as fast but any time complexity
above O(n log n) such as O(n²), O(c^n) and O(n!) are considered to be slow. Hence
we can say that O(n log n) acts like a threshold, any time complexity above it is
slower than the complexities below it.
Of course, when you try to solve complex problems you will come up with hundred
different ways to solve it. So, the point here is not of ‘right’ or ‘wrong’ but of ‘better’
and ‘worse’. Hence, whenever you write a code take time complexity into
perspective, as it will prove to be beneficial in a long run.
Time and Space Complexity Analysis of Algorithm
Every day we come across many problems and we find one or more than one
solutions to that particular problem. Some solutions may be efficient as compared to
others and some solutions may be less efficient. Generally, we tend to use the most
efficient solution.
For example, while going from your home to your office or school or college, there
can be "n" number of paths. But you choose only one path to go to your destination
i.e. the shortest path.
The same idea we apply in the case of the computational problems or problem-
solving via computer. We have one computational problem and we can design
various solutions i.e. algorithms and we choose the most efficient algorithm out of
those developed algorithms.
Critical Ideas to think!
What is an Algorithm?
For example, you have two integers "a" and "b" and you want to find the sum of
those two number. How will you solve this? One possible solution for the above
problem can be:
In the above example, you will find three things i.e. input, algorithm, and output:
• Input: Input is something for which you need to write an algorithm and
transform it into the desired output. Just like in machines where you give some
raw product and the machine transforms the raw product into some desirable
product. In our example, the input is the two numbers i.e. "a" and "b". Before
writing an algorithm, you should find the data type of input, distribution or
range of input and other relevant details related to it. So critically analyse your
input before writing the solution.
• Output: Output is the desired result in the problem. For example, if we are
finding the sum of two integers a and b then for every value of a and b it must
produce the correct sum as an output.
What do you mean by a good Algorithm?
There can be many algorithms for a particular problem. So, how will you classify an
algorithm to be good and others to be bad? Let's understand the properties of a good
algorithm:
• Finiteness: Generally, people ignore this but it is one of the important factors
in algorithm evaluation. The algorithm must always terminate after a finite
number of steps. For example, in the case of recursion and loop, your
algorithm should terminate otherwise you will end up having a stack overflow
and infinite loop scenario respectively.
1. The algorithm should efficiently use the resources available to the system.
Algorithm Efficiency
The efficiency of an algorithm is mainly defined by two factors i.e. space and time.
A good algorithm is one that is taking less time and less space, but this is not possible
all the time. There is a trade-off between time and space. If you want to reduce the
time, then space might increase. Similarly, if you want to reduce the space, then the
time may increase. So, you have to compromise with either space or time. Let's learn
more about space and time complexity of algorithms.
Space Complexity
Space Complexity of an algorithm denotes the total space used or needed by the
algorithm for its working, for various input sizes. For example:
vector<int> myVec(n);
In the above example, we are creating a vector of size n. So the space complexity of
the above code is in the order of "n" i.e. if n will increase, the space requirement will
also increase accordingly.
Even when you are creating a variable then you need some space for your algorithm
to run. All the space required for the algorithm is collectively called the Space
Complexity of the algorithm.
NOTE: In normal programming, you will be allowed to use 256MB of space for a
particular problem. So, you can't create an array of size more 10^8 because you will
be allowed to use only 256MB. Also, you can't create an array of size more than
10^6 in a function because the maximum space allotted to a function is 4MB. So, to
use an array of more size, you can create a global array.
Time Complexity
Input Size: Input size is defined as total number of elements present in the input.
For a given problem we characterize the input size n appropriately. For example:
The time taken by an algorithm also depends on the computing speed of the system
that you are using, but we ignore those external factors and we are only concerned
on the number of times a particular statement is being executed with respect to the
input size. Let's say, for executing one statement, the time taken is 1sec, then what
is the time taken for executing n statements, It will take n seconds.
Suppose you are having one problem and you wrote three algorithms for the same
problem. Now, you need to choose one out of those three algorithms. How will you
do that?
• One thing that you can do is just run all the three algorithms on three different
computers, provide same input and find the time taken by all the three
algorithms and choose the one that is taking the least amount of time. Is it ok?
No, all the systems might be using some different processors. So, the
processing speed might vary. So, we can't use this approach to find the most
efficient algorithm.
• Another thing that you can do is run all the three algorithms on the same
computer and try to find the time taken by the algorithm and choose the best.
But here also, you might get wrong results because, at the time of execution
of a program, there are other things that are executing along with your
program, so you might get the wrong time.
NOTE: One thing that is to be noted here is that we are finding the time taken by
different algorithms for the same input because if we change the input then the
efficient algorithm might take more time as compared to the less efficient one
because the input size is different for both algorithms.
So, we have seen that we can't judge an algorithm by calculating the time taken
during its execution in a particular system. We need some standard notation to
analyse the algorithm. We use Asymptotic notation to analyse any algorithm and
based on that we find the most efficient algorithm. Here in Asymptotic notation, we
do not consider the system configuration, rather we consider the order of growth of
the input. We try to find how the time or the space taken by the algorithm will
increase/decrease after increasing/decreasing the input size.
There are three asymptotic notations that are used to represent the time complexity
of an algorithm. They are:
• Θ Notation (theta)
• Big O Notation
• Ω Notation
Before learning about these three asymptotic notation, we should learn about the
best, average, and the worst case of an algorithm.
An algorithm can have different time for different inputs. It may take 1 second for
some input and 10 seconds for some other input.
For example: We have one array named "arr" and an integer "k". we need to find if
that integer "k" is present in the array "arr" or not? If the integer is there, then
return 1 other return 0. Try to make an algorithm for this question.
• Input: Here our input is an integer array of size "n" and we have one integer
"k" that we need to search for in that array.
• Output: If the element "k" is found in the array, then we have return 1,
otherwise we have to return 0.
Now, one possible solution for the above problem can be linear search i.e. we will
traverse each and every element of the array and compare that element with "k". If
it is equal to "k" then return 1, otherwise, keep on comparing for more elements in
the array and if you reach at the end of the array and you did not find any element,
then return 0.
/*
*/
if (arr[i] == k)
return 1; // return 1, if you find "k"
/*
* [Explanation]
* return 0 ---------> will be executed once(if "k" is not there in the array)
*/
Each statement in code takes constant time, let's say "C", where "C" is some
constant. So, whenever you declare an integer then it takes constant time when you
change the value of some integer or other variables then it takes constant time, when
you compare two variables then it takes constant time. So, if a statement is taking
"C" amount of time and it is executed "N" times, then it will take C*N amount of
time. Now, think of the following inputs to the above algorithm that we have just
written:
NOTE: Here we assume that each statement is taking 1sec of time to execute.
• If the input array is [1, 2, 3, 4, 5] and you want to find if "1" is present in the
array or not, then the if-condition of the code will be executed 1 time and it
will find that the element 1 is there in the array. So, the if-condition will take
1 second here.
• If the input array is [1, 2, 3, 4, 5] and you want to find if "3" is present in the
array or not, then the if-condition of the code will be executed 3 times and it
will find that the element 3 is there in the array. So, the if-condition will take
3 seconds here.
• If the input array is [1, 2, 3, 4, 5] and you want to find if "6" is present in the
array or not, then the if-condition of the code will be executed 5 times and it
will find that the element 6 is not there in the array and the algorithm will
return 0 in this case. So, the if-condition will take 5 seconds here.
As you can see that for the same input array, we have different time for different
values of "k". So, this can be divided into three cases:
• Best case: This is the lower bound on running time of an algorithm. We must
know the case that causes the minimum number of operations to be executed.
In the above example, our array was [1, 2, 3, 4, 5] and we are finding if "1" is
present in the array or not. So here, after only one comparison, you will get
that your element is present in the array. So, this is the best case of your
algorithm.
• Average case: We calculate the running time for all possible inputs, sum all
the calculated values and divide the sum by the total number of inputs. We
must know (or predict) distribution of cases.
So, we learned about the best, average, and worst case of an algorithm. Now, let's
get back to the asymptotic notation where we saw that we use three asymptotic
notations to represent the complexity of an algorithm i.e. Θ Notation (theta), Ω
Notation, Big O Notation.
NOTE: In the asymptotic analysis, we generally deal with large input size.
Θ Notation (theta)
The Θ Notation is used to find the average bound of an algorithm i.e. it defines an
upper bound and a lower bound, and your algorithm will lie in between these levels.
So, if a function is g(n), then the theta representation is shown as Θ(g(n)) and the
relation is shown as:
Θ(g(n)) = { f(n): there exist positive constants c1, c2 and n0
The above expression can be read as theta of g(n) is defined as set of all the functions
f(n) for which there exists some positive constants c1, c2, and n0 such that c1*g(n)
is less than or equal to f(n) and f(n) is less than or equal to c2*g(n) for all n that is
greater than or equal to n0.
For example:
if f(n) = 2n² + 3n + 1
and g(n) = n²
Ω Notation
The Ω notation denotes the lower bound of an algorithm i.e. the time taken by the
algorithm can't be lower than this. In other words, this is the fastest time in which
the algorithm will return a result. It’s the time taken by the algorithm when provided
with its best-case input. So, if a function is g(n), then the omega representation is
shown as Ω(g(n)) and the relation is shown as:
The above expression can be read as omega of g(n) is defined as set of all the
functions f(n) for which there exist some constants c and n0 such that c*g(n) is less
than or equal to f(n), for all n greater than or equal to n0.
if f(n) = 2n² + 3n + 1
and g(n) = n²
Big O Notation
The Big O notation defines the upper bound of any algorithm i.e. you algorithm can't
take more time than this time. In other words, we can say that the big O notation
denotes the maximum time taken by an algorithm or the worst-case time complexity
of an algorithm. So, big O notation is the most used notation for the time complexity
of an algorithm. So, if a function is g(n), then the big O representation of g(n) is
shown as O(g(n)) and the relation is shown as:
The above expression can be read as Big O of g(n) is defined as a set of functions
f(n) for which there exist some constants c and n0 such that f(n) is greater than or
equal to 0 and f(n) is smaller than or equal to c*g(n) for all n greater than or equal
to n0.
if f(n) = 2n² + 3n + 1
and g(n) = n²
In this example, we have to find the sum of first n numbers. For example, if n = 4,
then our output should be 1 + 2 + 3 + 4 = 10. If n = 5, then the ouput should be 1 +
2 + 3 + 4 + 5 = 15. Let's try various solutions to this code and try to compare all
those codes.
O(1) solution
int findSum(int n)
In the above code, there is only one statement and we know that a statement takes
constant time for its execution. The basic idea is that if the statement is taking
constant time, then it will take the same amount of time for all the input size and we
denote this as O(1).
O(n) solution
In this solution, we will run a loop from 1 to n and we will add these values to a
variable named "sum".
int findSum(int n)
for(int i = 1; i <= n; ++i) // --> here the comparision and increment will take
place n times(c2*n) and the creation of i takes place with some constant time
sum = sum + i; // -----------> this statement will be executed n times i.e. c3*n
/*
* here in our example we have 3 constant time taking statements i.e. "sum = 0", "i
= 0", and "return sum", so we can add all the constatnts and replacce with some
new constant "c"
* apart from this, we have two statements running n-times i.e. "i < n(in real n+1)"
and "sum = sum + i" i.e. c2*n + c3*n = c0*n
The big O notation of the above code is O(c0*n) + O(c), where c and c0 are
constants. So, the overall time complexity can be written as O(n).
O(n²) solution
In this solution, we will increment the value of sum variable "i" times i.e. for i = 1,
the sum variable will be incremented once i.e. sum = 1. For i = 2, the sum variable
will be incremented twice. So, let's see the solution.
int findSum(int n)
/*
* So, total complexity will be: c1*n² + c2*n + c3 [c1 is for the constant terms of n²,
c2 is for the constant terms of n, and c3 is for rest of the constant time]
*/
The big O notation of the above algorithm is O(c1*n²) +O( c2*n) + O(c3). Since we
take the higher order of growth in big O. So, our expression will be reduced to O(n²).
So, until now, we saw 3 solutions for the same problem. Now, which algorithm will
you prefer to use when you are finding the sum of first "n" numbers? If your answer
is O(1) solution, then we have one bonus section for you at the end of this blog. We
would prefer the O(1) solution because the time taken by the algorithm will be
constant irrespective of the input size.
In this part of the blog, we will find the time complexity of various searching
algorithms like the linear search and the binary search.
Linear Search
In a linear search, we will be having one array and one element is also given to us.
We need to find the index of that element in the array. For example, if our array is
[8, 10, 3, 2, 9] and we want to find the position of "3", then our output should be 2
(0 based indexing). Following is the code for the same:
/*
* @type of arr: integer array
*/
if(arr[i] == k)
return i;
return -1;
/*
* [Explanation]
* return -1 --------> will be executed once(if "k" is not there in the array)
*/
The worst-case time complexity of linear search is O(n) because in the worst case
the "if(arr[i] == k)" statement will be executed "n" times.
Binary Search
In a binary search, we will be having one sorted array and an element will be given.
We have to find the position of that element in the array. To do so, we follow the
below steps:
1. Divide the whole array into two parts by finding the middle element of the
array.
2. Find if the middle element is equal to the element "k" that you are searching
for. If it is equal, then return the value.
3. If the middle element is not equal to element "k", then find if the element "k"
is larger than or smaller than the middle element.
4. If the element "k" is larger than the middle element, then we will perform the
binary search in the [mid+1 to n] part of the array and if the element "k" is
smaller than the middle element, then we will perform the binary search in the
[0 to mid-1] part of the array.
/*
*/
{
while (left <= right) {
if (arr[mid] == k)
if (arr[mid] < k)
else
return -1;
Let's understand the working of the above code with the help of one example.
Finding the Time Complexity of Binary Search
• For finding the element "k", let's say after "ith" iteration, the iteration of
Binary search stops i.e. the size of the array becomes 1. Also, we are reducing
the size of our array by half after every iteration.
• So, during 1st iteration the size of the array is "n", during 2nd iteration the
size of the array is "n/2", during 3rd iteration the size of the array is "(n/2)/2
= n/2²", during 4th iteration the size of the array is "((n/2)/2)/2 = n/2³", and so
on.
• So, after the ith iteration, the size of the array will be n/2^i. Also, after
the ith iteration, the length of the array will become 1. So, the following
relation should hold true:
=> n/2^i = 1
=> n = 2^i
=> log2 (n) = log2 (2^i) [applying log2 both sides]
In this part of the blog, we will learn about the time complexity of the various sorting
algorithm. Sorting algorithms are used to sort a given array in ascending or
descending order. So, let's start with the Selection Sort.
Selection Sort
In selection sort, in the first pass, we find the minimum element of the array and put
it in the first place. In the second pass, we find the second smallest element of the
array and put it in the second place and so on.
/*
*/
{
// move from index 0 to n-1
int minIndex = i;
minIndex = j;
swap(arr[minIndex], arr[i]);
Bubble Sort
In bubble sort, we compare the adjacent elements and put the smallest element before
the largest element. For example, if the two adjacent elements are [4, 1], then the
final output will be [1, 4].
/*
*/
Insertion Sort
In Insertion sort, we start with the 1st element and check if that element is smaller
than the 0th element. If it is smaller then we put that element at the desired place
otherwise we check for 2nd element. If the 2nd element is smaller than 0th or 1st
element, then we put the 2nd element at the desired place and so on.
/*
*/
arr[j + 1] = arr[j];
j = j - 1;
Merge Sort
Merger Sort uses Divide and Conquer technique(you will learn more about divide
and conquer in this Data Structure series). The following steps are involved in Merge
Sort:
• Divide the array into two halves by finding the middle element.
• Call the Merge Sort function on the first half and the second half.
Here, we will use recursion, so to learn about recursion, you can read from here).
while(i <= mid && j <= end) // traverse and add smaller of both elements
in temp
{
temp[k] = arr[i];
k += 1; i += 1;
else
temp[k] = arr[j];
k += 1; j += 1;
temp[k] = arr[i];
k += 1; i += 1;
}
temp[k] = arr[j];
k += 1; j += 1;
/*
*/
The following table shows the best case, average case, and worst-case time
complexity of various sorting algorithms:
-----------------------------------------------------------------------------
|------------------|------------------|------------------|------------------|
-----------------------------------------------------------------------------
Bonus Section :)
So, here is one bonus section for you. This will help you in choosing the best solution
for a particular question that you will be solving on our website.
So, when you solve some coding questions, then you will be given some input
constraints and based on those constraints you have to decide the time complexity
of your algorithm. Generally, a typical computer system executes 10^18 operations
in one second. So, if the time limit for a particular question is one second and you
are trying to execute more than 10^18 instruction per second, then you will get Time
Limit Exceed(TLE) error. So, based on the input size, you should decide the time
complexity of your algorithm. The following table will help you to decide the time
complexity of your algorithm based on the input size:
--------------------------------
|-----------|------------------|
|10^18 | O(logn) |
|10^8 | O(n) |
|10^7 | O(nlogn) |
|10^4 | O(n^2) |
|10^2 | O(n^3) |
|9*10 | O(n^4) |
--------------------------------
Use this table to decide the complexity of your code before writing the code for any
problem and get rid of the TLE (thank me by solving questions from here ;))