Adding Queue Operations to CoordVector
We've seen that depth-first search (DFS) doesn't guarantee that we'll find the shortest path through a maze. To find the shortest possible path, we'll need to use BFS (breadth-first search), which explores all paths at the same rate. But BFS needs some different operations than DFS—in particular, it needs a queue instead of a stack.
Queue vs Stack Operations
A stack is LIFO (Last In, First Out):
- Add to the back with
pushBack()
- Remove from the back with
popBack()
A queue is FIFO (First In, First Out):
- Add to the back with
pushBack()
- Remove from the front with
popFront()
So we just need to add
popFront()
?We'll add that, but for completeness, we'll also add
pushFront()
andfront()
to mirror the back operations.When we can add and remove at both ends, the data structure is known as a double-ended queue (or “deque”, pronounced like “deck”).
Fun fact,
std::deque
in the C++ standard library provides these operations. But although it is array-like, it's not implemented quite likestd::vector
or ourCoordVector
. I can't remember now why we decided to make it more convoluted than necessary, but whatever those reasons were, they're now… part of our heritage. mutters quietly …I think I wanted different iterator invalidation rules…
The Naive Approach (Don't Do This!)
The obvious way to implement popFront()
would be to remove the first element and shift everything else down:
// DON'T DO THIS!
void CoordVector::popFront() {
for (size_t i = 0; i < size_ - 1; ++i) {
data_[i] = data_[i + 1];
}
--size_;
}
That's \(\mathrm{O}(n)\) time for every pop! If we have \(n\) elements and pop them all, that's \(\mathrm{O}(n^2)\) total!
Exactly. And yet…
Actually, Python's
list.pop(0)
does exactly this inefficient shifting. That's why we havecollections.deque
for when you need efficient operations at both ends. But people still uselist.pop(0)
all the time, and then wonder why their Python code is slow.std::vector::erase(begin())
does the same inefficient thing! Usestd::deque
if you need fast front operations.MORE inefficiency?! Wait, I mean LESS efficiency! No wait… I want MORE SPEED!!!
The Clever Solution: Circular Buffers
Instead of physically moving elements, we'll track where the logical “start” of our vector is. Imagine our array as a circle where we can wrap around:
When we popFront()
, we just move the offset forward. When we pushFront()
, we move it backward. No copying needed!
Hay! Is that why we've been using
logicalToPhysical()
all along?Exactly! We've been preparing for this moment. All your careful use of
logicalToPhysical()
is about to pay off.Oh, no. I think I might have cheated and used
data_[i]
directly in a few places…Time to fix those! The tests will definitely catch them now.
Your Task: Implement Queue Operations
Step 1: Update the Header
Add a new private member to coordvector.hpp
:
size_t offset_ = 0; // Where the logical start is in the physical array
Also add these public member-function declarations:
pushFront(coord)
— exactly likepushBack()
, but at the frontpopFront()
— exactly likepopBack()
, but at the frontfront()
— exactly likeback()
, but at the front
The signatures of these functions are exactly like the back versions. In fact, it's probably easiest to just copy and paste the signatures and comment blocks for pushBack()
, popBack()
, and back()
and modify them accordingly.
Step 2: Update Existing Functions
First, update the CoordVector(size_t initialSize)
constructor to initialize offset_
to 0
in the member-initialization list.
If you have any other constructors that aren't just delegating to another constructor, you need to make sure they initialize
offset_
to0
as well.
Next, update swap()
to include the new member by adding this line:
std::swap(offset_, other.offset_);
Seems like it'd be easy to forget to update
swap
when adding new members.That's why we're adding a test for it in Step 6.
Step 3: Update logicalToPhysical()
This is the heart of the circular buffer. After your existing bounds checking, add code to apply the offset and wrap around. Specifically, you need to
- Add
offset_
to your computed physical index. - If the result is greater than or equal to
capacity_
, subtractcapacity_
to wrap around.
You can use modulo (
%
) for the wraparound if you like, but don't give it a negative number. Modulo on negative numbers often gives people surprising (i.e., negative) results!
Step 4: Implement the New Functions
Again, these functions are very similar to the back versions. The main difference is how we handle the offset_
. The back versions don't change offset_
at all, whereas the front versions do.
pushFront()
:
- Check if we need to grow (should be the same code as
pushBack()
) - Decrement
offset_
(but wrap tocapacity_ - 1
if it's 0) - Increment
size_
- Store the element at
logicalToPhysical(0)
popFront()
:
- Check for underflow and throw a
std::out_of_range("
exception if necessaryyour-message-here ") - Increment
offset_
(wrap to 0 if it reachescapacity_
) - Decrement
size_
front()
:
- Check if empty and throw an exception if necessary
- Return the element at
logicalToPhysical(0)
Step 5: Check the Copy Constructor
Depending on how you wrote your copy constructor, you may not need to do anything at all. If it uses logicalToPhysical()
directly or indirectly by calling your operator[]
function, you should be good to go.
Should we copy the
offset_
of the original object?No, you don't need to do that. The new copy can start at offset 0. But, in principle, it really doesn't matter what the offset is, so long as the logical contents are the same. We could actually set it to a random (in range) value when we create
CoordVector
instances, and everything would still work fine.
Step 6: Update and Run Tests
Add a Capacity Check to the Swap Test
As we mentioned earlier, an alarmingly common mistake when adding a new member variable is forgetting to update swap()
. When we have this kind of bug, it causes a lot of head scratching and confusion until it's tracked down. So let's add a test for that issue in coordvector-test.cpp
by updating test_swap()
.
You'll need to do something like
- Make two vectors of different sizes so that they have different capacities. Check that they have the capacities you expect.
- Call
swap()
on them. - Check that their capacities have swapped.
The testing-library functions are documented in the help pages, but you can probably just figure it out by looking at the existing tests.
Enable the pushFront
Test
Uncomment test_pushFrontLoop()
and its call in main()
.
Rebuild and Run All Tests
Run all tests:
make coordvector-test
./coordvector-test
You should see output that looks like
CoordVector basics passed!
CoordVector swap passed!
CoordVector pushBack loop passed!
CoordVector reallocation passed!
CoordVector pushFront loop passed!
All tests passed! Summary of affirmations:
----
Fails / Total Issue
0 / 24 [CoordVector basics]
0 / 5 [CoordVector swap]
0 / 602 [CoordVector pushBack loop]
0 / 801 [CoordVector reallocation]
0 / 5657 [CoordVector pushFront loop]
Woo hoo! All tests passed!
Wow, 5657 tests in that last one!! But I bet we could test MORE edge cases!!!!
Meh. I'm good.
Hay! I had a thought. You know, we've got this array-like data structure, but all we ever do with it is do accesses at the ends. Wasn't indexing supposed to be its big thing?
Don't worry, we'll use the indexing operator in the next part of the assignment.
Helpful Hints
Debugging Circular Buffer Issues
Add a temporary debug function to print the physical array. Paste this code into the class definition in coordvector.hpp
(inside the public:
section). You can call it from anywhere to see what's going on inside your array. (Remember to remove it when you're done debugging!)
void debugPrint() const {
size_t p_first = logicalToPhysical(0);
size_t p_last = logicalToPhysical(size_ - 1);
std::cerr << "offset=" << offset_ << ", size=" << size_
<< ", capacity=" << capacity_ << ", logical [0] at physical ["
<< p_first << "], logical [" << size_ - 1 << "] at physical ["
<< p_last << "]\n";
for (size_t i = 0; i < capacity_; ++i) {
if (i == p_first) {
std::cerr << ">>";
std::cerr << data_[i] << " ";
if (i == p_last) {
std::cerr << "<<";
}
std::cerr << " ";
}
std::cerr << "\n";
}
Common Mistakes
- Forgetting to update
swap()
to includeoffset_
. - Not handling wraparound correctly (especially the backwards wrap in
pushFront()
). - Forgetting to initialize
offset_
in constructors. - Doing something wrong in the copy constructor.
- Updating
logicalToPhysical()
incorrectly.
The
debugPrint()
function above can help you track down these issues.
Understanding the Wraparound
For pushFront()
, going backwards from 0 should wrap to capacity_ - 1
:
offset_ = (offset_ == 0) ? capacity_ - 1 : offset_ - 1;
For popFront()
, going forward from capacity_ - 1
should wrap to 0.
(When logged in, completion status appears here.)