Сложение большого множества чисел, существенно отличающихся по величине

Материал из MachineLearning.

Перейти к: навигация, поиск

Содержание

Введение

Сложение – это, наверное, первая операция, которую вы научились выполнять при изучении математики, но это, возможно, одна из последних операций, которую вы научитесь надежно выполнять на компьютере.

Evan Manning


Задача вычисления суммы большого множества чисел с плавающей точкой часто возникает в практических приложениях. Самые простейшие примеры — численное интегрирование и вычисление среднего значения. Однако, какой бы тривиальной на первый взгляд эта задача не казалась, она не проста, если мы хотим надежно вычислить сумму.

Ниже будем предполагать, что числа, сумму которых нужно найти, записаны в массиве flt_arr. Длина массива ARR_SIZE достаточно большое число.

Тривиальный метод суммирования

Вначале рассмотрим простейший метод вычисления суммы чисел с плавающей точкой.

/* Simple Summation */

float f_add(float * flt_arr)
{
  long i;
  float sum = 0.0;
  for (i = 0; i < ARR_SIZE; i++)
    sum += flt_arr[i];
  return sum;
}

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

Приведем простейший пример</br> Числа в компьютере представляются приближенно, что обусловлено конечностью разрядной сетки (см. Международный стандарт представления чисел с плавающей точкой в ЭВМ). Допустим, что порядок мантиссы не позволяет различить цифры порядка в 10 биллионов. Мы хотим сложить числа:

1.0 + 1.0e10 + −1.0e10

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

(1.0 + 1.0e10) + −1.0e10
  = 1.0e10 + -1.0e10
  = 0.0

но

1.0 + (1.0e10 + −1.0e10)
  = 1.0 + 0.0
  = 1.0

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

Можно посоветовать, складывать числа с большей точностью. Если, к примеру, необходимо сложить числа типа float, то результат лучше представлять в виде типа double, аналогично для изначального double — лучше использовать long double.

/* Extended Precision method */

float fx_add(float * flt_arr)
{
  long i;
  double sum = 0.0;

  for (i = 0; i < ARR_SIZE; i++)
    sum += flt_arr[i];
  return sum;
}

Если есть такая возможность, то, конечно, лучше ей воспользоваться. Однако рассмотим другие методы повышения точности суммирования чисел.

Упорядоченное суммирование

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

/* Sorted Summation */

float fs_add(float * flt_arr)
{
  long i;
  float lcl_arr[ARR_SIZE], sum = 0.0;

  for (i = 0; i < ARR_SIZE; i++)
    lcl_arr[i] = flt_arr[i];
  qsort(lcl_arr, ARR_SIZE, sizeof(float),
        flt_abs_compare);
  for (i = 0; i < ARR_SIZE; i++)
    sum += lcl_arr[i];

  return sum;
}

Сначала создаем локальную копию массива flt_arr — lcl_arr. Сортируем полученный массив с помощью qsort. Затем суммируем элементы отсортированного массива. Но результаты по-прежнему остаются неудовлетворительными. Это происходит из-за того, что сумма очень быстро становится значительно больше по порядку, чем прибавляемые числа. В результате этого, точность вычисления повышается незначительно.


На самом деле, особенности суммируемых чисел сильно влияют на сортировку. Лучший случай, когда сумма принимает значения около нуля, а распределение примерно симметричное. Это условие способствует тому, что сумма всегда имеет примерно тот же порядок, что и суммируемые числа. Этот случай рассмотрен на примерах. Однако на практике он встречается довольно редко. Иногда сортировку чисел невозможно предугадать. Упорядоченное суммирование всегда дает один и тот же результат на одних и тех же множеств чисел, даже если числа следуют в разном порядке. Но это не совсем верно в случае, например, если использовать функцию сравнения:

/* Simple comparison */

int flt_abs_compare(const void * a, const void * b)
{
  float fa = fabs(*(float *)a);
  float fb = fabs(*(float *)b);
  if (fa == fb)
    return 0;
  else if (fa < fb)
    return -1;
  else
    return 1;
}

В этом случае число и обратная (по знаку) ему величина считаются одинаковыми. А это означает, что они могут оказаться в любом порядке следования в упорядоченной последовательности. Когда же нам нужна уверенность, необходимо использовать функцию сравнения вида:

/* Tie-breaking comparison */

