You can use a Linear Congruential Generator - this type of PRNG is considered very crude nowadays for any purpose requiring statistical randomness, but does have an advantage in your case that it can be made to repeat a specific sequence of known size. It also happens to be reversible, and this is related to your requirement of 1-to-1 mapping between sequence id and selected index id.
First, pick a couple of prime numbers, somewhere between 60% and 80% of your total size N.
N = 16_704_200
A = 9_227_917
C = 11_979_739
You can use the Prime module to find your numbers. You can even select them using a PRNG, and only store the prime numbers that you need.
Now you have these values, you can implement the LCG algorithm, which is your desired f(x)
:
def lcg x
( A * x + C ) % N
end
A quick test:
lcg( 0 )
# => 11979739
lcg( 12345 )
# => 7971104
(0..9).map { |x| lcg( x) }
# => [ 11979739, 4503456, 13731373, 6255090, 15483007,
# 8006724, 530441, 9758358, 2282075, 11509992 ]
. . . well it might be random, and if you feed back the output as next input parameter then you have an "old school" (and very low quality) PRNG. But you can just use it for index_id = lcg( sequence_id )
to fetch your objects in a random-looking sequence.
Does it map the whole set of input values to the same set of output values:
(0...N).map { |x| lcg( x ) }.uniq.count
# => 16704200
Yes!
Although you don't need it, the algorithm can be reversed. Here's how to do it:
The tricky bit is figuring out the multiplicative inverse of A
. Here is an example of how to do that I found.
AINVERSE = 9257653
# Test it:
( A * AINVERSE ) % N
# => 1
Now you have these values, you can implement the LCG algorithm forwards and backwards:
def lcg_fwd x
( A * x + C ) % N
end
def lcg_rev x
( AINVERSE * ( x - C ) ) % N
end
Test it:
lcg_fwd( 0 )
# => 11979739
lcg_rev( 11979739 )
# => 0
lcg_fwd( 12345 )
# => 7971104
lcg_rev( 7971104 )
# => 12345