За последние 24 часа нас посетили 21827 программистов и 1014 роботов. Сейчас ищут 663 программиста ...

Префиксное дерево trie низкоуровневая структура данных в PHP

Тема в разделе "PHP для профи", создана пользователем johovich, 20 июн 2018.

  1. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Привет. Я пытаюсь сделать низкоуровневую реализацию префиксного дерева trie. Мне нужно создать компактную бинарную структуру для
    хранения 3млн. слов.

    Вот так примерно выглядит структура дерева trie:
    Код (Text):
    1.  
    2.                        root
    3.                     /   \    \
    4.                     t   a     b
    5.                     |   |     |
    6.                     h   n     y
    7.                     |   |  \  |
    8.                     e   s  y  e
    9.                  /  |   |
    10.                  i  r   w
    11.                  |  |   |
    12.                  r  e   e
    13.                         |
    14.                         r
    15.                    
    16.                    
    Мы хотим получить значение для слова "абв"
    Будем считать, что словарь у нас уже загружен в переменную $dic.
    Читаем первые 5 байт нашего словаря это корень.

    PHP:
    1. $root = subsstr($dic, 0, 5);
    2. //Там у нас такой битмап
    3. //0000 0000 0000 0000 0000 0000 0000 0000 0000 0011
    4. //Два последних бита - это буквы а и б, т.е. на первом уровне у нас есть
    5. //2 узла а и б. Поскольку длина узла постоянная 4 байта, мы можем понять где у нас начинается следующий уровень 4 * 2
    6. //5 байт корень, 4 байта узел буквы а и 4 байта узел буквы б.
    7. $level2_offset = 5 + 4 * 2;
    8. //читаем заголовок 2 уровня
    9. $level2 = substr($level2_offset, 5);
    10. //Там у нас такой битмап
    11. //0000 0000 0000 0000 0000 0000 0000 0000 0000 0010
    12. //Т.е. на уровне только 1 узел б
    13. //Определяем смещение для уровня 3
    14. $level3_offset = $level2_offset + 5 + 4;
    15. //Читаем заголовок 3 уровня
    16. $level3 = substr($dic, $level3_offset, 5);
    17. //тут битмап такой
    18. //0000 0000 0000 0000 0000 0001 0000 0001 0000 0110
    19. //тут у нас 4 узла, нам нужен узел буквы "в" он третий и впереди него есть еще 1 узел буквы "б"
    20. //значит смещение к нашему узлу у нас будет такое
    21. $node_offset = $level3_offset + 5 + 4;
    22. $node = substr($dic, $node_offset, 4);
    Это концепция, но теперь проблема с реализацией. Зная количество узлов на уровне я легко с помощью умножения могу сосчитать смещение на следующий уровень.

    1. Проблема в том, что у меня не получается легкой и быстрой функцией определить количество поднятых битов в битмапе уровня. Не считать же единицы в текстовой строке.
    2. Как узнать количество поднятых битов младше искомого. Вот мне нужен 5 бит, и если впереди него все четыре подняты, то смещение 4*4, а если 0 поднято, то смещение 0.

    Вот какой функцией можно из двоичного числа 1010 получить 2. или из числа 1000 получить 1?
     
    #1 johovich, 20 июн 2018
    Последнее редактирование модератором: 20 июн 2018
  2. igordata

    igordata Суперстар
    Команда форума Модератор

    С нами с:
    18 мар 2010
    Сообщения:
    32.410
    Симпатии:
    1.768
    зачем тебе битмапы?

    ты знаком с другими формами записи деревьев?
     
  3. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    У меня одно условие жесткое, мне надо а память 3 млн. слов плюс по 4 байта к каждому слову в виде данных, чтобы все в памяти уместилось.
    А trie просто единственная форма, которую как мне кажется я на достаточном уровне понял. Как можно без битмапа компактно сохранить данные о 40 сущностях?
    --- Добавлено ---
    Я морфологический анализатор делаю. Сейчас все работает на основе mysql хранилища, потолок скорости ~2000 слов в секунду. Хочу получить хотя бы 10тыс. слов в секунду.
     
  4. igordata

    igordata Суперстар
    Команда форума Модератор

    С нами с:
    18 мар 2010
    Сообщения:
    32.410
    Симпатии:
    1.768
    и сколько тебе нужно памяти?

    а зачем компактно?

    а память зачем экономить?
     
  5. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Я морфологический анализатор делаю. Сейчас все работает на основе
    Саму по себе память не жалко. Чем больше памяти будет потреблять, тем дольше загрузка словаря. Если будет стоять выбор - скорость или размер памяти, конечно предпочтение скорости.
    Про различные способы организации данных много написано, но там уже на относительно высоком уровне, когда уже есть некие объекты, которых могут хранить данные.

    В общем буду рад любым предложениям по организации хранилища. Вот что потребуется хранить. Слова и соотв. им индексы более общих сущностей т.н. лемм. 3млн. слов. Лемм около 400 тыс. Если ничего не уменьшать то еще самый большой по кол-ву элементов объем - список форм в которые может трансформироваться лемма, тут около 8 млн. элементов. Сейчас в мускуле я это храню в таблице из 3 полей id леммы - id слова - список id грамматических свойств. Пример 28|34 - это единственное число именительный падеж. Работает со скоростью ~2000 слов в секунду. На всяких редисах и мемкешах еще медленнее, потому как там требуется больше операций IO. Совершенно точно PHP может лучше.
    --- Добавлено ---
    Мне тут подкинули материал на тему подсчета битов, но он на C++. Для меня эта тема и так покрыта туманом, мягко говоря, а еще на английском, а еще на незнакомом языке.
    Но материал очень хороший, видно даже не понимая всех деталей.
    https://graphics.stanford.edu/~seander/bithacks.html#CountBitsSetNaive
     
  6. runcore

    runcore Старожил

    С нами с:
    12 окт 2012
    Сообщения:
    3.625
    Симпатии:
    158
    PHP:
    1. function bitCnt($b) {
    2.     $cnt=0;
    3.     while ($b!=0) {
    4.         $cnt++;
    5.         $b &= $b-1;
    6.     }
    7.     return $cnt;
    8. }
    9.  
    10. echo bitCnt(9); // 1001 => 2
     
    johovich нравится это.
  7. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Прикольно. Но достаточно долго работает и не решает вопрос подсчета битов после определенного.
    Вот что показывает тест:

    Код (Text):
    1.  
    2. <?php
    3. function bmark()
    4. {
    5.     $args = func_get_args();
    6.     $len = count($args);
    7.     if ($len < 3) {
    8.         trigger_error("At least 3 args expected. Only $len given.", 256);
    9.         return false;
    10.     }
    11.     $cnt = array_shift($args);
    12.     $fun = array_shift($args);
    13.     $start = microtime(true);
    14.     $i = 0;
    15.     $args = array_map(function($e){return var_export($e, true);},$args);
    16.     $str = "$fun(" . implode(', ', $args) . ");";
    17.     while ($i < $cnt) {
    18.         $i++;
    19.         $res = eval($str);
    20.     }
    21.     $end = microtime(true) - $start;
    22.     return $end;
    23. }
    24.  
    25. function bitCnt($b) {
    26.     $cnt=0;
    27.     while ($b!=0) {
    28.         $cnt++;
    29.         $b &= $b-1;
    30.     }
    31.     return $cnt;
    32. }
    33. function bitCnt3($b) {
    34.     for ($c = 0; $b; $b >>= 1)
    35.     {
    36.         $c += $b & 1;
    37.     }
    38.     return $c;
    39. }
    40.  
    41. function bitCnt2($b) {
    42.     return substr_count(base_convert($b,10,2),'1');
    43. }
    44. //---------------------------------------
    45. $i = 13423432;
    46. $j = 1000000;
    47.  
    48. print "base_convert($i, 10,2): ".base_convert( $i, 10,2)."\n";
    49. print "bitCnt($i): ".bitCnt($i)."\n";
    50. print "bitCnt2($i): ".bitCnt2($i)."\n";
    51. print "bitCnt3($i): ".bitCnt3($i)."\n";
    52.  
    53. print str_repeat('-',40)."\n";
    54.  
    55. $m1 = bmark($j, 'bitCnt', $i);
    56. $m2  = bmark($j, 'bitCnt2', $i);
    57. $m3  = bmark($j, 'bitCnt3', $i);
    58. print "measure time bitCnt($i) $j times: $m1\n";
    59. print "measure time bitCnt2($i) $j times: $m2\n";
    60. print "measure time bitCnt3($i) $j times: $m3\n";
    61. print "diff: ". (1-min($m1,$m2,$m3)/max($m1,$m2,$m3))*100 ."%\n";

    Код (Text):
    1.  
    2. base_convert(13423432, 10,2): 110011001101001101001000
    3. bitCnt(13423432): 11
    4. bitCnt2(13423432): 11
    5. bitCnt3(13423432): 11
    6. ----------------------------------------
    7. measure time bitCnt(13423432) 1000000 times: 2.2895648479462
    8. measure time bitCnt2(13423432) 1000000 times: 2.711480140686
    9. measure time bitCnt3(13423432) 1000000 times: 2.7266490459442
    10. diff: 15.560331289504%
    bitCnt() - это от runcore;
    bitCnt2() - мой наивный вариант;
    bitCnt3() - наивный вариант Стэнфорда.
    Как говорится: "Почувствуй разницу".

     
  8. runcore

    runcore Старожил

    С нами с:
    12 окт 2012
    Сообщения:
    3.625
    Симпатии:
    158
    в вашем варианте, быстрее будет
    PHP:
    1. return substr_count(decbin($b),'1');
     
  9. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Да, но все равно самый медленный. :)

    Кстати метод, который предложил ты это метод Питера Вегнера и впервые был опубликован аж в 1960 году.
    https://dl.acm.org/citation.cfm?doid=367236.367286&preflayout=flat
    Вот этот вариант из стэнфордских, который мне удалось повторить и который оказался быстрее. Не пойму как он работает и что надо сделать, чтобы он умел считать числа больше 32бит.
    Counting bits set, in parallel
    unsigned int v; // count bits set in this (32-bit value)
    unsigned int c; // store the total here
    static const int S[] = {1, 2, 4, 8, 16}; // Magic Binary Numbers
    static const int B[] = {0x55555555, 0x33333333, 0x0F0F0F0F, 0x00FF00FF, 0x0000FFFF};

    c = v - ((v >> 1) & B[0]);
    c = ((c >> S[1]) & B[1]) + (c & B[1]);
    c = ((c >> S[2]) + c) & B[2];
    c = ((c >> S[3]) + c) & B[3];
    c = ((c >> S[4]) + c) & B[4];

    The B array, expressed as binary, is:
    B[0] = 0x55555555 = 01010101 01010101 01010101 01010101
    B[1] = 0x33333333 = 00110011 00110011 00110011 00110011
    B[2] = 0x0F0F0F0F = 00001111 00001111 00001111 00001111
    B[3] = 0x00FF00FF = 00000000 11111111 00000000 11111111
    B[4] = 0x0000FFFF = 00000000 00000000 11111111 11111111

    We can adjust the method for larger integer sizes by continuing with the patterns for the Binary Magic Numbers, B and S. If there are k bits, then we need the arrays S and B to be ceil(lg(k)) elements long, and we must compute the same number of expressions for c as S or B are long. For a 32-bit v, 16 operations are used.

    Код (Text):
    1.  
    2. function bitCnt4($v)
    3. {
    4.     $S = array(1, 2, 4, 8, 16, 32);
    5.     $B = array(0x55555555, 0x33333333, 0x0F0F0F0F, 0x00FF00FF, 0x0000FFFF);
    6.  
    7.     $c = $v - (($v >> 1) & $B[0]);
    8.     $c = (($c >> $S[1]) & $B[1]) + ($c & $B[1]);
    9.     $c = (($c >> $S[2]) + $c) & $B[2];
    10.     $c = (($c >> $S[3]) + $c) & $B[3];
    11.     $c = (($c >> $S[4]) + $c) & $B[4];
    12.     return $c;
    13.  
    14. }

    А вот про этот пишут, что он самый быстрый. Но его воспроизвести я не сумел.
    A generalization of the best bit counting method to integers of bit-widths upto 128 (parameterized by type T) is this:

    v = v - ((v >> 1) & (T)~(T)0/3); // temp
    v = (v & (T)~(T)0/15*3) + ((v >> 2) & (T)~(T)0/15*3); // temp
    v = (v + (v >> 4)) & (T)~(T)0/255*15; // temp
    c = (T)(v * ((T)~(T)0/255)) >> (sizeof(T) - 1) * CHAR_BIT; // count

    See Ian Ashdown's nice newsgroup post for more information on counting the number of bits set (also known as sideways addition). The best bit counting method was brought to my attention on October 5, 2005 by Andrew Shapira; he found it in pages 187-188 of Software Optimization Guide for AMD Athlon™ 64 and Opteron™ Processors. Charlie Gordon suggested a way to shave off one operation from the purely parallel version on December 14, 2005, and Don Clugston trimmed three more from it on December 30, 2005. I made a typo with Don's suggestion that Eric Cole spotted on January 8, 2006. Eric later suggested the arbitrary bit-width generalization to the best method on November 17, 2006. On April 5, 2007, Al Williams observed that I had a line of dead code at the top of the first method.
    [/quote]
     
  10. Emilien

    Emilien Активный пользователь

    С нами с:
    30 июн 2016
    Сообщения:
    246
    Симпатии:
    156
    PHP:
    1.     $cnt = array_shift($args);
    2.     $fun = array_shift($args);
    3.     $start = microtime(true);
    4.     $i = 0;
    5.     $args = array_map(function($e){return var_export($e, true);},$args);
    6.     $str = "$fun(" . implode(', ', $args) . ");";
    7.     while ($i < $cnt) {
    8.         $i++;
    9.         $res = eval($str);
    10.     }
    11.     $end = microtime(true) - $start;
    Тут нужно мерить без eval
    PHP:
    1.         $cnt = array_shift($args);
    2.         $fun = array_shift($args);
    3.         $num = array_shift($args);
    4.         $i = 0;
    5.         $start = microtime(true);
    6.         while ($i < $cnt) {
    7.             $i++;
    8.             $res = $fun($num);
    9.         }
    10.         $end = microtime(true) - $start;
     
  11. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Ну для сравнения eval не мешает, понятно что искажает время выполнения, но зато так функция универсальная, у меня раньше было через call_user_func_array(), но у нее недостаток, что она не позволяет запустить функцию var_export(), а я делал сравнение скорости функций сериализации.

    https://github.com/legale/serialize-bm
     
  12. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Мне же еще надо отсчитывать от заданного бита. У оксфордских ребят не нашел ничего похожего. Тут кстати на хабре видимо по мотивам этой страницы оксфордчан переработал и дополнил.


    https://habr.com/post/276957/
    --- Добавлено ---
    Чувак скромняга запилил на хабр под видом своей статьи. Но даже не потрудился подобрать нормального русского слова для слова наивный. :) Но статья все равно хорошая.
     
  13. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Сделал функции для установки и снятия нужного бита, надеюсь завтра уже можно будет испытать.

    Код (Text):
    1.  
    2. function bit_set($bitmap, $bit){
    3.     $bitmap  |= 1 << $bit - 1 ;
    4.     return $bitmap;
    5. }
    6. function bit_clear($bitmap, $bit){
    7.     $bitmap  &= ~(1 << $bit - 1) ;
    8.     return $bitmap;
    9. }
    10. function bit_check($bitmap, $bit ){
    11.     return (bool)(($bitmap >> $bit - 1) & 1);
    12. }
     
    #13 johovich, 21 июн 2018
    Последнее редактирование: 21 июн 2018
  14. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Заработало. Только у меня недо trie получилось. Если слово представить в виде башни, где каждая буква этаж. То у меня сейчас каждый этаж 48бит. Т.е. только 1 массив русских букв и цифр. А надо чтобы было в каждой букве, которая в ходу ещё 1 массив. Т.е. если делать по моему первому плану с фиксированной длиной, чтобы быстро можно было нужную часть брать не просматриваются остальные, тогда если грубо 64*64бит = 4096 бит на каждый этаж. Всего их будет примерно 30. Т.е.122880 бит или 15360 байт, что как-то очень мало.
     
  15. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Первая версия моего низкоуровневого префиксного дерева.

    Демонстрация работы:
    Код (Text):
    1.  
    2. <?php
    3. require(dirname(__FILE__).'/Trie2.txt');
    4. $trie = new Yatrie();
    5.  
    6. $trie->trie_add('баба');
    7. $trie->trie_add('абаб');
    8. $trie->trie_add('ваба');
    9. $trie->trie_add('ааааааббв');
    10. $trie->trie_add('человек');
    11. file_put_contents('dic_demo.txt',$trie->trie); //запишем словарь
    12. $trie = new Yatrie(file_get_contents('dic_demo.txt')); //заново создадим класс, но уже с сохраненным словарем
    13. print "Ожидаем true: ";
    14. var_dump( $trie->trie_check('баба'));
    15. print "Ожидаем true: ";
    16. var_dump( $trie->trie_check('человек'));
    17. print "Ожидаем true: ";
    18. var_dump( $trie->trie_remove('баба'));
    19. print "Ожидаем false: ";
    20. var_dump( $trie->trie_check('баба'));
    21. print "Ожидаем true: ";
    22. var_dump( $trie->trie_check('ааааааббв'));
    23. print "Ожидаем true: ";
    24. var_dump( $trie->trie_check('человек'));

    Принцип хранения двоичных данных словаря:
    1 буква - слой 0
    -----------
    6 байт - битовая маска кодовой таблицы
    6 байт - битовая маска флагов "конец слова"
    -----------
    2 буква - слой 1
    -----------
    буква "а"
    6 байт + 6 байт
    буква "б"
    6 байт + 6 байт
    ...
    и т.д.
    --- Добавлено ---
    Размер словаря поражает воображение и вызывает сомнения. Словарь с максимальной длиной слов до 35 букв составляет всего 19кб. Где подвох?
     

    Вложения:

    • Trie2.txt
      Размер файла:
      9,9 КБ
      Просмотров:
      2
    #15 johovich, 27 июн 2018
    Последнее редактирование: 27 июн 2018
  16. igordata

    igordata Суперстар
    Команда форума Модератор

    С нами с:
    18 мар 2010
    Сообщения:
    32.410
    Симпатии:
    1.768
    погоняй тесты и расскажи нам, всё ли работает, как ожидалось.
    напиши юнит тесты, и погоняй
     
  17. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Скорость поиска отличная получилась. 50 тыс. слов за 0.504 секунды.
    --- Добавлено ---
    Сейчас попробую свой гигантский словарь загнать. С такими размерами можно даже отказаться от оптимизации, которую я там использую. Дело в том, что функция pack конвертирует long long число в положенные 64 бита или 8 байт, у меня такие длинные числа не получаются, потому что кодовая таблица всего 6 первых байт использует, поэтому там у меня такой огород. Сначала pack('P', $data), а потом я substr отрезаю первые 6 байт и только потом сохраняю в словарь. Соответственно на чтении приходится читать по 6 байт, а потом дописывать их нулями через str_pad(), а только потом unpack('P', $bin).
     
  18. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Словарь гигантский добавился. Размер файла все те же 20кб. :) Слова из словаря ищет. Надо бы нормальные юнит тесты сделать, но мне лень тесты сочинять и писать их. Лучший тест - боевое применение. Надо приделать это к морфологическому анализатору. Еще не придумал как сделать красиво, дерево как и положено данных хранить не умеет, надо как-то придумать как я смогу от слова переходить к его словоформам.
     
  19. igordata

    igordata Суперстар
    Команда форума Модератор

    С нами с:
    18 мар 2010
    Сообщения:
    32.410
    Симпатии:
    1.768
    молодец, чо
    где ссылка на гитхаб? =) пришло время релизить в паблик бетку-то!
     
  20. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Гит сделаю. Нашёл ошибку концептуальную, которая и была причиной того, что словарь независимо от кол-ва слов в нем занимал так немного памяти. :)
    --- Добавлено ---
    Видимо первоначальная задумка со слоями или узлами постоянной длины не выйдет. По крайней мере так просто, как это сделано сейчас.
     
  21. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Две новости - хорошая и плохая.

    1. Переделал свое дерево. Теперь как по учебнику.
    2. Скорость упала радикально, пожалуй раз в 10 упала. Наверняка можно улучшить, в общем запиливаю на гит, надеюсь кто-то заинтересуется.
     
  22. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Хорошая новость. Решил не выкладывать такую медленную версию. Ну очень долго операция добавления выполнялась.
    Прогнал профайлером и выяснилось, что самая долгая операция substr(), которая постоянно выполняется и при чтении и при записи. Поскольку довольно быстро размер бинарной строки становится несколько мегабайт, то время выполнения substr() становится запредельным.

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

    Вложения:

  23. igordata

    igordata Суперстар
    Команда форума Модератор

    С нами с:
    18 мар 2010
    Сообщения:
    32.410
    Симпатии:
    1.768
    а покаж код с сабстр медленным
    и залей на гитхаб уже, чтобы можно было всегда видеть код
     
  24. johovich

    johovich Активный пользователь

    С нами с:
    24 авг 2016
    Сообщения:
    146
    Симпатии:
    17
    Той версии не осталось. Чтобы в этой версии увидеть как может медленно работать substr() можно увеличить размер блока. Т.е. сколько узлов может хранится в 1 строке. Там переменная класса $size_block. Если ставить даже 10000 - уже видно замедление, но если поставить 100 тыс. - будет видно отлично.
     
  25. igordata

    igordata Суперстар
    Команда форума Модератор

    С нами с:
    18 мар 2010
    Сообщения:
    32.410
    Симпатии:
    1.768
    где самая медленная часть скрипта?