AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

20 Interview idioms / Lesson 4 of 6

Implement the built-in — a vector, a unique_ptr, a hash map and a string by hand

15 min · +10 XP · 2 exercises · 7 questions

Read the text15 min
Exercises0/2
Quiz7 q

Quick answer

How do you implement std::vector in C++?

A minimal std::vector is a class owning three members: a pointer to a heap buffer, a size and a capacity. push_back doubles the capacity when the buffer is full, moving the elements into the new one, which makes it amortised O(1). The class then needs the rule of five: a destructor that frees the buffer, a copy that allocates, and a noexcept move that steals the pointer and empties the source.

"Implement std::vector" is the question every senior C++ interview asks in some form, because forty lines show whether you understand ownership, can write the rule of five from memory and know why growth doubles. The interviewer is listening for four sentences — it owns a heap buffer and frees it in the destructor; a copy allocates, a move steals the pointer and empties the source; a full push doubles, so push_back is amortised O(1); reallocation invalidates every reference into the old buffer. This lesson builds the four types they ask for: a Vec<T>, a UniquePtr<T>, a chaining hash map and, in outline, a String with the small-string optimisation. Module 9, Copies, moves and the rule of five, wrote the owning buffer; the new skill is producing it under a clock, narrating.

Vec<T>: three members and the growth rule

template <typename T>
class Vec {
    T* data_ = nullptr;
    size_t size_ = 0;
    size_t cap_ = 0;

    void grow() {
        const size_t next = cap_ == 0 ? 1 : cap_ * 2;
        T* fresh = new T[next];
        for (size_t i = 0; i < size_; ++i) fresh[i] = std::move(data_[i]);
        delete[] data_;
        data_ = fresh;
        cap_ = next;
    }

public:
    void push_back(T v) {                   // by value: see below
        if (size_ == cap_) grow();
        data_[size_++] = std::move(v);
    }
    T& operator[](size_t i) { return data_[i]; }
    const T& operator[](size_t i) const { return data_[i]; }
    size_t size() const { return size_; }
};

The handle is three words, like std::vector's. grow allocates the new buffer, moves the live elements across, frees the old one and only then updates the members, so a throwing allocation leaves the object as it was. Doubling is what makes push_back cheap: over n pushes the moves total 1 + 2 + 4 + … < 2n, a constant per push on average, where growing by a fixed amount would sum to O(n²). Name the simplification aloud: new T[next] default-constructs every slot; the real container allocates raw storage and constructs each element on push.

push_back takes its parameter by value for a reason: v.push_back(v[0]) on a full vector must work, as it does for std::vector. With const T& v the reference points into the old buffer, grow() frees it, and the assignment reads freed memory; by value, the copy is made at the call, before anything is reallocated.

The rule of five, written out

    ~Vec() { delete[] data_; }
    Vec(const Vec& o) : data_(o.size_ ? new T[o.size_] : nullptr), size_(o.size_), cap_(o.size_) {
        std::copy(o.data_, o.data_ + o.size_, data_);
    }
    Vec(Vec&& o) noexcept : data_(o.data_), size_(o.size_), cap_(o.cap_) {
        o.data_ = nullptr;
        o.size_ = o.cap_ = 0;
    }
    Vec& operator=(const Vec& o) { Vec tmp(o); swap(tmp); return *this; }
    Vec& operator=(Vec&& o) noexcept {
        if (this != &o) {
            delete[] data_;
            data_ = o.data_; size_ = o.size_; cap_ = o.cap_;
            o.data_ = nullptr; o.size_ = o.cap_ = 0;
        }
        return *this;
    }
    void swap(Vec& o) noexcept;             // std::swap the three members

Each of the five has one job. The copy constructor allocates exactly size elements — a copy has no reason to inherit the source's slack. The move constructor steals the three members and zeroes the source, so the source's destructor deletes nullptr; it is noexcept because std::vector<Vec> moves its elements on reallocation only when the move constructor promises not to throw (Module 9 lesson 3). Copy assignment is copy-and-swap: build the copy first, then exchange, so a failed allocation leaves *this untouched (the strong guarantee) and a = a swaps harmlessly. Move assignment cannot use that trick without an allocation, so it guards this != &o — without the guard, a = std::move(a) frees the buffer and then adopts the freed pointer.

Declare any one of these and the compiler stops generating the moves; write none and the defaults copy the pointer, so two Vecs free one buffer — or apply the rule of zero: a std::unique_ptr<T[]> member owns the buffer and only the two copy operations need writing.

UniquePtr<T>

