DSA

Stack

Stack

Stack in Python

A stack is a linear data structure that follows the Last-In, First-Out (LIFO) principle. The last element inserted into the stack is the first element removed.

Basic Stack Operations

  • push(item): Adds an item to the top of the stack.
  • pop(): Removes and returns the top item from the stack.
  • peek(): Returns the top item from the stack without removing it.
  • isEmpty(): Checks if the stack is empty.

Python Implementation (List)

Python lists can be used to implement a stack. The append() method is used for push, and the pop() method is used for pop.

class Stack:
    def __init__(self):
        self.items = []
 
    def isEmpty(self):
        return len(self.items) == 0
 
    def push(self, item):
        self.items.append(item)
 
    def pop(self):
        if not self.isEmpty():
            return self.items.pop()
        else:
            return None # or raise an exception
 
    def peek(self):
        if not self.isEmpty():
            return self.items[-1]
        else:
            return None # or raise an exception
 
    def size(self):
        return len(self.items)
 
# Example Usage
s = Stack()
s.push(1)
s.push(2)
s.push(3)
print(s.pop()) # Output: 3
print(s.peek()) # Output: 2
print(s.isEmpty()) # Output: False

Python Implementation (collections.deque)

The collections.deque class provides a double-ended queue, which can be used efficiently as a stack. deque is generally faster than using a list for stack operations because deque’s append and pop operations from the end of the queue have O(1) complexity, while list’s pop() can be O(n) in certain scenarios.

from collections import deque
 
class StackDeque:
    def __init__(self):
        self.items = deque()
 
    def isEmpty(self):
        return len(self.items) == 0
 
    def push(self, item):
        self.items.append(item)
 
    def pop(self):
        if not self.isEmpty():
            return self.items.pop()
        else:
            return None # or raise an exception
 
    def peek(self):
        if not self.isEmpty():
            return self.items[-1]
        else:
            return None # or raise an exception
    def size(self):
        return len(self.items)
 
# Example Usage
s = StackDeque()
s.push(1)
s.push(2)
s.push(3)
print(s.pop())
print(s.peek())
print(s.isEmpty())

Applications of Stack

  • Expression Evaluation: Evaluating postfix and prefix expressions.
  • Backtracking: Solving problems like maze traversal, N-Queens.
  • Function Calls: Managing function calls and local variables.
  • Undo/Redo Functionality: Implementing undo/redo features in applications.

Advanced Tips and Best Practices

  1. Use collections.deque: For better performance, especially when the stack grows large. deque offers O(1) time complexity for append and pop operations, whereas list-based stacks might degrade to O(n) in some cases.

  2. Handle Stack Underflow: Implement checks to prevent popping from an empty stack. Raise an exception or return a specific value (e.g., None) to indicate underflow.

  3. Handle Stack Overflow (if applicable): If the stack has a fixed size limit, implement checks to prevent pushing onto a full stack. This is less common in Python due to dynamic resizing, but important in other languages.

  4. Custom Exceptions: Consider creating custom exception classes for stack-related errors (e.g., StackUnderflowError, StackOverflowError) to improve code clarity and error handling.

  5. Consider threading implications: When using stacks in multi-threaded applications, ensure thread safety by using appropriate locking mechanisms to prevent race conditions.

References

Link to original

Queue

Queue

Queue in Python

Introduction

A queue is a linear data structure that follows the First-In-First-Out (FIFO) principle. Elements are added to the rear (enqueue) and removed from the front (dequeue).

Implementations

Python offers several ways to implement a queue:

  1. Using list: Python’s built-in list can be used as a queue, but it is not efficient for large queues because pop(0) (dequeue operation) has O(n) time complexity.

    queue = []
     
    # Enqueue
    queue.append(1)
    queue.append(2)
    queue.append(3)
     
    # Dequeue
    if queue:
        element = queue.pop(0)
        print(f"Dequeued: {element}") # Output: Dequeued: 1
    else:
        print("Queue is empty")
  2. Using collections.deque: collections.deque (double-ended queue) is a preferred implementation for queues in Python because it provides O(1) time complexity for both enqueue and dequeue operations.

    from collections import deque
     
    queue = deque()
     
    # Enqueue
    queue.append(1)
    queue.append(2)
    queue.append(3)
     
    # Dequeue
    if queue:
        element = queue.popleft()
        print(f"Dequeued: {element}") # Output: Dequeued: 1
    else:
        print("Queue is empty")
  3. Using queue.Queue: The queue.Queue class is designed for thread-safe FIFO queuing. It is useful in concurrent programming.

    import queue
     
    q = queue.Queue()
     
    # Enqueue
    q.put(1)
    q.put(2)
    q.put(3)
     
    # Dequeue
    if not q.empty():
        element = q.get()
        print(f"Dequeued: {element}") # Output: Dequeued: 1
    else:
        print("Queue is empty")

Basic Operations

  • Enqueue (add): Adds an element to the rear of the queue.
  • Dequeue (remove): Removes the element from the front of the queue.
  • Peek (front): Returns the element at the front of the queue without removing it.
  • isEmpty: Checks if the queue is empty.
  • size: Returns the number of elements in the queue.

Examples

collections.deque example with all operations:

from collections import deque
 
class Queue:
    def __init__(self):
        self.items = deque()
 
    def enqueue(self, item):
        self.items.append(item)
 
    def dequeue(self):
        if not self.is_empty():
            return self.items.popleft()
        else:
            return None
 
    def peek(self):
        if not self.is_empty():
            return self.items[0]
        else:
            return None
 
    def is_empty(self):
        return len(self.items) == 0
 
    def size(self):
        return len(self.items)
 
# Usage
q = Queue()
q.enqueue(10)
q.enqueue(20)
q.enqueue(30)
 
print(f"Front element: {q.peek()}") # Output: Front element: 10
print(f"Queue size: {q.size()}")   # Output: Queue size: 3
 
dequeued_item = q.dequeue()
print(f"Dequeued item: {dequeued_item}") # Output: Dequeued item: 10
 
print(f"Is queue empty? {q.is_empty()}") # Output: Is queue empty? False

Advanced Tips and Best Practices

  1. Choose the right implementation: For general-purpose queues, collections.deque is usually the best choice because of its O(1) performance for enqueue and dequeue. Use queue.Queue for thread-safe operations. Avoid using list for large queues due to its O(n) dequeue time complexity.

  2. Thread safety: If your queue is accessed by multiple threads, use queue.Queue to ensure thread safety. collections.deque is not thread-safe.

  3. Bounded Queues: For producer-consumer scenarios, consider using a bounded queue (e.g., queue.Queue with a maxsize argument). This prevents producers from overwhelming the queue and consuming excessive memory.

    import queue
    import threading
    import time
     
    def producer(q, item_count):
        for i in range(item_count):
            item = f"Item {i}"
            q.put(item)
            print(f"Produced: {item}")
            time.sleep(0.1) # Simulate work
     
    def consumer(q):
        while True:
            item = q.get()
            if item is None: # Sentinel value to signal completion
                break
            print(f"Consumed: {item}")
            q.task_done() # Indicate that a formerly enqueued task is complete
     
    q = queue.Queue(maxsize=5)  # Bounded queue
    item_count = 10
     
    producer_thread = threading.Thread(target=producer, args=(q, item_count))
    consumer_thread = threading.Thread(target=consumer, args=(q,))
     
    producer_thread.start()
    consumer_thread.start()
     
    producer_thread.join()  # Wait for producer to finish
    q.put(None) # Signal consumer to exit
    consumer_thread.join()
  4. Prioritization: If you need to process elements in a specific order based on priority, consider using queue.PriorityQueue.

  5. Error Handling: When using queue.Queue, use q.put(item, timeout=...) and q.get(timeout=...) with timeouts to handle potential blocking issues.

  6. Sentinel Values: In multithreaded scenarios, use sentinel values (e.g., None) to signal the consumer thread to exit gracefully when the producer is finished. This avoids deadlocks.

  7. Avoid iterating directly: Do not directly iterate a queue using a for loop unless you have a specific iterator class designed for it (as shown in some StackOverflow examples). Directly iterating a standard queue can lead to unexpected behavior and potential data loss due to concurrency issues or incorrect queue state. Use get() and put() for proper queue management.

Use Cases

  • Task scheduling: Queues are used to schedule tasks for execution.
  • Buffering: Queues can buffer data between producers and consumers.
  • Message passing: Queues are used in message passing systems.
  • Breadth-First Search (BFS): Queues are used in graph traversal algorithms like BFS.
  • Print spooling: Queues manage print jobs in the order they are received.
Link to original

Tree

Tree

Types of Tree

  • Binary Tree: Each node has at most two children (left and right).
  • Binary Search Tree (BST): Binary tree where the left subtree has values less than the node and right subtree has values greater.
  • Balanced Tree: Tree where the height difference between subtrees of any node is minimal (e.g., AVL tree).
  • Full Binary Tree: Every node has 0 or 2 children.
  • Complete Binary Tree: All levels are fully filled except possibly the last, and all nodes are as far left as possible.
  • N-ary Tree: Each node can have at most N children.
  • Trie: Tree used to store strings where nodes represent characters.

Detailed Notes

  • A tree is a non-linear data structure consisting of nodes connected by edges.
  • It starts with a root node and expands into sub-nodes (children).
  • Each node contains:
    • Data
    • Reference to child nodes (left/right or a list of children)

Creating a Basic Tree Node in Python

class Node:
    def __init__(self, data):
        self.data = data
        self.left = None
        self.right = None

Binary Tree Example

# Creating a simple binary tree
root = Node(10)
root.left = Node(5)
root.right = Node(15)

Tree Traversal Methods

Inorder (Left, Root, Right)

def inorder(root):
    if root:
        inorder(root.left)
        print(root.data, end=" ")
        inorder(root.right)

Preorder (Root, Left, Right)

def preorder(root):
    if root:
        print(root.data, end=" ")
        preorder(root.left)
        preorder(root.right)

Postorder (Left, Right, Root)

def postorder(root):
    if root:
        postorder(root.left)
        postorder(root.right)
        print(root.data, end=" ")

Level Order (Breadth First)

from collections import deque

def level_order(root):
    if not root:
        return
    q = deque([root])
    while q:
        node = q.popleft()
        print(node.data, end=" ")
        if node.left:
            q.append(node.left)
        if node.right:
            q.append(node.right)

Binary Search Tree (BST) Insertion

def insert(root, key):
    if root is None:
        return Node(key)
    if key < root.data:
        root.left = insert(root.left, key)
    else:
        root.right = insert(root.right, key)
    return root

Key Points

  • Trees are recursive structures — each child node is itself a tree.
  • Tree traversal is fundamental — Inorder traversal of BST gives sorted output.
  • None is used to signify empty nodes in Python.
  • Trees are used in hierarchical data (file systems, XML parsing, etc.).
  • Time complexity for operations like insertion/search in balanced BST: O(log n).

Advance Tips and Best Practices

  • Always check for None before accessing .left or .right to avoid AttributeError.
  • Use recursion with care — for large trees, consider iterative methods to avoid stack overflow.
  • Prefer using classes and objects for tree nodes for cleaner, scalable code.
  • For balanced BSTs, consider using AVL or Red-Black Trees for guaranteed O(log n) operations.
  • In competitive coding, use iterative traversals to optimize space.

Relevant Images

Binary Tree Structure

Tree Traversals

Binary Search Tree


References

Link to original

Graph

Graph

Cycel detection

DSU - Division Set Uniun

Khan’s Algorithm

Link to original

Connected Components

Connected Components

Let’s explore how to find cycles and connected components in both undirected and directed graphs. We’ll start with the fundamental concepts and then dive into implementation details.

Basic Concepts

  1. Graph Types:
  • Undirected Graphs: Edges have no direction, can be traversed both ways
  • Directed Graphs: Edges have direction, can only be traversed one way
  1. Key Terms:
  • Cycle: A path that starts and ends at the same vertex, visiting at least one edge
  • Connected Component: A subgraph where there’s a path between every pair of vertices
  • Strongly Connected Component (Directed only): A subgraph where there’s a directed path between every pair of vertices

Let’s visualize these concepts with some examples:

flowchart LR
    subgraph "Undirected Graph with Cycle"
        A((A)) --- B((B))
        B --- C((C))
        C --- A
        C --- D((D))
    end
    
    subgraph "Directed Graph with Cycle"
        E((E)) --> F((F))
        F --> G((G))
        G --> E
        G --> H((H))
    end
    
    subgraph "Connected Components"
        I((I)) --- J((J))
        K((K)) --- L((L))
    end
    
    style A fill:#f9f,stroke:#333,color:#000
    style B fill:#f9f,stroke:#333,color:#000
    style C fill:#f9f,stroke:#333,color:#000
    style D fill:#f9f,stroke:#333,color:#000
    style E fill:#9f9,stroke:#333,color:#000
    style F fill:#9f9,stroke:#333,color:#000
    style G fill:#9f9,stroke:#333,color:#000
    style H fill:#9f9,stroke:#333,color:#000
    style I fill:#ff9,stroke:#333,color:#000
    style J fill:#ff9,stroke:#333,color:#000
    style K fill:#ff9,stroke:#333,color:#000
    style L fill:#ff9,stroke:#333,color:#000

In the diagram above:

  • The left subgraph shows an undirected graph with a cycle (A-B-C-A), where edges can be traversed in both directions
  • The middle subgraph shows a directed graph with a cycle (E-F-G-E), where edges have direction and must be followed in that direction
  • The right subgraph shows two separate connected components (I-J and K-L), where there’s no path between vertices in different components