int flt_abs_compare(const void * a,
                    const void * b)
{
  float va = *(float *)a;
  float vb = *(float *)b;
  float fa = fabs(va);
  float fb = fabs(vb);
  if (fa < fb)
    return -1;
  else if (fa > fb)
    return 1;
  else if (va < vb)
    return -1;
  else if (va > vb)
    return 1;
  else
    return 0;
}

Данная функция добивается полной предсказуемости следования элементов.


Попарное суммирование

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

/* Pairwise Summation */

float fp_add(float * flt_arr)
{
  long i, j, limit;
  float sum[ARR_SIZE / 2 + 1];

  if (ARR_SIZE == 2)
    return flt_arr[0] + flt_arr[1];
  else if (ARR_SIZE == 1)
    return flt_arr[0];

  for (i = j = 0; i < ARR_SIZE / 2; i++, j += 2)
    sum[i] = flt_arr[j] + flt_arr[j + 1];
  if (ARR_SIZE & 1)
    sum[ARR_SIZE / 2] = flt_arr[ARR_SIZE - 1];
  limit = (ARR_SIZE + 1) / 2;

  while (limit > 2) {
    for (i = j = 0; i < limit / 2; i++, j += 2)
      sum[i] = sum[j] + sum[j + 1];

    if (limit & 1)
      sum[limit / 2] = sum[limit - 1];
    limit = (limit + 1) / 2;
  }

  return sum[0] + sum[1];
}

Данный алгоритм работает быстрее, чем упорядоченное суммирование, и при этом дает более аккуратный результат. Однако, нет предела совершенству…


Метод суммирования Кохена

D англоязычной литературе данный метод известен под названием Kahan summation algorithm или compensated summation.

Если перед нами стоит задача нахождения суммы большого множества чисел, то для достижения данной цели лучше всего использовать метод, известный под названием Kahan Summation. В данном методе коррекция промежуточной суммы производится на протяжении всей работы алгоритма. Ниже приведена реализация данного метода.

/* Kahan Summation */

float fk_add(float * flt_arr)
{
  long i;
  float sum, correction, corrected_next_term, new_sum;

  sum = flt_arr[0];
  correction = 0.0;
  for (i = 1; i < ARR_SIZE; i++)
  {
    corrected_next_term = flt_arr[i] - correction;
/* sum большое, corrected_next_term маленькое, т.о. младшие биты corrected_next_term теряются */
    new_sum = sum + corrected_next_term;
    correction = (new_sum - sum) - corrected_next_term;
    sum = new_sum;
  }
  return sum;
}

Каждый член вначале корректируется согласно ошибке, накопившаяся на предыдущих этапах. Затем новая сумма вычисляется путем суммирования скорректированного члена и промежуточной суммы. После этого вычисляется поправочный член как разница между изменением в сумме и прибавленного скорректированного члена.

Алгебраически, кажется, данная процедура не делает ничего особенного. Подставим выражение

new_sum = sum + corrected_next_term;

в строчку

correction = (new_sum — sum) — corrected_next_term;

Получим:

correction = ((sum + corrected_next_term) — sum) — corrected_next_term;

что можно упростить как:

correction = corrected_next_term — corrected_next_term = 0;

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

Для иллюстрации сказанного рассмотрим пример десятичной арифметики чисел с плавающей точкой, в которой значащими являются 6 знаков после запятой. Допустим, сумма sum достигла на каком-то шаге значения 100000 и нужно прибавить следующее число, равное 3.14159, correction равно нулю. На данной итерации будут произведены следующие высисления:

corrected_next_term  = 3.14159 — 0
  new_sum = 100000 + 3.14159
    = 100003                      /* Много цифр теряется */
  correction = (100003 - 100000) - 3.14159  /* Должно вычисляться так, как написано! */ 
    = 3.00000 - 3.14159       
    = -0.141590                  
sum = 100003

Значение суммы настолько велико, что в новое значение суммы вносят вклад лишь цифры числа большого порядка. Однако, на следующем этапе работы алгоритма, предположим, что слагаемое flt_arr[i] принимает значение 2.71828, но теперь correction ненулевое.

corrected_next_term  = 2.71828 — −0.141590
    = 2.85987                   
  new_sum = 100003 + 2.85987            
    = 100006                      
  correction = (100006 - 100003) - 2.85987
    = 3.00000 - 2.85987          
    = .140130                    
sum = 100006

Сумма вычисляется в процессе выполнения двух операций — sum хранит текущую сумму, а correction накапливает части чисел, не вошедших в sum .

Список литературы


См. также

Личные инструменты