Система Orphus
Версия для печати

Как найти миллион

Сравнение алгоритмов поиска множества подстрок

Автор: Антонов Егор Сергеевич
Опубликовано: 04.06.2011
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Истоки задачи
Краткое описание алгоритмов
Базовый класс
Алгоритм Ахо-Корасик
Алгоритм Рабина-Карпа
Сравнение алгоритмов
Время инициализации
Потребляемая память
Скорость работы
Выводы
Список литературы

Введение

В настоящее время имеется немалое количество алгоритмов, способных искать подстроку в тексте. На момент написания статьи русская Википедия содержит упоминание 14 различных алгоритмов. Тем не менее, почти все из них рассчитаны на поиск одной подстроки. Про поиск множества подстрок имеется уже в разы меньше упоминаний. И, увы, мне не удалось найти ни одного упоминания использования какого-либо алгоритма на очень большом (более миллиона) количестве строк. Эта статья призвана восполнить данный пробел.

Истоки задачи

В последнее время идея Web 3.0, или Semantic Web, стала довольно популярной. Многие компании вставляют в свой интернет-контент гиперссылки, отсылающие пользователя в Википедию или во внутренние ресурсы компании. Хорошим примером может послужить новостной текст, где можно кликнуть на любую упомянутую персону и получить ее досье. Помимо универсальной Википедии в интернете в настоящий момент существует около сотни открытых онтологий – баз данных, содержащих графы связей между объектами реальности.

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

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

Подобная программа может иметь такую структуру:


Чтобы найти все упоминания, нам необходимо иметь список названий географических объектов. В моем случае источником послужила онтология Freebase [1]. В ней содержится около миллиона географических объектов (страны, города, континенты, деревни, здания, регионы и т.п.). Каждый объект имеет имя (Name), псевдонимы (Alias) и ссылки на Википедию. Последние тривиальным алгоритмом преобразуются в «человеческие» строки (например, ссылки на Москву: Moscow_city, Big_Potato). Далее все три типа названий я буду называть синонимами. Всего на миллион объектов пришлось ~4млн. синонимов (таблица в БД занимает примерно 290Мб). В качестве текстового материала я использовал корпус новостей Reuters [2].

Краткое описание алгоритмов

В данной статье я буду сравнивать два алгоритма поиска множества подстрок: алгоритм Ахо‑Корасик [3] и модифицированный алгоритм Рабина-Карпа [4]. Полное описание этих алгоритмов нетрудно найти в Интернете, поэтому я ограничусь краткими описаниями и приведу код, который использовал в дальнейшем.

Базовый класс

От алгоритма поиска подстрок требуется две вещи. Во-первых, он должен уметь инициализироваться. На вход мы принимаем некоторое множество строк, логику его обработки реализует дочерний класс. Технически это выглядит так:

        private
        bool _initialized;
privatereadonlyobject _initializationLocker = newobject();

//Объект с интерфейсом IStringLoader отвечает за поставку строкpublicvoid Initialize(IStringLoader stringLoader, int? maxCount)
{
  if (_initialized)
    thrownew InvalidOperationException("Already initialized.");
  lock (_initializationLocker)
  {
    if (_initialized)
      thrownew InvalidOperationException("Already initialized.");

    foreach (string str in  stringLoader.LoadStrings(maxCount))
      ProcessString(str);

    OnStringsLoaded();

    _initialized = true;
  }
}

//Метод, отвечающий за логику обработки строкиprotectedabstractvoid ProcessString(string str);

//Виртуальный метод, позволяющий осуществить необходимые преобразования//над уже загруженными строкамиprotectedvirtualvoid OnStringsLoaded() {}

Во-вторых, алгоритм поиска должен, собственно, уметь искать подстроки в тексте:

        //Метод возвращает список позиций (пар <index, length>),
        //где была найдена какая-либо строка
        public IEnumerable<Position> Search(string text)
{
  if (text == null)
    thrownew ArgumentException("text");

  if (!_initialized)
    thrownew InvalidOperationException(
      "TextSearcher is not initialized");

  //Логику поиска реализует дочерний классreturn InnerSearch(text);
}

protectedabstract IEnumerable<Position> InnerSearch(string text);

Теперь рассмотрим реализацию самих алгоритмов.

Алгоритм Ахо-Корасик

Алгоритм Ахо-Корасик основан на построении конечного автомата следующего вида:


