Недавно я оказался в ситуации, когда хотел протестировать метод, загрузив его случайными числами с плавающей запятой и убедившись, что он остается без ограничений. Я думал, что это будет просто, но было много подводных камней, которые мы рассмотрим в этом посте.

Начнем со спецификации: мне нужна функция, которая может генерировать случайные числа с плавающей запятой без особых свойств. Т.е. следует использовать весь диапазон, без специальных значений, а также большие положительные (например, 1e38), маленькие положительные (например, 1e-20), маленькие отрицательные (например, -1e-20) и большие отрицательные (например, -1e38) .

Во всех примерах мы предполагаем, что у нас есть доступ к этой переменной:

private Random rand = new Random();

Нет диапазона

В Java получить случайное число с плавающей запятой легко:

return rand.nextFloat();

Однако этот метод не соответствует нашей спецификации, так как генерирует числа только в диапазоне [0;1].

Положительный диапазон

Нам нужно расширить диапазон. Начнем с расширения диапазона до [0;N] для любого N ≤ Float.MAX_VALUE. Математика проста:

И это переводится прямо в код:

return rand.nextFloat() * N;

Малый диапазон

Следующее, что мы делаем, это расширяем нашу функцию, чтобы мы также могли изменять нижнее значение. У нас уже есть [0;N], мы можем воспользоваться этим, если начнем с желаемого [min;max], а затем вычтем min из обоих компонентов диапазона следующим образом:

В результате этого метода:

return rand.nextFloat() * (max - min) + min;

Это отлично работает для небольших диапазонов, но помните, что наша исходная функция не работает для всех N. Наша проблема в этом случае заключается в том, что если minотрицательно, то max — min может стать больше, чем Float.MAX_VALUE, что приводит к ∞.

Большой диапазон

Чтобы обойти эту проблему переполнения, мы можем использовать хитрость: мы можем разделить диапазон посередине, а затем использовать случайное логическое значение, чтобы определить, какую из двух половин использовать. Но нам все равно нужно быть осторожным с диапазоном, так как мы не можем складывать числа (из-за того же переполнения). Решение состоит в том, чтобы применить разделение к каждому компоненту:

Ключевой вывод заключается в том, что независимо от того, какие числа мы добавляем, если они оба разделены на два, они никогда не переполнятся. Код следует из формулы:

float midpoint = max/2 + min/2;
float half_range = max/2 - min/2;
int plus_minus = rand.nextBoolean() ? 1 : -1;
return midpoint + plus_minus * rand.nextFloat() * half_range;

Благодаря этому мы можем генерировать числа с плавающей запятой во всем диапазоне от -Float.MAX_VALUE (почему не Float.MIN_VALUE?) до Float.MAX_VALUE без ошибок. Однако, если мы посмотрим на вывод, мы увидим только числа в диапазоне от «E36» до «E38». Почему это? Разве наша функция не распределена равномерно? Как это может быть, если мы полагаемся только на равномерно распределенные генераторы случайных чисел?

Сначала проблема довольно тонкая. Мы действительно генерируем равномерно распределенное число в полном диапазоне, как видно из их графика. Каждый номер имеет одинаковую вероятность быть выбранным, но номеров от E37 до E38 в 1000 раз больше, чем от E34 до E35. Таким образом, выбирая случайным образом, мы с гораздо большей вероятностью выберем числа с более высокими значениями E.

На самом деле, мы не хотим, чтобы они были одинаковыми. Нам нужны как большие , так и маленькие (близкие к нулю) числа.

Униформа в экспоненте

В этот момент мы приходим к новому пониманию. Мы хотим, чтобы числа были равномерно распределены по битовому шаблону. Таким образом, одна стратегия, которую мы можем использовать, состоит в том, чтобы сгенерировать случайный битовый шаблон (целое число), а затем преобразовать его в число с плавающей запятой. Выполнение этого очевидным способом ((float)rand.nextInt()) не работает, потому что Java пытается сохранить значение, а не битовый шаблон. Итак, нам нужно использовать:

return Float.intBitsToFloat(rand.nextInt());

При построении графика результатов получается, что мы генерируем числа только около нуля, но это потому, что теперь нам нужно отобразить их в логарифмическом масштабе. При этом мы видим, что это прямая линия, соответствующая генерации числа с одинаковой вероятностью в каждом диапазоне [Ex;E(x+1)].

Однако есть одна последняя проблема: он также генерирует битовые шаблоны для специальных значений Float.NaN, Float.NEGATIVE_INFINITY и Float.POSITIVE_INFINITY.

Нет специальных значений

Вместо того, чтобы изучать битовые паттерны, которые мы хотим отсеять, и придумывать для этого какую-то замысловатую формулу, мы используем простую технику движков рендеринга.

В движках рендеринга мы иногда хотим выполнить равномерное распределение лучей через круглую апертуру, вместо того, чтобы придумывать формулу, как быть однородным по кругу, мы генерируем луч через квадрат, а затем, если луч выходит за пределы круга, мы просто перекрутить его.

Изменение нежелательных значений — это стандартный способ сопоставления одного домена случайных алгоритмов с другим доменом. Его просто реализовать, и он сохраняет дистрибутив:

float result;
do {
  result = Float.intBitsToFloat(rand.nextInt());
} while (result != Float.NaN
      && result != Float.POSITIVE_INFINITY
      && result != Float.NEGATIVE_INFINITY);
return result;

Единственным недостатком этого подхода является то, что мы не гарантируем, что этот алгоритм завершится. Фактически, именно по этой причине он называется полуалгоритмом. На практике, однако, он завершается и выполняется быстро, а главное быстро реализуется.

При этом, если вы найдете настоящий алгоритм для этого, который достаточно прост, чтобы поместиться в твит, пожалуйста, твитните его мне @themaxipaxi, я бы хотел его увидеть. И если вам нравится решать проблему, улучшая ее шаг за шагом, как мы только что, вам следует прочитать мою книгу о рефакторинге: