【问题标题】:Efficiently find marked referenced records高效查找标记的引用记录
【发布时间】:2023-01-30 02:41:48
【问题描述】:

我有

  • 数据库中有几百万条记录
  • 相互引用(有向无环图)。有直接引用 (A -> B) 和间接引用(如果 A -> B 和 B -> C,则 A -> C)。间接引用可以有任何递归深度,但实际上深度最多为 100。这非常类似于面向对象语言中的对象可以递归地引用其他对象,只是不允许循环。
  • 一条记录可以有 0 到 100 个直接引用。
  • 每条记录都可以标记或不标记(大多数记录不标记)。

问题

我正在寻找一种有效的数据结构和算法来找到所有标记引用(直接或者间接引用)给定一组记录(通常只有一个,或最多 100 个)的记录。有直接标记的记录(如果标记了直接引用的记录),或间接标记的记录(如果标记了间接引用的记录)。

读取记录相对较慢,假设每条记录 2 毫秒。

我是不是在这里寻找使用更快的存储或类似的。我知道这是可能的,但很难保持同步。我正在尝试添加一个仅包含相关数据的辅助数据结构。这会大大加快速度(可能是 10 倍甚至 100 倍),但会带来常数因子的改进。如果数据量增加,我仍然有兴趣了解是否有可能改进算法。

想法

我考虑过以下选项:

  • 蛮力:一种算法是搜索所有(直接或间接引用的)条目,并过滤标记的条目。但这显然很慢,因为我必须处理所有(直接或间接)引用的条目。也许没有一个被标记,但有 20'000 个被引用。

  • 阴影标记:另一种算法是有一个反向索引(哪些条目引用其他条目),然后每次标记一个条目时,也递归地“阴影标记”所有引用该条目的条目。这样,在搜索标记的条目时,我们可以过滤那些设置了“影子标记”的条目。缺点是如果一个条目被标记,则需要多次更新。一个相关的选项是使用 Bloom 过滤器进行阴影标记。但这只会减少内存使用量。

  • 假设我们维护一个“最大深度”,它是树的最大深度(来自任何记录的最大跳数)。然后我们使用上面的 shadown-mark 算法,但只是部分使用:仅最大深度/2 递归级别。所以我们限制传播阴影标记。然后,对于查询,我们还将递归深度限制为 maximum-depth / 2。这样,我们将在最坏的情况下“在中间相遇”。 (我可能应该画一幅画。)然后一个子问题是如何有效地保持这个最大深度。

我想知道,是否有类似这种方法的东西?标记条目时不需要多次更新,查询时不需要太多读取的东西?或者如果一个条目被标记,一个允许逐渐更新条目的解决方案?

例子

在这个例子中(蓝色是“标记”),例如,如果我搜索(间接)引用标记记录 5,我想快速找到 1 和 3。

【问题讨论】:

  • “间接标记”是什么意思?它是“引用标记的条目”、“由标记的条目引用”还是两者兼而有之?它是一种传递关系,还是只有一个层次?
  • “每条记录 2 毫秒。”为什么这么慢?您使用的是什么数据库引擎?您正在通过网络阅读吗?我认为你应该在“着迷于一个特定问题”之前对你的数据库操作进行一些优化。
  • @btilly 我已经更新了问题:“间接”意味着具有任何深度的传递关系。
  • @ravenspoint 这绝对是一个很好的问题!即使所有条目都在内存中,处理 16'000 条记录也需要 9 秒。速度慢得惊人!任何你可以想象的,如果从 MongoDB 读取记录,一次一个,它会慢得多。我知道这一切都很奇怪,但这是一个庞大而陈旧的系统,尝试优化这部分真的非常非常困难。我已经添加了两个缓存,速度提高了三倍,但更多的缓存会花费更多时间。我要找的是算法的改进。如果我切换到 PostgreSQL,那么使用一个 CTE 查询需要 70 毫秒。
  • 我假设你的记录包含很多与这个问题无关的信息。为什么不只将您需要的信息(记录 ID、引用和标记)提取到本地 SSD(使用平面文件或高性能数据库引擎(例如 SQLite)),然后根据需要进行更新并运行强力算法。

标签: algorithm data-structures graph graph-theory


【解决方案1】:

这个问题与全动态传递闭包有关。我不太熟悉关于后者的研究文献(可能大部分都不实用),但是有一个你可能不知道的算法技巧,与你的“最大深度”想法有关。

