Skip to content

Commit 26460df

Browse files
committed
imp: Implemented the new Random.Sample method.
1 parent ba7e9cd commit 26460df

4 files changed

Lines changed: 235 additions & 2 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@byloth/core",
3-
"version": "2.2.2",
3+
"version": "2.2.3",
44
"description": "An unopinionated collection of useful functions and classes that I use widely in all my projects. 🔧",
55
"keywords": [
66
"Core",

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export const VERSION = "2.2.2";
1+
export const VERSION = "2.2.3";
22

33
export type { Constructor, Interval, Timeout, ValueOf } from "./core/types.js";
44

src/utils/random.ts

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,127 @@ export default class Random
179179
return elements[Random.Index(elements)];
180180
}
181181

182+
/**
183+
* Picks a random sample of elements from a given array without replacement.
184+
*
185+
* Uses the Fisher-Yates shuffle algorithm for uniform sampling,
186+
* which is O(count) instead of O(n log n) for a full shuffle.
187+
*
188+
* ---
189+
*
190+
* @example
191+
* ```ts
192+
* Random.Sample([1, 2, 3, 4, 5], 3); // e.g., [4, 1, 5]
193+
* ```
194+
*
195+
* ---
196+
*
197+
* @template T The type of the elements in the array.
198+
*
199+
* @param elements
200+
* The array of elements to sample from.
201+
*
202+
* It must contain at least one element. Otherwise, a {@link ValueException} will be thrown.
203+
*
204+
* @param count
205+
* The number of elements to sample.
206+
*
207+
* It must be between `0` and `elements.length`. Otherwise, a {@link ValueException} will be thrown.
208+
*
209+
* @returns An array containing the randomly sampled elements.
210+
*/
211+
public static Sample<T>(elements: readonly T[], count: number): T[];
212+
213+
/**
214+
* Picks a weighted random sample of elements from a given array without replacement.
215+
*
216+
* Uses the Efraimidis-Spirakis algorithm for weighted sampling.
217+
* Elements with higher weights have a higher probability of being selected.
218+
*
219+
* ---
220+
*
221+
* @example
222+
* ```ts
223+
* // Element "a" is 3x more likely to be picked than "b" or "c"
224+
* Random.Sample(["a", "b", "c"], 2, [3, 1, 1]);
225+
* ```
226+
*
227+
* ---
228+
*
229+
* @template T The type of the elements in the array.
230+
*
231+
* @param elements
232+
* The array of elements to sample from.
233+
*
234+
* It must contain at least one element. Otherwise, a {@link ValueException} will be thrown.
235+
*
236+
* @param count
237+
* The number of elements to sample.
238+
*
239+
* It must be between `0` and `elements.length`. Otherwise, a {@link ValueException} will be thrown.
240+
*
241+
* @param weights
242+
* The weights associated with each element.
243+
*
244+
* It must have the same length as the elements array.
245+
* All weights must be greater than zero. Otherwise, a {@link ValueException} will be thrown.
246+
*
247+
* @returns An array containing the randomly sampled elements.
248+
*/
249+
public static Sample<T>(elements: readonly T[], count: number, weights: readonly number[]): T[];
250+
public static Sample<T>(elements: readonly T[], count: number, weights?: readonly number[]): T[]
251+
{
252+
const length = elements.length;
253+
254+
if (length === 0) { throw new ValueException("You must provide at least one element."); }
255+
if (count < 0) { throw new ValueException("Count must be non-negative."); }
256+
if (count > length) { throw new ValueException("Count cannot exceed the number of elements."); }
257+
258+
if (count === 0) { return []; }
259+
260+
if (weights === undefined)
261+
{
262+
const pool = [...elements];
263+
const result: T[] = new Array(count);
264+
265+
for (let index = 0; index < count; index += 1)
266+
{
267+
const randomIndex = this.Integer(index, length);
268+
269+
result[index] = pool[randomIndex];
270+
pool[randomIndex] = pool[index];
271+
}
272+
273+
return result;
274+
}
275+
276+
if (weights.length !== length)
277+
{
278+
throw new ValueException("Weights array must have the same length as elements array.");
279+
}
280+
281+
const keys: ({ index: number, key: number })[] = new Array(length);
282+
for (let index = 0; index < length; index += 1)
283+
{
284+
if (weights[index] <= 0)
285+
{
286+
throw new ValueException(`Weight for element #${index} must be greater than zero.`);
287+
}
288+
289+
keys[index] = { index: index, key: Math.pow(Math.random(), 1 / weights[index]) };
290+
}
291+
292+
keys.sort((a, b) => b.key - a.key);
293+
294+
const result: T[] = new Array(count);
295+
for (let index = 0; index < count; index += 1)
296+
{
297+
result[index] = elements[keys[index].index];
298+
}
299+
300+
return result;
301+
}
302+
182303
private constructor() { /* ... */ }
183304

