Search
🧮

Big-O Notation

Big-O Notation is a mathematical way to express the performance of an algorithm, particularly how its execution time or memory usage grows as the input size increases. While we could say something like “this code takes 10 seconds to run,” that kind of absolute time is often unreliable because it depends heavily on external factors such as hardware, programming language, compiler, and system load.
For example, a piece of code that currently takes 10 seconds might take over an hour with a slightly larger input, while another algorithm might appear slower at first but scale much more efficiently as the input grows.
This is where Big-O notation becomes valuable. Instead of relying on specific timing, Big-O abstracts away real-world variables and focuses on the structural efficiency of the algorithm. It helps us understand how well an algorithm scales with larger inputs, and it allows us to compare algorithms in a consistent, machine-independent way.
In systems that handle large amounts of data, Big-O notation is often a more critical performance indicator than raw execution time, making it an essential concept in writing scalable and efficient code.

Types of Big-O Notation

Now that we’ve briefly covered what Big-O notation is, let’s take a closer look at the different types of Big-O complexities. Since each algorithm scales differently as the input size grows, understanding these various complexity types is a key step toward writing efficient code. Below, we’ll go through the most common Big-O notations and what they mean.

O(1) - Constant Time Complexity

O(1), or constant time complexity, refers to an algorithm whose execution time does not change regardless of the size of the input data. For example, suppose a particular operation always takes 1 second to complete.
Whether you input 1 item, 10 items, or even 1,000 items, that specific operation will still take just 1 second. In other words, the processing time stays completely consistent, no matter how much data you provide.
Common Misunderstanding : O(1) ≠ "1 second"
In O(1), the number 1 does not mean the operation takes 1 second. It simply means that the algorithm performs a fixed number of steps, regardless of the input size.
The actual time could be 0.0001 seconds or even 5 seconds, what matters is that it doesn’t grow as the input grows.
In other words, O(1) isn’t about being "fast", it’s about being constant.
A common example of O(1) is accessing the first element of an array, or retrieving a value from a dictionary using a key. These operations don’t require scanning the entire dataset—they directly jump to a specific location, so the time taken remains the same.
Because of this predictability and efficiency, O(1) is considered one of the most optimal time complexities, and it's ideal to use such approaches whenever possible.
def get_first_element(arr): return arr[0] # returns first element of arr always
Python
복사
Let’s take a look at the example code above. The get_first_element function takes an array as a parameter.
Whether the array has 10, 100, or 1,000 elements, it always returns only the first element. This means that the execution time remains constant regardless of the length of the array.
In other words, O(1) represents a complexity where the execution time stays the same no matter how large the input data is.

O(log n) — Logarithmic Time Complexity