向每个节点添加一个二进制标志(“打开”或“关闭”),并存储传入和传出弧。规则是,每个可以到达开放节点的节点都是开放的,并且(等效地)每个封闭节点可以到达的节点都是封闭的。每个封闭节点还存储它可以到达的标记节点集。要查询,从查询节点通过开放节点向前遍历(出弧),在封闭节点处停止。要更新,从更新的节点通过封闭节点向后遍历(传入弧),在开放节点处停止。

具有来自开放节点的传入弧的封闭节点只能转换为开放节点。具有到闭节点的出弧的开节点只能转换为闭节点。转换需要与(进或出)度成比例的更新。在这种规模下,我建议定期转储整个图表并在主内存中计算一组合理的调整。

【讨论】:

  • 这非常有用!恐怕我还不太明白它是如何工作的,所以我正在按照你的描述尝试自己实现它......我在文献中找不到它;我想知道找什么地方比较好?可能文献使用了其他术语(“开放”和“封闭”是非常通用的术语,也许很难找到......)。附言有趣的是,我在 2020 年 1 月的一次会议 (ALENEX) 上展示了其他内容,莫妮卡亨辛格 (Monika Henzinger) 发表了关于图算法的主题演讲……我想我可能永远不会使用其中的任何一个……
  • @ThomasMueller 对这个想法的另一种看法:arxiv.org/pdf/2002.00813.pdf
  • 很有意思!他们提到了 Bidirectional Breath-First Search,看起来很有竞争力。我将实现它以及更多的算法,然后进行比较。如果更改不会导致任何更新,那就太好了:只读算法的实现会简单得多。
【解决方案2】:

查找从给定记录可达的所有标记记录等价于计算包含给定记录的组件中的标记记录。

这可以通过广度优先或深度优先搜索来完成。

没有更快的算法。为了提高你的表现,我相信你需要:

  1. 使用优化编译器实现高效的搜索代码

  2. 切换到高性能数据库引擎

  3. 优化您的查询。 (不要一次读一个记录!)

  4. 优化您的硬件配置(无网络,无旋转磁盘)

【讨论】:

  • 我已经更新了问题。 “指定的记录”(实际上是一条或几条)。请注意,如果一条记录被标记,我们可以对其他记录进行“影子标记”。这意味着,不需要 Dijkstra,如果我们标记一个条目,代价是“影子标记”所有条目。我正在寻找这样的解决方案。
  • Dijkstra算法用于计算最短距离。为什么我需要知道最短距离?找到所有条目(已标记)似乎就足够了。呼吸优先或深度优先似乎就足够了,不是吗?
  • 你是对的。
【解决方案3】:

您可以在每个节点上保留一个表,记录哪些标记的节点可以从它到达,并在图表中添加或删除节点(或边缘)时保持更新,类似于为网络中的每个节点保留网络路由表.关于您的问题,有一些细节使其比网络路由表更简单:

  • 您不想知道从给定节点到标记节点的实际路径,只知道一个(或多个)存在。
  • 图形是无环的。
  • 它不是一个分布式系统,所以你有完全的控制权(显然......)。

因为您不关心路径并且因为图是非循环的,所以每个节点上的表可以是映射marked_node_id -> count,其中计数是从给定节点到给定标记节点的路径数。添加新节点时,新节点的表将构建为与新节点相邻的所有节点表的联合,其中 count 是总和。此外,所有相邻节点的表必须通过将新节点的表添加到它们中的每一个来更新新节点,并且必须递归地向上完成相邻的链。当一个节点被删除时,你必须做类似的事情。

基本复杂度分析:查找给定节点的所有标记节点是 O(1) 并且可以通过存储在单个节点上的信息来完成 - 这就是重点。通常,添加和删除一条边(或一个新节点加上它的边)将需要递归更新所有连接节点的表(调用深度高达 100,分支因子高达 100)。通过标记节点的反向泛洪,最初构建表的时间为 O(节点数)。


代码示例:

这是抽象的代码内解决方案,但应该翻译。我正在使用 Python (+GraphViz),因为你没有指定语言,它可能最适合最广泛的受众,并且很容易制作原型。我也将只实现添加/删除节点操作(修改节点可以删除然后添加不同的初始化)并从头开始构建图形,这不太现实,但是您可以通过从标记的节点向后工作很容易地在给定现有图形的情况下构建表。另请注意:

  • 除了 adjacent_to 列表之外,以下要求每个节点都拥有/维护一个 adjacent_from 列表,以便我们可以在删除给定节点时递归路径的相邻路径。
  • 我假设每个标记的节点都可以从自身到达 - 只是让事情更容易实现。

