助你成大佬系列之javascript从暴力递归到动态规划之路——认识一些经典的递归过程(一)

94 阅读8分钟

基本概念

字符串子串和子序列的区别

在字符串匹配里,子串通常指的是给定字符串的一部分,是连续的不可断开的。而子序列是不同的,是在给定字符串里,按照顺序取字符,可以连续可以断开,然后组合构成新的字符串

字串和子序列区别.png 字串-连续 子序列-不连续

经典递归

练习一:打印n层汉诺塔从左边移动到最右边的全部过程

汉诺塔问题源自印度一个古老的传说,印度教的“创造之神”梵天创造世界时做了 3 根金刚石柱,其中的一根柱子上按照从小到大的顺序摞着 64 个黄金圆盘。梵天命令一个叫婆罗门的门徒将所有的圆盘移动到另一个柱子上,移动过程中必须遵守以下规则:
每次只能移动柱子最顶端的一个圆盘;
每个柱子上,小圆盘永远要位于大圆盘之上;

下图给您展示了包含 3 个圆盘的汉诺塔问题:
hannuota1.gif

一根柱子上摞着 3 个不同大小的圆盘,那么在不违反规则的前提下,如何将它们移动到另一个柱子上呢?下图给大家提供了一种最快的实现方案:

hannuota2.gif

汉诺塔问题中,3 个圆盘至少需要移动 7 次,移动 n 的圆盘至少需要操作 2n-1 次。

我们把这个问题拆解一下,想要把n层圆盘从左侧移动到右侧,必须先把n-1层圆盘移动到中间(黑盒1),再把第n层圆盘移动到右侧,最后把n-1层的所有圆盘移动到右侧(黑盒2),通过这种方式我们就会有6种方式移动,最终会得到最后结果,代码如下

/**
 * @description: 把所有层数根据规则从左侧移动到右侧
 * @param {number} n 层数(从小到大)
 */
function leftToRight(n:number){
    //只有1层的时候直接移动即可
    if(n==1){
        console.log(`从左侧移动数字'${n}'到右侧`);
        return
    }
    leftToMid(n-1) //先把n-1层的移动到中间
    console.log(`从左侧移动数字'${n}'到右侧`);
    midToRight(n-1) //再把n-1层的移动到中间
}

/**
 * @description: 把所有层数根据规则从左侧移动到中间
 * @param {number} n 层数(从小到大)
 */
function leftToMid(n:number){
    if(n==1){
        console.log(`从左侧移动数字'${n}'到中间`);
        return
    }
    leftToRight(n-1)
    console.log(`从左侧移动数字'${n}'到中间`);
    rightToMid(n-1)
}

/**
 * @description: 把所有层数根据规则从右侧移动到中间
 * @param {number} n 层数(从小到大)
 */
function rightToMid(n:number){
    if(n==1){
        console.log(`从右侧移动数字'${n}'到中间`);
        return
    }
    rightToLeft(n-1)
    console.log(`从右侧移动数字'${n}'到中间`);
    leftToMid(n-1)
}

/**
 * @description: 把所有层数根据规则从右侧移动到左侧
 * @param {number} n 层数(从小到大)
 */
function rightToLeft(n:number){
    if(n==1){
        console.log(`从右侧移动数字'${n}'到左侧`);
        return
    }
    rightToMid(n-1)
    console.log(`从右侧移动数字'${n}'到左侧`);
    midToLeft(n-1)
}

/**
 * @description: 把所有层数根据规则从中间移动到左侧
 * @param {number} n 层数(从小到大)
 */
function midToLeft(n:number){
    if(n==1){
        console.log(`从中间移动数字'${n}'到左侧`);
        return
    }
    midToRight(n-1)
    console.log(`从中间移动数字'${n}'到左侧`);
    rightToLeft(n-1)
}

/**
 * @description: 把所有层数根据规则从中间移动到右侧
 * @param {number} n 层数(从小到大)
 */
function midToRight(n:number){
    if(n==1){
        console.log(`从中间移动数字'${n}'到右侧`);
        return
    }
    midToLeft(n-1)
    console.log(`从中间移动数字'${n}'到右侧`);
    leftToRight(n-1)
}

function main(num:number){
    leftToRight(num)
}

main(3)
//output:
// 从左侧移动数字'1'到右侧
// 从左侧移动数字'2'到中间
// 从右侧移动数字'1'到中间
// 从左侧移动数字'3'到右侧
// 从中间移动数字'1'到左侧
// 从中间移动数字'2'到右侧
// 从左侧移动数字'1'到右侧

通过上面的代码可以发现每个函数都有一定的规律,所以可以通过添加参数的方式进行增加功能

/**
 * @description: 把所有层数根据规则从from侧移动到to侧
 * @param {number} n 层数(从小到大)
 * @param {string} from 初始位置
 * @param {string} to   目标位置
 * @param {string} other 辅助位置
 */