У каждого узла конечного автомата есть два типа переходов: Goto- и Failure-функции. Функция Goto (сплошная стрелка) обеспечивает переход от одного состояния к другому при наличии определенной буквы. Если в массиве переходов нет подходящей буквы, то мы переходим на то состояние, которое указано Failure-функцией (пунктирная стрелка).

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

Соответственно мы имеем узел автомата:

        private
        class Node
{
  // Хеш-таблица переходов в дочерние состояния.// Ключом является буква.public Dictionary<char, Node> Children { get; set; }

  // Узел, куда мы перейдем при ошибкеpublic Node FailureNode { get; set; }

  // Длины результирующих строк, которые лежат в этом узлеpublic List<int> ResultLengths { get; set; }

  publicoverridestring ToString()
  {
    return"Node: " + GetHashCode();
  }
}

И сам конечный автомат:

        private Automaton _automaton = new Automaton();

privateclass Automaton
{
  public Automaton()
  {
    _root.FailureNode = _root;
  }

  //Корневой узелprivate Node _root = new Node();

  public Node Root
  {
    get { return _root; }
  }

  public Node Goto(Node fromNode, char ch)
  {
    Node outNode;
    if (fromNode.Children != null 
      && fromNode.Children.TryGetValue(ch, out outNode)) 
      return outNode;

    if (fromNode == _root)
      return _root;
    returnnull;
  }

  public Node Fail(Node fromNode)
  {
    return fromNode.FailureNode ?? _root;
  }

  public IEnumerable<int> GetNodeResults(Node node)
  {
    return node.ResultLengths ?? new List<int>(0);
  }
}

Инициализация алгоритма происходит в два этапа. На первом этапе создается конечный автомат с деревом переходов по функции Goto. Для каждого загружаемого синонима выполняется следующая процедура:

        protected
        override
        void ProcessString(string str)
{
  //Начинаем с корневого узла
  Node currentNode = _automaton.Root;

  for (int i = 0; i < str.Length; i++)
  {
    char currentChar = str[i];


     if (currentNode.Children == null)
      currentNode.Children = new Dictionary<char, Node>();

    //Для каждого символа в строке пытаемся найти//уже существующую ветку автоматаif (currentNode.Children.ContainsKey(currentChar))
    {
      currentNode = currentNode.Children[currentChar];

      //Если текущий символ последний,//то добавляем ему в результаты длину синонимаif (i == str.Length - 1)
      {
        if (currentNode.ResultLengths == null)
          currentNode.ResultLengths = new List<int>(1);
        currentNode.ResultLengths.Add(str.Length);
      }

      continue;
    }

    //Если ветки не существует, создаем ееvar nextNode = new Node();
    currentNode.Children.Add(currentChar, nextNode);
    if (i == str.Length - 1)
    {
      if (nextNode.ResultLengths == null)
        nextNode.ResultLengths = new List<int>(1);
      nextNode.ResultLengths.Add(str.Length);
    }


    currentNode = nextNode;
  }
}

На втором этапе мы вычисляем функцию ошибки для каждого узла:

        public
        void ProcessNodeFailures()
{
  Node root = _automaton.Root;

  //С помощью очереди проходимся по всем узламvar queue = new Queue<Node>();
  foreach (var node in root.Children.Values)
    queue.Enqueue(node);

  if (root.Children == null)
    return;

  while (queue.Count > 0)
  {
    Node node = queue.Dequeue();

    if (node.Children == null)
      continue;

    foreach (KeyValuePair<char, Node> kv in node.Children)
    {
      //Узел
      Node child = kv.Value;
      //Символ перехода из одного узла в другойchar ch = kv.Key;

      queue.Enqueue(child);
      
      //Идем к корню по "ошибочному" пути,//пока не наткнемся на возможность перехода//по символу, который привел нас в текущий узел
      Node failure = _automaton.Fail(child);
      while (_automaton.Goto(failure, ch) == null)
        failure = _automaton.Fail(failure);

      //Узел по переходу будет Failure-узлом для текущего
      Node failureDestination = _automaton.Goto(failure, ch);

      child.FailureNode = failureDestination;

      if (failure.ResultLengths != null
        && failure.ResultLengths.Count > 0)
      {
        child.ResultLengths = new List<int>(0);

        //Добавляем к результатам текущего узла//результаты Failure-узла
        child.ResultLengths.AddRange(failure.ResultLengths);
      }

    }
  }
}

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

