一个业务需求引起的回溯算法思考

作为一名合格的前端搬砖工程师来讲,真正用到算法的地方其实并不多,即使这样,我们也需掌握一些常见的算法思想,无论是找工作还是平时写代码上都会有意想不到的帮助

需求分析

根据实际工作上的产品需求稍作改变,自己画了一个简单的原型图

如上,两个多选级联框,右侧有 合并分析逐一分析 两个选项,当条件1选择合并分析的时候,这个多选框里选择的多项数据作为一个整体,与条件2里多选框里的每一个选中数据依次合并,并生成合并后的表格。

完成需求

冷不丁这么一瞅,这不就是一个妥妥的两两组合吗,于是乎,两个for循环暴力解决。

js 复制代码
const combination = (list1, list2) => {
    let result = []
    for (let i = 0; i < list1.length; i++) {
        let temp = []
        temp.push(list1[i])
        for (let j = 0; j < list2.length; j++) {
            temp.push(list2[j])
            result.push([...temp])
            // 存入结果后就吐出来
            temp.pop()
        }
    }
    return result
}

combination(['a'], [1,2]) // => [['a', 1], ['a', 2]]

假设我们现在选中的数据为:

条件1:[{name: '前端'}, {name: '后端'}]

条件2:[{name: 'react'}, {name: 'vue'}]

按照上述代码中的例子,我们只要把数据组装成 [条件1], ['条件2选项1', '条件2选项2']即可

js 复制代码
combination([[{name: '前端'}, {name: '后端'}]], [[{name: 'react'}], [{name: 'vue'}]])
// 得到结果
[
    [
        [{"name": "前端"},{"name": "后端"}],
        [{"name": "react"}]
    ],
    [
        [{"name": "前端"},{ "name": "后端"}],
        [{"name": "vue"}]
    ]
]

得到结果后就可以愉快的画表格了,但是将来如果我们再有一个条件想要组合的话(也就是说我想组合['a','b'], [1,2], ['c','d'] => ['a',1,'c']['a',2,'d']...),难道有几个条件就嵌套几个for循环吗,显然这是不可能的。

为了避免产品哪天突发奇想修改了需求,要求在两两组合的基础上再加一个条件变成三三组合或者四四组合,于是我开始了漫长的探索路程。

回溯算法

回溯算法的基本思想是:从问题的某一种状态(初始状态)出发,搜索从这种状态出发所能达到的所有 状态,当一条路走到 尽头 的时候(不能再前进),再后退一步或若干步,从另一种可能 状态 出发,继续搜索,直到所有的 路径(状态)都试探过。这种不断前进、不断 回溯(后退) 寻找解的方法,就称作回溯法

回溯算法的本质其实就是一种暴力穷举法 ,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些 剪枝(跳过一些选择) 的操作,但也改变不了回溯就是穷举的本质。

解决哪些问题?

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等

如何理解回溯法?

回溯法解决的问题都可以抽象为树形结构,所有回溯法的问题都可以抽象为树形结构!

因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度。有递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

回溯代码框架模板

js 复制代码
function backtracking(路径,选择列表) {
    // 终止递归
    if (条件) {
        return
    }
    
    for (选择:本层数组中的元素(树中节点孩子的数量就是数组的长度)) {
        // 选择节点,做出选择
        // 路径.push(选择)
        backtracking(路径,选择列表); // 递归
        // 回溯,撤销选择
        // 路径.pop()
    }
}

可能有些人就不明白了,为什么要在递归前做出选择,在递归后撤销选择就行了?我们先回忆一下二叉树的 前序遍历后序遍历

前序遍历先遍历根节点,再遍历左节点,最后遍历右节点

后序遍历先遍历左节点,再遍历右节点,最后遍历根节点

伪代码就是:

js 复制代码
 fuction traverse(root) {
     if (root === null) return
     // 前序遍历
     console.log(root.val)
     traverse(root.left)
     traverse(root.right)
     // 后序遍历
     console.log(root.val)
 }

是不是和回溯的伪代码有些类似?其实所谓的前序遍历后序遍历 ,他们只是两个很有用的时间点,前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行。所以我们只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节点的选择列表和路径。

递归执行流程:递归之前的代码立即执行,递归之后的代码,在没有遇到终止本次递归条件之前,你可以认为它是先存放于一个栈里,等遇到终止条件后,再依次从栈顶取出来执行,终止本次递归后它的状态就回到了第一次进入递归之前的下一次循环中

