Practice Focus

Level 3: Intermediate Advanced - Master complex problem-solving strategies and interview preparation

Practice Focus
Complex Problem Decomposition
Breaking Down Complex Problems
Complex problems often require systematic decomposition into smaller, manageable subproblems. This involves identifying patterns, recognizing familiar structures, and applying appropriate algorithms.
🏗️ Real-Life Analogy:
Think of building a house: you don't build everything at once. You break it down into foundation, framing, plumbing, electrical, and finishing. Each part has its own requirements and can be tackled separately.

🎯 Decomposition Strategies:

  • Identify Core Problem: Strip away details to find the fundamental challenge
  • Recognize Patterns: Map to known problem types (graph, DP, greedy, etc.)
  • Define Subproblems: Break into smaller, solvable pieces
  • Build Solutions: Combine subproblem solutions systematically
TypeScript
// Example: Word Break Problem Decomposition
// Problem: Can we break string s into words from dictionary?

// Step 1: Identify core problem - sequence segmentation
// Step 2: Recognize pattern - dynamic programming
// Step 3: Define subproblems - can break s[0:i]?
// Step 4: Build solution - combine smaller solutions

class WordBreakSolver {
    // Approach 1: Top-down with memoization
    wordBreakMemo(s: string, wordDict: string[]): boolean {
        const wordSet = new Set(wordDict);
        const memo = new Map();
        
        return this.canBreak(s, 0, wordSet, memo);
    }
    
    private canBreak(s: string, start: number, 
                    wordSet: Set, 
                    memo: Map): boolean {
        // Base case
        if (start === s.length) return true;
        
        // Check memo
        if (memo.has(start)) return memo.get(start)!;
        
        // Try all possible word endings
        for (let end = start + 1; end <= s.length; end++) {
            const word = s.substring(start, end);
            if (wordSet.has(word) && this.canBreak(s, end, wordSet, memo)) {
                memo.set(start, true);
                return true;
            }
        }
        
        memo.set(start, false);
        return false;
    }
    
    // Approach 2: Bottom-up dynamic programming
    wordBreakDP(s: string, wordDict: string[]): boolean {
        const wordSet = new Set(wordDict);
        const n = s.length;
        const dp = new Array(n + 1).fill(false);
        dp[0] = true; // Empty string
        
        for (let i = 1; i <= n; i++) {
            for (let j = 0; j < i; j++) {
                if (dp[j] && wordSet.has(s.substring(j, i))) {
                    dp[i] = true;
                    break;
                }
            }
        }
        
        return dp[n];
    }
    
    // Approach 3: BFS for finding all solutions
    wordBreakAllSolutions(s: string, wordDict: string[]): string[][] {
        const wordSet = new Set(wordDict);
        const memo = new Map();
        
        return this.findAllBreaks(s, 0, wordSet, memo);
    }
    
    private findAllBreaks(s: string, start: number,
                         wordSet: Set,
                         memo: Map): string[][] {
        if (memo.has(start)) return memo.get(start)!;
        
        const result: string[][] = [];
        
        if (start === s.length) {
            result.push([]);
            return result;
        }
        
        for (let end = start + 1; end <= s.length; end++) {
            const word = s.substring(start, end);
            if (wordSet.has(word)) {
                const suffixBreaks = this.findAllBreaks(s, end, wordSet, memo);
                for (const suffix of suffixBreaks) {
                    result.push([word, ...suffix]);
                }
            }
        }
        
        memo.set(start, result);
        return result;
    }
}

// Example: Complex scheduling problem
// Problem: Schedule tasks with dependencies and resource constraints

interface Task {
    id: string;
    duration: number;
    dependencies: string[];
    resources: string[];
}

class TaskScheduler {
    // Decomposition:
    // 1. Topological sort for dependencies
    // 2. Resource conflict resolution
    // 3. Time optimization
    
    scheduleTask(tasks: Task[]): Map {
        // Step 1: Build dependency graph
        const graph = this.buildDependencyGraph(tasks);
        
        // Step 2: Topological sort
        const order = this.topologicalSort(graph);
        
        // Step 3: Schedule with resource constraints
        return this.scheduleWithResources(tasks, order);
    }
    
    private buildDependencyGraph(tasks: Task[]): Map {
        const graph = new Map();
        const taskMap = new Map();
        
        for (const task of tasks) {
            taskMap.set(task.id, task);
            graph.set(task.id, []);
        }
        
        for (const task of tasks) {
            for (const dep of task.dependencies) {
                graph.get(dep)!.push(task.id);
            }
        }
        
        return graph;
    }
    
    private topologicalSort(graph: Map): string[] {
        const inDegree = new Map();
        const queue: string[] = [];
        const result: string[] = [];
        
        // Calculate in-degrees
        for (const [node, _] of graph) {
            inDegree.set(node, 0);
        }
        
        for (const [_, neighbors] of graph) {
            for (const neighbor of neighbors) {
                inDegree.set(neighbor, inDegree.get(neighbor)! + 1);
            }
        }
        
        // Find nodes with no dependencies
        for (const [node, degree] of inDegree) {
            if (degree === 0) queue.push(node);
        }
        
        // Process queue
        while (queue.length > 0) {
            const current = queue.shift()!;
            result.push(current);
            
            for (const neighbor of graph.get(current)!) {
                const newDegree = inDegree.get(neighbor)! - 1;
                inDegree.set(neighbor, newDegree);
                if (newDegree === 0) queue.push(neighbor);
            }
        }
        
        return result;
    }
    