Искать подстроки в тексте можно следующим образом:

        protected
        override IEnumerable<Position> InnerSearch(string text)
{
  var results = new List<Position>();

  //Начинаем с корня
  Node currentNode = _automaton.Root;
  for (int i = 0; i < text.Length; i++)
  {
    Node nextNode;
    do
    {
      //Пытаемся продвинуться по автомату
      nextNode = _automaton.Goto(currentNode, text[i]);

      //Если не получается, идем в Failure-узелif (nextNode == null)
        currentNode = _automaton.Fail(currentNode);
    //В конце концов мы придем либо в нормальный узел, либо в корень
    } while (nextNode == null);

    currentNode = nextNode;

    IEnumerable<int> nodeResults =
      _automaton.GetNodeResults(currentNode);

    //Добавляем результаты текущего узла к общему спискуif (nodeResults != null)
    {
      results.AddRange(
        nodeResults.Select(
          length => new Position(i - length + 1, length)));
    }
  }

  return results;
}

Алгоритм Рабина-Карпа

Алгоритм Рабина-Карпа основан на предварительном хешировании искомых подстрок. Всего в памяти должно храниться две хеш-таблицы: в первой хранится соответствие между префиксом фиксированной длины и множеством подстрок с этим префиксом; во второй хранятся сами строки. При поиске в тексте алгоритм проходит по тексту «окнами», по размерам равными длине префикса, и ищет соответствия в таблице префиксов. Если соответствие найдено, нужно вырезать из строки все строки определенной длины и посмотреть, присутствуют ли они во второй таблице. Рассмотрим код, реализующий этот алгоритм.

        //конструктор
        public RabinKarpSearcher(int prefixLength = 2)
{
  _prefixLength = prefixLength;
}

private readonly int _prefixLength;


//Ключ словаря – префикс, значение – пара из множества длин подстрок//и хеш-таблицы самих подстрокprivate Dictionary<string, StringSet> _keywords =
  new Dictionary<string, StringSet>();

//В этой таблице хранятся синонимы, длина которых меньше длины префиксаprivate HashSet<string> _shortWords = new HashSet<string>();

//При инициализации для каждого синонима выполняется эта функцияprotectedoverridevoid ProcessString(string str)
{
  //Строка короткаяif (str.Length < _prefixLength)
  {
    _shortWords.Add(str);
    return;
  }

  string prefix = str.Substring(0, _prefixLength);
  
  //Добавляем префикс в хеш-таблицу префиксовif (!_keywords.ContainsKey(prefix))
    _keywords.Add(prefix, new StringSet());

  StringSet set = _keywords[prefix];

  //Добавляем длину строки и саму строку в соответствие префиксуif (!set.LengthList.Contains(str.Length))
    set.LengthList.Add(str.Length);
  set.Strings.Add(str);
}

privateclass StringSet
{
  //Список длин строк – для каждой длины нужно будет сделать  //отдельную операцию вырезания подстроки и поиска в таблице Stringspublic List<int> LengthList = new List<int>(15);

  //Хеш-таблица синонимовpublic HashSet<string> Strings = new HashSet<string>();
}

Как вы могли заметить, помимо таблицы с префиксами в классе присутствует таблица коротких слов (_shortWords), в которую мы кладем слова по длине меньше префикса. Это позволит нам искать строки любой длины.

Поиск осуществляется с помощью следующей функции:

        protected
        override IEnumerable<Position> InnerSearch(string text)
{
  var searchResults = new List<Position>();

  for (int i = 0; i < text.Length; i++)
  {
    //Ищем строки длиной меньше префиксаfor (int k = 1; k < _prefixLength && i + k <= text.Length; k++)
    {
      string pretender = text.Substring(i, k);
      if (_shortWords.Contains(pretender))
        searchResults.Add(new Position(i, k));
    }
    
    if (i + _prefixLength > text.Length) continue;

    string currentPrefix = text.Substring(i, _prefixLength);

    StringSet set;
    //проверяем, присутствует ли префикс в таблицеif (!_keywords.TryGetValue(currentPrefix, outset))
      continue;

    //для каждой длины stringLength вырезаем подстроку pretender    //и проверяем, находится ли эта подстрока в хеш-таблицеforeach (int stringLength inset.LengthList)
    {
      if (i + stringLength > text.Length) continue;
      string pretender = text.Substring(i, stringLength);
      if (set.Strings.Contains(pretender))
        searchResults.Add(new Position(i, stringLength));
    }
  }
  return searchResults;
}

