39

Is there a specific data structure that a deque in the C++ STL is supposed to implement, or is a deque just this vague notion of an array growable from both the front and the back, to be implemented however the implementation chooses?

I used to always assume a deque was a circular buffer, but I was recently reading a C++ reference here, and it sounds like a deque is some kind of array of arrays. It doesn't seem like it's a plain old circular buffer. Is it a gap buffer, then, or some other variant of growable array, or is it just implementation-dependent?

UPDATE AND SUMMARY OF ANSWERS:

It seems the general consensus is that a deque is a data structure such that:

  • the time to insert or remove an element should be constant at beginning or end of the list and at most linear elsewhere. If we interpret this to mean true constant time and not amortized constant time, as someone comments, this seems challenging. Some have argued that we should not interpret this to mean non-amortized constant time.
  • "A deque requires that any insertion shall keep any reference to a member element valid. It's OK for iterators to be invalidated, but the members themselves must stay in the same place in memory." As someone comments: This is easy enough by just copying the members to somewhere on the heap and storing T* in the data structure under the hood.
  • "Inserting a single element either at the beginning or end of a deque always takes constant time and causes a single call to a constructor of T." The single constructor of T will also be achieved if the data structure stores T* under the hood.
  • The data structure must have random access.

It seems no one knows how to get a combination of the 1st and 4th conditions if we take the first condition to be "non-amortized constant time". A linked list achieves 1) but not 4), whereas a typical circular buffer achieves 4) but not 1). I think I have an implementation that fulfills both below. Comments?

We start with an implementation someone else suggested: we allocate an array and start placing elements from the middle, leaving space in both the front and back. In this implementation, we keep track of how many elements there are from the center in both the front and back directions, call those values F and B. Then, let's augment this data structure with an auxiliary array that is twice the size of the original array (so now we're wasting a ton of space, but no change in asymptotic complexity). We will also fill this auxiliary array from its middle and give it similar values F' and B'. The strategy is this: every time we add one element to the primary array in a given direction, if F > F' or B > B' (depending on the direction), up to two values are copied from the primary array to the auxiliary array until F' catches up with F (or B' with B). So an insert operation involves putting 1 element into the primary array and copying up to 2 from the primary to the auxiliary, but it's still O(1). When the primary array becomes full, we free the primary array, make the auxiliary array the primary array, and make another auxiliary array that's yet 2 times bigger. This new auxiliary array starts out with F' = B' = 0 and having nothing copied to it (so the resize op is O(1) if a heap allocation is O(1) complexity). Since the auxiliary copies 2 elements for every element added to the primary and the primary starts out at most half-full, it is impossible for the auxiliary to not have caught up with the primary by the time the primary runs out of space again. Deletions likewise just need to remove 1 element from the primary and either 0 or 1 from the auxiliary. So, assuming heap allocations are O(1), this implementation fulfills condition 1). We make the array be of T* and use new whenever inserting to fulfill conditions 2) and 3). Finally, 4) is fulfilled because we are using an array structure and can easily implement O(1) access.

