Skip to main content

Optimizing Time Complexity

1. Analyzing Time Complexity Classes

Class Description Common Operations Examples
O(1) Constant time — execution time does not depend on input size Accessing an array element by index, inserting at the end of a dynamic array
const arr = [1, 2, 3];
console.log(arr[0]); // O(1)
O(log n) Logarithmic time — execution time grows logarithmically with input size Binary search, divide and conquer algorithms
function binarySearch(arr, target) {
  let left = 0;
  let right = arr.length - 1;
  while (left <= right) {
    const mid = Math.floor((left + right) / 2);
    if (arr[mid] === target) return mid;
    if (arr[mid] < target) left = mid + 1;
    else right = mid - 1;
  }
  return -1; // Not found
}
O(n) Linear time — execution time grows linearly with input size Iterating through an array, searching for an element in an unsorted list
function findMax(arr) {
  let max = arr[0];
  for (let i = 1; i < arr.length; i++) {
    if (arr[i] > max) max = arr[i];
  }
  return max;
}
O(n log n) Linearithmic time — common in efficient sorting algorithms Merge sort, heap sort, quicksort (average case)
function mergeSort(arr) {
  if (arr.length <= 1) return arr;
  const mid = Math.floor(arr.length / 2);
  const left = mergeSort(arr.slice(0, mid));
  const right = mergeSort(arr.slice(mid));
  return merge(left, right);
}
O(n²) Quadratic time — execution time grows quadratically with input size Selection sort, bubble sort, nested loops over the same data structure
function bubbleSort(arr) {
  for (let i = 0; i < arr.length - 1; i++) {
    for (let j = 0; j < arr.length - i - 1; j++) {
      if (arr[j] > arr[j + 1]) {
        [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
      }
    }
  }
  return arr;
}
O(2ⁿ) Exponential time — execution time doubles with each additional input Recursive Fibonacci, brute-force solutions to combinatorial problems
function fibonacci(n) {
  if (n <= 1) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}

Example: Comparing Time Complexities

const sizes = [10, 100, 1000];
const algorithms = [
  { name: 'O(1)', func: () => 1 },
  { name: 'O(log n)', func: (n) => Math.log2(n) },
  { name: 'O(n)', func: (n) => n },
  { name: 'O(n log n)', func: (n) => n * Math.log2(n) },
  { name: 'O(n²)', func: (n) => n * n }
];

sizes.forEach(size => {
  console.log(`\nInput size: ${size}`);
  algorithms.forEach(alg => {
    console.log(`${alg.name}: ${alg.func(size).toFixed(2)} steps`);
  });
});

2. Using Memoization

Concept Description Implementation
Memoization Technique to cache results of expensive function calls and reuse them when the same inputs occur again
const memoize = (fn) => {
  const cache = {};
  return (...args) => {
    const key = JSON.stringify(args);
    if (key in cache) return cache[key];
    return cache[key] = fn(...args);
  };
};

Example: Fibonacci with Memoization

const fib = memoize((n) => {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
});
Note: Memoization trades space for time, reducing time complexity from O(2^n) to O(n) in the case of Fibonacci.

3. Implementing Tabulation

Step Description Implementation
1. Identify subproblems Break down the problem into smaller, overlapping subproblems.
// Example: Fibonacci sequence
// Subproblems: F(0), F(1), ..., F(n)
2. Define DP table Create an array or matrix to store computed values. let dp = new Array(n + 1).fill(0);
3. Initialize base cases Set initial values for known subproblems.
dp[0] = 0;
dp[1] = 1;
4. Fill the table iteratively Use recurrence relation to compute values from smaller subproblems.
for (let i = 2; i <= n; i++) {
  dp[i] = dp[i - 1] + dp[i - 2];
}
5. Return result Retrieve the final computed value. return dp[n];

Example: Fibonacci with Tabulation

function fibonacciTabulation(n) {
  if (n <= 1) return n;
  
  let dp = new Array(n + 1);
  dp[0] = 0;
  dp[1] = 1;
  
  for (let i = 2; i <= n; i++) {
    dp[i] = dp[i - 1] + dp[i - 2];
  }
  
  return dp[n];
}

console.log(fibonacciTabulation(6)); // Output: 8
Note: Tabulation avoids recursion overhead and ensures optimal substructure.

Tabulation Advantages

  • Eliminates recursive call stack
  • Bottom-up approach for better performance
  • Guarantees all subproblems are solved once

4. Using Hash Tables

Operation Time Complexity Description
Insert O(1) average Adding a key-value pair to the hash table
Search O(1) average Finding a value by its key
Delete O(1) average Removing a key-value pair from the hash table
Worst Case O(n) When all keys hash to the same index (collision)

Example: Hash Table Usage

const hashTable = new Map();
hashTable.set("name", "Alice");
hashTable.set("age", 30);

console.log(hashTable.get("name")); // Alice
hashTable.delete("age");
Note: Hash tables provide fast access to data, but performance depends on the hash function and collision resolution strategy.
Aspect Description Notes
Time Complexity O(log n) Logarithmic time due to halving the search space
Space Complexity O(1) Iterative version uses constant space
Prerequisites Sorted array or list Must be able to access elements by index

Example: Basic Binary Search Implementation

function binarySearch(arr, target) {
  let left = 0;
  let right = arr.length - 1;

  while (left <= right) {
    let mid = Math.floor((left + right) / 2);

    if (arr[mid] === target) {
      return mid;
    } else if (arr[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }

  return -1; // Not found
}

// Example usage:
const sortedArray = [1, 3, 5, 7, 9, 11, 13];
console.log(binarySearch(sortedArray, 7)); // Output: 3
function binarySearchRecursive(arr, target, left = 0, right = arr.length - 1) {
  if (left > right) return -1;

  let mid = Math.floor((left + right) / 2);

  if (arr[mid] === target) {
    return mid;
  } else if (arr[mid] < target) {
    return binarySearchRecursive(arr, target, mid + 1, right);
  } else {
    return binarySearchRecursive(arr, target, left, mid - 1);
  }
}

// Example usage:
const sortedArray = [2, 4, 6, 8, 10, 12];
console.log(binarySearchRecursive(sortedArray, 10)); // Output: 4
Note: Binary search is highly efficient for sorted datasets but requires the data to be sorted beforehand. It's a fundamental technique in algorithmic optimization.

6. Using Two Pointers

Use Case Description Time Complexity Space Complexity
Sorted Array/LinkedList Find pairs, triplets, or elements that satisfy a condition O(n) O(1)
Palindrome Check Determine if a string or array is a palindrome O(n) O(1)
Two Sum Find two numbers that sum to a target value O(n) O(1)

Example: Two Sum

function twoSum(nums, target) {
  let left = 0;
  let right = nums.length - 1;

  while (left < right) {
    const sum = nums[left] + nums[right];
    if (sum === target) {
      return [left, right];
    } else if (sum < target) {
      left++;
    } else {
      right--;
    }
  }

  return [];
}

Example: Palindrome Check

function isPalindrome(s) {
  let left = 0;
  let right = s.length - 1;

  while (left < right) {
    if (s[left] !== s[right]) {
      return false;
    }
    left++;
    right--;
  }

  return true;
}

7. Implementing Early Termination

Pattern Description Use Case Example
Early Exit in Loop Exit loop early when condition is met Searching for a match in array
for (let i = 0; i < arr.length; i++) {
  if (arr[i] === target) {
    console.log("Found");
    break;
  }
}
Return on Match Return immediately when result is found Binary search implementation
function binarySearch(arr, target) {
  let left = 0;
  let right = arr.length - 1;

  while (left <= right) {
    let mid = Math.floor((left + right) / 2);
    if (arr[mid] === target) {
      return mid;
    } else if (arr[mid] < target) {
      left = mid + 1;
    } else {
      right = mid - 1;
    }
  }
  return -1;
}
Pruning Search Space Eliminate branches that can't lead to solution Backtracking algorithms
function backtrack(path, options) {
  if (isComplete(path)) return path;
  for (let option of options) {
    if (!isValid(path, option)) continue; // Early pruning
    path.push(option);
    let result = backtrack(path, getValidOptions(path));
    if (result) return result;
    path.pop();
  }
}
function linearSearch(arr, target) {
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] === target) {
      return i; // Early termination
    }
  }
  return -1;
}
Note: Early termination reduces time complexity from O(n) to O(k) where k is the index of found element.

