Data Structures and Algorithms
Data Structures and Algorithms
Algorithms
1. Introduction
1.1 Variables
1.2 Data Types
1.3 Data Structures
1.4 Abstract Data Types (ADTs)
1.5 What is an Algorithm?
1.6 Why the Analysis of Algorithms?
1.7 Goal of the Analysis of Algorithms
1.8 What is Running Time Analysis?
1.9 How to Compare Algorithms
1.10 What is Rate of Growth?
1.11 Commonly Used Rates of Growth
1.12 Types of Analysis
1.13 Asymptotic Notation
1.14 Big-O Notation
4. Stacks
4.1 What is a Stack?
4.2 How Stacks are used
4.3 Stack ADT
4.4 Applications
4.5 Implementation
4.6 Comparison of Implementations
4.7 Stacks: Problems & Solutions
5. Queues
5.1 What is a Queue?
5.2 How are Queues Used?
5.3 Queue ADT
5.4 Exceptions
5.5 Applications
5.6 Implementation
5.7 Queues: Problems & Solutions
6. Trees
6.1 What is a Tree?
6.2 Glossary
6.3 Binary Trees
6.4 Types of Binary Trees
6.5 Properties of Binary Trees
7. Sorting
7.1 What is Sorting?
7.2 Why is Sorting Necessary?
7.3 Classification of Sorting Algorithms
7.4 Other Classifications
7.5 Bubble Sort
7.6 Selection Sort
7.7 Insertion Sort
7.8 Shell Sort
7.9 Merge Sort
7.10 Heap Sort
7.11 Quick Sort
7.12 Tree Sort
7.13 Comparison of Sorting Algorithms
8. Searching
8.1 What is Searching?
8.2 Why do we need Searching?
8.3 Types of Searching
8.4 Unordered Linear Search
8.5 Sorted/Ordered Linear Search
8.6 Binary Search
8.7 Interpolation Search
8.8 Comparing Basic Searching Algorithms
8.9 Symbol Tables and Hashing
The objective of this chapter is to explain the importance of the analysis of
algorithms, their notations, relationships and solving as many problems as
possible. Let us first focus on understanding the basic elements of algorithms,
the importance of algorithm analysis, and then slowly move toward the other
topics as mentioned above. After completing this chapter, you should be able to
find the complexity of any given algorithm (especially recursive functions).
1.1 Variables
Before going to the definition of variables, let us relate them to old mathematical
equations. All of us have solved many mathematical equations since childhood.
As an example, consider the below equation:
We don’t have to worry about the use of this equation. The important thing that
we need to understand is that the equation has names (x and y), which hold values
(data). That means the names (x and y) are placeholders for representing data.
Similarly, in computer science programming we need something for holding
data, and variables is the way to do that.
Computer memory is all filled with zeros and ones. If we have a problem and we
want to code it, it’s very difficult to provide the solution in terms of zeros and
ones. To help users, programming languages and compilers provide us with data
types. For example, integer takes 2 bytes (actual value depends on compiler),
float takes 4 bytes, etc. This says that in memory we are combining 2 bytes (16
bits) and calling it an integer. Similarly, combining 4 bytes (32 bits) and calling
it a float. A data type reduces the coding effort. At the top level, there are two
types of data types:
For example, “int” may take 2 bytes or 4 bytes. If it takes 2 bytes (16 bits), then
the total possible values are minus 32,768 to plus 32,767 (-215 to 215-1). If it takes
4 bytes (32 bits), then the possible values are between -2,147,483,648 and
+2,147,483,647 (-231 to 231-1). The same is the case with other data types.
1. Declaration of data
2. Declaration of operations
Commonly used ADTs include: Linked Lists, Stacks, Queues, Priority Queues,
Binary Trees, Dictionaries, Disjoint Sets (Union and Find), Hash Tables,
Graphs, and many others. For example, stack uses LIFO (Last-In-First-Out)
mechanism while storing the data in data structures. The last element inserted
into the stack is the first element that gets deleted. Common operations of it are:
creating the stack, pushing an element onto the stack, popping an element from
stack, finding the current top of the stack, finding number of elements in the
stack, etc.
While defining the ADTs do not worry about the implementation details. They
come into the picture only when we want to use them. Different kinds of ADTs
are suited to different kinds of applications, and some are highly specialized to
specific tasks. By the end of this book, we will go through many of them and
you will be in a position to relate the data structures to the kind of problems they
solve.
What we are doing is, for a given problem (preparing an omelette), we are
providing a step-by-step procedure for solving it. The formal definition of an
algorithm can be stated as:
An algorithm is the step-by-step unambiguous instructions to solve a given
problem.
In the traditional study of algorithms, there are two main criteria for judging the
merits of algorithms: correctness (does the algorithm give solution to theproblem
in a finite number of steps?) and efficiency (how much resources (in terms of
memory and time) does it take to execute the).
• Size of an array
• Polynomial degree
• Number of elements in a matrix
• Number of bits in the binary representation of the input
• Vertices and edges in a graph.
Ideal solution? Let us assume that we express the running time of a given
algorithm as a function of the input size n (i.e., f(n)) and compare these different
functions corresponding to running times. This kind of comparison is
independent of machine time, programming style, etc.
For the above-mentioned example, we can represent the cost of the car and the
cost of the bicycle in terms of function, and for a given function ignore the low
order terms that are relatively insignificant (for large value of input size, n). As
an example, in the case below, n4, 2n2, 100n and 500 are the individual costs of
some function and approximate to n4 since n4 is the highest rate of growth.
In general, the first case is called the best case and the second case is called the
worst case for the algorithm. To analyze an algorithm we need some kind of
syntax, and that forms the base for asymptotic analysis/notation. There are three
types of analysis:
• Worst case
○ Defines the input for which the algorithm takes a long time
(slowest time to complete).
○ Input is the one for which the algorithm runs the slowest.
• Best case
○ Defines the input for which the algorithm takes the least time
(fastest time to complete).
○ Input is the one for which the algorithm runs the fastest.
• Average case
○ Provides a prediction about the running time of the algorithm.
○ Run the algorithm many times, using many different inputs
that come from some distribution that generates these
inputs, compute the total running time (by adding the
individual times), and divide by the number of trials.
○ Assumes that the input is random.
For a given algorithm, we can represent the best, worst and average cases in the
form of expressions. As an example, let f(n) be the function which represents the
given algorithm.
Similarly for the average case. The expression defines the inputs with which the
algorithm takes the average running time (or memory).
Generally we discard lower values of n. That means the rate of growth at lower
values of n is not important. In the figure, n0 is the point from which we need to
consider the rate of growth for a given algorithm. Below n0, the rate of growth
could be different. n0 is called threshold for the given function.
Big-O Visualization
O(g(n)) is the set of functions with smaller or the same order of growth as g(n).
For example; O(n2) includes O(1), O(n), O(nlogn), etc.
Note: Analyze the algorithms at larger values of n only. What this means is,
below n0 we do not care about the rate of growth.
Big-O Examples
Example-1 Find upper bound for f(n) = 3n + 8
Solution: 3n + 8 ≤ 4n, for all n ≥ 8
∴ 3n + 8 = O(n) with c = 4 and n0 = 8
Example-2 Find upper bound for f(n) = n2 + 1
Solution: n2 + 1 ≤ 2n2, for all n ≥ 1
∴ n2 + 1 = O(n2) with c = 2 and n0 = 1
No Uniqueness?
There is no unique set of values for n 0 and c in proving the asymptotic bounds.
Let us consider, 100n + 5 = O(n). For this function there are multiple n0 and c
values possible.
Solution1: 100n + 5 ≤ 100n + n = 101n ≤ 101n, for all n ≥ 5, n0 = 5 and c = 101
is a solution.
Solution2: 100n + 5 ≤ 100n + 5n = 105n ≤ 105n, for all n > 1, n0 = 1 and c =
105 is also a solution.
2.1 Introduction
In this chapter, we will look at one of the important topics, “recursion”, which
will be used in almost every chapter, and also its relative “backtracking”.
It is important to ensure that the recursion terminates. Each time the function
calls itself with a slightly simpler version of the original problem. The sequence
of smaller problems must eventually converge on the base case.
Recursion is most useful for tasks that can be defined in terms of similar
subtasks. For example, sort, search, and traversal problems often have simple
recursive solutions.
In the base case, when n is 0 or 1, the function simply returns 1. This looks like
the following:
Recursion
• Terminates when a base case is reached.
• Each recursive call requires extra space on the stack frame (memory).
• If we get infinite recursion, the program may run out of memory and
result in stack overflow.
• Solutions to some problems are easier to formulate recursively.
Iteration
• Terminates when a condition is proven to be false.
• Each iteration does not require extra space.
• An infinite loop could loop forever since there is no extra memory being
created.
• Iterative solutions to a problem may not always be as obvious as a
recursive solution.
Algorithm:
• Move the top n – 1 disks from Source to Auxiliary tower, • Move the nth
disk from Source to Destination tower, • Move the n – 1 disks from
Auxiliary tower to Destination tower.
• Transferring the top n – 1 disks from Source to Auxiliary tower can again
be thought of as a fresh problem and can be solved in the same
manner. Once we solve Towers of Hanoi with three disks, we can
solve it with any number of disks with the above algorithm.
Problem-2 Given an array, check whether the array is in sorted order with
recursion.
Solution:
Time Complexity: O(n). Space Complexity: O(n) for recursive stack space.
Backtracking is a form of recursion. The usual scenario is that you are faced with
a number of options, and you must choose one of these. After you make your
choice you will get a new set of options; just what set of options you get depends
on what choice you made. This procedure is repeated over and over until you
reach a final state. If you made a good sequence of choices, your final state is a
goal state; if you didn’t, it isn’t.
Let T(n) be the running time of binary(n). Assume function printf takes time
O(1).
Using Subtraction and Conquer Master theorem we get: T(n) = O(2n). This
means the algorithm for generating bit-strings is optimal.
Problem-4 Generate all the strings of length n drawn from 0... k – 1.
Solution: Let us assume we keep current k-ary string in an array A[0.. n – 1].
Call function k-string(n, k):
Solution: The simplest idea is: for each location traverse in all 8 directions and
in each of those directions keep track of maximum region found.
3.1 What is a Linked List?
A linked list is a data structure used for storing collections of data. A linked list
has the following properties.
This process takes one multiplication and one addition. Since these two
operations take constant time, we can say the array access can be performed in
constant time.
Advantages of Arrays
• Simple and easy to use
• Faster access to the elements (constant access)
Disadvantages of Arrays
• Preallocates all needed memory up front and wastes memory space for
indices in the array that are empty.
• Fixed size: The size of the array is static (specify the array size before
using it).
• One block allocation: To allocate the array itself at the beginning,
sometimes it may not be possible to get the memory for the complete
array (if the array size is big).
• Complex position-based insertion: To insert an element at a given
position, we may need to shift the existing elements. This will create
a position for us to insert the new element at the desired position. If
the position at which we want to add an element is at the beginning,
then the shifting operation is more expensive.
Dynamic Arrays
Dynamic array (also called as growable array, resizable array, dynamic table, or
array list) is a random access, variable-size list data structure that allows
elements to be added or removed.
One simple way of implementing dynamic arrays is to initially start with some
fixed size array. As soon as that array becomes full, create the new array double
the size of the original array. Similarly, reduce the array size to half if the
elements in the array are less than half.
Note: We will see the implementation for dynamic arrays in the Stacks, Queues
and Hashing chapters.
We can prevent this by allocating lots of space initially but then we might
allocate more than we need and waste memory. With a linked list, we can start
with space for just one allocated element and add on new elements easily without
the need to do any copying and reallocating.
The ListLength() function takes a linked list as input and counts the number of
nodes in the list. The function given below can be used for printing the list data
with extra print function.
Note: To insert an element in the linked list at some position p, assume that after
inserting the element the position of this new node is p.
Let us write the code for all three cases. We must update the first element pointer
in the calling function, not just in the called function. For this reason we need to
send a double pointer. The following code inserts a node in the singly linked list.
Note: We can implement the three variations of the insert operation separately.
Time Complexity: O(n), since, in the worst case, we may need to insert the node
at the end of the list.
Space Complexity: O(1), for creating one temporary variable.
• Create a temporary node which will point to the same node as that of
head.
• Now, move the head nodes pointer to the next node and dispose of the
temporary node.
Deleting the Last Node in Singly Linked List
In this case, the last node is removed from the list. This operation is a bit trickier
than removing the first node, because the algorithm should find a node, which is
previous to the tail. It can be done in three steps:
• Traverse the list and while traversing maintain the previous node address
also. By the time we reach the end of the list, we will have two
pointers, one pointing to the tail node and the other pointing to the
node before the tail node.
Similar to a singly linked list, let us implement the operations of a doubly linked
list. If you understand the singly linked list operations, then doubly linked list
operations are obvious. Following is a type declaration for a doubly linked list of
integers:
• New node right pointer points to NULL and left pointer points to the end
of the list.
• Update right pointer of last node to point to new node.
• New node right pointer points to the next node of the position node where
we want to insert the new node. Also, new node left pointer points to
the position node.
• Position node right pointer points to the new node and the next node of
position node left pointer points to new node.
Now, let us write the code for all of these three cases. We must update the first
element pointer in the calling function, not just in the called function. For this
reason we need to send a double pointer.
Time Complexity: O(n). In the worst case, we may need to insert the node at the
end of the list.
Space Complexity: O(1), for creating one temporary variable.
For example, when several processes are using the same computer resource
(CPU) for the same amount of time, we have to assure that no process accesses
the resource before all other processes do (round robin algorithm). The following
is a type declaration for a circular linked list of integers:
In a circular linked list, we access the elements using the head node (similar to
head node in singly linked list and doubly linked lists).
The circular list is accessible through the node marked head. To count the nodes,
the list has to be traversed from the node marked head, with the help of a
dummy node current, and stop the counting when current reaches the starting
node head.
If the list is empty, head will be NULL, and in that case set count = 0.
Otherwise, set the current pointer to the first node, and keep on counting till the
current pointer reaches the starting node.
• Create a new node and initially keep its next pointer pointing to itself.
• Update the next pointer of the new node with the head node and also
traverse the list to the tail. That means in a circular list we should stop
at the node whose next node is head.
• Update the next pointer of the previous node to point to the new node and
we get the list as shown below.
Time Complexity: O(n), for scanning the complete list of size n.
Space Complexity: O(1), for temporary variable.
• Update the next pointer of the new node with the head node and also
traverse the list until the tail. That means in a circular list we should
stop at the node which is its previous node in the list.
• Update the previous head node in the list to point to the new node.
To delete the last node 40, the list has to be traversed till you reach 7. The next
field of 7 has to be changed to point to 60, and this node must be renamed pTail.
• Traverse the list and find the tail node and its previous node.
• Update the next pointer of tail node’s previous node to point to head.
• Create a temporary node which will point to the head. Also, update the
tail nodes next pointer to point to next node of head (as shown
below).
• Now, move the head pointer to next node. Create a temporary node which
will point to head. Also, update the tail nodes next pointer to point to
next node of head (as shown below).
Time Complexity: O(n), for scanning the complete list of size n.
Space Complexity: O(1), for a temporary variable.
The ptrdiff pointer field contains the difference between the pointer to the next
node and the pointer to the previous node. The pointer difference is calculated by
using exclusive-or (⊕) operation.
ptrdiff = pointer to previous node ⊕ pointer to next node.
The ptrdiff of the start node (head node) is the ⊕ of NULL and next node (next
node to head). Similarly, the ptrdiff of end node is the ⊕ of previous node
(previous to end node) and NULL. As an example, consider the following linked
list.
From the above discussion we can see that just by using a single pointer, we can
move back and forth. A memory-efficient implementation of a doubly linked list
is possible with minimal compromising of timing efficiency.
3.10 Unrolled Linked Lists
One of the biggest advantages of linked lists over arrays is that inserting an
element at any location takes only O(1) time. However, it takes O(n) to search
for an element in a linked list. There is a simple variation of the singly linked list
called unrolled linked lists.
An unrolled linked list stores multiple elements in each node (let us call it a block
for our convenience). In each block, a circular linked list is used to connect all
nodes.
Assume that there will be no more than n elements in the unrolled linked list at
any time. To simplify this problem, all blocks, except the last one, should contain
exactly elements. Thus, there will be no more than blocks at any time.
In unrolled linked lists, we can find the kth element in O( ): 1. Traverse the
list of blocks to the one that contains the kth node, i.e., the block. It takes
O( ) since we may find it by going through no more than blocks.
2. Find the (k mod )th node in the circular linked list of this block. It
also takes O( ) since there are no more than nodes in a single
block.
Inserting an element in Unrolled Linked Lists
When inserting a node, we have to re-arrange the nodes in the unrolled linked
list to maintain the properties previously mentioned, that each block contains
nodes. Suppose that we insert a node x after the ith node, and x should be
placed in the jth block. Nodes in the jth block and in the blocks after the jth block
have to be shifted toward the tail of the list so that each of them still have
nodes. In addition, a new block needs to be added to the tail if the last block of
the list is out of space, i.e., it has more than nodes.
Performing Shift Operation
Note that each shift operation, which includes removing a node from the tail of
the circular linked list in a block and inserting a node to the head of the circular
linked list in the block after, takes only O(1). The total time complexity of an
insertion operation for unrolled linked lists is therefore O( ); there are at most
O( ) blocks and therefore at most O( ) shift operations.
1. A temporary pointer is needed to store the tail of A.
2. In block A, move the next pointer of the head node to point to the
second-to-last node, so that the tail node of A can be removed.
3. Let the next pointer of the node, which will be shifted (the tail
node of A), point to the tail node of B.
4. Let the next pointer of the head node of B point to the node temp
points to.
5. Finally, set the head pointer of B to point to the node temp points
to. Now the node temp points to becomes the new head node of
B.
Performance
With unrolled linked lists, there are a couple of advantages, one in speed and one
in space. First, if the number of elements in each block is appropriately sized
(e.g., at most the size of one cache line), we get noticeably better cache
performance from the improved memory locality. Second, since we have O(n/m)
links, where n is the number of elements in the unrolled linked list and m is the
number of elements we can store in any block, we can also save an appreciable
amount of space, which is particularly noticeable if each element is small.
Assuming we have 4 byte pointers, each node is going to take 8 bytes. But the
allocation overhead for the node could be anywhere between 8 and 16 bytes.
Let’s go with the best case and assume it will be 8 bytes. So, if we want to store
IK items in this list, we are going to have 16KB of overhead.
Now, let’s think about an unrolled linked list node (let us call it LinkedBlock). It
will look something like this:
Therefore, allocating a single node (12 bytes + 8 bytes of overhead) with an array
of 100 elements (400 bytes + 8 bytes of overhead) will now cost 428 bytes,or 4.28
bytes per element. Thinking about our IK items from above, it would take about
4.2KB of overhead, which is close to 4x better than our original list. Even if the
list becomes severely fragmented and the item arrays are only 1/2
full on average, this is still an improvement. Also, note that we can tune the
array size to whatever gets us the best overhead for our application.
Implementation
Solution: Brute-Force Method: Start with the first node and count the number
of nodes present after that node. If the number of nodes is < n – 1 then return
saying “fewer number of nodes in the list”. If the number of nodes is > n – 1 then
go to next node. Continue this until the numbers of nodes after current nodeare n
– 1.
Time Complexity: O(n2), for scanning the remaining list (from current node) for
each node.
Space Complexity: O(1).
Problem-3 Can we improve the complexity of Problem-2?
Solution: Yes, using hash table. As an example consider the following list.
In this approach, create a hash table whose entries are < position of node, node
address >. That means, key is the position of the node in the list and value is the
address of that node.
By the time we traverse the complete list (for creating the hash table), we can
find the list length. Let us say the list length is M. To find nth from the end of
linked list, we can convert this to M-n + 1th from the beginning. Since we
already know the length of the list, it is just a matter of returning M-n + 1th key
value from the hash table.
Time Complexity: Time for creating the hash table, T(m) = O(m).
Space Complexity: Since we need to create a hash table of size m, O(m).
Problem-4 Can we use the Problem-3 approach for solving Problem-2
without creating the hash table?
Solution: Yes. If we observe the Problem-3 solution, what we are actually doing
is finding the size of the linked list. That means we are using the hash table to
find the size of the linked list. We can find the length of the linked list just by
starting at the head node and traversing the list.
So, we can find the length of the list without creating the hash table. After finding
the length, compute M – n + 1 and with one more scan we can get the M
– n+ 1th node from the beginning. This solution needs two scans: one for finding
the length of the list and the other for finding M – n+ 1th node from the beginning.
Time Complexity: Time for finding the length + Time for finding the M – n +
1th node from the beginning. Therefore, T(n) = O(n) + O(n) ≈ O(n). Space
Complexity: O(1). Hence, no need to create the hash table.
Problem-5 Can we solve Problem-2 in one scan?
Solution: Yes. Efficient Approach: Use two pointers pNthNode and pTemp.
Initially, both point to head node of the list. pNthNode starts moving only after
pTemp has made n moves.
From there both move forward until pTemp reaches the end of the list. As a result
pNthNode points to nth node from the end of the linked list.
That means the repetition of next pointers indicates the existence of a loop.
One simple and brute force way of solving this is, start with the first node and
see whether there is any node whose next pointer is the current node’s address. If
there is a node with the same address then that indicates that some other node is
pointing to the current node and we can say a loop exists. Continue this process
for all the nodes of the linked list.
Does this method work? As per the algorithm, we are checking for the next
pointer addresses, but how do we find the end of the linked list (otherwise we
will end up in an infinite loop)?
Note: If we start with a node in a loop, this method may work depending on the
size of the loop.
Problem-7 Can we use the hashing technique for solving Problem-6?
Algorithm:
Time Complexity; O(n) for scanning the linked list. Note that we are doing a
scan of only the input.
Space Complexity; O(n) for hash table.
Problem-8 Can we solve Problem-6 using the sorting technique?
Solution: No. Consider the following algorithm which is based on sorting. Then
we see why this algorithm fails.
Algorithm:
• Traverse the linked list nodes one by one and take all the next pointer
values into an array.
• Sort the array that has the next node pointers.
• If there is a loop in the linked list, definitely two next node pointers will
be pointing to the same node.
• After sorting if there is a loop in the list, the nodes whose next pointers
are the same will end up adjacent in the sorted list.
• If any such pair exists in the sorted list then we say the linked list has a
loop in it.
Problem with the above algorithm: The above algorithm works only if we can
find the length of the list. But if the list has a loop then we may end up in an
infinite loop. Due to this reason the algorithm fails.
Problem-9 Can we solve the Problem-6 in O(n)?
Note: slowPtr (tortoise) moves one pointer at a time and fastPtr (hare) moves
two pointers at a time.
slowPtr
fastPtr
fastPtr
slowPtr
fastPt
fastPtr slowPt
slowPtrfastPtr
Time Complexity: O(n). Space Complexity: O(1).
Problem-10 are given a pointer to the first element of a linked list L. Thereare
two possibilities for L: it either ends (snake) or its last element points back
to one of the earlier elements in the list (snail). Give an algorithm thattests
whether a given list L is a snake or a snail.
Solution: It is the same as Problem-6.
Problem-11 Check whether the given linked list is NULL-terminated or not.
If there is a cycle find the start node of the loop.
Solution: The solution is an extension to the solution in Problem-9. After finding
the loop in the linked list, we initialize the slowPtr to the head of the linked list.
From that point onwards both slowPtr and fastPtr move only one node at a time.
The point at which they meet is the start of the loop. Generally we use this
method for removing the loops.
Time Complexity: O(n). Space Complexity: O(1).
Problem-12 From the previous discussion and problems we understand thatthe
meeting of tortoise and hare concludes the existence of the loop, but how
does moving the tortoise to the beginning of the linked list while keeping
the hare at the meeting place, followed by moving both one step ata time,
make them meet at the starting point of the cycle?
Solution: This problem is at the heart of number theory. In the Floyd cycle
finding algorithm, notice that the tortoise and the hare will meet when they are n
× L, where L is the loop length. Furthermore, the tortoise is at the midpoint
between the hare and the beginning of the sequence because of the way they
move. Therefore the tortoise is n × L away from the beginning of the sequence as
well. If we move both one step at a time, from the position of the tortoise and
from the start of the sequence, we know that they will meet as soon as both are
in the loop, since they are n × L, a multiple of the loop length, apart. One of them
is already in the loop, so we just move the other one in single step until it enters
the loop, keeping the other n × L away from it at all times.
Problem-13 In the Floyd cycle finding algorithm, does it work if we use steps
2 and 3 instead of 1 and 2?
Solution: Yes, but the complexity might be high. Trace out an example.
Problem-14 Check whether the given linked list is NULL-terminated. If there
is a cycle, find the length of the loop.
Solution: This solution is also an extension of the basic cycle detection problem.
After finding the loop in the linked list, keep the slowPtr as it is. The fastPtr
keeps on moving until it again comes back to slowPtr. While moving fastPtr,
use a counter variable which increments at the rate of 1.
4.1 What is a Stack?
A stack is a simple data structure used for storing data (similar to Linked Lists).
In a stack, the order in which the data arrives is important. A pile of plates in a
cafeteria is a good example of a stack. The plates are added to the stack as they
are cleaned and they are placed on the top. When a plate, is required it is taken
from the top of the stack. The first plate placed on the stack is the last one to be
used.
Definition: A stack is an ordered list in which insertion and deletion are done at
one end, called top. The last element inserted is the first one to be deleted. Hence,
it is called the Last in First out (LIFO) or First in Last out (FILO) list.
Special names are given to the two changes that can be made to a stack. When
an element is inserted in a stack, the concept is called push, and when an element
is removed from the stack, the concept is called pop. Trying to pop out an empty
stack is called underflow and trying to push an element in a full stack is called
overflow. Generally, we treat them as exceptions. As an example, consider the
snapshots of the stack.
When the call is complete the task that was abandoned to answer the phone is
retrieved from the pending tray and work progresses. To take another call, it may
have to be handled in the same manner, but eventually the new task will be
finished, and the developer can draw the long-term project from the pending tray
and continue with that.
4.3 Stack ADT
The following operations make a stack an ADT. For simplicity, assume the data
is an integer type.
Exceptions
Attempting the execution of an operation may sometimes cause an error
condition, called an exception. Exceptions are said to be “thrown” by an
operation that cannot be executed. In the Stack ADT, operations pop and top
cannot be performed if the stack is empty. Attempting the execution of pop (top)
on an empty stack throws an exception. Trying to push an element in a full stack
throws an exception.
4.4 Applications
Following are some of the applications in which stacks play an important role.
Direct applications
• Balancing of symbols
• Infix-to-postfix conversion
• Evaluation of postfix expression
• Implementing function calls (including recursion)
• Finding of spans (finding spans in stock markets, refer to Problems
section)
• Page-visited history in a Web browser [Back Buttons]
• Undo sequence in a text editor
• Matching Tags in HTML and XML
Indirect applications
• Auxiliary data structure for other algorithms (Example: Tree traversal
algorithms)
• Component of other data structures (Example: Simulating queues, refer
Queues chapter)
4.5 Implementation
There are many ways of implementing stack ADT; below are the commonly
used methods.
The array storing the stack elements may become full. A push operation will
then throw a full stack exception. Similarly, if we try deleting an element from
an empty stack it will throw stack empty exception.
Performance & Limitations
Performance
Limitations
The maximum size of the stack must first be defined and it cannot be changed.
Trying to push a new element into a full stack causes an implementation-specific
exception.
Similarly, to delete (or pop) an element we take the element at top index and
then decrement the top index. We represent an empty queue with top value equal
to –1. The issue that still needs to be resolved is what we do when all the slots in
the fixed size array stack are occupied?
First try: What if we increment the size of the array by 1 every time the stack is
full?
• Push(); increase size of S[] by 1
• Pop(): decrease size of S[] by 1
This way of incrementing the array size is too expensive. Let us see the reason
for this. For example, at n = 1, to push an element create a new array of size 2
and copy all the old array elements to the new array, and at the end add the new
element. At n = 2, to push an element create a new array of size 3 and copy all
the old array elements to the new array, and at the end add the new element.
Let us improve the complexity by using the array doubling technique. If the array
is full, create a new array of twice the size, and copy the items. With this
approach, pushing n items takes time proportional to n (not n2).
For simplicity, let us assume that initially we started with n = 1 and moved up to
n = 32. That means, we do the doubling at 1,2,4,8,16. The other way of
analyzing the same approach is: at n = 1, if we want to add (push) an element,
double the current size of the array and copy all the elements of the old array to
the new array.
Performance
Let n be the number of elements in the stack. The complexities for operations
with this representation can be given as:
The other way of implementing stacks is by using Linked lists. Push operation is
implemented by inserting element at the beginning of the list. Pop operation is
implemented by deleting the node from the beginning (the header/top node).
4.6 Stacks: Problems & Solutions
Problem-1 Discuss how stacks can be used for checking balancing ofsymbols.
Solution: Stacks can be used to check whether the given expression has
balanced symbols. This algorithm is very useful in compilers. Each time the
parser reads one character at a time. If the character is an opening delimiter such
as (, {, or [- then it is written to the stack. When a closing delimiter isencountered
like ), }, or ]-the stack is popped.
The opening and closing delimiters are then compared. If they match, the parsing
of the string continues. If they do not match, the parser indicates that there is an
error on the line. A linear-time O(n) algorithm based on stack can be given as:
Algorithm:
a) Create a stack.
b) while (end of input is not reached) {
1) If the character read is not a symbol to be balanced, ignore it.
2) If the character is an opening symbol like (, [, {, push it onto
the stack
3) If it is a closing symbol like ),],}, then if the stack is empty
report an error. Otherwise pop the stack.
4) If the symbol popped is not the corresponding opening
symbol, report an error.
For tracing the algorithm let us assume that the input is: () (() [()])
Time Complexity: O(n). Since we are scanning the input only once. Space
Complexity: O(n) [for stack].
Problem-2 Discuss infix to postfix conversion algorithm using stack.
Solution: Before discussing the algorithm, first let us see the definitions of infix,
prefix and postfix expressions.
Now, let us focus on the algorithm. In infix expressions, the operator precedence
is implicit unless we use parentheses. Therefore, for the infix to postfix
conversion algorithm we have to define the operator precedence (or priority)
inside the algorithm.
The table shows the precedence and their associativity (order of evaluation)
among operators.
Token Operator Precedence Associativity
() fu n ction call 17 left-to-r igh t
array element
-
[I
.
-- ++
-- ++
struct or union member
increment, decrement
dec re me n t, inc rem ent
16
15
left-to-r igh t
right-to-left
! logical not
- one's complement
-+ unary minus or plus
&* address or indirection
sizeof size (in bytes)
(type) type cast 14 right-to-left
k I% multiplicative 13 Left-t o-righ t
+ - bina ry add or subtract 12 left-to -righ t
<< >> shift 11 left-to-righ t
> >= relational 10 left-to-righ t
< <=
== 1= equalitv 9 left-to-r igh t
& bitwise and 8 left-to-righ t
I\ bitwise exclusive or 7 left-to-righ t
I bitv.rise or 6 left -to-righ t
&& logical and 5 left-to -righ t
logical or 4 left-to-righ t
11
?: conditional 3 right-to-left
= += -= / = *= %= assignment 2 right-to-left
<<= >>=
& = /:\
comma 1 left-to-righ t
'
Important Properties
• Let us consider the infix expression 2 + 3*4 and its postfix equivalent
234*+. Notice that between infix and postfix the order of the numbers
(or operands) is unchanged. It is 2 3 4 in both cases. But theorder of
the operators * and + is affected in the two expressions.
• Only one stack is enough to convert an infix expression to postfix
expression. The stack that we use in the algorithm will be used to
change the order of operators from infix to postfix. The stack we use
will only contain operators and the open parentheses symbol ‘(‘.
Algorithm:
a) Create a stack
b) for each character t in the input stream}
Algorithm:
1 Scan the Postfix string from left to right.
2 Initialize an empty stack.
3 Repeat steps 4 and 5 till all the characters are scanned.
4 If the scanned character is an operand, push it onto the stack.
5 If the scanned character is an operator, and if the operator is a unary
operator, then pop an element from the stack. If the operator is a
binary operator, then pop two elements from the stack. After popping
the elements, apply the operator to those popped elements. Let the
result of this operation be retVal onto the stack.
6 After all characters are scanned, we will have only one element in the
stack.
7 Return top of the stack as result.
Initially the stack is empty. Now, the first three characters scanned are 1, 2 and
3, which are operands. They will be pushed into the stack in that order.
The next character scanned is “”, which is an operator. Thus, we pop the top two
elements from the stack and perform the “” operation with the two operands.
The second operand will be the first element that is popped.
The value of the expression (2*3) that has been evaluated (6) is pushed into the
stack.
The next character scanned is “+”, which is an operator. Thus, we pop the top
two elements from the stack and perform the “+” operation with the two
operands. The second operand will be the first element that is popped.
The value of the expression (1+6) that has been evaluated (7) is pushed into the
stack.
The next character scanned is “5”, which is added to the stack.
The next character scanned is “-”, which is an operator. Thus, we pop the top
two elements from the stack and perform the “-” operation with the twooperands.
The second operand will be the first element that is popped.
The value of the expression(7-5) that has been evaluated(23) is pushed into the
stack.
Now, since all the characters are scanned, the remaining element in the stack
(there will be only one element in the stack) will be returned. End result:
Algorithm:
1) Create an empty operator stack
2) Create an empty operand stack
3) For each token in the input string
a. Get the next token in the infix string
b. If next token is an operand, place it on the operand stack
c. If next token is an operator
i. Evaluate the operator (next op)
4) While operator stack is not empty, pop operator and operands (left and
right), evaluate left operator right and push result onto operand stack
5) Pop result from operator stack
Problem-5 How to design a stack such that GetMinimum( ) should be O(1)?
Solution: Take an auxiliary stack that maintains the minimum of all values in
the stack. Also, assume that each element of the stack is less than its below
elements. For simplicity let us call the auxiliary stack min stack.
When we pop the main stack, pop the min stack too. When we push the main
stack, push either the new element or the current minimum, whichever is lower.
At any point, if we want to get the minimum, then we just need to return the top
element from the min stack. Let us take an example and trace it out. Initially let
us assume that we have pushed 2, 6, 4, 1 and 5. Based on the above-mentioned
algorithm the min stack will look like:
Based on the discussion above, now let us code the push, pop and
GetMinimum() operations.
Time complexity: O(1). Space complexity: O(n) [for Min stack]. This algorithm
has much better space usage if we rarely get a “new minimum or equal”.
Problem-6 For Problem-5 is it possible to improve the space complexity?
Solution: Yes. The main problem of the previous approach is, for each push
operation we are pushing the element on to min stack also (either the new
element or existing minimum element). That means, we are pushing the
duplicate minimum elements on to the stack.
Now, let us change the algorithm to improve the space complexity. We still have
the min stack, but we only pop from it when the value we pop from the main
stack is equal to the one on the min stack. We only push to the min stack when
the value being pushed onto the main stack is less than or equal to the current
min value. In this modified algorithm also, if we want to get the minimum then
we just need to return the top element from the min stack. For example, taking
the original version and pushing 1 again, we’d get:
Popping from the above pops from both stacks because 1 == 1, leaving:
2 2
Popping again only pops from the main stack, because 5 > 1:
Definition: A queue is an ordered list in which insertions are done at one end
(rear) and deletions are done at other end (front). The first element to be inserted
is the first one to be deleted. Hence, it is called First in First out (FIFO) or Last
in Last out (LILO) list.
Similar to Stacks, special names are given to the two changes that can be made
to a queue. When an element is inserted in a queue, the concept is called
EnQueue, and when an element is removed from the queue, the concept is called
DeQueue.
As this happens, the next person will come at the head of the line, will exit the
queue and will be served. As each person at the head of the line keeps exiting the
queue, we move towards the head of the line. Finally we will reach the head of
the line and we will exit the queue and be served. This behavior is very useful in
cases where there is a need to maintain the order of arrival.
5.4 Exceptions
Similar to other ADTs, executing DeQueue on an empty queue throws an
“Empty Queue Exception” and executing EnQueue on a full queue throws “Full
Queue Exception”.
5.5 Applications
Following are some of the applications that use queues.
Direct Applications
Indirect Applications
In the example shown below, it can be seen clearly that the initial slots of the
array are getting wasted. So, simple array implementation for queue is not
efficient. To solve this problem we assume the arrays as circular arrays. That
means, we treat the last element and the first array elements as contiguous. With
this representation, if there are any free slots at the beginning, the rear pointer
can easily go to its next free slot.
Note: The simple circular array and dynamic circular array implementations are
very similar to stack array implementations. Refer to Stacks chapter for analysis
of these implementations.
Simple Circular Array Implementation
This simple implementation of Queue ADT uses an array. In the array, we add
elements circularly and use two variables to keep track of the start element and
end element. Generally, front is used to indicate the start element and rear is
used to indicate the end element in the queue. The array storing the queue
elements may become full. An EnQueue operation will then throw a full queue
exception. Similarly, if we try deleting an element from an empty queue it will
throw empty queue exception.
Note: Initially, both front and rear points to -1 which indicates that the queue is
empty.
Performance and Limitations
Performance: Let n be the number of elements in the queue:
Space Complexity (for n EnQueue operations) O(n)
Time Complexity of EnQueue() O(1)
Time Complexity of DeQueue() O(1)
Time Complexity of IsEmptyQueue() O(1)
Time Complexity of IsFullQueue() O(1)
Time Complexity of QueueSize() O(1)
Time Complexity of DeleteQueue() O(1)
Limitations: The maximum size of the queue must be defined as prior and
cannot be changed. Trying to EnQueue a new element into a full queue causes
an implementation-specific exception.
Linked List Implementation
Another way of implementing queues is by using Linked lists. EnQueue
operation is implemented by inserting an element at the end of the list. DeQueue
operation is implemented by deleting an element from the beginning of the list.
Performance
Let n be the number of elements in the queue, then
Comparison of Implementations
Note: Comparison is very similar to stack implementations and Stacks chapter.
EnQueue Algorithm
DeQueue Algorithm
• If stack S2 is not empty then pop from S2 and return that element.
• If stack is empty, then transfer all elements from SI to S2 and pop the top
element from S2 and return that popped element [we can optimize
the code a little by transferring only n – 1 elements from SI to S2 and
pop the nth element from SI and return that popped element].
• If stack S1 is also empty then throw error.
Time Complexity: From the algorithm, if the stack S2 is not empty then the
complexity is O(1). If the stack S2 is empty, then we need to transfer the
elements from SI to S2. But if we carefully observe, the number of transferred
elements and the number of popped elements from S2 are equal. Due to this the
average complexity of pop operation in this case is O(1).The amortized
complexity of pop operation is O(1).
Problem-3 Show how you can efficiently implement one stack using two
queues. Analyze the running time of the stack operations.
Solution: Yes, it is possible to implement the Stack ADT using 2
implementations of the Queue ADT. One of the queues will be used to store the
elements and the other to hold them temporarily during the pop and top methods.
The push method would enqueue the given element onto the storage queue. The
top method would transfer all but the last element from the storage queue onto
the temporary queue, save the front element of the storage queue to be returned,
transfer the last element to the temporary queue, then transfer all elements back
to the storage queue. The pop method would do the same as top, except instead
of transferring the last element onto the temporary queue after saving it for
return, that last element would be discarded. Let Q1 and Q2 be the two queues to
be used in the implementation of stack. All we have to do is to define the push
and pop operations for the stack.
In the algorithms below, we make sure that one queue is always empty.
Input: A long array A[], and a window width w. Output: An array B[],
B[i] is the maximum value from A[i] to A[i+w-1]. Requirement: Find a
good optimal way to get B[i]
Solution: This problem can be solved with doubly ended queue (which supports
insertion and deletion at both ends). Refer Priority Queues chapter for
algorithms.
Problem-5 Given a queue Q containing n elements, transfer these items on
to a stack S (initially empty) so that front element of Q appears at the top
of the stack and the order of all other items is preserved. Using enqueue
and dequeue operations for the queue, and push and pop operations for the
stack, outline an efficient O(n) algorithm to accomplish the above task,
using only a constant amount of additional storage.
Solution: Assume the elements of queue Q are a1:a2 ...an. Dequeuing all
elements and pushing them onto the stack will result in a stack with an at the top
and a1 at the bottom. This is done in O(n) time as dequeue and each push require
constant time per operation. The queue is now empty. By popping all elements
and pushing them on the queue we will get a1 at the top of the stack. This is done
again in O(n) time.
As in big-oh arithmetic we can ignore constant factors. The process is carried out
in O(n) time. The amount of additional storage needed here has to be big enough
to temporarily hold one item.
Problem-6 A queue is set up in a circular array A[O..n - 1] with front and rear
defined as usual. Assume that n – 1 locations in the array are availablefor
storing the elements (with the other element being used to detect full/empty
condition). Give a formula for the number of elements in the queue in terms
of rear, front, and n.
Solution: Consider the following figure to get a clear idea of the queue.
• Rear of the queue is somewhere clockwise from the front.
• To enqueue an element, we move rear one position clockwise and write
the element in that position.
• To dequeue, we simply move front one position clockwise.
• Queue migrates in a clockwise direction as we enqueue and dequeue.
• Emptiness and fullness to be checked carefully.
• Analyze the possible situations (make some drawings to see where front
and rear are when the queue is empty, and partially and totally filled).
We will get this:
In trees ADT (Abstract Data Type), the order of the elements is not important. If
we need ordering information, linear data structures like linked lists, stacks,
queues, etc. can be used.
6.2 Glossary
• The root of a tree is the node with no parents. There can be at most one
root node in a tree (node A in the above example).
• An edge refers to the link from parent to child (all links in the figure).
• A node with no children is called leaf node (E,J,K,H and I).
• Children of same parent are called siblings (B,C,D are siblings of A, and
E,F are the siblings of B).
• A node p is an ancestor of node q if there exists a path from root to q and
p appears on the path. The node q is called a descendant of p. For
example, A,C and G are the ancestors of if.
• The set of all nodes at a given depth is called the level of the tree (B, C
and D are the same level). The root node is at level zero.
• The depth of a node is the length of the path from the root to the node
(depth of G is 2, A – C – G).
• The height of a node is the length of the path from that node to the deepest
node. The height of a tree is the length of the path from the root to
the deepest node in the tree. A (rooted) tree with only one node (the
root) has a height of zero. In the previous example, the height of B is
2 (B – F – J).
• Height of the tree is the maximum height among all the nodes in the tree
and depth of the tree is the maximum depth among all the nodes in
the tree. For a given tree, depth and height returns the same value.
But for individual nodes we may get different results.
• The size of a node is the number of descendants it has including itself
(the size of the subtree C is 3).
• If every node in a tree has only one child (except leaf nodes) then we call
such trees skew trees. If every node has only left child then we call
them left skew trees. Similarly, if every node has only right child then
we call them right skew trees.
6.3 Binary Trees
A tree is called binary tree if each node has zero child, one child or two children.
Empty tree is also a valid binary tree. We can visualize a binary tree as
consisting of a root and two disjoint binary trees, called the left and right subtrees
of the root.
Full Binary Tree: A binary tree is called full binary tree if each node has
exactly two children and all leaf nodes are at the same level.
Complete Binary Tree: Before defining the complete binary tree, let us assume
that the height of the binary tree is h. In complete binary trees, if we give
numbering for the nodes by starting at the root (let us say the root node has 1)
then we get a complete sequence from 1 to the number of nodes in the tree. While
traversing we should give numbering for NULL pointers also. A binary tree is
called complete binary tree if all leaf nodes are at height h or h – 1 and also
without any missing number in the sequence.
• The number of nodes n in a full binary tree is 2h+1 – 1. Since, there are h
levels we need to add all nodes at each level [20 + 21+ 22 + ··· + 2h =
2h+1 – 1].
• The number of nodes n in a complete binary tree is between 2h
(minimum) and 2h+1 – 1 (maximum). For more information on this,
refer to Priority Queues chapter.
• The number of leaf nodes in a full binary tree is 2h.
• The number of NULL links (wasted pointers) in a complete binary tree of
n nodes is n + 1.
Note: In trees, the default flow is from parent to children and it is not mandatory
to show directed branches. For our discussion, we assume both the
representations shown below are the same.
Auxiliary Operations
Traversal Possibilities
Starting at the root of a binary tree, there are three main steps that can be
performed and the order in which they are performed defines the traversal type.
These steps are: performing an action on the current node (referred to as
“visiting” the node and denoted with “D”), traversing to the left child node
(denoted with “L”), and traversing to the right child node (denoted with “R”).
This process can be easily described through recursion. Based on the above
definition there are 6 possibilities:
1. LDR: Process left subtree, process the current node data and then
process right subtree
2. LRD: Process left subtree, process right subtree and then process the
current node data
3. DLR: Process the current node data, process left subtree and then
process right subtree
4. DRL: Process the current node data, process right subtree and then
process left subtree
5. RDL: Process right subtree, process the current node data and then
process left subtree
6. RLD: Process right subtree, process left subtree and then process the
current node data
There is another traversal method which does not depend on the above orders
and it is:
PreOrder Traversal
In preorder traversal, each node is processed before (pre) either of its subtrees.
This is the simplest traversal to understand. However, even though each node is
processed before the subtrees, it still requires that some information must be
maintained while moving down the tree. In the example above, 1 is processed
first, then the left subtree, and this is followed by the right subtree.
Therefore, processing must return to the right subtree after finishing the
processing of the left subtree. To move to the right subtree after processing the
left subtree, we must maintain the root information. The obvious ADT for such
information is a stack. Because of its LIFO structure, it is possible to get the
information about the right subtrees back in the reverse order.
Preorder traversal is defined as follows:
InOrder Traversal
In Inorder Traversal the root is visited between the subtrees. Inorder traversal is
defined as follows:
PostOrder Traversal
In postorder traversal, the root is visited after both subtrees. Postorder traversal
is defined as follows:
We use a previous variable to keep track of the earlier traversed node. Let’s
assume current is the current node that is on top of the stack. When previous is
current’s parent, we are traversing down the tree. In this case, we try to traverse
to current’s left child if available (i.e., push left child to the stack). If it is not
available, we look at current’s right child. If both left and right child do not exist
(ie, current is a leaf node), we print current’s value and pop it off the stack.
If prev is current’s left child, we are traversing up the tree from the left. We look
at current’s right child. If it is available, then traverse down the right child (i.e.,
push right child to the stack); otherwise print current’s value and pop it off the
stack. If previous is current’s right child, we are traversing up the tree from the
right. In this case, we print current’s value and pop it off the stack.
Time Complexity: O(n). Space Complexity: O(n).
Time Complexity: O(n). Space Complexity: O(n). Since, in the worst case, all
the nodes on the entire last level could be in the queue simultaneously.
9.2 Glossary
Graph: A graph is a pair (V, E), where V is a set of nodes, called vertices, and £
is a collection of pairs of vertices, called edges.
○ Undirected edge:
▪ unordered pair of vertices (u, v)
▪ Example: railway lines
○ Directed graph:
▪ all the edges are directed
▪ Example: route network
○ Undirected graph:
▪ all the edges are undirected
▪ Example: flight network
• When an edge connects two vertices, the vertices are said to be adjacent
to each other and the edge is incident on both vertices.
• A graph with no cycles is called a tree. A tree is an acyclic connected
graph.
• A cycle is a path where the first and last vertices are the same. A simple
cycle is a cycle with no repeated vertices or edges (except the first
and last vertices).
• We say that one vertex is connected to another if there is a path that
contains both of them.
• A graph is connected if there is a path from every vertex to every other
vertex.
• If a graph is not connected then it consists of a set of connected
components.
• Graphs with relatively few edges (generally if it edges < |V| log |V|) are
called sparse graphs.
• Graphs with relatively few of the possible edges missing are called dense.
• Directed weighted graphs are sometimes called network.
• We will denote the number of vertices in a given graph by |V|, and the
number of edges by |E|. Note that E can range anywhere from 0 to |V|
(|V| – l)/2 (in undirected graph). This is because each node can
connect to every other node.
• Adjacency Matrix
• Adjacency List
• Adjacency Set
Adjacency Matrix
First, let us look at the components of the graph data structure. To represent
graphs, we need the number of vertices, the number of edges and also their
interconnections. So, the graph can be declared as:
Description
In this method, we use a matrix with size V × V. The values of matrix are boolean.
Let us assume the matrix is Adj. The value Adj[u, v] is set to 1 if thereis an edge
from vertex u to vertex v and 0 otherwise.
In the matrix, each edge is represented by two bits for undirected graphs. That
means, an edge from u to v is represented by 1 value in both Adj[u,v ] and
Adj[u,v]. To save time, we can process only half of this symmetric matrix. Also,
we can assume that there is an “edge” from each vertex to itself. So, Adj[u, u] is
set to 1 for all vertices.
If the graph is a directed graph then we need to mark only one entry in the
adjacency matrix. As an example, consider the directed graph below.
Adjacency List
The total number of linked lists is equal to the number of vertices in the graph.
The graph ADT can be declared as:
Description
Considering the same example as that of the adjacency matrix, the adjacency list
representation can be given as:
Since vertex A has an edge for B and D, we have added them in the adjacency
list for A. The same is the case with other vertices as well.
Disadvantages of Adjacency Lists
Adjacency Set
It is very much similar to adjacency list but instead of using Linked lists, Disjoint
Sets [Union-Find] are used. For more details refer to the Disjoint Sets ADT
chapter.
After reaching a “dead end” the person knows that there is no more unexplored
path from the grey intersection, which now is completed, and he marks it with
black. This “dead end” is either an intersection which has already been marked
grey or black, or simply a path that does not lead to an intersection.
The intersections of the maze are the vertices and the paths between the
intersections are the edges of the graph. The process of returning from the “dead
end” is called backtracking. We are trying to go away from the starting vertex
into the graph as deep as possible, until we have to backtrack to the preceding
grey vertex. In DFS algorithm, we encounter the following types of edges.
Initially all vertices are marked unvisited (false). The DFS algorithm starts at a
vertex u in the graph. By starting at vertex u it considers the edges from u to
other vertices. If the edge leads to an already visited vertex, then backtrack to
current vertex u. If an edge leads to an unvisited vertex, then go to that vertex
and start processing from that vertex. That means the new vertex becomes the
current vertex. Follow this process until we reach the dead-end. At this point
start backtracking.
The process terminates when backtracking leads back to the start vertex. The
algorithm based on this mechanism is given below: assume Visited[] is a global
array.
As an example, consider the following graph. We can see that sometimes an
edge leads to an already discovered vertex. These edges are called back edges,
and the other edges are called tree edges because deleting the back edges from
the graph generates a tree.
The final generated tree is called the DFS tree and the order in which the vertices
are processed is called DFS numbers of the vertices. In the graph below, the gray
color indicates that the vertex is visited (there is no other significance). We need
to see when the Visited table is updated.
Visited Table Visited Table
1 0 0 0 1 01 01 0 0 1 1 0 0 1 01 01 0 1 0 1
---0
Starting vertex A is
marked visited Vertex B is visited
Visited Table
The time complexity of DFS is O(V + E), if we use adjacency lists for
representing the graphs. This is because we are starting at a vertex and
processing the adjacent nodes only if they are not visited. Similarly, if an
adjacency matrix is used for a graph representation, then all edges adjacent to a
vertex can’t be found efficiently, and this gives O(V2) complexity.
Applications of DFS
• Topological sorting
• Finding connected components
• Finding articulation points (cut vertices) of the graph
• Finding strongly connected components
• Solving puzzles such as mazes
BFS continues this process until all the levels of the graph are completed.
Generally queue data structure is used for storing the vertices of a level.
As an example, let us consider the same graph as that of the DFS example. The
BFS traversal can be shown as:
10.1 What is Sorting?
Sorting is an algorithm that arranges the elements of a list in a certain order
[either ascending or descending]. The output is a permutation or reordering of
the input.
By Number of Comparisons
In this method, sorting algorithms are classified based on the number of
comparisons. For comparison based sorting algorithms, best case behavior is
O(nlogn) and worst case behavior is O(n2). Comparison-based sorting
algorithms evaluate the elements of the list by key comparison operation and
need at least O(nlogn) comparisons for most inputs.
Later in this chapter we will discuss a few non – comparison (linear) sorting
algorithms like Counting sort, Bucket sort, Radix sort, etc. Linear Sorting
algorithms impose few restrictions on the inputs to improve the complexity.
By Number of Swaps
In this method, sorting algorithms are categorized by the number of swaps (also
called inversions).
By Memory Usage
Some sorting algorithms are “in place” and they need O(1) or O(logn) memory
to create auxiliary locations for sorting the data temporarily.
By Recursion
Sorting algorithms are either recursive [quick sort] or non-recursive [selection
sort, and insertion sort], and there are some algorithms which use both (merge
sort).
By Stability
Sorting algorithm is stable if for all indices i and j such that the key A[i] equals
key A[j], if record R[i] precedes record R[j] in the original file, record R[i]
precedes record R[j] in the sorted list. Few sorting algorithms maintain the
relative order of elements with equal keys (equivalent elements retain their
relative positions even after sorting).
By Adaptability
With a few sorting algorithms, the complexity changes based on presortedness
[quick sort]: presortedness of the input affects the running time. Algorithms that
take this into account are known to be adaptive.
Internal Sort
Sort algorithms that use main memory exclusively during the sort are called
internal sorting algorithms. This kind of algorithm assumes high-speed random
access to all memory.
External Sort
Sorting algorithms that use external memory, such as tape or disk, during the
sort come under this category.
The only significant advantage that bubble sort has over other implementations
is that it can detect whether the input list is already sorted or not.
Implementation
Algorithm takes O(n2) (even in best case). We can improve it by using one extra
flag. No more swaps indicate the completion of sorting. If the list is already
sorted, we can use this flag to skip the remaining passes.
This modified version improves the best case of bubble sort to O(n).
Performance
Advantages
• Easy to implement
• In-place sort (requires no additional storage space)
Disadvantages
Algorithm
1. Find the minimum value in the list
2. Swap it with the value in the current position
3. Repeat this process for all the elements until the entire array is sorted
This algorithm is called selection sort since it repeatedly selects the smallest
element.
Implementation
Performance
Advantages
• Simple implementation
• Efficient for small data
• Adaptive: If the input list is presorted [may not be completely] then
insertions sort takes O(n + d), where d is the number of inversions
• Practically more efficient than selection and bubble sorts, even though all
of them have O(n2) worst case complexity
• Stable: Maintains relative order of input data if the keys are same
• In-place: It requires only a constant amount O(1) of additional memory
space
• Online: Insertion sort can sort the list as it receives it
Algorithm
Every repetition of insertion sort removes an element from the input data, and
inserts it into the correct position in the already-sorted list until no inputelements
remain. Sorting is typically done in-place. The resulting array after k iterations
has the property where the first k + 1 entries are sorted.
Implementation
Example
Given an array: 6 8 1 4 5 3 7 2 and the goal is to put them in ascending order.
Analysis
Worst case analysis
Worst case occurs when for every i the inner loop has to move all elements A[1],
. . . , A[i – 1] (which happens when A[i] = key is smaller than all of them), that
takes Θ(i – 1) time.
Average case analysis
For the average case, the inner loop will insert A[i] in the middle of A[1], . . . ,
A[i – 1]. This takes Θ(i/2) time.
Performance
If every element is greater than or equal to every element to its left, the running
time of insertion sort is Θ(n). This situation occurs if the array starts out already
sorted, and so an already-sorted array is the best case for insertion sort.
Insertion sort is one of the elementary sorting algorithms with O(n2) worst-case
time. Insertion sort is used when the data is nearly sorted (due to its adaptiveness)
or when the input size is small (due to its low overhead). For thesereasons and
due to its stability, insertion sort is used as the recursive base case (when the
problem size is small) for higher overhead divide-and-conquer sortingalgorithms,
such as merge sort or quick sort.
Notes:
In insertion sort, comparisons are made between the adjacent elements. At most
1 inversion is eliminated for each comparison done with insertion sort. The
variation used in shell sort is to avoid comparing adjacent elements until the last
step of the algorithm. So, the last step of shell sort is effectively the insertion sort
algorithm. It improves insertion sort by allowing the comparison and exchange
of elements that are far away. This is the first algorithm which got less than
quadratic complexity among comparison sort algorithms.
Shellsort is actually a simple extension for insertion sort. The primary difference
is its capability of exchanging elements that are far apart, making it considerably
faster for elements to get to where they should be. For example, if the smallest
element happens to be at the end of an array, with insertion sort it will require
the full array of steps to put this element at the beginning of the array. However,
with shell sort, this element can jump more than one step a time and reach the
proper destination in fewer exchanges.
The basic idea in shellsort is to exchange every hth element in the array. Now
this can be confusing so we’ll talk more about this, h determines how far apart
element exchange can happen, say for example take h as 13, the first element
(index-0) is exchanged with the 14th element (index-13) if necessary (of course).
The second element with the 15th element, and so on. Now if we take has 1, it is
exactly the same as a regular insertion sort.
Shellsort works by starting with big enough (but not larger than the array size) h
so as to allow eligible element exchanges that are far apart. Once a sort is
complete with a particular h, the array can be said as h-sorted. The next step is to
reduce h by a certain sequence, and again perform another complete h-sort. Once
h is 1 and h-sorted, the array is completely sorted. Notice that the last sequence
for ft is 1 so the last sort is always an insertion sort, except by this time the array
is already well-formed and easier to sort.
Shell sort uses a sequence h1,h2, ...,ht called the increment sequence. Any
increment sequence is fine as long as h1 = 1, and some choices are better than
others. Shell sort makes multiple passes through the input list and sorts a number
of equally sized sets using the insertion sort. Shell sort improves the efficiency
of insertion sort by quickly shifting values to their destination.
Implementation
Note that when h == 1, the algorithm makes a pass over the entire list,comparing
adjacent elements, but doing very few element exchanges. For h == 1, shell sort
works just like insertion sort, except the number of inversions that have to be
eliminated is greatly reduced by the previous steps of the algorithm with h > 1.
Analysis
Shell sort is efficient for medium size lists. For bigger lists, the algorithm is not
the best choice. It is the fastest of all O(n2) sorting algorithms.
The disadvantage of Shell sort is that it is a complex algorithm and not nearly as
efficient as the merge, heap, and quick sorts. Shell sort is significantly slower
than the merge, heap, and quick sorts, but is a relatively simple algorithm, which
makes it a good choice for sorting lists of less than 5000 items unless speed is
important. It is also a good choice for repetitive sorting of smaller lists.
The best case in Shell sort is when the array is already sorted in the right order.
The number of comparisons is less. The running time of Shell sort depends on
the choice of increment sequence.
Performance
Important Notes
• Merging is the process of combining two sorted files to make one bigger
sorted file.
• Selection is the process of dividing a file into two parts: k smallest
elements and n – k largest elements.
• Selection and merging are opposite operations
○ selection splits a list into two lists
○ merging joins two files to make one file
• Merge sort is Quick sort’s complement
• Merge sort accesses the data in a sequential manner
• This algorithm is used for sorting a linked list
• Merge sort is insensitive to the initial order of its input
• In Quick sort most of the work is done before the recursive calls. Quick
sort starts with the largest subfile and finishes with the small ones
and as a result it needs stack. Moreover, this algorithm is not stable.
Merge sort divides the list into two parts; then each part is conquered
individually. Merge sort starts with the small subfiles and finishes
with the largest one. As a result it doesn’t need stack. This algorithm
is stable.
10.10 Quicksort
Quick sort is an example of a divide-and-conquer algorithmic technique. It is
also called partition exchange sort. It uses recursive calls for sorting the
elements, and it is one of the famous algorithms among comparison-based
sorting algorithms.
Divide: The array A[low ...high] is partitioned into two non-empty sub arrays
A[low ...q] and A[q + 1... high], such that each element of A[low ... high] is less
than or equal to each element of A[q + 1... high]. The index q is computed as part
of this partitioning procedure.
Conquer: The two sub arrays A[low ...q] and A[q + 1 ...high] are sorted by
recursive calls to Quick sort.
Algorithm
The recursive algorithm consists of four steps:
The worst-case occurs when the list is already sorted and last element chosen as
pivot.
Average Case: In the average case of Quick sort, we do not know where the
split happens. For this reason, we take all possible values of split locations, add
all their complexities and divide with n to get the average case complexity.
Performance
There are two ways of adding randomization in Quick sort: either by randomly
placing the input data in the array or by randomly choosing an element in the
input data for pivot. The second choice is easier to analyze and implement. The
change will only be done at the partition algorithm.
In normal Quick sort, pivot element was always the leftmost element in the list
to be sorted. Instead of always using A[low] as pivot, we will use a randomly
chosen element from the subarray A[low..high] in the randomized version of
Quick sort. It is done by exchanging element A[low] with an element chosen at
random from A[low..high]. This ensures that the pivot element is equally likely
to be any of the high – low + 1 elements in the subarray.
Since the pivot element is randomly chosen, we can expect the split of the input
array to be reasonably well balanced on average. This can help in preventing the
worst-case behavior of quick sort which occurs in unbalanced partitioning. Even
though the randomized version improves the worst case complexity, its worst
case complexity is still O(n2). One way to improve Randomized – Quick sort is
to choose the pivot for partitioning more carefully than by picking a random
element from the array. One common approach is to choose the pivot as the
median of a set of 3 elements randomly selected from the array.
• First phase is creating a binary search tree using the given array elements.
• Second phase is traversing the given binary search tree in inorder, thus
resulting in a sorted array.
Performance
The average number of comparisons for this method is O(nlogn). But in worst
case, the number of comparisons is reduced by O(n2), a case which arises when
the sort tree is skew tree.
• Counting Sort
• Bucket Sort
• Radix Sort
In the code below, A[0 ..n – 1] is the input array with length n. In Counting sort
we need two more arrays: let us assume array B[0 ..n – 1] contains the sorted
output and the array C[0 ..K – 1] provides temporary storage.
Total Complexity: O(K) + O(n) + O(K) + O(n) = O(n) if K =O(n). Space
Complexity: O(n) if K =O(n).
For bucket sort, the hash function that is used to partition the elements need to be
very good and must produce ordered hash: if i < k then hash(i) < hash(k). Second,
the elements to be sorted must be uniformly distributed.
The aforementioned aside, bucket sort is actually very good considering that
counting sort is reasonably speaking its upper bound. And counting sort is very
fast. The particular distinction for bucket sort is that it uses a hash function to
partition the keys of the input array, so that multiple keys may hash to the same
bucket. Hence each bucket must effectively be a growable list; similar to radix
sort.
In the below code insertionsort is used to sort each bucket. This is to inculcate
that the bucket sort algorithm does not specify which sorting technique to use on
the buckets. A programmer may choose to continuously use bucket sort on each
bucket until the collection is sorted (in the manner of the radix sort program
below). Whichever sorting method is used on the , bucket sort still tends toward
O(n).
Time Complexity: O(n). Space Complexity: O(n).
In Radix sort, first sort the elements based on the last digit [the least significant
digit]. These results are again sorted by second digit [the next to least significant
digit]. Continue this process for all digits until we reach the most significant
digits. Use some stable sort to sort them by last digit. Then stable sort them by
the second least significant digit, then by the third, etc. If we use Counting sort
as the stable sort, the total time is O(nd) ≈ O(n).
Algorithm:
The speed of Radix sort depends on the inner basic operations. If the operations
are not efficient enough, Radix sort can be slower than other algorithms such as
Quick sort and Merge sort. These operations include the insert and delete
functions of the sub-lists and the process of isolating the digit we want. If the
numbers are not of equal length then a test is needed to check for additional
digits that need sorting. This can be one of the slowest parts of Radix sort and
also one of the hardest to make efficient.
Since Radix sort depends on the digits or letters, it is less flexible than other
sorts. For every different type of data, Radix sort needs to be rewritten, and if the
sorting order changes, the sort needs to be rewritten again. In short, Radix sort
takes more time to write, and it is very difficult to write a general purpose Radix
sort that can handle all kinds of data.
For many programs that need a fast sort, Radix sort is a good choice. Still, there
are faster sorts, which is one reason why Radix sort is not used as much as some
other sorts.
As with internal sorting algorithms, there are a number of algorithms for external
sorting. One such algorithm is External Mergesort. In practice, these external
sorting algorithms are being supplemented by internal sorts.
1) Read 100MB of the data into main memory and sort by some
conventional method (let us say Quick sort).
2) Write the sorted data to disk.
3) Repeat steps 1 and 2 until all of the data is sorted in chunks of 100MB.
Now we need to merge them into one single sorted output file.
4) Read the first 10MB of each sorted chunk (call them input buffers) in
main memory (90MB total) and allocate the remaining 10MB for
output buffer.
5) Perform a 9-way Mergesort and store the result in the output buffer. If
the output buffer is full, write it to the final sorted file. If any of the 9
input buffers gets empty, fill it with the next 10MB of its associated
100MB sorted chunk; or if there is no more data in the sorted chunk,
mark it as exhausted and do not use it for merging.
The above algorithm can be generalized by assuming that the amount of data to
be sorted exceeds the available memory by a factor of K. Then, K chunks of data
need to be sorted and a K -way merge has to be completed.
If X is the amount of main memory available, there will be K input buffers and 1
output buffer of size X/(K + 1) each. Depending on various factors (how fast is
the hard drive?) better performance can be achieved if the output buffer is made
larger (for example, twice as large as one input buffer).
Complexity of the 2-way External Merge sort: In each pass we read + write each
page in file. Let us assume that there are n pages in file. That means we need
⌈logn⌉ + 1 number of passes. The total cost is 2n(⌈logn⌉ + 1).
Each iteration of the inner, j-indexed loop uses O(1) space, and for a fixed value
of i, the j loop executes n – i times. The outer loop executes n – 1 times, so the
entire function uses time proportional to
Time Complexity: O(n2). Space Complexity: O(1).
Problem-2 Can we improve the time complexity of Problem-1?
Heapsort function takes O(nlogn) time, and requires O(1) space. The scan clearly
takes n – 1 iterations, each iteration using O(1) time. The overall time is O(nlogn
+ n) = O(nlogn).
Solution: Yes. The approach is to sort the votes based on candidate ID, then
scan the sorted array and count up which candidate so far has the most votes. We
only have to remember the winner, so we don’t need a clever data structure. We
can use Heapsort as it is an in-place sorting algorithm.
Since Heapsort time complexity is O(nlogn) and in-place, it only uses an
additional O(1) of storage in addition to the input array. The scan of the sorted
array does a constant-time conditional n – 1 times, thus using O(n) time. The
overall time bound is O(nlogn).
Problem-5 Can we further improve the time complexity of Problem-3?
Solution: In the given problem, the number of candidates is less but the number
of votes is significantly large. For this problem we can use counting sort.
Time Complexity: O(n), n is the number of votes (elements) in the array. Space
Complexity: O(k), k is the number of candidates participating in the election.
Problem-6 Given an array A of n elements, each of which is an integer in
the range [1, n2], how do we sort the array in O(n) time?
Solution: If we subtract each number by 1 then we get the range [0, n2 – 1]. If
we consider all numbers as 2 –digit base n. Each digit ranges from 0 to n2 – 1.
Sort this using radix sort. This uses only two calls to counting sort. Finally, add 1
to all the numbers. Since there are 2 calls, the complexity is O(2n) ≈ O(n).
Problem-8 Given an array with n integers, each of value less than n100, can
it be sorted in linear time?
Solution: Yes. Find the median and partition the median. With this we can find
all the elements greater than it. Now find the Kth largest element in this set and
partition it; and get all the elements less than it. Output the sorted list of the final
set of elements. Clearly, this operation takes O(n + KlogK) time.
Problem-12 Consider the sorting algorithms: Bubble sort, Insertion sort,
Selection sort, Merge sort, Heap sort, and Quick sort. Which of these are
stable?
Solution: Let us assume that A is the array to be sorted. Also, let us say R and S
have the same key and R appears earlier in the array than S. That means, R is at
A[i] and S is at A[j], with i < j. To show any stable algorithm, in the sorted output
R must precede S.
Bubble sort: Yes. Elements change order only when a smaller record follows a
larger. Since S is not smaller than R it cannot precede it.
Selection sort: No. It divides the array into sorted and unsorted portions and
iteratively finds the minimum values in the unsorted portion. After finding a
minimum x, if the algorithm moves x into the sorted portion of the array by
means of a swap, then the element swapped could be R which then could be
moved behind S. This would invert the positions of R and S, so in general it is
not stable. If swapping is avoided, it could be made stable but the cost in time
would probably be very significant.
Merge sort: Yes, In the case of records with equal keys, the record in the left
subarray gets preference. Those are the records that came first in the unsorted
array. As a result, they will precede later records with the same key.
Heap sort: No. Suppose i = 1 and R and S happen to be the two records with the
largest keys in the input. Then R will remain in location 1 after the array is
heapified, and will be placed in location n in the first iteration of Heapsort. Thus
S will precede R in the output.
Quick sort: No. The partitioning step can swap the location of records many
times, and thus two records with equal keys could swap position in the final
output.
Problem-13 Consider the same sorting algorithms as that of Problem-12.
Which of them are in-place?
Solution:
Insertion sort: Yes, since we need to store two integers and a record.
Selection sort: Yes. This algorithm would likely need space for two integers and
one record.
Merge sort: No. Arrays need to perform the merge. (If the data is in the form of
a linked list, the sorting can be done in-place, but this is a nontrivial
modification.)
Heap sort: Yes, since the heap and partially-sorted array occupy opposite ends
of the input array.
Quicksort: No, since it is recursive and stores O(logn) activation records on the
stack. Modifying it to be non-recursive is feasible but nontrivial.
Problem-14 Among Quick sort, Insertion sort, Selection sort, and Heap sort
algorithms, which one needs the minimum number of swaps?
Solution: Selection sort – it needs n swaps only (refer to theory section).
Problem-15 What is the minimum number of comparisons required todetermine
if an integer appears more than n/2 times in a sorted array of n integers?
Solution: Refer to Searching chapter.
Problem-16 Sort an array of 0’s, 1’s and 2’s: Given an array A[] consisting
of 0’s, 1’s and 2’s, give an algorithm for sorting A[]. The
algorithm should put all 0’s first, then all 1’s and all 2’s last.
Example: Input = {0,1,1,0,1,2,1,2,0,0,0,1}, Output =
{0,0,0,0,0,1,1,1,1,1,2,2}
Solution: Use Counting sort. Since there are only three elements and the
maximum value is 2, we need a temporary array with 3 elements.
Time complexity: O(n), in the worst case we need to scan the complete array.
Space complexity: O(1).
For example, suppose we have a table like this, which gives some values of an
unknown function f. Interpolation provides a means of estimating the function at
intermediate points, such as x = 55.
x f(x)
1 10
2 20
3 30
4 40
5 50
6 60
7 70
There are many different interpolation methods, and one of the simplest methods
is linear interpolation. Since 55 is midway between 50 and 60, it is reasonable to
take f(55) midway between f(5) = 50 and f(6) = 60, which yields 55.
Linear interpolation takes two data points, say (x1; y2) and (x2, y2), and the
interpolant is given by:
With above inputs, what will happen if we don’t use the constant ½, but another
more accurate constant “K”, that can lead us closer to the searched item.
This algorithm tries to follow the way we search a name in a phone book, or a
word in the dictionary. We, humans, know in advance that in case the name
we’re searching starts with a “m”, like “monk” for instance, we should start
searching near the middle of the phone book. Thus if we’re searching the word
“career” in the dictionary, you know that it should be placed somewhere at the
beginning. This is because we know the order of the letters, we know the interval
(a-z), and somehow we intuitively know that the words are dispersed equally.
These facts are enough to realize that the binary search can be a bad choice.
Indeed the binary search algorithm divides the list in two equal sub-lists, which
is useless if we know in advance that the searched item is somewhere in the
beginning or the end of the list. Yes, we can use also jump search if the item is at
the beginning, but not if it is at the end, in that case this algorithm is not so
effective.
The interpolation search algorithm tries to improve the binary search. The
question is how to find this value? Well, we know bounds of the interval and
looking closer to the image above we can define the following formula.
This constant K is used to narrow down the search space. For binary search, this
constant K is (low + high)/2.
Now we can be sure that we’re closer to the searched value. On average the
interpolation search makes about log (logn) comparisons (if the elements are
uniformly distributed), where n is the number of elements to be searched. In the
worst case (for instance where the numerical values of the keys increase
exponentially) it can make up to O(n) comparisons. In interpolation-sequential
search, interpolation is used to find an item near the one being searched for, then
linear search is used to find the exact item. For this algorithm to give best results,
the dataset should be ordered and uniformly distributed.
Solution: Yes. Sort the given array. After sorting, all the elements with equal
values will be adjacent. Now, do another scan on this sorted array and see if there
are elements with the same value and adjacent.
Solution: Yes, using hash table. Hash tables are a simple and effective method
used to implement dictionaries. Average time to search for an element is O(1),
while worst-case time is O(n). Refer to Hashing chapter for more details on
hashing algorithms. As an example, consider the array, A = {3,2,1,2,2,3}.
Scan the input array and insert the elements into the hash. For each inserted
element, keep the counter as 1 (assume initially all entires are filled with zeros).
This indicates that the corresponding element has occurred already. For the
given array, the hash table will look like (after inserting the first three elements
3,2 and 1):
Now if we try inserting 2, since the counter value of 2 is already 1, we can say
the element has appeared twice.
Initially,
Notes:
• This solution does not work if the given array is read only.
• This solution will work only if all the array elements are positive.
• If the elements range is not in 0 to n – 1 then it may give exceptions.
Problem-5 Given an array of n numbers. Give an algorithm for finding the
element which appears the maximum number of times in the array?
Brute Force Solution: One simple solution to this is, for each input element
check whether there is any element with the same value, and for each such
occurrence, increment the counter. Each time, check the current counter with the
max counter and update it if this value is greater than max counter. This we can
solve just by using two simple for loops.
Time Complexity: O(n2), for two nested for loops. Space Complexity: O(1).
Problem-6 Can we improve the complexity of Problem-5 solution?
Solution: Yes. Sort the given array. After sorting, all the elements with equal
values come adjacent. Now, just do another scan on this sorted array and see
which element is appearing the maximum number of times.
Time Complexity: O(nlogn). (for sorting). Space Complexity: O(1).
Problem-7 Is there any other way of solving Problem-5?
Solution: Yes, using hash table. For each element of the input, keep track of
how many times that element appeared in the input. That means the counter
value represents the number of occurrences for that element.
Solution: Yes. We can solve this problem in two scans. We cannot use the
negation technique of Problem-3 for this problem because of the number of
repetitions. In the first scan, instead of negating, add the value n. That means for
each occurrence of an element add the array size to that element. In the second
scan, check the element value by dividing it by n and return the element which
gives the maximum value. The code based on this method is given below.
Notes:
• This solution does not work if the given array is read only.
• This solution will work only if the array elements are positive.
• If the elements range is not in 1 to n then it may give exceptions.
Time Complexity: O(n). Since no nested for loops are required. Space
Complexity: O(1).
Problem-9 Given an array of n numbers, give an algorithm for finding the first
element in the array which is repeated. For example, in the array A =
{3,2,1,2,2,3}, the first repeated number is 3 (not 2). That means, we need
to return the first element among the repeated elements.
Solution: We can use the brute force solution that we used for Problem-1. For
each element, since it checks whether there is a duplicate for that element or not,
whichever element duplicates first will be returned.
Problem-10 For Problem-9, can we use the sorting technique?
Solution: No. For proving the failed case, let us consider the following array.
For example, A = {3, 2, 1, 2, 2, 3}. After sorting we get A = {1,2,2,2,3,3}. In this
sorted array the first repeated element is 2 but the actual answer is 3.
Problem-11 For Problem-9, can we use hashing technique?
Solution: Yes. But the simple hashing technique which we used for Problem-3
will not work. For example, if we consider the input array as A = {3,2,1,2,3},
then the first repeated element is 3, but using our simple hashing technique we
get the answer as 2. This is because 2 is coming twice before 3. Now let us
change the hashing table behavior so that we get the first repeated element. Let
us say, instead of storing 1 value, initially we store the position of the element in
the array. As a result the hash table will look like (after inserting 3,2 and 1):
Now, if we see 2 again, we just negate the current value of 2 in the hash table.
That means, we make its counter value as –2. The negative value in the hash
table indicates that we have seen the same element two times. Similarly, for 3
(the next element in the input) also, we negate the current value of the hash table
and finally the hash table will look like:
After processing the complete input array, scan the hash table and return the
highest negative indexed value from it (i.e., –1 in our case). The highest negative
value indicates that we have seen that element first (among repeated elements)
and also repeating.
What if the element is repeated more than twice? In this case, just skip the
element if the corresponding value i is already negative.
Problem-12 For Problem-9, can we use the technique that we used for Problem-
3 (negation technique)?
Brute Force Solution: One simple solution to this is, for each number in 1 to n,
check whether that number is in the given array or not.
Time Complexity: O(n2). Space Complexity: O(1).
Problem-14 For Problem-13, can we use sorting technique?
Solution: Yes. Sorting the list will give the elements in increasing order and
with another scan we can find the missing number.
Solution: Yes. Scan the input array and insert elements into the hash. For
inserted elements, keep counter as 1 (assume initially all entires are filled with
zeros). This indicates that the corresponding element has occurred already. Now,
scan the hash table and return the element which has counter value zero.