O(log n), or logarithmic time complexity, refers to algorithms whose execution time increases slowly as the size of the input grows. In this context, n represents the input size that is, the amount of data the algorithm needs to process.
Let’s consider an example. Suppose you have a sorted list of 1,000 numbers and you want to find a specific value. The most straightforward way would be to check each number one by one from start to finish, which might require up to 1,000 comparisons. This method is accurate but not efficient, as the number of operations increases directly with the input size.
Now imagine using binary search instead, a method that cuts the search range in half with each step. For instance, if the target value m is somewhere between 1 and 1,000, you could first compare it to the middle value, 500. If m > 500, you now know it must be in the range of 501 to 1,000. Then you check the middle of that new range (750), and continue narrowing the search.
Let’s say m = 512. The comparisons would look like this
1.
m > 500yes (→ range : 501–1000)
2.
m > 750no (→ range : 501–750)
3.
m > 625no (→ range : 501–625)
4.
m > 563no (→ range : 501–563)
5.
m > 532no (→ range : 501–532)
6.
m > 516no (→ range : 501–516)
7.
m > 508yes (→ range : 509–516)
8.
m > 512no (→ range : 509–512)
9.
m > 510yes (→ range : 511–512)
10.
m > 511yes (→ m = 512)
In just 10 comparisons, you found the correct number out of 1,000 elements. That’s because log₂(1000) ≈ 10, and each step in a binary search reduces the search space by half.
In short, O(log n) algorithms are incredibly efficient because even very large inputs only require a relatively small number of operations. You’ll often see this time complexity in algorithms that follow a “divide and conquer” approach, such as binary search, heaps, and balanced binary search trees.
For anyone who’s not a fan of math — What exactly is a logarithm?
If the word log sounds confusing or intimidating, don’t worry, it's actually simpler than it seems!
A logarithm is just a way of asking, “How many times do I need to multiply something by itself to reach a certain number?”
For example,
232^3 = 8
So if we ask, “How many times do we multiply 2 to get 8?” the answer is 3
This is written as log28\log_2 {8} = 3
In other words,
log28\log_2 {8} = 3 → "2 multiplied 3 times equals 8"
log216\log_2 {16} = 4 → "2 multiplied 4 times equals 16"
log21000\log_2 {1000} ≈ 10 → "2 multiplied around 10 times equals about 1,000"
So when we say an algorithm has O(log n) time complexity, we mean that even if the input gets really big, the number of steps only increases slowly, maybe 10, 20 steps instead of thousands or millions. It’s all about cutting the problem in half over and over, which is incredibly efficient.
# Binary Search Example def binary_search(arr, target): left = 0 right = len(arr) - 1 while left <= right: mid = (left + right) // 2 if arr[mid] == target: return mid # Return the index if the target is found elif arr[mid] < target: left = mid + 1 # Move to the right half else: right = mid - 1 # Move to the left half return -1 # Return -1 if the target is not found
Python
복사
A representative example of an algorithm with O(log n) time complexity is binary search. This algorithm works on a sorted array and efficiently finds the position of a target value by repeatedly dividing the search range in half.
Instead of checking every element one by one, it compares the target with the middle element. Based on whether the target is smaller or larger, it narrows the search to the left or right half, eliminating half of the remaining elements in each step.
Thanks to this "divide-and-conquer" approach, the number of steps increases very slowly, even as the input size grows. For example, searching through 1,000 elements takes about 10 steps, and even searching 1 million elements takes only about 20 steps. Because of its efficiency, binary search is one of the most commonly used algorithms and a classic example of logarithmic time complexity.

O(n) — Linear Time Complexity

O(n), or linear time complexity, describes algorithms whose execution time increases in direct proportion to the size of the input.
In this context, n represents the amount of data the algorithm needs to process. So, if there are 10 items, the algorithm might perform around 10 operations, if there are 1,000 items, it might take around 1,000 steps. As the input grows, the time it takes grows linearly.
A classic example is scanning through a list or array from beginning to end. For instance, when checking if a specific value exists in a list, the algorithm may need to examine each element one by one. In the best case, the value you're searching for might be at the very beginning—found in the very first step.
But in the worst case, the value could be at the very end of a 1,000-element list, meaning the algorithm would have to make 1,000 comparisons. This worst-case scenario is what defines the O(n) complexity.
That’s why with O(n), we always consider the worst-case scenario!
The value you’re searching for might be at the very end of the list.
Even though you might get lucky and find it early, in the worst case, the algorithm has to check every single element, which is what makes it O(n).
It's important to note that O(n) doesn't mean an algorithm is slow—it simply reflects that the time taken scales linearly with the size of the input.
In many situations, especially where all data must be examined, O(n) is a completely reasonable and expected complexity. However, when dealing with very large datasets, it can be helpful to explore ways to reduce repeated work or restructure the algorithm for better performance.
def linear_search(arr, target): for i in range(len(arr)): if arr[i] == target: return i # Return the index if target is found return -1 # Return -1 if target is not found
Python
복사
The code above demonstrates a classic example of an O(n) time complexity algorithm, linear search. In this approach, the algorithm starts at the beginning of the list and checks each element one by one until it finds the target value or reaches the end of the list. This means that in the best case, the target might be found on the very first try, requiring only one comparison.
However, in the worst case, the algorithm might need to examine every single element, especially if the target is at the very end of the list or not present at all.
As a result, the number of comparisons grows linearly with the size of the input. If the list contains 10 elements, it may take up to 10 steps, if it contains 1,000 elements, it could take up to 1,000 steps. This is why we classify it as O(n).
Although not the most efficient in terms of speed, linear search is still simple and effective when dealing with small datasets or unsorted data where more advanced search methods like binary search are not applicable.
The graph above illustrates the difference between the best-case and worst-case performance of a linear search algorithm, which has a time complexity of O(n). On the x-axis, we see the size of the input (number of items in the list), while the y-axis represents the number of comparisons the algorithm needs to make.
In the best-case scenario, represented by the flat blue line—the target value is found on the very first try. Regardless of how large the input size is, the algorithm only performs one comparison. This behavior reflects a constant time complexity, or O(1), in that specific situation.
In contrast, the worst-case scenario, shown by the steadily rising orange line—occurs when the target value is either at the very end of the list or not present at all. In this case, the algorithm must check every element, leading to a linear increase in the number of comparisons as the input size grows. This scenario clearly reflects O(n) behavior.
By comparing these two lines, the graph helps to visualize why linear search is considered O(n), even though the best-case might be fast, the worst-case defines the algorithm’s overall time complexity, which grows proportionally with the input size.

