As you did not provide any estimates for the parameters in your task: here some approach for small k.
The basic idea: acceptance-rejection sampling with a full-restart if some partial-solution is infeasible (according to the sorted-characteristic). Of course the probability of not restarting is decreasing with k! (compare with bogosort). There is no extra memory used.
The following code compares this approach with the original, a wrong naive one, and a wrong one based on the other (now deleted) answer (which had an upvote). The code is pretty much garbage and just for demo-purporses:
Code:
import itertools
import random
from time import perf_counter
from collections import deque
n = 30
k = 4
its = 100000 # monte-carlo analysis -> will take some time with these values!
sample_space = itertools.combinations_with_replacement(range(n), k)
flat_map = {} # for easier counting / analysis
for ind, i in enumerate(sample_space):
flat_map[i] = ind
def a(n, k):
""" Original slow approach """
return random.choice(list(itertools.combinations_with_replacement(range(n), k)))
def b(n, k):
""" Naive attempt -> non-uniform """
chosen = [random.choice(list(range(n))) for i in range(k)]
return tuple(sorted(chosen))
def c(population, k):
""" jdehesa solution (hopefully not broken by my modifications) """
choices = [i for i in range(population) for _ in range(k)]
return tuple([i for i in sorted(random.sample(choices, k))])
def d(n, k):
""" Acceptance-rejection sampling with restart using python's list """
chosen = []
while True:
if len(chosen) == k:
return tuple(chosen)
else:
new_element = random.randint(0, n-1)
if len(chosen) > 0:
if new_element >= chosen[-1]:
chosen.append(new_element)
else:
chosen = []
else:
chosen.append(new_element)
return chosen
def d2(n, k):
""" Acceptance-rejection sampling with restart using deque """
chosen = deque()
while True:
if len(chosen) == k:
return tuple(chosen)
else:
new_element = random.randint(0, n-1)
if len(chosen) > 0:
if new_element >= chosen[-1]:
chosen.append(new_element)
else:
chosen = []
else:
chosen.append(new_element)
return chosen
start = perf_counter()
a_result = [flat_map[a(n, k)] for i in range(its)]
print('s: ', perf_counter() - start)
start = perf_counter()
b_result = [flat_map[b(n, k)] for i in range(its)]
print('s: ', perf_counter() - start)
start = perf_counter()
c_result = [flat_map[c(n, k)] for i in range(its)]
print('s: ', perf_counter() - start)
start = perf_counter()
d_result = [flat_map[d(n, k)] for i in range(its)]
print('s: ', perf_counter() - start)
start = perf_counter()
d2_result = [flat_map[d2(n, k)] for i in range(its)]
print('s: ', perf_counter() - start)
import matplotlib.pyplot as plt
f, arr = plt.subplots(5, sharex=True, sharey=True)
arr[0].hist(a_result, label='original')
arr[1].hist(b_result, label='naive (non-uniform)')
arr[2].hist(c_result, label='jdehesa (non-uniform)')
arr[3].hist(d_result, label='Acceptance-rejection restart -> list')
arr[4].hist(d2_result, label='Acceptance-rejection restart -> deque')
for i in range(5):
arr[i].legend()
plt.show()
Output:
s: 546.1523445801055
s: 1.272424016672062
s: 3.058098026099742
s: 12.665841491509354
s: 13.14264200539003

Yes, i put those labels in some sub-optimal position.
Alternative timings:
Only comparing original with deque-based AR-sampling. Also only relative-timings matter here.
n=100, k=3:
s: 22.6498539618067
s: 0.038274503506364965
n=100, k=4:
s: 7.047153613584993
s: 0.0009363589822841689
Remark: one might argue that the original approach should re-use the sample-space (which will shift those benchmarks) if memory allows this storage at all.