template <typename T>
class UniquePtr {
    T* p_ = nullptr;

public:
    explicit UniquePtr(T* p = nullptr) : p_(p) {}
    ~UniquePtr() { delete p_; }
    UniquePtr(const UniquePtr&) = delete;
    UniquePtr& operator=(const UniquePtr&) = delete;
    UniquePtr(UniquePtr&& o) noexcept : p_(o.release()) {}
    UniquePtr& operator=(UniquePtr&& o) noexcept { reset(o.release()); return *this; }
    T* release() { T* p = p_; p_ = nullptr; return p; }
    void reset(T* p = nullptr) { T* old = p_; p_ = p; delete old; }
    T& operator*() const { return *p_; }
    explicit operator bool() const { return p_ != nullptr; }
};

Every line is an interview answer. Copying is deleted because two owners would delete twice — the mistake becomes a compile error. The moves are written through release and reset, which makes self-move-assignment safe without a guard: release nulls o first, then reset deletes the old pointer after installing the new one. explicit operator bool allows if (p) but refuses int n = p;. The size is one pointer, the same as a raw T*, which is what "zero overhead" means.

A chaining hash map

class HashMap {
    std::vector<std::vector<std::pair<std::string, int>>> buckets_;
    size_t size_ = 0;

    size_t bucketOf(const std::string& key) const {
        size_t h = 0;
        for (unsigned char c : key) h = (h * 31 + c) % buckets_.size();
        return h;
    }

public:
    explicit HashMap(size_t buckets) : buckets_(buckets) {}
    void put(const std::string& key, int value) {
        auto& chain = buckets_[bucketOf(key)];
        for (auto& [k, v] : chain) if (k == key) { v = value; return; }
        chain.emplace_back(key, value);
        ++size_;
    }
};

A hash map is a vector of buckets plus a function from key to bucket index; with chaining, each bucket is a small vector of pairs and a collision appends. put walks the chain first so an existing key is overwritten in place; get walks it the same way and returns std::optional<int> rather than a sentinel; erase finds the pair, calls chain.erase(it) and returns at once, so the invalidated iterator is never incremented. Reducing modulo B at every step keeps the intermediate below 31 × 63 + 255, and unsigned char stops a high-bit char going negative. The cost is O(1 + n/B) on average — the load factor — and O(n) when everything collides, which is why std::unordered_map rehashes past a load factor of 1.0; the follow-up "how would you add rehashing?" is answered with 2B buckets and a re-put of every pair, amortised like the vector's growth.

String with SSO, in outline

class String {
    char* ptr_;                                      // points at local_ or at the heap
    size_t size_ = 0;
    union { char local_[16]; size_t heapCap_; };     // 15 characters + '\0' inline
    bool isLocal() const { return ptr_ == local_; }
};

That is libstdc++'s real layout, and why sizeof(std::string) is 32 on this platform. A string of at most fifteen characters lives inside the object, in the bytes that would otherwise hold the heap capacity; a longer one allocates and ptr_ points at the heap. Every member function first asks which mode is active, and the instructive case is the move: a small string has nothing on the heap to steal, so moving it copies sixteen bytes and empties the source — "moves are free" is not quite true for strings. You will not be asked to write it; you may be asked why short strings never allocate.

Pitfalls

  • A copy constructor that copies cap_ allocates slack the copy never uses; a move constructor that forgets to zero the source double-frees.
  • Move assignment without the this != &o guard frees its own buffer on self-move.
  • push_back(const T&) plus grow() dangles on v.push_back(v[0]).
  • erase inside a range-for over a chain is undefined behaviour; use the iterator form and return.

Key takeaways

  • Vec<T> is three members plus a grow() that doubles; 1 + 2 + 4 + … < 2n is why push_back is amortised O(1).
  • The five: the destructor frees; the copy allocates size; the move steals and zeroes, noexcept; copy assignment by copy-and-swap; move assignment guards self-assignment.
  • UniquePtr deletes copying, moves through release/reset, and is one pointer wide.
  • A chaining hash map is buckets of pairs: overwrite in place, append on a miss, erase then return; the cost is the load factor.
  • SSO keeps fifteen characters inside the object: small strings never allocate, and moving one is a copy.

Common questions

Why is vector push_back amortised O(1)?

When the buffer is full the vector allocates one about twice as large and moves the elements across. Over n pushes the moves total 1 + 2 + 4 + … < 2n, a constant per push on average; growing by a fixed amount instead would total O(n²). Each reallocation invalidates every reference into the old buffer.

How do you implement unique_ptr in C++?