    private scheduleWithResources(tasks: Task[], 
                                 order: string[]): Map {
        const schedule = new Map();
        const resourceAvailable = new Map();
        const taskMap = new Map();
        
        for (const task of tasks) {
            taskMap.set(task.id, task);
        }
        
        for (const taskId of order) {
            const task = taskMap.get(taskId)!;
            let startTime = 0;
            
            // Check dependencies
            for (const dep of task.dependencies) {
                const depEnd = schedule.get(dep)! + taskMap.get(dep)!.duration;
                startTime = Math.max(startTime, depEnd);
            }
            
            // Check resource availability
            for (const resource of task.resources) {
                const available = resourceAvailable.get(resource) || 0;
                startTime = Math.max(startTime, available);
            }
            
            // Schedule task
            schedule.set(taskId, startTime);
            
            // Update resource availability
            for (const resource of task.resources) {
                resourceAvailable.set(resource, startTime + task.duration);
            }
        }
        
        return schedule;
    }
}
                                
Python
# Problem Decomposition Example: Island Problems
# Shows how similar problems can be solved with pattern recognition

class IslandProblemSolver:
    """Demonstrates decomposition of various island-related problems"""
    
    def __init__(self):
        self.directions = [(0, 1), (1, 0), (0, -1), (-1, 0)]
    
    # Problem 1: Number of Islands
    def num_islands(self, grid):
        """Count number of islands in grid"""
        if not grid or not grid[0]:
            return 0
        
        rows, cols = len(grid), len(grid[0])
        visited = [[False] * cols for _ in range(rows)]
        count = 0
        
        def dfs(r, c):
            if (r < 0 or r >= rows or c < 0 or c >= cols or
                visited[r][c] or grid[r][c] == '0'):
                return
            
            visited[r][c] = True
            for dr, dc in self.directions:
                dfs(r + dr, c + dc)
        
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == '1' and not visited[r][c]:
                    dfs(r, c)
                    count += 1
        
        return count
    
    # Problem 2: Max Area of Island
    def max_area_of_island(self, grid):
        """Find maximum area of any island"""
        if not grid or not grid[0]:
            return 0
        
        rows, cols = len(grid), len(grid[0])
        max_area = 0
        
        def dfs(r, c):
            if (r < 0 or r >= rows or c < 0 or c >= cols or
                grid[r][c] == 0):
                return 0
            
            grid[r][c] = 0  # Mark visited
            area = 1
            
            for dr, dc in self.directions:
                area += dfs(r + dr, c + dc)
            
            return area
        
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == 1:
                    max_area = max(max_area, dfs(r, c))
        
        return max_area
    
    # Problem 3: Island Perimeter
    def island_perimeter(self, grid):
        """Calculate perimeter of the island"""
        if not grid or not grid[0]:
            return 0
        
        rows, cols = len(grid), len(grid[0])
        perimeter = 0
        
        for r in range(rows):
            for c in range(cols):
                if grid[r][c] == 1:
                    # Check all 4 sides
                    for dr, dc in self.directions:
                        nr, nc = r + dr, c + dc
                        # Add to perimeter if edge or water
                        if (nr < 0 or nr >= rows or nc < 0 or nc >= cols or
                            grid[nr][nc] == 0):
                            perimeter += 1
        
        return perimeter
    
    # Problem 4: Surrounded Regions (capture problem)
    def solve_surrounded_regions(self, board):
        """Capture regions surrounded by 'X'"""
        if not board or not board[0]:
            return
        
        rows, cols = len(board), len(board[0])
        
        def dfs(r, c):
            if (r < 0 or r >= rows or c < 0 or c >= cols or
                board[r][c] != 'O'):
                return
            
            board[r][c] = 'E'  # Mark edge-connected
            
            for dr, dc in self.directions:
                dfs(r + dr, c + dc)
        
        # Mark edge-connected regions
        for r in range(rows):
            dfs(r, 0)
            dfs(r, cols - 1)
        
        for c in range(cols):
            dfs(0, c)
            dfs(rows - 1, c)
        
        # Capture surrounded regions
        for r in range(rows):
            for c in range(cols):
                if board[r][c] == 'O':
                    board[r][c] = 'X'
                elif board[r][c] == 'E':
                    board[r][c] = 'O'