function move(n:number,from:string,to:string,other:string){
    if(n==1){
        console.log(`从${from}移动数字'${n}'到${to}`);
        return
    }
    move(n-1,from,other,to)
    console.log(`从${from}移动数字'${n}'到${to}`);
    move(n-1,other,to,from)
}


function main(num:number){
    move(num,'左侧','右侧','中间')
}

main(3)

//output:
// 从左侧移动数字'1'到右侧
// 从左侧移动数字'2'到中间
// 从右侧移动数字'1'到中间
// 从左侧移动数字'3'到右侧
// 从中间移动数字'1'到左侧
// 从中间移动数字'2'到右侧
// 从左侧移动数字'1'到右侧

上面两个函数的复杂度计算为2^N-1

一个递归函数,我们可以用增加参数的方式表达更多的可能性,从而让递归函数支持更多功能。

练习二:打印一个字符串的全部子序列

示例:abc 的全部子序列有 "" "a" "b" "c" "ab" "ac" "bc" "abc"
解题思路:针对于abc三个位置上的字符,每个位置的字符都有选中和未选中两种方式,最终会得到所有的子序列,如下图

字串.png

根据以上方式,可写代码如下:


/**
 * @param {string} strArr 原字符串数组(固定参数)
 * @param {string} store 存储所有的子序列数组
 * @param {number} index 当前所在的位置
 * @param {string} path 当前已经拼接的路径
 */
function process1(strArr: string[], store: string[], index: number, path: string) {
    //已经到头了,push拼接路径
    if (index == strArr.length) {
        return store.push(path)
    }
    //不选中当前位置参加path
    process1(strArr, store, index + 1, path)
    //选中当前位置参加path
    process1(strArr, store, index + 1, path + strArr[index])
}

function main(str: string): string[] {
    const ans: string[] = []
    const strArr = str.split("");
    process1(strArr, ans, 0, "")
    return ans
}

console.log(main("abc"))

//output:
//['','c','b','bc','a','ac','ab','abc']

练习三:打印一个字符串的全部子序列,要求不要出现重复子面值的子序列

还是上面的代码,如果我们使用的字符串是"aaa",则会打印

...
console.log(main("aaa"))
//output:
//['','a','a','aa','a','aa','aa','aaa']

会发现有很多重复部分,想要去除中间重复的部分也非常简单,只要我们把数组改成Set类型便可以去重,完整代码如下


/**
 * @param {string} strArr 原字符串数组(固定参数)
 * @param {string} store 存储所有的子序列数组
 * @param {number} index 当前所在的位置
 * @param {string} path 当前已经拼接的路径
 */
function process1(strArr: string[], store: Set<string>, index: number, path: string) {
    if (index == strArr.length) {
        return store.add(path)
    }
    //不选中当前位置参加path
    process1(strArr, store, index + 1, path)
    //选中当前位置参加path
    process1(strArr, store, index + 1, path + strArr[index])
}

function main(str: string): string[] {
    const ans:Set<string> = new Set()
    const strArr = str.split("");
    process1(strArr, ans, 0, "")
    return [...ans]
}

console.log(main("aaa"))

//output:
//[ '', 'a', 'aa', 'aaa' ]

练习四:打印一个字符串的全部排列

示例:字符串abc的全部排列为 abc acb bac bca cab cba
解题思路:我们可以先从三个字符串中选中一个填到第一个位置,然后从剩余两个中选中一个填到第二个位置,最后一个数放到最后一个位置(已经填好的数不再重复用) 全排列.png 代码如下:

/**
 * @description: 交换数组下标内容
 * @param {*} arr 数组
 * @param {*} i i位置
 * @param {*} j j位置
 */
function swap(arr: any[], i: number, j: number) {
    let cache = arr[i]
    arr[i] = arr[j]
    arr[j] = cache
}
/**
 * @description: 从index位置出发,数组中index之前位置已经确定,后续所有的方法
 * @param {string} strArr 字符串数组(index之前是固定好的)
 * @param {string} store 存储所有的全排列数组
 * @param {number} index 当前所在的位置
 */
function process1(strArr: string[], index: number, store: string[]) {
    if (index == strArr.length) {
        store.push(strArr.join(""))
    }
    for (let i = index; i < strArr.length; i++) {
        //index后面的任意位置都可以当做当前位置数据
        swap(strArr, index, i)
        process1(strArr, index + 1, store)
        //恢复现场
        swap(strArr, i, index)
    }
}



function main(str: string): string[] {
    const ans: string[] = []
    const strArr = str.split("");
    process1(strArr, 0, ans)
    return ans
}

console.log(main("abc"))

//output:
//[ 'abc', 'acb', 'bac', 'bca', 'cba', 'cab' ]

练习五:打印一个字符串的全部排列,要求不出现重复的排列