O(n log n) — Log-Linear Time Complexity

O(n log n) means that an algorithm processes a task in a fairly efficient way. Here, n stands for the number of items to process, and log n refers to how many times the data is divided in half. This time complexity often appears in sorting algorithms such as Merge Sort and Quick Sort (average case).
To relate this to real life, imagine you need to line up 100 people in alphabetical order. If you compare each person with every other person one by one, it takes a long time. But with an O(n log n) approach, you would first divide the group into smaller parts, sort each part, and then merge them back together. This strategy, “divide, sort, and merge” is the key idea behind O(n log n).
Let’s put some numbers in to make it clearer. If you have 8 items to sort, log₂(8) is 3. So, O(n log n) would require roughly 8 × 3 = 24 operations. On the other hand, a slower method like O(n²) would take 8 × 8 = 64 operations. As the amount of data grows, the difference becomes even larger. That’s why O(n log n) is widely used in real-world applications where performance matters.
In summary, O(n log n) is a smart and efficient way to handle large amounts of data, especially in sorting. It offers a great balance between speed and reliability, making it one of the most common time complexities used in programming.
Question
If O(n log n) is so efficient, why don’t we always use it instead of O(n)? Why does O(n) even exist?
Answer
While O(n log n) is indeed efficient, O(n) is actually faster and preferred when it's possible to use. The reason is simple, O(n log n) includes an additional log n factor, which means more operations compared to O(n).
For example, if you have 1,000 data points, an O(n) algorithm might require around 1,000 steps, while O(n log n) would require approximately 10,000 steps, assuming log₂(1000) ≈ 10. That’s a big difference when performance matters.
O(n) time complexity applies to problems that can be solved by simply going through the data once. Examples include finding the maximum or minimum value in an array, calculating a running total (prefix sum), or using a hash map to detect duplicates. These operations don’t require dividing the data or making multiple passes—they’re straightforward and highly efficient.
On the other hand, O(n log n) is commonly used in sorting algorithms like Merge Sort or Quick Sort, where the data must be divided and combined multiple times. While these algorithms are efficient for general-purpose sorting, they are inherently slower than O(n) operations due to the extra overhead.
In summary, we use O(n) whenever the problem allows it, because it’s faster and more efficient. O(n log n) is still a great option, especially when O(n) isn’t possible, but we should always aim for the lowest time complexity available for the task at hand.
If you're still unsure about the difference between O(n) and O(n log n), let’s look at a simple example.
Imagine you have a list like this : [1, 7, 5, 9, 3, 6, 2, 8, 4].
Depending on what you want to do with the list, the time complexity you need can vary.
For example, if you want to find the largest number, you can simply scan through the list once, comparing numbers as you go. This kind of task requires only a single pass, so the time complexity is O(n), very fast and efficient.
However, if you want to find the median value, it's a different story. While a human might be able to eyeball the middle value quickly, a computer cannot. It has to sort the list first before knowing what's in the middle. So it would first transform the list into something like [1, 2, 3, 4, 5, 6, 7, 8, 9], and then pick the middle value. Sorting takes O(n log n) time. After sorting, picking the middle value is fast and essentially O(1).
(Note : There are advanced algorithms that can find the median in O(n) time without sorting, but they are complex and not commonly used in simple implementations.)
So, whether O(n) or O(n log n) is "faster" depends entirely on what kind of task you're doing. You should always choose the time complexity that best fits the nature of the problem.
The chart above illustrates how two common time complexities, O(n) and O(n log n), grow as the size of the input data increases. The blue line represents O(n), which grows in a straight and linear fashion. This means that as the number of elements doubles, the number of operations required also roughly doubles. It’s a very efficient pattern, and is ideal for problems where a single pass over the data is enough—such as finding the maximum value in a list.
In contrast, the orange line represents O(n log n), which grows a bit faster. This complexity typically occurs in algorithms that need to both examine and reorganize data, such as sorting. The log n part reflects how many times the data can be divided in half, while the n part accounts for scanning or combining elements at each stage. Because of this, O(n log n) always results in more operations than O(n), especially as the data size becomes very large.
Although O(n log n) is still considered efficient, it’s not as fast as O(n). Therefore, if a task can be solved in O(n), it should be preferred. However, some tasks. like finding the median or sorting a list, require more complex operations and cannot be done with just O(n). In those cases, O(n log n) is the best realistic option.
Merge sort - one of the representaive algorithm with O(n log n)
def merge_sort(arr, depth = 0): indent = " " * depth # Distinguish by indentation level if len(arr) <= 1: print(f"{indent}Base case reached : {arr}") return arr mid = len(arr) // 2 print(f"{indent}Splitting : {arr} → Left : {arr[:mid]} | Right : {arr[mid:]}") # Divide left = merge_sort(arr[:mid], depth + 1) right = merge_sort(arr[mid:], depth + 1) # Conquer (merge) merged = merge(left, right, depth) print(f"{indent}Merged : {left} + {right}{merged}") return merged def merge(left, right, depth): indent = " " * depth merged = [] i = j = 0 print(f"{indent}Merging : {left} and {right}") # Merge two sorted arrays while i < len(left) and j < len(right): if left[i] <= right[j]: merged.append(left[i]) print(f"{indent}→ Taking {left[i]} from left") i += 1 else: merged.append(right[j]) print(f"{indent}→ Taking {right[j]} from right") j += 1 # Add remaining elements while i < len(left): merged.append(left[i]) print(f"{indent}→ Adding remaining {left[i]} from left") i += 1 while j < len(right): merged.append(right[j]) print(f"{indent}→ Adding remaining {right[j]} from right") j += 1 return merged # Example of use data = [1, 7, 5, 9, 3, 6, 2, 8, 4] sorted_data = merge_sort(data) print("\n✅ Final sorted result : ", sorted_data)
Python
복사
Code run result
Splitting : [1, 7, 5, 9, 3, 6, 2, 8, 4] → Left : [1, 7, 5, 9] | Right : [3, 6, 2, 8, 4] Splitting : [1, 7, 5, 9] → Left : [1, 7] | Right : [5, 9] Splitting : [1, 7] → Left : [1] | Right : [7] Base case reached : [1] Base case reached : [7] Merging : [1] and [7] → Taking 1 from left → Adding remaining 7 from right Merged : [1] + [7][1, 7] Splitting : [5, 9] → Left : [5] | Right : [9] Base case reached : [5] Base case reached : [9] Merging : [5] and [9] → Taking 5 from left → Adding remaining 9 from right Merged : [5] + [9][5, 9] Merging : [1, 7] and [5, 9] → Taking 1 from left → Taking 5 from right → Taking 7 from left → Adding remaining 9 from right Merged : [1, 7] + [5, 9][1, 5, 7, 9] Splitting : [3, 6, 2, 8, 4] → Left : [3, 6] | Right : [2, 8, 4] Splitting : [3, 6] → Left : [3] | Right : [6] Base case reached : [3] Base case reached : [6] Merging : [3] and [6] → Taking 3 from left → Adding remaining 6 from right Merged : [3] + [6][3, 6] Splitting : [2, 8, 4] → Left : [2] | Right : [8, 4] Base case reached : [2] Splitting : [8, 4] → Left : [8] | Right : [4] Base case reached : [8] Base case reached : [4] Merging : [8] and [4] → Taking 4 from right → Adding remaining 8 from left Merged : [8] + [4][4, 8] Merging : [2] and [4, 8] → Taking 2 from left → Adding remaining 4 from right → Adding remaining 8 from right Merged : [2] + [4, 8][2, 4, 8] Merging : [3, 6] and [2, 4, 8] → Taking 2 from right → Taking 3 from left → Taking 4 from right → Taking 6 from left → Adding remaining 8 from right Merged : [3, 6] + [2, 4, 8][2, 3, 4, 6, 8] Merging : [1, 5, 7, 9] and [2, 3, 4, 6, 8] → Taking 1 from left → Taking 2 from right → Taking 3 from right → Taking 4 from right → Taking 5 from left → Taking 6 from right → Taking 7 from left → Taking 8 from right → Adding remaining 9 from left Merged : [1, 5, 7, 9] + [2, 3, 4, 6, 8][1, 2, 3, 4, 5, 6, 7, 8, 9] ✅ Final sorted result : [1, 2, 3, 4, 5, 6, 7, 8, 9]
Bash
복사
Animation of how merge sort algorithm works

