【问题标题】:Filter huge array optimising performance过滤巨大的阵列优化性能
【发布时间】:2020-03-26 09:10:32
【问题描述】:

我有一个包含超过 300k 个对象的数组,我在 UITableView 中显示了这些对象。使用 filter 方法按前缀匹配过滤时,第一次搜索需要 60 多秒!之后,搜索速度要快得多,大约需要 1 秒,我仍然想进一步改进。

对象如下所示:

struct Book {
    let id: Int
    let title: String
    let author: String
    let summary: String
}

这就是我目前的过滤方式:

filteredBooks = books.filter { $0.title.lowercased().hasPrefix(prefix.lowercased()) }

数据来自我使用Codable 解码的 JSON 文件(这也比我想要的要长一点)。我试图在没有数据库或任何类型的框架实现或延迟加载元素的情况下实现这一目标。我希望能够以不错的性能显示UITableView 和实时filter 中的300k 个对象。

我在 Google 上搜索了一下,找到了 Binary Search 和 Trie 搜索算法,但不知道如何实现它们才能将它们与 Codable 和我的 struct 一起使用。此外,也许用另一种数据类型替换 struct 会有所帮助,但也不确定是哪一种。

【问题讨论】:

  • 300k 个对象大约是您应该开始使用数据库而不是大型 json 文件的时间。
  • @Alexander-ReinstateMonica 我知道,你是对的!但我已经知道如何使用数据库。这是一个测试项目,通过可能使用某种搜索算法来学习如何优化这样的极端情况:)
  • Book 的步长为 56 字节,因此 300K 对象大约需要 16.8 MB。没关系,但是如果您要制作大量大型子集的副本,那将是一个问题。使用 trie 会起作用,但您需要将对象作为引用类型(类的对象,而不是结构)。在您的情况下,您可以构建一个前缀树,用于快速前缀查询。然而,这将是一个糟糕的用户体验,因为它对拼写错误、常见的拼写错误等完全不灵活。你不能搜索电影的中间(例如你不能搜索The Fellowship of the Ring,没有开头部分)
  • @Alexander-ReinstateMonica 感谢您的评论。我已经看过Trie 结构,但我无法使用我的数据结构进行正确的实现。关于用户体验,这个不用太担心,我现在想关注prefix match :)
  • 这不就是我们在这里已经讨论过的关于半稳定分区的讨论吗? stackoverflow.com/questions/26173565/… 试试removeAll(where:) 或编写自己的分区算法。

标签: ios arrays swift search


【解决方案1】:

因为我喜欢挑战,所以我把一些东西放在一起。

它基本上是一棵树,树的每一层都包含一个标题前缀加上完全匹配的元素加上一个低级树列表,每个树都有相同的前缀加上一个字母表:

extension String {
    subscript (i: Int) -> String {
        let start = index(startIndex, offsetBy: i)
        return String(self[start...start])
    }
}

struct Book {
    let id: Int
    let title: String
    let author: String
    let summary: String
}

class PrefixSearchable <Element> {
    let prefix: String
    var elements = [Element]()
    var subNodes = [String:PrefixSearchable]()
    let searchExtractor : (Element) -> String

    private init(prefix: String, searchExtractor:@escaping(Element) -> String) {
        self.prefix = prefix
        self.searchExtractor = searchExtractor
    }

    convenience init(_ searchExtractor:@escaping(Element) -> String) {
        self.init(prefix: "", searchExtractor: searchExtractor)
    }

    func add(_ element : Element) {
        self.add(element, search: searchExtractor(element))
    }

    private func add(_ element : Element, search : String) {
        if search == prefix {
            elements.append(element)
        } else {
            let next = search[prefix.count]
            if let sub = subNodes[next] {
                sub.add(element, search: search)
            } else {
                subNodes[next] = PrefixSearchable(prefix: prefix + next, searchExtractor: searchExtractor)
                subNodes[next]!.add(element, search: search)
            }
        }
    }

    func elementsWithChildren() -> [Element] {
        var ele = [Element]()
        for (_, sub) in subNodes {
            ele.append(contentsOf: sub.elementsWithChildren())
        }
        return ele + elements
    }

    func search(search : String) -> [Element] {
        print(prefix)
        if search.count == prefix.count {
            return elementsWithChildren()
        } else {
            let next = search[prefix.count]
            if let sub = subNodes[next] {
                return sub.search(search: search)
            } else {
                return []
            }
        }
    }
}

let searchable : PrefixSearchable<Book> = PrefixSearchable({ $0.title.lowercased() })
searchable.add(Book(id: 1, title: "title", author: "", summary: ""))
searchable.add(Book(id: 2, title: "tille", author: "", summary: ""))

print(searchable.search(search: "ti")) // both books
print(searchable.search(search: "title")) // just one book
print(searchable.search(search: "xxx")) // no books

它可能在可读性方面可以改进(我的 swift 现在很生疏)。我不能保证它适用于所有极端情况。
您可能必须添加一个“搜索限制”,如果没有找到完全匹配,则停止递归返回所有子项。

