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 | |
| O(log n) | Logarithmic time — execution time grows logarithmically with input size | Binary search, divide and conquer algorithms | |
| O(n) | Linear time — execution time grows linearly with input size | Iterating through an array, searching for an element in an unsorted list | |
| O(n log n) | Linearithmic time — common in efficient sorting algorithms | Merge sort, heap sort, quicksort (average case) | |
| O(n²) | Quadratic time — execution time grows quadratically with input size | Selection sort, bubble sort, nested loops over the same data structure | |
| O(2ⁿ) | Exponential time — execution time doubles with each additional input | Recursive Fibonacci, brute-force solutions to combinatorial problems | |
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 | |
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. | |
| 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. | |
| 4. Fill the table iteratively | Use recurrence relation to compute values from smaller subproblems. | |
| 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.
5. Implementing Binary Search
| 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
Example: Recursive Binary Search
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 | |
| Return on Match | Return immediately when result is found | Binary search implementation | |
| Pruning Search Space | Eliminate branches that can't lead to solution | Backtracking algorithms | |
Example: Early Termination in Linear Search
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. |