O(n²) – Quadratic Time

O(n²), or quadratic time complexity, refers to algorithms where the number of operations grows very quickly as the input size increases. This often happens when there are two nested loops, meaning for each element, the algorithm loops through all other elements. One common example is when you need to compare every pair of elements in a list.
To understand this more intuitively, imagine a classroom where students are meeting each other for the first time. Suppose there are 5 students. A, B, C, D, and E. Each student politely greets every other student in the room. So A greets B, C, D, and E. B greets A, C, D and E, and so on. In total, each student interacts with every other student. This leads to approximately 5 × 5 = 25 greetings. But if there are 100 students, that's 100 × 100 = 10,000 greetings and with 1,000 students, it jumps to 1,000,000. This clearly shows how the number of interactions explodes as the input grows, which is a hallmark of O(n²).
This pattern appears in many basic algorithms, such as bubble sort, insertion sort, and selection sort. These are easy to implement and understand, but they become extremely slow when dealing with large amounts of data. You’ll also see O(n²) in problems where every pair of elements needs to be compared, like checking all combinations in a brute-force search, or comparing all substrings in a string.
In summary, O(n²) arises in situations where each element must interact with every other element. While it's simple and intuitive, it’s also inefficient for large datasets, so whenever possible, we look for faster alternatives.
students = ["A", "B", "C", "D", "E"] for i in range(len(students)): # loop 1 greetings = [] for j in range(len(students)): # loop 2 if i != j: greetings.append(students[j]) # Join the names with commas line = ", ".join(greetings) print(f"{students[i]} says hello to {line}")
Python
복사
This code simulates a situation where five students greet each other. The list stores the names of the students, and two nested loops are used to ensure that each student greets all the others except themselves.
The outer loop goes through each student one by one as the greeter, while the inner loop scans the entire list again to find who they should greet. The condition i != j ensures that a student does not greet themselves.
The names of the students to be greeted are collected in a list, and at the end of each outer loop iteration, the names are joined into a single comma-separated string using ", ".join(), and printed. Because each student interacts with every other student, the overall time complexity is O(n²), a common pattern when comparing or interacting with all possible pairs.
A says hello to B, C, D, E B says hello to A, C, D, E C says hello to A, B, D, E D says hello to A, B, C, E E says hello to A, B, C, D
Shell
복사

