Давайте внедрим структуру данных хэш-таблицы в Rust. Хэш-таблицы невероятно важны в структурах данных благодаря их эффективности и универсальности. Реализуя её с нуля, мы сможем получить представление о лежащих в его основе алгоритмах и структурах данных. Мало того, у нас также будет шанс улучшить наши навыки Rust.
Говоря об алгоритме, мы собираемся реализовать исследование линейной адресации хэш-таблицы. Есть и другие варианты, но я думаю, что с этого, проще начать.
Я возьму подход сверху вниз, где мы начинаем с абстракций более высокого уровня и идем к абстракциям и реализациям более низкого уровня. Начнем с самой высокой абстракции: API. Мы, пока не хотим поддерживать весь API стандартной библиотеки Rust std::collections::HashMap
. Для начала давайте построим его до основных функциональных возможностей.
use std::borrow::Borrow;
use std::hash::Hash;
pub struct HashMap<Key, Val> {
// todo
}
impl<Key: Eq + Hash, Val> HashMap<Key, Val> {
pub fn new() -> Self {
todo!()
}
pub fn len(&self) -> usize {
todo!()
}
pub fn insert(&mut self, key: Key, val: Val) -> Option<Val> {
todo!()
}
pub fn get<Q>(&self, key: &Q) -> Option<&Val>
where
Key: Borrow<Q>,
Q: Eq + Hash,
{
todo!()
}
pub fn get_mut<Q>(&mut self, key: &Q) -> Option<&mut Val>
where
Key: Borrow<Q>,
Q: Eq + Hash,
{
todo!()
}
pub fn remove<Q>(&mut self, key: &Q) -> Option<Val>
where
Key: Borrow<Q>,
Q: Eq + Hash,
{
todo!()
}
}
Если вы уже знакомы с алгоритмом хеш-таблицы, не стесняйтесь идти вперед и попробуйте реализовать его самостоятельно. Методы имеют ту же самое название, что и в стандартной библиотеке HashMap
. В двух словах API Borrow позволяет, например, передавать &str
или &String
в хэш-таблицу типа String.
Хеш-таблица — это, по сути, массив элементов, индекс которого является хеш-значением ключа по модулю размера массива. Мы, естественно, будем использовать Vec<T>
для этого массива, но что за тип T
? Давайте назовем его Entry и подумаем о том, что Entry должен реализовывать. Мы знаем, что occupied
может быть как пустым, так и содержать данные. Когда передаем данные, они должны содержать как Key
, так и Val
. Для поддержки метода remove()
нам нужно третье состояние: Tombstone
. Это состояние представляет собой запись, которая когда-то была занята, но в настоящее время пуста. Мы поговорим о том, зачем нам нужно такое различие, позже, а пока вот наша вспомогательная структура:
enum Entry<Key, Val> {
Vacant,
Tombstone,
Occupied { key: Key, val: Val },
}
Нам нужны два свойства usize
для хэш-таблицы - одно для отслеживания занятых записей и другое для отслеживания свободных записей. Первое свойство - это то, что мы возвращаем для метода len()
. Второе свойство позволяет нам решить, когда изменять размер массива. С помощью этого мы можем добавить некоторые детали реализации в наш скелет:
--- before
+++ after
@@ -1,17 +1,29 @@
use std::borrow::Borrow;
use std::hash::Hash;
+enum Entry<Key, Val> {
+ Vacant,
+ Tombstone,
+ Occupied { key: Key, val: Val },
+}
+
pub struct HashMap<Key, Val> {
- // todo
+ xs: Vec<Entry<Key, Val>>,
+ n_occupied: usize,
+ n_vacant: usize,
}
impl<Key: Eq + Hash, Val> HashMap<Key, Val> {
pub fn new() -> Self {
- todo!()
+ Self {
+ xs: Vec::new(),
+ n_occupied: 0,
+ n_vacant: 0,
+ }
}
pub fn len(&self) -> usize {
- todo!()
+ self.n_occupied
}
Теперь пришло время написать какой-нибудь вспомогательный метод - нам нужен метод, который вычисляет значение хэш из ключа и применяет размер массива по модулю для получения индекса.
--- before
+++ after
@@ -1,5 +1,6 @@
use std::borrow::Borrow;
-use std::hash::Hash;
+use std::collections::hash_map::DefaultHasher;
+use std::hash::{Hash, Hasher};
enum Entry<Key, Val> {
Vacant,
@@ -14,6 +15,16 @@ pub struct HashMap<Key, Val> {
}
impl<Key: Eq + Hash, Val> HashMap<Key, Val> {
+ fn get_index<Q>(&self, key: &Q) -> usize
+ where
+ Key: Borrow<Q>,
+ Q: Eq + Hash,
+ {
+ let mut hasher = DefaultHasher::new();
+ key.hash(&mut hasher);
+ hasher.finish() as usize % self.xs.len()
+ }
+
pub fn new() -> Self {
Self {
xs: Vec::new(),
Теперь реализуем метод get()
. Все, что нам нужно сделать, это выполнить итерацию по записям, начинающимся с индекса, и сравнить ключи, до тех пор, пока не будет найдено совпадение или пока мы не столкнемся с вакантной записью. Во время поиска мы просто игнорируем и пропускаем записи с tombstone
.
--- before
+++ after
pub fn get<Q>(&self, key: &Q) -> Option<&Val>
where
Key: Borrow<Q>,
Q: Eq + Hash,
{
- todo!()
+ if self.len() == 0 {
+ return None;
+ }
+ let mut idx = self.get_index(key);
+ loop {
+ match &self.xs[idx] {
+ Entry::Vacant => {
+ break None;
+ }
+ Entry::Occupied { key: k, val } if k.borrow() == key => {
+ break Some(val);
+ }
+ _ => {
+ idx = (idx + 1) % self.xs.len();
+ }
+ }
+ }
}
pub fn get_mut<Q>(&mut self, key: &Q) -> Option<&mut Val>
Отлично, это было не совсем плохо. Теперь мы можем сделать то же самое с методом get_mut()
? Если вы просто обновите неизменяемые объекты до изменяемых, вы получите несколько ошибок компилятора (Rust playground). Исправление требует немного более долгих изменений. Для этого я продолжу в следующей истории.