CS 70

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()
  • Duck speaking

    So we just need to add popFront()?

  • LHS Cow speaking

    We'll add that, but for completeness, we'll also add pushFront() and front() to mirror the back operations.

  • Rabbit speaking

    When we can add and remove at both ends, the data structure is known as a double-ended queue (or “deque”, pronounced like “deck”).

  • Bjarne speaking

    Fun fact, std::deque in the C++ standard library provides these operations. But although it is array-like, it's not implemented quite like std::vector or our CoordVector. 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_;
}
  • Cat speaking

    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!

  • LHS Cow speaking

    Exactly. And yet…

  • Python speaking

    Actually, Python's list.pop(0) does exactly this inefficient shifting. That's why we have collections.deque for when you need efficient operations at both ends. But people still use list.pop(0) all the time, and then wonder why their Python code is slow.

  • Rabbit speaking

    std::vector::erase(begin()) does the same inefficient thing! Use std::deque if you need fast front operations.

  • Pig speaking

    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:

(1,0) (2,0) (3,0) (4,0) (5,0) (-1,-1) (-1,-1) (-1,-1) 0 1 2 3 4 5 6 7 0 5 offset_ size_ data_ (1,0) (2,0) (3,0) (4,0) (5,0) (-1,-1) (-1,-1) (-1,-1) 0 1 2 3 4 5 6 7 2 5 offset_ size_ data_ (1,0) (2,0) (3,0) (4,0) (5,0) (-1,-1) (-1,-1) (-1,-1) 0 1 2 3 4 5 6 7 6 5 offset_ size_ data_ offset offset offset (1,0) (2,0) (3,0) (4,0) (5,0) 0 1 2 3 4 5 size_ data_ Logical View — A five-element vector Physical View — Three examples that can make that logical vector 8 capacity_ 8 capacity_ 8 capacity_

When we popFront(), we just move the offset forward. When we pushFront(), we move it backward. No copying needed!

  • Horse speaking

    Hay! Is that why we've been using logicalToPhysical() all along?

  • LHS Cow speaking

    Exactly! We've been preparing for this moment. All your careful use of logicalToPhysical() is about to pay off.

  • Hedgehog speaking

    Oh, no. I think I might have cheated and used data_[i] directly in a few places…

  • LHS Cow speaking

    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 like pushBack(), but at the front
  • popFront() — exactly like popBack(), but at the front
  • front() — exactly like back(), 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.

  • RHS Cow speaking

    If you have any other constructors that aren't just delegating to another constructor, you need to make sure they initialize offset_ to 0 as well.

Next, update swap() to include the new member by adding this line:

std::swap(offset_, other.offset_);
  • Hedgehog speaking

    Seems like it'd be easy to forget to update swap when adding new members.

  • LHS Cow speaking

    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

  1. Add offset_ to your computed physical index.
  2. If the result is greater than or equal to capacity_, subtract capacity_ to wrap around.
  • RHS Cow speaking

    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 to capacity_ - 1 if it's 0)
  • Increment size_
  • Store the element at logicalToPhysical(0)

popFront():

  • Check for underflow and throw a std::out_of_range("your-message-here") exception if necessary
  • Increment offset_ (wrap to 0 if it reaches capacity_)
  • 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.

  • Cat speaking

    Should we copy the offset_ of the original object?

  • LHS Cow speaking

    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]
  • Dog speaking

    Woo hoo! All tests passed!

  • Pig speaking

    Wow, 5657 tests in that last one!! But I bet we could test MORE edge cases!!!!

  • Goat speaking

    Meh. I'm good.

  • Horse speaking

    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?

  • LHS Cow speaking

    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 include offset_.
  • 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.
  • RHS Cow speaking

    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.

To Complete This Part of the Assignment...

You'll know you're done with this part when you've done all of the following:

(When logged in, completion status appears here.)