8. Using Sorting Preprocessing

Operation Description Time Complexity Space Complexity
Sort Input Sort the input array or list to enable efficient searching and grouping. O(n log n) O(1) or O(n) depending on sorting algorithm
Binary Search Use binary search on sorted data to find elements in O(log n). O(log n) O(1)
Two Pointers Use two pointers to traverse sorted array for pair/grouping problems. O(n) O(1)

Example: Two Sum in Sorted Array

function twoSumSorted(nums, target) {
  let left = 0;
  let right = nums.length - 1;

  while (left < right) {
    const sum = nums[left] + nums[right];
    if (sum === target) {
      return [left, right];
    } else if (sum < target) {
      left++;
    } else {
      right--;
    }
  }

  return [];
}

Example: Merge Intervals

function mergeIntervals(intervals) {
  intervals.sort((a, b) => a[0] - b[0]);

  const merged = [];
  for (const interval of intervals) {
    if (merged.length === 0 || merged[merged.length - 1][1] < interval[0]) {
      merged.push(interval);
    } else {
      merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], interval[1]);
    }
  }

  return merged;
}
Note: Sorting preprocessing is particularly effective for problems where elements can be grouped or compared in a linear fashion after sorting.

9. Implementing Greedy Approach

Property Description Usage
Local Optimization At each step, make the locally optimal choice Used in problems like Activity Selection, Huffman Coding
No Backtracking Once a choice is made, it's never reconsidered Applicable in scheduling and resource allocation
Efficiency Often provides optimal or near-optimal solutions quickly Preferred for real-time systems and large datasets

Example: Activity Selection

