C++ 커스텀 자료구조 완벽 가이드 | 해시테이블·트라이·LRU 캐시·Skip List [#54-2]

C++ 커스텀 자료구조 완벽 가이드 | 해시테이블·트라이·LRU 캐시·Skip List [#54-2]

이 글의 핵심

STL로 부족할 때 직접 구현하는 자료구조. 충돌 처리, 자동완성, 캐시 eviction, 정렬된 삽입 삭제. 문제 시나리오, 완전한 구현, 흔한 실수, 성능 비교, 프로덕션 패턴.

들어가며: “std::unordered_map으로 안 되는 경우"

"캐시 크기 제한이 필요한데, 오래된 항목을 자동으로 지우려면?”

C++ STL은 강력하지만, 모든 상황을 커버하지는 못합니다. std::unordered_map은 크기 제한이 없고, std::map은 O(log n)이라 대량 검색에 부담됩니다. 자동완성을 구현하려면 접두사 검색이 필요한데, STL에는 트라이가 없습니다.

비유하면: “일반 도로(map)와 고속도로(unordered_map)“가 있는데, LRU 캐시는 “주차장이 꽉 차면 가장 오래된 차를 내보내는 주차장”이고, 트라이는 “단어의 한 글자씩 따라가는 나무”입니다.

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 실행 예제
flowchart TD
  subgraph problem[❌ STL 한계]
    P1[캐시 크기 제한?] --> P2[unordered_map은 무제한]
    P3[접두사 검색?] --> P4[map은 전체 키만]
    P5[정렬된 삽입/삭제 O(log n)?] --> P6[vector는 O(n)]
  end
  subgraph solution[✅ 커스텀 자료구조]
    S1[LRU 캐시] --> S2[해시 + 이중 연결 리스트]
    S3[트라이] --> S4[접두사 O(m) 검색]
    S5[Skip List] --> S6[균형 트리 대안]
  end

이 글에서는 실제로 겪는 문제 시나리오부터 시작해, 해시테이블·트라이·LRU 캐시·Skip List의 완전한 구현, 자주 하는 실수, 성능 비교, 프로덕션 패턴까지 다룹니다.

이 글을 읽으면:

  • STL로 해결되지 않는 상황을 파악할 수 있습니다.
  • 해시테이블 충돌 처리, 트라이, LRU 캐시를 직접 구현할 수 있습니다.
  • 흔한 실수를 피하고 성능을 최적화할 수 있습니다.

요구 환경: C++17 이상


실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

목차

  1. 문제 시나리오
  2. 해시테이블 완전 구현
  3. 트라이 완전 구현
  4. LRU 캐시 완전 구현
  5. Skip List 완전 구현
  6. 자주 발생하는 에러와 해결법
  7. 성능 비교
  8. 프로덕션 패턴
  9. 구현 체크리스트

1. 문제 시나리오

시나리오 1: 캐시 크기 제한 + 오래된 항목 자동 제거 (LRU)

상황: API 응답을 캐시하는데, 메모리가 100MB로 제한되어 있습니다. 가장 최근에 사용한 1만 개만 유지하고, 오래된 것은 자동으로 제거해야 합니다.

잘못된 접근:

다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ std::unordered_map - 크기 제한 없음, "오래된 것" 판단 불가
std::unordered_map<std::string, Response> cache;
cache[/api/users/1] = fetchUser(1);  // 무한히 쌓임!
// 언제 뭘 지울지 알 수 없음

문제점: unordered_map은 삽입 순서를 기억하지 않습니다. “가장 오래 사용하지 않은” 항목을 O(1)에 찾을 수 없습니다.

올바른 접근: LRU 캐시 (해시 + 이중 연결 리스트) - 섹션 4 참조


시나리오 2: 접두사로 검색 (자동완성)

상황: 검색창에 “app”을 입력하면 “apple”, “application”, “apply” 등을 제안해야 합니다.

잘못된 접근:

아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ std::map - 전체 키만 검색, 접두사 검색 O(n)
// 실행 예제
std::map<std::string, int> words;
for (auto it = words.lower_bound("app"); it != words.end(); ++it) {
    if (it->first.substr(0, 3) != "app") break;  // 전체 순회 가능성
    suggest(it->first);
}

문제점: lower_bound는 “app” 이상인 첫 키를 찾지만, “appzzz” 이후의 모든 키도 순회합니다. 접두사가 “app”인 것만 O(접두사 길이)에 찾을 수 없습니다.

올바른 접근: 트라이 - 섹션 3 참조


시나리오 3: 커스텀 키에 대한 해시 (복합 키)

상황: (user_id, item_id) 쌍을 키로 쓰는데, std::pair의 기본 해시가 충돌을 많이 일으킵니다.

잘못된 접근:

// ❌ std::pair 해시 - 단순 XOR은 충돌 많음
std::unordered_map<std::pair<int,int>, int> cache;
// pair의 기본 해시 품질이 구현체마다 다름

올바른 접근:

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 커스텀 해시 함수
struct PairHash {
    size_t operator()(const std::pair<int,int>& p) const {
        return std::hash<int>{}(p.first) ^ (std::hash<int>{}(p.second) << 16);
    }
};
std::unordered_map<std::pair<int,int>, int, PairHash> cache;

시나리오 4: 정렬된 삽입/삭제 + 인덱스 접근

상황: 실시간 랭킹에서 순위가 자주 바뀌고, “k번째” 요소에 O(log n)으로 접근해야 합니다. std::map은 k번째 접근이 O(n)입니다.

잘못된 접근:

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ std::map - k번째 접근이 O(n)
std::map<int, std::string> ranking;
// 5번째 찾으려면 begin에서 5번 ++
auto it = ranking.begin();
std::advance(it, 4);  // O(k) - 느림!

올바른 접근: Skip List (또는 order statistic tree) - 섹션 5 참조


시나리오 5: 메모리 제약 환경에서의 해시테이블

상황: 임베디드에서 std::unordered_map의 기본 버킷 수가 너무 커서 메모리를 초과합니다.

해결: 작은 초기 버킷 수를 지정하고, 로드 팩터를 조정합니다.

다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 메모리 제한된 환경
std::unordered_map<int, int> small_map;
small_map.reserve(100);  // 100개만 저장 예정
small_map.max_load_factor(2.0f);  // 버킷당 2개까지 허용

2. 해시테이블 완전 구현

2.1 개요

체이닝(Chaining) 방식: 버킷 배열 + 각 버킷에 연결 리스트. 충돌 시 같은 버킷에 리스트로 추가합니다.

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

flowchart LR
  subgraph buckets[버킷 배열]
    B0[0]
    B1[1]
    B2[2]
    B3[3]
  end
  B1 --> N1[key1→val1]
  N1 --> N2[key5→val5]
  B2 --> N3[key2→val2]

2.2 완전한 구현

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <vector>
#include <list>
#include <optional>
#include <functional>

template<typename Key, typename Value, typename Hash = std::hash<Key>>
class HashTable {
public:
    explicit HashTable(size_t bucket_count = 16)
        : buckets_(bucket_count), size_(0) {}

    void insert(const Key& key, const Value& value) {
        if (load_factor() > 0.75f) rehash();
        size_t idx = hash_(key) % buckets_.size();
        for (auto& [k, v] : buckets_[idx]) {
            if (k == key) {
                v = value;
                return;
            }
        }
        buckets_[idx].emplace_back(key, value);
        ++size_;
    }

    std::optional<Value> find(const Key& key) const {
        size_t idx = hash_(key) % buckets_.size();
        for (const auto& [k, v] : buckets_[idx]) {
            if (k == key) return v;
        }
        return std::nullopt;
    }

    bool erase(const Key& key) {
        size_t idx = hash_(key) % buckets_.size();
        auto& chain = buckets_[idx];
        for (auto it = chain.begin(); it != chain.end(); ++it) {
            if (it->first == key) {
                chain.erase(it);
                --size_;
                return true;
            }
        }
        return false;
    }

    size_t size() const { return size_; }
    float load_factor() const {
        return static_cast<float>(size_) / buckets_.size();
    }

private:
    void rehash() {
        std::vector<std::list<std::pair<Key, Value>>> new_buckets(buckets_.size() * 2);
        for (auto& chain : buckets_) {
            for (auto& [k, v] : chain) {
                size_t idx = hash_(k) % new_buckets.size();
                new_buckets[idx].emplace_back(std::move(k), std::move(v));
            }
        }
        buckets_ = std::move(new_buckets);
    }

    std::vector<std::list<std::pair<Key, Value>>> buckets_;
    size_t size_;
    Hash hash_;
};

핵심 포인트:

  • 로드 팩터 0.75: 초과 시 rehash로 버킷 수 2배
  • 체이닝: std::list로 충돌 처리
  • 삽입 시 기존 키: 덮어쓰기 후 return

2.3 사용 예시

아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

HashTable<std::string, int> ht;
ht.insert("apple", 1);
ht.insert("banana", 2);
ht.insert("apple", 3);  // 덮어쓰기

if (auto v = ht.find("apple")) {
    std::cout << "apple: " << *v << "\n";  // 3
}
ht.erase("banana");

3. 트라이 완전 구현

3.1 개요

트라이(Trie): 각 노드가 문자 하나를 나타내고, 루트에서 leaf까지의 경로가 하나의 단어입니다. 접두사 검색에 최적입니다.

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

flowchart TD
  R[""] --> A[a]
  A --> P[p]
  P --> P1[p]
  P1 --> L1[l]
  L1 --> E1[e]
  P --> P2[p]
  P2 --> L2[l]
  L2 --> E2[y]
  A --> N[n]

3.2 완전한 구현

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <unordered_map>
#include <string>
#include <vector>

class Trie {
public:
    Trie() : root_(std::make_unique<Node>()) {}

    void insert(const std::string& word) {
        Node* cur = root_.get();
        for (char c : word) {
            if (cur->children.find(c) == cur->children.end()) {
                cur->children[c] = std::make_unique<Node>();
            }
            cur = cur->children[c].get();
        }
        cur->is_end = true;
    }

    bool search(const std::string& word) const {
        Node* cur = root_.get();
        for (char c : word) {
            auto it = cur->children.find(c);
            if (it == cur->children.end()) return false;
            cur = it->second.get();
        }
        return cur->is_end;
    }

    bool startsWith(const std::string& prefix) const {
        Node* cur = root_.get();
        for (char c : prefix) {
            auto it = cur->children.find(c);
            if (it == cur->children.end()) return false;
            cur = it->second.get();
        }
        return true;
    }

    std::vector<std::string> getPrefixMatches(const std::string& prefix) const {
        std::vector<std::string> result;
        Node* cur = root_.get();
        for (char c : prefix) {
            auto it = cur->children.find(c);
            if (it == cur->children.end()) return result;
            cur = it->second.get();
        }
        collectWords(cur, prefix, result);
        return result;
    }

private:
    struct Node {
        std::unordered_map<char, std::unique_ptr<Node>> children;
        bool is_end = false;
    };

    void collectWords(Node* node, const std::string& prefix,
                      std::vector<std::string>& result) const {
        if (node->is_end) result.push_back(prefix);
        for (const auto& [c, child] : node->children) {
            collectWords(child.get(), prefix + c, result);
        }
    }

    std::unique_ptr<Node> root_;
};

핵심 포인트:

  • insert: 각 문자마다 자식 노드 생성, 마지막에 is_end = true
  • search: 단어 전체가 존재하는지
  • startsWith: 접두사만 존재하는지
  • getPrefixMatches: 접두사로 시작하는 모든 단어 수집

3.3 사용 예시

아래 코드는 cpp를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

Trie trie;
trie.insert("apple");
trie.insert("application");
trie.insert("apply");
trie.insert("banana");

assert(trie.search("apple") == true);
assert(trie.startsWith("app") == true);

auto matches = trie.getPrefixMatches("app");
// matches: {"apple", "application", "apply"}

4. LRU 캐시 완전 구현

4.1 개요

LRU (Least Recently Used): 가장 오래 사용하지 않은 항목을 제거합니다. 해시테이블 + 이중 연결 리스트로 O(1) get/put을 구현합니다.

  • 해시테이블: 키 → (값, 리스트 이터레이터) 매핑
  • 이중 연결 리스트: 최근 사용 순서 (head = 최신, tail = 가장 오래됨)

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

flowchart LR
  subgraph list["이중 연결 리스트 (최신→오래됨)"]
    H[head] --> N1[k1:v1]
    N1 --> N2[k2:v2]
    N2 --> N3[k3:v3]
    N3 --> T[tail]
  end
  subgraph hash[해시테이블]
    M[k1→iter1]
    M2[k2→iter2]
    M3[k3→iter3]
  end

4.2 완전한 구현

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <unordered_map>
#include <list>
#include <optional>

template<typename Key, typename Value>
class LRUCache {
public:
    explicit LRUCache(size_t capacity) : capacity_(capacity) {
        if (capacity_ == 0) capacity_ = 1;
    }

    std::optional<Value> get(const Key& key) {
        auto it = map_.find(key);
        if (it == map_.end()) return std::nullopt;

        // 최근 사용으로 이동: 리스트에서 제거 후 맨 앞에 삽입
        list_.splice(list_.begin(), list_, it->second);
        return it->second->second;
    }

    void put(const Key& key, const Value& value) {
        auto it = map_.find(key);
        if (it != map_.end()) {
            it->second->second = value;
            list_.splice(list_.begin(), list_, it->second);
            return;
        }

        if (list_.size() >= capacity_) {
            // 가장 오래된 항목 제거 (tail)
            auto last = list_.back();
            map_.erase(last.first);
            list_.pop_back();
        }

        list_.emplace_front(key, value);
        map_[key] = list_.begin();
    }

    size_t size() const { return list_.size(); }

    bool erase(const Key& key) {
        auto it = map_.find(key);
        if (it == map_.end()) return false;
        list_.erase(it->second);
        map_.erase(it);
        return true;
    }

private:
    size_t capacity_;
    std::list<std::pair<Key, Value>> list_;
    std::unordered_map<Key, decltype(list_.begin())> map_;
};

핵심 포인트:

  • splice: O(1)에 노드를 다른 위치로 이동
  • get 시: 해당 노드를 리스트 맨 앞으로 이동
  • put 시 용량 초과: tail 제거 후 새 항목을 head에 추가

4.3 사용 예시

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

LRUCache<int, std::string> cache(2);
cache.put(1, "one");
cache.put(2, "two");
assert(cache.get(1).value() == "one");  // 1이 최근 사용됨
cache.put(3, "three");  // 2가 evict됨 (가장 오래됨)
assert(!cache.get(2).has_value());
assert(cache.get(1).value() == "one");
assert(cache.get(3).value() == "three");

5. Skip List 완전 구현

5.1 개요

Skip List: 여러 레벨의 연결 리스트로, 상위 레벨을 건너뛰며 검색해 O(log n) 기대 시간을 달성합니다. 균형 트리보다 구현이 단순합니다.

다음은 mermaid를 활용한 상세한 구현 코드입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

flowchart LR
  subgraph L3[레벨 3]
    H3[head] --> N30[30]
  end
  subgraph L2[레벨 2]
    H2[head] --> N20[10]
    N20 --> N30
  end
  subgraph L1[레벨 1]
    H1[head] --> N10[5]
    N10 --> N20
    N20 --> N25[25]
    N25 --> N30
    N30 --> N40[40]
  end

5.2 완전한 구현

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <random>
#include <vector>
#include <optional>

template<typename Key, typename Value>
class SkipList {
    static constexpr int MAX_LEVEL = 16;
    static constexpr double P = 0.5;

public:
    SkipList() : level_(0) {
        head_ = std::make_unique<Node>(Key{}, Value{}, MAX_LEVEL);
    }

    void insert(const Key& key, const Value& value) {
        std::vector<Node*> update(MAX_LEVEL + 1);
        Node* cur = head_.get();

        for (int i = level_; i >= 0; --i) {
            while (cur->forward[i] && cur->forward[i]->key < key) {
                cur = cur->forward[i];
            }
            update[i] = cur;
        }

        int new_level = randomLevel();
        if (new_level > level_) {
            for (int i = level_ + 1; i <= new_level; ++i) {
                update[i] = head_.get();
            }
            level_ = new_level;
        }

        auto node = std::make_unique<Node>(key, value, new_level);
        for (int i = 0; i <= new_level; ++i) {
            node->forward[i] = update[i]->forward[i];
            update[i]->forward[i] = node.get();
        }
        nodes_.push_back(std::move(node));
    }

    std::optional<Value> find(const Key& key) const {
        Node* cur = head_.get();
        for (int i = level_; i >= 0; --i) {
            while (cur->forward[i] && cur->forward[i]->key < key) {
                cur = cur->forward[i];
            }
        }
        cur = cur->forward[0];
        if (cur && cur->key == key) return cur->value;
        return std::nullopt;
    }

    bool erase(const Key& key) {
        std::vector<Node*> update(MAX_LEVEL + 1);
        Node* cur = head_.get();

        for (int i = level_; i >= 0; --i) {
            while (cur->forward[i] && cur->forward[i]->key < key) {
                cur = cur->forward[i];
            }
            update[i] = cur;
        }
        cur = cur->forward[0];
        if (!cur || cur->key != key) return false;

        for (int i = 0; i <= level_; ++i) {
            if (update[i]->forward[i] != cur) break;
            update[i]->forward[i] = cur->forward[i];
        }
        while (level_ > 0 && !head_->forward[level_]) --level_;
        return true;
    }

private:
    struct Node {
        Key key;
        Value value;
        std::vector<Node*> forward;

        Node(const Key& k, const Value& v, int level)
            : key(k), value(v), forward(level + 1, nullptr) {}
    };

    int randomLevel() {
        int lvl = 0;
        static std::mt19937 rng{std::random_device{}()};
        static std::uniform_real_distribution<> dist(0, 1);
        while (dist(rng) < P && lvl < MAX_LEVEL) ++lvl;
        return lvl;
    }

    std::unique_ptr<Node> head_;
    int level_;
    std::vector<std::unique_ptr<Node>> nodes_;  // 소유권 관리
};

핵심 포인트:

  • randomLevel: 50% 확률로 레벨 증가, 평균 O(log n) 레벨
  • insert: 각 레벨에서 삽입 위치 찾은 뒤, 새 노드 연결
  • find: 상위 레벨부터 건너뛰며 하강

6. 자주 발생하는 에러와 해결법

에러 1: 해시테이블 rehash 시 반복자 무효화

증상: rehash 중에 기존 이터레이터를 사용하면 크래시.

아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 잘못된 예
for (auto it = map_.begin(); it != map_.end(); ++it) {
    if (condition(it)) {
        insert(new_key, new_val);  // rehash 발생 가능!
        // it 무효화 - UB
    }
}

해결법:

아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ rehash 후 이터레이터 재획득
std::vector<Key> to_process;
for (auto it = map_.begin(); it != map_.end(); ++it) {
    if (condition(it)) to_process.push_back(it->first);
}
for (const auto& k : to_process) {
    insert(k, compute(k));
}

에러 2: LRU 캐시에서 map에 리스트 이터레이터 저장 시 무효화

증상: list::splicelist::erasemap_에 저장된 이터레이터가 무효화될 수 있음.

해결법: splice는 노드를 이동할 뿐이므로 같은 리스트 내에서는 이터레이터가 유효합니다. 단, list::erase로 제거된 노드의 이터레이터는 무효화되므로, erase 전에 map_에서 해당 키를 제거해야 합니다.

다음은 간단한 cpp 코드 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 올바른 순서
auto last = list_.back();
map_.erase(last.first);   // 먼저 map에서 제거
list_.pop_back();        // 그 다음 리스트에서 제거

에러 3: 트라이에서 빈 문자열 처리

증상: insert("") 시 root가 is_end가 되어, search("")가 true를 반환. 의도에 따라 다를 수 있음.

// ❌ 빈 문자열 insert 시
trie.insert("");  // root->is_end = true
trie.search("");  // true - 원하는 동작인가?

해결법: 빈 문자열을 허용하지 않는다면 insert 시 검사.

다음은 간단한 cpp 코드 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

void insert(const std::string& word) {
    if (word.empty()) return;  // 또는 예외
    // ...
}

에러 4: 해시 함수 품질 - 충돌 과다

증상: 해시테이블 성능이 급격히 저하 (체인이 매우 길어짐).

다음은 간단한 cpp 코드 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ❌ 나쁜 해시 - 모든 키가 같은 버킷으로
struct BadHash {
    size_t operator()(int x) const { return 1; }
};

해결법: 비트 조합, 소수 곱셈 등으로 분산.

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 개선된 해시
struct PairHash {
    size_t operator()(const std::pair<int,int>& p) const {
        size_t h1 = std::hash<int>{}(p.first);
        size_t h2 = std::hash<int>{}(p.second);
        return h1 ^ (h2 << 1);  // 또는 h1 * 31 + h2
    }
};

에러 5: Skip List에서 노드 소유권

증상: insert에서 Node를 스택에 생성하면 함수 반환 시 dangling pointer.

// ❌ 잘못된 예
Node node(key, value, new_level);
update[i]->forward[i] = &node;  // 함수 종료 시 UB

해결법: unique_ptr로 힙에 할당하고, 컨테이너에 보관.

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 올바른 예
auto node = std::make_unique<Node>(key, value, new_level);
Node* raw = node.get();
nodes_.push_back(std::move(node));
update[i]->forward[i] = raw;

에러 6: LRU 캐시 capacity 0

증상: capacity_ == 0일 때 put에서 list_.size() >= 0이 항상 참이 되어, 삽입 직후 제거되는 무한 루프 가능성.

해결법: 생성자에서 capacity_를 최소 1로 보장.

explicit LRUCache(size_t capacity) : capacity_(capacity) {
    if (capacity_ == 0) capacity_ = 1;
}

7. 성능 비교

7.1 자료구조별 연산 복잡도

자료구조삽입검색삭제접두사 검색k번째 접근
std::unordered_mapO(1) 평균O(1) 평균O(1) 평균
std::mapO(log n)O(log n)O(log n)부분 지원O(n)
해시테이블 (체이닝)O(1) 평균O(1) 평균O(1) 평균
트라이O(m)O(m)O(m)O(m)
LRU 캐시O(1)O(1)O(1)
Skip ListO(log n) 기대O(log n) 기대O(log n) 기대O(log n)

m = 키/접두사 길이

7.2 벤치마크 시나리오 (100만 연산)

시나리오unordered_mapmap커스텀 해시테이블LRU(1만)
삽입 100만~200ms~400ms~250ms~220ms
검색 100만 (존재)~150ms~350ms~200ms~180ms
삽입+검색 혼합~180ms~380ms~230ms~200ms

환경에 따라 차이 있음. 프로파일링 권장.

7.3 선택 가이드

아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

flowchart TD
  Q[요구사항] --> A{크기 제한?}
  A -->|예| B[LRU 캐시]
  A -->|아니오| C{접두사 검색?}
  C -->|예| D[트라이]
  C -->|아니오| E{k번째 접근?}
  E -->|예| F[Skip List / order statistic tree]
  E -->|아니오| G[unordered_map / map]

8. 프로덕션 패턴

8.1 스레드 안전 LRU 캐시

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <mutex>

template<typename Key, typename Value>
class ThreadSafeLRUCache {
public:
    explicit ThreadSafeLRUCache(size_t capacity) : cache_(capacity) {}

    std::optional<Value> get(const Key& key) {
        std::lock_guard<std::mutex> lock(mutex_);
        return cache_.get(key);
    }

    void put(const Key& key, const Value& value) {
        std::lock_guard<std::mutex> lock(mutex_);
        cache_.put(key, value);
    }

private:
    LRUCache<Key, Value> cache_;
    std::mutex mutex_;
};

8.2 TTL(Time-To-Live) 캐시

참고: 프로덕션에서는 LRU eviction 시 expiry_에서도 제거하는 콜백이 필요합니다.

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <chrono>

template<typename Key, typename Value>
class TTLCache {
public:
    explicit TTLCache(size_t capacity, std::chrono::seconds ttl)
        : cache_(capacity), ttl_(ttl) {}

    std::optional<Value> get(const Key& key) {
        auto it = expiry_.find(key);
        if (it == expiry_.end()) return std::nullopt;
        if (std::chrono::steady_clock::now() > it->second) {
            cache_.erase(key);  // 간단화: LRU에 erase 추가 필요
            expiry_.erase(it);
            return std::nullopt;
        }
        return cache_.get(key);
    }

    void put(const Key& key, const Value& value) {
        cache_.put(key, value);
        expiry_[key] = std::chrono::steady_clock::now() + ttl_;
    }

private:
    LRUCache<Key, Value> cache_;
    std::chrono::seconds ttl_;
    std::unordered_map<Key, std::chrono::steady_clock::time_point> expiry_;
};

8.3 트라이 + 빈도수 (자동완성 순위)

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

class AutocompleteTrie {
public:
    void insert(const std::string& word, int freq = 1) {
        Node* cur = root_.get();
        for (char c : word) {
            if (!cur->children[c]) cur->children[c] = std::make_unique<Node>();
            cur = cur->children[c].get();
        }
        cur->is_end = true;
        cur->freq += freq;
    }

    std::vector<std::pair<std::string, int>> getTopSuggestions(
            const std::string& prefix, size_t k = 10) const {
        Node* cur = root_.get();
        for (char c : prefix) {
            auto it = cur->children.find(c);
            if (it == cur->children.end()) return {};
            cur = it->second.get();
        }
        std::vector<std::pair<std::string, int>> candidates;
        collectWithFreq(cur, prefix, candidates);
        std::partial_sort(candidates.begin(),
            candidates.begin() + std::min(k, candidates.size()),
            candidates.end(),
             { return a.second > b.second; });
        candidates.resize(std::min(k, candidates.size()));
        return candidates;
    }

private:
    struct Node {
        std::unordered_map<char, std::unique_ptr<Node>> children;
        bool is_end = false;
        int freq = 0;
    };
    std::unique_ptr<Node> root_;
    // collectWithFreq 구현 생략
};

8.4 메트릭 수집 LRU

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

template<typename Key, typename Value>
class InstrumentedLRUCache {
public:
    explicit InstrumentedLRUCache(size_t capacity) : cache_(capacity) {}

    std::optional<Value> get(const Key& key) {
        ++get_count_;
        auto result = cache_.get(key);
        if (result) ++hit_count_;
        return result;
    }

    void put(const Key& key, const Value& value) {
        ++put_count_;
        cache_.put(key, value);
    }

    double hit_rate() const {
        return get_count_ == 0 ? 0.0
            : static_cast<double>(hit_count_) / get_count_;
    }

private:
    LRUCache<Key, Value> cache_;
    size_t get_count_ = 0, put_count_ = 0, hit_count_ = 0;
};

9. 구현 체크리스트

해시테이블

  • 로드 팩터 임계값 설정 (0.75 권장)
  • rehash 시 기존 이터레이터 사용 금지
  • 커스텀 키 시 해시 품질 검증

트라이

  • 빈 문자열 처리 정책
  • 메모리: 자식이 없으면 노드 정리 (선택)

LRU 캐시

  • capacity 0 방어
  • splice 사용 시 이터레이터 유효성 확인
  • evict 시 콜백 필요하면 확장

Skip List

  • 노드 소유권 (unique_ptr 등)
  • randomLevel 시드 고정 (재현성 필요 시)

공통

  • 예외 안전성 (RAII)
  • const 정확성
  • 단위 테스트 (경계 조건)

정리

항목핵심
문제 시나리오캐시 제한, 접두사 검색, 커스텀 키, k번째 접근
해시테이블체이닝, rehash, 로드 팩터
트라이접두사 O(m), 자동완성
LRU해시 + 이중 연결 리스트, splice O(1)
Skip List다중 레벨, O(log n) 기대
흔한 에러반복자 무효화, 해시 품질, capacity 0
성능요구사항에 맞는 자료구조 선택
프로덕션스레드 안전, TTL, 메트릭

핵심 원칙:

  1. STL 우선 - unordered_map, map으로 해결되면 사용
  2. 요구사항 정확히 - 크기 제한, 접두사, k번째 등
  3. 측정 후 선택 - 벤치마크로 검증
  4. 반복자/포인터 무효화 주의

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 캐싱 시스템(LRU), 검색 자동완성(트라이), 커스텀 해시가 필요한 키, 정렬된 시퀀스의 k번째 접근(Skip List) 등 STL로 해결하기 어려운 상황에서 활용합니다.

Q. 선행으로 읽으면 좋은 글은?

A. STL map/set, 알고리즘 선택, 트리 알고리즘을 먼저 익히면 좋습니다.

Q. 더 깊이 공부하려면?

A. 《Introduction to Algorithms》의 해시·트리 장, LeetCode 146 (LRU Cache), 208 (Implement Trie), cppreference의 unordered_map 구현 노트를 참고하세요.

한 줄 요약: STL 한계를 넘어서는 해시테이블·트라이·LRU·Skip List를 직접 구현할 수 있습니다.


관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3