Deques
Steven J. Zeil
1 What is a Deque?
A queue is a sequence of data such that
-
data can be added only at the end
-
data can be removed only from the front
-
data can be examined only at the front
A DEqueue (double-ended queue, pronounced “dee-cue”) is a sequence of data such that
-
data can be added only at the front or at the end
-
data can be removed only from the front or the end
-
data can be examined only at the front and end
Both the queue and the dequeue can grow to arbitrary size.
The C++ standard deque
(pronounced “deck”) is a generalized dequeue:
-
data can be added only at the front or at the end
-
data can be removed only from the front or the end
-
data can be examined at any arbitrary position
2 The deque Interface
The deque
interface is identical to that of vector
, except for
-
Two new operations:
-
push_front
-
pop_front
which are guaranteed to be O(1) time.
-
The operations
-
capacity()
-
reserve(size_type)
are not available.
So a deque
is a vector
that can grow/shrink at both ends.
3 Implementing Deques
Key items influencing the implementation: According to the C++ standard, we
-
need to add/delete from both ends in O(1) time (amortized)
-
need a structure that grows automatically
-
need O(1) time access to all elements (
operator[]
)
Most implementations of deque
are based on a scheme that technically meets the O(1) requirements for the push and pop operations only in an amortized sense, but does guarantee that each push or pop will only do O(1) copies of the actual heap elements.
This implementation is based upon the idea of allocating “blocks” (fixed-sized arrays) of elements and then using a “map” array (an array of pointers to these blocks) to keep track of the blocks.
3.1 Blocks and BlockMaps
The diagram shows a typical deque of strings under this scheme. To simplify the presentation, I have placed the strings in the deque in alphabetic order: Adams, Baker, Cox, … . You can clearly see the block and map structure.
Note that the blocks can occur essentially anywhere on the heap. The order of the blocks within the deque is determined by the map array.
3.2 The deque header
The deque itself has several data members.
-
theSize
records the number of elements currently in the deque. -
blockmap
is a pointer to the map array (on the heap). -
mapSize
indicates the size of the map array. -
firstBlock
is the index of the first occupied position in the map array. -
firstElement
is the index of the first occupied position in the the first block. -
numElementsInBlock
indicates the size of the blocks.
3.3 expandMap and indexAt
Here is the code for the private section of this deque implementation.
template <class T>
class deque
{
public:
⋮
private:
size_type theSize;
T** blockmap;
size_type mapSize;
size_type firstBlock;
size_type firstElement;
const static size_type BlockSize = 4096;
static size_type numElementsInBlock;
void deque<T>::expandMap (); // Double the map size
struct dqPosition {
size_type blockNum;
size_type elementNum;
};
dqPosition indexAt (deque<T>::size_type n) const;
// where do we find element # n?
};
In addition to the items already described, a few other things are worth taking note of:
-
The
expandMap
function will be described later. -
The
indexAt
function will be used to locate elements by number. It returns a structure containing two integers, one being the index of the desired block within the map, the other the index of the desired element within that block.
Both of these functions are “utilities” intended to simplify the implementation of the “real” deque member functions. They are private because they are only supposed to be called by the deque member functions, not by application code that uses the deque.
We can now start to describe how we actually accomplish some of the typical deque operations.
3.4 begin() & front()
For example, begin()
and front()
are pretty straightforward. To find the first element in the deque, we simply look in blockmap[firstElement]
. That points us to the block containing the first element. Then within that block, we look at the firstElement
position.
Here is the code for front
. As is often the case, this operation comes in a pair: one version for const deques and one for non-const deques. The non-const version returns a reference to the front element, allowing applications to change it:
myNonConstDeque.front() = someValue;
The const version returns a const reference, and so changes to the front element of a const deque would result in a compilation error.
template <class T>
T& deque<T>::front ()
{
return blockmap[firstBlock][firstElement];
}
template <class T>
const T& deque<T>::front () const
{
return blockmap[firstBlock][firstElement];
}
begin
is similar in theory, but slightly more complicated because we need to use that same reasoning to construct an iterator. We haven’t discussed the structure of the deque iterators yet, and I don’t intend to do so, because it’s not as instructive as the deque itself.
3.5 indexing
Next, let’s consider the indexing operation, e.g., myDeque[i]
, which is provided by operator[ ]
.
We can split the problem of finding the \( i^{\mbox{th}} \) element into cases:
-
If
i
is less thannumElementsInBlock - firstElement
, then the element we are looking for is in the first block. -
If it’s not in the first block, then we can find out how many map positions past
firstBlock
by dividingi - (numElementsInBlock - firstElement)
bynumElementsInBlock
.
Use the resulting quotient to index into the map and find the desired block. Then use the remainder of that division as the index within the block.
This calculation is one that we need to do quite often in this ADT implementation, so it’s worthwhile breaking that out into a separate utility function.
template <class T>
deque<T>::dqPosition
deque<T>::indexAt (deque<T>::size_type n) const
{
dqPosition pos;
pos.blockNum = firstBlock;
if (n < numElementsInBlock - firstElement)
{
pos.elementNum = n + firstElement;
}
else
{
n -= numElementsInBlock - firstElement;
++pos.blockNum;
int k = n / numElementsInBlock;
pos.blockNum += k;
pos.elementNum = n - k*numElementsInBlock;
}
return pos;
}
Here you see that utility, indexAt
. It carries out the calculation we have just described, and stores the result into a simple structure with two fields, blockNum
and elementNum
.
And, once we have that utility function, the implementation of the indexing operators becomes trivial.
template <class T>
T& deque<T>::operator [ ] (deque<T>::size_type n)
{
dqPosition<T> pos = indexAt(n);
return blockmap[pos.blockNum][pos.elementNum];
}
template <class T>
const T& deque<T>::operator [ ] (deque<T>::size_type n) const
{
dqPosition<T> pos = indexAt(n);
return blockmap[pos.blockNum][pos.elementNum];
}
3.6 back() and end()
The same utility makes for a simple implementation of the back()
function and would also help greatly in implementing end()
.
template <class T>
T& deque<T>::back ()
{
dqPosition pos = indexAt(theSize-1);
return blockmap[pos.blockNum][pos.elementNum];
}
template <class T>
const T& deque<T>::back () const
{
dqPosition pos = indexAt(theSize-1);
return blockmap[pos.blockNum][pos.elementNum];
}
It’s worth pointing out that, while there is a fair amount of calculation involved in getting to the \( i^{\mbox{th}} \) element in a deque, it’s all O(1). This is rather typical in comparing deques to arrays and vectors. deques tend to have more overhead, but it’s in the constant-multipliers. In a big-O sense, deques are always no slower than arrays and vectors, and may be faster for some operations (e.g., inserting at the front of the deque).
In addition, there are practical benefits to having at least one sequential container that does not keep all of its data in one huge contiguous area. Some operating systems place rather stringent limits on the largest amount of contiguous space you can allocate on the heap. For example, Windows 3.1 would not allow you to allocate more than 64K in a single block of memory. That may sound like a lot, but suppose you had an array or vector of PersonnelRecord
s, and each PersonnelRecord
occupied 1024 bytes (which is not at all difficult to achieve). Then an array or vector could hold no more than 64 elements, total. deques, on the other hand, because they break their storage into blocks, could grow to quite large size (up to the total limit of memory) as long as each individual block contained no more than 64 elements.
3.7 Inserting Data into a deque
That brings us to the question of just how the data gets inserted into the deque in the first place.
Looking at the structure shown here, you can see that pushing a new element onto either the front or the back would be no problem for this particular deque. We have room in both the first and the last block for additional elements.
What if we want to push something onto the end/front of the deque and the last/first block is full? Then we would need to allocate space for a new block, putting the pointer to the new block in the map just after/before the currently occupied slots, and placing the new element at the beginning/end of the newly created block.
Which leaves us with only one problem: what if the map is full, and there’s no room in the map for any more pointers?
3.7.1 Expanding the map
In that case we need to create a new, larger map, as shown here.
template <class T>
void deque<T>::expandMap ()
// double the size of the block map, copying the
// current set of pointers into the middle of the
// new map array
{
T** newMap = new T*[2*mapSize];
fill_n(newMap, 2*mapSize, (T*)0);
copy (blockmap+firstBlock, blockmap+mapSize,
newMap+mapSize/2);
delete [] blockmap;
blockmap = newMap;
firstBlock = mapSize/2;
mapSize *= 2;
}
Note that the fill and copy operations are O(mapSize
), and mapSize
could be as large as theSize/numElementsInBlock
, so this means that a push_back
or push_front
on a deque with $N$ elements is really $O(N)$.
This worst case cost does amortize to $O(1)$ if we start with an empty deque and push $N$ successive elements into it. Furthermore, and this may be important if we are dealing with elements of a large or complicated data type, expandMap
only copies pointers, not actual elements. The number of elements copied by a push call remains $O(1)$.
The remainder of the details for implementing the push operations is left as an exercise for the reader.