# Complex Problem Decomposition: Meeting Scheduler
class MeetingScheduler:
    """Demonstrates decomposition of scheduling problems"""
    
    def find_meeting_time(self, calendars, meeting_duration):
        """
        Find common free time for meeting
        Decomposition:
        1. Merge individual calendars
        2. Find free slots
        3. Check duration constraint
        """
        # Step 1: Merge all busy times
        all_busy = []
        for calendar in calendars:
            all_busy.extend(calendar)
        
        # Step 2: Sort and merge overlapping intervals
        all_busy.sort()
        merged = []
        
        for start, end in all_busy:
            if not merged or merged[-1][1] < start:
                merged.append([start, end])
            else:
                merged[-1][1] = max(merged[-1][1], end)
        
        # Step 3: Find gaps of sufficient duration
        free_slots = []
        
        # Check before first meeting
        if merged and merged[0][0] >= meeting_duration:
            free_slots.append([0, merged[0][0]])
        
        # Check between meetings
        for i in range(1, len(merged)):
            gap_start = merged[i-1][1]
            gap_end = merged[i][0]
            if gap_end - gap_start >= meeting_duration:
                free_slots.append([gap_start, gap_end])
        
        # Check after last meeting (assuming day ends at 1440 minutes)
        if merged and 1440 - merged[-1][1] >= meeting_duration:
            free_slots.append([merged[-1][1], 1440])
        
        return free_slots
    
    def schedule_interviews(self, candidates, interviewers, duration):
        """
        Complex scheduling with multiple constraints
        Decomposition:
        1. Model as bipartite matching
        2. Apply constraints
        3. Optimize for preferences
        """
        from collections import defaultdict
        
        # Build availability graph
        graph = defaultdict(list)
        
        for candidate in candidates:
            for interviewer in interviewers:
                # Check if they have common available time
                common_slots = self.find_common_slots(
                    candidate['available'],
                    interviewer['available'],
                    duration
                )
                if common_slots:
                    graph[candidate['id']].append({
                        'interviewer': interviewer['id'],
                        'slots': common_slots,
                        'preference': candidate['preferences'].get(interviewer['id'], 0)
                    })
        
        # Use Hungarian algorithm or greedy matching
        assignments = self.match_interviews(graph, candidates, interviewers)
        
        return assignments
    
    def find_common_slots(self, slots1, slots2, duration):
        """Find overlapping time slots of sufficient duration"""
        common = []
        i = j = 0
        
        while i < len(slots1) and j < len(slots2):
            start = max(slots1[i][0], slots2[j][0])
            end = min(slots1[i][1], slots2[j][1])
            
            if end - start >= duration:
                common.append([start, end])
            
            if slots1[i][1] < slots2[j][1]:
                i += 1
            else:
                j += 1
        
        return common
                                
OCaml
(* Problem Decomposition Patterns *)

(* Pattern 1: Recursive decomposition with memoization *)
module WordBreak = struct
  let word_break s word_dict =
    let word_set = List.fold_left (fun set w -> 
      StringSet.add w set
    ) StringSet.empty word_dict in
    
    let memo = Hashtbl.create 100 in
    
    let rec can_break start =
      if start = String.length s then true
      else if Hashtbl.mem memo start then
        Hashtbl.find memo start
      else
        let result = ref false in
        for end_pos = start + 1 to String.length s do
          let word = String.sub s start (end_pos - start) in
          if StringSet.mem word word_set && can_break end_pos then
            result := true
        done;
        Hashtbl.add memo start !result;
        !result
    in
    
    can_break 0

  (* Find all possible word breaks *)
  let all_word_breaks s word_dict =
    let word_set = List.fold_left (fun set w -> 
      StringSet.add w set
    ) StringSet.empty word_dict in
    
    let memo = Hashtbl.create 100 in
    
    let rec find_breaks start =
      if Hashtbl.mem memo start then
        Hashtbl.find memo start
      else
        let results = 
          if start = String.length s then
            [[]]
          else
            let all_results = ref [] in
            for end_pos = start + 1 to String.length s do
              let word = String.sub s start (end_pos - start) in
              if StringSet.mem word word_set then
                let suffix_breaks = find_breaks end_pos in
                List.iter (fun suffix ->
                  all_results := (word :: suffix) :: !all_results
                ) suffix_breaks
            done;
            !all_results
        in
        Hashtbl.add memo start results;
        results
    in
    
    find_breaks 0
end

(* Pattern 2: Graph-based decomposition *)
module TaskScheduler = struct
  type task = {
    id: string;
    duration: int;
    dependencies: string list;
    resources: string list;
  }
  
  type schedule = (string * int) list
  
  let schedule_tasks tasks =
    (* Step 1: Build dependency graph *)
    let graph = Hashtbl.create (List.length tasks) in
    let in_degree = Hashtbl.create (List.length tasks) in
    
    List.iter (fun task ->
      Hashtbl.add graph task.id [];
      Hashtbl.add in_degree task.id 0
    ) tasks;
    
    List.iter (fun task ->
      List.iter (fun dep ->
        let deps = Hashtbl.find graph dep in
        Hashtbl.replace graph dep (task.id :: deps);
        let degree = Hashtbl.find in_degree task.id in
        Hashtbl.replace in_degree task.id (degree + 1)
      ) task.dependencies
    ) tasks;
    
    (* Step 2: Topological sort *)
    let queue = Queue.create () in
    Hashtbl.iter (fun id degree ->
      if degree = 0 then Queue.push id queue
    ) in_degree;
    
    let order = ref [] in
    while not (Queue.is_empty queue) do
      let current = Queue.pop queue in
      order := current :: !order;
      
      List.iter (fun neighbor ->
        let degree = Hashtbl.find in_degree neighbor - 1 in
        Hashtbl.replace in_degree neighbor degree;
        if degree = 0 then Queue.push neighbor queue
      ) (Hashtbl.find graph current)
    done;
    
    (* Step 3: Schedule with resource constraints *)
    let schedule = Hashtbl.create (List.length tasks) in
    let resource_available = Hashtbl.create 10 in
    let task_map = List.fold_left (fun map task ->
      StringMap.add task.id task map
    ) StringMap.empty tasks in
    
    List.iter (fun task_id ->
      let task = StringMap.find task_id task_map in
      let start_time = ref 0 in
      
      (* Check dependencies *)
      List.iter (fun dep ->
        let dep_task = StringMap.find dep task_map in
        let dep_end = (Hashtbl.find schedule dep) + dep_task.duration in
        start_time := max !start_time dep_end
      ) task.dependencies;
      
      (* Check resources *)
      List.iter (fun resource ->
        let available = 
          try Hashtbl.find resource_available resource
          with Not_found -> 0
        in
        start_time := max !start_time available
      ) task.resources;
      
      (* Schedule task *)
      Hashtbl.add schedule task_id !start_time;
      
      (* Update resources *)
      List.iter (fun resource ->
        Hashtbl.replace resource_available resource 
          (!start_time + task.duration)
      ) task.resources
    ) (List.rev !order);
    
    schedule
