【问题标题】:Need help in understanding Dynamic Programming approach for "balanced 0-1 matrix"?在理解“平衡 0-1 矩阵”的动态编程方法方面需要帮助吗?
【发布时间】:2016-09-12 04:55:10
【问题描述】:

问题:我正在努力理解/可视化“动态编程 - 维基百科文章中的一种平衡 0-1 矩阵”的动态编程方法。

维基百科链接:https://en.wikipedia.org/wiki/Dynamic_programming#A_type_of_balanced_0.E2.80.931_matrix

在处理多维数组时,我无法理解记忆是如何工作的。例如,当尝试使用 DP 求解斐波那契数列时,使用数组来存储以前的状态结果很容易,因为数组的索引值存储了该状态的解。

有人可以用更简单的方式解释“0-1平衡矩阵”的DP方法吗?

【问题讨论】:

    标签: algorithm dynamic-programming


    【解决方案1】:

    维基百科提供了蹩脚的解释和不理想的算法。但让我们以它为起点。

    首先让我们来看看回溯算法。与其将矩阵的单元格“按某种顺序”排列,不如将第一行中的所有内容,然后第二行中的所有内容,然后第三行中的所有内容,依此类推。显然这会奏效。

    现在让我们稍微修改回溯算法。我们不会逐个单元格地进行,而是逐行进行。所以我们列出了n choose n/2 可能的行,其中一半是 0,一半是 1。然后有一个递归函数,看起来像这样:

    def count_0_1_matrices(n, filled_rows=None):
        if filled_rows is None:
            filled_rows = []
        if some_column_exceeds_threshold(n, filled_rows):
            # Cannot have more than n/2 0s or 1s in any column
            return 0
        else:
            answer = 0
            for row in possible_rows(n):
                answer = answer + count_0_1_matrices(n, filled_rows + [row])
            return answer
    

    这是一个与我们以前一样的回溯算法。我们一次只做整行,而不是单元格。

    但请注意,我们传递的信息超出了我们的需要。无需传递行的确切排列。我们只需要知道剩余的每一列中需要多少个 1。所以我们可以让算法看起来更像这样:

    def count_0_1_matrices(n, still_needed=None):
        if still_needed is None:
            still_needed = [int(n/2) for _ in range(n)]
    
        # Did we overrun any column?
        for i in still_needed:
            if i < 0:
                return 0
    
        # Did we reach the end of our matrix?
        if 0 == sum(still_needed):
            return 1
    
        # Calculate the answer by recursion.
        answer = 0
        for row in possible_rows(n):
            next_still_needed = [still_needed[i] - row[i] for i in range(n)]
            answer = answer + count_0_1_matrices(n, next_still_needed)
    
        return answer
    

    这个版本几乎就是维基百科版本中的递归函数。主要区别在于我们的基本情况是,在每一行完成后,我们什么都不需要,而 Wikipedia 会让我们编写基本情况,以便在每一行完成后检查最后一行。

    要从这个到一个自上而下的 DP,你只需要记住这个函数。在 Python 中,您可以通过定义然后添加 @memoize 装饰器来实现。像这样:

    from functools import wraps
    
    def memoize(func):
        cache = {}
        @wraps(func)
        def wrap(*args):
            if args not in cache:
                cache[args] = func(*args)
            return cache[args]
        return wrap
    

    但还记得我批评过维基百科的算法吗?让我们开始改进它吧!第一个大的改进就是这个。您是否注意到still_needed 元素的顺序无关紧要,只是它们的值?因此,仅对元素进行排序就会阻止您为每个排列分别进行计算。 (可能有很多排列!)

    @memoize
    def count_0_1_matrices(n, still_needed=None):
        if still_needed is None:
            still_needed = [int(n/2) for _ in range(n)]
    
        # Did we overrun any column?
        for i in still_needed:
            if i < 0:
                return 0
    
        # Did we reach the end of our matrix?
        if 0 == sum(still_needed):
            return 1
    
        # Calculate the answer by recursion.
        answer = 0
        for row in possible_rows(n):
            next_still_needed = [still_needed[i] - row[i] for i in range(n)]
            answer = answer + count_0_1_matrices(n, sorted(next_still_needed))
    
        return answer
    

    那个无害的小sorted 看起来并不重要,但它可以节省很多工作!现在我们知道still_needed 总是被排序的,我们可以简化我们是否完成的检查,以及是否有任何事情是负面的。另外,我们可以添加一个简单的检查来过滤掉我们在列中有太多 0 的情况。

    @memoize
    def count_0_1_matrices(n, still_needed=None):
        if still_needed is None:
            still_needed = [int(n/2) for _ in range(n)]
    
        # Did we overrun any column?
        if still_needed[-1] < 0:
            return 0
    
        total = sum(still_needed)
        if 0 == total:
            # We reached the end of our matrix.
            return 1
        elif total*2/n < still_needed[0]:
            # We have total*2/n rows left, but won't get enough 1s for a
            # column.
            return 0
    
        # Calculate the answer by recursion.
        answer = 0
        for row in possible_rows(n):
            next_still_needed = [still_needed[i] - row[i] for i in range(n)]
            answer = answer + count_0_1_matrices(n, sorted(next_still_needed))
    
        return answer
    

    而且,假设您实现了 possible_rows,这应该比 Wikipedia 提供的既有效又高效。

    =====

    这是一个完整的工作实现。在我的机器上,它在 4 秒内计算出第 6 项。

    #! /usr/bin/env python
    
    from sys import argv
    from functools import wraps
    
    def memoize(func):
        cache = {}
        @wraps(func)
        def wrap(*args):
            if args not in cache:
                cache[args] = func(*args)
            return cache[args]
        return wrap
    
    @memoize
    def count_0_1_matrices(n, still_needed=None):
        if 0 == n:
            return 1
    
        if still_needed is None:
            still_needed = [int(n/2) for _ in range(n)]
    
        # Did we overrun any column?
        if still_needed[0] < 0:
            return 0
    
        total = sum(still_needed)
        if 0 == total:
            # We reached the end of our matrix.
            return 1
        elif total*2/n < still_needed[-1]:
            # We have total*2/n rows left, but won't get enough 1s for a
            # column.
            return 0
        # Calculate the answer by recursion.
        answer = 0
        for row in possible_rows(n):
            next_still_needed = [still_needed[i] - row[i] for i in range(n)]
            answer = answer + count_0_1_matrices(n, tuple(sorted(next_still_needed)))
    
        return answer
    
    @memoize
    def possible_rows(n):
        return [row for row in _possible_rows(n, n/2)]
    
    
    def _possible_rows(n, k):
        if 0 == n:
            yield tuple()
        else:
            if k < n:
                for row in _possible_rows(n-1, k):
                    yield tuple(row + (0,))
            if 0 < k:
                for row in _possible_rows(n-1, k-1):
                    yield tuple(row + (1,))
    
    n = 2
    if 1 < len(argv):
        n = int(argv[1])
    
    print(count_0_1_matrices(2*n)))
    

    【讨论】:

    • 如果你有时间,你能证明排序是有效的吗?也许通过使用oeis.org/A058527 确认程序的结果?我对此并不满意,但我还没有尝试过仔细考虑。
    • 另外,如果您只计算仍然需要的 1,您的函数将如何确保每列中有一组匹配的零,而不将 k 作为参数传递?看到一个工作版本会有所帮助。
    • 修复了一些小错误后,我有了一个可行的实现。它产生正确的答案。至于为什么排序有效,0-1 矩阵的列的任何排列都会产生另一个 0-1 矩阵。这会在以一个 still_needed 完成的方式计数与相同的排序版本之间产生双射。至于匹配的 0 集合,这是通过以下事实来保证的:如果列中元素的总和是正确的,并且它只包含 1 和 0,那么 0 的数量也必须是正确的。
    • 哦,对了,如果我们为零需要保留占位符,而您的possible_rows 最多只能分配n/2 1,那么我们不需要k 供参考。我很高兴排序工作,这意味着它是一个比任何包含 - 排除我可能会感到困惑的更简单更直观的解决方案。
    【解决方案2】:

    您正在记忆可能重复的状态。在这种情况下需要记住的状态是向量(k 是隐含的)。让我们看一下您linked 的示例之一。向量参数(长度为n)中的每一对都表示“尚未放置在该列中的0 和1 的数量”。

    以左边的例子为例,向量是((1, 1) (1, 1) (1, 1) (1, 1)), when k = 2,指向它的赋值是1 0 1 0, k = 30 1 0 1, k = 4。但是我们可以从一组不同的分配中到达相同的状态,((1, 1) (1, 1) (1, 1) (1, 1)), k = 2,例如:0 1 0 1, k = 31 0 1 0, k = 4。如果我们记住状态的结果,((1, 1) (1, 1) (1, 1) (1, 1)),我们可以避免再次重新计算该分支的递归。

    如果有什么我可以更好地澄清的,请告诉我。

    针对您的评论进一步阐述:

    Wikipedia 的示例似乎是一种非常强大的记忆力。该算法似乎试图枚举所有矩阵,但使用记忆化提前退出重复状态。我们如何列举所有的可能性?以他们的例子n = 4 为例,我们从向量[(2,2),(2,2),(2,2),(2,2)] 开始,其中尚未放置零和一。 (由于向量中每个元组的总和是k,我们可以有一个更简单的向量,其中k 并且保持1 或0 的计数。)

    在每个阶段,k,在递归中,我们为下一个向量枚举所有可能的配置。如果状态存在于我们的哈希中,我们只需返回该键的值。否则,我们将向量分配为哈希中的新键(在这种情况下,此递归分支将继续)。

    例如:

    Vector                       [(2,2),(2,2),(2,2),(2,2)]
    
    Possible assignments of 1's: [1 1 0 0], [1 0 1 0], [1 0 0 1] ... etc.
    
    First branch:                [(2,1),(2,1),(1,2),(1,2)]
      is this vector a key in the hash?
      if yes, return value lookup
      else, assign this vector as a key in the hash where the value is the sum 
         of the function calls with the next possible vectors as their arguments
    

    【讨论】:

    • 感谢您的回复。好的,我看到逻辑是记住每行的对向量。这是否意味着每次从向量对生成状态?您能否以不同的方式解释维基百科的文章,第一行之后我真的无法理解该解释中的任何内容。或者我们可以使用@btilly 建议的 Backtrack 方法并将其修改为递归调用每一行而不是单元格。这是我使用 Backtrack link 的实现
    • @RuthraKumar 是的,必须根据哈希检查生成的每个状态,以避免重复剩余的递归。我在答案中详细说明了一点。如果我能进一步澄清一些事情,请告诉我。
    猜你喜欢
    • 2011-12-24
    • 1970-01-01
    • 2012-09-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2011-10-04
    • 2018-01-11
    相关资源
    最近更新 更多