记录回溯算法性能分析

7 阅读3分钟

力扣题目链接:22. 括号生成 - 力扣(LeetCode)

这道题目我采用了回溯算法,有两种近似的写法,如下:

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function(n) {
    let res = []
    let bt = (l,r,path)=>{
        if(path.length === 2* n){
            res.push(path)
            return 
        }
        if(l>0){
            bt(l-1,r,path+'(')
        }
        if(l<r){
            bt(l,r-1,path+')')
        }
    }
    bt(n,n,'')
    return res
};

/**
 * @param {number} n
 * @return {string[]}
 */
var generateParenthesis = function(n) {
    let res = []
    let path = ''
    let l = n;
    let r = n;
    let bt = ()=>{
        if(path.length === 2* n){
            res.push(path)
            return 
        }
        if(l>0){
            path+='('
            l-=1
            bt()
            path = path.slice(0,-1)
            l+=1
        }
        if(l<r){
            path+=')'
            r-=1
            bt()
            r+=1
            path = path.slice(0,-1)
        }
    }
    bt()
    return res
};

先说结果,第一段代码用时100%,第二段65%

可以发现两段代码差别在于如何回溯以及变量状态的管理方式

第一段代码是不可变的变量传递,第二段代码是共享变量,可变状态传递,需要手动实现回溯

第一个算法相较之下,避免了手动回溯的开销(slice,变量的加减),这种局部性的参数传递更有用于回溯场景,更利于js引擎优化(如JIT编译,栈帧复用),具体分析如下:

代码分析

let bt = (l,r,path)=>{ // l、r、path 都是函数参数(局部变量)
    if(path.length === 2*n) { res.push(path); return; }
    if(l>0){
        bt(l-1,r,path+'(') // 传递「新状态」:l-1、path+'('(不修改原变量)
    }
    if(l<r){
        bt(l,r-1,path+')') // 同样传递新状态,原状态不变
    }
}
bt(n,n,'')
//这种实现下,回溯是自动的,子递归执行完后,外层的 l/r/path 没有被修改,不需要手动恢复(比如 l+=1、path.slice),直接回到上一层探索下一个分支。

let path = ''; let l = n; let r = n; // 共享变量(外层作用域)
let bt = ()=>{
    if(path.length === 2*n) { res.push(path); return; }
    if(l>0){
        path+='('; l-=1; // 修改共享变量(探索)
        bt();
        path = path.slice(0,-1); l+=1; // 恢复共享变量(回溯)
    }
    if(l<r){
        path+=')'; r-=1; // 同上
        bt();
        r+=1; path = path.slice(0,-1);
    }
}
//每次探索分支时,要修改这些共享变量;子递归返回后,必须手动撤销修改(slice 截取字符串、l+=1/r+=1),否则会影响其他分支的探索。

性能分析

第一,slice的时间复杂度是O(k),(k是path长度),(slice会创建一个新的副本)

path+'(' 的执行时机是在递归调用前,而slice是在递归返回后,回溯时slice会在每一层递归都执行一次,开销累积极大

比如,当n=3的时候,生成‘((()))’,第二个算法会执行3次path+'('和3次slice(0,-1),每次slice都要复制当前长度的字符串(1-2-3-2-1-0),总的复制9次,而第一个算法的path+‘(’是一次性创建新字符串,没有回溯时的复制开销

第二,JavaScript 引擎对「局部变量」的访问速度远快于「外层作用域变量」(局部变量存在栈帧中,外层变量需要向上查找作用域链);

第二个算法的 l/r/path 是共享变量,每次修改和恢复都会产生「副作用」,

而第一个算法的 l/r/path 都是函数参数(局部变量),访问速度快,且无副作用,JIT 可以充分优化递归调用(比如内联小函数、优化栈帧分配)。