O(n³) – Cubic Time

O(n³), or cubic time complexity, refers to algorithms that use three nested loops over an input of size n. As the input size increases, the runtime grows proportionally to n × n × n = n³, which causes a very rapid increase in execution time.
For example, if n = 10, the algorithm performs about 1,000 operations, but if n = 100, that jumps to over 1,000,000. This makes it quite inefficient for larger inputs.
A great way to understand O(n³) time complexity is through a real-life analogy involving three independent choices. Imagine you're preparing lunchboxes for your friends, and each lunchbox must include one side dish, one type of rice, and one dessert. Suppose you have n options for each of those three categories. In order to find the most delicious combination, you decide to try out every possible mix of side, rice, and dessert.
To do this, you would need to try n × n × n = n³ different combinations. If n = 10, that’s 1,000 combinations. If n = 100, you’re looking at a million. As you can imagine, this method quickly becomes unmanageable as n increases. This is exactly what O(n³) represents a scenario where runtime grows dramatically due to triple nested loops or three-dimensional comparisons.
In computer algorithms, this kind of time complexity shows up when you need to perform exhaustive searches across three distinct dimensions, such as in Floyd–Warshall's algorithm or brute-force solutions that evaluate all triplet combinations. It’s a clear demonstration of how inefficiency compounds when multiple independent choices are explored fully.
# Number of options for each category n = 5 # Sample options for each category sides = [f"Side {i}" for i in range(1, n+1)] rices = [f"Rice {i}" for i in range(1, n+1)] desserts = [f"Dessert {i}" for i in range(1, n+1)] # Simulate evaluating all combinations best_score = 0 best_combo = () # Try all possible combinations (O(n³)) for side in sides: for rice in rices: for dessert in desserts: # Dummy scoring function: longer name = better taste score = len(side) + len(rice) + len(dessert) if score > best_score: best_score = score best_combo = (side, rice, dessert) print("Best combination : ", best_combo) print("Score : ", best_score)
Python
복사
This code simulates a brute-force search across three categories: sides, rice types, and desserts. Each category has n different options, and the goal is to evaluate every possible combination to find the one with the "best taste score." Here, the score is calculated based on the total length of the food item names, serving as a stand-in for some arbitrary evaluation logic.
To accomplish this, the program uses three nested for-loops, each iterating over one of the categories. This means the total number of iterations is n × n × n, which gives the algorithm a time complexity of O(n³). While this example uses a small n = 5, the number of iterations would explode rapidly with larger values of n, demonstrating why such complexity is often avoided for large datasets.
The result is the combination of food items with the highest score, printed at the end. This example provides an intuitive and visualizable way to understand the cost of cubic time complexity in real-world-like scenarios.
The graph above visually compares the growth rates of O(n), O(n²), and O(n³) time complexities. As the input size n increases, you can clearly see that O(n³) grows much faster than the others. While the differences may seem small for smaller values of n, by the time n reaches 20, the gap becomes overwhelming.
This illustrates exactly why O(n³) algorithms should be used with caution in practice, what seems manageable at small scales can quickly become impractical as input size grows. The chart offers a clear, intuitive view of why optimizing from cubic to quadratic or linear time can make a huge difference.
Why Do We Use O(n³) Algorithms since it looks unefficient?
At first glance, O(n³) may seem highly inefficient and in many cases, it is. However, there are valid and practical reasons why algorithms with cubic time complexity are still used in real-world scenarios and algorithm design.
The most fundamental reason is that some problems require a complete exploration of all possible combinations to ensure correctness. For example, the Floyd–Warshall algorithm, which finds the shortest paths between all pairs of vertices in a graph, is a classic O(n³) algorithm. While inefficient for large graphs, it's simple, elegant, and guarantees correct results for any input.
Moreover, cubic time algorithms can still be very practical when the input size n is small. For example, if n is 30, the total number of operations is around 27,000 (30³), which modern processors can handle in milliseconds. In many competitive programming problems, or specific applications with small input bounds (e.g., n ≤ 100), O(n³) solutions are entirely acceptable.
Another important factor is simplicity and ease of implementation. O(n³) solutions often involve three nested loops, which are easy to write, read, and debug. In contrast, more efficient algorithms like those with O(n log n) or O(n²) complexity can require complex data structures or intricate logic, which may not be worth the trade-off for small inputs or one-time calculations.
Additionally, O(n³) solutions are frequently used as reference or baseline implementations during algorithm development. They help verify correctness before optimizing performance. Developers may first implement a brute-force cubic version to ensure they understand the problem and test it against more optimized but complicated versions.