end

(* Pattern 3: Dynamic programming decomposition *)
module IntervalScheduling = struct
  type interval = {
    start_time: int;
    end_time: int;
    value: int;
  }
  
  (* Maximum value interval scheduling *)
  let max_value_schedule intervals =
    (* Sort by end time *)
    let sorted = List.sort (fun a b -> 
      compare a.end_time b.end_time
    ) intervals in
    
    let n = List.length sorted in
    let arr = Array.of_list sorted in
    let dp = Array.make n 0 in
    
    (* Find latest non-overlapping interval *)
    let find_latest_non_overlapping i =
      let rec binary_search low high =
        if low > high then -1
        else
          let mid = (low + high) / 2 in
          if arr.(mid).end_time <= arr.(i).start_time then
            if mid + 1 <= high && 
               arr.(mid + 1).end_time <= arr.(i).start_time then
              binary_search (mid + 1) high
            else mid
          else
            binary_search low (mid - 1)
      in
      binary_search 0 (i - 1)
    in
    
    (* Fill DP table *)
    dp.(0) <- arr.(0).value;
    
    for i = 1 to n - 1 do
      let include_current = arr.(i).value in
      let latest = find_latest_non_overlapping i in
      let include_value = 
        if latest >= 0 then
          include_current + dp.(latest)
        else
          include_current
      in
      dp.(i) <- max include_value dp.(i - 1)
    done;
    
    dp.(n - 1)
end
                                
Algorithm Design Patterns
Common Algorithm Design Patterns
Recognizing and applying common algorithm design patterns can dramatically simplify problem solving. These patterns provide templates for approaching various problem types.
🎨 Real-Life Analogy:
Like an artist who knows different painting techniques (watercolor, oil, acrylic), a programmer should know different algorithm patterns and when to apply each one.

🔧 Key Design Patterns:

  • Two Pointers: Linear traversal with multiple positions
  • Sliding Window: Maintain a range while moving through data
  • Fast & Slow Pointers: Cycle detection and middle finding
  • Merge Intervals: Combining overlapping ranges
  • Top K Elements: Using heaps for selection
Pattern When to Use Time Complexity Example Problems
Two Pointers Sorted arrays, pairs O(n) Two Sum, Container with Water
Sliding Window Subarray/substring O(n) Max Sum Subarray, Longest Substring
Fast & Slow Cycle detection O(n) Linked List Cycle, Happy Number
Merge Intervals Overlapping ranges O(n log n) Meeting Rooms, Insert Interval
Top K Elements Finding k best/worst O(n log k) K Closest Points, Top K Frequent
Optimization Techniques
Advanced Optimization Strategies
Optimization involves improving algorithm performance through various techniques including space-time tradeoffs, preprocessing, caching, and algorithmic improvements.
🏎️ Real-Life Analogy:
Think of optimizing like tuning a race car: you can improve the engine (algorithm), reduce weight (space), add better tires (data structures), or find a shorter track (preprocessing).

⚡ Optimization Strategies:

  • Preprocessing: Compute once, use many times
  • Caching/Memoization: Store computed results
  • Space-Time Tradeoff: Use more memory for speed
  • Early Termination: Stop when answer is found
  • Pruning: Skip unnecessary computations
TypeScript
// Optimization Example: Range Query Problems

// Problem: Answer multiple range sum queries
// Naive: O(n) per query
// Optimized: O(1) per query with O(n) preprocessing

class RangeQueryOptimizer {
    // Technique 1: Prefix Sum for Range Sum
    private prefixSum: number[];
    
    constructor(nums: number[]) {
        this.prefixSum = new Array(nums.length + 1).fill(0);
        for (let i = 0; i < nums.length; i++) {
            this.prefixSum[i + 1] = this.prefixSum[i] + nums[i];
        }
    }
    
    sumRange(left: number, right: number): number {
        return this.prefixSum[right + 1] - this.prefixSum[left];
    }
}

// Technique 2: Sparse Table for Range Minimum
class SparseTable {
    private table: number[][];
    private logs: number[];
    
    constructor(nums: number[]) {
        const n = nums.length;
        const k = Math.floor(Math.log2(n)) + 1;
        
        // Precompute logarithms
        this.logs = new Array(n + 1).fill(0);
        for (let i = 2; i <= n; i++) {
            this.logs[i] = this.logs[Math.floor(i / 2)] + 1;
        }
        
        // Build sparse table
        this.table = Array(k).fill(null).map(() => Array(n).fill(0));
        
        // Initialize for length 1
        for (let i = 0; i < n; i++) {
            this.table[0][i] = nums[i];
        }
        
        // Build for other lengths
        for (let j = 1; j < k; j++) {
            for (let i = 0; i + (1 << j) <= n; i++) {
                this.table[j][i] = Math.min(
                    this.table[j - 1][i],
                    this.table[j - 1][i + (1 << (j - 1))]
                );
            }
        }
    }
    
