【问题标题】:Time complexity of string slice字符串切片的时间复杂度
【发布时间】:2016-02-03 14:57:17
【问题描述】:

切片 Python 字符串的时间复杂度是多少?鉴于 Python 字符串是不可变的,我可以想象将它们切片为 O(1)O(n),具体取决于切片的实现方式。

我需要编写一个函数来迭代(可能很大)字符串的所有后缀。我可以通过将后缀表示为整个字符串的元组加上开始读取字符的索引来避免对字符串进行切片,但这很难看。相反,如果我像这样天真地编写我的函数:

def do_something_on_all_suffixes(big_string):
    for i in range(len(big_string)):
        suffix = big_string[i:]
        some_constant_time_operation(suffix)

...它的时间复杂度是O(n) 还是O(n<sup>2</sup>),其中nlen(big_string)

【问题讨论】:

标签: python time-complexity


【解决方案1】:

简短回答:str 切片,一般来说,复制。这意味着为每个字符串的 n 后缀执行切片的函数正在执行 O(n<sup>2</sup>) 工作。也就是说,如果您可以使用memoryviews to get zero-copy views of the original bytes data 处理类似bytes 的对象,则可以避免复制。请参阅下面的如何进行零复制切片,了解如何使其发挥作用。

长答案:(C)Python str 不要通过引用数据子集的视图进行切片。 str slicing 一共有三种操作模式:

  1. 完整的切片,例如mystr[:]:返回对完全相同的str 的引用(不仅仅是共享数据,相同的实际对象,mystr is mystr[:],因为str 是不可变的,所以这样做没有风险)
  2. 零长度切片和(取决于实现)缓存长度为 1 的切片;空字符串是单例(mystr[1:1] is mystr[2:2] is ''),长度为 1 的低序号字符串也是缓存的单例(在 CPython 3.5.0 上,看起来所有字符都可以用 latin-1 表示,即range(256) 中的 Unicode 序号, 被缓存)
  3. 所有其他切片:切片的str 在创建时被复制,此后与原始str 无关

#3 是一般规则的原因是为了避免大的str 被保存在内存中的问题,因为它的一小部分。如果你有一个 1GB 的文件,把它读入并像这样切片(是的,当你可以寻找的时候很浪费,这是为了说明):

with open(myfile) as f:
    data = f.read()[-1024:]

那么您将在内存中保存 1 GB 的数据来支持显示最后 1 KB 的视图,这是一种严重的浪费。由于切片通常很小,因此在切片上复制而不是创建视图几乎总是更快。这也意味着str 可以更简单;它需要知道它的大小,但它也不需要跟踪数据中的偏移量。

如何进行零拷贝切片

种方法可以在 Python 中执行基于视图的切片,在 Python 2 中,它将适用于 str(因为 str 在 Python 2 中类似于字节,支持 @987654322 @)。使用 Py2 str 和 Py3 bytes(以及许多其他数据类型,如 bytearrayarray.arraynumpy 数组、mmap.mmaps 等),您可以创建 memoryview that is a zero copy view of the original object 和可以在不复制数据的情况下进行切片。因此,如果您可以使用(或编码)到 Py2 str/Py3 bytes,并且您的函数可以与任意 bytes 类似的对象一起使用,那么您可以这样做:

def do_something_on_all_suffixes(big_string):
    # In Py3, may need to encode as latin-1 or the like
    remaining_suffix = memoryview(big_string)
    # Rather than explicit loop, just replace view with one shorter view
    # on each loop
    while remaining_suffix:  # Stop when we've sliced to empty view
        some_constant_time_operation(remaining_suffix)
        remaining_suffix = remaining_suffix[1:]

memoryviews 的切片确实创建了新的视图对象(它们只是超轻量级的,大小固定,与它们查看的数据量无关),只是没有任何数据,所以some_constant_time_operation 可以存储一个副本,如果需要,并且在我们稍后将其切片时不会更改。如果您需要像 Py2 str/Py3 bytes 这样的正确副本,您可以调用 .tobytes() 来获取原始的 bytes obj,或者(在 Py3 中仅出现),将其直接解码为 str从缓冲区复制,例如str(remaining_suffix[10:20], 'latin-1').