【讨论】:

  • 感谢您的回答!鉴于代码结构,我不太确定如何使用Codable 为searchable 提供所需的数据,并添加此PrefixSearchable 作为UITableView 的数据源...我是否遗漏了一些非常基本的东西也许?
  • @anonymous 你没有,你只是像现在一样读取所有数据,然后为每个解析的对象调用 add 。您可以更改 json 文件以支持树本身,以便更轻松地填充树,但这只是此数据结构之上的基本糖。
  • 非常感谢!问题最终是booksTableView.reloadSections([0], with: .automatic) 以一种非常奇怪的方式行事。我想尝试在第一次疯狂的大搜索中为所有单元格设置动画。 reloadData() 工作速度非常快!我想知道为什么当我让细胞出队时会出现这种行为......
【解决方案2】:

在您开始更改任何内容之前,请运行 Instruments 并确定您的瓶颈在哪里。追错事很容易。

我很怀疑那个 60 年代的数字。这是一个巨大的时间,并表明您实际上是在重复进行此过滤。我打赌你每个可见行或类似的东西都做一次。这可以解释为什么它第二次要快得多。 300k是很多,但实际上并没有那么多。计算机速度很快,一分钟就是很长的时间。

也就是说,您现有的过滤器存在一些明显的问题。它重新计算prefix.lowercased() 300k 次,这是不必要的。你可以把它拉出来:

let lowerPrefix = prefix.lowercased()
filteredBooks = books.filter { $0.title.lowercased().hasPrefix(lowerPrefix) }

同样,您为每次搜索重新计算所有title.lowercased(),而您几乎不需要所有这些。你可以缓存小写的版本,但你也可以只小写你需要的:

let lowerPrefix = prefix.lowercased()
let prefixCount = prefix.count // This probably isn't actually worth caching
filteredBooks = books.filter { $0.title.prefix(prefixCount).lowercased() == lowerPrefix }

我怀疑你会通过这种方式获得很多好处,但这是在探索新的数据结构之前需要探索的事情。

也就是说,如果您需要的唯一搜索类型是前缀搜索,那么 Trie 绝对是专门针对该问题设计的。是的,如果您可以保持列表按标题顺序排列,二分搜索也值得考虑,而前缀搜索是您唯一关心的事情。

虽然它不会帮助您进行第一次搜索,但请记住,通过缓存最近的搜索,您的第二次搜索通常会更快。特别是,如果您已经搜索过“a”,那么您知道“ap”将是其中的一个子集,因此您应该使用该事实。同样,当用户输入拼写错误和退格时,这些类型的搜索很常见。因此,以内存为代价保存一些最近的结果可能是一个巨大的胜利。

在这种规模下,内存分配和复制可能是个问题。您的 Book 类型大约为 56 个字节:

MemoryLayout.stride(ofValue: Book()) // 56

(大小是相同的,但是当您考虑将它们放入数组时,stride 会更有意义;它包括元素之间的任何填充。在这种情况下,填充为 0。但是如果您添加了 Bool 属性,你会看到区别的。)

不必复制字符串的内容(如果没有突变),因此字符串的长度并不重要。但元数据确实必须被复制,而且会累加。

因此,该数组的完整副本大约是 16MB 的“必须复制”数据。您期望的最大子集将是 10-15%(10% 的单词以英语中最常见的字母 s 开头,但标题可能会有所偏差)。这仍然是每个过滤器复制一兆字节的数量级。

您可以通过专门处理索引而不是完整元素来改进这一点。不幸的是,stdlib 中没有很好的工具,但它们并不难编写。

extension Collection {
    func indices(where predicate: (Element) -> Bool) -> [Index] {
        indices.filter { predicate(self[$0]) }
    }
}

这不是复制 56 个字节,而是每个结果复制 8 个字节,这可以显着减少您的内存流失。

您也可以将其实现为 IndexSet;我不确定哪个会更快。

【讨论】:

  • 我很确定我会在 UITextField 中添加的每个字符运行一次。我添加了一个日志,我可以看到每个键入的字符都会触发一次委托,因此,filter 方法只被调用一次。无论哪种方式,我都会进行一些额外的测试,看看是否有问题,但我确实认为这更像是一个搜索算法 O(n) 问题......
  • 顺便说一句,我构建了一个小测试工具来检查它的性能。在 iPhone 6S 上,我看到 gist.github.com/rnapier/6693048bbc4cd6770d59a20744ec9710
  • 我正在使用我的 2015 i7 Macbook Pro 并运行相同的测试 -copy&paste- 在模拟器上给我以下结果:Executed 1 test, with 0 failures (0 unexpected) in 151.860 (151.861) seconds :/
  • 您查看的值有误。这是运行测试的总时间。 .measure() 多次运行测试。寻找像... measured [Time, seconds] average: 0.672, relative standard deviation: 6.276%, ...这样的行
  • Xcode 将在灰色标签中显示该值,可能读取(不幸的是默认情况下)“时间没有基线平均值”。单击它,您将看到实际时间。或者单击装订线中的灰点,它会显示一个图表。
猜你喜欢
  • 2010-12-12
  • 2021-12-03
  • 2014-09-17
  • 2017-10-07
  • 2011-05-01
  • 1970-01-01
  • 2012-11-13
  • 2023-03-26
  • 2014-03-21
相关资源
最近更新 更多