Algorithm Approaches

  1. Cycle Detection:
  • DFS Approach 9:1:

    • Use a visited array and recursion stack
    • For undirected graphs: Track parent to avoid false positives
    • For directed graphs: Check if neighbor is in recursion stack
  • BFS Approach 10:1:

    • Level-order traversal
    • More memory-efficient for large graphs
    • Particularly useful for undirected graphs
  1. Connected Components:
  • DFS Approach:

    • Perform DFS from each unvisited vertex
    • Each DFS traversal finds a new component
    • Works for both directed and undirected graphs
  • Union-Find Approach:

    • More efficient for sparse graphs
    • Uses path compression and union by rank
    • Particularly useful for dynamic graphs

Let’s visualize the algorithm flow for cycle detection:

flowchart LR
    Start["Start DFS"] --> Visit["Mark vertex as visited"]
    Visit --> Stack["Add to recursion stack"]
    Stack --> Check{"Check all neighbors"}
    Check -->|"Unvisited neighbor"| Visit
    Check -->|"Visited in stack"| Cycle["Found cycle"]
    Check -->|"Visited not in stack"| Next["Continue to next neighbor"]
    Next --> Check
    Check -->|"No more neighbors"| Back["Remove from stack"]
    Back --> End["End DFS"]
    
    style Start fill:#f9f,stroke:#333,color:#000
    style Cycle fill:#f99,stroke:#333,color:#000
    style End fill:#9f9,stroke:#333,color:#000

The algorithm flow diagram shows the key steps in cycle detection:

  • The recursion stack keeps track of vertices in the current DFS path
  • For undirected graphs, we skip the parent vertex to avoid false positives
  • For directed graphs, we only check if the neighbor is in the recursion stack
  • The process continues until all neighbors are checked or a cycle is found

Implementation Details

  1. Cycle Detection Implementation 9:2:
def detect_cycle_util(graph, vertex, visited, rec_stack, parent=None):
    visited[vertex] = True
    rec_stack[vertex] = True
    
    for neighbor in graph[vertex]:
        if not visited[neighbor]:
            if detect_cycle_util(graph, neighbor, visited, rec_stack, vertex):
                return True
        elif rec_stack[neighbor] and neighbor != parent:  # parent check for undirected
            return True
            
    rec_stack[vertex] = False
    return False
  1. Connected Components Implementation 10:2:
def find_connected_components(graph):
    visited = set()
    components = []
    
    for vertex in graph:
        if vertex not in visited:
            component = []
            dfs_util(graph, vertex, visited, component)
            components.append(component)
            
    return components

Time and Space Complexity

  1. Cycle Detection:
  • Time Complexity: O(V + E)
  • Space Complexity: O(V) for recursion stack and visited array
  1. Connected Components:
  • Time Complexity: O(V + E)
  • Space Complexity: O(V) for visited array and component storage

Important Considerations

  1. For Undirected Graphs:
  • Always track parent vertex to avoid false positives
  • Consider both directions of each edge
  • Use BFS for better memory efficiency in large graphs
  1. For Directed Graphs:
  • Only consider edge direction
  • Strongly connected components require additional processing
  • Topological sort can help identify cycles
  1. Edge Cases:
  • Handle self-loops (edges to same vertex)
  • Consider parallel edges in directed graphs
  • Account for disconnected components

This implementation provides an efficient way to detect cycles and find connected components in both undirected and directed graphs, with clear handling of edge cases and optimal time complexity.

Link to original

Eulerian Cycle, Eulerian Path

Eulerian Cycle, Eulerian Path

Eulerian paths and cycles are fundamental concepts in graph theory that help us understand how to traverse all edges in a graph exactly once. Let’s explore these concepts with visual examples and clear explanations.

Basic Definitions

  • Eulerian Path: A path that visits every edge in a graph exactly once 2:0
  • Eulerian Circuit: A special case of Eulerian path that starts and ends at the same vertex 3:0

Let’s visualize these concepts with a simple example:

flowchart LR
    subgraph "Eulerian Circuit Example"
        A((A)) --- B((B))
        B --- C((C))
        C --- D((D))
        D --- A
        B --- D
    end
    
    subgraph "Properties"
        P1["• All vertices have even degree
        • All vertices are connected
        • Can start/end at any vertex"]
    end
    
    style A fill:#f9f,stroke:#333,color:#000
    style B fill:#f9f,stroke:#333,color:#000
    style C fill:#f9f,stroke:#333,color:#000
    style D fill:#f9f,stroke:#333,color:#000
    style P1 fill:#f0f0f0,stroke:#333,color:#000,text-align:left

In this diagram, each circle represents a vertex, and the lines between them represent edges. Notice how you can start at any vertex and trace through every edge exactly once, returning to your starting point. This is a perfect example of an Eulerian circuit because:

  • Every edge is used exactly once
  • The path returns to its starting point
  • Each vertex has an even number of connections (degree 3 for B and D, degree 2 for A and C)

Eulerian Path Example

Let’s look at a graph that has an Eulerian path but not an Eulerian circuit:

flowchart LR
    subgraph "Eulerian Path Example"
        A((A)) --- B((B))
        B --- C((C))
        C --- D((D))
        D --- E((E))
    end
    
    subgraph "Properties"
        P1["• Exactly two vertices have odd degree
        • All vertices are connected
        • Must start at one odd vertex
        • Must end at the other odd vertex"]
    end
    
    style A fill:#f9f,stroke:#333,color:#000
    style B fill:#9f9,stroke:#333,color:#000
    style C fill:#9f9,stroke:#333,color:#000
    style D fill:#9f9,stroke:#333,color:#000
    style E fill:#f9f,stroke:#333,color:#000
    style P1 fill:#f0f0f0,stroke:#333,color:#000,text-align:left

In this diagram, the pink vertices (A and E) have odd degrees (1 edge each), while the green vertices (B, C, D) have even degrees (2 edges each). This graph has an Eulerian path because:

  • You can trace through all edges exactly once
  • The path must start at vertex A (odd degree) and end at vertex E (odd degree)
  • All other vertices have even degrees

Mathematical Criteria

A graph has an Eulerian circuit if and only if 2:2:

  1. All vertices with non-zero degree are connected
  2. All vertices have even degree

A graph has an Eulerian path if and only if 3:13:

  1. All vertices with non-zero degree are connected
  2. Exactly two vertices have odd degree

Implementation in C++

Here’s a C++ implementation to check if a graph has an Eulerian path or circuit:

#include <iostream>
#include <list>
using namespace std;
 
class Graph {
    int V;
    list<int> *adj;
    
    bool isConnected() {
        bool *visited = new bool[V];
        for(int i = 0; i < V; i++) visited[i] = false;
        
        // Find first non-zero degree vertex
        int start = 0;
        for(int i = 0; i < V; i++) {
            if(adj[i].size() > 0) {
                start = i;
                break;
            }
        }
        
        // If no edges, graph is connected
        if(start == V) return true;
        
        // Perform DFS
        DFSUtil(start, visited);
        
        // Check if all non-zero degree vertices are visited
        for(int i = 0; i < V; i++) {
            if(visited[i] == false && adj[i].size() > 0) return false;
        }
        return true;
    }
    
    void DFSUtil(int v, bool visited[]) {
        visited[v] = true;
        for(int i : adj[v]) {
            if(!visited[i]) DFSUtil(i, visited);
        }
    }
    
    int countOddDegreeVertices() {
        int count = 0;
        for(int i = 0; i < V; i++) {
            if(adj[i].size() % 2 != 0) count++;
        }
        return count;
    }
    
public:
    Graph(int vertices) {
        V = vertices;
        adj = new list<int>[V];
    }
    
    void addEdge(int v, int w) {
        adj[v].push_back(w);
        adj[w].push_back(v);
    }
    
    int isEulerian() {
        if(!isConnected()) return 0;
        
        int odd = countOddDegreeVertices();
        if(odd == 0) return 2;  // Eulerian circuit
        if(odd == 2) return 1;  // Eulerian path
        return 0;               // Neither
    }
};

Implementation in Python

from collections import defaultdict
from typing import List, Tuple, Optional
 
class Graph:
    def __init__(self):
        self.graph = defaultdict(list)
        self.V = 0  # Number of vertices
        
    def add_edge(self, u: int, v: int) -> None:
        """Add an edge between vertices u and v"""
        self.graph[u].append(v)
        self.graph[v].append(u)
        self.V = max(self.V, u + 1, v + 1)
        
    def dfs_util(self, v: int, visited: List[bool]) -> None:
        """Utility function for DFS"""
        visited[v] = True
        for i in self.graph[v]:
            if not visited[i]:
                self.dfs_util(i, visited)
                
    def is_connected(self) -> bool:
        """Check if the graph is connected"""
        visited = [False] * self.V
        
        # Find first non-zero degree vertex
        start = 0
        for i in range(self.V):
            if len(self.graph[i]) > 0:
                start = i
                break
                
        # If no edges, graph is connected
        if start == self.V:
            return True
            
        # Perform DFS traversal
        self.dfs_util(start, visited)
        
        # Check if all non-zero degree vertices are visited
        for i in range(self.V):
            if not visited[i] and len(self.graph[i]) > 0:
                return False
        return True
        
    def count_odd_degree_vertices(self) -> int:
        """Count vertices with odd degree"""
        return sum(1 for i in range(self.V) if len(self.graph[i]) % 2 != 0)
        
    def is_eulerian(self) -> int:
        """
        Check if graph has Eulerian path or circuit
        Returns:
            0: Not Eulerian
            1: Has Eulerian path
            2: Has Eulerian circuit
        """
        if not self.is_connected():
            return 0
            
        odd = self.count_odd_degree_vertices()
        if odd == 0:
            return 2  # Eulerian circuit
        elif odd == 2:
            return 1  # Eulerian path
        else:
            return 0  # Not Eulerian
 
# Example usage
def main():
    # Create a graph with Eulerian circuit
    g1 = Graph()
    g1.add_edge(0, 1)
    g1.add_edge(1, 2)
    g1.add_edge(2, 3)
    g1.add_edge(3, 0)
    g1.add_edge(1, 3)
    
    print("Graph 1 (should have Eulerian circuit):")
    print(f"Result: {g1.is_eulerian()}")
    
    # Create a graph with Eulerian path
    g2 = Graph()
    g2.add_edge(0, 1)
    g2.add_edge(1, 2)
    g2.add_edge(2, 3)
    
    print("\nGraph 2 (should have Eulerian path):")
    print(f"Result: {g2.is_eulerian()}")
 
if __name__ == "__main__":
    main()

Time and Space Complexity

  • Time Complexity: O(V + E) 2:10
  • Space Complexity: O(V + E) 2:11

This implementation can be used to check any undirected graph for Eulerian properties. The algorithm first verifies connectivity using DFS, then counts vertices with odd degrees to determine if the graph has an Eulerian circuit (all vertices even) or path (exactly two odd vertices).

Link to original

Infix-Postfix-Prefix

Infix-Postfix-Prefix

flowchart LR
    subgraph "Expression Notations"
        direction TB
        I["Infix Notation
        (A + B) * C"]
        P["Prefix Notation
        * + A B C"]
        Po["Postfix Notation
        A B + C *"]
    end
    
    subgraph "Conversion Process"
        direction TB
        S1["Stack-based
        Conversion"]
        S2["Reverse Process"]
    end
    
    I -->|"Convert to"| P
    I -->|"Convert to"| Po
    P -->|"Convert to"| I
    Po -->|"Convert to"| I
    
    S1 --> P
    S1 --> Po
    S2 --> I
    
    style I fill:#f9f,stroke:#333,color:#000
    style P fill:#9f9,stroke:#333,color:#000
    style Po fill:#ff9,stroke:#333,color:#000
    style S1 fill:#ddd,stroke:#333,color:#000
    style S2 fill:#ddd,stroke:#333,color:#000

In the diagram above:

  • Pink represents infix notation (traditional mathematical notation)
  • Green represents prefix notation (operator before operands)
  • Yellow represents postfix notation (operator after operands)
  • The arrows show possible conversion paths between notations
  • The “Stack-based Conversion” box shows how we convert from infix to other notations
  • The “Reverse Process” box shows how we convert back to infix notation

Let’s implement a Python class that handles all these conversions:

class ExpressionConverter:
    def __init__(self):
        # Operator precedence table
        self.precedence = {
            '^': 3,
            '*': 2,
            '/': 2,
            '+': 1,
            '-': 1
        }
    
    def infix_to_postfix(self, expression: str) -> str:
        """Convert infix to postfix notation"""
        stack = []
        output = []
        
        # Process each character
        for char in expression:
            if char.isalnum():
                output.append(char)
            elif char == '(':
                stack.append(char)
            elif char == ')':
                while stack and stack[-1] != '(':
                    output.append(stack.pop())
                if stack and stack[-1] == '(':
                    stack.pop()  # Remove '('
            else:
                while (stack and stack[-1] != '(' and 
                       self.precedence.get(stack[-1], 0) >= self.precedence.get(char, 0)):
                    output.append(stack.pop())
                stack.append(char)
        
        # Pop remaining operators
        while stack:
            output.append(stack.pop())
            
        return ' '.join(output)
    
    def infix_to_prefix(self, expression: str) -> str:
        """Convert infix to prefix notation"""
        # Convert to postfix first
        postfix = self.infix_to_postfix(expression)
        
        # Convert postfix to prefix by reversing
        tokens = postfix.split()
        prefix = ' '.join(reversed(tokens))
        return prefix
    
    def postfix_to_infix(self, expression: str) -> str:
        """Convert postfix to infix notation"""
        stack = []
        
        for token in expression.split():
            if token.isalnum():
                stack.append(token)
            else:
                operand2 = stack.pop()
                operand1 = stack.pop()
                stack.append(f"({operand1}{token}{operand2})")
                
        return stack[0]
    
    def prefix_to_infix(self, expression: str) -> str:
        """Convert prefix to infix notation"""
        # Convert prefix to postfix by reversing
        tokens = expression.split()
        postfix = ' '.join(reversed(tokens))
        return self.postfix_to_infix(postfix)
 
# Example usage
converter = ExpressionConverter()
 
# Test infix to postfix
infix_expr = "(A + B) * C"
postfix_expr = converter.infix_to_postfix(infix_expr)
print(f"Infix to Postfix: {infix_expr} → {postfix_expr}")
 
# Test infix to prefix
prefix_expr = converter.infix_to_prefix(infix_expr)
print(f"Infix to Prefix: {infix_expr} → {prefix_expr}")
 
# Test postfix to infix
postfix_expr = "A B + C *"
infix_expr = converter.postfix_to_infix(postfix_expr)
print(f"Postfix to Infix: {postfix_expr} → {infix_expr}")
 
# Test prefix to infix
prefix_expr = "* + A B C"
infix_expr = converter.prefix_to_infix(prefix_expr)
print(f"Prefix to Infix: {prefix_expr} → {infix_expr}")

How the Conversions Work

  1. Infix to Postfix 12:2:
  • Use a stack to store operators
  • Process expression from left to right
  • When encountering an operand, add it to output
  • For operators, pop higher precedence operators first
  • Handle parentheses by pushing ’(’ and popping until ’)’
  1. Infix to Prefix:
  • Convert to postfix first
  • Reverse the postfix expression
  • This works because prefix is essentially postfix in reverse order
  1. Postfix to Infix:
  • Use a stack to build the expression
  • When encountering an operand, push to stack
  • When encountering an operator, pop two operands and combine
  • Add parentheses to maintain correct order
  1. Prefix to Infix:
  • Convert to postfix first by reversing
  • Then convert postfix to infix
  • This maintains the correct operator precedence

Time and Space Complexity

  • Time Complexity: O(n) where n is the length of the expression
  • Space Complexity: O(n) for the stack and output storage

Example Step-by-Step Conversion

Let’s convert “(A + B) * C” to postfix:

  1. Initial stack: []
  2. Process ’(’: [ ’(’ ]
  3. Process ‘A’: Output = [‘A’]
  4. Process ’+’: [ ’(’ , ’+’ ]
  5. Process ‘B’: Output = [‘A’, ‘B’]
  6. Process ’)’: Output = [‘A’, ‘B’, ’+’]
  7. Process '': [ ’(’ , ’+’ , '' ]
  8. Process ‘C’: Output = [‘A’, ‘B’, ’+’, ‘C’]
  9. Final stack pop: Output = [‘A’, ‘B’, ’+’, ‘C’, ’*’]

The final result is “A B + C *”, which represents the same expression in postfix notation.

Link to original

Unpublished note
Recursion

Recursion

Recursion in Python

Types of Recursion

  • Direct Recursion: Function calls itself directly.
  • Indirect Recursion: Function calls another function which in turn calls the original function.
  • Tail Recursion: Recursive call is the last operation in the function (not optimized in Python).
  • Head Recursion: Recursive call is the first operation in the function.
  • Tree Recursion: Function calls itself more than once per call (e.g., Fibonacci).

Detailed Notes

  • Recursion is a method of solving a problem where the solution depends on solving smaller instances of the same problem.
  • A recursive function calls itself until it reaches a base case, which stops the recursion.
  • Every recursive function must have:
    • Base Case: Condition that ends recursion.
    • Recursive Case: The function calls itself with a modified argument.

Basic Recursive Function Example

def factorial(n):
    if n == 0:  # base case
        return 1
    else:
        return n * factorial(n - 1)  # recursive call

Visual Flow for

factorial(3)

factorial(3)
→ 3 * factorial(2)
→ 3 * (2 * factorial(1))
→ 3 * (2 * (1 * factorial(0)))
→ 3 * 2 * 1 * 1 = 6

Fibonacci Series (Tree Recursion)

def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

Indirect Recursion Example

def funcA(x):
    if x > 0:
        print("A", x)
        funcB(x - 1)
 
def funcB(x):
    if x > 0:
        print("B", x)
        funcA(x // 2)
 
funcA(5)

Key Points

  • Recursion is elegant but may cause performance issues if not handled properly.
  • Python has a recursion limit (default: 1000), set using sys.setrecursionlimit(limit).
  • Recursive solutions are often easier to write for problems like tree traversal, factorial, Fibonacci, etc.
  • Every recursion can be rewritten iteratively.

Advance Tips and Best Practices

  • Always define a base case to avoid infinite recursion.
  • Use memoization to optimize overlapping subproblems (e.g., in Fibonacci).
# Memoization example
memo = {}
def fib(n):
    if n in memo:
        return memo[n]
    if n <= 1:
        memo[n] = n
    else:
        memo[n] = fib(n - 1) + fib(n - 2)
    return memo[n]
  • Tail recursion is not optimized in Python due to lack of tail-call optimization.
  • Avoid deep recursion for large input sizes — use iteration instead or increase the recursion depth carefully.
  • Debugging recursion can be tricky; use print statements or visualizers to trace.

Relevant Images

Recursive Function Structure

Fibonacci Recursion Tree

Stack Representation in Recursion


References

Link to original

Dynamic Programming

Dynamic Programming

Dynamic Programming (DP) is a powerful algorithmic technique that optimizes recursive solutions by storing and reusing solutions to subproblems 0:0. Let’s explore it through practical examples and detailed explanations.

Core Concepts

  1. Key Properties:

    • Optimization over plain recursion
    • Storage of subproblem solutions
    • Reduces exponential time complexity to polynomial
    • Requires overlapping subproblems and optimal substructure
  2. Two Main Approaches 0:1:

    • Top-down (Memoization): Recursive approach with memoization
    • Bottom-up (Tabulation): Iterative solution building from smaller problems

Let’s visualize how Dynamic Programming works with a simple example:

flowchart LR
    subgraph "Dynamic Programming Process"
        direction TB
        
        P["Problem"] --> B["Break Down"]
        
        subgraph "Storage Strategy"
            direction LR
            M["Memoization
            • Top-down
            • Store as needed
            • O(n) Space"] 
            T["Tabulation
            • Bottom-up
            • Pre-fill table
            • O(n) Space"]
        end
        
        B --> M
        B --> T
        
        M --> S["Store Solutions"]
        T --> S
        
        S --> C["Combine Results"]
        C --> F["Final Solution"]
    end
    
    style P fill:#f9f,stroke:#333,color:#000
    style F fill:#9f9,stroke:#333,color:#000
    style M fill:#ff9,stroke:#333,color:#000
    style T fill:#ff9,stroke:#333,color:#000

The diagram above illustrates the DP process:

  • Start with a problem and break it into smaller subproblems
  • Choose either memoization (top-down) or tabulation (bottom-up) approach
  • Store solutions to avoid recalculating
  • Combine stored solutions to reach the final answer

Let’s implement these approaches with a classic example - calculating Fibonacci numbers.

Memoization Approach (Top-down)```python

def fib_memo(n, memo=None):
    if memo is None:
        memo = {}
    
    # Base cases
    if n <= 1:
        return n
    
    # Check if already calculated
    if n in memo:
        return memo[n]
    
    # Calculate and store
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]
 
# Example usage
n = 5
result = fib_memo(n)
print(f"Fibonacci({n}) = {result}")  # Output: 5
  • Follows natural recursive thinking
  • Only calculates needed values
  • Easy to understand and implement
  • Recursive overhead
  • Stack space usage for recursion

Memoization stores solutions as they’re needed during recursive calls 0:2. Before calculating a value, we first check if it exists in our memo dictionary. If it does, we reuse it; otherwise, we calculate and store it. This prevents redundant calculations that occur in naive recursion. Tabulation Approach (Bottom-up)```python

def fib_tabu(n):
    # Create dp array
    dp = [0] * (n + 1)
    
    # Base cases
    dp[0] = 0
    dp[1] = 1
    
    # Fill dp array iteratively
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    
    return dp[n]
 
# Example usage
n = 5
result = fib_tabu(n)
print(f"Fibonacci({n}) = {result}")  # Output: 5
  • No recursive overhead
  • Predictable memory usage
  • Often more efficient in practice
  • May calculate unnecessary values
  • Requires more upfront memory

Tabulation builds the solution from bottom-up 0:3, starting with base cases and filling up a table iteratively. Each entry dp[i] represents the solution to the ith subproblem. This approach avoids recursion entirely and can be more efficient for larger inputs.

Space Optimization

Many DP problems can be optimized further by reducing space complexity. Here’s a space-efficient Fibonacci implementation:

def fib_optimized(n):
    if n <= 1:
        return n
    
    prev_prev = 0
    prev = 1
    
    for _ in range(2, n + 1):
        curr = prev_prev + prev
        prev_prev = prev
        prev = curr
    
    return curr
 
# Example usage
n = 5
result = fib_optimized(n)
print(f"Fibonacci({n}) = {result}")  # Output: 5

Common Dynamic Programming Problems

Here are some classic problems that demonstrate DP principles 2:0:

  1. Longest Common Subsequence (LCS) - Find longest sequence appearing in two strings
  • Used in diff utilities and text comparison
  1. Knapsack Problem - Maximize value while respecting weight constraints
  • Essential in resource allocation problems
  1. Shortest Path Problems - Bellman-Ford Algorithm
  • Floyd Warshall Algorithm
  1. Matrix Chain Multiplication - Optimize matrix multiplication sequence
  • Important in linear algebra computations

Best Practices

  1. Problem Identification:

    • Look for overlapping subproblems
    • Verify optimal substructure property
    • Consider recursive solution first
  2. Implementation Tips:

    • Choose memoization when:
    • Not all subproblems are needed
    • Recursive structure is natural
  • Choose tabulation when:
    • All subproblems are needed
    • Iterative solution is clearer
  1. Performance Considerations:
    • Time Complexity: Usually O(n) or O(n²)
    • Space Complexity: Can often be optimized
    • Consider trade-offs between approaches

Dynamic Programming is primarily a tool for optimizing recursive solutions 3:0. Focus on identifying the recursive relationship first, then apply DP techniques to optimize it.

Link to original

Longest Common Subsequence

Longest Common Subsequence

Building on our previous discussion of Dynamic Programming, let’s explore the Longest Common Subsequence (LCS) problem in detail. LCS finds the longest sequence that appears in two strings, though characters don’t need to appear consecutively.

Let’s visualize how LCS works with two example strings:

flowchart TD
    subgraph "String Comparison"
        direction TB
        
        S1["String 1: AGGTAB"]
        S2["String 2: GXTXAYB"]
        
        subgraph "DP Matrix Construction"
            direction TB
            
            M1["Initialize Matrix:
            [ ][A][G][G][T][A][B]
            [ ] 0  0  0  0  0  0
            [G] 0  0  1  1  1  1
            [X] 0  0  0  0  0  0
            [T] 0  0  0  1  2  2
            [X] 0  0  0  0  0  0
            [A] 0  1  1  1  2  2
            [Y] 0  0  0  0  0  0
            [B] 0  0  0  0  2  3"]
            
            M2["Final Result:
            LCS Length = 4
            Sequence: GTAB"]
        end
        
        S1 --> M1
        S2 --> M1
        M1 --> M2
    end
    
    style M1 text-align:left
    style M2 text-align:left

In the diagram above:

  • Each cell represents the length of LCS up to that point
  • Numbers increase when matching characters are found (like ‘G’, ‘T’, ‘A’, ‘B’)
  • The final result ‘GTAB’ comes from tracing back through the highest values

Now let’s implement this algorithm using both approaches:

Basic Implementation with Matrix Visualization

def lcs(str1, str2):
    m, n = len(str1), len(str2)
    # Create DP matrix
    dp = [[0] * (n + 1) for _ in range(m + 1)]
    
    # Fill the matrix
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if str1[i-1] == str2[j-1]:
                dp[i][j] = dp[i-1][j-1] + 1
            else:
                dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
    # Backtrack to find sequence
    lcs_str = []
    i, j = m, n
    while i > 0 and j > 0:
        if str1[i-1] == str2[j-1]:
            lcs_str.append(str1[i-1])
            i -= 1
            j -= 1
        elif dp[i-1][j] > dp[i][j-1]:
            i -= 1
        else:
            j -= 1
            
    return ''.join(reversed(lcs_str)), dp
 
# Example usage
	str1 = "AGGTAB"
	str2 = "GXTXAYB"
	sequence, matrix = lcs(str1, str2)
	print(f"LCS: {sequence}")  # Output: GTAB
  • Clear visualization of DP matrix
  • Includes backtracking for actual sequence
  • Easy to understand and debug
  • Uses O(mn) space
  • Matrix printing adds complexity

This implementation creates a dynamic programming matrix where each cell dp[i][j] represents the length of LCS up to index i in str1 and j in str2. When characters match, we increment the diagonal value; otherwise, we take the maximum from above or left cells.

Space-Optimized Implementation```python

def lcs_optimized(str1, str2):
    m, n = len(str1), len(str2)
    # Use two rows instead of full matrix
    prev_row = [0] * (n + 1)
    curr_row = [0] * (n + 1)
    
    # Fill rows iteratively
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            if str1[i-1] == str2[j-1]:
                curr_row[j] = prev_row[j-1] + 1
            else:
                curr_row[j] = max(prev_row[j], curr_row[j-1])
        prev_row, curr_row = curr_row, prev_row
    
    # Backtrack using last row
    lcs_str = []
    i, j = m, n
    while i > 0 and j > 0:
        if str1[i-1] == str2[j-1]:
            lcs_str.append(str1[i-1])
            i -= 1
            j -= 1
        elif prev_row[j] > prev_row[j-1]:
            i -= 1
        else:
            j -= 1
            
    return ''.join(reversed(lcs_str))
 
# Example usage
str1 = "AGGTAB"
str2 = "GXTXAYB"
sequence = lcs_optimized(str1, str2)
print(f"LCS: {sequence}")  # Output: GTAB
  • Uses O(n) space instead of O(mn)
  • More memory-efficient for long strings
  • Maintains same time complexity
  • Harder to visualize the DP process
  • Backtracking logic is more complex

This optimized version uses two rows instead of a full matrix, reducing space complexity from O(mn) to O(n). It achieves this by only keeping track of the previous row’s values, swapping rows after each iteration.

Time and Space Complexity Analysis

ImplementationTime ComplexitySpace Complexity
BasicO(mn)O(mn)
OptimizedO(mn)O(n)

Common Variations

  1. Shortest Common Supersequence - Find shortest string containing both sequences
  • Similar approach but finds minimum insertions
  1. Edit Distance - Measure minimum operations to transform one string to another
  • Includes insertions, deletions, and substitutions
  1. Multiple Sequence Alignment - Extend LCS to three or more sequences
  • Used in bioinformatics for DNA/RNA alignment

Best Practices

  1. Matrix Initialization - Always initialize with zeros
  • Consider edge cases (empty strings)
  • Use proper indexing (start from 1 or 0)
  1. Backtracking Tips - Store matrix values for reconstruction
  • Consider bidirectional tracing for optimal solutions
  • Handle ties consistently
  1. Space Optimization - Only store necessary rows/columns
  • Consider trade-offs between space and readability
  • Profile memory usage for large inputs

LCS is a classic example of Dynamic Programming where overlapping subproblems and optimal substructure properties naturally emerge 2:0. The solution builds upon smaller subproblems to construct the final sequence efficiently.

Link to original

Longest Increasing Subsequence

Longest Increasing Subsequence

Building on our previous discussions of Dynamic Programming, let’s explore the Longest Increasing Subsequence (LIS) problem. Given an array of integers, we need to find the longest sequence where each element is greater than its previous elements.

Let’s visualize how LIS works with an example array:

flowchart TD
    subgraph "Array Processing"
        direction TB
        
        A["Input Array: [10, 22, 9, 33, 21, 50, 41, 60, 80]"]
        
        subgraph "DP Matrix Construction"
            direction TB
            
            M1["Initialize dp array:
            [1, 1, 1, 1, 1, 1, 1, 1, 1]"]
            
            M2["After processing:
            [1, 2, 1, 3, 2, 4, 3, 5, 6]"]
            
            M3["Final Result:
            LIS Length = 6
            Sequence: [10, 22, 33, 50, 60, 80]"]
        end
        
        A --> M1
        M1 --> M2
        M2 --> M3
    end
    
    style M1 text-align:left
    style M2 text-align:left
    style M3 text-align:left

In the diagram above:

  • Each cell represents the length of LIS ending at that position
  • Numbers increase when we find a larger number that can extend our current sequence
  • The final result [10, 22, 33, 50, 60, 80] comes from tracing back through the highest values

Let’s implement this algorithm using different approaches:

Basic Implementation with Matrix Visualization```python

def lis_basic(arr):
    n = len(arr)
    # Create DP matrix
    dp = [[num] for num in arr]
    
    # Fill the matrix
    for i in range(1, n):
        for j in range(i):
            if arr[i] > arr[j] and len(dp[j]) + 1 > len(dp[i]):
                dp[i] = dp[j] + [arr[i]]
    
    # Find longest sequence
    return max(dp, key=len)
 
# Example usage
arr = [10, 22, 9, 33, 21, 50, 41, 60, 80]
sequence = lis_basic(arr)
print(f"LIS: {sequence}")  # Output: [10, 22, 33, 50, 60, 80]
  • Clear visualization of DP process
  • Stores actual sequences instead of lengths
  • Easy to understand and debug
  • Uses O(n²) space
  • Less efficient for large arrays

This implementation creates a dynamic programming array where each cell contains the actual increasing sequence ending at that position. When we find a larger number that can extend our current sequence, we create a new longer sequence. Optimized Binary Search Implementation```python from bisect import bisect_left

def lis_optimized(arr):
    if not arr:
        return []
        
    # Store smallest tail of each subsequence length
    tails = [arr[0]]
    # Store indices for reconstruction
    indices = {arr[0]: 0}
    # Previous element index for reconstruction
    prev_idx = {0: -1}
    
    for i in range(1, len(arr)):
        if arr[i] < tails[0]:
            tails[0] = arr[i]
            indices[arr[i]] = i
            prev_idx[i] = -1
        elif arr[i] > tails[-1]:
            tails.append(arr[i])
            indices[arr[i]] = len(tails) - 1
            prev_idx[i] = indices[tails[-2]]
        else:
            # Find position to replace
            pos = bisect_left(tails, arr[i])
            tails[pos] = arr[i]
            indices[arr[i]] = pos
            prev_idx[i] = indices[tails[pos-1]]
    
    # Reconstruct sequence
    result = []
    curr_idx = max(prev_idx.keys())
    while curr_idx != -1:
        result.append(arr[curr_idx])
        curr_idx = prev_idx[curr_idx]
        
    return list(reversed(result))
 
# Example usage
arr = [10, 22, 9, 33, 21, 50, 41, 60, 80]
sequence = lis_optimized(arr)
print(f"LIS: {sequence}")  # Output: [10, 22, 33, 50, 60, 80]
  • Uses O(n) space
  • More efficient for large arrays
  • Maintains sequence reconstruction
  • More complex implementation
  • Harder to visualize the DP process

This optimized version uses binary search to find positions efficiently and maintains only the smallest tail element for each subsequence length. It achieves O(n log n) time complexity while reducing space usage significantly.

Time and Space Complexity Analysis

ImplementationTime ComplexitySpace Complexity
BasicO(n²)O(n²)
OptimizedO(n log n)O(n)

Common Variations

  1. Longest Decreasing Subsequence - Similar approach but looks for decreasing sequences
  • Used in stock price analysis
  1. Longest Increasing Subsequence with Constraints - Add constraints like minimum gap between elements
  • Used in scheduling and resource allocation
  1. Multiple Dimensional LIS - Extend to arrays with multiple dimensions
  • Used in data analysis and pattern recognition

Best Practices

  1. Implementation Guidelines - Always initialize dp array properly
  • Consider edge cases (empty arrays)
  • Profile performance for large inputs
  1. Space Optimization Tips - Store only necessary information
  • Consider trade-offs between space and readability
  • Use binary search for position finding
  1. Sequence Reconstruction - Maintain indices for backtracking
  • Handle ties consistently
  • Consider bidirectional tracing for optimal solutions

LIS is a classic Dynamic Programming problem that demonstrates both overlapping subproblems and optimal substructure properties 2:0. The solution builds upon smaller subproblems to construct the final sequence efficiently.

Link to original

Unpublished note
Edit Distance

Edit Distance

The Edit Distance (or Levenshtein Distance) problem calculates the minimum number of operations needed to transform one string into another. Let’s visualize how this works with a concrete example:

flowchart TD
    subgraph "Edit Distance Matrix"
        direction TB
        
        M1["Initialize Matrix:
        [ ][r][o][s]
        [h] 0  1  2
        [o] 1  0  1
        [r] 2  1  1
        [s] 3  2  2
        [e] 4  3  3"]
        
        M2["Final Result:
        Edit Distance = 4
        Transformations:
        horse → rorse (replace 'h' with 'r')
        rorse → rose (remove 'r')
        rose → ros (remove 'e')"]
    end
    
    style M1 text-align:left
    style M2 text-align:left

In the diagram above:

  • Each cell represents the minimum operations needed to transform the substring of word1 up to row i into the substring of word2 up to column j
  • The final result of 4 represents the minimum operations needed to transform “horse” into “ros”
  • The transformations show how we can achieve this minimum distance through a series of operations

Let’s examine the implementation in detail:

Dynamic Programming Implementationpython

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        m, n = len(word1), len(word2)
        dp = [[0] * (n+1) for _ in range(m+1)]
        
        # Initialize base cases
        for i in range(m+1):
            dp[i][0] = i  # Cost of deleting i characters
        for j in range(n+1):
            dp[0][j] = j  # Cost of inserting j characters
            
        # Fill the DP matrix
        for i in range(1, m+1):
            for j in range(1, n+1):
                if word1[i-1] == word2[j-1]:
                    dp[i][j] = dp[i-1][j-1]  # No operation needed
                else:
                    dp[i][j] = min(
                        dp[i-1][j],    # deletion
                        dp[i][j-1],    # insertion
                        dp[i-1][j-1]   # replacement
                    ) + 1
        
        return dp[m][n]
  • Clear visualization of the DP process
  • Handles all three operations (insert, delete, replace)
  • Efficient O(mn) time complexity
  • Uses O(mn) space
  • May be harder to understand for beginners

This implementation uses a dynamic programming matrix where each cell dp[i][j] represents the minimum operations needed to transform the first i characters of word1 into the first j characters of word2. The base cases handle empty string transformations, and the main loop considers all possible operations at each step. Memoization Approachpython

class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        memo = {}
        
        def dfs(i, j):
            if (i, j) in memo:
                return memo[(i, j)]
            if i == 0:
                return j
            if j == 0:
                return i
            
            if word1[i-1] == word2[j-1]:
                result = dfs(i-1, j-1)
            else:
                result = 1 + min(
                    dfs(i-1, j),    # deletion
                    dfs(i, j-1),    # insertion
                    dfs(i-1, j-1)   # replacement
                )
            
            memo[(i, j)] = result
            return result
        
        return dfs(len(word1), len(word2))
  • More intuitive recursive approach
  • Avoids explicit matrix creation
  • Still O(mn) time complexity
  • Higher space usage due to recursion stack
  • Slightly more complex implementation

This approach uses memoization to store the results of subproblems instead of building a matrix. It recursively explores all possible operations while avoiding redundant calculations through the memo dictionary.

Time and Space Complexity

ImplementationTime ComplexitySpace Complexity
Dynamic ProgrammingO(mn)O(mn)
MemoizationO(mn)O(mn)

Common Applications

  1. Text Processing - Spell checkers (“did you mean…“)
  • Plagiarism detection
  • Document comparison
  1. Data Analysis - DNA sequence alignment
  • Pattern matching
  • Data validation
  1. User Interface - Autocomplete suggestions
  • Search result ranking
  • Error correction

Best Practices

  1. Implementation Tips - Always initialize base cases properly
  • Consider using memoization for recursive approaches
  • Profile performance for large inputs
  1. Space Optimization - Consider using iterative approach for large strings
  • Only store necessary intermediate results
  • Use appropriate data structures for memoization
  1. Error Handling - Validate input strings
  • Handle edge cases (empty strings)
  • Consider case sensitivity requirements

The Edit Distance problem demonstrates key Dynamic Programming principles 6:0: breaking down complex problems into smaller subproblems, solving each only once, and storing solutions to avoid redundant calculations.

Link to original

Dice Rolls with TargetSum

Dice Rolls with TargetSum

Let’s explore how Dynamic Programming can be applied to dice rolling problems. We’ll start with a simple example where we need to find the number of ways to reach a target sum using dice rolls.

flowchart TD
    subgraph "DP Matrix Construction"
        direction TB
        
        M1["Initialize dp array:
        [0, 0, 0, 0, 0, 0, 0]
        [1, 0, 0, 0, 0, 0, 0]
        [0, 1, 0, 0, 0, 0, 0]
        [0, 0, 1, 0, 0, 0, 0]
        [0, 0, 0, 1, 0, 0, 0]
        [0, 0, 0, 0, 1, 0, 0]
        [0, 0, 0, 0, 0, 1, 0]"]
        
        M2["After processing:
        [0, 0, 0, 0, 0, 0, 0]
        [1, 0, 0, 0, 0, 0, 0]
        [0, 1, 0, 0, 0, 0, 0]
        [0, 0, 1, 0, 0, 0, 0]
        [0, 0, 0, 1, 0, 0, 0]
        [0, 0, 0, 0, 1, 0, 0]
        [0, 0, 0, 0, 0, 1, 0]"]
        
        M3["Final Result:
        Ways to reach 6 = 1
        Sequence: [1, 1, 1, 1, 1, 1]"]
    end
    
    style M1 text-align:left
    style M2 text-align:left
    style M3 text-align:left

In the diagram above:

  • Each cell represents the number of ways to reach that sum using dice rolls
  • The first row initializes with 1 way to reach sum 1 (rolling a 1)
  • Each subsequent row builds upon previous possibilities
  • The final result shows there’s exactly one way to reach sum 6 using six 1’s

Let’s implement this algorithm using different approaches:

Basic Implementation with Matrix Visualizationpython

def numRollsToTarget(d: int, f: int, target: int) -> int:
    MOD = 10**9 + 7
    n = target + 1
    # Create DP matrix
    dp = [[0] * n for _ in range(d + 1)]
    
    # Base case: one die
    for j in range(1, min(f + 1, n)):
        dp[1][j] = 1
    
    # Fill the matrix
    for i in range(2, d + 1):
        for j in range(1, n):
            for k in range(1, min(f + 1, j)):
                dp[i][j] = (dp[i][j] + dp[i-1][j-k]) % MOD
    
    return dp[d][target]
 
# Example usage
d = 6  # number of dice
f = 6  # faces per die
target = 6
result = numRollsToTarget(d, f, target)
print(f"Ways to reach {target} with {d}d{f}: {result}")
  • Clear visualization of DP process
  • Handles modulo arithmetic for large numbers
  • Easy to understand and debug
  • Uses O(d*target) space
  • May be slow for very large inputs

This implementation creates a dynamic programming matrix where each cell dp[i][j] represents the number of ways to reach sum j using i dice. The base case handles one die, and each subsequent row builds upon previous possibilities. Space-Optimized Implementationpython

def numRollsToTarget(d: int, f: int, target: int) -> int:
    MOD = 10**9 + 7
    # Only keep two rows: current and previous
    prev_row = [0] * (target + 1)
    curr_row = [0] * (target + 1)
    
    # Base case
    for j in range(1, min(f + 1, target + 1)):
        prev_row[j] = 1
    
    # Fill rows iteratively
    for i in range(2, d + 1):
        for j in range(1, target + 1):
            for k in range(1, min(f + 1, j + 1)):
                curr_row[j] = (curr_row[j] + prev_row[j-k]) % MOD
        prev_row, curr_row = curr_row, prev_row
    
    return prev_row[target]
 
# Example usage
d = 6  # number of dice
f = 6  # faces per die
target = 6
result = numRollsToTarget(d, f, target)
print(f"Ways to reach {target} with {d}d{f}: {result}")
  • Uses O(target) space instead of O(d*target)
  • More memory-efficient for large number of dice
  • Maintains same time complexity
  • More complex implementation
  • Harder to visualize the DP process

This optimized version uses only two rows instead of a full matrix, reducing space complexity significantly. It achieves this by only keeping track of the previous row’s values, which is all we need to calculate the next row.

Time and Space Complexity

ImplementationTime ComplexitySpace Complexity
BasicO(dftarget)O(d*target)
OptimizedO(dftarget)O(target)

Common Variations

  1. Probability Calculations - Find probability of reaching target sum
  • Calculate expected value of rolls
  • Determine most likely outcomes
  1. Constrained Problems - Maximum/minimum sum constraints
  • Limited number of certain faces
  • Specific dice combinations
  1. Advanced Applications - Risk assessment in games
  • Statistical analysis
  • Game theory applications

Best Practices

  1. Implementation Guidelines - Always handle edge cases (zero dice, impossible targets)
  • Use modulo arithmetic for large numbers
  • Consider space optimization for large inputs
  1. Performance Optimization - Use iterative approach instead of recursive
  • Implement space optimization when possible
  • Profile performance for large inputs
  1. Error Handling - Validate input parameters
  • Check for impossible combinations
  • Handle overflow cases appropriately

Dice rolling problems demonstrate key Dynamic Programming principles 6:0: breaking down complex probability calculations into smaller subproblems, solving each only once, and storing solutions to avoid redundant calculations.

Link to original

Matrix Chain Multiplication

Matrix Chain Multiplication

The Matrix Chain Multiplication problem is a classic dynamic programming challenge that involves finding the optimal way to multiply a sequence of matrices while minimizing the number of scalar multiplications required. Let’s understand this problem thoroughly with visualizations and practical demonstrations.

Problem Understanding

Consider a chain of matrices where each matrix Ai has dimensions (arr[i-1] × arr[i]). When multiplying matrices, we must ensure that the number of columns in the first matrix matches the number of rows in the second matrix. For example, matrices of dimensions 30×35 and 35×15 can be multiplied because the column count (35) matches the row count.

Let’s visualize this concept with a practical example:

flowchart LR
    subgraph "Matrix Chain Multiplication Example"
        M1["Matrix A₁
        30×35"] --> |"First"| INT["Intermediate Result
        30×15"]
        INT --> |"Second"| M2["Matrix A₂
        35×15"]
        
        style M1 fill:#f9f,stroke:#333,color:#000
        style M2 fill:#bbf,stroke:#333,color:#000
        style INT fill:#ff9,stroke:#333,color:#000
        
        note["Total Operations:
        30 × 35 × 15 = 15,750"]
        
        style note fill:#fff,stroke:#333,color:#000,text-align:left
    end

In this diagram, we’re showing how matrices are multiplied in sequence. When multiplying a 30×35 matrix by a 35×15 matrix, the middle dimension (35) cancels out, resulting in a final 30×15 matrix. The total number of operations (15,750) comes from multiplying these three numbers together.

Implementation

Now, let’s look at how we solve this problem using dynamic programming. Here’s an interactive implementation that visualizes the DP table construction:

def matrix_chain_multiplication(arr):
    n = len(arr)
    dp = [[0 for _ in range(n)] for _ in range(n)]
    
    print("Initial DP table:")
    print_table(dp, arr)
    
    # Fill dp table
    for length in range(2, n):  # length of chain
        print(f"\nProcessing chains of length {length}:")
        for i in range(1, n - length + 1):
            j = i + length - 1
            dp[i][j] = float('inf')
            
            print(f"\nFinding optimal split for matrices {i}-{j}:")
            for k in range(i, j):
                cost = dp[i][k] + dp[k+1][j] + arr[i-1]*arr[k]*arr[j]
                if cost < dp[i][j]:
                    dp[i][j] = cost
                    print(f"Split at k={k}: Cost = {cost}")
            
            print(f"Minimum cost for matrices {i}-{j}: {dp[i][j]}")
    
    print("\nFinal DP table:")
    print_table(dp, arr)
    return dp[1][n-1]
 
def print_table(dp, arr):
    n = len(arr)
    print("     ", end="")
    for i in range(n):
        print(f"A{i+1}", end="\t")
    print("\n   ", end="")
    print("-" * 50)
    
    for i in range(n):
        print(f"A{i+1}", end=" ")
        for j in range(n):
            if dp[i][j] == float('inf'):
                print("∞", end="\t")
            else:
                print(dp[i][j], end="\t")
        print()
 
# Example 1
arr = [40, 20, 30, 10, 30]
print("Example 1:", arr)
result = matrix_chain_multiplication(arr)
print(f"\nMinimum number of scalar multiplications required: {result}")
 
# Example 2
arr = [1, 2, 3, 4, 3]
print("\nExample 2:", arr)
result = matrix_chain_multiplication(arr)
print(f"\nMinimum number of scalar multiplications required: {result}")

Example 1: [40, 20, 30, 10, 30]

Initial DP table: A1 A2 A3 A4 A5

A1 0 0 0 0 0 A2 0 0 0 0 0 A3 0 0 0 0 0 A4 0 0 0 0 0 A5 0 0 0 0 0

Processing chains of length 2:

Finding optimal split for matrices 1-2: Split at k=1: Cost = 24000 Minimum cost for matrices 1-2: 24000

Finding optimal split for matrices 2-3: Split at k=2: Cost = 6000 Minimum cost for matrices 2-3: 6000

Finding optimal split for matrices 3-4: Split at k=3: Cost = 9000 Minimum cost for matrices 3-4: 9000

Processing chains of length 3:

Finding optimal split for matrices 1-3: Split at k=1: Cost = 14000 Minimum cost for matrices 1-3: 14000

Finding optimal split for matrices 2-4: Split at k=2: Cost = 27000 Split at k=3: Cost = 12000 Minimum cost for matrices 2-4: 12000

Processing chains of length 4:

Finding optimal split for matrices 1-4: Split at k=1: Cost = 36000 Split at k=3: Cost = 26000 Minimum cost for matrices 1-4: 26000

Final DP table: A1 A2 A3 A4 A5

A1 0 0 0 0 0 A2 0 0 24000 14000 26000 A3 0 0 0 6000 12000 A4 0 0 0 0 9000 A5 0 0 0 0 0

Minimum number of scalar multiplications required: 26000

Example 2: [1, 2, 3, 4, 3]

Initial DP table: A1 A2 A3 A4 A5

A1 0 0 0 0 0 A2 0 0 0 0 0 A3 0 0 0 0 0 A4 0 0 0 0 0 A5 0 0 0 0 0

Processing chains of length 2:

Finding optimal split for matrices 1-2: Split at k=1: Cost = 6 Minimum cost for matrices 1-2: 6

Finding optimal split for matrices 2-3: Split at k=2: Cost = 24 Minimum cost for matrices 2-3: 24

Finding optimal split for matrices 3-4: Split at k=3: Cost = 36 Minimum cost for matrices 3-4: 36

Processing chains of length 3:

Finding optimal split for matrices 1-3: Split at k=1: Cost = 32 Split at k=2: Cost = 18 Minimum cost for matrices 1-3: 18

Finding optimal split for matrices 2-4: Split at k=2: Cost = 54 Split at k=3: Cost = 48 Minimum cost for matrices 2-4: 48

Processing chains of length 4:

Finding optimal split for matrices 1-4: Split at k=1: Cost = 54 Split at k=2: Cost = 51 Split at k=3: Cost = 30 Minimum cost for matrices 1-4: 30

Final DP table: A1 A2 A3 A4 A5

A1 0 0 0 0 0 A2 0 0 6 18 30 A3 0 0 0 24 48 A4 0 0 0 0 36 A5 0 0 0 0 0

Minimum number of scalar multiplications required: 30

Complexity Analysis

The algorithm has the following complexities:

Time Complexity: O(n³)

  • Outer loop runs n times (chain length)
  • Middle loop runs n times (start position)
  • Inner loop runs n times (split point)

Space Complexity: O(n²)

  • We need an n×n DP table to store intermediate results
  • Each cell stores the minimum cost for a subproblem

Notes

  1. Key Insights:

    • The solution uses bottom-up dynamic programming
    • Smaller subproblems are solved first
    • Results are stored in the DP table to avoid recalculations
  2. Implementation Tips:

    • Initialize diagonal elements to 0 (no multiplication needed)
    • Process chains of increasing length
    • Try all possible split points for optimal solution
  3. Practical Applications:

    • Optimizing matrix multiplication in linear algebra computations
    • Reducing computational cost in machine learning algorithms
    • Improving performance in scientific computing applications

The interactive visualization above demonstrates how the DP table builds up systematically, showing the optimal splits at each stage and the final minimum cost calculation. You can experiment with different matrix dimensions to understand how the solution adapts to various problem sizes.

Link to original

Knight Probability Problem

Knight Probability Problem

The Knight Probability Problem involves calculating the likelihood of a knight remaining on an 8×8 chessboard after making K random moves. Let’s understand this through visualization and implementation.

flowchart LR
    subgraph "Knight Movement Pattern"
        K((K)) --- |"+2,+1"| P1["•"]
        K --- |"+1,+2"| P2["•"]
        K --- |"-1,+2"| P3["•"]
        K --- |"-2,+1"| P4["•"]
        K --- |"-2,-1"| P5["•"]
        K --- |"-1,-2"| P6["•"]
        K --- |"+1,-2"| P7["•"]
        K --- |"+2,-1"| P8["•"]
        
        style K fill:#ff9999,stroke:#333,color:#000
        style P1,P2,P3,P4,P5,P6,P7,P8 fill:#99ff99,stroke:#333,color:#000
    end
    
    subgraph "Board Boundaries"
        direction TB
        B["• • • • • • • •
        • • • • • • • •
        • • • • • • • •
        • • • • • • • •
        • • • • • • • •
        • • • • • • • •
        • • • • • • • •
        • • • • • • • •"]
        
        style B text-align:left,color:#666
    end
    
    style P1,P2,P3,P4,P5,P6,P7,P8 text-align:center,color:#000
    style K text-align:center,color:#000

In the diagram above:

  • The red dot (K) shows the knight’s current position
  • Green dots indicate legal moves the knight can make
  • Numbers (+2,+1, etc.) show the change in coordinates for each move
  • The grid represents the 8×8 chessboard boundaries

Let’s implement this solution using dynamic programming. Here’s the complete code with detailed comments:

Implementation

def knight_probability(N, K, x, y):
    # Define knight moves (dx, dy pairs)
    dx = [1, 2, 2, 1, -1, -2, -2, -1]
    dy = [2, 1, -1, -2, -2, -1, 1, 2]
 
    # Create 3D DP array: [moves][rows][columns]
    dp = [[[0.0 for _ in range(N)] for _ in range(N)] for _ in range(K+1)]
    
    # Base case: probability is 1.0 for all positions at move 0
    for i in range(N):
        for j in range(N):
            dp[0][i][j] = 1.0
 
    # Fill DP table move by move
    for step in range(1, K+1):
        for i in range(N):  # Current row
            for j in range(N):  # Current column
                prob = 0.0
                # Check all 8 possible moves
                for move in range(8):
                    ni, nj = i + dx[move], j + dy[move]
                    # Only count moves that stay on board
                    if 0 <= ni < N and 0 <= nj < N:
                        prob += dp[step-1][ni][nj] / 8.0
                dp[step][i][j] = prob
 
    return round(dp[K][x][y], 4)
 
# Example usage
K = int(input())  # Number of moves
x, y = 0, 0       # Starting position (top-left corner)
print(knight_probability(8, K, x, y))

Implementation with visualization

Let’s visualize how the probability changes over moves:

import numpy as np
import matplotlib.pyplot as plt
 
def calculate_knight_probability(N=8, K=3, x=0, y=0):
    """
    Calculate probability of knight staying on board after K moves.
    
    Args:
        N (int): Board size (default 8)
        K (int): Number of moves
        x,y (int): Starting position coordinates
        
    Returns:
        float: Probability of staying on board
    """
    # Define knight moves (dx, dy pairs)
    dx = np.array([1, 2, 2, 1, -1, -2, -2, -1])
    dy = np.array([2, 1, -1, -2, -2, -1, 1, 2])
    
    # Create 3D DP array: [moves][rows][columns]
    dp = np.zeros((K+1, N, N))
    
    # Base case: probability is 1.0 for all positions at move 0
    dp[0] = 1.0
    
    # Fill DP table move by move
    for step in range(1, K+1):
        for i in range(N):  # Current row
            for j in range(N):  # Current column
                prob = 0.0
                # Check all 8 possible moves
                for move in range(8):
                    ni, nj = i + dx[move], j + dy[move]
                    # Only count moves that stay on board
                    if 0 <= ni < N and 0 <= nj < N:
                        prob += dp[step-1][ni][nj] / 8.0
                dp[step][i][j] = prob
                
    return dp[K][x][y]
 
# Calculate probabilities for different starting positions
start_positions = [(0,0), (0,7), (7,0), (7,7)]  # Corners
K_values = range(11)  # Calculate for 0 to 10 moves
results = {pos: [] for pos in start_positions}
 
# Calculate probabilities for each position and number of moves
for k in K_values:
    for pos in start_positions:
        prob = calculate_knight_probability(8, k, pos[0], pos[1])
        results[pos].append(prob)
 
# Create visualization
plt.figure(figsize=(12, 8))
colors = ['blue', 'red', 'green', 'purple']
markers = ['o', 's', '^', 'D']
 
for i, pos in enumerate(start_positions):
    plt.plot(K_values, results[pos], 
             label=f'Start: ({pos[0]},{pos[1]})',
             color=colors[i],
             marker=markers[i],
             markersize=8)
 
plt.grid(True, linestyle='--', alpha=0.7)
plt.xlabel('Number of Moves')
plt.ylabel('Probability of Staying On Board')
plt.title('Knight Probability of Staying On Board vs Number of Moves\nStarting from Different Corner Positions')
 
# Add legend
plt.legend(bbox_to_anchor=(1.05, 1), loc='upper left')
 
# Adjust layout to prevent label cutoff
plt.tight_layout()
 
# Show plot
plt.show()
 
# Print example probabilities
print("\nExample Probabilities:")
print("-" * 50)
for pos in start_positions:
    x, y = pos
    prob_1move = calculate_knight_probability(8, 1, x, y)
    prob_3moves = calculate_knight_probability(8, 3, x, y)
    prob_5moves = calculate_knight_probability(8, 5, x, y)
    
    print(f"\nStarting position ({x},{y}):")
    print(f"After 1 move: {prob_1move:.4f}")
    print(f"After 3 moves: {prob_3moves:.4f}")
    print(f"After 5 moves: {prob_5moves:.4f}")### Key Findings

The visualization and calculations reveal important insights about knight movement probabilities:

  1. Starting Position Impact: - Corner positions (0,0 and 7,7) have lower survival rates due to fewer initial moves
  • Edge positions (0,7 and 7,0) offer better chances initially due to more available moves
  • Symmetrical positions show identical probability patterns
  1. Probability Decay: - Each move significantly reduces the probability of staying on board
  • Corner positions drop to ~6.25% chance after 5 moves
  • Edge positions maintain slightly higher chances (~9.38%) after 5 moves
  1. Practical Implications: - Starting position significantly affects survival probability
  • More moves exponentially decrease the likelihood of staying on board
  • Edge positions offer slightly better survival chances due to more available moves

This dynamic programming solution efficiently calculates these probabilities while demonstrating the strategic importance of position choice in knight movements.

Complexity Analysis

Time Complexity: O(K * N² * 8)

- We iterate through K moves
- For each move, we process N×N positions
- At each position, we check 8 possible moves

Space Complexity: O(K * N²)

- We store probabilities for all positions at each move level
- The DP array dimensions are K × N × N

Example Walkthrough

For K=1 and starting position (0,0):

  1. Initial State (Move 0):
    • All positions have P(0,x,y) = 1.0
  2. After One Move (K=1):
    • From (0,0), only 2 moves stay on board: (1,2) and (2,1)
    • Therefore, P(1,0,0) = (P(0,1,2) + P(0,2,1))/8 = 2/8 = 0.25

This solution efficiently calculates probabilities for any starting position and number of moves, maintaining accuracy through careful tracking of valid positions at each step.

Link to original

Unpublished note
DSA Interview Questions

DSA Interview Questions

1. Arrays


2. Strings

  • Check if two strings are anagrams.
  • Longest Palindromic Substring.
  • Implement strStr() (substring search).
  • Count and say sequence.
  • Longest Common Prefix.
  • Group Anagrams.
  • Minimum number of characters to make string a palindrome.

3. Two Pointers / Sliding Window

  • Pair with a given sum in sorted array.
  • Move zeroes to the end.
  • Container with most water.
  • Longest Substring Without Repeating Characters.
  • Minimum window substring.
  • Substring with at most K distinct characters.

4. Hashing / HashMap

  • Two Sum / Four Sum / Subarray Sum = K.
  • Longest subarray with 0 sum.
  • Find the first non-repeating character.
  • Count distinct elements in every window of size k.
  • Find the intersection of two arrays.

5. Stacks & Queues

  • Implement a stack using two queues / queue using two stacks.
  • Next Greater Element.
  • Valid Parentheses.
  • Min Stack.
  • Evaluate Reverse Polish Notation.
  • Sliding Window Maximum.
  • Decode String.

6. Linked List

  • Reverse a linked list (Iterative & Recursive).
  • Detect and remove loop in linked list.
  • Merge two sorted linked lists.
  • Find the middle of the linked list.
  • Check if linked list is palindrome.
  • Intersection point of two linked lists.
  • Clone a linked list with random pointers.

7. Binary Trees / Binary Search Trees

  • Inorder / Preorder / Postorder traversal (iterative + recursive).
  • Check if tree is symmetric.
  • Lowest Common Ancestor (LCA).
  • Level Order Traversal / Zigzag Level Order.
  • Validate BST.
  • Construct tree from preorder and inorder.
  • Diameter of Binary Tree.
  • Flatten Binary Tree to Linked List.
  • Kth smallest/largest element in BST.

8. Recursion / Backtracking

  • N-Queens Problem.
  • Sudoku Solver.
  • Subsets / Subsets II.
  • Combination Sum.
  • Generate Parentheses.
  • Word Search in matrix.
  • Rat in a Maze / Knight’s tour.

9. Heap / Priority Queue

  • Kth largest/smallest element.
  • Merge K sorted arrays/lists.
  • Top K frequent elements.
  • Find median from data stream.
  • Sliding window maximum using heap.

10. Searching & Sorting

  • Binary Search (Standard and Variations):
    • Find first and last occurrence.
    • Search in rotated sorted array.
    • Find square root / kth root.
  • QuickSort / MergeSort (understanding + implementation).
  • Count Inversions.
  • Median of two sorted arrays.
  • Kth smallest/largest element using quickselect.

11. Greedy Algorithms

  • Activity Selection Problem.
  • Fractional Knapsack.
  • Job Scheduling with Deadlines.
  • Huffman Encoding.
  • Minimum number of platforms required for trains.
  • Gas Station problem.

12. Graphs

  • BFS / DFS traversal.
  • Detect cycle in graph (Undirected/Directed).
  • Topological Sort.
  • Shortest Path (Dijkstra / Bellman-Ford / Floyd-Warshall).
  • Union-Find (Disjoint Set Union – DSU).
  • Number of Islands / Connected Components.
  • Kruskal’s and Prim’s Algorithm for MST.

13. Dynamic Programming (DP)

  • 0/1 Knapsack Problem.
  • Longest Common Subsequence.
  • Longest Increasing Subsequence.
  • Coin Change (min coins, total ways).
  • Edit Distance.
  • Matrix Chain Multiplication.
  • Rod Cutting Problem.
  • Maximum Profit in Job Scheduling.
  • DP on Trees / Grids.

14. Bit Manipulation


15. Mathematics / Number Theory

  • Comparison of two number without comparison operator
  • Sieve of Eratosthenes.
  • GCD / LCM using Euclidean algorithm.
  • Fast Exponentiation.
  • Modular Inverse.
  • Check for prime / count primes.
  • Count trailing zeroes in factorial.

16. BONUS: System-Oriented & Logical Thinking

Even if not strict DSA, occasionally include:

  • LRU Cache Design.
  • Design Twitter / MinStack / Rate Limiter.
  • Implement Trie / Autocomplete.
  • TinyURL Encoding-Decoding.

chatGPT

Link to original

Interview Questions

Interview Questions

Based on Kaushik Shahare’s resume, here is a tailored list of DSA and system design-oriented questions I would ask during a technical interview for an SDE fresher role. The questions are aligned with the candidate’s experience in backend development, cloud deployment, and real-time systems.


General Strategy

I’ll evaluate him on:

  • Problem-solving (core DSA questions)
  • Application of concepts (how DSA fits into real projects)
  • Backend + systems thinking (as shown in AgriConnect & MiniMilitia)
  • Communication and debugging (e.g., usage of logging, CI/CD, etc.)

1. Arrays & Strings

  • Given an array of integers, return the maximum sum of a subarray (Kadane’s Algorithm).
  • Rotate an array to the right by k steps. Implement in-place.
  • Given a string, find the length of the longest substring without repeating characters.

2. Linked Lists

  • Reverse a singly linked list (iterative and recursive).
  • Detect a cycle in a linked list and return the starting node of the cycle.

3. Trees & Graphs

  • Implement level order traversal of a binary tree.

  • Given a graph of n cities and m roads, determine the number of connected components.

  • In a multiplayer game (like Mini-Militia), how would you model users and rooms using graphs?

4. Stacks & Queues

  • Implement a queue using two stacks.
  • Design a system to evaluate a Reverse Polish Notation expression.
  • How would you implement rate limiting using a queue (API throttling scenario)?

5. Hashing

  • Two Sum problem — optimal solution using a hash map.
  • Count the frequency of characters in a string and return the first non-repeating character.

6. Sliding Window / Two Pointer

  • Given a list of integers and a target sum, find the length of the smallest contiguous subarray with a sum ≥ target.

  • Minimum window substring problem.

7. Recursion & Backtracking

  • Solve the N-Queens problem using backtracking.
  • Given a matrix, find if a word exists in it (Word Search problem).

8. Dynamic Programming

  • Longest Increasing Subsequence.
  • 0/1 Knapsack problem.
  • You have a staircase of n steps. Each time you can climb 1 or 2 steps. How many ways can you climb the staircase?

System & Backend Oriented Thinking

Mini-Militia Web Game

  • How did you handle real-time synchronization using WebSockets?
  • What data structures did you use on the server side to manage user state and room state?
  • How would you detect and resolve lag or desync issues?

AgriConnect

  • How did you structure role-based access control in Django?
  • You mentioned “AI-powered diet advisor” — how was this implemented, and how did you manage model inference under load?
  • How would you scale your application if 10,000 farmers and experts use it concurrently?

DevOps / Infra Thinking

  • How does Docker help in backend deployments?
  • What is the difference between a process and a thread?
  • How would you monitor a microservice architecture in production?
  • What are Prometheus and Grafana used for?

Bonus CS Fundamentals

  • What’s the time complexity of inserting into a hash table?
  • Explain how Redis stores key-value pairs — in what scenarios is it a better choice than a database?
  • How do you handle concurrency issues in backend systems?

Behavioral & Communication

  • Tell me about a bug that took you the longest to fix.
  • How do you decide when to optimize code? Can you share an example from one of your projects?
  • What’s your process when you get stuck on a problem?

chatGPT

Link to original

Unpublished note
Unpublished note
Bit Masking (Subset Generation)

Bit Masking (Subset Generation)

Subset generation using bits (also called bitmasking) is a common and efficient technique to generate all possible subsets (also called the power set) of a given array or list.


Concept:

If an array has n elements, there are 2^n possible subsets. Each subset can be represented by a binary number of n bits:

  • Each bit in the binary number tells whether to include (1) or exclude (0) the corresponding element.

Example:

Given the array:

arr = [1, 2, 3]

There are 2^3 = 8 subsets. We loop from 0 to 7 (inclusive), and for each number, check which bits are set to decide which elements to include.


Code (Python):

def generate_subsets(arr):
    n = len(arr)
    subsets = []
    
    for i in range(1 << n):  # from 0 to 2^n - 1
        subset = []
        for j in range(n):
            if i & (1 << j):  # check if j-th bit in i is set
                subset.append(arr[j])
        subsets.append(subset)
    
    return subsets
 
# Example
arr = [1, 2, 3]
all_subsets = generate_subsets(arr)
for subset in all_subsets:
    print(subset)

Code (CPP):

#include <iostream>
#include <vector>
using namespace std;
 
void generateSubsets(vector<int>& arr) {
    int n = arr.size();
    int totalSubsets = 1 << n;  // 2^n subsets
 
    for (int mask = 0; mask < totalSubsets; ++mask) {
        vector<int> subset;
        for (int j = 0; j < n; ++j) {
            if (mask & (1 << j)) {
                subset.push_back(arr[j]);
            }
        }
 
        // Print the subset
        cout << "{ ";
        for (int val : subset) {
            cout << val << " ";
        }
        cout << "}" << endl;
    }
}
 
int main() {
    vector<int> arr = {1, 2, 3};
    generateSubsets(arr);
    return 0;
}

Output:

[]
[1]
[2]
[1, 2]
[3]
[1, 3]
[2, 3]
[1, 2, 3]

Time Complexity:

  • O(n * 2^n) — for each of the 2^n subsets, we loop through up to n elements.
Link to original

DSA Patterns

DSA Patterns

  1. Strings & Its Patterns
  • KMP Algorithm
  • Z-Algorithm
  • Rabin-Karp Algorithm
  • Longest Common Subsequence (LCS)
  • Edit Distance
  • Regular Expression Matching
  • Palindrome Problems
  1. Dynamic Programming (DP) & Its Patterns
  • Fibonacci/Simple Recurrence
  • 0/1 Knapsack
  • Unbounded Knapsack
  • Longest Common Subsequence (LCS)
  • Longest Increasing Subsequence (LIS)
  • Grid-Based DP
  • Interval DP
  • Tree DP
  • Bitmasking/State Compression
  • Digit DP
  • Probability/Expectation DP
  1. Graphs & Its Patterns
  • DFS/BFS Traversal
  • Shortest Path Algorithms (Dijkstra, Bellman-Ford, Floyd-Warshall)
  • Topological Sort
  • Cycle Detection
  • Connected Components
  • Minimum Spanning Tree (MST)
  • Union-Find (Disjoint Set Union - DSU)
  • Grid-Based Graph Problems
  • Graph Coloring
  • Strongly Connected Components (SCC)
  • Eulerian & Hamiltonian Paths
  • Planets & Queries (Advanced Graph Problems)
  1. Trees & Its Patterns
  • Distance Between Nodes (LCA, BFS/DFS)
  • Sum of Distances (Rerooting Technique)
  • Subtree Queries (Segment Trees, Heavy-Light Decomposition)
  • Binary Lifting (LCA Optimization)
  • Tree DP (Dynamic Programming on Trees)
  • Path Queries (Prefix Sums, Binary Lifting)
  • Tree Construction (BST, Trie, etc.)
  1. Heap (Priority Queue) & Its Patterns
  • Top K Elements
  • Merge K Sorted Structures
  • Two Heaps for Medians
  • Sliding Window Heaps
  • Greedy Heap Applications
  • Heap-based Game Theory
  1. Linked Lists & Its Patterns
  • Fast and Slow Pointers (Two Pointers)
  • Reversing Linked Lists
  • Merging and Partitioning Lists
  • Dummy Node Technique
  • List Manipulation Operations
  • List Transformations
  • Basic List Operations Implementation

→  Secondary/Supporting Data Structures for interviews

  • Hash Maps / Hash Sets (used in string matching, graph traversals, etc.)
  • Stacks & Queues (used in DFS/BFS, parsing, etc.)
  • Binary Search Trees (BST) (used in some tree-based problems)
  • Trie (Prefix Tree) (used in string matching problems)
  • Segment Trees & Fenwick Trees (used in range queries, subtree problems)
  • Monotonic Stacks/Queues (used in sliding window problems)
Link to original

DSA 90-Day

DSA 90-Day

Phase 1 — Graph Mastery (Day 1–20)

Target: Complete CSES Graph Algorithms + Key LeetCode Graph Hards


Day 1 — BFS & Multi-Source BFS

CSES:

  1. Labyrinth
  2. Message Route

LeetCode:


Day 2 — Dijkstra Basics

CSES:

  1. Shortest Routes I
  2. Flight Discount

LeetCode:


Day 3 — Bellman-Ford & Negative Cycles

CSES:

  1. Shortest Routes II (Floyd-Warshall)
  2. Cycle Finding

LeetCode:


Day 4 — Topological Sorting

CSES:

  1. Course Schedule (Khans ALgorith) - In_degree
  2. Longest Flight Route

LeetCode:


Day 5 — Strongly Connected Components

CSES:

  1. Planets and Kingdoms (Kosaraju)
  2. [] Giant Pizza Solution Link - 2-SAT Problem

LeetCode:


Day 6 — Bridges & Articulation Points

CSES:

  1. Flight Routes Check
  2. Road Construction (DSU)

LeetCode:


Day 7 — Minimum Spanning Tree

CSES:

  1. Road Reparation
  2. [Road Construction](repeat for MST version)

LeetCode:


Day 8 — Advanced BFS/0-1 BFS

CSES:

  1. High Score
  2. Game Routes

LeetCode:


Day 9 — DSU Rollback / Offline

CSES:

  1. Dynamic Connectivity
  2. School Dance

LeetCode:


Day 10 — Graph Recap Contest

  • Pick 4 unsolved/mistakes from last 9 days
  • Solve in 2.5 hrs timed
  • Review all Graph notes

Day 11 — Multi-Source + State BFS

CSES:

  1. Monsters
  2. Police Chase

LeetCode:


Day 12 — Flow Basics

CSES:

  1. Download Speed (Max Flow)
  2. Police Chase II

LeetCode:


Day 13 — Flow & Matching

CSES:

  1. School Dance (Matching)

  2. Distinct Routes

LeetCode:


Day 14 — Advanced Graph Mix

CSES:

  1. Game Routes
  2. Flight Routes

LeetCode:


Day 15 — Graph Contest

Task:

  • 4 problems: 2 CSES + 2 LeetCode Hard in 3 hrs.

  • Full post-mortem.


Day 16–20:

 Graph Revision + Weak Point Drills

  • Day 16: CSES repeat MST + DSU

  • Day 17: CSES repeat SCC + Bridges

  • Day 18: LeetCode Hard BFS/DFS mix

  • Day 19: LeetCode Hard Dijkstra/DP hybrid

  • Day 20: Full Graph Mock Contest


Phase 2 – Advanced DP (Day 21–40)


Day 21 — Bitmask DP Basics

CSES:

  1. Hamiltonian Flights
  2. Counting Tilings

LeetCode:


Day 22 — DP on Trees

CSES:

  1. Tree Matching
  2. Tree Diameter

LeetCode:


Day 23 — Digit DP

CSES:

  1. Counting Numbers
  2. Counting Sequences

LeetCode:


Day 24–28 — DP Mixed Sets

  • Knuth Optimization
  • Probability DP
  • State Compression

Example: LeetCode: 312 (Burst Balloons), 1547 (Min Cost to Cut Stick), 1220 (Count Vowel Permutations), 188 (Stock IV), 2218 (Max Value of K Coins)


Day 29–30 — DP Mock

  • Day 29: 4 advanced DP problems
  • Day 30: Mixed DP + Graph in 3 hr mock

Phase 3 – Mixed Advanced (Day 41–65)


Day 41–45 — Segment Tree / BIT

CSES:

  1. Dynamic Range Sum Queries
  2. Dynamic Range Minimum Queries
  3. Polynomial Queries

LeetCode:

  • 307, 315, 493, 327 (all segment tree/BIT)

Day 46–50 — Mo’s Algorithm & Offline Queries

  • CSES Range Queries advanced problems
  • LeetCode hard prefix/suffix trick problems

Day 51–55 — Backtracking + Pruning

  • CSES combinatorics problems
  • LeetCode: N-Queens II, Word Search II, Remove Invalid Parentheses, Sudoku Solver

Day 56–60 — Greedy + Proof Problems

  • CSES Greedy advanced
  • LeetCode Hard Greedy: 135, 164, 630, 757

Day 61–65 — Mixed Practice

  • Each day: 1 Graph, 1 DP, 1 Data Structure
  • Time cap: 35–40 min/problem

Phase 4 – Hardcore Interview Simulations (Day 66–90)


Daily Flow:

  • Problem 1 (Hard Graph) — 40 min
  • Problem 2 (Hard DP) — 40 min
  • Problem 3 (Mixed Advanced) — 40 min
  • Problem 4 (Medium speed) — 25 min

Weekly:

  • 2x Mock Interviews on Pramp/Interviewing.io
  • Friday night: Review all mistakes + pattern doc
  • Sunday morning: Full 3 hr contest

Final Week (Day 85–90):

  • Day 85–87 → Company-specific sets (Google, Meta)
  • Day 88–89 → 6 hr mock interview day
  • Day 90 → Chill + light revision of notes
Link to original

Segment Tree

Segment Tree

arr = [0]*100005
seg = [0] * (4 * 100005)
 
def build(idx, low, high):
    if low == high:
        seg[idx] = arr[low]
        return 
 
    mid = (low + high) // 2;
    build(2*idx+1, low, mid)
    build(2*idx+2, mid + 1, high)
 
    seg[idx] = max(seg[2*idx+1], seg[2*idx+2])
 
def query(idx, low, high, l, r):
    if low >= l and high <= r:
        return seg[idx]
 
    if high < l or low > r:
        return float('-inf')
 
    mid = (low + high) // 2
    left = query(2*idx+1, low, mid, l, r)
    right = query(2*idx+2, mid + 1, high, l, r)
 
    return max(left, right)
 
if __name__ == "__main__":
    ar = [1, 3, 4, 6, 5, 9, 8]
    for i in range(len(ar)):
        arr[i] = ar[i]
    n = len(ar)
 
    build(0, 0, n-1)
    print(query(0, 0, n-1, 1, 3))
 

Youtube

Link to original

Binary Lifting

Binary Lifting

def build_binary_lifting(n, parent, k):
    dp = [[-1] * (k+1) for _ in range(n+1)]
 
	# Recursion Approch
    # def binary_lifting(u, parent):
    #     dp[u][0] = parent
    #     for i in range(1, k+1):
    #         if dp[u][i-1] != -1:
    #             dp[u][i] = dp[dp[u][i-1]][i-1]
    #
    #     for v in tree[u]:
    #         if v == parent:
    #             continue
    #         binary_lifting(v, u)
    # binary_lifting(1, -1)
 
    for i in range(1, n+1):
        dp[i][0] = parent[i]
 
    for j in range(1, k+1):
        for i in range(1, n+1):
            if dp[i][j-1] != -1:
                dp[i][j] = dp[dp[i][j-1]][j-1]
 
    return dp
 
def find_kth_anc(dp, u, k):
    for i in range(len(dp[0]) -1, -1, -1):
        if k >= (1 << i):
            u = dp[u][i]
            if u == -1:
                return -1
            k -= (1<<i)
 
    return u
 
if __name__ == "__main__":
    n, q = map(int, input().split())
    
    from collections import defaultdict
 
    tree = defaultdict(list)
    parent = [-1] * (n+1)
 
    arr = list(map(int, input().split()))
    # Tree rooted at 1
    for u, v in enumerate(arr, 2):
        tree[u].append(v)
        tree[v].append(u)
        parent[u] = v
 
    dp = build_binary_lifting(n, parent, 20)
 
    for _ in range(q):
        node, k = map(int, input().split())
        print(find_kth_anc(dp, node, k))
 
Link to original

Tree of Space

Tree of Space

Using Hashmap

class Node:
    name: str
    parent: None
    children: list["Node"]
    isLocked: bool
    lockedBy: int
    numLockedDescendants: int
    def __init__(self, name):
        self.isLocked:bool = False
        self.lockedBy = -1
        self.numLockedDescendants = 0
        self.parent = None
        self.children = []
        self.name = name
 
 
def lock(v, uid):
    if v.isLocked or v.numLockedDescendants > 0:
        return False
 
    parent = v.parent
    while parent != None:
        if parent.isLocked:
            return False
        parent = parent.parent
 
    v.isLocked = True
    v.lockedBy = uid
 
    parent = v.parent
    while parent != None:
        parent.numLockedDescendants += 1
        parent = parent.parent
 
    return True
 
def unlock(v, uid):
    if v.isLocked or not v.lockedBy == uid:
        return False
 
    v.isLocked = False
    v.lockedBy = -1
 
    parent = v.parent
    while parent != None:
        parent.numLockedDescendants -= 1
        parent = parent.parent
 
    return True
 
def collectLockedDescendants(v, uid, lockedList):
    if v.numLockedDescendants == 0 and not v.isLocked:
        return True
 
    if v.isLocked:
        if v.lockedBy != uid:
            return False
        lockedList.append(v)
 
    for child in v.children:
        if not collectLockedDescendants(child, uid, lockedList):
            return False
 
    return True
 
def upgrade(v, uid):
    if v.isLocked and v.numLockedDescendants == 0:
        return False
 
    lockedList = []
    if (collectLockedDescendants(v, uid, lockedList) == False):
        return False
 
    for node in lockedList:
        unlock(node, uid)
 
    v.isLocked = True
    v.lockedBy = uid
 
    parent = v.parent
    while parent != None:
        parent.numLockedDescendants += 1
        parent = parent.parent
 
    return True
 
def build_tree():
    A = Node('A')
    B = Node('B'); C = Node('C')
    D = Node('D'); E = Node('E')
    A.children = [B, C]; B.parent = A; C.parent = A
    B.children = [D, E]; D.parent = B; E.parent = B
    return A, B, C, D, E
 
A, B, C, D, E = build_tree()
 
print(lock(D, 1))      # True — lock leaf D by user 1
print(lock(E, 1))      # True — lock E by user 1
print(upgrade(B, 1))   # True — upgrade locks D & E -> B
print(A.numLockedDescendants)  # Expect 1 (only B locked)
print(lock(D, 2))      # False — ancestor B locked by user 1
print(unlock(B, 1))    # True — unlock B
print(lock(D, 2))      # True — now user 2 can lock D
print(unlock(E, 1))    # False — E isn't locked
 
# Print statuses for verification
for node in [A, B, C, D, E]:
    print(f"{node.name}: locked={node.isLocked}, by={node.lockedBy}, lockedDesc={node.numLockedDescendants}")
 

Leetcode 1993

class LockingTree:
 
  
 
def __init__(self, parent: List[int]):
 
self.parent = parent
 
self.locked = [None] * len(parent)
 
self.child = {i: [] for i in range(len(parent))}
 
for i in range(1, len(parent)):
 
self.child[parent[i]].append(i)
 
  
 
def lock(self, num: int, user: int) -> bool:
 
if self.locked[num]: return False
 
self.locked[num] = user
 
return True
 
  
 
def unlock(self, num: int, user: int) -> bool:
 
if self.locked[num] != user:
 
return False
 
  
 
self.locked[num] = None
 
return True
 
  
 
def upgrade(self, num: int, user: int) -> bool:
 
# Check if there is any locked ancestors
 
temp = num
 
while temp != -1:
 
if self.locked[temp]:
 
return False
 
temp = self.parent[temp]
 
  
 
# Check if atleast one descendant is locked
 
from collections import deque
 
  
 
q = deque()
 
q.append(num)
 
locked = 0
 
while q:
 
val = q.popleft()
 
if self.locked[val]:
 
self.locked[val] = None
 
locked += 1
 
child = self.child[val]
 
q.extend(child)
 
  
 
if locked == 0:
 
return False
 
  
 
self.locked[num] = user
 
return True
 
 
# Your LockingTree object will be instantiated and called as such:
 
# obj = LockingTree(parent)
 
# param_1 = obj.lock(num,user)
 
# param_2 = obj.unlock(num,user)
 
# param_3 = obj.upgrade(num,user)

Binary Lifting

Link to original

Tree Matching

Tree Matching

def solution(n, tree):
	# dp[node][0] represents max count of tree matching of tree rooted at node where u is not included in edge
	# dp[node][1] where node is included in one edge
    dp = [[0,0] for _ in range(n+1)]
 
    def dfs(u, parent):
		# Count without including u
        for v in tree[u]:
            if v == parent:
                continue
            dfs(v, u)
            dp[u][0] += max(dp[v][0], dp[v][1])
 
 
		# Count including u in one of the child with max
        for v in tree[u]:
            if v == parent:
                continue
            use_edge = 1 + dp[v][0] + (dp[u][0] - max(dp[v][0], dp[v][1]))
            dp[u][1] = max(dp[u][1], use_edge)
 
    dfs(1, -1)
    return max(dp[1][0], dp[1][1])
 
 
 
if __name__ == "__main__":
    n = int(input())
 
    from collections import defaultdict
 
    tree = defaultdict(list)
 
    for _ in range(n-1):
        a, b = tuple(map(int, input().split()))
        tree[a].append(b)
        tree[b].append(a)
 
    print(solution(n, tree))
 

Question

Link to original

Shamir's Secret Sharing (SSS)

Shamir's Secret Sharing (SSS)

1. What is Shamir’s Secret Sharing?

  • Shamir’s Secret Sharing (SSS) is a cryptographic technique for splitting one secret into multiple shares.
  • It is based on polynomial interpolation over a finite field.
  • The secret is not stored directly in any individual share.
  • Instead, the secret is encoded as the value of a polynomial at x = 0.

Example: 3-of-5

  • Generate 5 shares.
  • Any 3 shares can reconstruct the secret.
  • 1 or 2 shares cannot uniquely determine the original secret.
  • 4 or 5 shares can also reconstruct it.
5 total shares
        │
        ├── Share 1
        ├── Share 2
        ├── Share 3
        ├── Share 4
        └── Share 5
 
Threshold = 3
 
3 shares → Secret
2 shares → Cannot uniquely determine Secret
1 share  → Cannot uniquely determine Secret

2. Why do we need SSS?

  • Storing one sensitive secret in one location creates a single point of failure.
  • If that location is compromised, the entire secret can be compromised.
  • SSS distributes control of the secret among multiple parties.

Example: protecting an encryption key

Master Encryption Key
        │
        ▼
     3-of-5 SSS
        │
 ┌──────┼──────┬──────┐
 ▼      ▼      ▼      ▼
S1     S2     S3     S4     S5

Possible distribution:

Share 1 → Security Team
Share 2 → CTO
Share 3 → Offline Vault
Share 4 → Disaster Recovery
Share 5 → CEO
  • One compromised share is not enough.
  • Two compromised shares are still not enough.
  • Any three authorized shares can recover the key.

3. Threshold notation

SSS is commonly described as:

k-of-n

Where:

  • k = minimum number of shares required to reconstruct the secret.
  • n = total number of shares generated.

Examples

SchemeMeaning
2-of-3Any 2 of 3 shares recover the secret
3-of-5Any 3 of 5 shares recover the secret
5-of-7Any 5 of 7 shares recover the secret
10-of-15Any 10 of 15 shares recover the secret

4. Core mathematical idea

SSS uses a polynomial.

For a 3-of-5 scheme, we need a polynomial of degree 2:

The secret is stored as:

Therefore:

So we can write:

The coefficients a1 and a2 are randomly generated.


5. Why does a 3-of-5 scheme use a quadratic polynomial?

A polynomial of degree d requires d + 1 points to uniquely reconstruct it.

Therefore:

1-of-n → degree 0 polynomial
2-of-n → degree 1 polynomial
3-of-n → degree 2 polynomial
4-of-n → degree 3 polynomial
5-of-n → degree 4 polynomial

General rule:

So:

3-of-5
   ↓
threshold = 3
   ↓
degree = 3 - 1
   ↓
degree = 2
   ↓
quadratic polynomial

6. Numerical example

Let’s use a small example so the mathematics is easy to see.

Suppose:

We want:

3-of-5

Choose two random coefficients:

Therefore:

The secret is:


7. Generate the shares

Evaluate the polynomial at different x values.

Share 1

Share:

(1, 17)

Share 2

Share:

(2, 30)

Share 3

Share:

(3, 49)

Share 4

Share:

(4, 74)

Share 5

Share:

(5, 105)

So the five shares are:

Share 1 → (1, 17)
Share 2 → (2, 30)
Share 3 → (3, 49)
Share 4 → (4, 74)
Share 5 → (5, 105)

8. What does a share actually contain?

A share is essentially a point:

For example:

(2, 30)

means:

x = 2
y = 30

It does not directly contain:

secret = 10

The secret is hidden inside the polynomial.


9. Reconstructing the secret

Suppose we have:

(1, 17)
(2, 30)
(3, 49)

Three points are enough to uniquely determine a quadratic polynomial.

We use Lagrange interpolation.

The general formula is:

where:

But we only need the secret.

Since:

we calculate the interpolation directly at:


10. Lagrange calculation

Our points are:

(x1, y1) = (1, 17)
(x2, y2) = (2, 30)
(x3, y3) = (3, 49)

Contribution from Share 1

Contribution:

Contribution from Share 2

Contribution:

Contribution from Share 3

Contribution:


11. Recover the secret

Add the contributions:

Therefore:

The original secret has been recovered.


12. Why 2 shares are not enough

Suppose we only have:

(1, 17)
(2, 30)

There are many quadratic polynomials that can pass through those two points.

Therefore, we cannot uniquely determine:

The important distinction is:

2 points
   ↓
many possible quadratic polynomials
   ↓
many possible secrets
   ↓
cannot determine original secret

But:

3 points
   ↓
exactly one quadratic polynomial
   ↓
f(0)
   ↓
original secret

That is the mathematical reason the threshold works.


13. Finite fields

The simple example above uses ordinary integer arithmetic.

Real cryptographic implementations use arithmetic inside a finite field.

A common construction is:

where p is a large prime.

Operations are performed modulo p.

For example, if:

then:

and:

This keeps all values inside a finite mathematical field.


14. Modular division

Normal division does not work directly in a finite field.

Instead:

becomes:

where:

is the modular multiplicative inverse.

In Python:

inverse = pow(b, -1, PRIME)

This is used during Lagrange interpolation.


15. Overall SSS algorithm

Splitting

Secret
  │
  ▼
Choose random coefficients
  │
  ▼
Create polynomial
  │
  ▼
Evaluate polynomial at x=1,2,3,...
  │
  ▼
Generate shares

Mathematically:

Then:

Reconstruction

Enough shares
      │
      ▼
Lagrange interpolation
      │
      ▼
Reconstruct polynomial
      │
      ▼
Evaluate at x=0
      │
      ▼
Secret

Because:


16. SSS vs encryption

SSS is not encryption.

It is a secret-sharing mechanism.

Encryption

Plaintext
    │
    ▼
Encryption Key
    │
    ▼
Ciphertext

Secret Sharing

Secret
   │
   ▼
SSS
   │
   ├── Share 1
   ├── Share 2
   ├── Share 3
   ├── Share 4
   └── Share 5

SSS can be used to protect an encryption key:

AES Master Key
      │
      ▼
   3-of-5 SSS
      │
 ┌────┼────┬────┐
 ▼    ▼    ▼    ▼
S1   S2   S3   S4   S5

Later:

S1 + S3 + S5
       │
       ▼
   SSS recovery
       │
       ▼
 AES Master Key

17. Practical use cases

  • Encryption key backup
    • Split a master key among multiple trusted parties.
  • Disaster recovery
    • No single employee or machine needs to hold the complete recovery secret.
  • Cryptocurrency/private-key custody
    • Distribute control of sensitive key material.
  • Certificate/key management
    • Protect high-value cryptographic keys.
  • Organizational access control
    • Require multiple people to participate before recovering a secret.
  • Backup systems
    • Avoid a single backup location becoming a single point of failure.

18. Important security property

A properly implemented threshold scheme aims for:

fewer than k shares
        ↓
no useful information about the secret

while:

k or more shares
        ↓
secret can be reconstructed

This is the core security property of Shamir’s Secret Sharing.


19. Python implementation

The following implementation is educational. For production cryptography, use a well-reviewed, maintained implementation rather than writing your own cryptographic primitive.

Imports and finite field

from secrets import randbelow
 
 
# Large prime defining our finite field.
PRIME = 2**127 - 1

Polynomial evaluation

def eval_polynomial(coefficients, x):
    """
    Evaluate:
 
        f(x) = a0 + a1*x + a2*x^2 + ...
 
    modulo PRIME.
 
    Horner's method is used to evaluate the polynomial.
    """
 
    result = 0
 
    for coefficient in reversed(coefficients):
        result = (result * x + coefficient) % PRIME
 
    return result

Creating shares

def create_shares(secret, threshold, num_shares):
    """
    Create a k-of-n Shamir Secret Sharing scheme.
 
    Example:
 
        threshold = 3
        num_shares = 5
 
    means any 3 of the 5 shares can reconstruct the secret.
    """
 
    if threshold > num_shares:
        raise ValueError(
            "Threshold cannot exceed number of shares"
        )
 
    if not (0 <= secret < PRIME):
        raise ValueError(
            "Secret must be inside the finite field"
        )
 
    # a0 = secret
    coefficients = [secret]
 
    # Add random coefficients.
    #
    # For threshold = 3:
    #
    # f(x) = secret + a1*x + a2*x^2
    #
    for _ in range(threshold - 1):
        coefficients.append(
            randbelow(PRIME)
        )
 
    shares = []
 
    for x in range(1, num_shares + 1):
 
        y = eval_polynomial(
            coefficients,
            x
        )
 
        shares.append((x, y))
 
    return shares

Reconstructing the secret

def reconstruct_secret(shares):
    """
    Recover f(0) using Lagrange interpolation.
    """
 
    secret = 0
 
    for i, (xi, yi) in enumerate(shares):
 
        numerator = 1
        denominator = 1
 
        for j, (xj, _) in enumerate(shares):
 
            if i == j:
                continue
 
            # Numerator:
            # (0 - xj)
            numerator = (
                numerator * (-xj)
            ) % PRIME
 
            # Denominator:
            # (xi - xj)
            denominator = (
                denominator * (xi - xj)
            ) % PRIME
 
        # Modular division:
        #
        # numerator / denominator
        #
        # becomes:
        #
        # numerator * inverse(denominator)
        #
        lagrange_coefficient = (
            numerator
            * pow(
                denominator,
                -1,
                PRIME
            )
        ) % PRIME
 
        secret = (
            secret
            + yi * lagrange_coefficient
        ) % PRIME
 
    return secret

Complete example

secret = 123456789
 
# Create 5 shares.
# Any 3 shares are sufficient.
shares = create_shares(
    secret=secret,
    threshold=3,
    num_shares=5
)
 
print("Shares:")
 
for share in shares:
    print(share)
 
 
# Select any 3 shares.
selected_shares = [
    shares[0],
    shares[2],
    shares[4]
]
 
 
# Recover the secret.
recovered = reconstruct_secret(
    selected_shares
)
 
print("Original:", secret)
print("Recovered:", recovered)

Expected result:

Original: 123456789
Recovered: 123456789

The actual share values will be different each time because the polynomial coefficients are randomly generated.


20. Secret as bytes

Real secrets are often byte strings rather than small integers.

Convert bytes to an integer:

secret_bytes = b"my-secret"
 
secret_int = int.from_bytes(
    secret_bytes,
    byteorder="big"
)

After reconstruction:

recovered_bytes = recovered.to_bytes(
    (recovered.bit_length() + 7) // 8,
    byteorder="big"
)
 
print(recovered_bytes)

Output:

b'my-secret'

For real cryptographic keys, the field size and encoding must be chosen carefully so the entire secret fits correctly.


21. Complete mental model

                 SECRET
                    │
                    │
                    ▼
           f(0) = SECRET
                    │
                    ▼
       ┌──────────────────────┐
       │ Random Polynomial    │
       │                      │
       │ f(x) = S + a1*x      │
       │          + a2*x² ... │
       └──────────────────────┘
                    │
          Evaluate at x values
                    │
       ┌────────────┼────────────┐
       ▼            ▼            ▼
   (1, f(1))    (2, f(2))    (3, f(3)) ...
       │            │            │
       ▼            ▼            ▼
    Share 1      Share 2      Share 3
       │            │            │
       └────────────┼────────────┘
                    │
              k shares needed
                    │
                    ▼
          Lagrange Interpolation
                    │
                    ▼
                  f(0)
                    │
                    ▼
                 SECRET

22. Key points to remember

  • SSS = Shamir’s Secret Sharing.
  • It splits one secret into n shares.
  • A threshold k determines how many shares are required.
  • It uses a polynomial of degree k - 1.
  • The secret is stored as:
  • Each share is a point:
  • Lagrange interpolation reconstructs the polynomial.
  • Reconstruction evaluates the polynomial at x = 0.
  • Fewer than k shares cannot uniquely determine the secret.
  • Real implementations use finite-field arithmetic.
  • SSS itself is not encryption.
  • SSS can be used to distribute or protect encryption keys.
  • The educational Python implementation above demonstrates the mathematics but should not be treated as production cryptographic software.
Link to original

Notes in this canvas