【发布时间】:2018-10-01 17:00:18
【问题描述】:
当我解决以下示例算法问题时,我很想了解字符串比较在 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:二进制搜索
另一种方法是将两个字符串平分以创建两个前缀子字符串。如果前缀相等,我们知道公共前缀点至少和中点一样长。否则,公共前缀点至少不大于中点。然后我们可以递归找到前缀长度。
又称二分查找。
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中的字符串比较大概是由幕后的解释器/CPU 优化的。charByChar实际上为每个访问的字符创建新字符串,这会产生大量开销。
为了验证这一点,我对比较和切片字符串的性能进行了基准测试,分别在下面标记为 cmp 和 slice。
图B
这张图显示了两件重要的事情:
- 正如预期的那样,比较和切片随长度线性增加。
- 相对于算法性能,比较和切片的成本随着长度的增加而非常缓慢地增加,图 A。请注意,这两个数字都上升到长度为 10 亿个字符的字符串。因此,比较 1 个字符 10 亿次的成本要远大于比较 10 亿个字符一次的成本。但这仍然不能回答为什么......
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 代码并找到了以下代码 two pieces 但我不确定这是发生字符串比较的地方。
问题
- 字符串比较在cpython的什么地方发生?
- 是否有 CPU 优化?是否有特殊的 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 字符的字符串的成本。
slice 是访问一个字符的成本,相当于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(s1, s2) = costOfHalfOfEachString * log_2(s1Len)
log_2是将字符串分成两半直到达到长度为1的字符串的次数。其中
costOfHalfOfEachString = slice(s1Len, s1Len / 2) + slice(s2Len, s1Len / 2) + cmp(s1Len / 2)
所以binarySearch的大O会根据
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
所以O(charByChar(s1, s2)) > O(binarySearch(s1, s2)当
2 ** prefixLen = s1Len
因此,插入上面的公式,我为图 A 重新生成了测试,但字符串的总长度为 2 ** prefixLen,预计两种算法的性能大致相等。
但是,显然charByChar 的性能要好得多。经过一番反复试验,s1Len = 200 * prefixLen时两种算法的性能大致相当
为什么关系是 200 倍?
【问题讨论】:
-
比较整个字符串发生在 C 级别,这将使其更快。不知道这是否解释了你的整个观察。
-
要回答这个问题,我们必须确切地确定 CPython 如何解释
smaller[:mid] == bigger[:mid]以及它如何解释smaller[p] != bigger[p]。 -
你的时间复杂度分析太草率了。但即使是更严格的问题也不会得出答案。
-
了解您的输入将有助于更好地理解情节——这两个字符串通常是非常相似还是非常不同? ASCII 还是 Unicode?内存中的字符串是否已经存在(可能是刚刚创建并由 CPU 缓存)还是在文件中?内存映射文件?我的直觉告诉我最好的解决方案是开始比较 len 1, 4, 16, ... 直到比较失败,然后切换到对更短的拆分稍微加权的二分搜索。实现将使用 MemoryView 或缓冲区。
-
反编译 Python 字节码不会告诉你那么多,Python 字节码运算符是相当高级的构造。要回答您的子问题字符串比较发生在 cpython 的哪个位置?,请参阅Runtime of python's if substring in string
标签: python x86 interpreter cpython strncmp