@@ -3746,6 +3746,8 @@ class flat_hash_multi_set_gt {
37463746 char * data_ = nullptr ;
37473747 std::size_t buckets_ = 0 ;
37483748 std::size_t populated_slots_ = 0 ;
3749+ // / @brief Number of tombstones (slots marked deleted but not yet reclaimed)
3750+ std::size_t deleted_slots_ = 0 ;
37493751 // / @brief Number of slots
37503752 std::size_t capacity_slots_ = 0 ;
37513753
@@ -3781,6 +3783,34 @@ class flat_hash_multi_set_gt {
37813783 }
37823784 }
37833785
3786+ /* *
3787+ * @brief Copies every live entry of @p source into the freshly zeroed @p target.
3788+ *
3789+ * Tombstones are never carried over, so probe chains must be rebuilt from the hash
3790+ * rather than reproduced slot-for-slot: a live entry displaced past a tombstone by
3791+ * linear probing would otherwise become unreachable once that tombstone reads empty.
3792+ * The target holds no tombstones, so an unpopulated slot is always a free slot.
3793+ */
3794+ void rehash_into (char * source, std::size_t source_slots, char * target, std::size_t target_slots) const noexcept {
3795+ hash_t hasher;
3796+ for (std::size_t i = 0 ; i != source_slots; ++i) {
3797+ slot_ref_t source_slot = slot_ref (source, i);
3798+ if (!(source_slot.header .populated & source_slot.mask ) || (source_slot.header .deleted & source_slot.mask ))
3799+ continue ;
3800+
3801+ std::size_t target_index = hasher (source_slot.element ) & (target_slots - 1 );
3802+ while (true ) {
3803+ slot_ref_t target_slot = slot_ref (target, target_index);
3804+ if (!(target_slot.header .populated & target_slot.mask )) {
3805+ new (&target_slot.element ) element_t (source_slot.element );
3806+ target_slot.header .populated |= target_slot.mask ;
3807+ break ;
3808+ }
3809+ target_index = (target_index + 1 ) & (target_slots - 1 );
3810+ }
3811+ }
3812+ }
3813+
37843814 public:
37853815 std::size_t size () const noexcept { return populated_slots_; }
37863816 std::size_t capacity () const noexcept { return capacity_slots_ * 2u / 3u ; }
@@ -3805,22 +3835,15 @@ class flat_hash_multi_set_gt {
38053835 if (!data_)
38063836 usearch_raise_runtime_error (" failed memory allocation" );
38073837
3808- // Copy metadata
3838+ // Copy metadata. Only live entries are rehashed below, so the copy has no tombstones.
38093839 buckets_ = other.buckets_ ;
38103840 populated_slots_ = other.populated_slots_ ;
3841+ deleted_slots_ = 0 ;
38113842 capacity_slots_ = other.capacity_slots_ ;
38123843
38133844 // Initialize new buckets to empty
38143845 std::memset (data_, 0 , buckets_ * bytes_per_bucket ());
3815-
3816- // Copy elements and bucket headers
3817- for (std::size_t i = 0 ; i < capacity_slots_; ++i) {
3818- slot_ref_t old_slot = other.slot_ref (i);
3819- if ((old_slot.header .populated & old_slot.mask ) && !(old_slot.header .deleted & old_slot.mask )) {
3820- slot_ref_t new_slot = slot_ref (i);
3821- populate_slot (new_slot, old_slot.element );
3822- }
3823- }
3846+ rehash_into (other.data_ , other.capacity_slots_ , data_, capacity_slots_);
38243847 }
38253848
38263849 flat_hash_multi_set_gt& operator =(flat_hash_multi_set_gt const & other) {
@@ -3848,22 +3871,15 @@ class flat_hash_multi_set_gt {
38483871 if (!data_)
38493872 usearch_raise_runtime_error (" failed memory allocation" );
38503873
3851- // Copy metadata
3874+ // Copy metadata. Only live entries are rehashed below, so the copy has no tombstones.
38523875 buckets_ = other.buckets_ ;
38533876 populated_slots_ = other.populated_slots_ ;
3877+ deleted_slots_ = 0 ;
38543878 capacity_slots_ = other.capacity_slots_ ;
38553879
38563880 // Initialize new buckets to empty
38573881 std::memset (data_, 0 , buckets_ * bytes_per_bucket ());
3858-
3859- // Copy elements and bucket headers
3860- for (std::size_t i = 0 ; i < capacity_slots_; ++i) {
3861- slot_ref_t old_slot = other.slot_ref (i);
3862- if ((old_slot.header .populated & old_slot.mask ) && !(old_slot.header .deleted & old_slot.mask )) {
3863- slot_ref_t new_slot = slot_ref (i);
3864- populate_slot (new_slot, old_slot.element );
3865- }
3866- }
3882+ rehash_into (other.data_ , other.capacity_slots_ , data_, capacity_slots_);
38673883
38683884 return *this ;
38693885 }
@@ -3880,6 +3896,7 @@ class flat_hash_multi_set_gt {
38803896 if (data_)
38813897 std::memset (data_, 0 , buckets_ * bytes_per_bucket ());
38823898 populated_slots_ = 0 ;
3899+ deleted_slots_ = 0 ;
38833900 }
38843901
38853902 void reset () noexcept {
@@ -3889,11 +3906,19 @@ class flat_hash_multi_set_gt {
38893906 data_ = nullptr ;
38903907 buckets_ = 0 ;
38913908 populated_slots_ = 0 ;
3909+ deleted_slots_ = 0 ;
38923910 capacity_slots_ = 0 ;
38933911 }
38943912
3913+ /* *
3914+ * @brief Grows the table to fit @p capacity live entries, reclaiming tombstones.
3915+ *
3916+ * Tombstones are reclaimed even when no growth is requested. They only ever stop
3917+ * being created once reclaimed, and a table with no empty slot left cannot
3918+ * terminate a probe, silently stranding live entries.
3919+ */
38953920 bool try_reserve (std::size_t capacity) noexcept {
3896- if (capacity <= this ->capacity ())
3921+ if (capacity <= this ->capacity () && deleted_slots_ == 0 )
38973922 return true ;
38983923
38993924 // Calculate new sizes
@@ -3913,7 +3938,15 @@ class flat_hash_multi_set_gt {
39133938 checked_size_result_t new_slots = checked_mul (new_buckets_checked.value , slots_per_bucket ());
39143939 if (!new_slots)
39153940 return false ;
3916- checked_size_result_t new_bytes = checked_mul (new_buckets_checked.value , bytes_per_bucket ());
3941+
3942+ // Never shrink: reclaiming tombstones alone needs no more than the current capacity
3943+ std::size_t target_buckets = new_buckets_checked.value ;
3944+ std::size_t target_slots = new_slots.value ;
3945+ if (target_slots < capacity_slots_) {
3946+ target_buckets = buckets_;
3947+ target_slots = capacity_slots_;
3948+ }
3949+ checked_size_result_t new_bytes = checked_mul (target_buckets, bytes_per_bucket ());
39173950 if (!new_bytes)
39183951 return false ;
39193952
@@ -3924,36 +3957,15 @@ class flat_hash_multi_set_gt {
39243957
39253958 // Initialize new buckets to empty
39263959 std::memset (new_data, 0 , new_bytes.value );
3927-
3928- // Rehash and copy existing elements to new_data
3929- hash_t hasher;
3930- for (std::size_t i = 0 ; i < capacity_slots_; ++i) {
3931- slot_ref_t old_slot = slot_ref (i);
3932- if ((~old_slot.header .populated & old_slot.mask ) | (old_slot.header .deleted & old_slot.mask ))
3933- continue ;
3934-
3935- // Rehash
3936- std::size_t hash_value = hasher (old_slot.element );
3937- std::size_t new_slot_index = hash_value & (new_slots.value - 1 );
3938-
3939- // Linear probing to find an empty slot in new_data
3940- while (true ) {
3941- slot_ref_t new_slot = slot_ref (new_data, new_slot_index);
3942- if (!(new_slot.header .populated & new_slot.mask ) || (new_slot.header .deleted & new_slot.mask )) {
3943- populate_slot (new_slot, std::move (old_slot.element ));
3944- new_slot.header .populated |= new_slot.mask ;
3945- break ;
3946- }
3947- new_slot_index = (new_slot_index + 1 ) & (new_slots.value - 1 );
3948- }
3949- }
3960+ rehash_into (data_, capacity_slots_, new_data, target_slots);
39503961
39513962 // Deallocate old data and update pointers and sizes
39523963 if (data_)
39533964 allocator_t {}.deallocate (data_, buckets_ * bytes_per_bucket ());
39543965 data_ = new_data;
3955- buckets_ = new_buckets_checked.value ;
3956- capacity_slots_ = new_slots.value ;
3966+ buckets_ = target_buckets;
3967+ capacity_slots_ = target_slots;
3968+ deleted_slots_ = 0 ;
39573969
39583970 return true ;
39593971 }
@@ -4082,6 +4094,7 @@ class flat_hash_multi_set_gt {
40824094 // Found a match, mark as deleted
40834095 slot.header .deleted |= slot.mask ;
40844096 --populated_slots_;
4097+ ++deleted_slots_;
40854098 popped_value = slot.element ;
40864099 return true ; // Successfully removed
40874100 }
@@ -4117,6 +4130,7 @@ class flat_hash_multi_set_gt {
41174130 // Found a match, mark as deleted
41184131 slot.header .deleted |= slot.mask ;
41194132 --populated_slots_;
4133+ ++deleted_slots_;
41204134 ++count; // Increment count of elements removed
41214135 }
41224136 } else {
@@ -4247,8 +4261,10 @@ class flat_hash_multi_set_gt {
42474261 }
42484262
42494263 bool try_emplace (element_t const & element) noexcept {
4250- // Check if we need to resize
4251- if (populated_slots_ * 3u >= capacity_slots_ * 2u )
4264+ // Both live entries and tombstones consume slots a probe must walk past, so the
4265+ // load factor counts them together. Under churn the live count alone stays flat
4266+ // and would never trigger the rehash that reclaims the tombstones.
4267+ if ((populated_slots_ + deleted_slots_) * 3u >= capacity_slots_ * 2u )
42524268 if (!try_reserve (populated_slots_ + 1 ))
42534269 return false ;
42544270
@@ -4260,7 +4276,9 @@ class flat_hash_multi_set_gt {
42604276 while (true ) {
42614277 slot_ref_t slot = slot_ref (slot_index);
42624278 if ((~slot.header .populated & slot.mask ) | (slot.header .deleted & slot.mask )) {
4263- // Found an empty or deleted slot
4279+ // Found an empty or deleted slot; reusing a tombstone reclaims it.
4280+ // Read the tombstone bit before `populate_slot` clears it.
4281+ deleted_slots_ -= (slot.header .deleted & slot.mask ) != 0 ;
42644282 populate_slot (slot, element);
42654283 ++populated_slots_;
42664284 return true ;
0 commit comments