    rangeMin(left: number, right: number): number {
        const len = right - left + 1;
        const k = this.logs[len];
        return Math.min(
            this.table[k][left],
            this.table[k][right - (1 << k) + 1]
        );
    }
}

// Technique 3: Binary Indexed Tree for Dynamic Updates
class OptimizedBIT {
    private tree: number[];
    private nums: number[];
    
    constructor(nums: number[]) {
        this.nums = [...nums];
        this.tree = new Array(nums.length + 1).fill(0);
        
        // Optimized construction O(n)
        for (let i = 0; i < nums.length; i++) {
            this.updateDelta(i, nums[i]);
        }
    }
    
    private updateDelta(index: number, delta: number): void {
        index++;
        while (index < this.tree.length) {
            this.tree[index] += delta;
            index += index & (-index);
        }
    }
    
    update(index: number, value: number): void {
        const delta = value - this.nums[index];
        this.nums[index] = value;
        this.updateDelta(index, delta);
    }
    
    sumRange(left: number, right: number): number {
        return this.prefixSum(right) - (left > 0 ? this.prefixSum(left - 1) : 0);
    }
    
    private prefixSum(index: number): number {
        index++;
        let sum = 0;
        while (index > 0) {
            sum += this.tree[index];
            index -= index & (-index);
        }
        return sum;
    }
}

// Technique 4: Optimization through Bit Manipulation
class BitOptimizations {
    // Count set bits using Brian Kernighan's algorithm
    countSetBits(n: number): number {
        let count = 0;
        while (n) {
            n &= n - 1; // Clear rightmost set bit
            count++;
        }
        return count;
    }
    
    // Find missing number in O(n) time, O(1) space
    findMissingNumber(nums: number[]): number {
        let xor = 0;
        
        // XOR all array elements
        for (const num of nums) {
            xor ^= num;
        }
        
        // XOR with all numbers from 0 to n
        for (let i = 0; i <= nums.length; i++) {
            xor ^= i;
        }
        
        return xor;
    }
    
    // Check if number is power of 2
    isPowerOfTwo(n: number): boolean {
        return n > 0 && (n & (n - 1)) === 0;
    }
}

// Technique 5: String Matching Optimization
class StringOptimizer {
    // Rolling Hash for substring matching
    findSubstringRollingHash(text: string, pattern: string): number[] {
        const matches: number[] = [];
        const base = 256;
        const prime = 101;
        const m = pattern.length;
        const n = text.length;
        
        if (m > n) return matches;
        
        let patternHash = 0;
        let textHash = 0;
        let h = 1;
        
        // Precompute h = base^(m-1) % prime
        for (let i = 0; i < m - 1; i++) {
            h = (h * base) % prime;
        }
        
        // Initial hashes
        for (let i = 0; i < m; i++) {
            patternHash = (base * patternHash + pattern.charCodeAt(i)) % prime;
            textHash = (base * textHash + text.charCodeAt(i)) % prime;
        }
        
        // Slide and check
        for (let i = 0; i <= n - m; i++) {
            if (patternHash === textHash) {
                // Verify to avoid hash collision
                if (text.substring(i, i + m) === pattern) {
                    matches.push(i);
                }
            }
            
            if (i < n - m) {
                textHash = (base * (textHash - text.charCodeAt(i) * h) + 
                           text.charCodeAt(i + m)) % prime;
                if (textHash < 0) textHash += prime;
            }
        }
        
        return matches;
    }
}
                                
Python
# Optimization Techniques Examples

import bisect
from collections import deque
import heapq

class OptimizationTechniques:
    """Collection of optimization techniques for common problems"""
    
    # Technique 1: Binary Search Optimization
    def search_in_rotated_array(self, nums, target):
        """Find target in rotated sorted array - O(log n)"""
        left, right = 0, len(nums) - 1
        
        while left <= right:
            mid = (left + right) // 2
            
            if nums[mid] == target:
                return mid
            
            # Determine which half is sorted
            if nums[left] <= nums[mid]:
                # Left half is sorted
                if nums[left] <= target < nums[mid]:
                    right = mid - 1
                else:
                    left = mid + 1
            else:
                # Right half is sorted
                if nums[mid] < target <= nums[right]:
                    left = mid + 1
                else:
                    right = mid - 1
        
        return -1
    
    # Technique 2: Monotonic Stack Optimization
    def next_greater_elements(self, nums):
        """Find next greater element for each element - O(n)"""
        n = len(nums)
        result = [-1] * n
        stack = []  # Monotonic decreasing stack
        
        # Process array twice for circular array
        for i in range(2 * n):
            while stack and nums[stack[-1]] < nums[i % n]:
                result[stack.pop()] = nums[i % n]
            
            if i < n:
                stack.append(i)
        
        return result
    
    # Technique 3: Sliding Window with Deque
    def max_sliding_window(self, nums, k):
        """Maximum in sliding window - O(n)"""
        if not nums:
            return []
        
        dq = deque()  # Store indices
        result = []
        
        for i in range(len(nums)):
            # Remove elements outside window
            while dq and dq[0] <= i - k:
                dq.popleft()
            
            # Remove smaller elements
            while dq and nums[dq[-1]] < nums[i]:
                dq.pop()
            
            dq.append(i)
            
            # Add to result after first window
            if i >= k - 1:
                result.append(nums[dq[0]])
        
        return result
    
    # Technique 4: Bucket Sort Optimization
    def top_k_frequent_bucket(self, nums, k):
        """Top K frequent elements using buckets - O(n)"""
        from collections import Counter
        
        count = Counter(nums)
        buckets = [[] for _ in range(len(nums) + 1)]
        
        # Group by frequency
        for num, freq in count.items():
            buckets[freq].append(num)
        
        # Collect top k
        result = []
        for i in range(len(buckets) - 1, -1, -1):
            result.extend(buckets[i])
            if len(result) >= k:
                return result[:k]
        
        return result
    
    # Technique 5: Matrix Optimization
    def search_2d_matrix(self, matrix, target):
        """Search in row and column sorted matrix - O(m + n)"""
        if not matrix or not matrix[0]:
            return False
        
        rows, cols = len(matrix), len(matrix[0])
        row, col = 0, cols - 1  # Start from top-right
        
        while row < rows and col >= 0:
            if matrix[row][col] == target:
                return True
            elif matrix[row][col] > target:
                col -= 1
            else:
                row += 1
        
        return False