O(2ⁿ) – Exponential Time

O(2ⁿ) time complexity refers to algorithms whose execution time increases exponentially as the input size n grows. This typically occurs in problems where all possible combinations or cases must be explored.
For instance, generating all subsets of a set or finding all valid paths under certain conditions often results in this complexity. A well-known example is the naive recursive algorithm for calculating Fibonacci numbers, which redundantly computes the same subproblems and thus demonstrates inefficient O(2ⁿ) behavior.
The key characteristic of this complexity is that even a small increase in input size can lead to an explosion in computation. For example, when n = 10, the number of operations is 2102^{10} = 1,024, but when n = 30, it surpasses a billion.
Therefore, while such algorithms may work for small inputs, they quickly become impractical for larger ones. To deal with this inefficiency, optimization techniques such as Dynamic Programming or Memoization are often used.
This concept can be illustrated through a real-life analogy. Imagine one person shares a secret with two people, and each of those two shares it with another two people, and so on. After just a few rounds, hundreds or even thousands of people have heard the secret. While it starts off small, the doubling at each step causes it to escalate rapidly. O(2ⁿ) algorithms behave in the same way—initially manageable, but quickly growing out of control as the input increases.
As a result, O(2ⁿ) algorithms are generally considered inefficient and should be avoided when possible. If their use is unavoidable, the input size must be strictly limited, or more efficient algorithmic strategies should be considered.
def generate_subsets(nums): result = [] def backtrack(index, current): # Base case : if we've considered all elements if index == len(nums): result.append(current[:]) # Add a copy of the current subset return # Include the current element current.append(nums[index]) backtrack(index + 1, current) # Exclude the current element (backtrack) current.pop() backtrack(index + 1, current) backtrack(0, []) return result # Example usage nums = [1, 2, 3] subsets = generate_subsets(nums) print(subsets)
Python
복사
This code generates all possible subsets of a given list of numbers and is a classic example of an algorithm with O(2ⁿ) time complexity. A subset refers to any combination of elements selected from a set, including the empty set and the set itself. For instance, given the list [1, 2], the possible subsets are [], [1], [2], and [1, 2], making a total of four. The number of subsets grows exponentially with the number of elements n, specifically 2ⁿ, which reflects the exponential growth in time complexity.
The algorithm uses recursion and backtracking to explore all combinations. The internal backtrack function takes the current subset being built (current) and the index of the current element (index). At each recursive step, it makes a binary decision: whether to include or exclude the current element. If included, the element is added to the current subset and recursion continues. If excluded, the function backtracks (removes the element) and explores the other possibility. Because each element leads to two branching paths, the total number of possibilities becomes 2ⁿ.
For example, if the input is nums = [1, 2, 3], the algorithm generates the following subsets → [], [1], [2], [3], [1, 2], [1, 3], [2, 3], and [1, 2, 3], a total of 8 subsets, matching 2³ = 8. While this algorithm provides a clear and intuitive solution for small inputs, the number of operations grows exponentially with input size. Therefore, for larger inputs, performance may become a concern, and more efficient or constrained approaches may be necessary.
If the efficiency is this bad, why do we use O(2ⁿ) algorithms at all?
O(2ⁿ) algorithms are generally considered inefficient because the amount of computation grows exponentially with the input size. However, there are still valid reasons why they are used in practice, which can be grouped into three main points.
First, some problems are inherently exponential by nature. For example, generating all subsets, permutations, combinations, or exploring all possible paths often requires considering every possible configuration. In such cases, exponential time is unavoidable, and an O(2ⁿ) algorithm provides the clearest and most intuitive solution.
Second, for small input sizes, these algorithms are often practically usable. For instance, if n = 10, then 2102^{10} equals only 1,024 possible combinations, which modern computers can process almost instantly. This is why O(2ⁿ) algorithms are commonly seen in coding interviews and algorithmic challenges—they work just fine within constrained limits.
Third, O(2ⁿ) algorithms often serve as a foundational starting point for optimization. Although the initial solution may be exponential, it can later be improved using techniques such as dynamic programming (DP), pruning, or memoization. In this way, exponential-time solutions often act as a stepping stone toward more efficient algorithms.
In summary, while O(2ⁿ) algorithms are slow in theory, they are still used when the problem requires it, when the input size is small, or when they act as the base from which more optimized solutions can be developed. That’s why they remain relevant and frequently appear in real-world problem solving.