def main():
  ''' Build a test graph, then test. '''
  graph = Graph()
  a = graph.add_node('a', marked=True)
  b = graph.add_node('b', marked=True)
  c = graph.add_node('c', marked=True)
  d = graph.add_node('d', adjacent_to=[a])
  e = graph.add_node('e', adjacent_to=[d])
  f = graph.add_node('f',adjacent_to=[c])
  g = graph.add_node('g', adjacent_to=[d,f])
  h = graph.add_node('h', adjacent_to=[e,g])
  i = graph.add_node('i')
  j = graph.add_node('j', marked=True, adjacent_to=[i])
  k = graph.add_node('k', adjacent_to=[j])
  l = graph.add_node('l', adjacent_to=[k])
  m = graph.add_node('m', adjacent_to=[j])
  with open('main0.dot', 'w') as f:
    f.write(graph.gviz())
  graph.delete_node('f')
  with open('main1.dot', 'w') as f:
    f.write(graph.gviz())
  graph.delete_node('e')
  with open('main2.dot', 'w') as f:
    f.write(graph.gviz())
  graph.delete_node('g')
  with open('main3.dot', 'w') as f:
    f.write(graph.gviz())
  # Run this script to process graphviz files: for i in *.dot; do dot -Tpng $i > "${i%%.dot}.png"; done

class Graph:
  ''' Container for nodes. '''
  def __init__(self):
    self.nodes = {}

  def add_node(self, id, marked=False, adjacent_to=[]):
    assert id not in self.nodes
    self.nodes[id] = Node(id, marked, adjacent_to)
    return self.nodes[id]

  def delete_node(self, id):
    assert id in self.nodes
    node = self.nodes[id]
    self._recursive_subtract_table_on_delete(node, node)
    for adjacent_from_node in node.adjacent_from:
      adjacent_from_node._remove_adjacent_node(node.id)
    del self.nodes[id]

  def _recursive_subtract_table_on_delete(self, node, deleted_node):
    for adjacent_from_node in node.adjacent_from:
      self._recursive_subtract_table_on_delete(adjacent_from_node, deleted_node)
    node._delete_reachability_table(deleted_node)

  def gviz(self):
    return 'strict digraph {
%s}' % ''.join([n._gviz_edges() for n in self.nodes.values()])

class Node:
  def __init__(self, id, marked=False, adjacent_to = []):
    ''' Init node. Note only adjacent_to not adjacent_from node are allowed,
    which measn we dno't have to update adjacent_from reachable_marks.  '''
    self.id = id
    self.marked = marked
    self.adjacent_to = adjacent_to
    self.adjacent_from = []
    self.reachable_marks = {}

    if marked:
      self.reachable_marks[id] = 1
    for adjacent_node in adjacent_to:
      adjacent_node.adjacent_from.append(self);
      self._add_reachability_table(adjacent_node)

  def _add_reachability_table(self, node):
    ''' Add the reachable_marks table from node to self. '''
    for (marked_node_id, k) in node.reachable_marks.items():
      self.reachable_marks[marked_node_id] = self.reachable_marks[marked_node_id] + 1 if marked_node_id in self.reachable_marks else 1

  def _delete_reachability_table(self, node):
    ''' Delete the reachable_marks table from node from self. '''
    for (marked_node_id, k) in node.reachable_marks.items():
      self.reachable_marks[marked_node_id] = self.reachable_marks[marked_node_id] - 1 if marked_node_id in self.reachable_marks else 0
    self.reachable_marks = {k: v for k,v in self.reachable_marks.items() if v}

  def _remove_adjacent_node(self, id):
    self.adjacent_to = list(filter(lambda n: n.id != id, self.adjacent_to))

  def _gviz_edges(self):
    ''' Helper to print graphviz edges adjacent to this node. '''
    _str = ''
    if self.marked:
      _str += ' %s[style=filled,fillcolor=blue]
' % (self._gviz_name(),)
    else:
      _str +=  self._gviz_name() + '
'
    for adjacent_node in self.adjacent_to:
      _str += ' %s -> %s
' % (self._gviz_name(), adjacent_node._gviz_name())
    return _str;

  def _gviz_name(self):
    ''' Helper to print graphviz name with reachable marks. '''
    return '"' + self.id + '(' + ','.join(self.reachable_marks.keys()) + ')"'

if __name__ == '__main__':
  main()

结果:

输出图显示了从括号中的每个节点可到达的标记节点。

最初的:

删除节点 f:

删除节点 e:

删除节点 g:

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2010-11-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-05-26
    • 2010-12-13
    相关资源
    最近更新 更多