184305
public readonly [Symbol.toStringTag]: string = "Random";

tests/utils/random.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,116 @@ describe("Random", () =>
113113
expect(() => Random.Choice([])).toThrow(ValueException);
114114
});
115115
});
116+
117+
describe("Sample", () =>
118+
{
119+
describe("Without weights", () =>
120+
{
121+
it("Should return an array with the specified number of elements from the original array", () =>
122+
{
123+
const elements = [1, 2, 3, 4, 5];
124+
const sample = Random.Sample(elements, 3);
125+
126+
expect(sample).toHaveLength(3);
127+
128+
for (const element of sample)
129+
{
130+
expect(elements).toContain(element);
131+
}
132+
});
133+
134+
it("Should return unique elements (no replacement)", () =>
135+
{
136+
const elements = [1, 2, 3, 4, 5];
137+
const sample = Random.Sample(elements, 5);
138+
139+
const uniqueElements = new Set(sample);
140+
expect(uniqueElements.size).toBe(5);
141+
});
142+
it("Should return an empty array when count is 0", () =>
143+
{
144+
const elements = [1, 2, 3, 4, 5];
145+
const sample = Random.Sample(elements, 0);
146+
147+
expect(sample).toHaveLength(0);
148+
});
149+
it("Should return all elements when count equals length", () =>
150+
{
151+
const elements = [1, 2, 3, 4, 5];
152+
const sample = Random.Sample(elements, 5);
153+
154+
expect(sample).toHaveLength(5);
155+
expect(sample.sort()).toEqual(elements.sort());
156+
});
157+
158+
it("Should throw `ValueException` if the array is empty", () =>
159+
{
160+
expect(() => Random.Sample([], 1)).toThrow(ValueException);
161+
});
162+
it("Should throw `ValueException` if count is negative", () =>
163+
{
164+
expect(() => Random.Sample([1, 2, 3], -1)).toThrow(ValueException);
165+
});
166+
it("Should throw `ValueException` if count exceeds array length", () =>
167+
{
168+
expect(() => Random.Sample([1, 2, 3], 5)).toThrow(ValueException);
169+
});
170+
});
171+
172+
describe("With weights", () =>
173+
{
174+
it("Should return an array with the specified number of elements from the original array", () =>
175+
{
176+
const elements = ["a", "b", "c"];
177+
const weights = [1, 1, 1];
178+
const sample = Random.Sample(elements, 2, weights);
179+
180+
expect(sample).toHaveLength(2);
181+
182+
for (const element of sample)
183+
{
184+
expect(elements).toContain(element);
185+
}
186+
});
187+
188+
it("Should return unique elements (no replacement)", () =>
189+
{
190+
const elements = ["a", "b", "c", "d", "e"];
191+
const weights = [1, 2, 3, 4, 5];
192+
const sample = Random.Sample(elements, 5, weights);
193+
194+
const uniqueElements = new Set(sample);
195+
196+
expect(uniqueElements.size).toBe(5);
197+
});
198+
it("Should favor elements with higher weights", () =>
199+
{
200+
const elements = ["rare", "common"];
201+
const weights = [1, 100];
202+
203+
let commonFirstCount = 0;
204+
for (let i = 0; i < 1000; i += 1)
205+
{
206+
const sample = Random.Sample(elements, 1, weights);
207+
208+
if (sample[0] === "common") { commonFirstCount += 1; }
209+
}
210+
211+
expect(commonFirstCount).toBeGreaterThan(900);
212+
});
213+
214+
it("Should throw `ValueException` if weights length differs from elements length", () =>
215+
{
216+
expect(() => Random.Sample([1, 2, 3], 2, [1, 1])).toThrow(ValueException);
217+
});
218+
it("Should throw `ValueException` if any weight is zero", () =>
219+
{
220+
expect(() => Random.Sample([1, 2, 3], 2, [1, 0, 1])).toThrow(ValueException);
221+
});
222+
it("Should throw `ValueException` if any weight is negative", () =>
223+
{
224+
expect(() => Random.Sample([1, 2, 3], 2, [1, -1, 1])).toThrow(ValueException);
225+
});
226+
});
227+
});
116228
});

0 commit comments

Comments
 (0)