# Advanced Caching Techniques
class LRUCache:
    """Least Recently Used Cache with O(1) operations"""
    
    class Node:
        def __init__(self, key=0, value=0):
            self.key = key
            self.value = value
            self.prev = None
            self.next = None
    
    def __init__(self, capacity):
        self.capacity = capacity
        self.cache = {}  # key -> node
        self.head = self.Node()  # Dummy head
        self.tail = self.Node()  # Dummy tail
        self.head.next = self.tail
        self.tail.prev = self.head
    
    def _add_to_head(self, node):
        """Add node right after head"""
        node.prev = self.head
        node.next = self.head.next
        self.head.next.prev = node
        self.head.next = node
    
    def _remove_node(self, node):
        """Remove node from list"""
        prev_node = node.prev
        next_node = node.next
        prev_node.next = next_node
        next_node.prev = prev_node
    
    def get(self, key):
        if key not in self.cache:
            return -1
        
        # Move to head (mark as recently used)
        node = self.cache[key]
        self._remove_node(node)
        self._add_to_head(node)
        
        return node.value
    
    def put(self, key, value):
        if key in self.cache:
            # Update existing
            node = self.cache[key]
            node.value = value
            self._remove_node(node)
            self._add_to_head(node)
        else:
            # Add new
            node = self.Node(key, value)
            self.cache[key] = node
            self._add_to_head(node)
            
            if len(self.cache) > self.capacity:
                # Remove LRU
                lru = self.tail.prev
                self._remove_node(lru)
                del self.cache[lru.key]
                                
OCaml
(* Optimization Techniques in OCaml *)

(* Technique 1: Memoization for Dynamic Programming *)
module Memoize = struct
  (* Generic memoization function *)
  let memoize f =
    let table = Hashtbl.create 100 in
    let rec memoized x =
      try Hashtbl.find table x
      with Not_found ->
        let result = f memoized x in
        Hashtbl.add table x result;
        result
    in
    memoized
  
  (* Example: Fibonacci with memoization *)
  let fib =
    let rec fib_helper memo n =
      if n <= 1 then n
      else memo (n - 1) + memo (n - 2)
    in
    memoize fib_helper
  
  (* Longest Common Subsequence with memoization *)
  let lcs s1 s2 =
    let n1 = String.length s1 in
    let n2 = String.length s2 in
    let memo = Hashtbl.create (n1 * n2) in
    
    let rec lcs_helper i j =
      if i = n1 || j = n2 then 0
      else
        try Hashtbl.find memo (i, j)
        with Not_found ->
          let result =
            if s1.[i] = s2.[j] then
              1 + lcs_helper (i + 1) (j + 1)
            else
              max (lcs_helper (i + 1) j) (lcs_helper i (j + 1))
          in
          Hashtbl.add memo (i, j) result;
          result
    in
    lcs_helper 0 0
end

(* Technique 2: Lazy Evaluation for Optimization *)
module LazyOptimization = struct
  (* Lazy list for infinite sequences *)
  type 'a lazy_list = Nil | Cons of 'a * 'a lazy_list Lazy.t
  
  let rec take n = function
    | Nil -> []
    | Cons (h, t) ->
        if n <= 0 then []
        else h :: take (n - 1) (Lazy.force t)
  
  (* Prime numbers using Sieve of Eratosthenes *)
  let primes =
    let rec sieve = function
      | Nil -> Nil
      | Cons (p, xs) ->
          let rec filter n = function
            | Nil -> Nil
            | Cons (x, xs) ->
                if x mod n = 0 then
                  filter n (Lazy.force xs)
                else
                  Cons (x, lazy (filter n (Lazy.force xs)))
          in
          Cons (p, lazy (sieve (filter p (Lazy.force xs))))
    in
    let rec integers_from n =
      Cons (n, lazy (integers_from (n + 1)))
    in
    sieve (integers_from 2)
end

