C++手撕STL-其四
今天我们来介绍最后的一部分STL知识:
Map&&Set
map是一种有序容器,我们有一个更通俗的叫法:有序哈希表,map里存储的是一个键值对,其中是一个映射关系,map支持唯一键,也就是每个键值都是唯一的。
map的底层是基于红黑树实现的,关于红黑树:
红黑树是一种自平衡的二叉搜索树,他最大的特点就是具有相当稳定的性质,无论增删查改他的复杂度都是稳定的Ologn。当然缺点也是他的稳定,客观地说logn并不算一个特别低的复杂度,不具有更高的提升空间。
template <class Key, class T, class Compare = less<Key>, class Alloc = alloc>
class map {
public:typedef Key key_type; // 键类型typedef T mapped_type; // 值类型typedef pair<const Key, T> value_type; // 元素类型(键不可修改)typedef Compare key_compare; // 键比较函数private:// 底层红黑树类型定义typedef rb_tree<key_type, value_type, select1st<value_type>, key_compare, Alloc> rep_type;rep_type t; // 红黑树实例
};
这个是map的实现,可以看到我们是需要一个红黑树结构的实例的,我们用红黑树来存储数据并进行排序。
map是支持下标访问的,同时包含插入操作insert:
pair<iterator, bool> insert(const value_type& x) {return t.insert_unique(x);
}
T& operator[](const key_type& k) {return (*((insert(value_type(k, T()))).first)).second;
}
// 查找元素(时间复杂度 O(log n))
iterator find(const Key& key) {rb_tree_node_base* current = t.root();while (current) {if (key_compare(key, current->value)) {current = current->left;} else if (key_compare(current->value, key)) {current = current->right;} else {return iterator(current);}}return end();
}
// 删除元素
size_t erase(const Key& key) {return t.erase(key);
}
可以看到我们的insert函数调用了红黑树实例t的一个成员函数insert_unique(),这个是C++内部帮我们写好的,具体如下:
template<typename _Key, typename _Val, typename _KeyOfValue, typename _Compare, typename _Alloc>
pair<typename _Rb_tree<_Key, _Val, _KeyOfValue, _Compare, _Alloc>::iterator, bool>
_Rb_tree<_Key, _Val, _KeyOfValue, _Compare, _Alloc>::insert_unique(const _Val& value) {// 1. 查找插入位置pair<_Base_ptr, _Base_ptr> res = _M_get_insert_unique_pos(_KeyOfValue()(value));// 2. 如果位置有效(无重复键),则插入新节点if (res.second) {_Link_type new_node = _M_create_node(value);_M_insert_node(res.first, res.second, new_node);return { iterator(new_node), true };}// 3. 键已存在时返回失败return { iterator(static_cast<_Link_type>(res.first)), false };
}
我们就不无限地深入了,不然要没完没了了。大体上insert_unique函数的作用就是:
而中括号的重载的作用就是返回对应键值对的键的值,可能有点绕。查找和删除的函数也比较好理解,本质上就是二叉搜索树的查找,时间复杂度为ologn(稳定)。
然后就是迭代器,这里我们直接复用红黑树的即可:
iterator begin() { return t.begin(); }
iterator end() { return t.end(); }
构造函数:
// 默认构造函数(使用less<Key>比较)
map() : t(Compare()) {}// 自定义比较函数的构造函数
explicit map(const Compare& comp) : t(comp) {}// 范围初始化
template <class InputIterator>
map(InputIterator first, InputIterator last) : t(Compare()) {t.insert_unique(first, last);
}
默认调用红黑树的默认仿函数,当然也可以显式调用,关于初始化我们之所以要用模板是因为输入的迭代器可能是各种各样的迭代器。
key_compare key_comp() const { return t.key_comp(); }
value_compare value_comp() const { return value_compare(t.key_comp()); }
我们把红黑树实例内部的值比较方法也直接拿来作为Map的比较方法。
map的代码大致如此,其源码非常复杂,如果有人想看的话网址在此:gcc/libstdc++-v3/include/bits/stl_map.h at master · gcc-mirror/gcc · GitHub
现在让我们把目光转向set。
set也是有序容器,但不同的地方在于他只存储单个值,且每个值也是唯一的,换句话说,set是一个自动排序和去重的容器。
template <typename Key, typename Compare = std::less<Key>, typename Alloc = std::allocator<Key>>
class set {
private:typedef rb_tree<Key, Compare, Alloc> rep_type;rep_type t; // 底层红黑树实例public:// 迭代器类型typedef typename rep_type::iterator iterator;// 构造函数set() : t(Compare()) {}explicit set(const Compare& comp) : t(comp) {}// 插入元素(自动去重)std::pair<iterator, bool> insert(const Key& key) {return t.insert_unique(key);}// 查找元素(时间复杂度 O(log n))iterator find(const Key& key) {rb_tree_node_base* current = t.root();while (current) {if (key_compare(key, current->value)) {current = current->left;} else if (key_compare(current->value, key)) {current = current->right;} else {return iterator(current);}}return end();}// 删除元素size_t erase(const Key& key) {return t.erase(key);}};
而事实上,如果我们再仔细回顾红黑树的特性:自平衡的二叉搜索树,我们知道二叉搜索树的要求是左子树一定值比根节点小而右子树值一定比根节点大,那么假如我们把一个红黑树中已有的节点放入树中,会直接被忽略,从而实现了去重的效果。对于Map而言,我们把键值对存储在红黑树上保证了键的唯一性和排序,对于set而言就可以直接把值放在红黑树上存储实现排序和去重了。
其实不难发现,本质上Map和set都是高度依赖于红黑树结构实现的容器,最大的区别就在于map是把键值对存储在红黑树上而set只是把一个单独的值存储在红黑树上。
Unordered_map&&Unordered_Set
介绍完map和set,那自然免不了unordered_map和unordered_set。
显然这两种容器不太可能是基于红黑树实现的,因为红黑树并不是所有情况下的最优解:
红黑树的增删查改的复杂度稳定为Ologn,这并不是一个优秀的复杂度,且我们要时刻维护红黑树,这本身需要非常大的内存开销;相比之下,基于链地址法实现的哈希表在很多方面更具优势:
这里可以看到存在一种哈希表的最坏情况,这个就是哈希表的键设置有问题,最极端的情况下,所有值被放入一个键中,这个时候哈希表就退化成链表,复杂度来到On,不过这种情况极少发生,正常情况下哈希表的增删查改都将是O1复杂度。
现在让我们回归具体的容器代码,来看看如何实现。
#include <vector>
#include <functional>// 哈希节点结构(链地址法)
template <typename T>
struct HashNode {T _data; // 存储键值对(unordered_map)或键(unordered_set)HashNode<T>* _next; // 指向同一桶中的下一个节点HashNode(const T& data) : _data(data), _next(nullptr) {}
};// 哈希表模板类
template <typename K, typename T, typename KeyOfT, typename Hash>
class HashTable {
public:typedef HashNode<T> Node;std::vector<Node*> _table; // 桶数组,存储链表头指针[1,4](@ref)size_t _size = 0; // 元素总数Hash _hf; // 哈希函数对象[4](@ref)KeyOfT _kot; // 键提取仿函数[4](@ref)// 构造函数HashTable(size_t capacity = 10) {_table.resize(capacity, nullptr);}// 析构函数(释放所有节点)~HashTable() {for (size_t i = 0; i < _table.size(); ++i) {Node* cur = _table[i];while (cur) {Node* next = cur->_next;delete cur;cur = next;}}}// 插入键值对(简化版逻辑)bool Insert(const T& data) {// 检查负载因子,触发扩容(示例阈值设为0.7)[3](@ref)if (_size >= _table.size() * 0.7) {Rehash(_table.size() * 2);}// 计算桶索引const K& key = _kot(data);size_t index = _hf(key) % _table.size();// 检查键是否已存在Node* cur = _table[index];while (cur) {if (_kot(cur->_data) == key) return false; // 键重复cur = cur->_next;}// 头插法添加新节点Node* newNode = new Node(data);newNode->_next = _table[index];_table[index] = newNode;++_size;return true;}// 哈希表扩容(重新哈希)void Rehash(size_t newSize) {std::vector<Node*> newTable(newSize, nullptr);for (size_t i = 0; i < _table.size(); ++i) {Node* cur = _table[i];while (cur) {Node* next = cur->_next;const K& key = _kot(cur->_data);size_t newIndex = _hf(key) % newSize;cur->_next = newTable[newIndex];newTable[newIndex] = cur;cur = next;}}_table.swap(newTable);}// 查找操作(返回节点指针)Node* Find(const K& key) {size_t index = _hf(key) % _table.size();Node* cur = _table[index];while (cur) {if (_kot(cur->_data) == key) return cur;cur = cur->_next;}return nullptr;}
};// 自定义哈希函数(示例:字符串哈希)
struct StringHash {size_t operator()(const std::string& s) {size_t hash = 0;for (char c : s) hash = hash * 131 + c; // 简单哈希算法return hash;}
};// 键提取仿函数(unordered_map专用)
template <typename K, typename V>
struct MapKeyOfT {const K& operator()(const std::pair<const K, V>& kv) const {return kv.first;}
};// unordered_map封装类
template <typename K, typename V, typename Hash = StringHash>
class UnorderedMap {
private:HashTable<K, std::pair<const K, V>, MapKeyOfT<K, V>, Hash> _ht;public:// 插入接口bool Insert(const std::pair<const K, V>& kv) {return _ht.Insert(kv);}// 查找接口V* Find(const K& key) {auto* node = _ht.Find(key);return node ? &(node->_data.second) : nullptr;}
};
我们没有展示更复杂的哈希函数以及哈希表结构,但是大体上依然可以体现unordered_map基本原理,其中最大的特点就是我们有一个根据负载因子实现重新哈希的过程:
所谓的桶其实就是存储键值对的数据结构,但是我们会根据哈希函数计算得到的哈希值来决定把键值对放在哪个桶中,负载因子表示的就是存储的总元素数量和哈希表中的桶的总值的比值。最理想的情况,我们当然希望一个桶对应一个键值对,但是很多时候并非如此:
所以我们需要负载因子来提前进行重新哈希而不是等出现哈希冲突再做,重新哈希的步骤无非增加桶的数量(一般是翻倍)并重新分配键值对到桶中,当然,内存占用也得扩大才行。
unordered_set也是基于哈希表实现,不过我们注意这里unordered_set要如何实现去重呢?
对于Unordered_set来说,我们依然先计算出插入的值的哈希值,然后来到对应的桶,我们会遍历桶中的元素,如果发现有相等的值我们就会终止插入操作,这就是我们实现unordered_set去重的方法。