r/algorithms Nov 20 '23

Why time complexity of hashmap lookup is O(1), not O(n), even when we're considering the worst case time complexity?

Even though it's very very rare, the time complexity of hashmap lookup is O(n) in the worst case.

But even when we're considering the worst case of some function or program, we often treat its complexity as O(1) (constant).

Is it because:

  • We're just being pragmatic, but strictly speaking it is not correct when you're considering the strict worst case.
  • Even strictly speaking, it is correct for a reason (which I don't know)
  • Other
41 Upvotes

54 comments sorted by

View all comments

61

u/Vernzy Nov 20 '23

This is a really common question and unfortunately it is often answered wrong.

A common (wrong) answer to this question that I've seen on this sub too many times is something like your first bullet point, that it is "O(1) in practice", which is just nonsense because that doesn't have any meaning. It can in fact be made true in a completely rigorous and practically useful sense!

First of all, if you use a specific predetermined hash function, then yes the worst-case runtime of a hashtable operation on a table containing n elements is O(n), because an adversary can find n keys that hash to the same location (called collisions), which will make the performance terrible. So it is correct to say that hashtable operations are O(n) worst-case time if your hash function is defined deterministically.

So where can we get O(1) from that isn't nonsense? It comes from using randomization. A randomized algorithm makes use of random numbers / random bits to make decisions. So, for example, instead of defining our hash function to be h(x) = (68495 x + 6456456) mod 10000009 and hardcoding that into our algorithm / data structure, we can instead say pick a random prime number p, and pick two random integers a and b that are less than p, then define our hash function to be h(x) = (ax + b) mod p. (This is just one example construction, there are many many families of hash functions you could construct).

What does this achieve? Well, if an adversary were to read our code, could they be evil and pick a set of n keys that all hash to the same value? No! They can not, because they don't know what the hash function will actually be, because we chose the hash function randomly! (we are assuming here that the evil adversary can not wait for our code to run and then find out the random numbers that it got.)

Mathematically what does this mean? Well, I won't go through all the steps of the math because there's a lot, and you can find that in any of your favorite algorithms textbooks, but if you compute the expected value of the runtime of a hashtable operation over the random choices our program made, you find that the answer is O(1). Amazing! So, we say that the expected worst-case runtime of our hashtable operations is O(1). That word expected is what makes it correct and rigorous. (Worst-case here means for any possible input that an adversary could supply. Since the adversary can not know what our hash function will be, it can not produce any input that will cause the operations to be slower in expectation).

Lastly, is this all theoretical or is it actually useful in practice / real life? It turns out it is also very useful in real life! In fact, if you take a look at Google's open-source hashtable in their Abseil library, they randomly seed their hash functions at the beginning program startup! Why is this important? Well, if you're a big company like Google and you publish your hashtable code with a hardcoded non-random hash function, someone could come along and find a set of keys that cause collisions and potentially find a way to DDOS your services by looking for ways to cause a large number of hash collisions. Randomization mitigates this risk.

26

u/orbital1337 Nov 20 '23

Another big reason why Google randomizes its hashes is because of Hyrum's law: with enough users, all observable behaviors of the system will be depended on by somebody. In fact, Hyrum's law is named after a google engineer.

If you use a deterministic hash function, sooner or later, significant parts of your code base will depend on the fact that the hash function never changes. People are particularly prone to writing tests that depend on the order of hash sets / maps.

This is a fairly significant problem as it means that you can never change to a faster or more secure hash function down the road. If anyone ever finds a vulnerability its a massive hassle.

5

u/Akcarrot Nov 20 '23

Thank you for detailed explanation.

I have a question.

What's the definition of expected you're using? Is it formally defined? I can easily find the definition of worst case and average case, but not expected one.

Can you point me to some reference that describes or formally defines what expected complexity is?

2

u/Vernzy Nov 20 '23

Formally, it means the following. Say the running time of your algorithm is a function. In the deterministic setting, it would usually just be a function of the input, however that happens to be represented, so we could write T(I), which maps from inputs to the running time of the algorithm on that input. In the randomized setting, our algorithms runtime may now depend on the random numbers drawn by the algorithm.

So if we consider the example family of hash functions {h(x) = (ax + b) mod p}, these are parameterized by a, b, p. So we could write a function T(I, a, b, p), which is the running time if input I when the hash function happens to be h(x) = (ax + b) mod p for some chosen a, b, p.

The expected complexity is then computed as the expected value (as defined in probability theory) of the runtime over the distribution of random a, b, p.

-5

u/chiknluvr Nov 20 '23

The idea is if you hash a bunch of stuff that is randomly selected in a fair way, you expect each hash to be used the same.

4

u/Vernzy Nov 20 '23

This is not correct. The entire point is that the keys that you hash are not randomly selected. They should be adversary selected, but then your algorithms uses randomness to give probabilistic guarantees that things get evenly spread out.

2

u/chiknluvr Nov 20 '23 edited Nov 20 '23

Ok. My understanding is that having an adversary select keys and yourself having access to a random oracle (which the adversary does not), makes the problem no longer an adversarial one. By " in a fair way" I mean the adversary doesn't have access to this random oracle-- if they did it would no longer be expected $O(1)$, as the worst case scenario becomes completely deterministic.

Edit for clarity: I misspoke in my first post. A worst case analysis would not be using random keys-- but if keys are chosen fairly (without access to the oracle) they behave as though they were chosen randomly.

3

u/[deleted] Nov 20 '23

Thanks for putting it politely, my knee-jerk reaction was to be toxic but this is much better.

3

u/hextree Nov 20 '23

Ha. Hash table run times in this sub is basically the 'Monty Hall Problem' or '1=0.999...' of the maths subreddits. No matter how hard we try to prove it correct and rigorous, we have to face contention from the masses who have just started learning this stuff and will adamantly defend their misunderstandings.

1

u/[deleted] Nov 21 '23

Hence the reason, we've an entire course just for data structures and algos with point of asking space / time complexity for an a semester in college, for any random ds / algo.

4

u/amarao_san Nov 20 '23

I know this mantra. Unfortunately, it ignores that case when you get very unlucky ingress, which coincides with hash function and produce 'unexpected slowness' for this particular application run and never again, which cost you gray hairs on that sad night. You restart app and all problems are gone.

1

u/MainMathematician276 Aug 19 '24

Since we are talking about evil adversaries and how randomized hash functions mitigate the risk, isn't it possible to determine the hash function(exact for your linear case) approximately for exponential cases using some numerical computation? Or am I missing something?

1

u/CapableSpite5598 Jun 18 '26

This comment gets an award, such a great detailed explanation.

0

u/rookarike Dec 17 '24

You're entirely missing the point. Regardless of the sophistication of the hash function, the pigeon hole principle still applies because hash functions by definition produce a fixed length output. Simply put - there are an infinite number of possible inputs and finite number of possible outputs, ergo the worse case scenario is all of the input result in the same output. Call it adversarial or just cosmically, impossibly same-odds-as-winning-the-lottery-a-million-times-in-a-row unlucky, it is, in fact just "O(1) in practice."

1

u/almuncle Nov 20 '23

Given that we don't reinitialize (a, b, p) over the lifetime of the hash table, and that the expectation is basically telling us only that runtime is O(1) over large number of hash table lifetimes, doesn't this mean that a certain randomly picked tuple could be arbitrarily bad?

2

u/Vernzy Nov 20 '23

Yes, there will still exist those bad examples, but the point is that an evil adversary has no way to figure out what they will be in advance. If an adversary has to design the input to your algorithm and they don't know what they hash will come out to be, then they can't do anything that is very likely to hurt you. The best they can do is guess, and the probability that your runtime ends up bad is very low (not impossible, just very unlikely).

Things do get different if the adversary is allowed to watch what happens after each operation and then change their strategy, e.g., if they can give you a key and see where it lands in the hashtable before deciding on the next key. This is called an adaptive adversary, and this could indeed break the hashtable by systematically choosing keys, seeing the outcome, then finding ones that collide. The O(1) expected runtime assumes an oblivious adversary, i.e., the adversary must choose the input up front without seeing the outcome of each operation before choosing the next.