(* Technique 3: Bit Manipulation Optimizations *)
module BitOptimizations = struct
  (* Count set bits *)
  let count_bits n =
    let rec count n acc =
      if n = 0 then acc
      else count (n land (n - 1)) (acc + 1)
    in
    count n 0
  
  (* Find single number (all others appear twice) *)
  let find_single nums =
    List.fold_left (lxor) 0 nums
  
  (* Power set generation using bits *)
  let power_set arr =
    let n = Array.length arr in
    let total = 1 lsl n in
    let result = ref [] in
    
    for i = 0 to total - 1 do
      let subset = ref [] in
      for j = 0 to n - 1 do
        if (i land (1 lsl j)) <> 0 then
          subset := arr.(j) :: !subset
      done;
      result := !subset :: !result
    done;
    !result
end

(* Technique 4: Functional Data Structure Optimizations *)
module PersistentQueue = struct
  (* Amortized O(1) persistent queue *)
  type 'a queue = {
    front: 'a list;
    rear: 'a list;
  }
  
  let empty = { front = []; rear = [] }
  
  let enqueue x q = { q with rear = x :: q.rear }
  
  let dequeue = function
    | { front = []; rear = [] } -> failwith "Empty queue"
    | { front = h :: t; rear } -> (h, { front = t; rear })
    | { front = []; rear } ->
        let front = List.rev rear in
        (List.hd front, { front = List.tl front; rear = [] })
end

(* Technique 5: Parallel Processing Optimization *)
module ParallelOptimization = struct
  (* Parallel map using Domain module (OCaml 5.0+) *)
  let parallel_map f lst =
    let n_domains = Domain.recommended_domain_count () in
    let chunk_size = (List.length lst + n_domains - 1) / n_domains in
    
    let rec chunks lst =
      match lst with
      | [] -> []
      | _ ->
          let chunk, rest = 
            try List.split_at chunk_size lst
            with _ -> lst, []
          in
          chunk :: chunks rest
    in
    
    let work_chunks = chunks lst in
    let domains = List.map (fun chunk ->
      Domain.spawn (fun () -> List.map f chunk)
    ) work_chunks in
    
    List.fold_left (fun acc d ->
      acc @ Domain.join d
    ) [] domains
end
                                
Graded Problem Sets
Progressive Difficulty Training
Practice problems organized by difficulty to build skills systematically. Each problem includes solution approaches and complexity analysis.
🏋️ Real-Life Analogy:
Like training at the gym, you start with lighter weights and gradually increase. Each difficulty level builds the foundation for the next.
🟢 Easy Problems
Foundation problems that introduce core concepts and basic pattern recognition.

Problem 1: Valid Parentheses

Problem: Given a string containing '(', ')', '{', '}', '[', ']', determine if the input string is valid.
Approach: Use a stack to track opening brackets
Hint: Push opening brackets, pop and match closing brackets
Time: O(n), Space: O(n)
Key Insight: Stack naturally handles nested structures

Problem 2: Two Sum

Problem: Find two numbers in array that add up to target.
Approach: Use hash map to store complements
Hint: For each number x, check if (target - x) exists
Time: O(n), Space: O(n)
Key Insight: Trade space for time with hash lookups

Problem 3: Maximum Subarray

Problem: Find contiguous subarray with largest sum.
Approach: Kadane's algorithm - track current and global max
Hint: Reset current sum when it becomes negative
Time: O(n), Space: O(1)
Key Insight: Negative prefix never helps future sums

Problem 4: Binary Tree Inorder Traversal

Problem: Return inorder traversal of binary tree.
Approach: Recursive: left → root → right
Hint: Use stack for iterative solution
Time: O(n), Space: O(h) where h is height
Key Insight: Recursion naturally handles tree structure

Problem 5: Merge Two Sorted Lists

Problem: Merge two sorted linked lists into one sorted list.
Approach: Two pointers, compare and advance smaller
Hint: Use dummy head to simplify edge cases
Time: O(m + n), Space: O(1)
Key Insight: Dummy nodes eliminate special cases
🟡 Medium Problems
Intermediate problems requiring pattern combination and deeper algorithmic thinking.

Problem 1: Longest Substring Without Repeating Characters

Problem: Find length of longest substring without repeating characters.
Approach: Sliding window with hash set
Hint: Expand window until duplicate, then shrink from left
Time: O(n), Space: O(min(m,n)) where m is charset size
Key Insight: Window technique for substring problems

Problem 2: Group Anagrams

Problem: Group strings that are anagrams of each other.
Approach: Use sorted string or character count as key
Hint: Anagrams have same sorted characters
Time: O(n * k log k) where k is max string length
Key Insight: Canonical form for grouping

Problem 3: 3Sum

Problem: Find all unique triplets that sum to zero.
Approach: Sort array, fix one element, two pointers for rest
Hint: Skip duplicates to avoid repeated triplets
Time: O(n²), Space: O(1)
Key Insight: Reduce to 2Sum with sorting

Problem 4: Binary Tree Level Order Traversal

Problem: Return level-by-level traversal of binary tree.
Approach: BFS using queue, track level boundaries
Hint: Process all nodes at current level before next
Time: O(n), Space: O(w) where w is max width
Key Insight: BFS naturally processes by levels

Problem 5: Coin Change

Problem: Find minimum coins needed to make amount.
Approach: Dynamic programming - build up from smaller amounts
Hint: dp[i] = min(dp[i-coin] + 1) for all coins ≤ i
Time: O(amount × coins), Space: O(amount)
Key Insight: Optimal substructure - optimal solution contains optimal subsolutions
🔴 Hard Problems
Advanced problems requiring sophisticated algorithms and optimization techniques.

