【问题标题】:Why for backtracking sometimes we need to explicitly pop after recursion, and sometimes we don't?为什么回溯有时我们需要在递归后显式弹出,而有时我们不需要?
【发布时间】:2022-01-07 05:24:20
【问题描述】:

例如,让我们考虑一个任务,我们需要找到给定字符串的所有排列,保留字符序列但改变大小写。

这里是没有.pop()的回溯解决方案:

def letterCasePermutation(S):
    """
    :type S: str
    :rtype: List[str]
    """
    def backtrack(sub="", i=0):
        if len(sub) == len(S):
            res.append(sub)
        else:
            if S[i].isalpha():
                backtrack(sub + S[i].swapcase(), i + 1)
            backtrack(sub + S[i], i + 1)
            
    res = []
    backtrack()
    return res

这是.pop()的解决方案:

def letterCasePermutation(s):
    def backtrack(idx, path):
        if idx == n:
            res.append("".join(path))
            return
        
        ele = s[idx]
        if ele.isnumeric():
            path.append(ele)
            backtrack(idx + 1, path)
            path.pop()
        else:
            path.append(ele.lower())
            backtrack(idx + 1, path)
            path.pop()
            path.append(ele.upper())
            backtrack(idx + 1, path)
            path.pop()
            
    n = len(s)
    res = []
    backtrack(0, [])
    return res

两个代码示例都是回溯,还是我应该调用第一个 DFS 而第二个回溯?

【问题讨论】:

  • 考虑将元素x 添加到列表s 的两种方法。 1)s + [x]返回一个新列表,不修改s。 2) s.append(x) 修改 s。如果以后要重用s,情况(1)可以直接重用s,但情况(2)需要先从s弹出x。
  • 第一个示例使用代码堆栈和递归上下文隐式推送和弹出在第二个示例中显式执行的操作到path,如path.append(..)path.pop()

标签: algorithm depth-first-search backtracking recursive-backtracking


【解决方案1】:

对于回溯(以及一般的大多数递归函数),每个函数调用的关键不变量是它不会破坏父调用中的状态。

这应该是直观的,因为递归依赖于自相似性。如果调用堆栈的其他地方发生不可预测的状态更改,影响与祖先调用共享的数据结构,则很容易看出自相似性是如何丢失的。

递归函数调用的工作原理是将一个帧推入call stack,根据需要在本地操作状态,然后弹出调用堆栈。在返回父框架之前,子调用负责恢复状态,以便从父调用框架的角度来看,执行可以继续进行,而不会被链上的一些随机祖先调用修改。

打个比方,你可以把每个呼叫框架想象成The Cat in the HatRisky Business的情节,主角们(在他们的呼叫框架中)弄得一团糟,然后必须在故事结束前恢复秩序(函数返回)。

现在,鉴于这个高级目标,有多种方法可以实现它,如您的 sn-ps 所示。一种方法是分配某种数据结构,例如列表对象一次,然后在每个调用帧上分配push (append) 和pop,镜像调用堆栈。

另一种方法是在产生子调用时复制状态,以便每个帧接收相关数据的新版本,并且它们所做的任何修改都不会扰乱其父状态。与更改单个数据结构相比,这通常需要更少的簿记,并且不易受到细微错误的影响,但由于内存分配器和垃圾收集器操作以及为每一帧复制数据结构,往往会产生更高的开销。

简而言之,不要混淆保持每个调用帧状态完整的高级目标以及代码如何实现它。


backtrackingDFS 而言,我认为回溯是一种专门的DFS,它会剪除启发式确定不值得进一步探索的搜索树的分支,因为它们无法得出解决方案。和以前一样,代码如何实际实现状态恢复以实现回溯(复制数据结构或推送/弹出显式堆栈)不应改变它是相同的基本算法技术这一事实。

我已经看到将“回溯”一词应用于这样的置换算法。尽管该术语可能相当普遍,但它似乎是一种误用,因为置换算法是一种全状态递归遍历,它将始终访问树中的所有节点,并且不像回溯那样进行任何智能启发式修剪。

【讨论】:

    猜你喜欢
    • 2019-06-14
    • 1970-01-01
    • 1970-01-01
    • 2019-06-09
    • 2023-01-24
    • 2020-05-13
    • 1970-01-01
    • 2019-08-21
    • 1970-01-01
    相关资源
    最近更新 更多