前言
本文将介绍两种算法设计技巧:贪心算法与回溯算法,并用TypeScript将其实现,欢迎各位感兴趣的开发者阅读本文。
贪心算法
贪心算法遵循一种近似解决问题的技术,期盼通过每个阶段的局部最优选择(当前最好的解),从而达到全局的最优。
实例讲解
接下来我们通过两个例子讲解下贪心算法。
最少硬币找零问题
最少硬币找零问题也可以用贪心算法来解决,大部分情况下的结果都是最优的,不过对于有些面额而言,结果不会是最优的。
实现思路
- 需要两个参数:硬币面额
coins
、找零金额amount
- 声明辅助变量
change
,用于存储找零方案 - 声明辅助变量
total
,用于存储当前已找零金额 - 从大到小遍历
coins
- 取出当前遍历到的面额,判断当前取出的面额加上total,其值是否小于
amount
- 如果小于等于,则执行while循环,将当前面额放入找零方案中,total的值加上当前面额
- 否则退出while循环,继续下一轮for循环,直至
coins
被取完
- 循环结束,找零方案已计算完毕,返回找零方案
change
实现代码
接下里我们将上述思路转换为代码,我们继续使用上一篇文章中创建的
DesignSkills.ts
文件,在其中添加如下代码。
knapSackGreedy(capacity: number, weights: number[], values: number[]): number { const n = values.length; // 已装入背包的物品总重量 let load = 0; // 已装入背包的物品总价值 let val = 0; for (let i = 0; i < n && load < capacity; i++) { // 物品可以完整的放入背包 if (weights[i] <= capacity - load) { // 将物品的价值计入背包已装入物品的总价值 val += values[i]; // 将物品的重量计入背包已装入物品的总重量 load += weights[i]; } else { // 物品无法完整的放入背包,计算能够装入部分的比例 const r = (capacity - load) / weights[i]; // 将计算出的物品价值计入背包已装入物品的总价值 val += r * values[i]; // 将物品的重量计入背包已装入物品的总重量 load += weights[i]; } } // 返回物品总价值 return val; }
编写测试代码
我们通过一个例子来测试下上述代码能否正确执行
const designSkills = new DesignSkills(); const result = designSkills.minCoinChangeGreedy([1, 5, 10, 25], 8); console.log(`找零方案: ${result}`);
背包问题
用动态规划只能解决整数背包问题,而贪心算法可以解决分数背包问题,我们使用动态规划中举的例子来看下分数背包问题。
如下所示:
物品 | 重量 | 价值 |
1 | 2 | 3 |
2 | 3 | 4 |
3 | 4 | 5 |
背包的容量为5,要将上述物品放进背包里,最佳方案是放入物品1和物品2,总重量为5,总价值为7。
使用贪心算法解决容量为5的背包,得到的结果是一样的,此处我们考虑背包容量为6的情况。
在这种情况下,解决方案是装入物品1和物品2,再装入25%的物品3,总价值为8.25。
实现思路
接下来,我们来看看如何用贪心算法解决上述分数背包问题。
- 需要3个参数:背包容量
capacity
、物品重量weights
、物品价值values
- 声明三个辅助变量:物品数量
n
、已装入背包的物品总重量load
、已装入背包的物品总价值val
。 - 遍历背包中的物品,终止条件为当前遍历到的元素小于
n
且load
小于capacity
- 如果当前遍历到的物品重量
weights[i]
小于等于背包容量capacity
- 以装入背包的物品总量load
,则代表物品可以完整的放入背包,将当前物品的重量和价值计入已装入背包中 - 否则,物品无法完整放入背包,计算能够装入部分的比例,计算方法为:(背包容量-已装入背包的物品总重量)/ 当前要放入背包的物品重量
- 用计算出来的比例*当前物品的价值,将得出的结果计入以装入背包的物品总价值中。将当前物品的重量的计入已装入背包的总重量中。
- 遍历结束,物品价值计算完毕,返回已装入物品总价值。
实现代码
接下来,我们将上述思路转换为代码。
/** * 贪心算法: 背包问题 * @param capacity 背包容量 * @param weights 物品重量 * @param values 物品价值 */ knapSackGreedy( capacity: number, weights: number[], values: number[] ): { val: number; compose: ({ dart: boolean; scale: number; id: number } | { dart: boolean; id: number })[] } { const n = values.length; // 存储解决方案 const compose = []; // 已装入背包的物品总重量 let load = 0; // 已装入背包的物品总价值 let val = 0; for (let i = 0; i < n && load < capacity; i++) { // 物品可以完整的放入背包 if (weights[i] <= capacity - load) { // 将物品的价值计入背包已装入物品的总价值 val += values[i]; // 将物品的重量计入背包已装入物品的总重量 load += weights[i]; // 当前物品可以完整放入,将物品编号放入组合方案中 compose.push({ id: i, dart: false }); } else { // 物品无法完整的放入背包,计算能够装入部分的比例 const r = (capacity - load) / weights[i]; // 将计算出的物品价值计入背包已装入物品的总价值 val += r * values[i]; // 将物品的重量计入背包已装入物品的总重量 load += weights[i]; // 当前物品无法完整放入,将物品编号和可放物品比例放入组合方案中 compose.push({ id: i, dart: true, scale: r }); } } // 返回物品总价值 return { val: val, compose: compose }; }
编写测试代码
我们用一开始的例子,测试下上述代码是否正确执行。
const designSkills = new DesignSkills(); const values = [3, 4, 5], weights = [2, 3, 4], capacity = 6; console.log("容量为6的背包其解决方案为:", designSkills.knapSackGreedy(capacity, weights, values));
回溯算法
回溯是一种渐进式寻找并构建问题解决方式的策略,我们会从一个可能的动作开始试着用这个动作解决问题。如果不能解决,就回溯选择另一个动作直到问题解决。
回溯算法会尝试所有可能的动作(如果更快找到了解决办法就尝试较少的次数)来解决问题。
实例讲解
接下来我们通过两个例子来讲解下回溯算法。
迷宫老鼠问题
迷宫老鼠问题的规则如下:
- 给定一个大小为
N*N
的矩阵,矩阵的每个位置都是一个方块。 - 每个位置的值为0或1
- 0表示这个格子有障碍物不能走,1表示这个格子为空闲状态可以走
如下表所示为一个矩阵,其中S是起点,D是重点
S | |||
D |
矩阵就是迷宫,老鼠的目标就是从S
位置移动到D
位置,老鼠可以在垂直或水平方向上任意值为1的格子间移动。
接下来,我们来看一个具体的例子,下表描述了一个迷宫:
M | 0 | 1 | 2 | 3 |
0 | 1 | 0 | 0 | 0 |
1 | 1 | 1 | 1 | 1 |
2 | 0 | 0 | 1 | 0 |
3 | 0 | 1 | 1 | 1 |
只有格子为1时,老鼠才能移动,所以上述迷宫老鼠移动轨迹为:
[0][0] -> [1][0] -> [1][1] -> [1][2] -> [2]][2] -> [3][2] -> [3][3]
实现思路
上述迷宫的老鼠运动轨迹是经过大脑+眼睛+手配合得出的解决方案,那么如何利用回溯算法来得出上述答案?接下来我们就来看下其实现思路。
判断格子是否可走会用到递归,因此该算法分为2部分,我们先来看看算法的主体实现
- 接收一个参数
maze
,其类型为一个二维数组,表示迷宫主体。 - 声明辅助变量
solution
,用于存放解决方案 - 初始化
solution
,将所有格子填充为o
- 从起始位置
[0][0]
开始寻找路径,更新solution
- 寻找路径方法返回
true
则返回solution
,否则返回无解
再然后,我们来看看寻找路径的递归函数的实现
- 寻找路径函数接收4个参数:横纵坐标
x, y
、迷宫maze
、解决方案solution
- 由于该函数为递归实现,因此我们先确立递归的基准条件:当x和y都到终点时。即:
x = n-1 && y = n-1
,满足条件时,我们将解决方案的最后一个位置标为1然后返回解决方案 - 判断迷宫
x,y
位置的值是否可走,判断条件为:x和y的值必须大于等于0且x和y的值必须必须小于迷宫的长度且x,y
位置的值不为0 - 如果可以走,则将
solution
该格子的值改为1 - 随后,老鼠的位置向下移动一格,即
x+1
,用新的值递归调用寻找路径函数 - 向下移动的过程中,如果遇到格子的值为0时,则向右移动老鼠的位置,即
y+1
,用新的值递归调用寻找路径函数。 - 上述两个条件都无法满足,则表示老鼠水平和垂直都不能移动,则将该格子的值改为0,表示无法移动,回溯,即将当前层从递归栈中移除,寻找另一种解决方案。
- 当所有方案都尝试完毕后还是未能找到解,则代表该迷宫无解,返回false。
接下来,我们把上述实现思路应用到一开始我们举的例子中,最终构成的解决方案如下表所示。
S | 0 | 1 | 2 | 3 |
0 | 1 | 0 | 0 | 0 |
1 | 1 | 1 | 1 | 0 |
2 | 0 | 0 | 1 | 0 |
3 | 0 | 0 | 1 | 1 |
实现代码
接下来,我们将上述思路转换为代码。
/** * 回溯算法:迷宫老鼠问题 * * @param maze 迷宫 */ ratInAmaze(maze: number[][]): number[][] | string { // 解决方案 const solution: number[][] = []; for (let i = 0; i < maze.length; i++) { solution[i] = []; for (let j = 0; j < maze[i].length; j++) { solution[i][j] = 0; } } // 寻找路径 if (this.findPath(0, 0, maze, solution)) { // 返回解决方案 return solution; } // 无解 return "此迷宫无解"; } // 寻找路径 findPath(x: number, y: number, maze: number[][], solution: number[][]): boolean { const n = maze.length; // 递归基准条件:老鼠走到了迷宫的尽头 if (x === n - 1 && y === n - 1) { // 将最后一个位置标记为路径的一部分 solution[x][y] = 1; return true; } // 判断老鼠能否安全移动到该位置 if (this.isSafe(maze, x, y)) { // 该位置可以移动,将其标注为可移动 solution[x][y] = 1; // 沿着迷宫的行移动 if (this.findPath(x + 1, y, maze, solution)) { return true; } // 沿着迷宫的列移动 if (this.findPath(x, y + 1, maze, solution)) { return true; } // 水平和垂直都无法移动,将这步路径标注为不可移动 solution[x][y] = 0; // 回溯,即将当前层从递归栈中移除,尝试另一种解决方案 return false; } // 所有移动方案都尝试完毕,都无法移动,则退出当前递归 return false; } // 验证此位置是否能走 isSafe(maze: number[][], x: number, y: number): boolean { const n = maze.length; // x和y必须大于等于0且迷宫的第x行y列不能为0老鼠就可以走 return x >= 0 && y >= 0 && x < n && y < n && maze[x][y] !== 0; }
编写测试代码
const designSkills = new DesignSkills(); // 迷宫老鼠问题 const RatResult = designSkills.ratInAmaze([ [1, 0, 0, 0], [1, 1, 1, 1], [0, 0, 1, 0], [0, 1, 1, 1] ]); console.log(RatResult);
数独解题器
数独的游戏规则如下:
- 由一个
9*9
的矩阵组成 - 矩阵的每行每列都由
1~9
这9个数字组成,且不重复 - 矩阵中还包含了
3*3
的小矩阵,同样由9个数字组成,且不重复。 - 游戏开始前会提供一个数独矩阵,它填充了部分数字,未填充部分用0表示
我们通过一个例子来讲解下,如下表所示,准备了一个数独,它填充了部分数字。
S | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
0 | 5 | 3 | 7 | ||||||
1 | 6 | 1 | 9 | 5 | |||||
2 | 9 | 8 | 6 | ||||||
3 | 8 | 6 | 3 | ||||||
4 | 4 | 8 | 3 | 1 | |||||
5 | 7 | 2 | 6 | ||||||
6 | 6 | 2 | 8 | ||||||
7 | 4 | 1 | 9 | 5 | |||||
8 | 8 | 7 | 9 |
我们根据上述规则,将剩余格子填充,填充好的数独如下所示:
S | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
0 | 5 | 3 | 4 | 6 | 7 | 8 | 9 | 1 | 2 |
1 | 6 | 7 | 2 | 1 | 9 | 5 | 3 | 4 | 8 |
2 | 1 | 9 | 8 | 3 | 4 | 2 | 5 | 6 | 7 |
3 | 8 | 5 | 9 | 7 | 6 | 1 | 4 | 2 | 3 |
4 | 4 | 2 | 6 | 8 | 5 | 3 | 7 | 9 | 1 |
5 | 7 | 1 | 3 | 9 | 2 | 4 | 8 | 5 | 6 |
6 | 9 | 6 | 1 | 5 | 3 | 7 | 2 | 8 | 4 |
7 | 2 | 6 | 7 | 4 | 1 | 9 | 6 | 3 | 5 |
8 | 3 | 4 | 5 | 2 | 8 | 6 | 1 | 7 | 9 |
实现思路
接下来,我们根据上述规则,来看下如何将其实现。
由于是回溯问题,因此我们需要用到递归,我们先来看看算法的主体实现。
- 接收一个参数
matrix
,即数独。 - 调用递归函数,填充数独。
- 如果递归函数将数独填充完毕,则返回填充好的数独。否则返回错无解。
我们来看看递归函数的实现。
- 接收一个参数
matrix
,即待填充的数独 - 我们声明三个辅助变量
row, col, checkBankSpaces
分别用于描述数独的行、列、当前格子是否为空 - 遍历数独,寻找空格子,记录空格子的位置,即:
row, col
- 递归基线条件:格子不为空
- 为空格子填充数字,判断其是否满足数独的填充规则
- 如果满足规则就往空格子填充对应的数字
- 继续递归,寻找空格子进行填充
- 所有数字都尝试完后,仍然不满足规则,就填充0
- 回溯,返回上一个递归栈
检查值是否满足填充规则的条件如下:
- 当前填充的数字在其行中不重复
- 当前填充的数字在其列中不重复
- 当前填充的数字在其
3*3
的矩阵中不重复
实现代码
接下来,我们将上述实现思路转换为代码。
/** * 数独解题器 * 游戏规则: * 1. 用数字1~9填满一个9*9的矩阵 * 2. 矩阵的每行每列都由1~9这九个数字组成,且不能重复 * 3. 矩阵还包含了3*3的小矩阵,同样需要用这9个数字填满,填充时其值所在的小矩阵中不能有重复的数字 * 4. 游戏开始前会提供一个数独矩阵,它填了部分数字,未填充部分用0表示 * @param matrix 数独矩阵 */ sudokuSolver(matrix: number[][]): number[][] | string { if (this.solveSudoku(matrix)) { return matrix; } return "此问题无解"; } /** * 解数独 * @param matrix 数独 * @private */ private solveSudoku(matrix: number[][]) { // 辅助变量用于描述数独的行和列 let row: number; let col = 0; // 检查格子是否为空 let checkBlankSpaces = false; // 寻找空格子 for (row = 0; row < matrix.length; row++) { for (col = 0; col < matrix[row].length; col++) { // 检测到空格子,终止内层循环 if (matrix[row][col] === this.UNASSIGNED) { checkBlankSpaces = true; break; } } // 格子为空终止外层循环 if (checkBlankSpaces) { break; } } // 格子不为空时终止递归 if (!checkBlankSpaces) { return true; } // 为空格子填充数字,判断其是否满足数独的填充规则 for (let num = 1; num <= 9; num++) { // 如果满足规则,就往空格子填充num if (DesignSkills.MatrixIsSafe(matrix, row, col, num)) { matrix[row][col] = num; // 递归,继续寻找空格子,然后填充 if (this.solveSudoku(matrix)) { return true; } // 所有数字都尝试完后,仍然不满足则填充0 matrix[row][col] = this.UNASSIGNED; } } // 回溯,即将返回到上一个递归栈 return false; } /** * 校验当前值是否冲突 * @param matrix * @param row * @param col * @param num * @private */ private static MatrixIsSafe(matrix: number[][], row: number, col: number, num: number): boolean { // 当num的值不再当前行,不在当前列,不在3*3的小格子中时则表示num不冲突 return ( !DesignSkills.usedInRow(matrix, row, num) && !DesignSkills.usedInCol(matrix, col, num) && !DesignSkills.usedInBox(matrix, row - (row % 3), col - (col % 3), num) ); } /** * 检测当前值是否在矩阵的指定行中 * @param matrix * @param row * @param num * @private */ private static usedInRow(matrix: number[][], row: number, num: number): boolean { for (let col = 0; col < matrix.length; col++) { if (matrix[row][col] === num) { return true; } } return false; } /** * 检测当前值是否在矩阵的指定列中 * @param matrix * @param col * @param num * @private */ private static usedInCol(matrix: number[][], col: number, num: number): boolean { for (let row = 0; row < matrix.length; row++) { if (matrix[row][col] === num) { return true; } } return false; } /** * 检测当前值是否在3*3的小矩阵中 * @param matrix * @param boxStartRow * @param boxStartCol * @param num * @private */ private static usedInBox(matrix: number[][], boxStartRow: number, boxStartCol: number, num: number): boolean { for (let row = 0; row < 3; row++) { for (let col = 0; col < 3; col++) { if (matrix[row + boxStartRow][col + boxStartCol] === num) { return true; } } } return false; }
编写测试代码
const designSkills = new DesignSkills(); // 数独解题器 const sudokuGrid = [ [5, 3, 0, 0, 7, 0, 0, 0, 0], [6, 0, 0, 1, 9, 5, 0, 0, 0], [0, 9, 8, 0, 0, 0, 0, 6, 0], [8, 0, 0, 0, 6, 0, 0, 0, 3], [4, 0, 0, 8, 0, 3, 0, 0, 1], [7, 0, 0, 0, 2, 0, 0, 0, 6], [0, 6, 0, 0, 0, 0, 2, 8, 0], [0, 0, 0, 4, 1, 9, 0, 0, 5], [0, 0, 0, 0, 8, 0, 0, 7, 9] ]; console.log(designSkills.sudokuSolver(sudokuGrid));
写在最后
- 公众号无法外链,文中链接请自行点击阅读原文查看。