Intro to Algorithms: Part Deux✌️

Intro to Algorithms: Part Deux✌️

·

19 min read

Welcome to Part Two of our series on algorithms!

In Part One, we covered the basics of algorithms and explored some common types, such as search and sorting algorithms. In this installment, we will delve deeper into the world of algorithms and look at some more advanced types, including sorting, searching & graph algorithms.

Whether you are a beginner looking to learn about algorithms for the first time, or an experienced developer looking to expand your knowledge, this blog post will provide a comprehensive overview of the different types of algorithms and their applications. We'll start by exploring sorting algorithms, which are used to arrange data in a specific order. We'll then move on to search algorithms, which are used to locate specific items in a data set. Finally, we'll delve into the realm of graph algorithms, which are used to analyze and manipulate graphs, and discuss some advanced algorithms that are used in modern computer science.

By the end of this blog post, you will have a solid understanding of the various types of algorithms and how they can be applied to solve real-world problems. So, let's get started!

Sorting Algorithms

Definition of sorting algorithms: Sorting algorithms are used to arrange data in a specific order. There are many different types of sorting algorithms, each with its own set of characteristics and trade-offs.

Bubble sort💬:

Description of how the algorithm works: Bubble sort is a simple sorting algorithm that repeatedly iterates through a list of items, compares adjacent elements, and swaps them if they are in the wrong order. This process is repeated until the list is sorted.

Pros and cons of using bubble sort:

  • Pros: Bubble sort is simple to implement and easy to understand. It requires minimal additional space and can be easily modified for use with different data types.

  • Cons: Bubble sort is not very efficient, with a time complexity of O(n^2). It is generally not used for large data sets.

An example of how the bubble sort can be implemented to solve a simple problem:

Problem: Given a list of student names and grades, sort the list by grade in ascending order

Solution:

def sort_by_grade(students):
    n = len(students)
    for i in range(n):
        for j in range(0, n-i-1):
            if students[j][1] > students[j+1][1]:
                students[j], students[j+1] = students[j+1], students[j]

Code explanation:

This code defines a function sort_by_grade() that takes a list of students as input and sorts the list by grade in ascending order. The function first initializes a variable n to the length of the list. It then enters a nested loop, where the outer loop iterates through the list n times, and the inner loop iterates through the list n-i-1 times, where i is the current iteration of the outer loop.

Inside the inner loop, the code compares the grade of the current student (students[j][1]) to the grade of the next student (students[j+1][1]). If the grade of the current student is greater than the grade of the next student, the code swaps their positions in the list using tuple unpacking (students[j], students[j+1] = students[j+1], students[j]).

After the inner loop completes, the outer loop increments i and the process repeats until the entire list has been sorted.

Merge sort🤝:

  • Description of how the algorithm works: Merge sort is a divide-and-conquer algorithm that works by dividing a list of items in half, sorting the two halves, and then merging the sorted halves back together. This process is repeated recursively until the entire list is sorted.

Pros and cons of using merge sort:

  • Pros: Merge sort is a stable sort, meaning that it preserves the relative order of items with equal keys. It has a time complexity of O(n*log(n)), making it more efficient than bubble sort for large data sets.

  • Cons: Merge sort requires additional space to store the two halves of the list while they are being sorted, which can be a drawback for large data sets.

An example of how the Merge sort can be implemented to solve a simple problem:

Problem: Given a large list of products and their prices, sort the list by price in ascending order.

Solution:

def sort_by_price(products):
    if len(products) > 1:
        mid = len(products) // 2
        left = products[:mid]
        right = products[mid:]
        sort_by_price(left)
        sort_by_price(right)
        i = j = k = 0
        while i < len(left) and j < len(right):
            if left[i][1] < right[j][1]:
                products[k] = left[i]
                i += 1
            else:
                products[k] = right[j]
                j += 1
            k += 1
        while i < len(left):
            products[k] = left[i]
            i += 1
            k += 1
        while j < len(right):
            products[k] = right[j]
            j += 1
            k += 1

Code explanation:

