【问题标题】:Maximum path sum of 2 lists2个列表的最大路径总和
【发布时间】:2022-01-10 14:09:51
【问题描述】:

我的问题是关于 Codewars 上的 this kata。该函数采用两个具有不同元素的排序列表作为参数。这些列表可能有也可能没有共同项目。任务是找到最大路径和。在求总和的同时,如果有任何共同项,您可以选择将路径更改为其他列表。

给定的例子是这样的:

list1 = [0, 2, 3, 7, 10, 12]
list2 = [1, 5, 7, 8]
0->2->3->7->10->12 => 34
0->2->3->7->8      => 20
1->5->7->8         => 21
1->5->7->10->12    => 35 (maximum path)

我解决了 kata,但我的代码不符合性能标准,所以我执行超时。我能为它做些什么?

这是我的解决方案:

def max_sum_path(l1:list, l2:list):
    common_items = list(set(l1).intersection(l2))
    if not common_items:
        return max(sum(l1), sum(l2))
    common_items.sort()
    s = 0
    new_start1 = 0
    new_start2 = 0
    s1 = 0
    s2 = 0
    for item in common_items:
        s1 = sum(itertools.islice(l1, new_start1, l1.index(item)))
        s2 = sum(itertools.islice(l2, new_start2, l2.index(item)))
        new_start1 = l1.index(item)
        new_start2 = l2.index(item)
        s += max(s1, s2)
    s1 = sum(itertools.islice(l1, new_start1, len(l1)))
    s2 = sum(itertools.islice(l2, new_start2, len(l2)))
    s += max(s1, s2)
    return s

【问题讨论】:

  • 首先我会通过拨打time.time 几个电话来找出哪个部分花费的时间最多。
  • 像这样:start = time.time(); #do stuff that takes very long; print(time.time() - start) - 您将获得两次 time.time 调用之间的时间(以毫秒为单位)。
  • 当列表长度为 619 和 1352 时,第二个代码的 for 循环需要 0.01697850227355957 毫秒。当我为大列表尝试它们时,我注意到第一个返回错误的总和。但第二个工作正常。但是我仍然不知道该怎么做才能减少长时间输入的时间。
  • 啊抱歉……time.time 返回秒数,而不是毫秒数。但这意味着您的代码只需要 16 毫秒 - 我怀疑这可以进一步减少。因此,要么执行您在该网站上提交的代码的服务器超慢并且花费的时间超过这 16 毫秒,要么确定您提交的代码是否太慢的代码存在错误。
  • 0

标签: python python-3.x list algorithm


【解决方案1】:

你的算法实际上很快,只是你的实现很慢。

导致它花费整体 O(n²) 时间的两件事:

  • l1.index(item) 总是从列表的开头搜索。应该是l1.index(item, new_start1)
  • itertools.islice(l1, new_start1, ...)l1 创建一个迭代器,并迭代第一个new_start1 元素,然后再到达您想要的元素。因此,只需使用普通的列表切片即可。

然后它只是 O(n log n) 用于排序和 O(n) 用于其他所有内容。并且排序的 O(n log n) 很快,对于任何允许的输入甚至更大的输入,可能很容易比 O(n) 部分花费更少的时间。

这是重写的版本,大约 6 秒后被接受,就像其他答案中的解决方案一样。

def max_sum_path(l1:list, l2:list):
    common_items = list(set(l1).intersection(l2))
    if not common_items:
        return max(sum(l1), sum(l2))
    common_items.sort()
    s = 0
    new_start1 = 0
    new_start2 = 0
    s1 = 0
    s2 = 0
    for item in common_items:
        next_start1 = l1.index(item, new_start1)  # changed
        next_start2 = l2.index(item, new_start2)  # changed
        s1 = sum(l1[new_start1 : next_start1])    # changed
        s2 = sum(l2[new_start2 : next_start2])    # changed
        new_start1 = next_start1                  # changed
        new_start2 = next_start2                  # changed
        s += max(s1, s2)
    s1 = sum(l1[new_start1:])                     # changed
    s2 = sum(l2[new_start2:])                     # changed
    s += max(s1, s2)
    return s

或者您可以使用迭代器而不是索引。这是您为此重写的解决方案,在大约 6 秒内也被接受:

def max_sum_path(l1:list, l2:list):
    common_items = sorted(set(l1) & set(l2))
    s = 0
    it1 = iter(l1)
    it2 = iter(l2)
    for item in common_items:
        s1 = sum(iter(it1.__next__, item))
        s2 = sum(iter(it2.__next__, item))
        s += max(s1, s2) + item
    s1 = sum(it1)
    s2 = sum(it2)
    s += max(s1, s2)
    return s

