algorithms-and-complexity-analysis-csc304_1716907183
algorithms-and-complexity-analysis-csc304_1716907183
LECTURE NOTES
AYEPEKU F.O
o Graph representation
CHAPTER ONE
An algorithm is a set of commands that must be followed for a computer to perform calculations
or other problem-solving operations. According to its formal definition, an algorithm is a finite
set of instructions carried out in a specific order to perform a particular task. It is not the entire
program or code; it is simple logic to a problem represented as an informal description in the
form of a flowchart or pseudocode.
Algorithms are step-by-step procedures designed to solve specific problems and perform tasks
efficiently in the realm of computer science and mathematics. These powerful sets of instructions
form the backbone of modern technology and govern everything from web searches to artificial
intelligence. Here's how algorithms work:
Input: Algorithms take input data, which can be in various formats, such as numbers, text,
or images.
Scalability
It aids in your understanding of scalability. When you have a sizable real-world problem, you
must break it down into small steps to analyze it quickly.
Performance
The real world is challenging to break down into smaller steps. If a problem can be easily
divided into smaller steps, it indicates that the problem is feasible.
After understanding what is an algorithm, why you need an algorithm, you will look at how to
write one using an example.
Types of Algorithms
1. Brute Force Algorithm: A straightforward approach that exhaustively tries all possible
solutions, suitable for small problem instances but may become impractical for larger
ones due to its high time complexity.
2. Recursive Algorithm: A method that breaks a problem into smaller, similar subproblems
and repeatedly applies itself to solve them until reaching a base case, making it effective
for tasks with recursive structures.
There are no well-defined standards for writing algorithms. It is, however, a problem that
is resource-dependent. Algorithms are never written with a specific programming
language in mind.
As you all know, basic code constructs such as loops like do, for, while, all programming
languages share flow control such as if-else, and so on. An algorithm can be written using
these common constructs.
Example
Problem: Create an algorithm that multiplies two numbers and displays the output.
Step 1 − Start
Step 6 − print z
Step 7 − Stop
Algorithms instruct programmers on how to write code. In addition, the algorithm can be written
as:
Step 3 − z ← x * y
Step 4 − display z
Step 5 − Stop
In algorithm design and analysis, the second method is typically used to describe an algorithm. It
allows the analyst to analyze the algorithm while ignoring all unwanted definitions easily. They
As a result, many solution algorithms for a given problem can be derived. The following step is
to evaluate the proposed solution algorithms and implement the most appropriate solution.
As you progress through this "what is an Algorithm" tutorial, you will learn about some of the
components of an algorithm.
Factors of an Algorithm
Modularity: This feature was perfectly designed for the algorithm if you are given a
problem and break it down into small-small modules or small-small steps, which is a
basic definition of an algorithm.
Correctness: An algorithm's correctness is defined as when the given inputs produce the
desired output, indicating that the algorithm was designed correctly. An algorithm's
analysis has been completed correctly.
Maintainability: It means that the algorithm should be designed in a straightforward,
structured way so that when you redefine the algorithm, no significant changes are made
to the algorithm.
Functionality: It takes into account various logical steps to solve a real-world problem.
Robustness: Robustness refers to an algorithm's ability to define your problem clearly.
User-friendly: If the algorithm is difficult to understand, the designer will not explain it to
the programmer.
Efficiency: A good algorithm should perform its task quickly and use minimal resources.
Correctness: It must produce the correct and accurate output for all valid inputs.
Clarity: The algorithm should be easy to understand and comprehend, making it
maintainable and modifiable.
Scalability: It should handle larger data sets and problem sizes without a significant
decrease in performance.
Reliability: The algorithm should consistently deliver correct results under different
conditions and environments.
Optimality: Striving for the most efficient solution within the given problem constraints.
Robustness: Capable of handling unexpected inputs or errors gracefully without crashing.
Adaptability: Ideally, it can be applied to a range of related problems with minimal
adjustments.
Simplicity: Keeping the algorithm as simple as possible while meeting its requirements,
avoiding unnecessary complexity.
Advantages of Algorithms:
The complexity of an algorithm is a measure of the amount of time and/or space required by an
algorithm for an input of a given size (n). Though the complexity of the algorithm does depends
upon the specific factors such as: The architecture of the computer i.e. the hardware platform
representation of the Abstract Data Type(ADT) compiler efficiency the complexity of the
underlying algorithm size of the input. Though you will see most significant factors are a
complexity of underlying algorithm and size of the input.
Asymptotic Analysis
Asymptotic analysis refers to the computing of the running time of any piece of code or the
operation in a mathematical unit of a computation. Its operation is computed in terms of a
function like f(n). In mathematical analysis, asymptotic analysis, also known as asymptotics, is a
method of describing limiting behavior.
The time required by the algorithm falls under the three types: Worst case - Maximum time
required by an algorithm and it is mostly used or done while analyzing the algorithm. Best case -
Minimum time required for the algorithm or piece of code and it is not normally calculated while
analyzing the algorithm. Average case - Average time required for an algorithm or portion of
code and it is sometimes done while analyzing the algorithm.
The commonly used notation for calculating the running time complexity of the algorithm is as
follows:
Big O notation
Big θ notation
Big Ω notation
Big Oh Notation, Ο
The notation Ω(n) is the formal way to express the lower bound of an algorithm's running time. It
measures the best case time complexity or the best amount of time an algorithm can possibly
take to complete.
The notation θ(n) is the formal way to express both the lower bound and the upper bound of an
algorithm's running time.
1. Big O Notation (O): Think of Big O as an upper bound, indicating the worst-case
scenario for an algorithm's performance. It answers the question: "In the worst situation,
how much time will our algorithm take?" For example, O(n) signifies that the time taken
by the algorithm grows linearly with the input size.
2. Omega Notation (Ω): In contrast, Omega serves as a lower bound, representing the best-
case scenario for an algorithm's performance. It answers the question: "No matter what,
1. Identify the Input Size: Determine what aspect of the algorithm's efficiency you're
evaluating, often the input size, like the number of elements in a list.
2. Count Basic Operations: Break down the algorithm into its fundamental operations. For
example, if you're sorting a list, count the number of times you compare two elements or
swap them.
3. Focus on the Worst Case: Analyze the algorithm's behavior in the worst-case scenario,
where the input is most challenging for the algorithm. This provides insight into the
algorithm's worst performance.
4. Remove Constants: Eliminate constant factors from your analysis. Concentrate on the
most significant factors that affect the algorithm's performance.
5. Use Big O Notation: Express the algorithm's complexity using "Big O" notation,
describing how the algorithm's runtime or memory usage grows as the input size
increases. For example, if doubling the input size approximately doubles the runtime, it
might be expressed as O(n).
6. Compare with Other Algorithms: Finally, compare the results with other algorithms to
determine which one is more efficient. In general, the algorithm with a lower Big O
notation runs faster.
Upper complexity bounds, such as Big O notation, are particularly important because they
provide a guarantee on the algorithm's performance in the worst-case scenario. This helps ensure
that the algorithm won't perform unacceptably poorly under any circumstances.
Complexity classes categorize algorithms based on their worst-case time or space complexity.
Some standard complexity classes include:
Constant Time (O(1)): Algorithms with constant time complexity execute in a fixed
amount of time regardless of the size of the input. Accessing an element in an array by
index is an example of an O(1) operation.
Logarithmic Time (O(log n)): Algorithms with logarithmic time complexity typically
divide the problem size in half with each step. Binary search is an example of an
algorithm with O(log n) complexity.
Linear Time (O(n)): Algorithms with linear time complexity have their runtime directly
proportional to the size of the input. Linear search through an unsorted array is an
example of O(n) complexity.
Quadratic Time (O(n^2)): Algorithms with quadratic time complexity have their
runtime proportional to the square of the size of the input. Nested loops iterating over the
input are common in algorithms with O(n^2) complexity.
Exponential Time (O(2^n)): Algorithms with exponential time complexity grow very
rapidly with the size of the input. Recursive algorithms without proper memoization often
exhibit exponential time complexity.
Understanding these complexity classes helps in selecting the most appropriate algorithm for a
given problem, balancing between efficiency and scalability. For instance, an algorithm with
Time Complexity
The amount of time required to complete an algorithm's execution is called time complexity. The
big O notation is used to represent an algorithm's time complexity. The asymptotic notation for
describing time complexity, in this case, is big O notation. The time complexity is calculated
primarily by counting the number of steps required to complete the execution. Let us look at an
example of time complexity.
mul = 1;
for i=1 to n
// when the loop ends, then mul holds the multiplication of the n numbers
return mul;
Time Complexity: The time complexity of an algorithm represents the amount of time it takes
to execute as a function of the input size. In this code snippet, the loop iterates from 1 to nn,
where nn is the number of numbers to be multiplied.
Inside the loop, there is a single operation: mul = mul * 1;. This operation is constant
time (O(1)O(1)) because multiplying by 1 does not depend on the size of the input.
The loop runs nn times, where nn is the input size.
Since the loop runs nn times and each iteration takes constant time, the overall time complexity
of the algorithm is O(n)O(n).
The variable mul requires a constant amount of space to store a single integer, regardless
of the size of the input.
There are no other variables or data structures used that grow with the input size.
Therefore, the space complexity of the algorithm is O(1)O(1), indicating constant space usage
regardless of the input size.
Summary:
Time Complexity: O(n)O(n) - Linear time complexity indicates that the time taken to
execute the algorithm grows linearly with the size of the input.
Space Complexity: O(1)O(1) - Constant space complexity indicates that the amount of
memory required remains constant regardless of the size of the input.
Example 2: Explain the time and space complexity with respect to the program below
def twoForLoops(n):
for i in range(1,n):
print("Printing:"+i);
print("Printing:"+i);
Let's analyze the time and space complexity of the given Python function twoForLoops(n).
Time Complexity:
First Loop:
python
for i in range(1, n):
print("Printing:" + i)
The time complexity of this loop is O(n)O(n) because the number of iterations directly depends
on the input size nn.
Second Loop:
python
for i in range(1, 100):
print("Printing:" + i)
This loop iterates from 1 to 99, with a fixed number of iterations (100 iterations).
Regardless of the input size nn, this loop always executes 100 times.
The time complexity of this loop is constant, O(1)O(1), because the number of iterations remains
constant irrespective of the input size.
Overall Time Complexity: Since the loops are sequential and not nested, we consider the
dominant term. In this case, the first loop dominates the time complexity. Thus, the overall time
complexity is O(n)O(n), linear time complexity, because the first loop's time complexity is
proportional to the input size nn.
The function does not declare or use any additional variables that grow with the input
size.
The memory required for the loops is constant.
Therefore, the space complexity of the function is O(1)O(1), indicating constant space usage
regardless of the input size.
Algorithm analysis often involves making trade-offs between time and space efficiency. Here's a
comprehensive exploration of these trade-offs:
In algorithm design, there's often a trade-off between optimizing for time complexity (execution
speed) and space complexity (memory usage). Let's explore this trade-off with examples:
Dynamic Programming:
Merge Sort:
Hash Tables:
Example: Implementing a hash table for storing key-value pairs allows fast retrieval and
updates (O(1)O(1) average time complexity) but consumes memory proportional to the
number of elements stored.
Example: Inserting elements into a binary search tree maintains the tree's balance,
ensuring fast lookup times. However, if the tree becomes unbalanced, the space
complexity increases, impacting performance.
Bit Manipulation:
2. Time-Space Trade-off:
In algorithm design, a time-space trade-off refers to the balancing act between optimizing for
time efficiency (execution speed) and space efficiency (memory usage). Here's a more detailed
exploration:
Definition:
o A time-space trade-off involves making decisions that impact both the time and
space complexity of an algorithm.
o It involves optimizing one aspect (time or space) at the expense of the other,
based on the specific requirements and constraints of the problem at hand.
Decision Making:
o When faced with a time-space trade-off, developers must carefully consider the
trade-offs and make informed decisions based on the needs of the application.
o The decision-making process often involves analyzing the relative importance of
time and space efficiency in the context of the problem domain.
o In scenarios where fast execution is critical, optimizing for time efficiency takes
precedence over minimizing memory usage.
o Strategies for optimizing time efficiency may include using more complex
algorithms, precomputing results, or employing parallel processing techniques.
Dynamic Trade-offs:
o The optimal balance between time and space efficiency may vary depending on
factors such as input size, available hardware resources, and performance
requirements.
o Algorithms may dynamically adjust their behavior based on these factors to
achieve the best possible performance under different conditions.
Heuristic Algorithms:
When designing algorithms, developers must carefully consider the trade-offs between time
efficiency (execution speed) and space efficiency (memory usage). Here's a detailed exploration
of the key considerations:
Problem Requirements:
o The characteristics of the input data can significantly impact the choice of
algorithm and its associated time and space complexities.
o Consideration must be given to factors such as input size, distribution, and
variability.
o The hardware environment in which the algorithm will run plays a crucial role in
design considerations.
o Factors such as available memory, processing power, and storage capacity must
be taken into account when making decisions about time-space trade-offs.
Performance Objectives:
Scalability:
Optimization Opportunities:
In algorithm design, developers often face the trade-off between using more memory to reduce
computation time or performing more computations to conserve memory. Here's a deeper dive
into this trade-off:
Memory Optimization:
Computation Optimization:
Examples:
Matrix Multiplication:
Sorting Algorithms:
Dynamic Programming:
Data structures play a crucial role in algorithm design, significantly influencing both time and
space complexity. Here's an in-depth look at how different data structures impact these
complexities:
Arrays:
o Arrays are contiguous blocks of memory that store elements of the same data
type.
o Time Complexity: Accessing elements by index in an array is efficient
(O(1)O(1)) since it involves simple pointer arithmetic.
o Space Complexity: Arrays have a fixed size determined at the time of
declaration, leading to O(n)O(n) space complexity, where nn is the number of
elements.
Linked Lists:
o Linked lists consist of nodes where each node contains a value and a
pointer/reference to the next node.
o Time Complexity: Traversing a linked list requires O(n)O(n) time since it
involves following pointers from one node to another.
o Space Complexity: Linked lists have O(n)O(n) space complexity due to the
overhead of storing pointers for each node.
Hash Tables:
o Hash tables use a hash function to map keys to indices in an array, providing
constant-time access to elements.
o Time Complexity: On average, hash table operations like insertion, deletion, and
search have O(1)O(1) time complexity. However, worst-case scenarios can lead to
O(n)O(n) time complexity.
o Space Complexity: Hash tables typically have O(n)O(n) space complexity due to
the underlying array used to store elements.
Example: Using a hash table for frequency counting allows efficient retrieval of
counts for each element (O(1)O(1) average time complexity for insertion and
lookup).
o Trees are hierarchical data structures where each node has zero or more child
nodes.
o Time Complexity: Operations like insertion, deletion, and search in balanced
trees have O(logn)O(logn) time complexity, making them efficient for large
datasets.
o Space Complexity: Balanced trees have O(n)O(n) space complexity, where nn is
the number of elements.
1. Dynamic Programming:
o Dynamic programming is a powerful technique used to solve problems by
breaking them down into smaller subproblems and storing the solutions to these
subproblems for future reference.
o By memoizing intermediate results, dynamic programming optimizes both time
and space complexities.
2. Greedy Algorithms:
o Greedy algorithms make locally optimal choices at each step with the hope of
finding a global optimum.
o These algorithms often require less memory overhead compared to other
techniques, resulting in optimized space complexity.
Example: Minimum Spanning Tree (MST) - Kruskal's algorithm greedily selects edges
in ascending order of weight to construct an MST. It achieves both time and space
efficiency without requiring memoization or excessive memory usage.
Example: Two Sum Problem - Given an array of integers, find two numbers that sum up
to a specific target. Using the two-pointer technique, we can solve this problem in
O(n)O(n) time and O(1)O(1) space.
Example: Bloom Filters - Bloom filters are probabilistic data structures used to test
whether an element is a member of a set. They achieve space efficiency by using a
compact array of bits, with constant-time insertions and lookups.
5. Lazy Evaluation:
o Lazy evaluation delays the evaluation of an expression until its value is actually
needed, avoiding unnecessary computations and conserving memory.
Example: Lazy Initialization - In lazy initialization, objects are created only when they
are first accessed, rather than when they are declared. This technique saves memory by
deferring object creation until necessary.
6. Approximation Algorithms:
o Approximation algorithms provide approximate solutions to optimization
problems with guaranteed performance bounds, often trading off exactness for
efficiency in terms of both time and space.
Example: Parallel Merge Sort - By splitting the array into smaller subarrays and sorting
them concurrently in parallel, parallel merge sort achieves both time and space efficiency,
reducing sorting time while conserving memory.
1. Database Indexing:
o In databases, indexing improves query performance by trading off additional
storage space for reduced query time.
o Indexes are precomputed data structures that speed up data retrieval operations.
2. Image Compression:
o Image compression algorithms trade off space for time by compressing image
data to reduce storage requirements while maintaining reasonable decompression
time.
o Examples include JPEG and PNG compression techniques.
3. Web Caching:
o Web caching mechanisms store copies of frequently accessed web pages or
resources closer to the user, reducing server load and improving response time.
o This trade-off involves allocating additional storage space to cache data in
exchange for faster access.
4. File Compression:
o File compression algorithms, such as ZIP and RAR, compress files to reduce
storage space while allowing for relatively fast decompression.
RECURSIVE ALGORITHMS
Recursion is a method of solving problems where the solution depends on solutions to smaller
instances of the same problem. In mathematical terms, it's a way of defining functions in which
the function being defined is applied within its own definition.
A recursive algorithm, in the simplest terms, is a method of problem solving where the solution
to a problem depends on solutions to smaller instances of the same problem. It breaks down a
problem into smaller and smaller subproblems, until it gets to a problem that is small enough to
be solved easily
Recursive algorithms are a powerful tool for problem-solving, but as with everything in
programming, there are trade-offs. Here are some advantages and disadvantages to using
recursive algorithms:
Understanding when to use a recursive algorithm is part of becoming a better programmer. They
can be used to make code cleaner and easier to understand, but they can also become a source of
inefficiency if used improperly.
Advantages:
Disadvantages:
1. Base Case: Every recursive function must have a base case, which serves as the
termination condition. It's the simplest form of the problem that doesn't need further
recursion. Without a base case, the recursion would continue indefinitely, leading to
what's called a "stack overflow" or infinite loop.
2. Recursive Step: This is where the function calls itself with a modified version of the
original problem. Each successive recursive call should move closer to the base case,
eventually reaching it.
3. Progress Towards the Base Case: To ensure termination, each recursive call must
reduce the size of the problem in some way, either by reducing the input size or by
moving closer to the base case in some other manner.
Factorial Function:
Recursion in the factorial function occurs when a function calls itself with a smaller
argument until it reaches the base case (usually n=0n=0).
For example, in the factorial function n!n!, the base case is when n=0n=0, where
0!=10!=1.
The recursive step is defined as n!=n×(n−1)!n!=n×(n−1)! for n>0n>0. This means that the
factorial of nn is calculated by multiplying nn with the factorial of n−1n−1.
factorial(n):
if n == 0:
return 1
else:
Meaning each Fibonacci number is the sum of the two previous Fibonacci numbers.
fibonacci(n):
if n == 0:
return 0
elif n == 1:
return 1
else:
return fibonacci(n - 1) + fibonacci(n - 2)
Both examples demonstrate how recursion can be used to define mathematical sequences or
operations concisely. However, it's essential to handle base cases properly to avoid infinite
recursion. Recursion offers elegant solutions for certain problems but may lead to inefficiency or
stack overflow errors for large inputs if not implemented carefully.
There are four different types of recursive algorithms, you will look at them one by one.
Direct Recursion
A function is called direct recursive if it calls itself in its function body repeatedly. To better
understand this definition, look at the structure of a direct recursive program.
In this program, you have a method named fun that calls itself again in its function body. Thus,
you can say that it is direct recursive.
Indirect Recursion
The recursion in which the function calls itself via another function is called indirect recursion.
Now, look at the indirect recursive program structure.
int fun1(int z) {
// Definition of fun2 within fun1
int fun2(int y) {
// Base case for fun2
if (y <= 0) {
return 0;
} else {
// Recursive call to fun1 with y decremented by 2
fun1(y - 2);
}
}
In this example, you can see that the function fun1 explicitly calls fun2, which is invoking fun1
again. Hence, you can say that this is an example of indirect recursion.
A recursive function is said to be tail-recursive if the recursive call is the last execution done by
the function. Let’s try to understand this definition with the help of an example.
void fun(int z) {
// Print the value of z
printf("%d ", z);
If you observe this program, you can see that the last line ADI will execute for method fun is a
recursive call. And because of that, there is no need to remember any previous state of the
program.
Non-Tailed Recursion
A recursive function is said to be non-tail recursive if the recursion call is not the last thing done
by the function. After returning back, there is something left to evaluate. Now, consider this
example.
void fun(int z) {
if (z > 1) {
fun(z - 1); // Recursive call with z decremented by 1
}
printf("%d ", z); // Print the value of z after the recursive call
}
In this function, you can observe that there is another operation after the recursive call. Hence the
ADI will have to memorize the previous state inside this method block. That is why this program
can be considered non-tail recursive.
Non Recursive Algorithm, also known as an iterative algorithm, involves solving a problem
through repetition of a series of instructions until a specific condition is met, typically without
the need for the function to call itself.
So, let's delve into comparing recursive and non-recursive algorithms. Each method has its
unique operations and performances which brings about several comparative elements. The
following table provides a succinct contrast of the two:
Relies on calling itself to solve smaller Does not call itself. Primarily uses
Function calls
instances of the problem loops to resolve a problem
Code Often results in cleaner, simpler code, Can result in lengthier, complex code
complexity enhancing readability as problem size increases
Memory Tend to use more memory due to stack Generally consumes less memory, as it
Can be slower due to overhead of Often faster due to fewer function calls
Speed
function calls and less overhead
Each type of algorithm comes with its pros and cons. Recursive algorithms are lauded for
cleaner, easier-to-understand code. However, they are generally slower and consume more
memory due to the overhead involved with multiple function calls. On the contrary, non-
recursive algorithms are praised for being more efficient in terms of speed and memory usage.
Although they can result in more complex and lengthier code, they bring better runtime
efficiency, making them a preferable choice for larger data sets. Remember, thoughtful
discretion of the nature of the problem, the performance requirements, and the available
computational resources is mandatory when it comes to selecting between recursive and non-
recursive algorithms.
def factorial(n):
result = 1
for i in range(1, n + 1):
result *= i
return result
2. Fibonacci Sequence: Just like calculating the factorial of a number, the Fibonacci sequence,
which is widely used as an example of a problem solved by recursion, may also be computed
iteratively.
Embarking on the journey to comprehend the recursive algorithm in its entirety, you might
encounter a few roadblocks. Still, don’t worry, these hurdles are merely part of the learning
experience. Step by step, let's dissect the recursive algorithm's definition and understand its core
components. After all, mastering this ever-important concept is key to solving complex problems
in computer science.
At its core, a recursive algorithm is a method of solving problems that involves the algorithm
calling itself to solve smaller instances of the same problem. Breaking down the definition
further will give you a detailed perspective and reinforce your understanding. Method of
Solving Problems: Recursion is fundamentally a problem-solving approach. We utilise
recursion in computer science due to its unparalleled strength and simplicity in solving intricate
problems. Algorithm Calling Itself: The quintessential element that distinguishes recursive
algorithms from other algorithms is that it involves the function calling itself. This self-
involvement of the function happens until handling the smaller or simpler problem instances
become manageable. Solving Smaller Instances of the Same Problem: Recursive algorithms
exhibit the beauty of problem-solving through its capability to divide an overwhelming problem
into manageable sub-problems. These sub-problems are essentially smaller instances of the very
problem itself. It's crucial to note that the concept of 'smaller instances' can mean two things
based on the problem's nature. It may refer to either a physically smaller subset of the original
problem or a problem that is logically simpler to solve. An essential feature to bear in mind is the
Recursive algorithms have profound implications and widespread applications in various realms
of computer science, owing to their ability in presenting concise and clean solutions to intricate
problems. With their unique problem-solving approach that deals with smaller instances of the
same problem, recursive algorithms often prove to be immensely beneficial in tackling complex
scenarios. Let's explore a few paramount applications of recursive algorithms:
1. Sorting Algorithms: Recursive algorithms drive some of the most efficient sorting algorithms
in computer science, such as Merge Sort and Quick Sort. They utilise the divide-and-conquer
strategy to divide the dataset into smaller subsets, recursively sort them, and finally reassemble
them into a sorted whole.
2. Tree and Graph Data Structures: Recursive algorithms are extensively used in various
operations involving tree and graph data structures. Be it performing Depth-First Search on a
graph, or traversing a Binary Search Tree, recursive algorithms provide the simplest and the most
intuitive solutions. The process of breaking the problem down to smaller sub-problems aligns
with the inherent hierarchical structure of trees and graphs, making recursion the go-to approach
for many tasks involving these data structures.
Analysis of recursive algorithms' time and space complexities is crucial for understanding their
efficiency and resource usage. Here's how you typically perform such analysis:
1. Recurrence Relation: Define a recurrence relation that represents the time complexity of
the algorithm. This relation describes the time taken by the algorithm in terms of the time
taken by its subproblems.
2. Solving the Recurrence: Solve the recurrence relation to find a closed-form expression
for the time complexity. This often involves techniques like substitution, iteration, or
master theorem for divide-and-conquer algorithms.
1. Call Stack: Understand how the call stack behaves during the execution of the recursive
algorithm. Each recursive call consumes space on the call stack.
2. Memory Usage: Analyze the memory usage of the algorithm. This includes space
required for variables, data structures, and any additional memory used during recursive
calls.
3. Recursion Depth: Determine the maximum depth of recursion, which corresponds to the
maximum space consumed on the call stack.
int factorial(int n) {
if (n <= 1) {
return 1;
} else {
return n * factorial(n - 1);
}
}
Recurrence Relation: T(n) = T(n-1) + O(1), where T(n) represents the time taken to
compute factorial(n).
Solving the Recurrence: By unfolding the recursion, we find that T(n) = O(n), as each
recursive call reduces the problem size by one until it reaches the base case.
Call Stack: The space required on the call stack is proportional to the depth of recursion.
Recursion Depth: The recursion depth for factorial(n) is n, as it makes n recursive calls.
Space Complexity: O(n) due to the space used on the call stack.
Algorithm:
Complexity:
Time Complexity: O(n) - Linear time complexity since the algorithm may need to
traverse the entire list.
Space Complexity: O(1) - Constant space complexity since no additional data structures
are used.
Practical Application:
Unordered Lists: Sequential search is useful for searching unsorted lists or arrays where
elements are not in any particular order.
Linear Search in Arrays: It's commonly used in programming languages to find
elements in arrays or lists.
Example:
python
# Sequential search implementation in Python
def sequential_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i # Return index if found
return -1 # Return -1 if not found
# Example usage
my_list = [3, 5, 2, 8, 4, 7, 1]
target_value = 8
Algorithm:
Complexity:
Time Complexity: O(log n) - Logarithmic time complexity since the search interval is
halved in each step.
Space Complexity: O(1) - Constant space complexity since the algorithm uses only a
few variables for bookkeeping.
Practical Application:
Sorted Lists or Arrays: Binary search is ideal for searching in sorted collections where
elements are arranged in ascending or descending order.
Efficient Searching: It's commonly used in libraries, databases, and search engines for
efficient searching of large datasets.
Example:
# Example usage
sorted_list = [1, 2, 3, 4, 5, 6, 7, 8, 9]
target_value = 7
index = binary_search(sorted_list, target_value)
if index != -1:
print(f"Target value {target_value} found at index {index}.")
else:
print("Target value not found.")
2. Sorting Algorithms: Sorting algorithms are fundamental operations in computer science used
to arrange elements of a collection in a specific order. These algorithms are ubiquitous in various
applications, from organizing data for efficient searching to optimizing performance in
computational tasks. In this comprehensive exploration, we'll delve into key sorting algorithms,
their algorithms, practical applications, complexities, and examples.
1. Bubble Sort:
o Description: Bubble Sort repeatedly steps through the list, compares adjacent
elements, and swaps them if they are in the wrong order.
o Example:
Input: [5, 3, 8, 4, 2]
Pass 1: [3, 5, 4, 2, 8]
Pass 2: [3, 4, 2, 5, 8]
Pass 3: [3, 2, 4, 5, 8]
Pass 4: [2, 3, 4, 5, 8]
Output: [2, 3, 4, 5, 8]
Bubble Sort is a simple sorting algorithm that repeatedly steps through the list, compares
adjacent elements, and swaps them if they are in the wrong order. The pass through the list is
repeated until the list is sorted. Here's a simple algorithm for Bubble Sort:
python
def bubble_sort(arr):
n = len(arr)
# Traverse through all array elements
for i in range(n):
# Last i elements are already in place
for j in range(0, n - i - 1):
# Traverse the array from 0 to n-i-1
# Swap if the element found is greater
# than the next element
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
# Example usage:
arr = [64, 34, 25, 12, 22, 11, 90]
bubble_sort(arr)
print("Sorted array:", arr)
This algorithm has a time complexity of O(n^2) in the worst case scenario, where n is the
number of elements in the list. It is not recommended for large datasets due to its inefficiency
compared to more advanced sorting algorithms like Merge Sort or Quick Sort. However, Bubble
2. Quick Sort:
o Description: Quick Sort selects a pivot element and partitions the array into two
sub-arrays around the pivot such that elements smaller than the pivot are on the
left, and larger elements are on the right. It then recursively sorts the sub-arrays.
o Example:
Input: [7, 2, 1, 6, 8, 5, 3, 4]
Pivot: 4
Left sub-array: [2, 1, 3]
Right sub-array: [7, 6, 8, 5]
Output: [1, 2, 3, 4, 5, 6, 7, 8]
Quick Sort is a divide-and-conquer sorting algorithm that partitions the array into two sub-arrays
based on a pivot element. Elements smaller than the pivot are placed to its left, and elements
greater than the pivot are placed to its right. The process is recursively applied to the sub-arrays
until the entire array is sorted. Here's a simple algorithm for Quick Sort:
1. Choose a pivot element from the array. This pivot can be selected randomly, or it can be
the first, last, or middle element of the array.
2. Partition the array into two sub-arrays: elements less than the pivot and elements greater
than the pivot.
3. Recursively apply Quick Sort to the two sub-arrays.
4. Concatenate the sorted sub-arrays and the pivot to obtain the final sorted array.
python
def quick_sort(arr):
if len(arr) <= 1:
return arr
else:
# Example usage:
arr = [64, 34, 25, 12, 22, 11, 90]
sorted_arr = quick_sort(arr)
print("Sorted array:", sorted_arr)
This algorithm has a time complexity of O(n log n) in the average and best case scenarios and
O(n^2) in the worst case scenario when the pivot selection is not optimal. However, with proper
pivot selection techniques (e.g., median-of-three), the worst-case time complexity can be
avoided. Quick Sort is efficient for sorting large datasets and is widely used in practice.
3. Merge Sort:
o Description: Merge Sort divides the array into halves recursively until each sub-
array contains only one element. It then merges the sub-arrays in sorted order.
o Example:
Input: [6, 3, 8, 2, 7, 1, 4, 5]
Step 1: [6, 3, 8, 2] [7, 1, 4, 5]
Step 2: [6, 3] [8, 2] [7, 1] [4, 5]
Step 3: [3, 6] [2, 8] [1, 7] [4, 5]
Step 4: [2, 3, 6, 8] [1, 4, 5, 7]
Final Step: [1, 2, 3, 4, 5, 6, 7, 8]
Merge Sort is a divide-and-conquer sorting algorithm that divides the array into smaller sub-
arrays until each sub-array contains only one element. It then merges adjacent sub-arrays in a
sorted order until the entire array is sorted. Here's a simple algorithm for Merge Sort:
python
def merge_sort(arr):
if len(arr) > 1:
mid = len(arr) // 2 # Find the middle index
left_half = arr[:mid] # Divide the array into two halves
right_half = arr[mid:]
# Example usage:
arr = [64, 34, 25, 12, 22, 11, 90]
merge_sort(arr)
print("Sorted array:", arr)
This algorithm has a time complexity of O(n log n) in all cases, making it efficient for sorting
large datasets. Merge Sort is a stable sorting algorithm, meaning it preserves the relative order of
equal elements, and it is suitable for sorting linked lists as well as arrays.
1. Database Indexing: Sorting algorithms are used in databases to organize data efficiently
for indexing and querying.
2. Ordering Lists: Sorting algorithms are employed in various applications to order lists of
items alphabetically, numerically, or based on custom criteria.
3. Search Engine Optimization: Sorting algorithms play a crucial role in search engine
optimization (SEO) by arranging search results based on relevance and popularity.
4. Data Analysis: Sorting algorithms are used in data analysis tasks to arrange datasets for
statistical analysis, visualization, and reporting.
5. File Systems: Operating systems use sorting algorithms to organize files and directories
on disk drives for efficient storage and retrieval.
1. Linear Search:
o Description: Linear Search checks each element of the list sequentially until the
target element is found or the entire list has been traversed.
o Example:
Input: [5, 8, 3, 2, 7, 1], Target: 3
Steps: Compare 5, 8, 3 (found), stop
Output: Element found at index 2
2. Binary Search:
o Description: Binary Search works on a sorted list by repeatedly dividing the
search interval in half until the target element is found or the interval is empty.
o Example:
Input: [1, 2, 3, 4, 5, 6, 7, 8, 9], Target: 6
Steps: Compare with mid element 5, 7, 6 (found), stop
Output: Element found at index 5
1. Linear Search:
o Start from the beginning of the array.
o Compare each element with the target value until a match is found or the end of
the array is reached.
o If the target value is found, return its index. Otherwise, return -1.
python
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1
python
def binary_search(arr, target):
low = 0
high = len(arr) - 1
while low <= high:
mid = (low + high) // 2
if arr[mid] == target:
return mid
elif arr[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
These algorithms are fundamental search algorithms used to find elements within an array.
Linear search has a time complexity of O(n), where n is the number of elements in the array.
Binary search, on the other hand, has a time complexity of O(log n), but it requires the array to
be sorted beforehand.
Graph Algorithms
Graph Representation:
Graphs are versatile data structures used to model relationships between entities. They can
represent a wide range of real-world scenarios, from social networks to computer networks to
transportation systems. Graphs can be classified into various types based on their properties and
structures. Let's explore some common types of graphs with diagrams:
1. Undirected Graph:
o An undirected graph is a graph where edges have no direction.
o It represents symmetric relationships between vertices.
o Diagram:
A ---- B
/ \
/ \
D ---- C ---- E
In the diagram, the edges (lines) between vertices (nodes) have no arrows, indicating that
the relationship between vertices is bidirectional.
A --> B
| | \
v v \
D <-- C E
Weighted Graph:
A weighted graph is a graph where edges have weights or costs assigned to them.
It is used to represent scenarios where there is a cost associated with traversing from one
vertex to another.
Diagram:
css
A
/|\
2 | 5
/ | \
B-3-C
- In the diagram, the numbers next to the edges represent the weights of the connections between
vertices.
4. **Unweighted Graph**:
- An unweighted graph is a graph where edges have no weights assigned to them.
- It represents relationships between vertices without considering any associated costs.
- Diagram:
A ---- B
/ \
/ \
D ---- C ---- E
- In the diagram, there are no weights associated with the edges between vertices.
5. **Cyclic Graph**:
- A cyclic graph is a graph that contains at least one cycle, where a cycle is a path that starts and
ends at the same vertex.
A ---- B ---- C
/ | \
/ | \
D ---- E ------- F
less
- In the diagram, the path A -> B -> E -> C -> F -> B forms a cycle because it starts and ends at
vertex B.
6. **Acyclic Graph**:
- An acyclic graph is a graph that contains no cycles.
- It represents relationships without any loops or repetitions.
- Diagram:
A ---- B ---- C
| | |
| | |
D E F
- In the diagram, there are no cycles present, as there is no path that starts and ends at the same
vertex.
These are some common types of graphs, each with its own characteristics and applications.
Diagrams help visualize the structure of these graphs and understand their properties more
intuitively.
Graphs can be represented in various ways, including adjacency matrix, adjacency list, and edge
list.
Breadth-first search explores a graph level by level, visiting all the neighbors of a vertex before
moving on to the next level.
Example:
python
from collections import deque
# Example usage:
graph = {
'A': ['B', 'C'],
'B': ['A', 'D', 'E'],
'C': ['A', 'F'],
Depth-first search explores as far as possible along each branch before backtracking.
Example:
python
def dfs(graph, start, visited=None):
if visited is None:
visited = set()
visited.add(start)
print(start, end=' ')
for neighbor in graph[start]:
if neighbor not in visited:
dfs(graph, neighbor, visited)
# Example usage:
print("\nDFS traversal:")
dfs(graph, 'A') # Output: A B D E F C
1. Dijkstra's Algorithm:
Problem: Find the shortest path from a source vertex to all other vertices in a weighted graph.
Dijkstra's Algorithm:
1. Initialization:
o Assign a distance value to every vertex. Initialize the distance to the source vertex
as 0 and all other distances as infinity.
o Create an empty set to keep track of vertices whose shortest distance from the
source has been found.
o Initialize a priority queue (min-heap) to store vertices based on their current
shortest distance from the source.
2. Main Loop:
o Repeat until all vertices have been processed:
Select the vertex u with the minimum distance from the priority queue
(vertex with the shortest distance).
Mark vertex u as visited.
Update the distances of all adjacent vertices v of u if the total distance
from the source to v through u is shorter than the current distance recorded
for v.
Enqueue/update v in the priority queue with its updated distance.
3. Termination:
o Once all vertices have been processed, the shortest path distances from the source
vertex to all other vertices are found.
Example:
scss
4 2
(1)---------(2)
Let's find the shortest paths from vertex 1 to all other vertices using Dijkstra's algorithm:
1. Initialization:
o Start at vertex 1. Set its distance to 0 and all other vertices' distances to infinity.
o Initialize an empty set of visited vertices and a priority queue.
2. Main Loop:
o Start with vertex 1.
o Visit vertex 1, update the distances of its neighbors: 2 (distance 4) and 3 (distance
3).
o Visit vertex 3, update the distance of vertex 4 (distance 9).
o Visit vertex 2, update the distance of vertex 4 (distance 6).
o Visit vertex 4, update the distance of vertex 5 (distance 9).
o Visit vertex 5, no further updates.
3. Termination:
o Shortest path distances from vertex 1 to all other vertices: {1: 0, 2: 4, 3: 3, 4: 6, 5:
9}.
Implementation:
python
import heapq
return distances
source_vertex = 1
shortest_distances = dijkstra(graph, source_vertex)
print("Shortest path distances from vertex", source_vertex, ":", shortest_distances)
This implementation finds the shortest path distances from a given source vertex to all other
vertices in the graph using Dijkstra's algorithm.
find the shortest distance graph represented as an adjacency list graph = { 1: {2: 4, 3: 3}, 2: {1:
4, 3: 5, 4: 2}, 3: {1: 3, 2: 5, 4: 6}, 4: {2: 2, 3: 6, 5: 3}, 5: {4: 3} }
To find the shortest distances between all pairs of vertices in the given graph represented as an
adjacency list, we can use Floyd-Warshall algorithm. This algorithm works for both directed and
undirected graphs with positive or negative edge weights (as long as there are no negative
cycles). Here's how the algorithm works:
Floyd-Warshall Algorithm:
1. Initialization:
o Initialize a 2D array distances of size (n x n), where n is the number of vertices
in the graph.
o Initialize distances[i][j] to the weight of the edge between vertices i and j if
there's an edge, otherwise set it to infinity.
o Set distances[i][i] to 0 for all vertices i.
2. Main Loop:
o Iterate over all vertices k from 1 to n.
o For each pair of vertices i and j, update distances[i][j] to the minimum of:
The current value of distances[i][j].
The sum of distances[i][k] and distances[k][j].
3. Termination:
o After the main loop, distances[i][j] contains the shortest distance between
vertex i and j for all pairs of vertices i and j.
Example:
python
graph = {
1: {2: 4, 3: 3},
2: {1: 4, 3: 5, 4: 2},
3: {1: 3, 2: 5, 4: 6},
4: {2: 2, 3: 6, 5: 3},
5: {4: 3}
}
We can construct the adjacency matrix and then apply the Floyd-Warshall algorithm to find the
shortest distances.
Implementation:
python
INF = float('inf')
def floyd_warshall(graph):
vertices = list(graph.keys())
num_vertices = len(vertices)
return distances
shortest_distances = floyd_warshall(graph)
for i in range(len(shortest_distances)):
for j in range(len(shortest_distances[i])):
if shortest_distances[i][j] == INF:
shortest_distances[i][j] = "INF"
print("Shortest distance graph represented as an adjacency list:")
for i, row in enumerate(shortest_distances):
print(i+1, ":", {j+1: dist for j, dist in enumerate(row)})
This implementation will print the shortest distance graph represented as an adjacency list. The
output will show the shortest distances between all pairs of vertices in the graph.
draw the graph for graph represented as an adjacency list graph = { 1: {2: 4, 3: 3}, 2: {1: 4, 3: 5,
4: 2}, 3: {1: 3, 2: 5, 4: 6}, 4: {2: 2, 3: 6, 5: 3}, 5: {4: 3} }
ChatGPT
To draw the graph represented as an adjacency list, we can visualize each vertex as a node and
draw edges between vertices based on the connections specified in the adjacency list. We'll use a
graphical representation where nodes are circles and edges are lines connecting the circles.
lua
4 2
1 -----> 2 -------> 4
| 3 ^ \ 2 / |
| | \ / | 3
| | 5 \ / 6 |
| v v |
3 <----- 5 <----- 3
In this visualization:
Here's a textual representation of the graph based on the given adjacency list:
scss
1 -> 2 (weight 4), 3 (weight 3)
2 -> 1 (weight 4), 3 (weight 5), 4 (weight 2)
3 -> 1 (weight 3), 2 (weight 5), 4 (weight 6)
4 -> 2 (weight 2), 3 (weight 6), 5 (weight 3)
5 -> 4 (weight 3)
This visualization provides a clear representation of the connections between vertices and the
weights of the edges in the graph.
Example:
python
import heapq
# Example usage:
graph = {
'A': {'B': 3, 'C': 1},
'B': {'A': 3, 'C': 7, 'D': 5},
'C': {'A': 1, 'B': 7, 'D': 2},
'D': {'B': 5, 'C': 2}
}
print("Shortest distances from 'A':", dijkstra(graph, 'A')) # Output: {'A': 0, 'B': 3, 'C': 1, 'D': 3}
2. Bellman-Ford Algorithm:
Problem: Find the shortest path from a source vertex to all other vertices in a weighted graph
with negative edge weights.
Example:
python
def bellman_ford(graph, start):
distances = {vertex: float('inf') for vertex in graph}
distances[start] = 0
for _ in range(len(graph) - 1):
for u in graph:
for v, weight in graph[u].items():
if distances[u] + weight < distances[v]:
# Example usage:
graph = {
'A': {'B': 3, 'C': 1},
'B': {'C': 7, 'D': 5},
'C': {'B': -2},
'D': {'B': 4, 'C': 2}
}
print("Shortest distances from 'A':", bellman_ford(graph, 'A')) # Output: {'A': 0, 'B': 1, 'C': -1,
'D': 3}
1. Prim's Algorithm:
Example:
python
def prim_mst(graph):
mst = set()
vertices = list(graph.keys())
visited = set(vertices[0])
while len(visited) < len(vertices):
min_edge = float('inf')
min_edge_info = None
for u in visited:
for v, weight in graph[u].items():
if v not in visited and weight < min_edge:
min_edge = weight
# Example usage:
graph = {
'A': {'B': 2, 'D': 5},
'B': {'A': 2, 'C': 3, 'D': 2},
'C': {'B': 3, 'D': 4},
'D': {'A': 5, 'B': 2, 'C': 4}
}
print("Minimum Spanning Tree (Prim's Algorithm):", prim_mst(graph))
# Output: {('A', 'B', 2), ('B', 'C', 3), ('B', 'D', 2)}
2. Kruskal's Algorithm:
Example:
python
class DisjointSet:
def __init__(self, vertices):
self.parent = {v: v for v in vertices}
def kruskal_mst(graph):
mst = set()
vertices = list(graph.keys())
edges = [(u, v, weight) for u in graph for v, weight in graph[u].items()]
edges.sort(key=lambda x: x[2])
ds = DisjointSet(vertices)
for u, v, weight in edges:
if ds.find(u) != ds.find(v):
mst.add((u, v, weight))
ds.union(u, v)
return mst
# Example usage:
print("Minimum Spanning Tree (Kruskal's Algorithm):", kruskal_mst(graph))
# Output: {('A', 'B', 2), ('B', 'D', 2), ('B', 'C', 3)}
These examples illustrate various graph algorithms, including BFS, DFS, shortest path
algorithms (Dijkstra's and Bellman-Ford), and minimum spanning tree algorithms (Prim's and
Kruskal's). Graph algorithms are essential in solving problems related to networks, routing,
optimization, and more.
Module 6
2. Complexity Theory
o Classes of problems: P, NP, NP-hard, NP-complete
o Reductions and NP-completeness
o Approximation algorithms
Classes of Problems:
1. P (Polynomial Time):
o Problems that can be solved in polynomial time by a deterministic Turing
machine.
o Examples: Sorting (e.g., Merge Sort, Quick Sort), Searching (e.g., Binary Search),
Shortest Path (e.g., Dijkstra's Algorithm).
P (Polynomial Time):
In complexity theory, the class P (Polynomial Time) consists of decision problems that can be
solved by a deterministic Turing machine in polynomial time. A problem is said to be in P if
there exists an algorithm that can solve it in polynomial time with respect to the size of its input.
In other words, the running time of the algorithm must be bounded by a polynomial function of
the input size.
Key Properties of P:
Examples of Problems in P:
1. Divide: Divide the unsorted list into two halves recursively until each sublist contains
only one element.
2. Conquer: Sort each sublist independently using Merge Sort.
3. Merge: Merge the sorted sublists back together to produce a single sorted list.
Detailed Explanation:
1. Divide: The unsorted list is divided into two equal halves (or nearly equal if the number
of elements is odd) until each sublist contains only one element.
2. Conquer: Each sublist is sorted independently using the Merge Sort algorithm. This
involves recursively applying the divide-and-conquer approach to sort each half of the
sublist.
3. Merge: The sorted sublists are merged back together by repeatedly comparing the
elements of the two sublists and selecting the smaller (or larger) element to form the
merged list. This process continues until all elements from both sublists are included in
the merged list.
Example:
1. Divide: Divide the list into two halves: [38, 27, 43, 3] and [9, 82, 10].
2. Conquer: Sort each sublist independently using Merge Sort.
o Sort [38, 27, 43, 3] to [3, 27, 38, 43].
o Sort [9, 82, 10] to [9, 10, 82].
3. Merge: Merge the sorted sublists back together.
o Compare elements from both sublists: [3, 27, 38, 43] and [9, 10, 82].
o Select the smaller element from the beginning of each sublist to form the merged
list: [3, 9, 10, 27, 38, 43, 82].
Time Complexity: O(n log n) in all cases (worst-case, average-case, and best-case),
where n is the number of elements in the list.
Space Complexity: O(n) for the additional space required for the merge step.
Quick Sort:
Quick Sort is another efficient sorting algorithm that follows the divide-and-conquer approach. It
selects a pivot element from the list and partitions the other elements into two sublists according
to whether they are less than or greater than the pivot. It then recursively sorts the sublists.
Detailed Explanation:
1. Partitioning: Select a pivot element from the list and partition the other elements into
two sublists: one with elements less than the pivot and one with elements greater than the
pivot.
2. Recursion: Recursively apply Quick Sort to the sublists created in the previous step.
3. Combine: No combine step is needed as the sorting is done in place.
Example:
1. Partitioning: Select a pivot (e.g., 38) and partition the list into two sublists: [27, 3, 9, 10]
(less than 38) and [43, 82] (greater than 38).
2. Recursion: Apply Quick Sort recursively to the sublists [27, 3, 9, 10] and [43, 82].
o For [27, 3, 9, 10], select 27 as the pivot and partition into [3, 9, 10] and [27].
o For [43, 82], no further partitioning is needed as they are already sorted.
3. Combine: No explicit combine step is required as the sorting is done in place.
Complexity:
Both Merge Sort and Quick Sort are efficient sorting algorithms that can handle large datasets
effectively. Merge Sort is stable and guarantees O(n log n) time complexity, making it suitable
for scenarios where stability is important and the dataset is not too large. Quick Sort, on the other
hand, is often preferred for its space efficiency and typically performs well in practice despite its
worst-case time complexity.
Binary search is a classic algorithm used to find a target element in a sorted array efficiently.
Here's how you can implement it in Python:
python
def binary_search(arr, target):
left, right = 0, len(arr) - 1
# Example usage:
arr = [1, 3, 5, 7, 9, 11, 13, 15, 17]
target = 11
index = binary_search(arr, target)
if index != -1:
print(f"Target {target} found at index {index}.")
else:
print("Target not found in the array.")
We set two pointers, left and right, initially pointing to the start and end of the array
respectively.
We repeat the following steps until left is less than or equal to right:
o Calculate the middle index mid.
o If the element at mid is equal to the target, return mid.
o If the element at mid is less than the target, we adjust left to mid + 1 to search the
right half of the array.
o If the element at mid is greater than the target, we adjust right to mid - 1 to search
the left half of the array.
This algorithm has a time complexity of O(log n), where n is the number of elements in the
array, making it very efficient for large arrays.
Example Problem: Consider the problem of determining whether a given integer is prime.
1. If n is less than 2, return False (since prime numbers must be greater than 1).
This algorithm runs in O(sqrt(n)) time, which is polynomial in the size of the input (the number
of digits in n). Therefore, the problem of determining whether a given integer is prime is in the
class P.
Problem: Given a set of integers and a target sum, determine whether there exists a subset of the
integers that sums to the target.
Verification: Given a subset of integers, we can verify in polynomial time whether the sum of
the subset equals the target sum.
Example: Consider the set {1, 3, 5, 7} and the target sum 10.
In summary, NP problems are those for which a solution can be verified in polynomial time,
even though finding the solution itself may be difficult. Subset Sum, Traveling Salesman
Problem, and Graph Coloring are examples of such problems, where the verification of a
solution can be done efficiently.
The Traveling Salesman Problem is one of the most famous problems in optimization and graph
theory. It seeks to find the shortest possible route that visits each city exactly once and returns to
the starting city. The problem is NP-hard, meaning there is no known polynomial-time algorithm
that can solve all instances of the problem optimally.
Example: Let's consider a simple example to illustrate the Traveling Salesman Problem:
Suppose a salesman needs to visit 5 cities (A, B, C, D, E) and return to the starting city (A). The
distances between these cities are as follows:
The objective is to find the shortest route that visits each city exactly once and returns to the
starting city.
Solution: To solve the TSP, we typically use optimization algorithms such as dynamic
programming, branch and bound, or heuristic approaches like nearest neighbor or genetic
algorithms.
For this example, let's consider the brute-force approach of trying all possible permutations of
the cities and calculating the total distance for each permutation to find the shortest route.
1. Generate all possible permutations of the cities (excluding the starting city since it's
fixed).
2. Calculate the total distance for each permutation.
3. Find the permutation with the shortest total distance.
Solution: After evaluating all permutations and calculating their total distances, we find that the
shortest route is: A-B-D-E-C-A with a total distance of 80 units.
Verification: We can verify that the solution satisfies the TSP criteria:
Conclusion: The Traveling Salesman Problem is a classic optimization problem with numerous
real-world applications, such as vehicle routing, logistics planning, and circuit design. While
brute-force approaches are impractical for large instances of the problem, various heuristic and
approximation algorithms are used to find near-optimal solutions efficiently.
olve this Suppose a salesman needs to visit 5 cities (A, B, C, D, E) and return to the starting city
(A). The distances between these cities are as follows: Distance from A to B: 10 units Distance
from A to C: 15 units Distance from A to D: 20 units Distance from A to E: 25 units Distance
from B to C: 35 units Distance from B to D: 25 units Distance from B to E: 30 units Distance
from C to D: 30 units Distance from C to E: 20 units Distance from D to E: 15 units
To solve the Traveling Salesman Problem (TSP) for this scenario, we will use the brute-force
approach of trying all possible permutations of the cities and calculating the total distance for
each permutation to find the shortest route.
Given the distances between the cities as described, let's calculate the total distance for each
permutation of the cities (excluding the starting city since it's fixed) and find the permutation
with the shortest total distance.
1. Generate all possible permutations of the cities (excluding the starting city A).
2. Calculate the total distance for each permutation.
3. Find the permutation with the shortest total distance.
plaintext
Possible permutations (excluding starting city A):
1. B-C-D-E
2. B-C-E-D
3. B-D-C-E
4. B-D-E-C
5. B-E-C-D
6. B-E-D-C
7. C-B-D-E
8. C-B-E-D
9. C-D-B-E
10. C-D-E-B
11. C-E-B-D
12. C-E-D-B
13. D-B-C-E
14. D-B-E-C
15. D-C-B-E
16. D-C-E-B
17. D-E-B-C
18. D-E-C-B
19. E-B-C-D
20. E-B-D-C
21. E-C-B-D
22. E-C-D-B
plaintext
1. B-C-D-E: 35 + 30 + 15 + 15 = 95 units
2. B-C-E-D: 35 + 20 + 15 + 25 = 95 units
3. B-D-C-E: 25 + 30 + 30 + 15 = 100 units
4. B-D-E-C: 25 + 15 + 20 + 30 = 90 units
5. B-E-C-D: 30 + 20 + 15 + 25 = 90 units
6. B-E-D-C: 30 + 15 + 25 + 30 = 100 units
7. C-B-D-E: 35 + 25 + 15 + 15 = 90 units
8. C-B-E-D: 35 + 30 + 25 + 20 = 110 units
9. C-D-B-E: 30 + 25 + 25 + 15 = 95 units
10. C-D-E-B: 30 + 15 + 20 + 35 = 100 units
11. C-E-B-D: 20 + 35 + 25 + 15 = 95 units
12. C-E-D-B: 20 + 15 + 25 + 35 = 95 units
13. D-B-C-E: 25 + 35 + 30 + 15 = 105 units
14. D-B-E-C: 25 + 30 + 20 + 30 = 105 units
15. D-C-B-E: 30 + 35 + 25 + 15 = 105 units
16. D-C-E-B: 30 + 15 + 20 + 35 = 100 units
17. D-E-B-C: 15 + 30 + 20 + 35 = 100 units
18. D-E-C-B: 15 + 20 + 25 + 35 = 95 units
19. E-B-C-D: 25 + 30 + 15 + 15 = 85 units
20. E-B-D-C: 25 + 15 + 20 + 30 = 90 units
21. E-C-B-D: 20 + 35 + 25 + 15 = 95 units
22. E-C-D-B: 20 + 15 + 25 + 35 = 95 units
23. E-D-B-C: 15 + 30 + 20 + 35 = 100 units
24. E-D-C-B: 15 + 20 + 25 + 35 = 95 units
Finally, we find that the permutation with the shortest total distance is:
Therefore, the salesman should follow the route E-B-C-D-A to minimize the total distance
traveled while visiting each city exactly once and returning to the starting city A.
NP-hard problems are a class of decision problems that are at least as hard as the hardest
problems in NP. This means that if there exists a polynomial-time algorithm for any NP-hard
problem, then there exists a polynomial-time algorithm for every problem in NP, making NP-
hard problems among the most difficult problems in computational complexity theory.
solve Suppose we have a knapsack with a capacity of 10 units and the following items: (weight,
value) - (5, 10), (4, 7), (3, 5), (2, 3). Determining the subset of items that maximizes the value
while respecting the capacity constraint is a challenging optimization problem.
To solve the Knapsack Problem for this scenario, where we have a knapsack with a capacity of
10 units and the following items: (weight, value) - (5, 10), (4, 7), (3, 5), (2, 3), we can use
dynamic programming to efficiently find the subset of items that maximizes the total value while
respecting the capacity constraint.
Here's the step-by-step process to solve the Knapsack Problem using dynamic programming:
python
def knapsack(items, capacity):
n = len(items)
# Initialize a table to store maximum values for different capacities and number of items
dp = [[0] * (capacity + 1) for _ in range(n + 1)]
Output:
java
Maximum value that can be obtained: 15
Selected items (weight, value): [(4, 7), (3, 5)]
In this solution, the maximum value that can be obtained while respecting the capacity constraint
of the knapsack is 15. The selected items are (4, 7) and (3, 5), which have a total weight of 7
units and a total value of 15 units.
Knapsack Problem:
Problem: Given a set of items with weights and values, determine the subset of items
that maximizes the total value while keeping the total weight within a given limit
(knapsack capacity).
NP-hardness: The Knapsack Problem is NP-hard, indicating its computational difficulty.
Verification: Given a proposed subset of items, it's challenging to verify in polynomial
time whether it's the optimal subset that maximizes the total value while respecting the
weight constraint.
Example: Consider a knapsack with a capacity of 10 units and items with different
weights and values. Determining the subset of items that maximizes the total value
without exceeding the capacity is a challenging optimization problem.
In summary, NP-hard problems represent some of the most difficult computational challenges, as
their solutions cannot be efficiently verified and are at least as hard as the hardest problems in
NP. Examples such as the Traveling Salesman Problem and Knapsack Problem highlight the
complexity and significance of NP-hard problems in computational complexity theory.
NP-complete problems are a class of decision problems that are both in NP and NP-hard. These
problems are among the hardest problems in NP, meaning they are at least as hard as the hardest
problems in NP, and their solutions cannot be efficiently verified in polynomial time.
Problem Statement: Given a set of customers, each with a specified demand for goods
and a time window during which they must be serviced, and a fleet of vehicles with
limited capacity and starting from a central depot, determine the optimal set of routes for
the vehicles to visit all customers while minimizing the total distance traveled and
respecting the capacity constraints and time windows.
Formal Definition:
o Input:
A set of customers, each with a specified demand and time window.
A fleet of vehicles with limited capacity and a starting depot.
o Output:
Routes for each vehicle to visit all customers, minimizing the total
distance traveled.
o Objective:
Minimize the total distance traveled by the vehicles while ensuring that
each customer's demand is met within its time window and respecting the
capacity constraints of the vehicles.
Example:
Let's consider a specific instance of the VRP with Time Windows problem:
Suppose we have a set of 5 customers (C1, C2, C3, C4, C5) with the following demands
and time windows:
o C1: Demand = 3, Time Window = [8:00 - 10:00]
o C2: Demand = 4, Time Window = [9:00 - 11:00]
o C3: Demand = 2, Time Window = [10:00 - 12:00]
o C4: Demand = 5, Time Window = [11:00 - 13:00]
o C5: Demand = 3, Time Window = [12:00 - 14:00]
python
import numpy as np
def nearest_neighbor(points):
visited = [False] * len(points)
route = []
current = 0
total_distance = 0
Output:
yaml
Optimal Route: [0, 4, 1, 3, 2, 0]
Total Distance Traveled: 29.49331726907675
In this solution, the nearest neighbor heuristic is used to construct a route for a single vehicle to
visit all customers while minimizing the total distance traveled. The optimal route is [0, 4, 1, 3,
2, 0], indicating that the vehicle starts and ends at the depot (index 0) and visits customers in the
order C5, C1, C4, C3, and C2. The total distance traveled is approximately 29.49 units.
Problem: Given a list of cities and distances between them, find the shortest route that
visits each city exactly once and returns to the starting city.
NP-completeness: TSP is an NP-complete problem, as it is both in NP and NP-hard.
Verification: The shortest route found by a proposed solution cannot be verified in
polynomial time, making TSP an NP-complete problem.
Example: As previously discussed, finding the shortest route for a salesman visiting
multiple cities is an NP-complete problem.
Problem: Given a set of integers and a target sum, determine whether there exists a
subset that sums to the target.
NP-completeness: The Subset Sum Problem is NP-complete.
Verification: Given a subset of integers, we cannot efficiently verify whether it sums to
the target in polynomial time without checking all possible subsets, making Subset Sum
an NP-complete problem.
In summary, NP-complete problems are among the hardest problems in NP, as they are both in
NP and NP-hard. Examples such as the Traveling Salesman Problem, Subset Sum Problem, and
Boolean Satisfiability Problem (SAT) highlight the complexity and significance of NP-complete
problems in computational complexity theory. If any NP-complete problem can be solved in
polynomial time, then all problems in NP can also be solved in polynomial time (P = NP), which
remains one of the unsolved problems in computer science.
plaintext
Subset Sum Problem:
Knapsack Problem:
Given a set of items, each with a weight and a value, determine the maximum value that can be
obtained by selecting a subset of the items whose total weight does not exceed a given limit.
Approximation Algorithms:
plaintext
Vertex Cover Problem:
Given an undirected graph, find the smallest set of vertices such that each edge in the graph is
incident to at least one vertex in the set.
Approximation Algorithm:
1. Start with an empty set of vertices (vertex cover).
2. Repeat until all edges are covered:
- Choose an uncovered edge.
- Add both endpoints of the edge to the vertex cover.
3. Return the vertex cover.
Approximation Ratio: The size of the obtained vertex cover is at most twice the size of the
optimal vertex cover.