Show HN: Array with Constant Time Access and Fast Insertion and Deletion
github.com
github.com
For accesses, you can in constant time determine which circular array contains the element at the given index (it's simply i % sqrt(N)) and then in constant time access the element from the underlying array. For inserts and deletions, you find the circular array that contains the location you want to insert into. First you make sure there is space in the array to insert. You do this by moving one element from each circular array to the next one. Since deleting and inserting from the end of a circular array takes O(1) and there are O(sqrt(N)) arrays, this takes a total of O(sqrt(N)) time. Then you insert the new element into the middle of the designated circular array which is of size sqrt(N) so it takes in the worst case O(sqrt(N)). This means insertions take a total of O(sqrt(N)) time.
As immawizard pointed out, there is a generalized version of this idea called tiered vectors[0] that supports an arbitrary level of nesting. A 1-tiered vector is a circular array. A k-tiered vector is an array of n^(1/k) tiered vectors of tier (k-1). You can show that for a k-tiered vector, access time is O(k), while insertion and deletion have a runtime of O(n^(1/k)). The datastructure mentioned in the post can be considered a 2-tiered vector.
The post includes benchmarks comparing the datastructure to std::vector. I would be interested in seeing benchmarks vs a binary search tree. Even though the datastructure has O(sqrt(N)) performance, that's still a lot slower than O(log(N)). The square root of a million is 1000, while the log base 2 of a million is only ~20.
One nitpick is that the author names the datastructure after themselves. Naming things after yourself is typically a faux pas.
Here's my nomination:
Loop List
Basically, it has O(log n) everything like a binary search tree you suggested but also very good constant factors.
By default, leaves hold 1024/sizeof(T) elements and branches hold 47 elements, so it can access up to 13,289,344 8 byte elements with only 4 levels.
Does it require constant time concat?
Does it require immutability?
Does it require a balancing scheme based on the fib sequence?
Does it require that the tree is binary?
My tree is an attempt to be as fast as possible with log everything operations and no pointer/reference stability.
1. Simply testing something 1000 times and (presumably) presenting the arithmetic mean is not very informative. Looking at the detailed reported benchmark times (in the output file in tests), it looks like many of the timing outcomes have high variance. Rather than running the tests 1000 times and taking the mean, you might consider running 10 batches of 100 tests (or 1000, if you can) and presenting the mean and variance of the resulting distribution. In general, k sample groups each of size p will provide more reliable information about the underlying distribution than one sample group of size k*p (for reasonable k and p, obviously).
2. Related to that, the results of the "inserting a number of elements" and "deleting a number of elements" tests are significantly worse for the tiered vector vs the std::vector than the "insert/delete a single element" tests. You don't mention this in the readme, but thinking about why it is might be informative. Thrashing seems like a possible explanation, and one you might be able to mitigate.
3. Are you making sure your cache is warm before starting to measure performance? (Pardon, I didn't look through every line of your tests.) Particularly for std::vector, and likely your intermediate deques too, this will have a big effect on timing.
4. Finally, it looks like you're primarily testing using ints (?). It would probably be a good idea to see if your results hold for a different payload size.
I don't know whether these will improve or worsen your comparison against std::vector, but they will make your claims more robust.
His lookup should be roughly twice as expensive as a regular array when cache is cold - it will be dominated by the cache misses two vs one.
With a hot cache, the array lookup becomes a single load op of a few cycles, and even if he gets two cache hits, his will probably be about 6-10 ops with 2 loads, a div, and a few more ops to get the index of the sub bucket.
On one cache hit and a possible miss on the other (eg his top level index is in cache, but the bucket might not be), he's going to be getting high variance in his lookup timings. OP: to help the iterator access (sum), you might be able to prefetch (eg force a read) of the folling bucket before you need it. when you move over the bucket 2, cause a fetch of bucket 3 and the same time. You might be able to hide some of that cache miss latency then.
Also, with the DEQs you are potentially starting at the middle of a slab and having to roll around its front. That isn't going to prefetch well, so you might want to fetch the next next slab's base address and the address it logically starts at since those cane be different. Just try to hide as much cache miss latency as you can because that is where you are probably getting killed on in the iterator access.
There's also some benchmarks for another implementation of the same idea linked to in the README here: https://github.com/mettienne/tiered-vector/blob/master/READM.... That implementation also just punted on growing the maximum capacity of the area.
Update: answer is to implement DEQ with circular buffer for O(1) pop/push.
For example, for a full DEQ, in order to perform a push, I would have to:
1. Pop the last element
2. Move all element to the right by 1
3. Push the item into position 0
This would be a linear operation. Unless the address of the starting and ending element can change, I don’t think it’s possible to get both push and pop to less than linear time.
Edit: I was wrong--key is circular buffer
Edit: should have thought of that. Brain fart—-long day at work.
Sure, but this step would require a right shift for (in the worst case) every element in the structure. Suppose you have the following DEQ:
[R0] -> [0, 1, 2]
[R1] -> [3, 4, 5]
[R2] -> [6, 7, 8]
And you want to remove 4. From my understanding of your README, the structure will look like this after the operation: [R0] -> [0, 1, 2]
[R1] -> [3, 5, 6]
[R2] -> [7, 8, _]
This was not and O(sqrt(n)) operation, because you had to move elements 5, 6, 7, and 8 (not just 5 as suggested by your figures). So in general you still need to move O(n) elements upon any insertion or deletion.EDIT: Just read some of the other comments and realized that this can be done with a circular buffer with a bit more memory than the subarrays themselves. Makes sense + clever! @OP, I really think you should clarify on this important implementation detail in your writeup.
It's a neat restructuring of the vector into a SQRT(N) * SQRT(N) arrangement.
You have a limited size of the deq of course, but here that's fine, it's actually constant size for all of them except the final possibly smaller one.
So now, what work do you have to do on insert? For the destination deq, you need to do one pop, one copy-in and up to n^1/2 moves. For every deq after that, up to about n^1/2 of them, you need to do one pop and one push, each of which costs O(1).
I don't think any of us mean to imply that you copied off another implementation; independent discovery happens all the time! It's great you've made this a usable C++ library and have contributed your own thoughts on the structure!
It looks like you're having difficulty with some of the resizing. The two array approach described here https://stackoverflow.com/questions/10478260/looking-for-a-d... makes resizing a lot easier. You just double the size of the main array and increase the size of the offset array by the square root of two, without needing to fiddle around with anything else. You also get unchanged amortized time bounds.
Interesting!
ArrayList list = new ArrayList();
for (int i = 0; i < N; i++) {
list.add(i);
}
(Forgive me, Java is my mother tongue).I suspect an amortized data structure could automatically and internally do this rebalancing operation, but I'd have to work out the scheme and the analysis. At a guess, something like normal dynamic arrays do, where you multiply the max size by a constant when it fills up, would give you decent amortized bounds.
https://www.codeproject.com/Articles/5425/An-In-Depth-Study-...
Of course, the growth factor is implementation-dependent and I believe some compilers use smaller growth factors to improve performance.
I hope your mother taught you the value of using generics ;)
Interesting alternatives that also support insertion, deletion, and lookup (is there a name for this interface?) are AVL trees and hash tables.
As described in https://arxiv.org/pdf/1711.00275.pdf, tiered vectors can be improved further:
"We present a highly optimized implementation of tiered vectors, a data structure for maintaining a sequence of n elements supporting access in time O(1) and insertion and deletion in time O(n^ε) for ε > 0 while using o(n) extra space."
https://en.wikipedia.org/wiki/Judy_array http://judy.sourceforge.net
To install the Judy C lib:
brew install judy
apt-get install libjudy-dev
Then compile with -lJudy and #include <Judy.h>An rrb-tree is an effective O(1) time for every category. Scala's Vector type implements one.
If interested about the data structure: https://youtu.be/sPhpelUfu8Q
Scala bigO doc: https://docs.scala-lang.org/overviews/collections-2.13/perfo... Scala's doc on it: https://github.com/nicolasstucki/scala-rrb-vector/blob/maste...
https://en.wikipedia.org/wiki/Van_Emde_Boas_tree
(it basically trades a little more work on indexing for a little loser structure)
I like the use of the DE queue, but having to keep all the sub vectors packed to ensure constant time lookup might not be completely needed if you are willing to keep a little more information around or have two candidate buckets. Not sure if would be a win or not though, since they your probably just better using a VeB tree.
e.g., I don't think you always need to compress everything back to the front if you can make sure all the buckets are within 1 or each other, but you can hit cases where you have to look in two buckets which isn't that difficult since you how the high and low of each bucket at the head and tail of each DEQ.
But then you would need to keep some local coloring info keep the buckets perfectly balanced, as everything should be.
This data structure makes more sense in the 10k-1M size, where chasing pointers is expensive and the scaling doesn't dominate.
I'm curious to know from approximately which n this is a problem though. It can be easy to make something with a better O complexity that is actually slower for what computers can actually handle.
edit: nevermind, I thought deque was more "smart" than it is but it needs to move all elements when inserting / deleting in the middle, so it won't have a great constant factor.
An rrb-tree solves this problem by being relaxed, which means allowing for having random empty places in the cache line sized arrays within the structure. This way insertion is effectively O(1).
Modern languages implement these, like Scala, but C++ has been around for a while, so it's the stl is not the fastest in a few ways. std::deque is one of them. Another example, std::sort isn't the fastest, I believe.
That being said, the overload of a complex data structure might multiply the running time by a factor up to ~10 (mostly because of cache misses), so simpler structures will be more performant for short inputs.
There is a more general pattern in most tree data structures where you can transform the log(n) recursive operations on the tree into sqrt(n) operations on a simpler structure with 2 levels.
I happen to have described the solution to a problem where you must use a sqrt-decomposition datastructure to have updates in O(1) and queries in O(sqrt(n)) because O(log(n)) everywhere is not good enough as there are a lot of updates to do (https://tryalgo.org/en/2017/09/01/path-statistics/).
Or there has to be a readjustment of k.
Cab somebody point out my thinking error?
This is similar to how you can do O(logN) for insertion / deletion & access using an indexed B-tree, except you're using a fixed height instead of a fixed branching factor.
1. extend the construction to three dimensions, so that the insertion cost is a cubic root?
2. iterate the extension to an increasing number of dimensions, so that the cost becomes logarithmic?
1 2 3 4 5
Deleting number three requires you to do three operations: 1 2 4 4 5 # Move 4th element to the 3rd place
1 2 4 5 5 # Move 5th element to the 4th place
1 2 4 5 # Clear the 5th value
Similarly inserting a second three requires 3 operations 1 2 3 4 5 5 # Move 5th element to the 6th place
1 2 3 4 4 5 # Move 4th element to the 5th place
1 2 3 3 4 5 # Insert the second 3
This means as the array gets bigger you have to do more work to insert/delete values. If your arrays are large and need to do this often, your software will be very slow and you should use a more suitable data structure with different trade offs.Alternatively, here's my list ADT implementation which has O(log n) access/insertion/deletion time: https://www.nayuki.io/page/avl-tree-list .
( O(N^(1/2)), O(N^0.5), O(sqrt(N)), O(N¹ᐟ²), O(√N̅) would have been better... )
Edit: curious about the downvotes - care to explain? I feel like this is a legitimate and fairly uncontroversial take, but happy to hear differing perspectives...
[1] https://news.ycombinator.com/item?id=20873110 [2] https://www.ics.uci.edu/~goodrich/pubs/wads99.pdf
Also, I don't think the author claimed it's "objectively superior." It has objectively superior asymptotic performance in certain relatively niche workloads. However, its constant time factor is iffy due to all the pointer following and calculations you have to do for random accesses. And the benchmark results in the README show this clearly -- both the strengths and the weaknesses.
Actually, your whole train of thought around the claims in the README is puzzling to me. You seem to think they're grandiose and over the top, but the README seems mostly explanatory to me. What statements in there do you think ought to be qualified, specifically, and how?
If you want upvotes next time, explain in detail what claims you object to, and why. This comment I'm afraid did not add much to the discussion, at least for me.
> It has objectively superior asymptotic performance in certain relatively niche workloads. However, its constant time factor is iffy due to all the pointer following and calculations you have to do for random accesses. And the benchmark results in the README show this clearly -- both the strengths and the weaknesses.
I got that impression after going through a fair bit of the README. My feeling is neither the post title nor the introductory statements in the README reflected these very relevant qualifications to the claim of "An Array with Constant Time Access and Fast Insertion and Deletion", and putting the onus on the reader to dig through the whole thing to discover this is either naïve or a bit disingenuous. As others have noted, and as I implied in my comment, there is in fact existing literature that captures the particular ideas and tradeoffs in this implementation - Tiered Vectors. I believe the author has a duty to cite that up front and prominently in the spirit of honest academic discourse.
What "academic discourse"? This is a datastructure implementation with some documentation in a Github repo, not a submission to a scientific journal. You're insinuating a level of rigor that is completely unwarranted.
Just lean back, relax, and accept that you overreacted a bit in your original post. It's not the end of the world to admit that.
Why would the author need to add qualifications to the title? That is literally and exactly what the data structure is.
Where I think this would be slower is streaming access. Say you have 10,000 elements, this would store it in a 100 by 100 grid. You want to read off elements 200-500.
A normal array would allow you to sequence through them as a continuous block of memory, probably as fast as it gets. I'm outside my expertise here but I believe that block could be loaded into the processors inner most cache?
This array OTOH would require some indexing/pointers along the way which would slow it down.
So this will add to some of the constants that the O notation conveniently omits!