还是上面的代码,如果我们使用的字符串是"aaa",则会打印

...
console.log(main("aac"))
//output:
//[ 'aac', 'aca', 'aac', 'aca', 'caa', 'caa' ]

会发现会有很多重复值,想要去除重复,我们可以在代码中做一些剪枝策略(下面会讲)

/**
 * @description: 交换数组下标内容
 * @param {*} arr 数组
 * @param {*} i i位置
 * @param {*} j j位置
 */
function swap(arr: any[], i: number, j: number) {
    let cache = arr[i]
    arr[i] = arr[j]
    arr[j] = cache
}
/**
 * @description: 从index位置出发,数组中index之前位置已经确定,后续所有的方法
 * @param {string} strArr 字符串数组(index之前是固定好的)
 * @param {string} store 存储所有的全排列数组
 * @param {number} index 当前所在的位置
 */
function process1(strArr: string[], index: number, store: string[]) {
    if (index == strArr.length) {
        store.push(strArr.join(""))
    }
    let cache:boolean[] = new Array(128).fill(false) //构建一个长度为128的数组(asc码的范围是0-127)
    for (let i = index; i < strArr.length; i++) {
        const ascCode = strArr[i].charCodeAt(0)
        if(cache[ascCode]) continue;    //遇到了重复的直接忽略
        cache[ascCode] = true
        //index后面的任意位置都可以当做当前位置数据
        swap(strArr, index, i)
        process1(strArr, index + 1, store)
        //恢复现场
        swap(strArr, i, index)
    }
}


function main(str: string): string[] {
    const ans: string[] = []
    const strArr = str.split("");
    process1(strArr, 0, ans)
    return ans
}

console.log(main("aac"))

//output:
//[ 'aac', 'aca', 'caa' ]

当然我们也可以像子序列去重复那样,直接用一个Set类型进行去重(过滤),但是这种方式在速度上更加快,在下一次递归之前就提前将这个分支进行杀死,这种方式也称为剪枝策略
剪枝要比过滤要快, 因为过滤是你没有省掉所有的情况,所有情况你都来了,只是你最后一步过滤它,提前杀死肯定少走很多路

练习六:栈逆序问题

一个栈依次压入1、2、3、4、5,那么从栈顶到栈底分别为5、4、3、2、1。将这个栈转置后,从栈顶到栈底为1、2、3、4、5,也就是实现栈中元素的逆序,但是只能运用递归函数来实现,不能使用其他数据结构。

栈逆序1.png 解题思路:每个栈在执行的时候都会缓存一些活动变量,通过这些活动变量可以缓存栈中的值,从而达到逆序的效果,代码如下

/**
 * @description: 移除栈底元素并获取该元素的值
 * @param {T} stack 栈
 * @return {*} 栈底元素的值
 */
function getAndRemoveLastElement<T>(stack:T[]):T{
    let result = stack.pop();
    if(stack.length==0){
        return result;
    }
    const lastElement = getAndRemoveLastElement(stack)
    stack.push(result)
    return lastElement
}


/**
 * @description: 递归反转栈元素
 * @param {T} stack 栈
 */
function reverse<T>(stack:T[]){
    if(stack.length==0){
        return
    }
    let i = getAndRemoveLastElement(stack)
    reverse(stack)
    stack.push(i)
}

/**
 * @description:  用数组来模拟栈结构,但是栈是后进先出的方式 假设只能使用push pop
 * @return {*}  逆序栈
 */
function main(){
    const stack = [1,2,3];
    reverse(stack)
    return stack
}

console.log(main());
//output:
//[ 3, 2, 1 ]

如果从stack的栈顶到栈底依次为3、2、1,getAndRemoveLastElement函数的具体过程如下图所示,

栈逆序3.png getAndRemoveLastElement我们简称 get 方法,表示移除并返回当前栈底元素。 栈逆序2.png

总结一: 暴力递归尝试的步骤

  1. 把大问题转化为规模缩小的同类问题的子问题
  2. 有明确的不需要继续进行递归的条件,以后我们称之为baseCase
  3. 有当得到了子问题的结果后决策的过程
  4. 不记录每一个子问题的解

总结二:做递归要有黑盒思维

比如说你要做一个递归函数,你就规定好这个黑盒(F函数), 这个函数它满足什么样的条件。条件包括显示的条件和隐藏的条件潜台词, 就当你这个F函数它的含义固定了,你就把它作黑盒来用。
对于这个黑盒来说,最重要的是它的含义。输入什么,达到什么效果,遵循什么样的限制条件,规定好,basecase想好规定好, (basecase就是什么时候就不用再分解问题了, 直接就能出来规定好), 接下来就想我怎么用这个黑盒。
设计黑盒函数不要太在意细节。先把黑盒规定好, 接下来就是这个黑盒怎么用问题。