O(n!) – Factorial Time

O(n!) complexity means that the execution time of an algorithm increases in proportion to the factorial of the input size n. This is an extremely inefficient time complexity, where the amount of computation grows explosively even with small increases in n.
For example, if n = 5, the algorithm would require 5! = 120 operations, but if n = 10, it would require 10! = 3,628,800 operations. A classic example is the Traveling Salesman Problem (TSP), where finding the optimal route to visit n cities involves trying all possible orderings a case of brute-force permutation search. To put this into a real-life analogy,
imagine trying to take the perfect group photo of 10 friends by testing every possible arrangement to find the most visually pleasing one. There would be 10! = 3,628,800 possible configurations. Assuming it takes 1 second to try each, it would take more than 42 days to go through all combinations—clearly an impractical approach. Because of this, problems with O(n!) complexity are only feasible for very small inputs, and in most cases, more efficient algorithms or heuristic methods are necessary.
import itertools # Generate all permutations of a given list of elements def generate_permutations(elements): # Use itertools.permutations to generate all possible orderings return list(itertools.permutations(elements)) # Example usage items = ['A', 'B', 'C'] permutations = generate_permutations(items) # Print all permutations for p in permutations: print(p)
Python
복사
Result of run code
('A', 'B', 'C') ('A', 'C', 'B') ('B', 'A', 'C') ('B', 'C', 'A') ('C', 'A', 'B') ('C', 'B', 'A')
Shell
복사
This Python script demonstrates a classic example of factorial time complexity, O(n!), by generating all possible permutations of a given list. The number of permutations for a list with n distinct elements is n!, since each element can appear in every possible position, and we must account for all such ordered arrangements.
In the code, we use the itertools.permutations() function from Python’s standard library to efficiently compute these permutations.
For example, given three items [A, B, C], the code outputs all six possible orderings : ABC, ACB, BAC, BCA, CAB, and CBA. This approach is straightforward for small input sizes, but becomes impractical for large n, because the number of permutations grows extremely fast (e.g., 10! = 3,628,800).
Thus, while simple and illustrative, factorial-time algorithms are computationally expensive and should be avoided in large-scale problems unless absolutely necessary.
This graph illustrates how the factorial value n! grows as the input n increases from 1 to 10. Factorial is defined as n! = n × (n-1) × ... × 1 and is a fundamental concept used in permutations and combinations.
At n = 1, the value is 1, at n = 2, it is 2; n = 3 yields 6, n = 4 is 24, and n = 5 is 120. Up to this point, the growth appears relatively gentle. However, starting from n = 6 and n = 7, the values jump to 720 and 5,040, respectively, signaling the beginning of rapid escalation. At n = 8, the value reaches 40,320; n = 9 hits 362,880; and by n = 10, the value explodes to 3,628,800.
This dramatic increase is clearly reflected in the steep curve of the graph. While the line appears relatively flat for n values up to 7, the actual numerical growth is exponential, often increasing by more than tenfold with each step. Because of this explosive growth rate, algorithms with a time complexity of O(n!) quickly become impractical as n increases.
As a result, factorial-time algorithms are only feasible for small input sizes, and more efficient alternatives are generally required for larger problems.