Wrap a raw T*: the destructor calls delete, the copy constructor and copy assignment are = delete so two owners cannot free the pointer twice, and the move operations transfer it through release() and reset(), which also makes self-move-assignment safe. Add operator*, operator-> and an explicit operator bool; the result is one pointer wide.

Why should a move constructor be noexcept?

std::vector moves its elements during reallocation only if their move constructor is noexcept; otherwise it copies them, because a move that threw half-way through would leave both buffers partly moved. A move constructor without noexcept turns every reallocation into a full copy.

How does a hash map work internally?

A hash map is an array of buckets plus a hash function that maps each key to a bucket index. With separate chaining each bucket holds a small list of key–value pairs, and a collision appends to it. Operations cost O(1 + n/B) on average for n keys in B buckets, so the table rehashes into more buckets as the load factor grows; std::unordered_map does so past 1.0 by default.

What is the small string optimisation in C++?

The small string optimisation (SSO) stores a short string's characters inside the std::string object instead of on the heap. In libstdc++ a string of up to 15 characters needs no allocation, which is why sizeof(std::string) is 32 there; moving such a string copies its bytes rather than stealing a pointer.

Mark as readSign in to keep your place.

How to Implement std::vector in C++: Rule of Five, Growth

Build a vector, a unique pointer, a chaining hash map and an SSO string by hand in C++: the rule of five, doubling growth and what interviewers listen for.

  • Course: C++ study plan
  • Module: Interview idioms
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: Clang 18 · C++20

How do you implement std::vector in C++?

A minimal std::vector is a class owning three members: a pointer to a heap buffer, a size and a capacity. push_back doubles the capacity when the buffer is full, moving the elements into the new one, which makes it amortised O(1). The class then needs the rule of five: a destructor that frees the buffer, a copy that allocates, and a noexcept move that steals the pointer and empties the source.

Lesson

"Implement std::vector" is the question every senior C++ interview asks in some form, because forty lines show whether you understand ownership, can write the rule of five from memory and know why growth doubles. The interviewer is listening for four sentences — it owns a heap buffer and frees it in the destructor; a copy allocates, a move steals the pointer and empties the source; a full push doubles, so push_back is amortised O(1); reallocation invalidates every reference into the old buffer. This lesson builds the four types they ask for: a Vec<T>, a UniquePtr<T>, a chaining hash map and, in outline, a String with the small-string optimisation. Module 9, Copies, moves and the rule of five, wrote the owning buffer; the new skill is producing it under a clock, narrating.

Vec<T>: three members and the growth rule

template <typename T>
class Vec {
    T* data_ = nullptr;
    size_t size_ = 0;
    size_t cap_ = 0;

    void grow() {
        const size_t next = cap_ == 0 ? 1 : cap_ * 2;
        T* fresh = new T[next];
        for (size_t i = 0; i < size_; ++i) fresh[i] = std::move(data_[i]);
        delete[] data_;
        data_ = fresh;
        cap_ = next;
    }

public:
    void push_back(T v) {                   // by value: see below
        if (size_ == cap_) grow();
        data_[size_++] = std::move(v);
    }
    T& operator[](size_t i) { return data_[i]; }
    const T& operator[](size_t i) const { return data_[i]; }
    size_t size() const { return size_; }
};

The handle is three words, like std::vector's. grow allocates the new buffer, moves the live elements across, frees the old one and only then updates the members, so a throwing allocation leaves the object as it was. Doubling is what makes push_back cheap: over n pushes the moves total 1 + 2 + 4 + … < 2n, a constant per push on average, where growing by a fixed amount would sum to O(n²). Name the simplification aloud: new T[next] default-constructs every slot; the real container allocates raw storage and constructs each element on push.

push_back takes its parameter by value for a reason: v.push_back(v[0]) on a full vector must work, as it does for std::vector. With const T& v the reference points into the old buffer, grow() frees it, and the assignment reads freed memory; by value, the copy is made at the call, before anything is reallocated.

The rule of five, written out

    ~Vec() { delete[] data_; }
    Vec(const Vec& o) : data_(o.size_ ? new T[o.size_] : nullptr), size_(o.size_), cap_(o.size_) {
        std::copy(o.data_, o.data_ + o.size_, data_);
    }
    Vec(Vec&& o) noexcept : data_(o.data_), size_(o.size_), cap_(o.cap_) {
        o.data_ = nullptr;
        o.size_ = o.cap_ = 0;
    }
    Vec& operator=(const Vec& o) { Vec tmp(o); swap(tmp); return *this; }
    Vec& operator=(Vec&& o) noexcept {
        if (this != &o) {
            delete[] data_;
            data_ = o.data_; size_ = o.size_; cap_ = o.cap_;
            o.data_ = nullptr; o.size_ = o.cap_ = 0;
        }
        return *this;
    }
    void swap(Vec& o) noexcept;             // std::swap the three members