Problem 1: Median of Two Sorted Arrays

Problem: Find median of two sorted arrays in O(log(min(m,n))) time.
Approach: Binary search on smaller array to partition both
Hint: Ensure left partition ≤ right partition
Time: O(log(min(m,n))), Space: O(1)
Key Insight: Median divides array into equal halves

Problem 2: Trapping Rain Water

Problem: Calculate trapped rainwater given elevation map.
Approach: Two pointers from both ends, track max heights
Hint: Water level limited by minimum of left/right max
Time: O(n), Space: O(1)
Key Insight: Water trapped = min(left_max, right_max) - height

Problem 3: Word Ladder

Problem: Find shortest transformation sequence from start to end word.
Approach: BFS treating words as graph nodes
Hint: Each word differs by exactly one character
Time: O(M² × N) where M is word length, N is word count
Key Insight: Shortest path in unweighted graph = BFS
Common Interview Questions
Topic-Based Question Categories
Frequently asked interview questions organized by algorithmic topics and data structures.
📚 Real-Life Analogy:
Like studying for an exam, knowing the most common question types helps you prepare effectively and recognize patterns during interviews.
Topic Common Questions Key Techniques Difficulty Range
Arrays & Strings Two Sum, Longest Palindrome, Rotate Array, Product Except Self Two Pointers, Sliding Window, Hash Maps Easy - Medium
Linked Lists Reverse List, Detect Cycle, Merge Lists, Remove Nth Node Fast/Slow Pointers, Dummy Nodes Easy - Medium
Trees & Graphs Tree Traversals, Validate BST, Course Schedule, Clone Graph DFS, BFS, Recursion, Topological Sort Medium - Hard
Dynamic Programming Climbing Stairs, House Robber, Edit Distance, Knapsack Memoization, Tabulation, State Machines Medium - Hard
System Design Design Cache, Rate Limiter, Chat System, Search Engine Scalability, Load Balancing, Databases Hard
Optimization Challenges
Before/After Optimization Examples
Real optimization scenarios showing how to improve algorithm performance through better techniques and data structures.
🔧 Real-Life Analogy:
Like tuning a car engine for better performance, algorithm optimization involves identifying bottlenecks and applying targeted improvements.
Challenge 1: Range Sum Queries

❌ Before: Naive Approach

Problem: Answer Q range sum queries on array of size N
Approach: For each query, iterate through range
Time: O(Q × N), Space: O(1)
Issue: Recalculating same sums repeatedly

✅ After: Prefix Sum Optimization

Approach: Precompute prefix sums, answer queries in O(1)
Time: O(N + Q), Space: O(N)
Improvement: 1000x faster for large Q
Trade-off: Extra space for dramatic time savings
Challenge 2: Finding Duplicates

❌ Before: Nested Loops

Problem: Find if array contains duplicates
Approach: Compare every pair of elements
Time: O(N²), Space: O(1)
Issue: Quadratic time complexity

✅ After: Hash Set Optimization

Approach: Use hash set to track seen elements
Time: O(N), Space: O(N)
Improvement: Linear vs quadratic time
Trade-off: Space for time - classic optimization pattern
Challenge 3: Top K Elements

❌ Before: Sort Everything

Problem: Find K largest elements in array
Approach: Sort entire array, take last K elements
Time: O(N log N), Space: O(1)
Issue: Sorting more than necessary

✅ After: Min-Heap Optimization

Approach: Maintain min-heap of size K
Time: O(N log K), Space: O(K)
Improvement: Better when K << N
Alternative: QuickSelect for O(N) average case
Challenge 4: String Matching

❌ Before: Brute Force

Problem: Find pattern in text
Approach: Check pattern at every position
Time: O(N × M), Space: O(1)
Issue: Redundant character comparisons

✅ After: KMP Algorithm

Approach: Use failure function to skip redundant checks
Time: O(N + M), Space: O(M)
Improvement: Linear time complexity
Key Insight: Preprocess pattern to avoid backtracking
Interview Problem Categories
Common Interview Problem Types
Understanding common interview problem categories helps you quickly identify the appropriate approach and data structures to use.
🗂️ Real-Life Analogy:
Like a doctor who recognizes symptoms to diagnose illnesses, recognizing problem patterns helps you prescribe the right algorithmic solution.
Category Key Indicators Common Techniques Example Problems
Array/String Sequential data, subarray/substring Two pointers, Sliding window, Prefix sum Two Sum, Longest Palindrome, Maximum Subarray
Tree/Graph Hierarchical/connected data DFS, BFS, Recursion, Backtracking Valid BST, Shortest Path, Connected Components
Dynamic Programming Optimal substructure, overlapping subproblems Memoization, Tabulation, State transition Knapsack, Edit Distance, House Robber
Greedy Local optimal leads to global optimal Sorting, Priority queue, Proof by exchange Activity Selection, Huffman Coding, Dijkstra's
Backtracking Explore all possibilities, constraints Recursion, Pruning, State space tree N-Queens, Sudoku Solver, Permutations

💡 Interview Tips:

  • Always clarify problem constraints and edge cases
  • Start with a brute force solution, then optimize
  • Think out loud and explain your approach
  • Test your solution with examples
  • Analyze time and space complexity