Нетрудно заметить, что реализация алгоритма Рабина-Карпа компактней, чем Ахо‑Корасик. С моей точки зрения этот алгоритм также гораздо проще для понимания.

Сравнение алгоритмов

Перейдем к сравнению алгоритмов. Сравнивать будем по трем параметрам:

  1. Время инициализации.
  2. Скорость.
  3. Потребляемая память.

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

Время инициализации

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

Тем не менее, существуют некоторые сценарии, когда очень большое время инициализации болезненно (примером может послужить ленивая инициализация веб-сервиса при требовании к максимальному времени отклика).

Для сравнения алгоритмов по времени инициализации я использовал следующий код:

        //Количество загружаемых строк
        private
        static List<int> _cutOffs = new List<int> {
  1, 10, 50, 100, 200, 400, 800, 1200, 2000, 4000, 8000, 16000, 32000, 64000,
  100000, 150000, 200000, 300000, 400000, 500000, 600000, 700000, 800000,
  900000, 1000000
};


publicstaticvoid Main(string[] args)
{
  foreach (int stringCount in _cutOffs)
  {
    GC.Collect();

    //Объект, ответственный за загрузку строк
    IStringLoader stringLoader = new OntologyStringLoader();

    //Создаем нужный объект поиска
    ITextSearcher searcher = new AhoCorasickSearcherNaive();

    var initializationTimer = new Stopwatch();
    initializationTimer.Start();
    //Инициализация
    searcher.Initialize(stringLoader, stringCount);
    initializationTimer.Stop();

    Console.WriteLine(
      "Initialization time (" + stringCount + "): " 
      + initializationTimer.Elapsed);
  }
}

Алгоритм Ахо-Корасик имеет ярко выраженную полиномиальную зависимость от количества искомых строк. Функция возрастает не очень быстро, однако на больших объемах инициализация занимает значительное время.


Алгоритм Рабина-Карпа на большом объеме строк инициализируется в разы быстрее.


Нетрудно заметить, что алгоритм Рабина-Карпа зависит от количества искомых строк линейно. Соответственно, чем больше будет строк, тем выгодней использовать алгоритм Рабина-Карпа.

Потребляемая память

Теперь сравним алгоритмы по количеству потребляемой памяти. При малом количестве искомых строк (несколько тысяч) этот пункт не очень актуален. Однако когда речь идет о сотнях тысяч строк, количество потребляемой памяти становится ощутимым.

Тестировать алгоритмы будем с помощью следующего кода:

        //Список отсечек, на которых будем замерять память
        private
        static List<int> _cutOffs = new List<int> {
  1, 1000, 2000, 4000, 8000, 16000, 32000, 64000,
  100000, 150000, 200000, 300000, 400000, 500000, 600000, 700000, 800000, 
900000, 1000000
};

publicstaticvoid Main(string[] args)
{
  //Список пар <количество_строк, потребляемая_память>   //В него мы будем складывать результатыvar memoryConsumptionList = new List<Tuple<int, decimal>>();

  foreach (int stringCount in _cutOffs)
  {
    //Принудительно очищаем память перед очередной инициализацией
    GC.Collect(3, GCCollectionMode.Forced);

    //Объект, отвечающий за загрузку строк
    IStringLoader stringLoader = new OntologyStringLoader();

    //Инициализируем нужный алгоритм
    ITextSearcher searcher = new AhoCorasickSearcherNaive();

    //Получаем текущее значение потребляемой памятиdecimal memoryBeforeInitialize = MemoryMeasurer.MemoryConsumption;

    Console.WriteLine("Memory consumption before initialization: " 
      + memoryBeforeInitialize.ToString("0.000") 
      + "Mb.");

    searcher.Initialize(stringLoader, stringCount);

    decimal memoryAfterInitialize = MemoryMeasurer.MemoryConsumption;

    //Считаем разницу в потребляемой памятиdecimal memoryDifference = 
memoryAfterInitialize - memoryBeforeInitialize;

    Console.WriteLine("Memory consumption after initialization: " 
      + memoryAfterInitialize.ToString("0.000") 
      + "Mb.");
    Console.WriteLine("Total difference: " 
      + memoryDifference.ToString("0.000") 
      + "Mb.");

    memoryConsumptionList.Add(
      new Tuple<int, decimal>(searcher.LoadedStringsCount, 
memoryDifference));
  }

   //Пишем в файл итоговые результаты
  LogToFile("Memory consumption points: \r\n" + memoryConsumptionPoints);
}