Each of the five has one job. The copy constructor allocates exactly size elements — a copy has no reason to inherit the source's slack. The move constructor steals the three members and zeroes the source, so the source's destructor deletes nullptr; it is noexcept because std::vector<Vec> moves its elements on reallocation only when the move constructor promises not to throw (Module 9 lesson 3). Copy assignment is copy-and-swap: build the copy first, then exchange, so a failed allocation leaves *this untouched (the strong guarantee) and a = a swaps harmlessly. Move assignment cannot use that trick without an allocation, so it guards this != &o — without the guard, a = std::move(a) frees the buffer and then adopts the freed pointer.

Declare any one of these and the compiler stops generating the moves; write none and the defaults copy the pointer, so two Vecs free one buffer — or apply the rule of zero: a std::unique_ptr<T[]> member owns the buffer and only the two copy operations need writing.

UniquePtr<T>

template <typename T>
class UniquePtr {
    T* p_ = nullptr;

public:
    explicit UniquePtr(T* p = nullptr) : p_(p) {}
    ~UniquePtr() { delete p_; }
    UniquePtr(const UniquePtr&) = delete;
    UniquePtr& operator=(const UniquePtr&) = delete;
    UniquePtr(UniquePtr&& o) noexcept : p_(o.release()) {}
    UniquePtr& operator=(UniquePtr&& o) noexcept { reset(o.release()); return *this; }
    T* release() { T* p = p_; p_ = nullptr; return p; }
    void reset(T* p = nullptr) { T* old = p_; p_ = p; delete old; }
    T& operator*() const { return *p_; }
    explicit operator bool() const { return p_ != nullptr; }
};

Every line is an interview answer. Copying is deleted because two owners would delete twice — the mistake becomes a compile error. The moves are written through release and reset, which makes self-move-assignment safe without a guard: release nulls o first, then reset deletes the old pointer after installing the new one. explicit operator bool allows if (p) but refuses int n = p;. The size is one pointer, the same as a raw T*, which is what "zero overhead" means.

A chaining hash map

class HashMap {
    std::vector<std::vector<std::pair<std::string, int>>> buckets_;
    size_t size_ = 0;

    size_t bucketOf(const std::string& key) const {
        size_t h = 0;
        for (unsigned char c : key) h = (h * 31 + c) % buckets_.size();
        return h;
    }

public:
    explicit HashMap(size_t buckets) : buckets_(buckets) {}
    void put(const std::string& key, int value) {
        auto& chain = buckets_[bucketOf(key)];
        for (auto& [k, v] : chain) if (k == key) { v = value; return; }
        chain.emplace_back(key, value);
        ++size_;
    }
};

A hash map is a vector of buckets plus a function from key to bucket index; with chaining, each bucket is a small vector of pairs and a collision appends. put walks the chain first so an existing key is overwritten in place; get walks it the same way and returns std::optional<int> rather than a sentinel; erase finds the pair, calls chain.erase(it) and returns at once, so the invalidated iterator is never incremented. Reducing modulo B at every step keeps the intermediate below 31 × 63 + 255, and unsigned char stops a high-bit char going negative. The cost is O(1 + n/B) on average — the load factor — and O(n) when everything collides, which is why std::unordered_map rehashes past a load factor of 1.0; the follow-up "how would you add rehashing?" is answered with 2B buckets and a re-put of every pair, amortised like the vector's growth.

String with SSO, in outline

class String {
    char* ptr_;                                      // points at local_ or at the heap
    size_t size_ = 0;
    union { char local_[16]; size_t heapCap_; };     // 15 characters + '\0' inline
    bool isLocal() const { return ptr_ == local_; }
};

That is libstdc++'s real layout, and why sizeof(std::string) is 32 on this platform. A string of at most fifteen characters lives inside the object, in the bytes that would otherwise hold the heap capacity; a longer one allocates and ptr_ points at the heap. Every member function first asks which mode is active, and the instructive case is the move: a small string has nothing on the heap to steal, so moving it copies sixteen bytes and empties the source — "moves are free" is not quite true for strings. You will not be asked to write it; you may be asked why short strings never allocate.

Pitfalls

  • A copy constructor that copies cap_ allocates slack the copy never uses; a move constructor that forgets to zero the source double-frees.
  • Move assignment without the this != &o guard frees its own buffer on self-move.
  • push_back(const T&) plus grow() dangles on v.push_back(v[0]).
  • erase inside a range-for over a chain is undefined behaviour; use the iterator form and return.

