Python Algorithms: Types, Usage, Features and Examples

There are five types of algorithms in Python: 1. Recursive Algorithms, 2. Memoization Algorithms, 3. Divide and Conquer Algorithms, 4. Dynamic Programming Algorithms, 5. Greedy Algorithms

1. Recursive Algorithms

What is Recursive Algorithms?

A recursive algorithm is an algorithm which calls itself with a smaller or simpler input each time. Eventually, the input becomes so small or simple that the algorithm can return a direct answer.

What are the usages of Recursive Algorithms?

There are many uses for recursive algorithms, including:

  • Finding the factorial of a number
  • Computing the Fibonacci sequence
  • Generating permutations and combinations
  • Solving Towers of Hanoi puzzles
  • Searching and sorting data structures
  • Implementing certain mathematical functions
  • Manipulating directory trees and file systems

Features of Recursive Algorithm:

  • Recursive algorithms are easy to understand and implement.
  • Recursive algorithms can play an important role in solving complex problems.
  • Recursive algorithms can be used to obtain an optimal solution to a problem.
  • Recursive algorithms can be used to generate efficient code.

Examples of Recursive Algorithm:

-Traversing a tree or a graph

def traverse(node, visited):
    visited[node] = True
    print(node, end=" ")
    for i in graph[node]:
        if visited[i] == False:
            traverse(i, visited)

-Calculating the factorial of a number

def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n-1)

-Generating all permutations of a set of elements

def permutations(elements):
    if len(elements) <= 1:
        yield elements
    else:
        for perm in permutations(elements[1:]):
            for i in range(len(elements)):
                yield perm[:i] + elements[0:1] + perm[i:]

2. Memoization Algorithms

What is Memoization Algorithm?

Memoization is an optimization technique used to speed up programs by storing the results of expensive function calls and returning the cached result when the same inputs occur again.

What are usages of Memoization Algorithms?

Memoization algorithms are used to optimize code by storing the results of expensive function calls and reusing the results when the same inputs occur again. This can improve the performance of the code by reducing the number of expensive function calls that need to be made.

Features of Memoization Algorithms:

  • The speed of execution is increased.
  • It reduces the space complexity.
  • It avoids duplicate computations.
  • It improves the code readability.
  • It is easy to implement.
  • It is applicable to both serial and parallel programs.
  • It is highly flexible and can be easily customized.

Examples of Memoization Algorithms:

* Fibonacci series

def fib(n):
    if n == 0:
        return 0
    elif n == 1:
        return 1
    else:
        return fib(n-1) + fib(n-2)

* Factorial

def factorial(n):
    if n == 0:
        return 1
    else:
        return n * factorial(n-1)

* Matrix Exponentiation