Ниже приведен график зависимости потребляемой памяти от количества синонимов для обоих алгоритмов:


Оба алгоритма требуют примерно линейно зависящее количество памяти. Однако если алгоритм Рабина-Карпа выглядит нормально, то алгоритм Ахо-Корасик на миллионе строк требует огромное количество памяти (2,25 Гб). Можно попробовать его оптимизировать.

Столь большое количество необходимой памяти объясняется простым фактом: для поиска миллиона подстрок алгоритму требуется около 10 млн. узлов. Чтобы уменьшить количество потребляемой памяти, можно пойти двумя путями: во-первых, можно уменьшить «вес» одного узла; во-вторых, можно попытаться уменьшить количество узлов.

В наивной реализации Ахо-Корасик узел выглядит так:

        private
        class Node
{
  public Node()
  {
    Children = new Dictionary<char, Node>();
    ResultLengths = new List<int>(0);
  }

  // Хеш-таблица переходов в дочерние состояния.// Ключом является буква.public Dictionary<char, Node> Children { get; privateset; }

  // Узел, куда мы перейдем при ошибкеpublic Node FailureNode { get; set; }

  // Длины результирующих строк, которые лежат в этом узлеpublic List<int> ResultLengths { get; privateset; }

  publicoverridestring ToString()
  {
    return"Node: " + GetHashCode();
  }
}

Наша тактика оптимизации тривиальна: нужно вынести из узла всё, что можно вынести. Выносить будем в класс автомата, где мы создадим соответствующую хеш-таблицу, где в ключе будет узел, а в значении – то, что мы вынесли из узла. Нетрудно заметить, что узел состоит из трех частей: карты переходов, ссылки на «ошибочный» узел и списка результатов. Рассмотрим все три варианта.

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

        private Dictionary<Node, Dictionary<char, Node>> _gotoDict =
  new Dictionary<Node, Dictionary<char, Node>>();

public Dictionary<Node, Dictionary<char, Node>> GotoDict
{
  get { return _gotoDict; }
}

Это, безусловно, замедлит поиск (вместо одного поиска по хеш-таблице придется делать два).

Аналогично можно поступить и с остальными полями класса Node:

        private Dictionary<Node, Node> _failureDict =
  new Dictionary<Node, Node>();

public Dictionary<Node, Node> FailureDict
{
  get { return _failureDict; }
}

private Dictionary<Node, List<int>> _resultsDict =
  new Dictionary<Node, List<int>>();

public Dictionary<Node, List<int>> ResultsDict
{
  get { return _resultsDict; }
}

Нетрудно заметить, что эти изменения независимы друг от друга и могут дать кумулятивный эффект. Соответственно, мы можем измерить эффект по-отдельности:


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

Второй способ оптимизировать алгоритм – это сократить число состояний конечного автомата. Стандартным способом минимизации конечного автомата является объединение эквивалентных узлов (см. [5]). Определим отношение эквивалентности для нашего случая. Два узла эквивалентны, если выполняются все следующие условия:

  1. У них нет переходов по общей букве, ведущих в разные узлы.
  2. У них одинаковый «ошибочный» узел.
  3. Словарь результатов обоих узлов одинаков.
  4. Они оба не являются «ошибочным» узлом для других узлов.

Скорость работы

Наконец, сравним алгоритмы по скорости работы. В качестве текстового материала для теста я взял корпус Reuters [2]. Он состоит из нескольких сотен тысяч новостных статей (для тестов я ограничился 100000 статей). Сравнивать алгоритмы будем с помощью следующего кода:

        //количество текстов, которое будем обрабатывать
        private
        const
        int TextCount = 100000;

//количество искомых строкprivatestaticreadonlyint? StringCount = 1000000;

