It’s been about a year since I started to work for Spotify. That time I wrote a few articles about my job search experience. Among others, I shared how much I underestimated the importance of complexity analysis and the big-O notations when it comes to senior roles.
My assumption was that knowing the space and time complexities of the different algorithms and operations on different data structures isn’t something important. Dealing with such issues is rare in most jobs. Still, asking about complexities might be an easy way to rate someone’s knowledge when it comes to candidates with no or short experience. On the other hand, when you’re looking for experienced people, there should be many other more meaningful ways to understand someone else’s skills. I still think that this is true, but I also realized that many big (and small) companies just go with the standard questions involving complexity analysis.
Therefore, finally, I collected some complexities and some tips to learn them.
Communication over end results
The end result of your complexity analysis is important. Yet, it’s secondary. It doesn’t differ from other interview questions in this sense. The most important is clear communication. You have to share your thread of thoughts partly because it proves how good a communicator you are and partly because sharing your thoughts is insurance against your mistakes. If you have a mistake in your analysis, you’ll still show that otherwise, your thinking was right. Whereas if you simply share a result and it’s bad, you failed to share anything positive at all.
This is even more important if you know that you have holes in your knowledge - just like me. When you start analyzing a problem, tell what you know for sure and share the assumptions you have and start your analysis from there.
Most probably you’ll understand how loops are composed, how they (don’t) add up if they are sequential and how they influence the complexity of an algorithm if they are nested. At the same time, most probably there will be some container manipulations - either directly or through some standard functions - within the loops and you might lack some knowledge regarding the complexity of those calls. Sharing your assumptions will at least balance a bit of that lack of knowledge. In the worst case, you’ll have a useful discussion with your interviewer.
When I was interviewing for a position at a code analysis company, I clearly failed the interview due to my lack of knowledge in this field, but at least I learned a few tips about how to approach complexity analysis in future interviews, which clearly helped me get an offer from Spotify.
How to learn
If you want to learn complexity analysis, you might just revisit your CS studies where most probably it was covered in detail. An even better option with less fluff is to take a book such as Cracking the Coding Interview and start reading it thoroughly and solving its exercises on a regular basis.
Ideally, you’d spend some time on it every day or at least once or twice a week. Even if you don’t plan to interview in the near future.
Or maybe especially if you don’t plan to interview in the near future!
I don’t know about you, but I’m not a university student anymore who can pull in one-nighters just to pass an exam and forget about what I learned hoping that I won’t need that crap for the rest of my life.
You never know what tomorrow will bring. Maybe you get laid off the next day, or you just realise that it’s time to leave. Then it’ll be a bit late to start preparing. It’s better if you have already started your preparation!
But as I said many times, most people are lazy and disorganized. So what to do if you need to improve your complexity analysis skills as soon as possible before a C++ coding interview?
- identify the most important time/space complexities you should know
- go on the C++ Reference pages of the identified functions and methods and start memorising the complexities
- spend some time on this activity every day, consistency will do miracles
- even better if you revisit your CS basics when you learn about a complexity so that you understand why inserting into a
std::map
has the complexity of O(log n). This will both make it easier for you to succeed in general complexity analysis tasks and it’ll also make you realize that it’s not so difficult to learn it, you just need some time.
In the next sections, I’ll help you collect the most fundamental complexities.
Notions
Before we get into discussing complexities, let’s remind ourselves about the different notions.
O(1)
stands for constant complexity, meaning that the complexity doesn’t depend on the input size, it’ll always be about the same. A variation of constant complexity is amortized constant complexity. It means that the runtime will be much slower once in a while, but very rarely, so overall, on average, that slowdown is amortized so we can still talk about a constant complexity.
Linear complexity O(n)
means that as the input size grows, the necessary amount of operations grows the same way.
On the other hand, when we talk about logarithmic complexity O(log n)
, the number of operations that need to be executed grows much slower than the input size.
When it comes to C++ standard containers and algorithms, these are the three notions that we use. Still, it’s good to remind ourselves that there are also quadratic and exponential complexities. When we talk about quadratic complexity O(n^2)
, the number of required operations is about the squared size of the input. That’s quite bad. But not as bad as the exponential complexity O(2^n)
where by each additional input element the necessary time or space doubles. (While there are other complexities, these are the most widely used ones.)
Containers and operations
First, let’s see what are the most important containers you’ll likely deal with in a coding interview, what are the underlying data structures and what are the related complexities. My goal is not to give you a deep analysis, just to provide you with the most necessary information, then you can do your own research.
std::array
std::array
is a fixed-size array, storing objects in contiguous memory locations.
- accessing the first element: with
front()
which has a complexity ofO(1)
- accessing the last element: with
back()
which has a complexity ofO(1)
- accessing a random element: with
at()
or withoperator[]
both have a complexity ofO(1)
std::list
std::list
is a container that supports fast insertion and removal, but doesn’t support fast random access. It is usually implemented as a doubly-linked list. std::forward_list
is similar, but implemented with a singly-linked list, so it’s more space efficient, but it supports iteration only in one direction
- accessing the first element: with
front()
which has a complexity ofO(1)
- accessing the last element: with
back()
which has a complexity ofO(1)
(not supported bystd::forward_list
) accessing a random element: not supported
- inserting at the front: with
push_front()
which has a complexity ofO(1)
- inserting at the back: with
push_back()
which has a complexity ofO(1)
(not supported bystd::forward_list
) inserting at a random location: with
insert()
which has a complexity ofO(1)
for one element, and complexity ofO(n)
for multiple elements, wheren
is the number of elements to be inserted (insert_after
forstd::forward_list
)- removing an item from the front: with
pop_front()
which has a complexity ofO(1)
- removing an item from the back: with
pop_back()
which has a complexity ofO(1)
(not supported bystd::forward_list
) - removing an item from a random location: with
erase()
which has a complexity ofO(1)
for one element, and a complexity ofO(n)
for multiple elements, wheren
is the number of elements to be erased (erase_after
forstd::forward_list
)
std::vector
std::vector
is a dynamically sized sequence container, where the elements are stored contiguously. Random access is cheap, as well as operations at the end, unless reallocation is required.
- accessing the first element: with
front()
which has a complexity ofO(1)
- accessing the last element: with
back()
which has a complexity ofO(1)
accessing a random element: with
at()
or withoperator[]
both have a complexity ofO(1)
- inserting at the front: with
insert()
which has a complexity ofO(n+m)
wheren
is the number of elements to insert andm
is the size of the container - inserting at the back: with
push_back()
which has a complexity of amortizedO(1)
inserting at a random location: with
insert()
which has a complexity ofO(n+m)
wheren
is the number of elements to insert andm
is the distance between the elements to insert and the end of the container- removing an item from the front: with
erase()
which has a complexity ofO(n+m)
wheren
is the number of elements erased (calls to the destructor) andm
is the number of assignments to make - the size of the elements left in the vector - removing an item from the back: with
pop_back()
which has a complexity ofO(1)
- removing an item from a random location: with
erase()
which has a complexity ofO(n+m)
wheren
is the number of elements erased (calls to the destructor) andm
is the number of assignments to make - at least the number of elements after the last erased item, worst the size of the whole container left
std::map
std::map
is a sorted associative container providing search, removal and insertion at a logarithmic complexity. They are usually implemented as red-black trees.
accessing an element: with
at()
or withoperator[]
both have a complexity ofO(log n)
wheren
is the size of the containerinserting an element at a random location: with
insert()
or withoperator[]
both have a complexity ofO(log n)
wheren
is the size of the container. Withinsert()
you can insert multiple elements, and then the complexity becomesO(m * log n)
, wherem
is the number of elements to insert.insert()
can also take a position as a hint where to insert. If the insertion happens there then the complexity is amortizedO(1)
otherwiseO(log n)
- removing an item: with
erase()
which has a complexity of amortizedO(1)
if the erasure happens with an iterator, otherwise it’sO(log(n) + m)
wheren
is the size of the container andm
is the number of elements to remove - finding an element: with
find()
which has a complexity ofO(log n)
wheren
is the size of the container
std::unordered_map
std::unordered_map
is an unsorted associative container optimized for search, removal and insertion which come at a constant time complexity. std::unordered_map
is a hash map internally.
accessing an element: with
at()
or withoperator[]
both have a complexity ofO(1)
on average andO(n)
at worst wheren
is the size of the container- inserting an element at a random location: with
insert()
or withoperator[]
both have a complexity ofO(1)
on average andO(n)
at worst, wheren
is the size of the map. Ifm
elements are inserted then the average case isO(m)
and the worst case isO(m * n + n)
- removing an item: with
erase()
which has a complexity of amortizedO(1)
if the erasure happens with an iterator, otherwise, on average it’sO(m)
wherem
is the number of elements to remove, worst case it’sO(n)
wheren
is the size of the container - finding an element: with
find()
it’sO(1)
on average and in the worst case it’sO(n)
wheren
is the size of the container
Algorithms
If you use raw loops and you understand the containers, you don’t have to deal with these. A surprising “advantage” of using raw loops - please, prefer algorithms!
Otherwise, most probably you understand what standard algorithms do. Think about them and you’ll be able to come up with their complexities in most cases. Let’s have a look at some algorithms:
all_of
/any_of
/none_of
have at mostO(n)
complexity wheren
is the size of the range the algorithm is applied oncount_if
has a complexity ofO(n)
wheren
is the size of the range the algorithm is applied onfind
/find_if
have a complexity ofO(n)
. They need at mostn
applications ofoperator==
or a predicate wheren
is the length of the range passed inreplace
/replace_if
have a complexity ofO(n)
. They needn
applications ofoperator==
or of a predicate wheren
is the length of the range passed in and at mostn
assignmentscopy
/copy_if
have a complexity ofO(n)
.copy
doesn
assignments wheren
is the length of the passed-in range, forcopy_if
we also have to think about the application of the predicate, while the number of assignments might be smaller.transform
also has a complexity ofO(n)
. It performs exactlyn
applications of the operation, wheren
is the length of the passed-in range.generate
has a complexity ofO(n)
as it invokesn
times the generator function and also performs the same amount of assignments.remove_if
has a complexity ofO(n)
as it performsn
applications ofoperator==
or of a predicate wheren
is the length of the range passed in.swap
has a complexity ofO(1)
if applied on single values andO(n)
if applied on arrays wheren
is the size of the arrays to be swappedreverse
performs exactly half as many swaps as the size of the range to be reversed, therefore the complexity isO(n)
rotate
also has a complexity ofO(n)
.
Quite boring, right? But boredom brings simplicity to your calculations.
Conclusion
In this article, we talked about the complexity analysis of operations on containers and of algorithms which are so often make important part of a software developer job interview. We discussed some hints on how to approach such questions if you neglected complexity analysis during most of your preparation for interviews. Finally, we quickly went through the most important complexities of C++ containers and standard algorithms so that you can have the most basic characteristics that you’d need at a job interview. Good luck!
Connect deeper
If you liked this article, please
- hit on the like button,
- subscribe to my newsletter
- and let’s connect on Twitter!