Мне стало любопытно понять внутренности того, как сравнение строк работает на python, когда я решал следующую проблему с примером алгоритма:
Учитывая две строки, верните длину самого длинного общего префикса
Решение 1: charByChar
Моя интуиция сказала мне, что оптимальным решением было бы начать с одного курсора в начале обоих слов и итерации вперед, пока префиксы больше не совпадут. Что-то вроде
def charByChar(smaller, bigger):
assert len(smaller) <= len(bigger)
for p in range(len(smaller)):
if smaller[p] != bigger[p]:
return p
return len(smaller)
Чтобы упростить код, функция предполагает, что длина первой строки smaller
всегда меньше или равна длине второй строки, bigger
.
Решение 2: binarySearch
Другим методом является разделение двух строк на две подстроки. Если префиксы равны, мы знаем, что общая точка префикса по крайней мере равна средней точке. В противном случае общая точка префикса по крайней мере не больше, чем средняя точка. Затем мы можем найти длину префикса.
Ака бинарный поиск.
def binarySearch(smaller, bigger):
assert len(smaller) <= len(bigger)
lo = 0
hi = len(smaller)
# binary search for prefix
while lo < hi:
# +1 for even lengths
mid = ((hi - lo + 1) // 2) + lo
if smaller[:mid] == bigger[:mid]:
# prefixes equal
lo = mid
else:
# prefixes not equal
hi = mid - 1
return lo
Сначала я предположил, что binarySearch
будет медленнее, потому что сравнение строк будет сравнивать все символы несколько раз, а не только символы префикса, как в charByChar
.
Сюрпризно, binarySearch
оказался намного быстрее после некоторого предварительного бенчмаркинга.
Рисунок A
Выше показано, как влияет производительность, поскольку длина префикса увеличивается. Длина суффикса остается постоянной на 50 символов.
Этот график показывает две вещи:
- Как и ожидалось, оба алгоритма выполняют линейно хуже по мере увеличения длины префикса.
- Производительность
charByChar
ухудшается с гораздо большей скоростью.
Почему binarySearch
намного лучше? Я думаю, это потому, что
- Сравнение строк в
binarySearch
предположительно оптимизировано интерпретатором/процессором за кулисами.charByChar
фактически создает новые строки для каждого доступного символа, и это создает значительные накладные расходы.
Чтобы проверить это, я сравнивал производительность сравнения и нарезки строки, помеченной cmp
и slice
соответственно ниже.
Рисунок B
Этот график показывает две важные вещи:
- Как и ожидалось, сравнение и нарезка линейно увеличиваются с длиной.
- Стоимость сравнения и нарезки увеличивается очень медленно с длиной относительно производительности алгоритма. Рисунок A. Обратите внимание, что обе цифры соответствуют строкам длиной 1 миллиард символов. Поэтому стоимость сравнения 1 символа 1 миллиард раз намного больше, чем сравнение 1 миллиарда символов. Но это все еще не отвечает, почему...
CPython
В попытке выяснить, как интерпретатор cpython оптимизирует сравнение строк, я сгенерировал байтовый код для следующей функции.
In [9]: def slice_cmp(a, b): return a[0] == b[0]
In [10]: dis.dis(slice_cmp)
0 LOAD_FAST 0 (a)
2 LOAD_CONST 1 (0)
4 BINARY_SUBSCR
6 LOAD_FAST 1 (b)
8 LOAD_CONST 1 (0)
10 BINARY_SUBSCR
12 COMPARE_OP 2 (==)
14 RETURN_VALUE
Я ткнул код cpython и нашел следующее два фрагментов кода, но я не уверен, что это происходит при сравнении строк.
Вопрос
- Где в cpython происходит сравнение строк?
- Есть ли оптимизация ЦП? Есть ли специальная инструкция x86, которая выполняет сравнение строк? Как я могу узнать, какие команды сборки создаются cpython? Вы можете предположить, что я использую последнюю версию python3, Intel Core i5, OS X 10.11.6.
- Почему сравнение длинной строки намного быстрее, чем сравнение каждого из символов?
Бонусный вопрос: когда charByChar более совершенен?
Если префикс достаточно мал по сравнению с остатком длины строки, в какой-то момент стоимость создания подстрок в charByChar
становится меньше стоимости сравнения подстрок в binarySearch
.
Чтобы описать это отношение, я проанализировал анализ времени выполнения.
Анализ времени выполнения
Чтобы упростить приведенные ниже уравнения, предположим, что smaller
и bigger
имеют одинаковый размер, и я буду называть их как s1
и s2
.
charByChar
charByChar(s1, s2) = costOfOneChar * prefixLen
Где
costOfOneChar = cmp(1) + slice(s1Len, 1) + slice(s2Len, 1)
Где cmp(1)
- это стоимость сравнения двух строк длины 1 char.
slice
- это стоимость доступа к char, эквивалент charAt(i)
. Python имеет неизменяемые строки, и доступ к char фактически создает новую строку длины 1. slice(string_len, slice_len)
- это сокращение длины строки string_len
до фрагмента размера slice_len
.
Итак,
charByChar(s1, s2) = O((cmp(1) + slice(s1Len, 1)) * prefixLen)
BinarySearch
binarySearch(s1, s2) = costOfHalfOfEachString * log_2(s1Len)
log_2
- это количество раз, чтобы разделить строки пополам, до тех пор, пока не получится строка длиной 1. Где
costOfHalfOfEachString = slice(s1Len, s1Len / 2) + slice(s2Len, s1Len / 2) + cmp(s1Len / 2)
Таким образом, большой-O of binarySearch
будет расти согласно
binarySearch(s1, s2) = O((slice(s2Len, s1Len) + cmp(s1Len)) * log_2(s1Len))
Основываясь на нашем предыдущем анализе стоимости
Если мы предположим, что costOfHalfOfEachString
является приблизительно costOfComparingOneChar
, тогда мы можем называть их как x
.
charByChar(s1, s2) = O(x * prefixLen)
binarySearch(s1, s2) = O(x * log_2(s1Len))
Если приравнять их
O(charByChar(s1, s2)) = O(binarySearch(s1, s2))
x * prefixLen = x * log_2(s1Len)
prefixLen = log_2(s1Len)
2 ** prefixLen = s1Len
So O(charByChar(s1, s2)) > O(binarySearch(s1, s2)
, когда
2 ** prefixLen = s1Len
Таким образом, включение вышеприведенной формулы я привело к регенерации тестов на рисунке A, но с строками общей длины 2 ** prefixLen
, ожидающими, что производительность двух алгоритмов будет примерно одинаковой.
Однако, очевидно, charByChar
работает намного лучше. С небольшим количеством проб и ошибок производительность двух алгоритмов примерно одинакова, когда s1Len = 200 * prefixLen
Почему отношение 200x?