Key takeaways

  • Vec<T> is three members plus a grow() that doubles; 1 + 2 + 4 + … < 2n is why push_back is amortised O(1).
  • The five: the destructor frees; the copy allocates size; the move steals and zeroes, noexcept; copy assignment by copy-and-swap; move assignment guards self-assignment.
  • UniquePtr deletes copying, moves through release/reset, and is one pointer wide.
  • A chaining hash map is buckets of pairs: overwrite in place, append on a miss, erase then return; the cost is the load factor.
  • SSO keeps fifteen characters inside the object: small strings never allocate, and moving one is a copy.

Common questions

Why is vector push_back amortised O(1)?

When the buffer is full the vector allocates one about twice as large and moves the elements across. Over n pushes the moves total 1 + 2 + 4 + … < 2n, a constant per push on average; growing by a fixed amount instead would total O(n²). Each reallocation invalidates every reference into the old buffer.

How do you implement unique_ptr in C++?

Wrap a raw T*: the destructor calls delete, the copy constructor and copy assignment are = delete so two owners cannot free the pointer twice, and the move operations transfer it through release() and reset(), which also makes self-move-assignment safe. Add operator*, operator-> and an explicit operator bool; the result is one pointer wide.

Why should a move constructor be noexcept?

std::vector moves its elements during reallocation only if their move constructor is noexcept; otherwise it copies them, because a move that threw half-way through would leave both buffers partly moved. A move constructor without noexcept turns every reallocation into a full copy.

How does a hash map work internally?

A hash map is an array of buckets plus a hash function that maps each key to a bucket index. With separate chaining each bucket holds a small list of key–value pairs, and a collision appends to it. Operations cost O(1 + n/B) on average for n keys in B buckets, so the table rehashes into more buckets as the load factor grows; std::unordered_map does so past 1.0 by default.

What is the small string optimisation in C++?

The small string optimisation (SSO) stores a short string's characters inside the std::string object instead of on the heap. In libstdc++ a string of up to 15 characters needs no allocation, which is why sizeof(std::string) is 32 there; moving such a string copies its bytes rather than stealing a pointer.

Exercises

Vec<T> with the rule of five

Implement template <typename T> class Vec — a growable array with a raw T* buffer, size(), capacity(), push_back, pop_back, operator[], and the rule of five written by hand (destructor, copy constructor, copy assignment, move constructor, move assignment). Growth policy: capacity starts at 0; a push into a full vector reallocates to 1 when the capacity is 0 and to double otherwise. A copy allocates exactly the source's size (so its capacity equals its size); a moved-from Vec is left empty with capacity 0; pop_back never shrinks.

Drive a Vec<long long> with n commands:

  • push x — append; pop — remove the last, or print empty when there is none
  • get i — print element i, or out of range
  • stats — print size=<s> capacity=<c>
  • print — the elements space-separated, or empty
  • copy — copy-construct a second Vec, push -1 onto the copy, then print original: <elements> (size=<s> capacity=<c>) and copy: <elements> (size=<s> capacity=<c>)
  • move — move-construct a second Vec from the current one, print moved: size=<s> capacity=<c> and source: size=<s> capacity=<c>; the current Vec stays in its moved-from state for later commands
9
push 1
push 2
push 3
stats
print
get 1
get 3
pop
stats

prints

size=3 capacity=4
1 2 3
2
out of range
size=2 capacity=4

A chaining hash map

Implement a hash map from std::string to int with separate chaining: a vector of B buckets, each a vector<pair<string, int>>. The hash is fixed so the buckets are deterministic: start with h = 0 and, for each character c of the key taken as an unsigned char, set h = (h * 31 + c) % B. A new key is appended to the end of its chain; overwriting an existing key keeps its position.

Read B (1 ≤ B ≤ 64), then n, then n commands:

  • put key value — insert or overwrite; prints nothing
  • get key — print the value, or missing
  • del key — print deleted or missing
  • dump — one line per bucket, <b>: k=v k=v … in chain order or <b>: - when empty, then size=<number of keys>
4
7
put ab 1
put cd 2
put ab 3
get ab
get zz
dump
del cd

prints

3
missing
0: -
1: ab=3 cd=2
2: -
3: -
size=2
deleted

(ab hashes to ((97 % 4) * 31 + 98) % 4 = 1; so does cd, and it joins the chain behind ab.)

In this module: Interview idioms

← The pitfalls that fail interviews · Writing clean solutions under time pressure →