const activities = [
  { name: 'A1', start: 0, end: 6 },
  { name: 'A2', start: 3, end: 4 },
  { name: 'A3', start: 1, end: 2 },
  { name: 'A4', start: 5, end: 7 },
  { name: 'A5', start: 8, end: 9 },
  { name: 'A6', start: 5, end: 9 }
];

function selectActivities(activities) {
  // Sort by finish time
  activities.sort((a, b) => a.end - b.end);
  const selected = [activities[0]];
  let lastEnd = activities[0].end;

  for (let i = 1; i < activities.length; i++) {
    if (activities[i].start >= lastEnd) {
      selected.push(activities[i]);
      lastEnd = activities[i].end;
    }
  }

  return selected;
}

console.log(selectActivities(activities));
// Output: [{ name: 'A3', start: 1, end: 2 }, { name: 'A2', start: 3, end: 4 }, { name: 'A4', start: 5, end: 7 }, { name: 'A5', start: 8, end: 9 }]
Note: Greedy algorithms work optimally for problems with the greedy choice property and optimal substructure.
Warning: Not all problems can be solved optimally with greedy approach. It's essential to prove the greedy choice property for correctness.

10. Using Bit Manipulation

Operation Operator Description Example
AND & Bitwise AND operation 5 & 3 // 1 (binary: 101 & 011 = 001)
OR | Bitwise OR operation 5 | 3 // 7 (binary: 101 | 011 = 111)
XOR ^ Bitwise XOR operation 5 ^ 3 // 6 (binary: 101 ^ 011 = 110)
NOT ~ Bitwise NOT operation ~5 // -6 (binary: ~101 = ...11111010)
Left Shift << Shift bits left, effectively multiplying by powers of 2 5 << 1 // 10 (binary: 101 << 1 = 1010)
Right Shift >> Shift bits right, effectively dividing by powers of 2 10 >> 1 // 5 (binary: 1010 >> 1 = 101)

Example: Check if a number is even using bitwise AND

function isEven(num) {
  return (num & 1) === 0;
}

console.log(isEven(4)); // true
console.log(isEven(5)); // false

Example: Swap two numbers without a temporary variable

let a = 10;
let b = 20;

a = a ^ b; // a becomes 30 (10^20)
b = a ^ b; // b becomes 10 (30^20)
a = a ^ b; // a becomes 20 (30^10)

console.log(a); // 20
console.log(b); // 10

Example: Find the single element in an array where every other element appears twice

function findSingle(arr) {
  let result = 0;
  for (let i = 0; i < arr.length; i++) {
    result ^= arr[i];
  }
  return result;
}

console.log(findSingle([1, 2, 3, 2, 1])); // 3
Note: Bit manipulation is highly efficient for operations on integers and can significantly reduce time complexity in algorithms.

11. Implementing Amortized Analysis

Example: Dynamic Array Growth

class DynamicArray {
  constructor() {
    this.data = new Array(1);
    this.size = 0;
    this.capacity = 1;
  }

  push(value) {
    if (this.size === this.capacity) {
      // Resize: double the capacity
      const newCapacity = this.capacity * 2;
      const newData = new Array(newCapacity);
      for (let i = 0; i < this.size; i++) {
        newData[i] = this.data[i];
      }
      this.data = newData;
      this.capacity = newCapacity;
    }
    this.data[this.size] = value;
    this.size++;
  }

  // Amortized O(1) for push due to rare resizing
}
Operation Average Time Complexity Description
push() O(1) amortized Inserts element at end; may resize array
access() O(1) Access element by index
resize() O(n) Copy elements to new larger array
Note: Amortized analysis considers the average cost over a sequence of operations, even if some individual operations are expensive.

12. Using Mathematical Formulas

Pattern Description Formula Use Case
Sum of Arithmetic Sequence Calculates the sum of a sequence with a constant difference. S = n/2 * (a1 + an) Efficiently compute sums without iteration.
Sum of Geometric Sequence Calculates the sum of a sequence with a constant ratio. S = a * (1 - r^n) / (1 - r) Optimize calculations involving exponential growth.
Fibonacci Formula Direct calculation of Fibonacci numbers using Binet's formula. F(n) = (φ^n - ψ^n) / √5 Avoid recursion or iteration for large Fibonacci numbers.
Modular Exponentiation Computes (a^b) mod m efficiently using binary exponentiation. a^b mod m = (a^(b/2) mod m)^2 mod m Optimize large exponentiation in modular arithmetic.

Example: Sum of Arithmetic Sequence

function sumArithmeticSequence(a1, an, n) {
  return n / 2 * (a1 + an);
}

// Example usage:
const sum = sumArithmeticSequence(1, 10, 10); // Returns 55

Example: Fibonacci Using Binet's Formula

function fibonacciFormula(n) {
  const phi = (1 + Math.sqrt(5)) / 2;
  const psi = (1 - Math.sqrt(5)) / 2;
  return Math.round((Math.pow(phi, n) - Math.pow(psi, n)) / Math.sqrt(5));
}

// Example usage:
console.log(fibonacciFormula(10)); // Returns 55

Was this article helpful?