Author: Niraj Kumar Mahto

  • Advanced Graph Algorithms in Python

    Advanced graph algorithms solve complex relationship, routing, and optimization problems. They are widely used in real-world systems such as:

    • Compilers and build systems
    • Dependency resolution (pip, npm, Maven)
    • Social networks and community detection
    • Navigation systems and maps
    • AI search and game pathfinding
    • Network routing and optimization

    When Do We Need Advanced Graph Algorithms?

    Graphs become challenging when they are:

    • Directed (dependencies, workflows)
    • Weighted (costs, distances, time)
    • Cyclic (loops in dependencies)
    • Global in nature (SCCs, all-pairs shortest paths)

    Advanced algorithms handle these efficiently and correctly.


    1. Topological Sorting

    Topological sorting produces a linear ordering of vertices in a Directed Acyclic Graph (DAG) such that for every edge
    u → v, u appears before v.

    Use Cases

    • Course prerequisites
    • Build systems and compilers
    • Task scheduling
    • Dependency resolution

    Important Rule

    Only works for DAGs
    ❌ If a cycle exists, topological ordering is impossible.


    Topological Sort using Kahn’s Algorithm (BFS)

    Idea

    1. Compute in-degree for each node
    2. Push all nodes with in-degree 0 into a queue
    3. Remove nodes, reduce neighbors’ in-degrees
    4. If all nodes are processed → valid ordering

    Implementation

    from collections import deque, defaultdict
    
    def topo_sort_kahn(n, edges):
        graph = defaultdict(list)
        indegree = [0] * n
    
        for u, v in edges:
            graph[u].append(v)
            indegree[v] += 1
    
        q = deque([i for i in range(n) if indegree[i] == 0])
        order = []
    
        while q:
            node = q.popleft()
            order.append(node)
    
            for neigh in graph[node]:
                indegree[neigh] -= 1
                if indegree[neigh] == 0:
                    q.append(neigh)
    
        return order if len(order) == n else None
    

    Complexity

    • Time: O(V + E)
    • Space: O(V + E)

    Topological Sort using DFS

    Idea

    • Perform DFS
    • Add node to stack after visiting all neighbors
    • Reverse stack for final order
    • Detect cycles using visitation states

    Implementation

    from collections import defaultdict
    
    def topo_sort_dfs(n, edges):
        graph = defaultdict(list)
        for u, v in edges:
            graph[u].append(v)
    
        visited = [0] * n  # 0=unvisited, 1=visiting, 2=visited
        stack = []
    
        def dfs(node):
            if visited[node] == 1:
                return False
            if visited[node] == 2:
                return True
    
            visited[node] = 1
            for neigh in graph[node]:
                if not dfs(neigh):
                    return False
    
            visited[node] = 2
            stack.append(node)
            return True
    
        for i in range(n):
            if visited[i] == 0 and not dfs(i):
                return None
    
        return stack[::-1]
    

    2. Strongly Connected Components (SCC)

    A Strongly Connected Component is a group of nodes in a directed graph where every node is reachable from every other node.

    Applications

    • Cycle detection in dependency graphs
    • Social network communities
    • Web graph clustering
    • Control flow analysis

    Kosaraju’s Algorithm

    Idea

    1. DFS and store nodes by finish time
    2. Reverse the graph
    3. DFS in reverse finish-time order
    4. Each DFS traversal forms one SCC

    Implementation

    from collections import defaultdict
    
    def kosaraju_scc(n, edges):
        graph = defaultdict(list)
        rev_graph = defaultdict(list)
    
        for u, v in edges:
            graph[u].append(v)
            rev_graph[v].append(u)
    
        visited = [False] * n
        order = []
    
        def dfs1(node):
            visited[node] = True
            for neigh in graph[node]:
                if not visited[neigh]:
                    dfs1(neigh)
            order.append(node)
    
        for i in range(n):
            if not visited[i]:
                dfs1(i)
    
        visited = [False] * n
        sccs = []
    
        def dfs2(node, comp):
            visited[node] = True
            comp.append(node)
            for neigh in rev_graph[node]:
                if not visited[neigh]:
                    dfs2(neigh, comp)
    
        for node in reversed(order):
            if not visited[node]:
                comp = []
                dfs2(node, comp)
                sccs.append(comp)
    
        return sccs
    

    Complexity

    • Time: O(V + E)
    • Space: O(V + E)

    3. Floyd–Warshall Algorithm (All-Pairs Shortest Path)

    Finds shortest paths between every pair of vertices.

    When to Use

    • Small to medium graphs
    • Need distances between all node pairs
    • Graph may contain negative edges (no negative cycles)

    Idea

    Dynamic Programming:

    dist[i][j] = min(dist[i][j], dist[i][k] + dist[k][j])
    

    Implementation

    def floyd_warshall(dist):
        n = len(dist)
        for k in range(n):
            for i in range(n):
                for j in range(n):
                    if dist[i][k] + dist[k][j] < dist[i][j]:
                        dist[i][j] = dist[i][k] + dist[k][j]
        return dist
    

    Complexity

    • Time: O(V³)
    • Space: O(V²)

    4. A* (A-Star) Search Algorithm

    A* is a heuristic-based shortest path algorithm widely used in AI, games, and navigation systems.

    Key Components

    • g(n) → cost from start to node
    • h(n) → heuristic estimate to goal
    • f(n) = g(n) + h(n)

    Why A*?

    • Faster than Dijkstra
    • Focuses search toward the goal
    • Works best with a good heuristic

    A* on a Grid (Manhattan Distance)

    import heapq
    
    def astar(grid, start, goal):
        rows, cols = len(grid), len(grid[0])
    
        def heuristic(a, b):
            return abs(a[0]-b[0]) + abs(a[1]-b[1])
    
        pq = [(0, start)]
        g_cost = {start: 0}
        parent = {start: None}
    
        while pq:
            _, current = heapq.heappop(pq)
    
            if current == goal:
                path = []
                while current:
                    path.append(current)
                    current = parent[current]
                return path[::-1]
    
            r, c = current
            for dr, dc in [(1,0),(-1,0),(0,1),(0,-1)]:
                nr, nc = r+dr, c+dc
                if 0 <= nr < rows and 0 <= nc < cols and grid[nr][nc] == 0:
                    neighbor = (nr, nc)
                    new_g = g_cost[current] + 1
                    if neighbor not in g_cost or new_g < g_cost[neighbor]:
                        g_cost[neighbor] = new_g
                        f = new_g + heuristic(neighbor, goal)
                        heapq.heappush(pq, (f, neighbor))
                        parent[neighbor] = current
        return None
    

    Complexity

    • Depends on heuristic quality
    • Worst case similar to Dijkstra
    • Typically much faster in practice

    Summary

    AlgorithmPurpose
    Topological SortOrders tasks in DAGs
    SCC (Kosaraju)Finds strongly connected groups
    Floyd–WarshallAll-pairs shortest paths
    A* SearchFast goal-directed shortest path

    These algorithms are fundamental in real-world systems involving dependencies, routing, optimization, and AI-based navigation.

  • Divide and Conquer in Python

    Divide and Conquer is a fundamental algorithmic strategy that solves a problem by:

    1. Dividing the problem into smaller subproblems
    2. Conquering (solving) each subproblem recursively
    3. Combining the results to form the final solution

    This approach is powerful because it often reduces time complexity dramatically—commonly to O(n log n)—and it forms the backbone of many classic algorithms.


    Introduction to the Divide and Conquer Strategy

    Core Idea

    Instead of solving a large problem directly, we break it into smaller problems of the same type, solve them independently, and then combine their results.

    This works especially well when:

    • Subproblems are independent
    • The problem has a recursive structure
    • Combining results is efficient

    When Divide and Conquer Is Used

    • Sorting large datasets
    • Searching in sorted data
    • Efficient multiplication of large numbers or matrices
    • Recursive problem structures (trees, ranges, segments)
    • Optimization via “binary search on answer”

    Typical Divide and Conquer Template

    def divide_and_conquer(problem):
        if problem is small:
            return solve_directly(problem)
    
        left, right = divide(problem)
        left_ans = divide_and_conquer(left)
        right_ans = divide_and_conquer(right)
    
        return combine(left_ans, right_ans)
    

    Complexity Intuition

    If a problem of size n is:

    • Split into 2 halves
    • Each recursive call costs T(n/2)
    • Combine step costs O(n)

    Then the total time complexity becomes:

    O(n log n)


    Classic Divide and Conquer Algorithms


    Merge Sort

    Merge Sort divides the array into halves, recursively sorts each half, and then merges the sorted halves.

    Why It Works

    • Each division halves the problem size
    • Merging two sorted arrays is linear
    • Guarantees optimal performance regardless of input order

    def merge_sort(arr):
        if len(arr) <= 1:
            return arr
    
        mid = len(arr) // 2
        left = merge_sort(arr[:mid])
        right = merge_sort(arr[mid:])
    
        return merge(left, right)
    
    def merge(left, right):
        result = []
        i = j = 0
    
        while i < len(left) and j < len(right):
            if left[i] <= right[j]:
                result.append(left[i])
                i += 1
            else:
                result.append(right[j])
                j += 1
    
        result.extend(left[i:])
        result.extend(right[j:])
        return result
    
    print(merge_sort([5, 2, 4, 7, 1, 3, 2, 6]))
    

    Complexity

    • Time: O(n log n) (best, average, worst)
    • Space: O(n)
    • Stable: Yes

    Quick Sort

    Quick Sort selects a pivot, partitions the array into smaller and larger elements, and recursively sorts them.

    Key Points

    • Very fast in practice
    • In-place versions use less memory
    • Worst-case happens with poor pivot choice

    Simple Implementation

    def quick_sort(arr):
        if len(arr) <= 1:
            return arr
    
        pivot = arr[len(arr)//2]
        left = [x for x in arr if x < pivot]
        mid = [x for x in arr if x == pivot]
        right = [x for x in arr if x > pivot]
    
        return quick_sort(left) + mid + quick_sort(right)
    
    print(quick_sort([10, 7, 8, 9, 1, 5]))
    

    Complexity

    • Average: O(n log n)
    • Worst: O(n²)
    • Space: O(n) (for this version)
    • Stable: No

    Binary Search

    Binary Search repeatedly divides the search space in half to locate a target element.

    Important Requirement

    • Works only on sorted arrays

    def binary_search(arr, target):
        left, right = 0, len(arr) - 1
    
        while left <= right:
            mid = (left + right) // 2
    
            if arr[mid] == target:
                return mid
            elif arr[mid] < target:
                left = mid + 1
            else:
                right = mid - 1
    
        return -1
    
    print(binary_search([1, 3, 5, 7, 9], 7))  # Output: 3
    

    Complexity

    • Time: O(log n)
    • Space: O(1)

    Strassen’s Matrix Multiplication (Conceptual)

    Standard matrix multiplication takes O(n³) time.

    Strassen’s algorithm applies divide and conquer to reduce this to approximately:

    O(n²·⁸¹)


    High-Level Idea

    Split matrices into four submatrices:

    A = [A11 A12]     B = [B11 B12]
        [A21 A22]         [B21 B22]
    
    • Standard approach: 8 multiplications
    • Strassen’s approach: 7 multiplications + additions

    Why It Matters

    • Faster for large matrices
    • Used in scientific computing and numerical libraries
    • Demonstrates how divide and conquer can reduce complexity

    ⚠️ Note: Strassen’s algorithm is rarely required in interviews, but understanding the concept is valuable.


    Where Divide and Conquer Appears in Problem Solving

    Common Patterns

    • Sort then solve (merge sort + two pointers)
    • Binary search on answer
    • Recursive splitting (trees, segment trees, range queries)

    Binary Search on Answer (Concept)

    Used when you search for a minimum or maximum feasible value.

    Examples:

    • Minimum time to complete tasks
    • Capacity to ship packages
    • Aggressive cows (classic problem)

    Practice Problems (Recommended)

    • Merge Sort (implement from scratch)
    • Quick Sort variants
    • Binary Search (first/last occurrence)
    • Search in Rotated Sorted Array
    • Kth Largest Element (Quickselect)
    • Find Peak Element
    • Median of Two Sorted Arrays

    Summary

    Divide and Conquer is a core algorithmic paradigm that:

    • Breaks large problems into smaller ones
    • Solves them recursively
    • Combines results efficiently

    It powers critical algorithms such as merge sort, quick sort, binary search, and even advanced techniques like Strassen’s matrix multiplication. Mastering divide and conquer improves recursive thinking and unlocks solutions to many high-level algorithmic problems.

  • Bit Manipulation in Python

    Bit manipulation means working directly with the binary (base-2) representation of numbers using bitwise operators. Many problems become faster, cleaner, and more elegant when solved using bits—especially in competitive programming and interviews.

    Bit manipulation is heavily used in:

    • Optimization problems
    • Subset and mask-based problems
    • XOR-based tricks
    • Low-level performance tuning

    Introduction to Bitwise Operations

    Computers store integers as binary numbers (0s and 1s).
    Bitwise operators act directly on these bits.


    Common Bitwise Operators in Python

    AND (&)

    Returns 1 only if both bits are 1.

    print(5 & 3)   # 0101 & 0011 = 0001 => 1
    

    OR (|)

    Returns 1 if at least one bit is 1.

    print(5 | 3)   # 0101 | 0011 = 0111 => 7
    

    XOR (^)

    Returns 1 if bits are different.

    print(5 ^ 3)   # 0101 ^ 0011 = 0110 => 6
    

    NOT (~)

    Flips all bits.
    In Python, this results in a negative number due to infinite-bit representation.

    print(~5)  # -6
    

    Left Shift (<<)

    Shifts bits left (multiplies by 2 for each shift).

    print(5 << 1)  # 10
    print(5 << 2)  # 20
    

    Right Shift (>>)

    Shifts bits right (integer division by 2 for each shift).

    print(5 >> 1)   # 2
    print(20 >> 2)  # 5
    

    Common Bit Manipulation Techniques

    Checking if a Number is Odd or Even

    Logic

    • Odd numbers → last bit is 1
    • Even numbers → last bit is 0
    def is_odd(n):
        return (n & 1) == 1
    
    print(is_odd(7))   # True
    print(is_odd(10))  # False
    

    Complexity: O(1)


    Finding the i-th Bit of a Number

    Logic

    • Create a mask: 1 << i
    • AND with the number
    def get_ith_bit(n, i):
        return (n & (1 << i)) != 0
    
    print(get_ith_bit(13, 2))  # True (13 = 1101)
    print(get_ith_bit(13, 1))  # False
    

    Setting the i-th Bit (Make it 1)

    Logic

    • OR with a mask
    def set_ith_bit(n, i):
        return n | (1 << i)
    
    print(set_ith_bit(9, 1))  # 9=1001 → 1011 => 11
    

    Clearing the i-th Bit (Make it 0)

    Logic

    • AND with inverted mask
    def clear_ith_bit(n, i):
        return n & ~(1 << i)
    
    print(clear_ith_bit(13, 2))  # 1101 → 1001 => 9
    

    Toggling the i-th Bit (Flip it)

    Logic

    • XOR with mask
    def toggle_ith_bit(n, i):
        return n ^ (1 << i)
    
    print(toggle_ith_bit(13, 1))  # 1101 → 1111 => 15
    

    Counting Set Bits (Popcount)

    Method 1: Python Built-in

    def count_ones(n):
        return bin(n).count("1")
    
    print(count_ones(13))  # 3
    

    Method 2: Brian Kernighan’s Algorithm (Efficient)

    Removes the lowest set bit in each iteration.

    def count_ones_kernighan(n):
        count = 0
        while n:
            n = n & (n - 1)
            count += 1
        return count
    
    print(count_ones_kernighan(13))  # 3
    

    Complexity: O(number of set bits)
    Very fast when few bits are set.


    Important Bit Tricks Used in Interviews

    Check if a Number Is a Power of Two

    A power of two has exactly one set bit.

    def is_power_of_two(n):
        return n > 0 and (n & (n - 1)) == 0
    
    print(is_power_of_two(16))  # True
    print(is_power_of_two(18))  # False
    

    Find the Only Non-Repeating Number (XOR Trick)

    If every number appears twice except one, XOR reveals the unique number.

    def single_number(nums):
        x = 0
        for n in nums:
            x ^= n
        return x
    
    print(single_number([2, 2, 1, 4, 4]))  # 1
    

    Why it works

    • a ^ a = 0
    • 0 ^ b = b

    Applications of Bit Manipulation in Algorithms

    • Optimization: reduce memory using bitmasks
    • Subset generation: represent subsets using bits
    • Fast checks: odd/even, power of two, parity
    • XOR-based problems: unique number, missing number
    • Competitive programming: bitmask DP, state compression

    Example: Generating All Subsets Using Bitmask

    Each subset corresponds to a binary number from 0 to 2ⁿ - 1.

    def subsets(nums):
        n = len(nums)
        result = []
        for mask in range(1 << n):
            subset = []
            for i in range(n):
                if mask & (1 << i):
                    subset.append(nums[i])
            result.append(subset)
        return result
    
    print(subsets([1, 2, 3]))
    

    Complexity: O(n · 2ⁿ)


    Summary

    Bit manipulation allows you to write fast, memory-efficient, and elegant solutions by working directly with binary representations. Master the operators & | ^ ~ << >> and common tricks like:

    • odd/even checks
    • extracting, setting, clearing bits
    • counting set bits
    • XOR-based uniqueness problems

  • Recursion and Backtracking in Python

    Recursion is a technique where a function calls itself to solve smaller instances of the same problem.
    Backtracking is a systematic problem-solving approach (usually implemented with recursion) that explores possible choices, and undoes (backtracks) them when they lead to a dead end.

    These concepts are fundamental in DSA and appear frequently in interviews and competitive programming.

    They are heavily used in:

    • Permutations and combinations
    • Subset generation
    • Constraint satisfaction problems (N-Queens, Sudoku)
    • Pathfinding in grids and mazes
    • DFS-based graph and tree problems

    Understanding Recursion and Its Applications

    What is Recursion?

    Recursion solves a problem by:

    1. Base Case – defines when recursion should stop
    2. Recursive Case – reduces the problem and calls itself
    3. Progress – each call moves closer to the base case

    Core Parts of a Recursive Solution

    • Base Case: Prevents infinite recursion
    • Recursive Relation: Defines how the problem is broken down
    • Progress Toward Base Case: Input size must shrink

    Example: Factorial

    def factorial(n):
        if n == 0 or n == 1:   # base case
            return 1
        return n * factorial(n - 1)  # recursive case
    
    print(factorial(5))  # 120
    

    Recursion Stack (Important Concept)

    Each recursive call is placed on the call stack until it returns.
    For deep recursion, this can cause a stack overflow (Python has a recursion limit).


    Common Recursion Mistakes

    • Missing base case → infinite recursion
    • Not reducing problem size
    • Incorrect return handling
    • Too deep recursion (prefer iteration or DP when possible)

    Implementing Recursive Algorithms in Python

    Recursion is commonly used in:

    • Tree traversals
    • Divide and conquer (merge sort)
    • Depth-first search (DFS)
    • Dynamic programming (top-down)

    Example: Sum of an Array (Recursive)

    def sum_array(arr, i=0):
        if i == len(arr):
            return 0
        return arr[i] + sum_array(arr, i + 1)
    
    print(sum_array([1, 2, 3, 4]))  # 10
    

    Example: Recursive Binary Search

    def binary_search_recursive(arr, target, left, right):
        if left > right:
            return -1
    
        mid = (left + right) // 2
        if arr[mid] == target:
            return mid
        elif arr[mid] < target:
            return binary_search_recursive(arr, target, mid + 1, right)
        else:
            return binary_search_recursive(arr, target, left, mid - 1)
    
    arr = [1, 3, 5, 7, 9]
    print(binary_search_recursive(arr, 7, 0, len(arr) - 1))  # 3
    

    Introduction to Backtracking

    Backtracking is used when:

    • You must explore all possibilities
    • You must satisfy constraints
    • You want to stop early once a solution is found

    Backtracking Pattern

    1. Choose an option
    2. Explore recursively
    3. Undo the choice if it fails

    Generic Backtracking Template

    def backtrack(state):
        if goal_reached(state):
            save_answer(state)
            return
    
        for choice in choices(state):
            apply(choice, state)
            backtrack(state)
            undo(choice, state)
    

    Classic Backtracking Problems


    1) N-Queens Problem

    Place N queens on an N x N chessboard such that no two queens attack each other.

    Constraints

    A queen attacks:

    • Same row
    • Same column
    • Same diagonals

    Backtracking Idea

    • Place queens row by row
    • For each row, try all columns
    • Place queen if safe
    • Backtrack if no valid placement exists

    Implementation

    def solve_n_queens(n):
        board = [["."] * n for _ in range(n)]
        cols = set()
        diag1 = set()  # r - c
        diag2 = set()  # r + c
        results = []
    
        def backtrack(r):
            if r == n:
                results.append(["".join(row) for row in board])
                return
    
            for c in range(n):
                if c in cols or (r - c) in diag1 or (r + c) in diag2:
                    continue
    
                board[r][c] = "Q"
                cols.add(c)
                diag1.add(r - c)
                diag2.add(r + c)
    
                backtrack(r + 1)
    
                board[r][c] = "."
                cols.remove(c)
                diag1.remove(r - c)
                diag2.remove(r + c)
    
        backtrack(0)
        return results
    
    print(len(solve_n_queens(4)))  # 2
    

    Complexity

    • Worst case: Exponential
    • Optimized checks using sets: O(1) per check

    2) Sudoku Solver

    Fill a 9 x 9 grid so that each row, column, and 3 x 3 box contains digits 1–9.


    Backtracking Idea

    1. Find an empty cell
    2. Try digits 1–9
    3. If valid, place digit and recurse
    4. If failure, undo and try next digit

    Implementation

    def solve_sudoku(board):
        def is_valid(r, c, val):
            for j in range(9):
                if board[r][j] == val:
                    return False
    
            for i in range(9):
                if board[i][c] == val:
                    return False
    
            box_r = (r // 3) * 3
            box_c = (c // 3) * 3
            for i in range(box_r, box_r + 3):
                for j in range(box_c, box_c + 3):
                    if board[i][j] == val:
                        return False
    
            return True
    
        def backtrack():
            for i in range(9):
                for j in range(9):
                    if board[i][j] == ".":
                        for val in "123456789":
                            if is_valid(i, j, val):
                                board[i][j] = val
                                if backtrack():
                                    return True
                                board[i][j] = "."
                        return False
            return True
    
        backtrack()
        return board
    

    3) Rat in a Maze Problem

    Find a path from the top-left to bottom-right in a maze.

    • 1 → open cell
    • 0 → blocked cell

    Backtracking Idea

    • From each cell, try moving right or down
    • Mark path if valid
    • Backtrack if dead end

    Implementation (Right + Down Moves)

    def rat_maze(maze):
        n = len(maze)
        sol = [[0] * n for _ in range(n)]
    
        def is_safe(x, y):
            return 0 <= x < n and 0 <= y < n and maze[x][y] == 1
    
        def backtrack(x, y):
            if x == n - 1 and y == n - 1:
                sol[x][y] = 1
                return True
    
            if is_safe(x, y):
                sol[x][y] = 1
    
                if backtrack(x + 1, y):
                    return True
                if backtrack(x, y + 1):
                    return True
    
                sol[x][y] = 0
                return False
    
            return False
    
        if backtrack(0, 0):
            return sol
        return None
    
    maze = [
        [1, 0, 0, 0],
        [1, 1, 0, 1],
        [0, 1, 0, 0],
        [1, 1, 1, 1]
    ]
    
    print(rat_maze(maze))
    

    Summary

    • Recursion breaks problems into smaller subproblems using base cases.
    • Backtracking systematically explores choices and undoes them on failure.
    • Classic problems like N-Queens, Sudoku, and Rat-in-a-Maze are ideal for mastering these techniques.
    • Understanding recursion + backtracking builds strong foundations for DFS, graphs, trees, and constraint-based problems.

  • Greedy Algorithms in Python

    Greedy algorithms solve problems by making the best choice at the current moment (a locally optimal choice), hoping it leads to a globally optimal solution. Greedy approaches are often simpler and faster than Dynamic Programming—but they only work when the problem satisfies the greedy-choice property.


    Introduction to the Greedy Approach

    What is a Greedy Algorithm?

    A greedy algorithm builds a solution step-by-step and at each step chooses what looks best right now.


    Greedy-Choice Property

    A problem has the greedy-choice property if:

    ✅ Making the best local choice always leads to an optimal global solution.

    This is not true for all problems—greedy needs the right structure.


    When Greedy Works Well

    Greedy works well when:

    • Local choices never block an optimal solution
    • The problem has a provable greedy rule
    • Sorting first makes the greedy choices valid (very common)

    When Greedy Fails

    Greedy can fail when a local best choice blocks a better future result.

    Example where greedy fails (coin system):

    • coins = [1, 3, 4], amount = 6
    • greedy picks: 4 + 1 + 1 → 3 coins
    • optimal is: 3 + 3 → 2 coins ✅

    Classic Greedy Problems


    1) Activity Selection Problem

    Problem: Given activities with start and end times, select the maximum number of non-overlapping activities.

    Key Greedy Idea

    ✅ Sort by end time, then always pick the one that ends earliest.

    Why it works: Finishing earliest leaves the most room for future activities.

    Implementation

    def activity_selection(activities):
        # activities = [(start, end), ...]
        activities.sort(key=lambda x: x[1])  # sort by end time
        selected = []
    
        last_end = float("-inf")
        for start, end in activities:
            if start >= last_end:
                selected.append((start, end))
                last_end = end
    
        return selected
    
    acts = [(1, 3), (2, 4), (3, 5), (0, 6), (5, 7), (8, 9)]
    print(activity_selection(acts))
    

    Complexity

    • Time: O(n log n) (sorting)
    • Space: O(n) (selected list)

    2) Fractional Knapsack Problem

    Problem: Items have weights and values. You can take fractions of items. Maximize value under capacity.

    Greedy Idea

    ✅ Take items in decreasing order of value/weight ratio.

    Why greedy works here: Fractions are allowed, so choosing best density first always stays optimal.

    Implementation

    def fractional_knapsack(weights, values, capacity):
        items = []
        for w, v in zip(weights, values):
            items.append((v / w, w, v))  # (ratio, weight, value)
    
        items.sort(reverse=True)  # sort by ratio descending
    
        total_value = 0
        for ratio, w, v in items:
            if capacity == 0:
                break
    
            if w <= capacity:
                total_value += v
                capacity -= w
            else:
                total_value += ratio * capacity
                capacity = 0
    
        return total_value
    
    weights = [10, 20, 30]
    values = [60, 100, 120]
    print(fractional_knapsack(weights, values, 50))  # 240.0
    

    Complexity

    • Time: O(n log n)
    • Space: O(n)

    3) Huffman Coding (Data Compression)

    Huffman coding assigns shorter binary codes to more frequent characters.

    Greedy Idea

    ✅ Repeatedly combine the two least frequent nodes.

    Data Structure Used

    • Min-heap (priority queue)

    Build Huffman Tree

    import heapq
    from collections import Counter
    
    class Node:
        def __init__(self, freq, char=None, left=None, right=None):
            self.freq = freq
            self.char = char
            self.left = left
            self.right = right
    
        def __lt__(self, other):
            return self.freq < other.freq
    
    def build_huffman_tree(text):
        freq = Counter(text)
        heap = [Node(f, ch) for ch, f in freq.items()]
        heapq.heapify(heap)
    
        while len(heap) > 1:
            a = heapq.heappop(heap)
            b = heapq.heappop(heap)
            merged = Node(a.freq + b.freq, left=a, right=b)
            heapq.heappush(heap, merged)
    
        return heap[0] if heap else None
    

    Generate Codes (DFS Traversal)

    def generate_codes(root):
        codes = {}
    
        def dfs(node, path):
            if not node:
                return
            if node.char is not None:
                codes[node.char] = path
                return
            dfs(node.left, path + "0")
            dfs(node.right, path + "1")
    
        dfs(root, "")
        return codes
    
    text = "aaabbc"
    root = build_huffman_tree(text)
    print(generate_codes(root))
    

    Complexity

    • Heap build: O(k)
    • Merging: O(k log k)
      where k = number of unique characters

    4) Prim’s Algorithm (Minimum Spanning Tree)

    Finds MST of a connected weighted undirected graph.

    Greedy Idea

    ✅ Expand MST by always picking the cheapest edge that connects to a new node.

    Implementation (Min Heap)

    import heapq
    
    def prim_mst(graph, start=0):
        # graph: {u: [(v, w), ...]}
        visited = set()
        mst_cost = 0
        pq = [(0, start)]  # (weight, node)
    
        while pq:
            weight, node = heapq.heappop(pq)
            if node in visited:
                continue
    
            visited.add(node)
            mst_cost += weight
    
            for neighbor, w in graph[node]:
                if neighbor not in visited:
                    heapq.heappush(pq, (w, neighbor))
    
        return mst_cost
    

    Complexity

    • Time: O(E log V)
    • Space: O(V + E)

    5) Kruskal’s Algorithm (Minimum Spanning Tree)

    Builds MST by picking the globally smallest edges.

    Greedy Idea

    ✅ Sort edges by weight, take the next smallest edge that doesn’t form a cycle.

    Needs:

    • Union-Find (DSU) to detect cycles efficiently.

    Union-Find

    class DSU:
        def __init__(self, n):
            self.parent = list(range(n))
            self.rank = [0] * n
    
        def find(self, x):
            if self.parent[x] != x:
                self.parent[x] = self.find(self.parent[x])
            return self.parent[x]
    
        def union(self, a, b):
            ra, rb = self.find(a), self.find(b)
            if ra == rb:
                return False
            if self.rank[ra] < self.rank[rb]:
                self.parent[ra] = rb
            elif self.rank[ra] > self.rank[rb]:
                self.parent[rb] = ra
            else:
                self.parent[rb] = ra
                self.rank[ra] += 1
            return True
    

    Kruskal Implementation

    def kruskal(n, edges):
        # edges: [(w, u, v), ...]
        edges.sort()
        dsu = DSU(n)
        mst_cost = 0
        mst_edges = []
    
        for w, u, v in edges:
            if dsu.union(u, v):
                mst_cost += w
                mst_edges.append((u, v, w))
    
        return mst_cost, mst_edges
    

    Complexity

    • Sorting edges: O(E log E)
    • DSU ops: ~ O(1) amortized
    • Total: O(E log E)

    Summary

    Greedy algorithms make locally optimal decisions and work when the problem structure guarantees that those local choices lead to a globally optimal solution.

    ✅ Common Greedy Problems:

    • Activity Selection (sort by end time)
    • Fractional Knapsack (sort by value density)
    • Huffman Coding (merge two smallest using heap)
    • MST: Prim’s (expand using min edge), Kruskal’s (sort edges + DSU)

  • Dynamic Programming in Python

    Dynamic Programming (DP) is a powerful problem-solving technique used to optimize problems that involve overlapping subproblems and optimal substructure. It is one of the most important topics in DSA and interviews because it transforms slow exponential-time solutions into efficient polynomial-time algorithms.


    Introduction to Dynamic Programming and Its Principles

    What Is Dynamic Programming?

    Dynamic Programming is applicable when a problem satisfies both of the following properties:

    1. Overlapping Subproblems
      The same subproblems appear multiple times during computation.
    2. Optimal Substructure
      The optimal solution to the problem can be constructed from optimal solutions of its subproblems.

    Why Dynamic Programming Improves Performance

    A naive recursive solution often recomputes the same values many times.

    • Without DP: Exponential time (very slow)
    • With DP: Linear or polynomial time (efficient)

    DP avoids recomputation by storing results and reusing them.


    DP vs Recursion

    TechniqueDescription
    RecursionSolves by calling itself repeatedly; may repeat work
    Dynamic ProgrammingRecursion + memory (memoization) or iterative table (tabulation)

    Memoization and Tabulation

    Dynamic Programming is implemented using two main approaches.


    Memoization (Top-Down DP)

    Memoization is recursion with caching.

    Steps

    1. Write a recursive solution
    2. Store computed results in a dictionary or array
    3. Return cached results when available

    Advantages

    • Easy to write
    • Natural extension of recursion

    Disadvantages

    • Uses recursion stack
    • Slight overhead due to function calls

    Tabulation (Bottom-Up DP)

    Tabulation builds the solution iteratively from base cases.

    Steps

    1. Identify base cases
    2. Build the DP table iteratively
    3. The final cell contains the answer

    Advantages

    • Faster in practice
    • No recursion overhead

    General Rule

    • Memoization → easier to code
    • Tabulation → better performance

    Classic Dynamic Programming Problems


    Fibonacci Sequence

    Fibonacci is the simplest example of overlapping subproblems.

    Naive Recursive Solution (Inefficient)

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

    Time Complexity: O(2ⁿ) → too slow


    Fibonacci with Memoization (Top-Down)

    def fib_memo(n, memo=None):
        if memo is None:
            memo = {}
    
        if n in memo:
            return memo[n]
    
        if n <= 1:
            return n
    
        memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
        return memo[n]
    
    print(fib_memo(10))  # 55
    

    Complexity

    • Time: O(n)
    • Space: O(n) (memo + recursion stack)

    Fibonacci with Tabulation (Bottom-Up)

    def fib_tab(n):
        if n <= 1:
            return n
    
        dp = [0] * (n + 1)
        dp[1] = 1
    
        for i in range(2, n + 1):
            dp[i] = dp[i-1] + dp[i-2]
    
        return dp[n]
    
    print(fib_tab(10))  # 55
    

    Space-Optimized Fibonacci

    def fib_optimized(n):
        if n <= 1:
            return n
    
        prev2, prev1 = 0, 1
        for _ in range(2, n + 1):
            prev2, prev1 = prev1, prev1 + prev2
    
        return prev1
    

    Space: O(1)


    0/1 Knapsack Problem

    Problem Statement

    You are given:

    • weights[]
    • values[]
    • capacity W

    Each item can be chosen at most once.
    Goal: maximize total value without exceeding capacity.


    DP Idea

    dp[i][w] = maximum value using first i items with capacity w.


    Tabulation Implementation

    def knapsack(weights, values, W):
        n = len(weights)
        dp = [[0] * (W + 1) for _ in range(n + 1)]
    
        for i in range(1, n + 1):
            for w in range(W + 1):
                if weights[i-1] <= w:
                    dp[i][w] = max(
                        dp[i-1][w],
                        values[i-1] + dp[i-1][w - weights[i-1]]
                    )
                else:
                    dp[i][w] = dp[i-1][w]
    
        return dp[n][W]
    
    print(knapsack([2, 3, 4, 5], [3, 4, 5, 6], 5))  # 7
    

    Complexity

    • Time: O(n × W)
    • Space: O(n × W)

    Longest Common Subsequence (LCS)

    Problem Statement

    Given two strings, find the length of the longest subsequence common to both.

    Example

    s1 = "abcde"
    s2 = "ace"
    LCS = "ace" → length = 3
    

    DP Idea

    dp[i][j] = LCS length for s1[:i] and s2[:j]


    Implementation

    def lcs(s1, s2):
        n, m = len(s1), len(s2)
        dp = [[0] * (m + 1) for _ in range(n + 1)]
    
        for i in range(1, n + 1):
            for j in range(1, m + 1):
                if s1[i-1] == s2[j-1]:
                    dp[i][j] = 1 + dp[i-1][j-1]
                else:
                    dp[i][j] = max(dp[i-1][j], dp[i][j-1])
    
        return dp[n][m]
    
    print(lcs("abcde", "ace"))  # 3
    

    Complexity

    • Time: O(n × m)
    • Space: O(n × m)

    Coin Change Problem (Minimum Coins)

    Problem Statement

    Given coin denominations and a target amount, find the minimum number of coins required.

    Example

    coins = [1, 2, 5]
    amount = 11
    Output = 3 (5 + 5 + 1)
    

    DP Idea

    dp[x] = minimum coins needed to make amount x


    Implementation

    def coin_change(coins, amount):
        dp = [float("inf")] * (amount + 1)
        dp[0] = 0
    
        for x in range(1, amount + 1):
            for coin in coins:
                if coin <= x:
                    dp[x] = min(dp[x], 1 + dp[x - coin])
    
        return dp[amount] if dp[amount] != float("inf") else -1
    
    print(coin_change([1, 2, 5], 11))  # 3
    

    Complexity

    • Time: O(amount × number_of_coins)
    • Space: O(amount)

    Summary

    Dynamic Programming improves efficiency by storing results of overlapping subproblems.

    • Memoization (Top-Down): recursion + cache
    • Tabulation (Bottom-Up): iterative DP table
    • Classic DP problems like Fibonacci, Knapsack, LCS, and Coin Change teach core DP patterns used in interviews and real-world optimization systems.

    Mastering DP requires:

    • Identifying states
    • Defining transitions
    • Handling base cases
    • Optimizing space when possible

  • Searching Algorithms in Python

    Searching is the process of finding whether an element exists in a collection and, if required, returning its position (index, node, or reference). Searching is a core operation used everywhere—databases, file systems, search engines, recommendation systems, and filtering features.

    The choice of searching algorithm depends on:

    • The data structure (array, tree, graph, hash map)
    • Whether the data is sorted
    • The type of result needed (existence, index, first/last occurrence, closest value)

    Introduction to Searching Algorithms

    What Searching Tries to Answer

    • Does the element exist?
    • Where is it located?
    • How fast can we find it?

    Factors Affecting Search Performance

    • Input size (n)
    • Data structure used
    • Whether the data is sorted
    • Time and memory constraints

    Linear Search

    Linear search checks elements one by one until the target is found or the list ends.


    When to Use Linear Search

    • Data is unsorted
    • Input size is small
    • Simplicity matters more than speed

    Python Implementation

    def linear_search(arr, target):
        for i in range(len(arr)):
            if arr[i] == target:
                return i
        return -1
    
    print(linear_search([5, 3, 7, 1], 7))  # 2
    print(linear_search([5, 3, 7, 1], 9))  # -1
    

    Complexity

    • Time:
      • Worst/Average: O(n)
      • Best case: O(1) (first element matches)
    • Space: O(1)

    Common Mistake

    Returning only True/False when the problem explicitly asks for the index.


    Binary Search

    Binary search works only on sorted data. It repeatedly divides the search space into halves.


    Why Binary Search Is Powerful

    • Instead of checking n elements, it takes log₂(n) steps
    • Example:
      • 1,000,000 elements → ~20 comparisons

    Requirements

    • Data must be sorted
    • You must define what “match” means (any, first, or last occurrence)

    Binary Search (Iterative)

    def binary_search(arr, target):
        left, right = 0, len(arr) - 1
    
        while left <= right:
            mid = (left + right) // 2
    
            if arr[mid] == target:
                return mid
            elif arr[mid] < target:
                left = mid + 1
            else:
                right = mid - 1
    
        return -1
    
    print(binary_search([1, 3, 5, 7, 9], 7))  # 3
    

    Complexity

    • Time: O(log n)
    • Space: O(1) (iterative version)

    Binary Search Variants (Very Important for Interviews)

    Many interview problems rely on binary search variations, not the basic version.


    First Occurrence (Lower Bound)

    Find the first index where arr[index] == target.

    def first_occurrence(arr, target):
        left, right = 0, len(arr) - 1
        ans = -1
    
        while left <= right:
            mid = (left + right) // 2
            if arr[mid] == target:
                ans = mid
                right = mid - 1
            elif arr[mid] < target:
                left = mid + 1
            else:
                right = mid - 1
    
        return ans
    
    print(first_occurrence([1, 2, 2, 2, 3], 2))  # 1
    

    Last Occurrence (Upper Bound)

    Find the last index where arr[index] == target.

    def last_occurrence(arr, target):
        left, right = 0, len(arr) - 1
        ans = -1
    
        while left <= right:
            mid = (left + right) // 2
            if arr[mid] == target:
                ans = mid
                left = mid + 1
            elif arr[mid] < target:
                left = mid + 1
            else:
                right = mid - 1
    
        return ans
    
    print(last_occurrence([1, 2, 2, 2, 3], 2))  # 3
    

    Common Binary Search Mistakes

    • Infinite loop due to incorrect boundary updates
    • Using left < right instead of left <= right
    • Incorrect mid update logic
    • Forgetting sorted-data requirement

    Searching in Trees

    Tree searching depends on traversal strategy and tree structure.


    Searching in a Binary Search Tree (BST)

    BSTs allow efficient searching due to ordering.

    def search_bst(root, target):
        if root is None:
            return False
        if root.data == target:
            return True
        if target < root.data:
            return search_bst(root.left, target)
        else:
            return search_bst(root.right, target)
    

    Complexity

    • Average: O(log n) (balanced BST)
    • Worst: O(n) (skewed BST)

    Searching in Graphs

    Graphs are searched using traversal algorithms.


    BFS Search (Shortest Path in Unweighted Graphs)

    from collections import deque
    
    def bfs_search(graph, start, target):
        visited = set([start])
        q = deque([start])
    
        while q:
            node = q.popleft()
            if node == target:
                return True
            for neighbor in graph[node]:
                if neighbor not in visited:
                    visited.add(neighbor)
                    q.append(neighbor)
        return False
    

    DFS Search

    def dfs_search(graph, start, target, visited=None):
        if visited is None:
            visited = set()
    
        if start == target:
            return True
    
        visited.add(start)
    
        for neighbor in graph[start]:
            if neighbor not in visited:
                if dfs_search(graph, neighbor, target, visited):
                    return True
        return False
    

    Complexity (BFS / DFS)

    • Time: O(V + E)
    • Space: O(V) (visited + queue/stack)

    Summary

    Searching algorithms depend heavily on data structure and ordering:

    • Linear Search: Simple, works on unsorted data, but slow
    • Binary Search: Extremely fast but requires sorted input
    • Binary Search Variants: First/last occurrence are interview favorites
    • Trees: BST search is efficient when balanced
    • Graphs: BFS and DFS are fundamental traversal-based searches

  • Sorting Algorithms in Python

    Sorting means arranging elements in a specific order (usually ascending or descending). It is one of the most important topics in DSA because many problems become much easier once the data is sorted—such as binary search, two pointers, interval merging, duplicate handling, and greedy strategies.

    Sorting algorithms are commonly compared based on:

    • Time complexity (how fast they run)
    • Space complexity (extra memory used)
    • Stability (whether equal elements keep their original order)

    Introduction to Sorting and Why It Matters

    Why Sorting Is Important

    • Enables binary search (requires sorted data)
    • Simplifies patterns like two pointers and sliding window
    • Essential for greedy algorithms and interval problems
    • Used in ranking, scheduling, and data processing systems

    Key Properties of Sorting Algorithms

    1. Time Complexity
      • Best case
      • Average case
      • Worst case
    2. Space Usage
      • In-place (O(1) extra space)
      • Not in-place (uses extra memory)
    3. Stability
      • Stable sort: equal elements preserve original order
      • Unstable sort: order of equal elements may change

    Basic Sorting Algorithms

    These algorithms are mainly used for learning and small inputs.


    Bubble Sort

    Bubble Sort repeatedly compares adjacent elements and swaps them if they are in the wrong order. After each pass, the largest element “bubbles” to the end.

    How It Works

    • Compare a[0] and a[1], swap if needed
    • Compare a[1] and a[2], swap if needed
    • Continue till the end
    • Repeat passes until no swaps occur

    Python Implementation

    def bubble_sort(arr):
        n = len(arr)
        for i in range(n):
            swapped = False
            for j in range(0, n - i - 1):
                if arr[j] > arr[j + 1]:
                    arr[j], arr[j + 1] = arr[j + 1], arr[j]
                    swapped = True
            if not swapped:
                break
        return arr
    
    print(bubble_sort([5, 1, 4, 2, 8]))
    

    Complexity

    • Time:
      • Best: O(n) (already sorted with swap check)
      • Average/Worst: O(n²)
    • Space: O(1)
    • Stable: Yes

    Usage: Educational only; not suitable for large datasets.


    Selection Sort

    Selection Sort repeatedly selects the minimum element from the unsorted portion and places it at the beginning.

    How It Works

    • Find the smallest element
    • Swap it with the first unsorted position
    • Repeat for the remaining list

    Python Implementation

    def selection_sort(arr):
        n = len(arr)
        for i in range(n):
            min_index = i
            for j in range(i + 1, n):
                if arr[j] < arr[min_index]:
                    min_index = j
            arr[i], arr[min_index] = arr[min_index], arr[i]
        return arr
    
    print(selection_sort([64, 25, 12, 22, 11]))
    

    Complexity

    • Time: O(n²) (best/average/worst)
    • Space: O(1)
    • Stable: No (due to swapping)

    Usage: Simple, minimal swaps, but inefficient for large inputs.


    Insertion Sort

    Insertion Sort builds the sorted array one element at a time—similar to sorting cards in hand.

    How It Works

    • Take the next element
    • Shift larger elements to the right
    • Insert the element in the correct position

    Python Implementation

    def insertion_sort(arr):
        for i in range(1, len(arr)):
            key = arr[i]
            j = i - 1
    
            while j >= 0 and arr[j] > key:
                arr[j + 1] = arr[j]
                j -= 1
    
            arr[j + 1] = key
        return arr
    
    print(insertion_sort([12, 11, 13, 5, 6]))
    

    Complexity

    • Best: O(n) (already sorted)
    • Average/Worst: O(n²)
    • Space: O(1)
    • Stable: Yes

    Usage: Very good for small or nearly sorted datasets.


    Advanced Sorting Algorithms

    These are used in real-world systems.


    Merge Sort

    Merge Sort is a divide-and-conquer algorithm:

    1. Divide array into halves
    2. Sort each half recursively
    3. Merge the sorted halves

    Why Merge Sort Is Powerful

    • Guaranteed O(n log n) time
    • Stable
    • Works well for linked lists and large files (external sorting)

    Python Implementation

    def merge_sort(arr):
        if len(arr) <= 1:
            return arr
    
        mid = len(arr) // 2
        left = merge_sort(arr[:mid])
        right = merge_sort(arr[mid:])
    
        return merge(left, right)
    
    def merge(left, right):
        result = []
        i = j = 0
    
        while i < len(left) and j < len(right):
            if left[i] <= right[j]:
                result.append(left[i])
                i += 1
            else:
                result.append(right[j])
                j += 1
    
        result.extend(left[i:])
        result.extend(right[j:])
        return result
    
    print(merge_sort([38, 27, 43, 3, 9, 82, 10]))
    

    Complexity

    • Time: O(n log n) (all cases)
    • Space: O(n)
    • Stable: Yes

    Quick Sort

    Quick Sort also uses divide and conquer:

    1. Choose a pivot
    2. Partition elements smaller and larger than pivot
    3. Recursively sort partitions

    Important Note

    Quick Sort is extremely fast in practice, but has worst-case O(n²) if pivot selection is poor.


    Python Implementation (Simple Version)

    def quick_sort(arr):
        if len(arr) <= 1:
            return arr
    
        pivot = arr[len(arr)//2]
        left = [x for x in arr if x < pivot]
        mid = [x for x in arr if x == pivot]
        right = [x for x in arr if x > pivot]
    
        return quick_sort(left) + mid + quick_sort(right)
    
    print(quick_sort([10, 7, 8, 9, 1, 5]))
    

    Complexity

    • Average: O(n log n)
    • Worst: O(n²)
    • Space: Depends on implementation
    • Stable: No

    Heap Sort

    Heap Sort uses a heap (priority queue):

    1. Build a heap
    2. Repeatedly extract min/max

    Python Implementation (Using heapq)

    import heapq
    
    def heap_sort(arr):
        heapq.heapify(arr)
        result = []
        while arr:
            result.append(heapq.heappop(arr))
        return result
    
    print(heap_sort([12, 11, 13, 5, 6, 7]))
    

    Complexity

    • Time: O(n log n)
    • Space: O(n) (in this implementation)
    • Stable: No

    Comparison of Sorting Algorithms

    Time Complexity Summary

    • O(n²): Bubble, Selection, Insertion
    • O(n log n): Merge, Quick (avg), Heap

    Stability Summary

    • Stable: Bubble, Insertion, Merge
    • Unstable: Selection, Quick, Heap

    When to Use Which Algorithm

    • Small / nearly sorted data: Insertion Sort
    • Guaranteed performance + stability: Merge Sort
    • Fast average performance: Quick Sort
    • Priority-based operations: Heap Sort

    Summary

    Sorting is a foundational skill in DSA.

    • Basic sorting algorithms build intuition and understanding.
    • Advanced algorithms like Merge Sort, Quick Sort, and Heap Sort are used in real systems.
    • Choosing the right sorting algorithm depends on input size, memory constraints, and stability requirements.

  • Hashing (Hash Map) in Python

    Hashing is a technique used to store and retrieve data efficiently using key-value pairs. Python’s dict is a built-in hash map that provides very fast average operations.

    A hash map is one of the most important tools in DSA because it reduces search time from O(n) to O(1) average.


    Introduction to Hashing and Hash Functions

    What is a Hash Function?

    A hash function takes an input (key) and returns an integer index (hash code).

    Example idea:

    key -> hash(key) -> index in table -> store value
    

    What makes a good hash function?

    • Fast to compute
    • Distributes keys evenly
    • Minimizes collisions

    What is a Collision?

    A collision happens when two different keys produce the same index.

    Example:

    • hash(“abc”) % 10 = 3
    • hash(“xyz”) % 10 = 3 → collision

    Implementing Hash Tables/Dictionaries in Python

    Python dictionary:

    • Uses hashing internally
    • Average lookup/insert/delete is O(1)

    Example:

    student = {
        "name": "Amit",
        "age": 21,
        "course": "DSA"
    }
    
    print(student["name"])  # Amit
    

    Insertion / Update

    student["grade"] = "A"
    student["age"] = 22
    

    Deletion

    del student["course"]
    

    Safe Lookup

    print(student.get("course", "Not found"))
    

    Handling Collisions

    Internally, hash tables handle collisions using strategies like:

    Chaining

    Each index stores a list of key-value pairs.

    Conceptual example:

    index 3 -> [(k1,v1), (k2,v2)]
    

    Open Addressing

    If the slot is occupied, search another slot (linear probing/quadratic probing).

    Python’s dict uses a probing-based approach (implementation detail).


    Applications of Hashing

    • Fast lookup (username → user object)
    • Counting frequencies (most common in interviews)
    • Caching (store results of expensive operations)
    • Duplicate detection (using sets/dicts)
    • Two-sum style problems

    Example: Frequency Count

    from collections import Counter
    
    nums = [1, 2, 1, 3, 2, 1]
    freq = Counter(nums)
    
    print(freq)  # Counter({1: 3, 2: 2, 3: 1})
    

    Example: First non-repeating character

    from collections import Counter
    
    s = "aabbcddee"
    count = Counter(s)
    
    for ch in s:
        if count[ch] == 1:
            print(ch)  # c
            break
    

    Summary

    Hashing enables fast search using key-value mapping. Python dictionaries are highly optimized hash tables. Hash maps are essential for frequency problems, caching, and lookup-based algorithms.

  • Graphs in Python

    A graph is a non-linear data structure used to represent relationships between objects. Graphs are everywhere: social networks, road maps, computer networks, recommendation systems, dependency management, and more.

    A graph is made of:

    • Vertices (Nodes): the entities (people, cities, computers)
    • Edges (Connections): relationships between entities (friendship, roads, cables)

    Graphs can model both simple and complex real-world systems, and many important algorithms (BFS, DFS, shortest path, MST) are built on graphs.


    Introduction to Graphs (Terminology)

    Vertex (Node)

    A vertex represents an entity in the graph (like a person in a social network).

    Edge

    An edge connects two vertices and represents a relationship.

    Directed vs Undirected Graph

    • Undirected: edge goes both ways (A — B)
    • Directed: edge has direction (A → B)

    Example:

    • Instagram follow: directed
    • Facebook friendship: undirected

    Weighted vs Unweighted Graph

    • Unweighted: all edges have equal cost
    • Weighted: edges have weights (distance, time, cost)

    Example:

    • Map roads with distance → weighted graph

    Degree

    • Degree (undirected): number of edges connected to a node
    • In-degree (directed): edges coming into node
    • Out-degree (directed): edges leaving node

    Path

    A path is a sequence of vertices connected by edges.

    Cycle

    A cycle occurs when you can start at a node and return to it by following edges.


    Representation of Graphs

    Graphs can be stored in different ways. The best choice depends on how dense the graph is and what operations you need.

    Adjacency Matrix

    A 2D matrix where:

    • matrix[u][v] = 1 (or weight) if edge exists
    • otherwise 0 (or infinity)

    Example:

    # 4 nodes: 0,1,2,3
    matrix = [
        [0, 1, 1, 0],
        [1, 0, 0, 1],
        [1, 0, 0, 1],
        [0, 1, 1, 0]
    ]
    

    Pros:

    • Very fast edge lookup O(1)

    Cons:

    • Uses O(V^2) memory (bad for large sparse graphs)

    Adjacency List (Most common)

    Store for each node: list of its neighbors.

    Example:

    graph = {
        0: [1, 2],
        1: [0, 3],
        2: [0, 3],
        3: [1, 2]
    }
    

    Pros:

    • Memory efficient for sparse graphs: O(V + E)
    • Great for traversals (BFS/DFS)

    Cons:

    • Edge lookup can be slower than matrix

    Implementing Graphs in Python

    Using a Dictionary (Adjacency List)

    from collections import defaultdict
    
    graph = defaultdict(list)
    
    # Add edges (undirected example)
    graph[0].append(1)
    graph[1].append(0)
    
    graph[0].append(2)
    graph[2].append(0)
    

    Weighted Graph (Adjacency List)

    Store pairs: (neighbor, weight)

    graph = {
        "A": [("B", 4), ("C", 2)],
        "B": [("A", 4), ("D", 5)],
        "C": [("A", 2), ("D", 1)],
        "D": [("B", 5), ("C", 1)]
    }
    

    Graph Traversal Algorithms

    Traversal means visiting all reachable nodes in a structured way.

    Depth-First Search (DFS)

    DFS explores as deep as possible before backtracking.

    Use cases:

    • Cycle detection
    • Topological sorting
    • Connected components
    • Maze solving

    DFS (Recursive)

    def dfs(graph, start, visited=None):
        if visited is None:
            visited = set()
    
        visited.add(start)
        print(start, end=" ")
    
        for neighbor in graph[start]:
            if neighbor not in visited:
                dfs(graph, neighbor, visited)
    

    DFS (Iterative using Stack)

    def dfs_iterative(graph, start):
        visited = set()
        stack = [start]
    
        while stack:
            node = stack.pop()
            if node not in visited:
                visited.add(node)
                print(node, end=" ")
                for neighbor in reversed(graph[node]):
                    if neighbor not in visited:
                        stack.append(neighbor)
    

    Time complexity: O(V + E)


    Breadth-First Search (BFS)

    BFS explores level by level using a queue.

    Use cases:

    • Shortest path in unweighted graphs
    • Level order traversal
    • Finding minimum edges between nodes

    BFS Implementation

    from collections import deque
    
    def bfs(graph, start):
        visited = set([start])
        q = deque([start])
    
        while q:
            node = q.popleft()
            print(node, end=" ")
            for neighbor in graph[node]:
                if neighbor not in visited:
                    visited.add(neighbor)
                    q.append(neighbor)
    

    Time complexity: O(V + E)


    Shortest Path Algorithms

    Shortest path algorithms find the minimum distance between nodes in weighted graphs.

    Dijkstra’s Algorithm

    Dijkstra finds shortest path from a source node to all others when all weights are non-negative.

    Core idea:

    • Always expand the node with the smallest known distance
    • Use a min-heap (priority queue)

    Dijkstra Implementation (Using heapq)

    import heapq
    
    def dijkstra(graph, start):
        distances = {node: float("inf") for node in graph}
        distances[start] = 0
    
        pq = [(0, start)]  # (distance, node)
    
        while pq:
            current_dist, node = heapq.heappop(pq)
    
            if current_dist > distances[node]:
                continue
    
            for neighbor, weight in graph[node]:
                new_dist = current_dist + weight
                if new_dist < distances[neighbor]:
                    distances[neighbor] = new_dist
                    heapq.heappush(pq, (new_dist, neighbor))
    
        return distances
    

    Time complexity: O((V + E) log V)

    When not to use: If graph has negative edges.


    Bellman-Ford Algorithm

    Bellman-Ford works even if there are negative weights and can detect negative cycles.

    Core idea:
    Relax all edges V-1 times.

    Bellman-Ford Implementation

    def bellman_ford(vertices, edges, start):
        dist = {v: float("inf") for v in vertices}
        dist[start] = 0
    
        for _ in range(len(vertices) - 1):
            for u, v, w in edges:
                if dist[u] != float("inf") and dist[u] + w < dist[v]:
                    dist[v] = dist[u] + w
    
        # Detect negative cycle
        for u, v, w in edges:
            if dist[u] != float("inf") and dist[u] + w < dist[v]:
                raise ValueError("Graph contains a negative weight cycle")
    
        return dist
    

    Time complexity: O(V * E)


    Applications of Graphs

    • Networking: routing packets between routers
    • Social networks: friend/follow relationships
    • Maps: shortest route between locations
    • Dependencies: build systems, course prerequisites
    • Recommendation systems: graph-based suggestions

    Summary

    Graphs represent relationships and power many real-world systems. Adjacency lists are most common in Python. BFS and DFS are fundamental traversals, while Dijkstra and Bellman-Ford solve shortest path problems under different weight constraints.