def matrix_exponentiation(A, n):
    if n == 1:
        return A
    if n % 2 == 0:
        return matrix_exponentiation(matrix_multiply(A, A), n // 2)
    else:
        return matrix_multiply(A, matrix_exponentiation(A, n - 1))

* Longest Common Subsequence (LCS)

def lcs(X, Y, m, n):
    if m == 0 or n == 0:
        return 0
    elif X[m - 1] == Y[n - 1]:
        return 1 + lcs(X, Y, m - 1, n - 1)
    else:
        return max(lcs(X, Y, m, n - 1), lcs(X, Y, m - 1, n))

* Shortest Path in a Directed Acyclic Graph (DAG)

def dag(graph, A, B):
    # create a set of visited nodes
    visited = set()
    # create a set of nodes to be visited
    unvisited = set(graph.keys())
    # create a dictionary of distances
    distance = {}
    # create a dictionary of predecessors
    predecessor = {}
    # assign the distance from A to A to be 0
    distance[A] = 0
    # assign the predecessor of A to be None
    predecessor[A] = None
    # while there are unvisited nodes
    while unvisited:
        # find the node with the smallest distance in the set of unvisited nodes
        current = min(unvisited, key=lambda k: distance[k])
        # remove the node from the set of unvisited nodes
        unvisited.remove(current)
        # if the distance of the current node is infinity, there is no path to the end node
        if distance[current] == float('inf'):
            break
        # for each neighbor of the current node
        for neighbor in graph[current]:
            # if the neighbor is not visited
            if neighbor not in visited:
                # if the distance to the neighbor is greater than the distance to the current node plus the edge weight
                if distance[neighbor] > distance[current] + graph[current][neighbor]:
                    # update the distance to the neighbor
                    distance[neighbor] = distance[current] + graph[current][neighbor]
                    # update the predecessor of the neighbor
                    predecessor[neighbor] = current
        # add the current node to the set of visited nodes
        visited.add(current)
    # if the distance to the end node is infinity, there is no path to the end node
    if distance[B] == float('inf'):
        return None
    # create a list of nodes that are on the shortest path from the start node to the end node
    path = []
    # set the current node to be the end node
    current = B
    # while the current node has a predecessor
    while current:
        # add the current node to the list of nodes
        path.append(current)

3. Divide and Conquer Algorithms

What are Divide and Conquer Algorithms?

Divide and conquer algorithms are designed to facilitate efficient execution of a given task by breaking it down into smaller, more manageable sub tasks. These sub tasks are then solved independently, with the overall solution being obtained by combining the solutions to the sub tasks. A typical example of a divide and conquer algorithm is the quicksort algorithm, which is used to sort an array of data.

What are the usages of Divide and Conquer Algorithms?

There are many usage cases for divide and conquer algorithms. Some common applications include:

  • searching and sorting data sets
  • multiplying large numbers
  • solving complex mathematical problems
  • finding the shortest path between two points

Features of of Divide and Conquer Algorithms:

Some features of divide and conquer algorithms include their ability to solve problems recursively, their use of subproblems, and their generally efficient running time. Additionally, divide and conquer algorithms often have a good worst-case performance guarantee.

Examples of of Divide and Conquer Algorithms:

Here are a few examples of divide and conquer algorithms:

1. Merge sort

Click here to go to Merge Sort

2. Quick sort

Click here to go to Quick Sort

3. Binary search

Click here to go to Binary search

4. Strassen’s matrix multiplication

def strassen_matrix_multiply(A, B):
    # A and B are two matrices
    # A[i][j] is the i-th row and j-th column of A
    # B[i][j] is the i-th row and j-th column of B
    # C[i][j] is the i-th row and j-th column of C
    # C = A * B
    # n is the dimension of the matrices
    n = len(A)
    # base case
    if n == 1:
        return [[A[0][0] * B[0][0]]]
    # split the matrices into 4 sub-matrices
    # A11 A12
    # A21 A22
    # B11 B12
    # B21 B22
    # C11 C12 C21 C22
    # n/2 is the dimension of the sub-matrices
    A11 = [[A[i][j] for j in range(n // 2)] for i in range(n // 2)]
    A12 = [[A[i][j] for j in range(n // 2, n)] for i in range(n // 2)]
    A21 = [[A[i][j] for j in range(n // 2)] for i in range(n // 2, n)]
    A22 = [[A[i][j] for j in range(n // 2, n)] for i in range(n // 2, n)]
    B11 = [[B[i][j] for j in range(n // 2)] for i in range(n // 2)]
    B12 = [[B[i][j] for j in range(n // 2, n)] for i in range(n // 2)]
    B21 = [[B[i][j] for j in range(n // 2)] for i in range(n // 2, n)]
    B22 = [[B[i][j] for j in range(n // 2, n)] for i in range(n // 2, n)]
    # calculate the sub-matrices of C
    # C11 = A11*B11 + A12*B21

4. Dynamic Programming Algorithms

What is Dynamic Programming Algorithm?

A dynamic programming algorithm is an algorithm that solves a complex problem by breaking it down into smaller subproblems. It then solves each subproblem using a bottom-up approach, and finally combines the solutions to the subproblems to solve the original problem.

What are the usages of Dynamic Programming Algorithms?

These algorithms typically have a recursive structure, and they use memoization to store the results of previous computations to avoid re-computing the same subproblem multiple times. The one usage of Dynamic Programming Algorithm is to find the shortest path between two nodes in a graph.

Examples of Dynamic Programming Algorithms:

Maximum Subarray:

Given an array of integers, find the contiguous subarray (containing at least one number) which has the largest sum and return its sum.

def maxSubArray(nums):
    if len(nums) == 0:
        return 0
    max_sum = nums[0]
    curr_sum = nums[0]
    for i in range(1, len(nums)):
        curr_sum = max(nums[i], curr_sum + nums[i])
        max_sum = max(max_sum, curr_sum)
    return max_sum

knapsack problem:

Given a set of items, each with a weight and a value, determine the number of each item to include in a collection so that the total weight is less than or equal to a given limit and the total value is as large as possible.

def knapsack(W, wt, val, n):
    K = [[0 for x in range(W+1)] for x in range(n+1)]
    for i in range(n+1):
        for w in range(W+1):
            if i==0 or w==0:
                K[i][w] = 0
            elif wt[i-1] <= w:
                K[i][w] = max(val[i-1] + K[i-1][w-wt[i-1]], K[i-1][w])
            else:
                K[i][w] = K[i-1][w]
    return K[n][W]

5. Greedy Algorithms

What are Greedy Algorithms?

A greedy algorithms is one that always chooses the best option available at each step, without regard for future consequences.

What are the usages of Greedy Algorithms?

Greedy algorithms are often used for optimization problems. An optimization problem is one where we want to find the best solution from a set of possible solutions. Greedy algorithms work by making the best choice at each step, without regard for future consequences. This can often lead to sub-optimal solutions, but greedy algorithms are usually much faster than other algorithms that guaranteed to find the optimal solution.

Features of Greedy Algorithms:

Some key features of greedy algorithms include:

  • They are simple and easy to implement.
  • They are usually efficient in terms of time and space.
  • They can be used to solve a wide variety of problems.
  • They often produce near-optimal solutions.

Examples of Greedy Algorithms:

1) Huffman coding

Huffman coding is a method of encoding characters based on their frequency. The process of finding the optimal prefix codes for characters is called Huffmandef HuffmanCoding(data):

def HuffmanCoding(data):
    # data is a list of tuples (char, frequency)
    # return a list of tuples (char, encoding)
    # build a heap
    heap = []
    for char, freq in data:
        heapq.heappush(heap, (freq, char))
    # build a tree
    while len(heap) > 1:
        freq1, char1 = heapq.heappop(heap)
        freq2, char2 = heapq.heappop(heap)
        heapq.heappush(heap, (freq1 + freq2, (char1, char2)))
    # build a code
    code = {}
    def build_code(tree, prefix):
        if isinstance(tree, str):
            code[tree] = prefix
        else:
            build_code(tree[0], prefix + '0')
            build_code(tree[1], prefix + '1')
    build_code(heap[0][1], '')
    return code

2) Kruskal’s algorithm

def kruskal(vertices, edges):
    # sort the edges in non-decreasing order of their weight
    edges = sorted(edges, key=lambda x: x[2])
    # create a disjoint set data structure
    ds = DisjointSet(vertices)
    # create a list to store the minimum spanning tree
    mst = []
    # start from the first edge in the sorted edges
    for edge in edges:
        # get the two vertices of the current edge
        u, v, w = edge
        # if the two vertices of the current edge are in different trees
        if ds.find(u) != ds.find(v):
            # add the current edge to the MST
            mst.append(edge)
            # merge the two trees
            ds.union(u, v)
    # return the MST
    return mst

OR

# to find the minimum spanning tree of a graph
# using the union-find data structure
def kruskal(graph):
    # sort the edges in non-decreasing order
    # by weight
    edges = sorted(graph['edges'], key=lambda x: x[2])
    # initialize the disjoint set forest
    # which will store the vertices
    # and their parent/child relationship
    forest = DisjointSetForest(graph['vertices'])
    # initialize the minimum spanning tree
    # which will store the edges
    # and their weight
    mst = []
    # loop over the sorted edges
    for edge in edges:
        # get the indices of the edge's
        # endpoints
        u, v, w = edge
        # if the endpoints
        # are in different components
        if forest.find(u) != forest.find(v):
            # add the edge to the mst
            mst.append(edge)
            # merge the components
            # of the endpoints
            forest.union(u, v)
    # return the edges in the mst
    return mst

3) Prim’s algorithm

def prim(graph, start):
    # create a list of nodes
    nodes = []
    # create a list of edges
    edges = []
    # create a list of nodes
    for node in graph:
        nodes.append(node)
    # create a list of edges
    for edge in graph:
        edges.append(edge)
    # create a list of distances
    distances = [float("inf")] * len(nodes)
    # create a list of previous nodes
    previous = [None] * len(nodes)
    # set the distance of the start node to 0
    distances[start] = 0
    # while there are still nodes to visit
    while len(nodes) > 0:
        # find the node with the smallest distance
        smallest = None
        for node in nodes:
            if smallest is None:
                smallest = node
            elif distances[node] < distances[smallest]:
                smallest = node
        # remove the smallest node from the nodes list
        nodes.remove(smallest)
        # find the neighbors of the smallest node
        neighbors = []
        for edge in edges:
            if edge[0] == smallest:
                neighbors.append(edge[1])
            elif edge[1] == smallest:
                neighbors.append(edge[0])
        # find the neighbor with the smallest distance
        for neighbor in neighbors:
            if neighbor in nodes:
                if distances[neighbor] > distances[smallest] + graph[(smallest, neighbor)]:
                    distances[neighbor] = distances[smallest] + graph[(smallest, neighbor)]
                    previous[neighbor] = smallest
    # return the list of distances and the list of previous nodes
    return distances, previous

4) Dijkstra’s algorithm

For further explaination regarding Dijkstra Algorithm, click here

def dijkstra(graph, start, end):
    # keep track of all the nodes that have been visited
    nodes = set()
    # keep track of all the paths to be checked
    paths = [[start, 0]]
    # shortest path dictionary
    shortest_path = {}
    # while there is still a path to be checked
    while paths:
        # sort the paths by their weight
        paths = sorted(paths, key=lambda x: x[1])
        # get the first path
        path = paths.pop(0)
        # get the last node from the path
        node = path[-1]
        # check if the node has already been visited
        if node not in nodes:
            # add the node to the visited nodes
            nodes.add(node)
            # check if the node is the end node
            if node == end:
                # set the shortest path to the path found
                shortest_path = path
            else:
                # loop through the neighbors of the node
                for neighbor, distance in graph[node].items():
                    # create a new path to the neighbor with the distance
                    new_path = list(path)
                    new_path.append(neighbor)
                    new_path.append(distance)
                    # add the new path to the paths to be checked
                    paths.append(new_path)
    # return the shortest path
    return shortest_path

5) Knapsack problem

def knapsack(W, wt, val, n):
    K = [[0 for x in range(W+1)] for x in range(n+1)]
    for i in range(n+1):
        for w in range(W+1):
            if i==0 or w==0:
                K[i][w] = 0
            elif wt[i-1] <= w:
                K[i][w] = max(val[i-1] + K[i-1][w-wt[i-1]],  K[i-1][w])
            else:
                K[i][w] = K[i-1][w]
    return K[n][W]

Leave a Reply