力扣题目链接: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 可以充分优化递归调用(比如内联小函数、优化栈帧分配)。