【讨论】:

  • 有趣。我想知道 Python 是否有一种巧妙的方法可以解决您为第 3 点给出的原因并获得 O(1) 切片而不会产生可怕的内存影响。我可以在 Programmers 或 CS Stack Exchange 上提出一个问题,也许......
  • @msw:他们有looked at strview as a possibility;到目前为止,什么都没有发生。早期的提议(没有专门的视图类型)是rejected because of the "keep-alive effect",其中巨大的str 由于一小部分而存在;只会考虑显式使用视图(因此保持活动效果是显式的)。
  • @MarkAmery:我刚刚添加了一个冗长的解释,说明如何(在某些情况下)使用memoryview 进行视图切片而不是复制切片。如果您在 Py2 上使用 str 或 Py3 并且可以预先编码为 bytes 对象,并且该函数可以使用类似 bytes 的对象,那就足够了。
  • @msw 你似乎认为我一个聪明的计划。我不。这是一个不平凡的问题,据我所知,在没有 [对垃圾收集性能的不良影响] 或 [无法垃圾收集已被切片的巨型字符串] 的情况下,允许 O(1) 字符串切片可能(已证明)是不可能的。我什至不确定我所说的 [对垃圾收集性能的不良影响] 的正式含义是什么,更不用说我尚未正式确定的问题是否可以解决。
【解决方案2】:

这完全取决于您的切片有多大。我将以下两个基准放在一起。第一个切片整个字符串,第二个只切片一点。与this tool 的曲线拟合给出了

# s[1:-1]
y = 0.09 x^2 + 10.66 x - 3.25

# s[1:1000]
y = -0.15 x + 17.13706461

对于高达 4MB 的字符串切片,第一个看起来非常线性。我想这确实衡量了构建第二个字符串所花费的时间。第二个是相当稳定的,虽然它太快了,可能不太稳定。

import time

def go(n):
    start = time.time()
    s = "abcd" * n
    for j in xrange(50000):

        #benchmark one
        a = s[1:-1]

        #benchmark two
        a = s[1:1000]

    end = time.time()
    return (end - start) * 1000

for n in range(1000, 100000, 5000):
    print n/1000.0, go(n)

【讨论】:

  • 好吧,0.086 x^2 + 10.66 x - 3.25 这个词对我来说不是很线性 :) 但是对于相对较小的 n 来说是线性的...
  • @YannisP。我假设(并希望!)0.086 x^2 只是曲线拟合器对随机变化过于敏感,而不是切片真的是O(n^2) 操作。切片为O(n^2) 会令人震惊!
  • 很公平。如果 Python 和其他编程语言复制字符串切片的开头和结尾的内存位置,而不是整个字符串,这对我来说也很有意义,从而为切片生成 O(1)
  • @YannisP。是的......但我想这会使垃圾收集字符串的代码比简单地通过复制实现切片更复杂,并且可能在收集时引入性能问题。这是一个复杂的问题,乍一看,我无法弄清楚是否有可能在不影响垃圾收集效率或失去垃圾收集巨型字符串的能力的情况下获得 O(1)切片。
【解决方案3】:

如果你只是将它们打包在一个对象中,那么传递索引并不是那么糟糕

from dataclasses import dataclass

@dataclass
class StrSlice:
    str: str
    start: int
    end: int

def middle(slice):
    return slice.str[(slice.start + slice.end) // 2]

def reverse(slice):
    return slice.str[slice.start : slice.end][::-1]

def length(slice):
    return slice.end - slice.start

def chars(slice):
    yield from (slice.str[i] for i in range(slice.start, slice.end)

def equals(slice1, slice2):
    if length(slice1) != length(slice2):
        return False
    return all(c1 == c2 for c1, c2 in zip(chars(slice1), chars(slice2))

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2020-07-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2018-01-06
    • 1970-01-01
    • 2013-01-21
    相关资源
    最近更新 更多