Implementation of the Reservoir sampling algorithm A-ExpJ that allows sampling K random elements from a stream of elements according to their weights when we don't know the size of the stream in advance and don't have the memory to store all the elements of the stream.
Description of the algorithm: https://en.wikipedia.org/wiki/Reservoir_sampling#Algorithm_A-ExpJ
I tried to make it as efficient time-wise as I could:
- one allocation for all the data
- not do anything if the object is not going to be added
- the ability to construct objects in-place
- not move objects while adding new ones
also tried to make it safe and support as many types as I could:
- should work with custom arithmetic types as weights (bigint?), but not tested
- should work with non-copyable and/or non-movable types
template<typename T, typename WeightType = float, typename URBG = std::mt19937, typename RandType = float>
class ReservoirSamplerWeighted
{
public:
ReservoirSamplerWeighted(size_t samplesCount, URBG&& rand = std::mt19937{std::random_device{}()})
: mSamplesCount(samplesCount)
, mRand(std::forward<URBG>(rand))
{
}
~ReservoirSamplerWeighted()
{
if (mData)
{
for (size_t i = 0; i < mAllocatedElementsCount; ++i)
{
mElements[i].~T();
}
for (size_t i = 0; i < mSamplesCount; ++i)
{
mQueuePrios[i].~RandType();
}
std::free(mData);
}
}
ReservoirSamplerWeighted(const ReservoirSamplerWeighted& other)
: mSamplesCount(other.mSamplesCount)
, mWeightJumpOver(other.mWeightJumpOver)
, mRand(other.mRand)
, mUniformDist(other.mUniformDist)
, mAllocatedElementsCount(other.mAllocatedElementsCount)
{
if (other.mData)
{
allocateData();
for (size_t i = 0; i < mSamplesCount; ++i)
{
new (mQueuePrios + i) RandType(other.mQueuePrios[i]);
}
std::memcpy(mQueueIndexes, other.mQueueIndexes, sizeof(size_t)*mAllocatedElementsCount);
for (size_t i = 0; i < mAllocatedElementsCount; ++i)
{
new (mElements + i) T(other.mElements[i]);
}
}
}
ReservoirSamplerWeighted(ReservoirSamplerWeighted&& other)
: mSamplesCount(other.mSamplesCount)
, mWeightJumpOver(other.mWeightJumpOver)
, mRand(other.mRand)
, mUniformDist(other.mUniformDist)
, mAllocatedElementsCount(other.mAllocatedElementsCount)
, mData(other.mData)
, mQueuePrios(other.mQueuePrios)
, mQueueIndexes(other.mQueueIndexes)
, mElements(other.mElements)
{
other.mWeightJumpOver = {};
other.mAllocatedElementsCount = 0;
other.mData = nullptr;
other.mQueuePrios = nullptr;
other.mQueueIndexes = nullptr;
other.mElements = nullptr;
}
ReservoirSamplerWeighted& operator=(const ReservoirSamplerWeighted&) = delete;
ReservoirSamplerWeighted& operator=(ReservoirSamplerWeighted&&) = delete;
// if creation of an object to store is expensive you can check this before calling addElement
// and provide a "dummy" object in case this returns false, because it will be ignored in that case
bool willNextBeConsidered(WeightType weight) const
{
return (mWeightJumpOver - weight) <= 0;
}
template<typename E, typename = std::enable_if_t<std::is_move_constructible_v<std::decay_t<E>> && std::is_move_assignable_v<std::decay_t<E>> && std::is_same_v<std::decay_t<E>, T>>>
void addElement(WeightType weight, E&& element)
{
emplaceElement(weight, std::move(element));
}
void addElement(WeightType weight, const T& element)
{
emplaceElement(weight, element);
}
template<typename... Args>
void emplaceElement(WeightType weight, Args&&... arguments)
{
if (mData == nullptr)
{
prepareData();
}
if (weight > WeightType(0.0))
{
if (mAllocatedElementsCount < mSamplesCount)
{
const RandType r = std::pow(mUniformDist(mRand), (static_cast<RandType>(1.0) / weight));
insertSorted(r, std::forward<Args>(arguments)...);
if (mAllocatedElementsCount == mSamplesCount)
{
mWeightJumpOver = log(mUniformDist(mRand)) / log(mQueuePrios[0]);
}
}
else
{
mWeightJumpOver -= weight;
if (mWeightJumpOver <= 0)
{
const RandType t = std::pow(mQueuePrios[0], weight);
const RandType r = std::pow(std::uniform_real_distribution<RandType>(t, static_cast<RandType>(1.0))(mRand), static_cast<RandType>(1.0) / weight);
insertSortedRemoveFirst(r, std::forward<Args>(arguments)...);
mWeightJumpOver = log(mUniformDist(mRand)) / log(mQueuePrios[0]);
}
}
}
}
const std::pair<const T*, size_t> getResult() const { return std::make_pair(mElements, mAllocatedElementsCount); }
private:
void prepareData()
{
allocateData();
for (size_t i = 0; i < mSamplesCount; ++i)
{
new (mQueuePrios + i) RandType();
}
}
void allocateData()
{
assert(mData == nullptr);
constexpr size_t alignment = std::max(std::max(std::alignment_of_v<RandType>, std::alignment_of_v<size_t>), std::alignment_of_v<T>);
const size_t keysExtent = (sizeof(RandType)*mSamplesCount) % std::alignment_of_v<size_t>;
const size_t indexesAlignmentGap = keysExtent > 0 ? (std::alignment_of_v<size_t> - keysExtent) : 0;
const size_t indexesOffset = sizeof(RandType)*mSamplesCount + indexesAlignmentGap;
const size_t indexesExtent = (indexesOffset + sizeof(size_t)*mSamplesCount) % std::alignment_of_v<T>;
const size_t elementsAlignmentGap = indexesExtent > 0 ? (std::alignment_of_v<T> - indexesExtent) : 0;
const size_t elementsOffset = indexesOffset + sizeof(size_t)*mSamplesCount + elementsAlignmentGap;
const size_t alignedSize = elementsOffset + sizeof(T)*mSamplesCount;
mData = std::aligned_alloc(alignment, alignedSize);
mQueuePrios = reinterpret_cast<RandType*>(mData);
mQueueIndexes = reinterpret_cast<size_t*>(static_cast<char*>(mData) + indexesOffset);
mElements = reinterpret_cast<T*>(static_cast<char*>(mData) + elementsOffset);
}
template<typename... Args>
void insertSorted(RandType r, Args&&... arguments)
{
RandType* it = std::upper_bound(mQueuePrios, mQueuePrios + mAllocatedElementsCount, r);
const size_t firstMovedIdx = std::distance(mQueuePrios, it);
std::move_backward(it, mQueuePrios + mAllocatedElementsCount, mQueuePrios + mAllocatedElementsCount + 1);
std::move_backward(mQueueIndexes + firstMovedIdx, mQueueIndexes + mAllocatedElementsCount, mQueueIndexes + mAllocatedElementsCount + 1);
mQueuePrios[firstMovedIdx] = r;
mQueueIndexes[firstMovedIdx] = mAllocatedElementsCount;
new (mElements + mAllocatedElementsCount) T(std::forward<Args>(arguments)...);
++mAllocatedElementsCount;
}
template<typename... Args>
void insertSortedRemoveFirst(RandType r, Args&&... arguments)
{
RandType* it = std::upper_bound(mQueuePrios + 1, mQueuePrios + mSamplesCount, r);
const size_t firstNotMovedIdx = std::distance(mQueuePrios, it);
const size_t oldElementIdx = mQueueIndexes[0];
std::move(mQueuePrios + 1, it, mQueuePrios);
std::move(mQueueIndexes + 1, mQueueIndexes + firstNotMovedIdx, mQueueIndexes);
mQueuePrios[firstNotMovedIdx - 1] = r;
mQueueIndexes[firstNotMovedIdx - 1] = oldElementIdx;
if constexpr (std::is_assignable_v<T, T> && sizeof...(Args) == 1 && std::is_same_v<std::decay_t<std::tuple_element_t<0, std::tuple<Args...>>>, T>)
{
mElements[oldElementIdx] = std::forward<Args...>(arguments...);
}
else if constexpr (std::is_move_assignable_v<T>)
{
mElements[oldElementIdx] = T(std::forward<Args>(arguments)...);
}
else
{
// support for non-moveable types
mElements[oldElementIdx].~T();
new (mElements + (oldElementIdx)) T(std::forward<Args>(arguments)...);
}
}
private:
const size_t mSamplesCount;
WeightType mWeightJumpOver {};
URBG mRand;
std::uniform_real_distribution<RandType> mUniformDist{static_cast<RandType>(0.0), static_cast<RandType>(1.0)};
size_t mAllocatedElementsCount = 0;
void* mData = nullptr;
RandType* mQueuePrios = nullptr;
size_t* mQueueIndexes = nullptr;
T* mElements = nullptr;
};
Examples with test code: https://wandbox.org/permlink/gvRzEKxibM9sv5A1