This code defines a function sort_by_price() that takes a list of products as input and sorts the list by price in ascending order. The function first checks if the length of the list is greater than 1, and if it is, it divides the list into two halves using integer division (mid = len(products) // 2). It then recursively calls itself on the two halves of the list (sort_by_price(left) and sort_by_price(right)), which continues until each sublist consists of a single element.

Once the sublists are sorted, the function begins the merge process by initializing three variables: i, j, and k to 0. It then enters a while loop that continues as long as i is less than the length of the left sublist and j is less than the length of the right sublist. Inside the loop, the function compares the prices of the current products in the left and right sublists (left[i][1] and right[j][1]). If the price of the product in the left sublist is less than the price of the product in the right sublist, the function assigns the product in the left sublist to the current position in the original list (products[k] = left[i]) and increments i. If the price of the product in the right sublist is less than or equal to the price of the product in the left sublist, the function does the same thing with the product in the right sublist (products[k] = right[j] and j += 1). In either case, the function increments k to move on to the next position in the original list.

Once the while loop completes, the function enters two additional while loops to handle any remaining elements in the left or right sublists. These elements are simply appended to the end of the original list.

The function then returns the sorted list of products.

Quick sort⚡:

  • Description of how the algorithm works: Quick sort is another divide-and-conquer algorithm that works by selecting a pivot element from the list and partitioning the other elements around it. The pivot element is placed in its final sorted position, and the process is repeated recursively on the sublists on either side of the pivot until the entire list is sorted.

  • Pros & cons of using quick sort:

    • Pros: Quick sort is generally very efficient, with a time complexity of O(n*log(n)) on average. It is also an in-place sort, meaning that it does not require additional space to perform the sort.

    • Cons: The performance of quick sort can degrade to O(n^2) in the worst case, if the pivot element is poorly chosen. However, this is rare and can be mitigated by using a random pivot element or other strategies.

### An example of how the bubble sort can be implemented to solve a simple problem:

***Problem***: Given a list of integers, sort the list in ascending order.

***Solution***:

```python
def sort_ascending(arr):
    quick_sort(arr, 0, len(arr)-1)

def quick_sort(arr, low, high):
    if low < high:
        pivot_index = partition(arr, low, high)
        quick_sort(arr, low, pivot_index-1)
        quick_sort(arr, pivot_index+1, high)

def partition(arr, low, high):
    pivot = arr[high]
    i = low-1
    for j in range(low, high):
        if arr[j] <= pivot:
            i += 1
            arr[i], arr[j] = arr[j], arr[i]
    arr[i+1], arr[high] = arr[high], arr[i+1]
    return i+1
```

***Code explanation:***

This code defines three functions: `sort_ascending()`, `quick_sort()`, and `partition()`. The `sort_ascending()` function takes an array as input and calls the `quick_sort()` function on the array, passing in the array, the index of the first element (0), and the index of the last element (`len(arr)-1`).

The `quick_sort()` function is the main function for implementing quick sort. It takes an array, a low index, and a high index as input and divides the array into two subarrays around a pivot element. It does this by calling the `partition()` function, which selects the pivot element (`pivot = arr[high]`) and rearranges the other elements in the array so that all elements less than the pivot are placed before it, and all elements greater than the pivot are placed after it. The `partition()` function then returns the index of the pivot element, which is used by the `quick_sort()` function to determine the new low and high indices for the subarrays.

The `quick_sort()` function then recursively calls itself on the two subarrays until the array is fully sorted.

### Comparison of different sorting algorithms in terms of complexity and efficiency:

* **Bubble sort**: O(n^2) time complexity, O(1) space complexity

* **Merge sort**: O(n\*log(n)) time complexity, O(n) space complexity

* **Quick sort**: O(n\*log(n)) average time complexity, O(n^2) worst-case time complexity, O(1) space complexity

Search Algorithms:

  • Description of how the algorithm works: Linear search is a simple search algorithm that iterates through a list of elements sequentially, starting from the first element, and returns the index of the element if it is found. If the element is not found, the algorithm returns -1.

Pros and cons of using linear search:

  • Pros: Linear search is very simple to implement and requires no additional space.

  • Cons: Linear search has a time complexity of O(n), meaning it becomes slower as the size of the list increases. It is not efficient for large lists.

    An example of how the linear search can be implemented to solve a simple problem:

    Problem: Given a list of integers and a target integer, find the index of the target integer in the list.

    Solution:

def linear_search(arr, target):
    for i in range(len(arr)):
        if arr[i] == target:
            return i
    return -1

Code explanation:

This code defines a function linear_search() that takes an array and a target element as input. It then iterates through the array using a for loop, and if it finds an element that is equal to the target element, it returns the index of the element. If the element is not found, the function returns -1.

Binary search0️⃣1️⃣:

  • Description of how the algorithm works: Binary search is a more efficient search algorithm that works by dividing the list of elements in half repeatedly until the target element is found, or it is determined that the element is not present in the list. Binary search can only be used on sorted lists.

  • Pros and cons of using binary search:

    • Pros: Binary search has a time complexity of O(log(n)), making it much more efficient for large lists than linear search.

    • Cons: Binary search requires the list to be sorted, and it is not suitable for searching for multiple elements or for searching for elements in a list that is frequently updated.

### An example of how the binary search can be implemented to solve a simple problem:

***Problem***: Given a list of integers and a target integer, find the index of the target integer in the list.

***Solution:***
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

Code explanation:

This code defines a function binary_search() that takes a sorted array and a target element as input. It initializes two variables, low and high, to the indices of the first and last elements in the array, respectively. It then enters a while loop that continues as long as low is less than or equal to high. Inside the loop, the function calculates the midpoint of the array using integer division (mid = (low + high) // 2) and compares the element at the midpoint to the target element. If the element at the midpoint is equal to the target element, the function returns the index of the element. If the element at the midpoint is less than the target element, the function updates the value of low to be the index of the element after the midpoint (low = mid + 1). If the element at the midpoint is greater than the target element, the function updates the value of high to be the index of the element before the midpoint (high = mid - 1).

Once the while loop completes, the function returns -1 if the target element was not found.

  • Description of how the algorithm works: Depth-first search (DFS) is a search algorithm that traverses a tree or graph by exploring as far as possible along each branch before backtracking. DFS can be implemented using a stack or recursion.

  • Pros and cons of using depth-first search:

    • Pros: DFS is a simple algorithm to implement and can be used to explore and find paths in large graphs.

    • Cons: DFS may not find the shortest path between two nodes in a graph, and it may take longer to find a path in a graph with a large branching factor. DFS can also lead to an infinite loop if the graph contains cycles.

### An example of how the depth-first search can be implemented to solve a simple problem:

***Problem***: Given a graph represented as an adjacency list, find the shortest path from the start node to the target node using DFS.

***Solution:***
def depth_first_search(graph, start, target):
    stack = [(start, [start])]
    while stack:
        (vertex, path) = stack.pop()
        for neighbor in graph[vertex] - set(path):
            if neighbor == target:
                yield path + [neighbor]
            else:
                stack.append((neighbor, path + [neighbor]))

Code explanation:

  • This code defines a function depth_first_search() that takes a graph represented as an adjacency list, a start node, and a target node as input. It initializes a stack with a tuple containing the start node and a list containing the start node. It then enters a while loop that continues as long as the stack is not empty. Inside the loop, the function pops the top element of the stack and assigns it to the variables vertex and path. It then iterates through the neighbors of the vertex (for neighbor in graph[vertex] - set(path)), where graph[vertex] is a list of the neighbors of the vertex and set(path) is a set of the nodes in the path.

If the current neighbor is the target node, the function yields the path to the target node (yield path + [neighbor]). If the current neighbor is not the target node, the function appends a tuple containing the neighbor and the current path to the stack (stack.append((neighbor, path + [neighbor]))).

Graph Algorithms:

Breadth-first search (BFS)📉:

  • Description of how the algorithm works: Breadth-first search (BFS) is a search algorithm that traverses a tree or graph by exploring all the nodes at the current depth level before moving on to the nodes at the next depth level. BFS can be implemented using a queue or recursion.

  • Pros and cons of using BFS:

    • Pros: BFS is a simple algorithm to implement and can be used to find the shortest path between two nodes in a graph. BFS is also well-suited for finding the connected components of a graph.

    • Cons: BFS may take longer to find a path in a graph with a large branching factor.

### An example of how the breadth-first search can be implemented to solve a simple problem:

***Problem***: Given a graph represented as an adjacency list and a start node, find the shortest path to all the other nodes in the graph using BFS.

***Solution:***
def breadth_first_search(graph, start):
    queue = [(start, 0)]
    visited = set()
    while queue:
        (vertex, depth) = queue.pop(0)
        if vertex not in visited:
            visited.add(vertex)
            for neighbor in graph[vertex]:
                queue.append((neighbor, depth+1))
    return visited

Code explanation:

This code defines a function breadth_first_search() that takes a graph represented as an adjacency list and a start node as input. It initializes a queue with a tuple containing the start node and a depth of 0, and a visited set to track the nodes that have been visited.

The function then enters a while loop that continues as long as the queue is not empty. Inside the loop, the function removes the first element of the queue (using queue.pop(0)) and assigns it to the variables vertex and depth. If the vertex has not yet been visited (if vertex not in visited), the function adds the vertex to the visited set and iterates through the neighbors of the vertex (for neighbor in graph[vertex]). For each neighbor, the function appends a tuple containing the neighbor and an updated depth (depth+1) to the queue.

Once the while loop completes, the function returns the visited set, which contains all the nodes that were reached by the BFS search.

Dijkstra's algorithm📈:

  • Description of how the algorithm works: Dijkstra's algorithm is a graph search algorithm that works by iteratively updating the distance of each node to the start node and selecting the node with the smallest distance as the next node to be visited. The algorithm terminates when the target node is reached or all nodes have been visited. Dijkstra's algorithm is only applicable to graphs with non-negative edge weights.

  • Pros: Dijkstra's algorithm is a popular algorithm for finding the shortest path between two nodes in a graph with non-negative edge weights. It is efficient and can be implemented using a priority queue to achieve a time complexity of O(mlogn) where m is the number of edges and n is the number of vertices.

  • Cons: Dijkstra's algorithm requires all edge weights to be non-negative and does not work for graphs with negative edge weights. It also requires the use of a priority queue, which can be complex to implement.

    An example of how Dijkstra's algorithm can be implemented to solve a simple problem:

    Problem: Given a graph represented as an adjacency matrix with edge weights, find the shortest path from the start node to the target node using Dijkstra's algorithm.

    Solution:

import sys

def dijkstra(graph, start, target):
    distances = {vertex: sys.maxsize for vertex in graph}
    distances[start] = 0
    previous_vertices = {vertex: None for vertex in graph}

    unvisited_vertices = set(graph)
    while unvisited_vertices:
        current_vertex = min(unvisited_vertices, key=lambda vertex: distances[vertex])
        unvisited_vertices.remove(current_vertex)

        if current_vertex == target:
            break

        for neighbor, weight in graph[current_vertex].items():
            alternative_route = distances[current_vertex] + weight
            if alternative_route < distances[neighbor]:
                distances[neighbor] = alternative_route
                previous_vertices[neighbor] = current_vertex

    path, current_vertex = [], target
    while previous_vertices[current_vertex] is not None:
        path.append(current_vertex)
        current_vertex = previous_vertices[current_vertex]
    if path:
        path.append(start)
        return path[::-1]
    return "There is no path between the start and target vertices."

Code explanation:

This code defines a function dijkstra() that takes a graph represented as an adjacency matrix with edge weights, a start node, and a target node as input. It initializes a dictionary distances with the maximum integer value as the distance for each vertex in the graph, and sets the distance for the start node to 0. It also initializes a dictionary previous_vertices with None as the value for each vertex in the graph.

The function then creates a set unvisited_vertices containing all the vertices in the graph and enters a while loop that continues as long as the set is not empty. Inside the loop, the function selects the vertex with the minimum distance from the set using the min() function and a lambda function as the key. It removes the selected vertex from the unvisited_vertices set and checks if it is the target vertex. If it is, the loop is terminated.

If the selected vertex is not the target, the function iterates through the neighbors of the vertex and calculates an alternative route to each neighbor by adding the weight of the edge to the distance of the current vertex. If the alternative route is shorter than the current distance to the neighbor, the function updates the distance and sets the current vertex as the previous vertex for the neighbor.

After the while loop completes, the function constructs a path from the start to the target vertex by starting at the target vertex and following the previous_vertices pointers until the start vertex is reached. It then returns the path in reverse order. If no path is found, the function returns a string indicating that there is no path between the start and target vertices.

And Finally,

  • Description of how the algorithm works: A* search is a graph search algorithm that combines the strengths of breadth-first search and uniform-cost search by using a heuristic function to guide the search towards the target node. A* search is considered the most efficient search algorithm and is often used for pathfinding in video games and other applications.

  • Pros and cons of using A* search:

    • Pros: A* search is the most efficient search algorithm and is widely used for pathfinding in various applications. It is also relatively easy to implement.

    • Cons: A* search requires the use of a heuristic function, which must be carefully designed to ensure the accuracy of the search. A* search can also be slower than other algorithms in certain cases, such as when the heuristic function is not well-suited for the problem.

  • An example of how the A* search can be implemented to solve a simple problem:

    Problem: Given a graph represented as an adjacency matrix with edge weights and a heuristic function, find the shortest path from the start node to the target node using A* search.

    Solution:

import sys
from heapq import heappop, heappush

def a_star(graph, start, target, heuristic):
    distances = {vertex: sys.maxsize for vertex in graph}
    distances[start] = 0
    previous_vertices = {vertex: None for vertex in graph}
    heap = []
    heappush(heap, (0, start))

    while heap:
        current_distance, current_vertex = heappop(heap)
        if current_vertex == target:
            break

        for neighbor, weight in graph[current_vertex].items():
            alternative_route = distances[current_vertex] + weight
            if alternative_route < distances[neighbor]:
                distances[neighbor] = alternative_route
                priority = alternative_route + heuristic(neighbor, target)
                heappush(heap, (priority, neighbor))
                previous_vertices[neighbor] = current_vertex

    path, current_vertex = [], target
    while previous_vertices[current_vertex] is not None:
        path.append(current_vertex)
        current_vertex = previous_vertices[current_vertex]
    if path:
        path.append(start)
        return path[::-1]
    return "There is no path between the start and target vertices."

Code explanation:

This code defines a function a_star() that takes a graph represented as an adjacency list, a start node, a target node, and a heuristic function as input. It initializes a dictionary distances with the maximum integer value as the distance for each vertex in the graph, and sets the distance for the start node to 0. It also initializes a dictionary previous_vertices with None as the value for each vertex in the graph and a heap heap containing a tuple with the start node and a distance of 0.

The function then enters a while loop that continues as long as the heap is not empty. Inside the loop, the function removes the element with the minimum distance from the heap using the heappop() function and assigns it to the variables current_distance and current_vertex. If the current vertex is the target vertex, the loop is terminated.

If the current vertex is not the target, the function iterates through the neighbors of the vertex and calculates an alternative route to each neighbor by adding the weight of the edge to the distance of the current vertex. If the alternative route is shorter than the current distance to the neighbor, the function updates the distance and sets the current vertex as the previous vertex for the neighbor. It also pushes a tuple containing the alternative route and the neighbor onto the heap using the heappush() function.

After the while loop completes, the function constructs a path from the start to the target vertex by starting at the target vertex and following the previous_vertices pointers until the start vertex is reached. It then returns the path in reverse order. If no path is found, the function returns a string indicating that there is no path between the start and target vertices.

Concluding

In conclusion, algorithms are a fundamental part of computer science and play a crucial role in solving a wide range of problems. In this article, we learned about three types of algorithms: sorting, search, and graph algorithms. We also looked at specific examples of each type of algorithm and saw how they can be implemented in Python.

In the next article in the series, "Intro to Algorithms: Part 3," we'll dive deeper into the world of algorithms and explore more advanced topics such as dynamic programming, Machine learning algorithms & other real-world applications of the algorithms we discussed in this article. Get your calculators and thinking caps ready, because we're about to take a trip down the rabbit hole of algorithm design and analysis!

Thanks for reading and I hope you found this introduction to algorithms helpful. Until next time, happy coding!✌️💻