staticvoid Main(string[] args)
{
  //Объект, ответственный за загрузку строк
  IStringLoader stringLoader = new OntologyStringLoader();

  ITextSearcher searcher = new RabinKarpSearcher(2);
    //new AhoCorasickSearcherNaive();

  searcher.Initialize(stringLoader, StringCount);

  var timer = new Stopwatch();

  //В этот список складываем пары <длина_текста, время_обработки>var textLengths = new List<Tuple<int, double>>(TextCount);

  //Объект, ответственный за поставку текстов
  ITextManager textManager = new DbTextManager();

  for (int i = 0; i < TextCount; i++)
  {
    string text = textManager.GetNextText();

    int previousElapsed = timer.ElapsedMilliseconds;

    timer.Start();
    searcher.Search(text);
    timer.Stop();

    int textLength = text.Length;

    textLengths.Add(
      new Tuple<int, double>(textLength,
        timer.ElapsedMilliseconds - previousElapsed));

    //Принудительно очищаем память каждые 2000 текстов,    //чтобы минимизировать погрешностиif (i % 2000 == 0)
      GC.Collect();
  }
  //Выводим средний показатель (количество текстов в минуту)
  Console.WriteLine("Total text per minute: " +
    TextCount / timer.Elapsed.TotalMinutes);
   
   //Логируем в файл пары <длина_текста, время_обработки>   //для составления статистики и построения графика
  LogToFile(textLengths);
}

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


Алгоритм Ахо-Корасик показал высокую скорость работы (93370 т/м). Как видно из графика, среднее время обработки примерно линейно зависит от длины текста.

Алгоритм Рабина-Карпа показывает более медленный результат. При первом запуске (с длиной префикса=2) скорость работы оказалась примерно в 12 раз медленней, чем Ахо-Корасик (6940 т/м). Профайлер показал, что основное время тратится на поиск в хеш-таблице синонимов. Это означает, что на каждый префикс имеется слишком много уникальных длин синонимов, т.к. на каждую уникальную длину синонима для префикса необходимо произвести одну операцию поиска. Слабость алгоритма заключается в том, что если имеется хотя бы один синоним длины N, то мы должны вырезать строку длиной N и произвести поиск в хеш-таблице. В нашем случае распределение длин синонимов было таким:


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

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

Однако здесь появляется другое ограничение: помимо поиска строк по префиксу мы должны искать все строки короче префикса (они лежат в хеш-таблице _shortWords). Иными словами, количество операций поиска на символ текста складывается из длины префикса (P) и количества уникальных длин с данным префиксом (U). Число P фиксировано и выполняется для каждого символа текста. Число U зависит от конкретного префикса (большое число уникальных длин имеется только у префиксов, характерных для начала слова). Таким образом, нужно соблюдать баланс между увеличением уникальности префикса и его длиной. В нашем случае оптимальным оказалось число 5 (47060 т/м). На графике ниже представлены три варианта алгоритма с различной длиной префикса:


Вариант алгоритма с длиной префикса 8 работает уже вдвое медленней оптимального (24960 т/м).

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

Алгоритм

Скорость (текстов в минуту)

Рабин-Карп (длина префикса=2)

6940

Рабин-Карп (длина префикса=5)

47060

Рабин-Карп (длина префикса=8)

24960

Ахо-Корасик

93370

Выводы

Общие результаты сравнения таковы: алгоритм Ахо-Корасик долго инициализируется, потребляет большое количество оперативной памяти, но имеет большую скорость работы. Алгоритм Рабина-Карпа ощутимо выигрывает по скорости инициализации и памяти. Скорость Рабина-Карпа поддается регулировке (с помощью изменения длины префикса), что позволяет достичь довольно неплохих результатов (всего вдвое медленней, чем Ахо-Корасик).

В случае с поиском географических объектов мне пришлось отказаться от алгоритма Ахо-Корасик. Различия в скорости хоть и велики, но не критичны, но для 3 млн. строк Ахо-Корасик требует около 7 Гб. памяти. Учитывая постоянное пополнение Freebase и, соответственно, рост количества синонимов, эта цифра может стать слишком большой даже для серверных машин.

Тем не менее, оба алгоритма занимают свою нишу и могут быть полезны разработчикам.

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

  1. Freebase: http://freebase.com
  2. Reuters Corpora: http://trec.nist.gov/data/reuters/reuters.html
  3. Aho, Alfred V. ; Margaret J. Corasick (June 1975). "Efficient string matching: An aid to bibliographic search". Communications of the ACM 18 (6): 333–340.
  4. Karp, Richard M. ; Rabin, Michael O. (March 1987). Efficient randomized pattern-matching algorithms .
  5. Шишков Д. Б.; Минимизация конечных автоматов // Kybernetika, выпуск 4 (т. 8), 1972.


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