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

Не проще обстоят дела и с программным представлением таких объектов.


Привет, Хабр! Меня зовут Андрей Коваленко (Keva), я занимаюсь лингвистическими задачами и делаю поисковые движки. В МойОфис возглавляю разработку корпоративного полнотекстового поиска.

Существует две основные проблемы поиска численных значений в текстовых массивах (и массивах текстов).

Первая из них — многообразие способов представления одного и того же значения в тексте. Для одного и того же числа это может быть и 3600, и 3,600, и 3.6*10^3, и просто "три тысячи шестьсот". Решается это аккуратным прописыванием способов выделения таких объектов из текста.

Вторая проблема связана с поиском числовых интервалов.

Напомню, что, как правило, поисковые системы работают с так называемым обратным индексом, отличной метафорой которого будет алфавитный указатель в конце книги: все использованные термины приведены в нормальной форме и упорядочены лексикографически — проще говоря, по алфавиту, и после каждого указан номер страницы, на которой этот термин встречается. Разница только в том, что такая координатная информация в поисковиках, как правило, значительно подробнее. Например, корпоративный поиск МойОфис (рабочее название — baalbek), для каждого появления слова в документе хранит, кроме порядкового номера, ещё и его грамматическую форму и привязку к разметке.

Поиск начинается с выбора нужного ключа в оглавлении индекса (алфавитном указателе), далее задача сводится к обработке обычного однословного запроса.

И вот этот выбор ключа или ключей для некоторого искомого интервала — например, "значение в поле 'Цена' не более 160" или "рабочий объём не менее 2500" — и становится сложной задачей.

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

Очевидно, что текстовое представление таким свойством не обладает: "2" будет больше "10". Стандартная форма также не годится: "7e3" меньше "8.1e-3". Нужна запись вида "P|M", где P — показатель степени, | — разделитель, а M — мантисса.

Примеры из предыдущего абзаца будут записаны как "+0|2", "+1|1", "+3|7" и "-3|81". Здесь все сравнения становятся уже правильными. Выборка ключей для поиска цены из примера выше будет выглядеть как проход по численным к��ючам индекса до тех пор, пока результат их сравнения со строкой "+2|16" меньше либо равен нулю.

Задача решена? Ну, почти. Привыкнуть к такому формату чисел ещё как-то можно. Если бы не было отрицательных чисел. Потому что -0.7 больше, чем -0.8. И даже если записать их в форме "P|M", то получатся "--1|7" и "--1|8", и результат лексикографического сравнения будет обратным численному, потому что '8' > '7'.

Что ж, заменим для отрицательных чисел значения на их дополнения до базы системы исчисления, в нашем случае до 10: "--9|3" и "--9|2". Ура? Первое больше второго? Ага. Ну и заодно, наконец, достигнута полная нечитаемость.

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

Для удобства вычислений возьмём базу 16 (деление на 16 — это битовый сдвиг вправо на 4). Сначала приведём число к стандартному виду по основанию 16: целая часть — число от 1 до 15, показатель шестнадцатеричной степени, и дробная часть.

Значение показателя степени запишем по принципу кодирования, используемому в utf-8, зерезервировав максимум 6 бит. 16^126 степени представляется достаточно большим числом, больше, чем гугол (10^100, что, в свою очередь, больше, чем количество атомов в обозримой части Вселенной — 10^80). Целая часть — ещё 4 бита, всего полтора байта. Дробную часть, то есть остаток от вычитания мантиссы в степени из исходного числа, сбросим последовательно четырёхбитными фрагментами. Размер ключа выравниваем на байт. И не забываем всё инвертировать для отрицательных чисел. Код будет ниже.

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

Значение

Представление

1

c0 10

7

c0 70

16

c1 10

255

c1 ff

256

c2 10

1048576 = 16^5

c5 10

16^5 + 1

c5 10 00 01

0.1

be 19 99 99 99 99 99 9a

0.03125 = 1/32

bd 80

Ниже приведён код (C++) для подобного кодирования чисел с плавающей запятой. Для целых, очевидно, его можно записать несколько проще, не используя арифметических функций вроде pow.

/*
* Store - писатель данных по квартетам
*/
template <size_t length, char chfill>
class Store
{
  char buffer[length];
  char* bufend = buffer;
  bool bupper = true;


public: // write
  void store( unsigned value )
  {
    if ( !(bupper = !bupper) ) *bufend = (value << 4) | (chfill & 0x0f);
      else { *bufend = (*bufend & 0xf0) | value; ++bufend; }
  }
  template <class ... Values>
  void store( unsigned value, Values ... items )
  {
    return store( value ), store( items... );
  }

public:
  auto size() const -> size_t { return bufend - buffer + (bupper ? 0 : 1); }
  auto data() const -> const void* { return buffer; }

};


template <size_t length, char chfill>
struct negative_flush: public Store<length, chfill>
{
  auto put_mantis( int xpower, int mantis ) -> negative_flush&
  {
    if ( xpower >= 0 ) this->store( 0x3 - ((xpower >> 4) & 0x3), 0xf - (xpower & 0x0f) );
      else this->store( 0x4 | (-xpower >> 4) & 0x03, (-xpower) & 0x0f );
    return put_nibble( mantis );
  }
  auto put_nibble( unsigned u ) -> negative_flush&
  {
    return this->store( 0xf - (u & 0xf) ), *this;
  }
  template <class ... Nibbles>
  auto put_nibble( unsigned u, Nibbles... n ) -> negative_flush&
  {
    return put_nibble( u ).put_nibble( n... );
  }
};


template <size_t length, char chfill>
struct positive_flush: public Store<length, chfill>
{
  auto put_mantis( int xpower, int mantis ) -> positive_flush&
  {
    if ( xpower >= 0 ) this->store( 0x8 | (mantis != 0 ? 0x4 : 0) | ((xpower >> 4) & 0x3), xpower & 0x0f );
      else this->store( 0x8 | (0x3 - ((-xpower >> 4) & 0x3)), (0xf - (-xpower) & 0x0f) );

    return this->store( mantis ), *this;
  }
  auto put_nibble( unsigned u ) -> positive_flush&
  {
    return this->store( u ), *this;
  }
  template <class ... Nibbles>
  auto put_nibble( unsigned u, Nibbles... n ) -> positive_flush&
  {
    return put_nibble( u ).put_nibble( n... );
  }
};


template <class Store, class Value>
static auto make_double_key( Store& flush, const Value& value ) -> const Store&
{
  auto divide = value;
  auto mantis = (int)value;
  auto xpower = (int)0;

  static_assert( std::is_floating_point<Value>::value,
    "make_double_key( store, value ) would be called for floating point values!" );

// убедиться в корректности вызова функции - только положительные
  assert( value >= 0.0 );

// отсеять 0.0
// выделить целую часть и степень
  if ( value >= 1.0 ) while ( divide >= 16.0 ) { mantis = (divide /= 16.0); ++xpower; }
    else
  if ( value != 0.0 ) while ( divide < 1.0 ) { mantis = (divide *= 16.0); --xpower; }
    else
  return flush.put_mantis( 0, 0 );

  flush.put_mantis( xpower, mantis );

  if ( mantis != 0 )
  {
    auto  dleast = fmod( value, mantis * pow( 16, xpower ) );
    auto  dpower = pow( 16, xpower - 1 );

    for ( ; dleast != 0.0; dpower /= 16.0 )
    {
      auto  ndigit = (int)(dleast / dpower);

      flush.put_nibble( ndigit );
      dleast -= ndigit * dpower;
    }
  }

  return flush;
}

***

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