我会将最后四行合二为一,就像你原来的那样,这样比较容易。

【讨论】:

    【解决方案2】:

    问题是“以线性时间复杂度为目标”,这是一个很大的暗示,比如嵌套循环之类的东西不会飞(index 是嵌套的 O(n) 循环,sort() 是 O(n log (n)) 当输入列表之间有很多重复值时)。

    This answer 展示了如何缓存重复的.index 调用并使用最后一个块的起始偏移量来降低复杂性。

    正如链接的答案还指出,itertools.islice 在这里不合适,因为它从列表的开头遍历。相反,使用本机切片。再加上上面对index 的修改,总体上为您提供线性复杂度,在大多数输入上都是线性的。


    就上下文而言,这是我的方法,虽然我缓存索引并避免排序,但与您的方法没有什么不同。

    我首先将问题表述为directed acyclic graph,并考虑搜索最大路径总和:

           +---> [0, 2, 3] ---+            +---> [10, 12]
    [0] ---|                  |---> [7] ---|
           +---> [1, 5] ------+            +---> [8]
    

    为了清楚起见,我们不妨也将每个节点的值相加:

         +---> 5 ---+          +---> 22
    0 ---|          |---> 7 ---|
         +---> 6 ---+          +---> 8
    

    上图显示,在唯一性约束条件下,贪心解决方案将是最优的。例如,从根开始,我们只能选择 5 或 6 值的路径来得到 7。两者中较大的 6 保证是最大权重路径的一部分,所以我们取它。

    现在,问题只是如何实现这个逻辑。回到列表,这里有一个更重要的输入,其中包含格式和注释,以帮助激发一种方法:

    [1, 2, 4, 7, 8,    10,         14, 15    ]
    [      4,    8, 9,     11, 12,     15, 90]
           ^     ^                      ^
           |     |                      |
    

    这说明了链接索引是如何排列的。我们的目标是遍历链接之间的每个块,取两个子列表和中较大的一个:

    [1, 2, 4, 7, 8,    10,         14, 15    ]
    [      4,    8, 9,     11, 12,     15, 90]
     ^~~^     ^     ^~~~~~~~~~~~~~~~^      ^^
      0       1             2               3  <-- chunk number
    

    上述输入的预期结果应该是 3 + 4 + 7 + 8 + 32 + 15 + 90 = 159,取所有链接值加上块 0 和 1 的顶部列表的子列表总和以及底部列表的块 2 和 3。

    这是一个相当冗长但希望易于理解的实现;您可以访问该线程以查看更优雅的解决方案:

    def max_sum_path(a, b):
        b_idxes = {k: i for i, k in enumerate(b)}
        link_to_a = {}
        link_to_b = {}
        
        for i, e in enumerate(a):
            if e in b_idxes:
                link_to_a[e] = i
                link_to_b[e] = b_idxes[e]
        
        total = 0
        start_a = 0
        start_b = 0
        
        for link in link_to_a: # dicts assumed sorted, Python 3.6+
            end_a = link_to_a[link]
            end_b = link_to_b[link]
            total += max(sum(a[start_a:end_a]), sum(b[start_b:end_b])) + link
            start_a = end_a + 1
            start_b = end_b + 1
            
        return total + max(sum(a[start_a:]), sum(b[start_b:]))
    

    【讨论】:

    • 我并不是说这些函数本身就具有这些复杂性,或者您不能使用它们来解决问题(我显然使用的是切片),而是在 OP 的代码中,函数因为它们被使用这些复杂性。重点应该放在那句话中的“这里”这个词上,指的是 OP 使用它们的方式。例如,common_items.sort() 是 O(n),因为 common_items 可能是两个列表中的所有元素! CW 测试套件是否真的测试这种边缘情况尚不清楚(嵌套 slices/sums 可能是最大的问题)。
    • 当然,复杂性与 Codewars 解决方案实际测试的内容之间存在差异。再一次,如果复杂性在纸面上看起来很糟糕,但 CW 测试套件并没有像我提到的那样将代码真正推送到边缘情况下,那么我们算幸运。但是在没有任何信息的情况下超时,最安全的做法是假设这些案例正在被测试并尝试确保它真正是线性的。再次查看 OP 的代码,它并没有我想象的那么糟糕(例如,sums/slices 是可以的),所以我将修改我的帖子。谢谢。
    • 再一次,它们的复杂性只是“不是问题”,因为 CW 测试的设计方式——本质上是幸运的,它们并没有真正推动代码。我可以轻松设计具有高密度重复对象的大型测试,以确保真正的线性解决方案。
    • 好点,我似乎不能给它造成任何性能问题,即使有大量重复输入试图得到最坏的情况。基本上,当我看到超时时,我并没有打扰 OP 的代码,但事实证明缓存 .index(),使用 .index() 的偏移量,并且使用原生切片几乎可以提供合理的解决方案。感谢没有把婴儿和洗澡水一起扔出去——我高估了分类的影响。答案已更新以提及所有这些。
    • 我想知道它们都是不同的数字这一事实是否会使排序运行得更快。此外,它是本机代码,所以,是的,它不应该是一个大问题。有时线性可能是一个真正的瓶颈,但显然不是。
    【解决方案3】:

    这可以在O(n) 运行时和O(1) 空间复杂度中一次性完成。您只需要两个指针来并行遍历两个数组和两个路径值。

    您增加指向较小元素的指针并将其值添加到其路径。当你找到一个共同的元素时,你将它添加到两个路径中,然后将两个路径都设置为最大值。

    def max_sum_path(l1, l2):
        path1 = 0
        path2 = 0
        i = 0
        j = 0
        while i < len(l1) and j < len(l2):
            if l1[i] < l2[j]:
                path1 += l1[i]
                i += 1
            elif l2[j] < l1[i]:
                path2 += l2[j]
                j += 1
            else:
                # Same element in both paths
                path1 += l1[i]
                path2 += l1[i]
                path1 = max(path1, path2)
                path2 = path1
                i += 1
                j += 1
        while i < len(l1):
            path1 += l1[i]
            i += 1
        while j < len(l2):
            path2 += l2[j]
            j += 1
        return max(path1, path2)
    

    【讨论】:

      【解决方案4】:

      一旦您知道两个列表之间共享的项目,您就可以分别迭代每个列表以总结共享项目之间的项目,从而构建一个部分和的列表。这些列表对于两个输入列表将具有相同的长度,因为共享项的数量是相同的。

      然后可以通过对共享值之间的每个拉伸取两个列表之间的最大值来找到最大路径总和:

      def max_sum_path(l1, l2):
          shared_items = set(l1) & set(l2)
          
          def partial_sums(lst):
              result = []
              partial_sum = 0
              for item in lst:
                  partial_sum += item
                  if item in shared_items:
                      result.append(partial_sum)
                      partial_sum = 0
              result.append(partial_sum)
              return result
                  
          return sum(map(max, partial_sums(l1), 
                              partial_sums(l2)))
      

      时间复杂度:我们只在每个列表上迭代一次(对较短的部分和列表的迭代在这里无关紧要),所以这段代码在输入列表的长度上是线性的。但是,正如您和 Kelly Bundy 所指出的,您自己的算法实际上具有相同的时间复杂度,除了对常见项目的排序部分,这似乎与给定的测试用例不太相关。

      因此,作为一般结论,如果您的目标只是让您的代码足够快以通过某些测试用例,那么最好对执行进行分析以找出实际实现中的时间消耗,而不是担心理论上的最坏情况场景。

      【讨论】:

      • 也可以返回sum(map(max, partial_sums1, partial_sums2))
      • 我不认为排序是一个问题,它只完成一次,它的隐藏常数相当快。不管有没有sorted(shared_items),这在网站上大约需要 5.8 秒(使用 ATTEMPT 按钮)。
      • 我解决问题的方法是这样的:找到共同的项目。如果没有返回最大总和 l1,l2。如果有的话,直到他们总结。在公共项目中选择它的最大值并添加到总和。当完成选择部分和的最大值。我认为问题是“sum(itertools.islice(l1,new_start1,l1.index(item)))”而不是我应该通过迭代线性并为每个列表相加来做到这一点。
      • 感谢 @KellyBundy 指出 OP 代码的真正问题。我赞成您的回答并编辑了自己的回答以提及您的观点,并按照您的建议更改了返回声明。
      • 感谢@ÜmitKara 提供更多信息。这有助于我编辑我的答案。
      【解决方案5】:

      基准测试

      Discourse 选项卡上,您可以单击“显示 Kata 测试用例”(一旦您解决了 kata)来查看他们的测试用例生成器。我用它来对迄今为止发布的解决方案以及我的一个解决方案进行基准测试。几十轮,因为测试用例非常随机,导致运行时波动很大。在每一轮中,生成的所有测试用例都给所有解决方案(因此在每一轮中,所有解决方案都得到相同的测试用例)。

      还有 Kelly Bundy 对公共值集进行排序的最坏情况:

      代码应遵循。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 2021-09-29
        • 1970-01-01
        • 2013-02-10
        • 1970-01-01
        • 1970-01-01
        • 2019-08-07
        • 1970-01-01
        相关资源
        最近更新 更多