如何通过分步分析确定递归回溯算法的时间和空间复杂度 步骤 1:使问题准确第 2 步:注释和剥离代码第三步:写出递推关系第四步:求解递推关系步骤 5:结果更新

问题描述

背景信息:我使用下面的 C# 算法解决了 N-Queens 问题,该算法返回给定大小为 n x n 的棋盘的解决方案总数。它有效,但我不明白为什么这会是 O(n!) 时间复杂度,或者它是否是不同的时间复杂度。我也不确定递归堆栈中使用的空间(但我知道布尔锯齿状数组中使用的额外空间)。我似乎无法理解这些解决方案的时间和空间复杂性。拥有这种理解在技术面试中特别有用,对于没有运行代码能力的复杂性分析。

初步调查:我读过几篇 SO 帖子,其中作者直接要求社区提供他们算法的时间和空间复杂度。与其做同样的事情并寻求快速简单的答案,我想了解如何计算回溯算法的时间和空间复杂度,以便我可以继续前进。

我还在 SO 内外的许多地方读到过,一般来说,递归回溯算法的时间复杂度为 O(n!),因为在 n 次迭代中的每一次,您都会少看一个项目:n,然后是 n - 1,then n - 2,... 1. 但是,我没有找到任何解释为什么会这样。对于此类算法的空间复杂度,我也没有找到任何解释。

问题:有人可以解释一下分步解决问题的方法,以确定诸如此类的递归回溯算法的时间和空间复杂性吗?

public class Solution {
    public int NumWays { get; set; }
    public int TotalNQueens(int n) {
        if (n <= 0)
        {
            return 0;
        }
        
        NumWays = 0;
        
        bool[][] board = new bool[n][];
        for (int i = 0; i < board.Length; i++)
        {
            board[i] = new bool[n];
        }
        
        Solve(n,board,0);
        
        return NumWays;
    }
    
    private void Solve(int n,bool[][] board,int row)
    {
        if (row == n)
        {
            // Terminate since we've hit the bottom of the board
            NumWays++;
            return;
        }
        
        for (int col = 0; col < n; coL++)
        {
            if (CanPlaceQueen(board,row,col))
            {
                board[row][col] = true; // Place queen
                Solve(n,row + 1);
                board[row][col] = false; // Remove queen
            }
        }
    }
    
    private bool CanPlaceQueen(bool[][] board,int row,int col)
    {
        // We only need to check diagonal-up-left,diagonal-up-right,and straight up. 
        // this is because we should not have a queen in a later row anywhere,and we should not have a queen in the same row
        for (int i = 1; i <= row; i++)
        {
            if (row - i >= 0 && board[row - i][col]) return false;
            if (col - i >= 0 && board[row - i][col - i]) return false;
            if (col + i < board[0].Length && board[row - i][col + i]) return false;
        }
        
        return true;
    }
}

解决方法

首先,递归回溯算法都在O(n!)中绝对不是真的:当然这取决于算法,而且很可能会更糟。话虽如此,一般的方法是写下时间复杂度 T(n) 的递推关系,然后尝试求解它或至少刻画它的渐近行为。

步骤 1:使问题准确

我们对最坏情况、最好情况还是平均情况感兴趣?输入参数是什么?

在这个例子中,让我们假设我们要分析最坏情况的行为,并且相关的输入参数是 n 方法中的 Solve

在递归算法中,找到一个从输入参数的值开始然后随着每次递归调用而减小直到达到基本情况的参数是有用的(尽管并非总是可行)。

在这个例子中,我们可以定义 k = n - row。因此,每次递归调用时,k 都会从 n 递减到 0。

第 2 步:注释和剥离代码

不,我们查看代码,将其分解为相关位并用复杂的注释对其进行注释。

我们可以将您的示例归结为以下内容:

private void Solve(int n,bool[][] board,int row)
    {
        if (row == n) // base case
        {
           [...] // O(1)
           return;
        }
        
        for (...) // loop n times
        {
            if (CanPlaceQueen(board,row,col)) // O(k)
            {
                [...] // O(1)
                Solve(n,board,row + 1); // recurse on k - 1 = n - (row + 1)
                [...] // O(1)
            }
        }
    }

第三步:写出递推关系

这个例子的递归关系可以直接从代码中读出:

T(0) = 1         // base case
T(k) = k *       // loop n times 
       (O(k) +   // if (CanPlaceQueen(...))
       T(k-1))   // Solve(n,row + 1)
     = k T(k-1) + O(k)

第四步:求解递推关系

对于这一步,了解一些递推关系的一般形式及其解决方案是很有用的。上面的关系是一般形式

T(n) = n T(n-1) + f(n)

精确解决方案

T(n) = n!(T(0) + Sum { f(i)/i!,for i = 1..n })

我们可以很容易地通过归纳证明:

T(n) = n T(n-1) + f(n)                                          // by def.
     = n((n-1)!(T(0) + Sum { f(i)/i!,for i = 1..n-1 })) + f(n) // by ind. hypo.
     = n!(T(0) + Sum { f(i)/i!,for i = 1..n-1 }) + f(n)/n!)
     = n!(T(0) + Sum { f(i)/i!,for i = 1..n })                 // qed

现在,我们不需要确切的解决方案;当 n 接近无穷大时,我们只需要渐近行为。

那么让我们看看无穷级数

Sum { f(i)/i!,for i = 1..infinity }

在我们的例子中,f(n) = O(n),但让我们看看更一般的情况,其中 f(n)n 中的任意多项式(因为它会变成这真的没关系)。使用 ratio test:

很容易看出级数收敛
L = lim { | (f(n+1)/(n+1)!) / (f(n)/n!) |,for n -> infinity }
  = lim { | f(n+1) / (f(n)(n+1)) |,for n -> infinity }
  = 0  // if f is a polynomial
  < 1,and hence the series converges

因此,对于n -> infinity

T(n) -> n!(T(0) + Sum { f(i)/i!,for i = 1..infinity })
      = T(0) n!,if f is a polynomial

步骤 5:结果

既然T(n)的极限是T(0) n!,我们可以写

T(n) ∈ Θ(n!)

这是算法最坏情况复杂度的严格界限。

此外,我们已经证明,除了递归调用之外,您在 for 循环中做了多少工作并不重要,只要它是多项式,复杂度就保持 Θ(n!) (对于这种形式的递推关系)。(粗体是因为有很多 SO 答案都弄错了。)

对于具有不同递推关系形式的类似分析,请参阅here


更新

我在代码的注释中犯了一个错误(我会留下它,因为它仍然具有指导意义)。实际上,循环和循环内完成的工作都不依赖于 k = n - row 而是依赖于 initialn(让我们称其为 n0清除)。

所以递推关系变成

T(k) = n0 T(k-1) + n0

确切的解决方案是

T(k) = n0^k (T(0) + Sum { n0^(1-i),for i = 1..k })

但是从最初的 n0 = k 开始,我们已经

T(k) = k^k (T(0) + Sum { n0^(1-i),for i = 1..k })
     ∈ Θ(k^k)

Θ(k!)差一点。