Space Complexity Analysis¶
This guide covers space complexity analysis, memory usage patterns, and how to analyze algorithm memory efficiency across different programming languages.
๐ฏ What is Space Complexity?¶
Space complexity measures how the memory usage of an algorithm grows as the input size increases. It helps us understand memory requirements and optimize for memory-constrained environments.
Space Complexity Notation¶
- O(1): Constant space - memory usage doesn't depend on input size
- O(log n): Logarithmic space - grows very slowly
- O(n): Linear space - grows proportionally with input size
- O(n log n): Linearithmic space - common in efficient sorting
- O(nยฒ): Quadratic space - grows quickly, common in nested data structures
๐ง Space Complexity Examples¶
O(1) - Constant Space¶
Temporary Variables¶
Using a few additional variables (like a temp for swapping or a counter for a loop) consumes a fixed amount of memory regardless of the input size. This is the definition of O(1) space complexity.
# Swapping two variables
def swap(a, b):
temp = a # One extra variable
a = b
b = temp
return a, b
# Finding maximum
def find_max(arr):
max_val = arr[0] # One extra variable
for num in arr:
if num > max_val:
max_val = num
return max_val
O(n) - Linear Space¶
# Creating a copy of array
def reverse_array(arr):
reversed_arr = [] # O(n) space
for i in range(len(arr)-1, -1, -1):
reversed_arr.append(arr[i])
return reversed_arr
# Recursive factorial (call stack)
def factorial(n):
if n <= 1:
return 1
return n * factorial(n-1) # O(n) call stack space
O(nยฒ) - Quadratic Space¶
# Creating all pairs
def all_pairs(arr):
pairs = [] # O(nยฒ) space
for i in range(len(arr)):
for j in range(i+1, len(arr)):
pairs.append((arr[i], arr[j]))
return pairs
# Adjacency matrix for graph
def create_adjacency_matrix(n):
matrix = [[0] * n for _ in range(n)] # O(nยฒ) space
return matrix
๐ Memory Usage Comparison¶
| Input Size | O(1) | O(log n) | O(n) | O(n log n) | O(nยฒ) |
|---|---|---|---|---|---|
| 10 | 1 | 4 | 10 | 33 | 100 |
| 100 | 1 | 7 | 100 | 664 | 10,000 |
| 1,000 | 1 | 10 | 1,000 | 9,966 | 1,000,000 |
| 10,000 | 1 | 13 | 10,000 | 132,877 | 100,000,000 |
๐ฏ Language-Specific Memory Analysis¶
Java Memory¶
// Primitive types - fixed size
int num = 42; // 4 bytes
double value = 3.14; // 8 bytes
boolean flag = true; // 1 byte
// Objects - overhead + fields
String str = "Hello"; // ~12 bytes + overhead
ArrayList<Integer> list = new ArrayList<>(); // Dynamic, grows as needed
// Memory estimation
public class MemoryExample {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
long usedMemory = runtime.totalMemory() - runtime.freeMemory();
int[] array = new int[1000000]; // ~4MB
long newUsedMemory = runtime.totalMemory() - runtime.freeMemory();
System.out.println("Memory used: " + (newUsedMemory - usedMemory) + " bytes");
}
}
Python Memory¶
import sys
# Check object size
def get_size(obj):
return sys.getsizeof(obj)
# Lists have overhead
my_list = [1, 2, 3, 4, 5]
print(f"List size: {get_size(my_list)} bytes") # ~120 bytes
# Dictionaries have more overhead
my_dict = {"a": 1, "b": 2, "c": 3}
print(f"Dict size: {get_size(my_dict)} bytes") # ~360 bytes
# Strings are objects
my_string = "Hello, World!"
print(f"String size: {get_size(my_string)} bytes") # ~50 bytes
C Memory¶
#include <stdio.h>
#include <stdlib.h>
// Manual memory management
int* create_array(int size) {
int* array = (int*)malloc(size * sizeof(int));
if (array == NULL) {
fprintf(stderr, "Memory allocation failed\n");
exit(1);
}
return array;
}
// Memory usage tracking
void check_memory_usage() {
// Platform-specific memory checking
#ifdef __linux__
FILE* status = fopen("/proc/self/status", "r");
// Parse memory information
fclose(status);
#endif
}
๐ Memory Optimization Strategies¶
In-Place Operations¶
# Bad: O(n) extra space
def reverse_array_extra(arr):
reversed_arr = [] # O(n) space
for i in range(len(arr)-1, -1, -1):
reversed_arr.append(arr[i])
return reversed_arr
# Good: O(1) extra space
def reverse_array_inplace(arr):
left, right = 0, len(arr) - 1
while left < right:
arr[left], arr[right] = arr[right], arr[left]
left += 1
right -= 1
return arr
Generator Usage¶
# Bad: O(n) space for large dataset
def process_large_file_bad(filename):
with open(filename, 'r') as file:
lines = file.readlines() # Loads entire file
return [line.strip() for line in lines]
# Good: O(1) space using generator
def process_large_file_good(filename):
with open(filename, 'r') as file:
for line in file: # Process one line at a time
yield line.strip() # Generator, memory efficient
Memory Pool Pattern¶
// Bad: Creating many objects
public class BadExample {
public void processItems(List<Item> items) {
for (Item item : items) {
ProcessedItem processed = new ProcessedItem(item); // Many objects
process(processed);
}
}
}
// Good: Reusing objects
public class GoodExample {
private ProcessedItem reusableItem = new ProcessedItem();
public void processItems(List<Item> items) {
for (Item item : items) {
reusableItem.reset(item); // Reuse object
process(reusableItem);
}
}
}
Efficient Data Structures¶
# Bad: List for frequent lookups
user_permissions_list = [("alice", ["read", "write"]), ("bob", ["read"])]
def has_permission(username, permission):
for user, perms in user_permissions_list: # O(n)
if user == username:
return permission in perms
return False
# Good: Dictionary for O(1) lookups
user_permissions_dict = {
"alice": {"read", "write"},
"bob": {"read"}
}
def has_permission_optimized(username, permission):
return permission in user_permissions_dict.get(username, set()) # O(1)
๐ ๏ธ Memory Profiling¶
Java Profiling¶
// Using VisualVM or JConsole
// 1. Run application with: java -XX:+PrintGCDetails -XX:+PrintGCTimeStamps
// 2. Connect VisualVM: jvisualvm
// 3. Monitor heap usage, garbage collection, and object creation
// Programmatic memory checking
public class MemoryProfiler {
public static void logMemoryUsage(String label) {
Runtime runtime = Runtime.getRuntime();
long totalMemory = runtime.totalMemory();
long freeMemory = runtime.freeMemory();
long usedMemory = totalMemory - freeMemory;
System.out.println(label + ": " + (usedMemory / 1024 / 1024) + " MB");
}
}
Python Profiling¶
import tracemalloc
import sys
# Memory tracing
tracemalloc.start()
# Your code here
def memory_intensive_function():
large_list = [i for i in range(1000000)]
return sum(large_list)
result = memory_intensive_function()
# Get memory statistics
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current / 1024 / 1024:.2f} MB")
print(f"Peak memory usage: {peak / 1024 / 1024:.2f} MB")
# Get top memory consumers
snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')
for stat in top_stats[:10]:
print(stat)
C Memory Debugging¶
# Using Valgrind
gcc -g program.c -o program
valgrind --tool=memcheck --leak-check=full ./program
# Using AddressSanitizer
gcc -fsanitize=address -g program.c -o program
./program
# Memory leak detection
# In code:
#include <stdlib.h>
void* safe_malloc(size_t size) {
void* ptr = malloc(size);
if (ptr == NULL) {
fprintf(stderr, "Memory allocation failed\n");
exit(1);
}
printf("Allocated %zu bytes at %p\n", size, ptr);
return ptr;
}
void safe_free(void* ptr) {
if (ptr != NULL) {
printf("Freeing memory at %p\n", ptr);
free(ptr);
}
}
๐ Memory Optimization Patterns¶
Lazy Loading¶
# Bad: Load everything upfront
class BadDataLoader:
def __init__(self):
self.all_data = self.load_all_data() # Loads everything
def get_user(self, user_id):
return self.all_data[user_id]
# Good: Load on demand
class GoodDataLoader:
def __init__(self):
self.cache = {}
def get_user(self, user_id):
if user_id not in self.cache:
self.cache[user_id] = self.load_user(user_id) # Load only when needed
return self.cache[user_id]
Memory-Mapped Files¶
// For large files, use memory mapping
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
void* map_file(const char* filename, size_t* size) {
int fd = open(filename, O_RDONLY);
if (fd == -1) return NULL;
struct stat sb;
if (fstat(fd, &sb) == -1) return NULL;
*size = sb.st_size;
void* mapped = mmap(NULL, *size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
return mapped; // File mapped into memory
}
Streaming Processing¶
# Process large datasets without loading everything
def process_large_dataset(filename):
with open(filename, 'r') as file:
for line in file: # Process one line at a time
data = parse_line(line)
if meets_criteria(data):
yield process_data(data) # Generator, memory efficient
# Usage
for result in process_large_dataset("large_file.txt"):
handle_result(result)
๐งช Practice Problems¶
Easy¶
- Space Analysis: What is the space complexity of reversing a string in-place?
- Memory Usage: How much memory does storing 1 million integers require?
- Optimization: Convert O(nยฒ) space algorithm to O(n) space.
Medium¶
- In-Place Operations: Implement in-place quicksort and analyze its space complexity.
- Memory Pool: Design a memory pool for frequent object allocation/deallocation.
- Streaming: Process a large CSV file without loading it entirely into memory.
Hard¶
- External Sorting: Design an algorithm to sort data that doesn't fit in memory.
- Memory Mapping: Implement file processing using memory-mapped I/O.
- Garbage Collection: Analyze memory usage patterns and optimize for GC efficiency.
๐ Related Resources¶
- Memory Management in Java - Official Java memory guide
- Python Memory Management - Python memory documentation
- C Memory Management - GNU C memory guide
๐ Related Guides¶
- Time Complexity - Runtime analysis
- Algorithm Analysis - Comprehensive analysis techniques
- Optimization Techniques - Performance improvement strategies
- Benchmarking Methods - Measuring actual performance
๐ Language-Specific Memory¶
- Java Performance Tips - Java memory optimization
- Python Performance Tips - Python memory optimization
- C Performance Tips - C memory management
- Oracle Performance Tips - Oracle memory optimization