Gravity
  • 2,598
  • 1
  • 17
  • 28
  • You might be interested in reading [this question](http://stackoverflow.com/questions/8305492/about-dequets-extra-indirection) I asked a while ago. – user541686 Dec 24 '11 at 23:23
  • 9
    it's not a circular buffer in any sense – Lightness Races in Orbit Dec 24 '11 at 23:34
  • Gap buffers are inefficient for deques. – Pubby Dec 24 '11 at 23:46
  • @TomalakGeret'kal, there's nothing wrong with using circular buffers. They are quite a reasonable implementation in fact. In you look at the comment thread at Pubby's answer, you can see a lot of argument. It appears some people are convinced in must be a simple linked list, with non-random access. It would be good to get more people to look at this. – Aaron McDaid Dec 25 '11 at 03:51
  • 2
    Doh! The following sentences in N3242 are relevant, and give me pause for thought (i.e. I might have been wrong). "An insertion at either end of the deque invalidates all the iterators to the deque, but has no effect on the validity of references to elements of the deque." and "Inserting a single element either at the beginning or end of a deque always takes constant time and causes a single call to a constructor of T." – Aaron McDaid Dec 25 '11 at 04:03
  • The standard library defines containers in terms of [performance characteristics](http://stackoverflow.com/questions/181693/what-are-the-complexity-guarantees-of-the-standard-containers) not how they should be implemented. The implementation is free to do whatever they like as long as it has the appropriate performance characteristics. It is implemented the way it is because it needs the characteristics of a `Front Insert Sequence`, `Back Insert Sequence` and a `Random Access Sequence` – Martin York Dec 25 '11 at 09:25
  • @AaronMcDaid: I never said that there's anything wrong with circular buffers. – Lightness Races in Orbit Dec 25 '11 at 18:55
  • I've asked a related, more basic, question elsewhere on SO: ["What does “constant” complexity really mean? Time? Count of copies/moves?"](http://stackoverflow.com/questions/8631531/what-does-constant-complexity-really-mean-time-count-of-copies-moves) – Aaron McDaid Dec 25 '11 at 21:04
  • There are a number of duplicate questions about deques on SO. Maybe it's time to consolidate. I shouldn't have taken part in all the threads. For example, this seems identical: http://stackoverflow.com/questions/6292332/what-really-is-a-deque-in-stl/8636478#8636478 I'd vote to close, but I don't have that privilege. – Aaron McDaid Dec 28 '11 at 16:56

7 Answers7

13

It's implementation specific. All a deque requires is constant time insertion/deletion at the start/end, and at most linear elsewhere. Elements are not required to be contiguous.

Most implementations use what can be described as an unrolled list. Fixed-sized arrays get allocated on the heap and pointers to these arrays are stored in a dynamically sized array belonging to the deque.

Pubby
  • 48,511
  • 12
  • 121
  • 172
  • 3
    @BentFX Yeah, that was confusing. Reworded it. – Pubby Dec 24 '11 at 23:33
  • 1
    The restrictions are a lot more specific. Especially a deque is required to have random access iterators. Insertion and deletion at the begin/end don't need to be efficient, they need to be constant time and at most linear time at other positions. – pmr Dec 24 '11 at 23:57
  • I believe it also required O(size) (or better) insertion and deletion from the middle of the list using `erase` or `insert` with an iterator. I believe that it may also require iterators to be random access, but I'm not sure, it may just be bi-directional. – Omnifarious Dec 24 '11 at 23:57
  • @pmr: Is it absolute constant time, or amortized constant time? – Omnifarious Dec 25 '11 at 00:00
  • Updated it a bit. @Omnifarious it uses RandomAccessIterators but cannot be truly random-access due to the list nature. – Pubby Dec 25 '11 at 00:03
  • @Omnifarious It is absolut constant time according to n3920. I'm not sure if this should be considered a bug in the standard. The only way to implement this is a linked list. But that would not be what I would expect. The question linked by Mehrdad is pretty enlightening. – pmr Dec 25 '11 at 00:15
  • 1
    @pmr: It is. And I consider it a bug now. The requirement of a random access iterator is incompatible with the requirement of absolute O(1) time for inserts and deletes to either end. Not even `vector` accomplishes this feat. Maybe some brilliant computer scientist knows how to create a data structure that can satisfy these two requirements. But I don't know of such a data structure. – Omnifarious Dec 25 '11 at 00:36
  • @Omnifarious It's of course possible if implemented as a vector that can grow both ways, although this isn't possible without control of the reallocation, which `new` can't provide. – Pubby Dec 25 '11 at 00:38
  • @pmr: I'm having a hard time finding a copy of the actual text, but in one draft I found, the same clause that says `deque::push_front` must be constant time says the same thing about `vector::push_back`. –  Dec 25 '11 at 00:44
  • And another copy I came across has "amortized constant time" on the same table. –  Dec 25 '11 at 00:50
  • 2
    @Omnifarious: It's **completely possible** to have constant-time insertion/deletion at either end while keeping it random-access. Just leave extra space in the beginning as well as the end. See the question I asked a while ago (link above). We had an entire discussion about this, which the poster deleted because he thought it wasn't possible. It's completely possible -- it just wastes a bit more space (which doesn't really matter anyway). – user541686 Dec 25 '11 at 03:01
  • @Pubby: It is nothing at all like a linked list. The iterators really are random-access; i.e. it is _constant time_ to access any element in the deque by index. – Nemo Dec 25 '11 at 03:04
  • It's not a bug, it is possible to get constant time insertion at both ends. It is basically a vector of arrays. IF you run out of space at either end, you simply add a new array to the collection. And you can add this array at either the beginning or the end of the data structure. No data ever needs to be copied, so it is constant time. – jalf Dec 25 '11 at 03:15
  • @jalf: In your proposed implementation, you may have to copy **vector**'s contents when you resize. That is not constant time, just amortized constant time. (and, of course, you need a double-ended sequence of arrays....) –  Dec 25 '11 at 03:32
  • @Mehrdad: you seem to have overlooked what happens when you `push_front` or `push_back` many times in a row. (e.g. one more time than the amount of space you left) –  Dec 25 '11 at 03:35
  • (I've just posted an answer which might move the discussion forward a little.). Anyway, I suspect it is amortized constant time, and perhaps some websites were a bit careless and just wrote "constant". Anybody got an authoritative answer? – Aaron McDaid Dec 25 '11 at 03:37
  • I have a comment on the state of this comment thread. I think many of us would be well-advised to fully understand `vector` first before arguing about `deque`. In particular, once a vector has reached its capacity, it would be just as complex to do a push_front as to do a push_back. – Aaron McDaid Dec 25 '11 at 03:40
  • @Aaron: Actually, I believe that "constant" was actually in one of the drafts. Searching with Google, I found two different drafts of the C++ standard, one had "constant" and the other had "amortized constant". Alas, the only copy of the actual standard I have access to is at work. :( –  Dec 25 '11 at 03:48
  • @Hurkyl: We had an *entire discussion* about this... no, I haven't overlooked anything. Lemme get you a copy of the discussion or whatever so you can see (it's deleted, so only 10k users can see it... I don't wanna repeat it so I'll try to get a copy or something). – user541686 Dec 25 '11 at 03:49
  • @Hurkyl: I voted to undelete [the post](http://stackoverflow.com/a/8305544/541686)... if it gets enough votes (just 2 more!) you'll be able to see the discussion and hopefully it'll make sense. – user541686 Dec 25 '11 at 03:57
  • @Hurkyl, you're right. From a late C++11 draft: "Inserting a single element either at the beginning or end of a deque always takes constant time and causes a single call to a constructor of T. " – Aaron McDaid Dec 25 '11 at 04:11
  • @Mehrdad: the description you gave has an upper-bound on how much you're allowed to do pushes. If instead you meant "double the size when you need to realloc, but leave space in front too" then it's *amortized* constant time. Whether or not it's possible to get both `push` and `operator[]` to be constant (not merely amortized constant) is a different question. –  Dec 25 '11 at 04:36
  • @Hurkyl: Yes, it's amortized constant. That was actually my *first comment* on the now-deleted post. But the point is, how does that make any difference? In fact, have you *ever* heard of a "constant-time" addition/removal that was *not* *amortized* constant time? Linked lists, vectors, etc. are *all* amortized constant time -- if nothing else, because of the heap allocations, which are unbounded in terms of the time they take (due to paging, internal fragmentation issues, critical sections, etc.) *The distinction between "constant-time" and "amortized constant time" **really doesn't exist**.* – user541686 Dec 25 '11 at 04:59
  • 1
    @pmr: If you run out of space at one end or the other, even if you've left space, then you have to copy stuff to a new allocation. Not even `realloc` can save you when adding memory *before* your allocation. No, true constant time is not possible with any data structure anybody has mentioned here. Even having big blocks and a small vector of pointers to these blocks means you sometimes have to copy that vector. Again, not constant time. No, there is no possible conforming implementation that I can think of. The standard is in error. – Omnifarious Dec 25 '11 at 05:04
  • 1
    @Mehrdad: This whole thread of discussion has been about the difficulty of making it strictly constant time. Also, from the standard: "All of the complexity requirements in this clause are stated solely in terms of the number of operations on the contained objects." The time it takes to e.g. allocate space is *explicitly* excluded. –  Dec 25 '11 at 05:07
  • 1
    @Omnifarious: I'm pretty sure the standard is **not** in error -- its writers weren't stupid after all (and probably smarter than me, not sure about you). :) See my last reply to Hurkyl -- there really *isn't* a distinction between "amortized constant" and "constant" unless the writer *explicitly mentions* otherwise... picking on this point is just finding an excuse to find something wrong with something that's correct. – user541686 Dec 25 '11 at 05:07
  • 1
    @MehrdadL On modern architectures with virtual memory, you are perhaps correct. But absolute constant time does exist. Doing a register load on a 6502 from an absolute immediate address took x cycles, no matter what. That's absolute constant time. And on some embedded targets, that level of exact is possible. Presumably, an absolute constant time requirement would mean that if you had no paging in the background, and no cache misses to deal with, it would take constant time. That distinction is real, and it's why algorithms started talking about amortized time in the first place. – Omnifarious Dec 25 '11 at 05:08
  • @Omnifarious: Maybe it's just me, but I fail to see how your claim being true in "some embedded targets" (a **ridiculously restricted** set of systems) is at all relevant to "the C++ standard" (a **ridiculously loose** standard, meant for a wide variety of systems). Do you know of *any* operations that are 'truly' constant-time according to the C++ standard? No? Does that mean the C++ standard is wrong? OK, sure. You're right. The authors made a grave mistake, and it was impossible to deduce what they actually meant by "constant". Obviously they didn't know as much about computers as we did. – user541686 Dec 25 '11 at 05:14
  • Guys, check out my update to the question. I mention a data structure that could indeed - unless I'm mistaken, and I would like to see comments - achieve O(1) insertion and deletion without amortization if you're willing to assume `new` and `delete` can run in O(1) (perhaps possible if no zeroing out of the memory is needed like in this case). – Gravity Dec 25 '11 at 06:07
  • @Mehrdad: and yes, I see the virtual memory point you're making, but just for fun, we can make a distinction between worst-case O(1) and amortized O(1) talking about the amount of work done in the algorithm and not the operating system. I do agree that in practice, your point is valid, and the difference is likely only important for very specific systems that have very particular super-low-latency requirements. – Gravity Dec 25 '11 at 06:19
  • @Gravity: Sure, but virtual memory was only *one* of my points, and not even the main one. Dynamic memory allocation *itself* is **impossible** as a 'true' constant-time operation, unless you have infinite memory (which you don't) or unless you're guaranteed to bound the amount of data you're allocating (in which case the argument is pointless -- just reserve the capacity you need in the beginning). And of course that's the job of the program, not the implementation. It really has nothing to do with the implementation, but the very idea of dynamic memory allocation itself. – user541686 Dec 25 '11 at 06:29
  • Why is it so completely impossible to have constant-time memory allocation, at least on some systems? What makes it fundamentally impossible, at least if you don't need to zero out the memory? – Gravity Dec 25 '11 at 06:58
  • @Gravity: You didn't '@' me so I wasn't notified. :P It's fundamentally impossible because, well, it *is*... any scheme you try immediately falls apart as soon as something is deallocated from the heap, because then you have to figure out how to keep track of the blocks that were deallocated (which become fragmented), which introduces non-constant-time overhead (the more fragmented the heap is, the more time it takes to figure out where you should allocate the next block). You *might* be able to keep it down to logarithmic time, but not constant time (since you have to track the freed blocks). – user541686 Dec 25 '11 at 07:16
  • 3
    @Mehrdad: The standard is intended to apply to all environments. That's why so much is left unspecified or undefined. There is a distinction between absolute constant time, and amortized constant time. The standard makes this distinction in some places, most notably (AFAIK) `::std::vector` explicitly specifies that `push_back` is amortized constant time. So if that's what they mean, that's what they should say. The point is for the language to be precise and unambiguous, not to make sense for 'most' environments. – Omnifarious Dec 25 '11 at 07:57
  • @Omnifarious: I'm not gonna continue... I made my point as clear as possible. If you still think the standard is wrong then sure, ok. – user541686 Dec 25 '11 at 08:05
  • @Mehrdad: I agree that it's somewhat doubtful C++ could allocate faster than log-time, but for languages that can move and compact allocated memory (e.g. Java), I don't see why allocation couldn't be made to be O(1). Java zeroes out all memory locations before giving them to you, so it's a bad example, but you get the idea...I'm just not convinced allocation speed is some kind of fundamental limitation that transcends C++. Though, it *is* C++ we're talking here, and you're probably right about C++. – Gravity Dec 25 '11 at 08:55
  • @Gravity: If you're assuming Java can run a parallel thread in the background to do the extra management work, then *maybe* it's possible (as long as it doesn't pause you). But that's assuming the management thread can keep up with the main thread (which it might not), and it's disregarding the point that the *amount of work* is not constant. I guess the amount of *physical* 'time' might be constant in such a case, but the amount of *processor time* certainly isn't, if you add it up for both threads. Instead of thinking 'amount of time vs. memory', think 'amount of work vs. memory'. – user541686 Dec 25 '11 at 09:00
  • @Gravity: Also: Note that moving/compacting memory is certainly **not** constant time! If you're doing that every now and then, your GC certainly isn't constant-time. So really, being able to move/copy memory only makes the amount of work depend on the *amount* of memory allocated, instead of making it depend on the number/size of free fragments. – user541686 Dec 25 '11 at 09:03
  • @Gravity: Another point is regarding the fact that the heap needs to be synchronized, so the GC thread can't just randomly move around data at free will. It will need to pause your thread at some points to prevent corruption, and then swap all references to point to a new location. (Double-indirection through a table so that you only need to swap a single reference does *not* help here -- managing the allocation of the table is itself the same kind of problem, which needs *another* table, etc.) So you can't parallelize the GC 100%. – user541686 Dec 25 '11 at 09:12
  • 1
    There is a sense in which `list::push_back` is more 'constant' than `vector::push_back`. With every `list::push_back`, the same (small) amount of memory is alloced from the heap and a couple of pointers are updated. But with `vector::push_back`, every now and then a massive (re)allocation and copying of data will happen. The worst-case time for a vector's push_back is much worse than the worst time for a list's push_back. I think `deque::push_{front,back}` is supposed to be as good as `list::push_back`, but maybe I've misread and the performance of `vector::push_back` is OK. – Aaron McDaid Dec 25 '11 at 12:55
  • I've asked a related, more basic, question elsewhere on SO: ["What does “constant” complexity really mean? Time? Count of copies/moves?"](http://stackoverflow.com/questions/8631531/what-does-constant-complexity-really-mean-time-count-of-copies-moves) – Aaron McDaid Dec 25 '11 at 21:06
  • @AaronMcDaid: No, that's the right analysis regarding `list` and `vector`. In the update to my question, I've posted an algorithm by which something could be both stored in an array for random access and still have constant worst-case insert if you're willing to assume O(1) memory block allocations (no O(n) copy needed). But, as discussed here, O(1) allocations are still not a very good assumption. – Gravity Dec 25 '11 at 22:13
  • 1
    @Mehrdad: There's a big difference between amortised `O(1)` and worst-case `O(1)` operations. I really don't see what it has to do with the architecture. It's essentially the difference between `std::list::push_back` and `std::vector::push_back` - the list is worst case `O(1)`, the vector is amortised `O(1)`. As per @Omnifarious I think the standard is in error regarding the `deque` requirements. See here - http://stackoverflow.com/questions/8335430/complexity-requirements-for-stddequepush-back-front – Darren Engwirda Dec 26 '11 at 04:42
  • Could you please help? How can I make sure that most implementations use unrolled linked list? – Narek Jul 27 '16 at 13:36
  • @Narek I wouldn't worry about it too much, though f you want consistency across platforms then check out the [Boost Container Library](http://www.boost.org/doc/libs/1_61_0/doc/html/container.html). – Pubby Jul 27 '16 at 20:32
  • And for a bit of trivia: MSVC's deque is considered the worst among commonly used standard libraries because it allocates in nonsensically small chunks (16 bytes). – Pubby Jul 27 '16 at 20:41
10

A deque is typically implemented as a dynamic array of arrays of T.

 (a) (b) (c) (d)
 +-+ +-+ +-+ +-+
 | | | | | | | |
 +-+ +-+ +-+ +-+
  ^   ^   ^   ^
  |   |   |   |
+---+---+---+---+
| 1 | 8 | 8 | 3 | (reference)
+---+---+---+---+

The arrays (a), (b), (c) and (d) are generally of fixed capacity, and the inner arrays (b) and (c) are necessarily full. (a) and (d) are not full, which gives O(1) insertion at both ends.

Imagining that we do a lot of push_front, (a) will fill up, when it's full and an insertion is performed we first need to allocate a new array, then grow the (reference) vector and push the pointer to the new array at the front.

This implementation trivially provides:

  • Random Access
  • Reference Preservation on push at both ends
  • Insertion in the middle that is proportional to min(distance(begin, it), distance(it, end)) (the Standard is slightly more stringent that what you required)

However it fails the requirement of amortized O(1) growth. Because the arrays have fixed capacity whenever the (reference) vector needs to grow, we have O(N/capacity) pointer copies. Because pointers are trivially copied, a single memcpy call is possible, so in practice this is mostly constant... but this is insufficient to pass with flying colors.

Still, push_front and push_back are more efficient than for a vector (unless you are using MSVC implementation which is notoriously slow because of very small capacity for the arrays...)


Honestly, I know of no data structure, or data structure combination, that could satisfy both:

  • Random Access

and

  • O(1) insertion at both ends

I do know a few "near" matches:

  • Amortized O(1) insertion can be done with a dynamic array in which you write in the middle, this is incompatible with the "reference preservation" semantics of the deque
  • A B+ Tree can be adapted to provide an access by index instead of by key, the times are close to constants, but the complexity is O(log N) for access and insertion (with a small constant), it requires using Fenwick Trees in the intermediate level nodes.
  • Finger Trees can be adapted similarly, once again it's really O(log N) though.
Matthieu M.
  • 251,718
  • 39
  • 369
  • 642
  • But fixed-length arrays allow constant-time insertion only at the end, so would you not need some kind of flag indicating whether the array should be read backwards for indexing, thus requiring random access to iterate? And what happens when the frontmost array is filled? How does the top-level array allocate space for a pointer at the front? Would you not have to reverse the order of elements in the former frontmost array, thereby destroying constant-time insertion? – EMBLEM Mar 08 '17 at 04:19
  • @EMBLEM: The key word is **amortized**. Amortized constant time means constant time *in average*. I'll let you [read more on the topic](http://stackoverflow.com/questions/200384/constant-amortized-time/). So, when the front array is full and you push to the front, the top-level array is re-allocated first (for example, doubling in length) and a new fixed size array is allocated for the element you are pushing. And of course, "constant time" is pushing it a bit given that a memory allocation has high variance.. but we mean O(1) with regard to the number of elements. – Matthieu M. Mar 08 '17 at 07:36
5

A deque<T> could be implemented correctly by using a vector<T*>. All the elements are copied onto the heap and the pointers stored in a vector. (More on the vector later).

Why T* instead of T? Because the standard requires that

"An insertion at either end of the deque invalidates all the iterators to the deque, but has no effect on the validity of references to elements of the deque."

(my emphasis). The T* helps to satisfy that. It also helps us to satisfy this:

"Inserting a single element either at the beginning or end of a deque always ..... causes a single call to a constructor of T."

Now for the (controversial) bit. Why use a vector to store the T*? It gives us random access, which is a good start. Let's forget about the complexity of vector for a moment and build up to this carefully:

The standard talks about "the number of operations on the contained objects.". For deque::push_front this is clearly 1 because exactly one T object is constructed and zero of the existing T objects are read or scanned in any way. This number, 1, is clearly a constant and is independent of the number of objects currently in the deque. This allows us to say that:

'For our deque::push_front, the number of operations on the contained objects (the Ts) is fixed and is independent of the number of objects already in the deque.'

Of course, the number of operations on the T* will not be so well-behaved. When the vector<T*> grows too big, it'll be realloced and many T*s will be copied around. So yes, the number of operations on the T* will vary wildly, but the number of operations on T will not be affected.

Why do we care about this distinction between counting operations on T and counting operations on T*? It's because the standard says:

All of the complexity requirements in this clause are stated solely in terms of the number of operations on the contained objects.

For the deque, the contained objects are the T, not the T*, meaning we can ignore any operation which copies (or reallocs) a T*.

I haven't said much about how a vector would behave in a deque. Perhaps we would interpret it as a circular buffer (with the vector always taking up its maximum capacity(), and then realloc everything into a bigger buffer when the vector is full. The details don't matter.

In the last few paragraphs, we have analyzed deque::push_front and the relationship between the number of objects in the deque already and the number of operations performed by push_front on contained T-objects. And we found they were independent of each other. As the standard mandates that complexity is in terms of operations-on-T, then we can say this has constant complexity.

Yes, the Operations-On-T*-Complexity is amortized (due to the vector), but we're only interested in the Operations-On-T-Complexity and this is constant (non-amortized).

Epilogue: the complexity of vector::push_back or vector::push_front is irrelevant in this implementation; those considerations involve operations on T* and hence is irrelevant.

Xeo
  • 123,374
  • 44
  • 277
  • 381
Aaron McDaid
  • 24,484
  • 9
  • 56
  • 82
  • I disagree. The complexity of `push_front` is relevant, because the Standard precises the complexity in terms of N where N represents the number of elements in the sequence. Adding your level of indirection does not solve the issue that N pointers need be copied when doing a `push_front`... – Matthieu M. Dec 27 '11 at 16:50
  • 1
    @MatthieuM., in my answer, you'll see a sentence from the C++ standard that suggests that copying pointers is not a problem and doesn't count towards the complexity. Also, I did not add the indirection, others have noted that the deque is a two level structure - see this accepted answer http://stackoverflow.com/a/6292437/146041 This appears to a complex and subtle, and interesting(!), question. – Aaron McDaid Dec 28 '11 at 17:06
  • ah... interesting! Indeed if the complexity is defined in terms of the number of calls of the copy constructor, then there is none when pushing elements at the end with the traditional array of pointers implementations (whether they use blocks or simple pointers). Still, I find this somewhat cheating. Even though it means that the complexity does not vary depending on the complexity of T, it hides the fact that it varies depending on the number of elements... – Matthieu M. Dec 28 '11 at 17:54
4

(Making this answer a community-wiki. Please get stuck in.)

First things first: A deque requires that any insertion to the front or back shall keep any reference to a member element valid. It's OK for iterators to be invalidated, but the members themselves must stay in the same place in memory. This is easy enough by just copying the members to somewhere on the heap and storing T* in the data structure under the hood. See this other StackOverflow question " About deque<T>'s extra indirection "

(vector doesn't guarantee to preserve either iterators or references, whereas list preserves both).

So let's just take this 'indirection' for granted and look at the rest of the problem. The interesting bit is the time to insert or remove from the beginning or end of the list. At first, it looks like a deque could trivially be implemented with a vector, perhaps by interpreting it as a circular buffer.

BUT A deque must satisfy "Inserting a single element either at the beginning or end of a deque always takes constant time and causes a single call to a constructor of T."

Thanks to the indirection we've already mentioned, it's easy to ensure there is just one constructor call, but the challenge is to guarantee constant time. It would be easy if we could just use constant amortized time, which would allow the simple vector implementation, but it must be constant (non-amortized) time.

Community
  • 1
  • 1
Aaron McDaid
  • 24,484
  • 9
  • 56
  • 82
  • I've changed this answer quite a lot. It's a community wiki now, and I don't claim to have all the answer any more! I'm primarily focused on just clarifying the problem. – Aaron McDaid Dec 25 '11 at 04:42
  • The versions of the draft that required constant time `push` also required constant time `operator[]`. Even ignoring the issue of invalidating references, I think that constraint is a challenging problem. –  Dec 25 '11 at 04:43
  • If `operator[]` (and other non-random operations) could be linear, then a standard linked-list would be the answer. I can't find the relevant sentence in the draft I'm looking at, so I don't know if `operator[]` is amortized or not. (I'd be surprised if it wasn't constant) – Aaron McDaid Dec 25 '11 at 04:52
  • @Hurkyl, this is the closest I've found in N3242 " deque is a sequence container that, like a vector (23.3.6), supports *random access iterators* " (my emphasis) – Aaron McDaid Dec 25 '11 at 04:54
  • In the versions I looked at there was a table, with the comment "An implementation shall provide these operations for all container types shown ... so as to take amortized constant time". (And the other one I read said the same thing, but without the word 'amortized') In the version I saved, this was "Table 71: Optional sequence operations" in subsubsection 23.1.1 - Sequences [lib.sequence.reqmts] in the Container Requirements subsection. –  Dec 25 '11 at 05:03
  • Oh! My comment to Mehrdad in the other thread reveals the way out: "constant time" only refers to the number of operations done on the contained elements. We could do **exponential** work managing `deque`'s internal structure and still be standard conforming, so long as we only do O(1) operations on the elements themselves. –  Dec 25 '11 at 05:18
  • @Hurkyl, it's the same in the document I'm looking at, but it's table 101 in 23.2.3 'sequence.reqmts'. The draft is N3242 from the 28th Feb 2011. So constant amortized time for `operator[]` and `at`. – Aaron McDaid Dec 25 '11 at 05:18
  • @Hurkyl, can you give an example of what text allows exponential complexity? For example, "The complexity is linear in the number of elements inserted " means that the *time* will be a linear function of the number of elements. Have I misinterpreted? (By the way, I'm signing off now for the night) – Aaron McDaid Dec 25 '11 at 05:24
  • "All of the complexity requirements in this clause are stated solely in terms of the number of operations on the contained objects." Second sentence of [lib.container.requirements]. (23.1 in my copy) –  Dec 25 '11 at 05:28
  • @Hurkyl, I still think they mean that the *time* is a linear function of the number of elements. As opposed to saying that the number of operations done is a linear function of the number of elements inserted. – Aaron McDaid Dec 25 '11 at 13:38
  • FWIW, the complexities of algorithms explicitly only count the number of operations done. e.g. `find` is not "linear time in `last - first`" but "at most `last - first` applications of the predicate". Sure, I believe there's an implicit assumption that an operation shouldn't be substantially more time expensive than the work done on the operations that are actually counted, but I don't think it's actually strictly required. –  Dec 25 '11 at 14:59
  • I've asked a related, more basic, question elsewhere on SO: ["What does “constant” complexity really mean? Time? Count of copies/moves?"](http://stackoverflow.com/questions/8631531/what-does-constant-complexity-really-mean-time-count-of-copies-moves) – Aaron McDaid Dec 25 '11 at 21:06
  • "A deque requires that any insertion shall keep any reference to a member element valid." - this is wrong, only inserts at the ends (front/back) are guaranteed to keep references valid. Any insertion into the middle of the deque invalidates all iterators and references. Refer to `§23.3.3.4 [deque.modifiers] p1`. – Xeo Dec 26 '11 at 01:05
  • @AaronMcDaid: See this for a related question (that hasn't really been answered) http://stackoverflow.com/questions/8335430/complexity-requirements-for-stddequepush-back-front. At the end of the day it doesn't seem possible (to me) to achieve *worst-case* `O(1)` complexity for both `push/pop` etc and `operator[]`. Amortised O(1) no problems... – Darren Engwirda Dec 26 '11 at 04:29
  • That's interesting, Darren. I have now posted a similar answer there. http://stackoverflow.com/a/8636478/146041 This is an interesting issue and has come up on StackOverflow a lot! – Aaron McDaid Dec 26 '11 at 14:12
  • @DarrenEngwirda: do you consider an allocation (without zeroing out memory) as O(1) time? If yes, it's possible. If not, then I don't think it's possible either. – Gravity Dec 27 '11 at 21:03
  • A new theory: In complexity theory, a 'constant complexity' operation does *not* require that the operation always takes the *same* amount of time - it just means that it has a finite upper bound. If the deque must, on occasion, allocate a new block of a fixed (small) size to store a set of points; that's OK for constant non-amortized. The reason that vector is not constant non-amortized is because sometimes it will have to copy all the elements and there is no upper bound on the number of elements. A deque could be implemented as a linked list of blocks. But how do we get random access then? – Aaron McDaid Dec 28 '11 at 21:49
0

My understanding of deque

It allocates 'n' empty contiguous objects from the heap as the first sub-array. The objects in it are added exactly once by the head pointer on insertion.

When the head pointer comes to the end of an array, it allocates/links a new non-contiguous sub-array and adds objects there.

They are removed exactly once by the tail pointer on extraction. When the tail pointer finishes a sub-array of objects, it moves on to the next linked sub-array, and deallocates the old.

The intermediate objects between the head and tail are never moved in memory by deque.

A random access first determines which sub-array has the object, then access it from it's relative offset with in the subarray.

0

This is an answer to user gravity's challenge to comment on the 2-array-solution.

  • Some details are discussed here
  • A suggestion for improvement is given

Discussion of details: The user "gravity" has already given a very neat summary. "gravity" also challenged us to comment on the suggestion of balancing the number of elements between two arrays in order to achieve O(1) worst case (instead of average case) runtime. Well, the solution works efficiently if both arrays are ringbuffers, and it appears to me that it is sufficient to split the deque into two segments, balanced as suggested. I also think that for practical purposes the standard STL implementation is at least good enough, but under realtime requirements and with a properly tuned memory management one might consider using this balancing technique. There is also a different implementation given by Eric Demaine in an older Dr.Dobbs article, with similar worst case runtime.

Balancing the load of both buffers requires to move between 0 or 3 elements, depending on the situation. For instance, a pushFront(x) must, if we keep the front segment in the primary array, move the last 3 elements from the primary ring to the auxiliary ring in order to keep the required balance. A pushBack(x) at the rear must get hold of the load difference and then decide when it is time to move one element from the primary to the auxiliary array.

Suggestion for improvement: There is less work and bookkeeping to do if front and rear are both stored in the auxiliary ring. This can be achieved by cutting the deque into three segments q1,q2,q3, arranged in the following manner: The front part q1 is in the auxiliary ring (the doubled-sized one) and may start at any offset from which the elements are arranged clockwise in subsequent order. The number of elements in q1 are exactly half of all elements stored in the auxiliary ring. The rear part q3 is also in the auxilary ring, located exactly opposite to part q1 in the auxilary ring, also clockwise in subsequent order. This invariant has to be kept between all deque operations. Only the middle part q2 is located (clockwise in subsequent order) in the primary ring.

Now, each operation will either move exactly one element, or allocate a new empty ringbuffer when either one gets empty. For instance, a pushFront(x) stores x before q1 in the auxilary ring. In order to keep the invariant, we move the last element from q2 to the front of the rear q3. So both, q1 and q3 get an additional element at their fronts and thus stay opposite to each other. PopFront() works the other way round, and the rear operations work the same way. The primary ring (same as the middle part q2) goes empty exactly when q1 and q3 touch each other and form a full circle of subsequent Elements within the auxiliary ring. Also, when the deque shrinks, q1,q3 will go empty exactly when q2 forms a proper circle in the primary ring.

Ogli
  • 1
  • 1
0

The datas in deque are stored by chuncks of fixed size vector, which are

pointered by a map(which is also a chunk of vector, but its size may change)

deque internal structure

The main part code of the deque iterator is as below:

/*
buff_size is the length of the chunk
*/
template <class T, size_t buff_size>
struct __deque_iterator{
    typedef __deque_iterator<T, buff_size>              iterator;
    typedef T**                                         map_pointer;

    // pointer to the chunk
    T* cur;       
    T* first;     // the begin of the chunk
    T* last;      // the end of the chunk

    //because the pointer may skip to other chunk
    //so this pointer to the map
    map_pointer node;    // pointer to the map
}

The main part code of the deque is as below:

/*
buff_size is the length of the chunk
*/
template<typename T, size_t buff_size = 0>
class deque{
    public:
        typedef T              value_type;
        typedef T&            reference;
        typedef T*            pointer;
        typedef __deque_iterator<T, buff_size> iterator;

        typedef size_t        size_type;
        typedef ptrdiff_t     difference_type;

    protected:
        typedef pointer*      map_pointer;

        // allocate memory for the chunk 
        typedef allocator<value_type> dataAllocator;

        // allocate memory for map 
        typedef allocator<pointer>    mapAllocator;

    private:
        //data members

        iterator start;
        iterator finish;

        map_pointer map;
        size_type   map_size;
}

Below i will give you the core code of deque, mainly about two parts:

  1. iterator

  2. Simple function about deque

1. iterator(__deque_iterator)

The main problem of iterator is, when ++, -- iterator, it may skip to other chunk(if it pointer to edge of chunk). For example, there are three data chunks: chunk 1,chunk 2,chunk 3.

The pointer1 pointers to the begin of chunk 2, when operator --pointer it will pointer to the end of chunk 1, so as to the pointer2.

enter image description here

Below I will give the main function of __deque_iterator:

Firstly, skip to any chunk:

void set_node(map_pointer new_node){
    node = new_node;
    first = *new_node;
    last = first + chunk_size();
}

Note that, the chunk_size() function which compute the chunk size, you can think of it returns 8 for simplify here.

operator* get the data in the chunk

reference operator*()const{
    return *cur;
}

operator++, --

// prefix forms of increment

self& operator++(){
    ++cur;
    if (cur == last){      //if it reach the end of the chunk
        set_node(node + 1);//skip to the next chunk
        cur = first;
    }
    return *this;
}

// postfix forms of increment
self operator++(int){
    self tmp = *this;
    ++*this;//invoke prefix ++
    return tmp;
}
self& operator--(){
    if(cur == first){      // if it pointer to the begin of the chunk
        set_node(node - 1);//skip to the prev chunk
        cur = last;
    }
    --cur;
    return *this;
}

self operator--(int){
    self tmp = *this;
    --*this;
    return tmp;
}

2. Simple function about deque

common function of deque

iterator begin(){return start;}
iterator end(){return finish;}

reference front(){
    //invoke __deque_iterator operator*
    // return start's member *cur
    return *start;
}

reference back(){
    // cna't use *finish
    iterator tmp = finish;
    --tmp; 
    return *tmp; //return finish's  *cur
}

reference operator[](size_type n){
    //random access, use __deque_iterator operator[]
    return start[n];
}

If you want to understand deque more deeply you can also see this question https://stackoverflow.com/a/50959796/6329006

Jayhello
  • 3,887
  • 3
  • 34
  • 44