С другой стороны, само занятие микрооптимизацией, при поддержке, конечно же, тестов и контроле инвариантов, на нервной и пищеварительной системах сказывается исключительно благотворно. Код находится под контролем, ничего не ломается, ничего не меняется, только замеры производительности становятся все лучше и лучше. Лучше и лучше. Душеполезнейшее занятие.
В этой заметке я бы хотел поделиться замечаниями по микрооптимизации алгоритма поиска подстроки в строке использующего карту вероятностей. Все описанное касается только CPython, в других реализациях все может быть немного по-другому.
Возьмем для начала алгоритм в наивном исполнении:
def substr(text, pattern): test_plan = [] imap = {} for c, i in zip(pattern, range(len(pattern))): test_plan += [(stat_map[c], i, c)] if not c in imap: imap[c] = [i] else: imap[c] = imap[c] + [i] test_plan.sort() for i in range(0, len(text), len(pattern)): c = text[i] if c in imap: for h in imap[c]: found = True for (_, ti, tc) in test_plan: if i - h + ti >= len(text) or text[i - h + ti] != tc: found = False break if found: return i-h return -1
Алгоритм работает так: для паттерна составляется тест-план, включающий каждый символ и его позицию. Тест-план сортируется по вероятности вхождения символа в текст таким образом, что наименее вероятный символ будет проверяться в первую очередь. Также для паттерна составляется карта вхождений символа. Каждому символу ставится в соотвествие список его вхождений. Например, для строки «Hello world!» тест-план и карта будут соответственно такими:
[(11, '!'), (0, 'H'), (6, 'w'), (2, 'l'), (3, 'l'), (9, 'l'), (4, 'o'), (7, 'o'), (10, 'd'), (8, 'r'), (1, 'e'), (5, ' ')]
{'!': [11], ' ': [5], 'e': [1], 'd': [10], 'H': [0], 'l': [2, 3, 9], 'o': [4, 7], 'r': [8], 'w': [6]}
Далее для текста проверяется каждый n символ, где n — длина паттерна. Если символ содержится в карте, то для каждой его позиции в паттерне выполняется проверка остальных символов согласно тест-плану до первого расхождения. Если ни одного расхождения нет, все хорошо — мы нашли первое вхождение.
Сделаем замер производительности: время поиска нескольких строк в значительном объеме текста. Для этих целей в CPython есть профайлер. Для профилирования кода достаточно запустить его с модулем profile: python -m profile your_code.py
.
Детали замера несущественны. Мы просто подгоним размер текста, чтобы цифры замера были одновременно и достаточно большими для сравнения, и достаточно маленькими, чтобы не ждать их долго каждую итерацию. В моем случае первое число: 2.35 с.
Теперь попробуем оптимизировать. Первое и очевидное: избавиться от проверок в карте. Вместо if a in map мы можем воспользоваться конструкцией map.get(a, []), которая возвращает значение по умолчанию, если в карте нет нужного значения. Делаем замер, и… 3.0. Питон, в отличие от C++ STL, использует хеш-таблицы для организации словарей, они же мапы, карты и ассоциативные массивы. Проверка на содержание выполняется в среднем за константное время, и время это меньше, чем время создания нового списка в текущем контексте.
Сразу скажу, что замена карты списком, по образцу плюсовой же замены ассоциативного массива на вектор, тоже плодов не даст. В плюсах мы уходим от логарифмической сложности к константной, здесь же нам уходить не от чего.
Хорошо, но все-таки лишняя проверка в цикле выглядит плохо. Ее можно убрать, если убедиться, что все возможные символы заведомо лежат в карте, но возвращают пустой список. Пробуем… 2.3. Чуть лучше, но незначительно. Зато теперь с используется в одном месте и мы можем ее не объявлять, а сразу брать в этом месте text[i]. Код теперь выглядит так:
def substr(text, pattern): test_plan = [] imap = {} imap = {} for i in range(128): imap[chr(i)] = [] for c, i in zip(pattern, range(len(pattern))): test_plan += [(stat_map[c], i, c)] imap[c] += [i] test_plan.sort() for i in range(0, len(text), len(pattern)): for h in imap[text[i]]: found = True for (_, ti, tc) in test_plan: ni = i - h + ti if ni >= len(text) or text[ni] != tc: found = False break if found: return i-h return -1
Проверяем. 2.25. Объявление переменных, стало быть, дорогое удовольствие. Попробуем заодно избавиться от ni. 2.26. Разница невелика, но стало хуже. Мы обменяли одну локальную переменную на два сложения и они примерно друг друга стоят.
К вопросу о лишних переменных. В кортеже тест-плана первое значение — вероятность вхождения — используется только для сортировки. Мы же ее получаем в неиспользуемую переменную _. Кстати, это не эрланговский вайлд-карт, а вполне легитимное имя переменной. Просто оно хорошо смотрится в роли бесполезного значения. «Почистим» же тест-план, чтобы лишнего не брать. 2.18. Хорошо.
Далее к циклу. Конечно, рефлексы сишника подсказывают, что создавать список и ходить по нему — занятие бесполезное. Цикл можно переписать так, чтобы избавиться от «рендж».
i = 0 len_text = len(text) len_pattern = len(pattern) while i < len_text: for h in imap[text[i]]: found = True for (ti, tc) in test_plan: ni = i - h + ti if ni >= len(text) or text[ni] != tc: found = False break if found: return i-h i += len_pattern
И… 2.55. Это не работает. Питон очень эффективен со списками. Зато вот мысль предрассчитать длину текста — здравая.
if ni >= len_text or text[ni] != tc:
1.88. Конечно же, len не считает длину строки перебором, как в чистом Си. Это довольно эффективная функция, но все-таки функция. Лишний вызов функции дороже, чем доступ к переменной.
И к слову о проверках. А насколько часто у нас будет превышаться длина строки? Не более чем (n^2)/2 раз, если я правильно понимаю. Так как мы считаем n длиной паттерна, а не текста, это не очень много. В Питоне or работает до первой правды. То есть если слева стоит более часто выполнимое условие, вся проверка работает эффективнее. Поменяем местами условия. 1.77. И кстати, раз уж теперь второе условие считается не так уж часто, обмен переменой на плюсы может сработать.
Осталась, правда, одна некрасивость. Переменная found. Если бы в языке был goto
или break
с параметрами, можно было бы без нее обойтись. В Питоне goto
нет, но есть else
. Да, это неочевидно, но else
работает с циклами и выполняется, когда цикл проходит без единого break
. Замеряем. 1.55. И код выглядит так.
def substr(text, pattern): test_plan = [] imap = {} imap = {} for i in range(128): imap[chr(i)] = [] for c, i in zip(pattern, range(len(pattern))): test_plan += [(stat_map[c], i, c)] imap[c] += [i] test_plan.sort() test_plan = [(ti, tc) for (_, ti, tc) in test_plan] text_len = len(text) for i in range(0, len(text), len(pattern)): for h in imap[text[i]]: for (ti, tc) in test_plan: ni = i - h + ti if text[ni] != tc or ni >= text_len: break else: return i-h return -1
Очень нехорошо, что теперь код работает только с 128 символами. Этого мало даже для английского языка. Рано или поздно кто-то захочет написать «naïve», имея на то полное право, и все поломается. Стоит ли того десяток микросекунд на тесте? Едва ли.
Выводы
Микрооптимизация как явление — это и хорошо, и плохо. Это замечательное упражнение, позволяющее узнать глубже язык, его структуры данных, его особенности и неочевидности. Но для живого рабочего кода, исключая исключения, повышение производительности на проценты, а не на порядки — плохой фокус. Увлекшись оптимизацией очень просто забыть то, для чего мы делаем то, что делаем.
ссылка на оригинал статьи http://habrahabr.ru/post/191710/
Добавить комментарий