全排列

假设我们现在要对数组 [1,2,3] 进行全排列,则抽象为树结构就是:

横向遍历 就是遍历数组[1,2,3]纵向遍历 就是重复横向遍历,也就是递归,或者说是循环嵌套。我们只要从根遍历这棵树,收集 节点 之间 路径 上的数字,就能得到全排列的结果

由于全排列不允许重复使用元素,所以我们必须做相应的 剪枝 操作,按照上面提供的回溯代码框架模板 ,那么对于数组 [1,2,3] 进行全排列的代码,就可以写成:

js 复制代码
function allPermute(nums) {
    let res = []; // 存储最终结果
    let track = []; // 存储路径
    let used = []; // 标记元素是否用过

    function backtrack(nums) {
        // 终止递归条件,当收集的路径长度等于数组长度,把结果存入
        if (track.length === nums.length) {
            res.push([...track]);
            return;
        }

        for (let i = 0; i < nums.length; i++) {
            // 剪枝,使用过的元素不重复使用
            if (used[i]) {
                // nums[i] 已经在 track 中,跳过
                continue;
            }
            // 做选择
            track.push(nums[i]);
            used[i] = true; // 标记当前元素使用过
            console.log('前', i, used, track)
            // 递归,进入树的下一层,即下一层嵌套循环
            backtrack(nums);
            // 取消选择
            track.pop();
            used[i] = false; // 取消标记
            console.log('后', i, used, track)
        }
    }
    backtrack(nums);
    return res;
}

allPermute([1,2,3])
        // A节点选择到B节点 
第一次: i = 0, used[0] => true, used = [true, false, false],track = [1] 进入递归

第二次: i = 0, used[0] = true,continue 跳过
        // B节点选择到C节点(E节点稍后再选)
        i = 1, used[0] => true, used = [true, true, false], track = [1, 2] 进入递归
        
第三次  i = 0, used[0] = true, continue 跳过
        i = 1, used[1] = true, continue 跳过
        // C节点没得选到D节点
        i = 2, used[2] => true, used = [true, true, true], track = [1, 2, 3]  进入递归
        // ** track.length === nums.length 条件成立结束递归,执行递归后的代码**
        // D节点回到C节点
        i = 2, used[2] => false, used = [true, true, false], track = [1,2] 
        // C节点没有其他可选了回到B节点
        i = 1, used[1] => false, used = [true, false, false], track = [1] 
        
        // B节点除了C节点还有E节点可选,C节点已选过,于是重新选择E节点,此时
        i = 2,used[2] => true, used = [true, false, true], track = [1, 3] 进入递归
        i = 0, used[0] = true, continue跳过
        
        // E 节点只有F节点可选
        i = 1, used[1] => true, used = [true, true, true], track = [1,3,2] 进入递归
        // ** track.length === nums.length 条件成立结束递归,执行递归后的代码**
        // F 节点回到E节点
        i = 1, used[1] => false, used = [true, false, true], track = [1,3]
        // E 节点回到B节点
        i = 2, used[2] => false, used = [true, false, false], track = [1]
        // B 节点回到A节点
        i = 0, used[0] => false, used = [false, false, false], track = []
        
        // A节点重新做选择
        i = 1, used[1] => true, used = [false, true, false], track = [2]
        ....

或许你对树的结构并不是那么的理解,那么你可以认为回溯代码中的每执行一次递归,其实就是每次嵌套一层for循环 ,由于进入下一次递归(即下一层嵌套循环),数组的遍历都是从头开始的,所以需要进行合适的 剪枝。数组[1,2,3]的长度为3,如果用迭代方式写的话,就只能嵌套三层for循环来解决。

组合

回到最开始的问题,要使['a', 'b'][1,2]两两组合,暴力解决就是两个循环嵌套。可如果要实现三三组合四四组合或者更多的组合呢?显然一直嵌套循环下去是不可能的,那有没有更优雅的写法呢?答案是有的。

要实现组合,我们把循环嵌套的写法换成递归的写法,不管是几几组合,我们都可以把每一个数组放到一个大数组里,用一个 index 表示当前遍历到哪个数组,每次递归 index + 1,就是遍历下一个数组,再根据模板框架,代码就可以写成如下:

js 复制代码
const combine = (...list) => {
  let res = [], track = []
  const fn = (list, index = 0) => {
    // 结束递归
    if (list.length === track.length) {
      res.push([...track])
      return
    }
    for (let i = 0; i < list[index].length; i++) {
      // 做选择
      track.push(list[index][i])
      // 递归,遍历下一个数组
      fn(list, index + 1)
      // 撤销选择
      track.pop()
    }
  }
  fn(list)
  return res
}

leetcode 第77题 组合

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例 1:

输入: n = 4, k = 2

输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4] ]

示例 2:

输入: n = 1, k = 1

输出: [[1]]

js 复制代码
var combine = function(n, k) {
    let result = [], track = []
    function fn(start = 1) {
        // 结束递归条件
        if (track.length === k) {
            result.push([...track])
            return
        }
        for (let i = start; i <= n; i++) {
            track.push(i)
            // 通过 start 参数控制树枝的遍历,避免产生重复的子集
            fn(i + 1)
            track.pop()
        }
    }
    fn()
    return result
};

总结

万变不离其宗,回溯法解决的 子集/排列/组合 问题,建议从的角度去分析,那么这些问题看似复杂多变,实则改改 base case 就能解决,以下总结一些解题模板,以后碰到类似问题相信都可以迎刃而解了。

元素无重不可复选

数组中的元素都是唯一的,每个元素最多只能使用一次

js 复制代码
// 组合/子集问题回溯算法框架
function backtrack(list, start) {
    // 回溯算法标准框架
    for (let i = start; i < list.length; i++) {
        // 做选择
        track.push(list[i]);
        // 注意参数
        backtrack(list, i + 1);
        // 撤销选择
        track.pop();
    }
}

// 排列问题回溯算法框架
function backtrack(list) {
    for (let i = 0; i < list.length; i++) {
        // 剪枝逻辑
        if (used[i]) {
            continue;
        }
        // 做选择
        used[i] = true;
        track.push(list[i]);
        backtrack(list);
        // 撤销选择
        track.pop();
        used[i] = false;
    }
}

元素可重不可复选

数组中的元素可以存在重复,每个元素最多只能使用一次,其关键在于排序和剪枝

js 复制代码
function backtrack(nums, start, track = [], used = []) => {
    nums = nums.sort((a, b) => a - b);
    // 满足条件,结束递归
    if (track.length === nums.length) {
        // 结果处理
        return;
    }
    for (let i = start; i < nums.length; i++) {
        // 剪枝逻辑,跳过值相同的相邻树枝
        if (i > start && nums[i] === nums[i - 1]) {
            continue;
        }
        // 做选择
        track.push(nums[i]);
        used[i] = true;
        // 注意参数
        backtrack(nums, i + 1, track, used);
        // 撤销选择
        track.pop();
        used[i] = false;
    }
};

元素无重可复选

数组中的元素都是唯一的,每个元素可以被使用若干次,即不需要去重逻辑

js 复制代码
function backtrack(nums, start) {
    // 回溯算法标准框架
    for (var i = start; i < nums.length; i++) {
        // 做选择
        track.push(nums[i]);
        // 注意参数
        backtrack(nums, i);
        // 撤销选择
        track.pop();
    }
};

function backtrack(nums) {
    // 排列问题回溯算法框架
    for (var i = 0; i < nums.length; i++) {
        // 做选择
        track.push(nums[i]);
        backtrack(nums);
        // 撤销选择
        track.pop();
    }
};
相关推荐
qystca24 分钟前
洛谷 P11242 碧树 C语言
数据结构·算法
冠位观测者31 分钟前
【Leetcode 热题 100】124. 二叉树中的最大路径和
数据结构·算法·leetcode
悲伤小伞37 分钟前
C++_数据结构_详解二叉搜索树
c语言·数据结构·c++·笔记·算法
m0_675988232 小时前
Leetcode3218. 切蛋糕的最小总开销 I
c++·算法·leetcode·职场和发展
佳心饼干-4 小时前
C语言-09内存管理
c语言·算法
dbln4 小时前
贪心算法(三)
算法·贪心算法
songroom5 小时前
Rust: offset祼指针操作
开发语言·算法·rust
chenziang17 小时前
leetcode hot100 环形链表2
算法·leetcode·链表
Captain823Jack8 小时前
nlp新词发现——浅析 TF·IDF
人工智能·python·深度学习·神经网络·算法·自然语言处理
Captain823Jack9 小时前
w04_nlp大模型训练·中文分词
人工智能·python·深度学习·神经网络·算法·自然语言处理·中文分词