1、背景需求:
“杨辉三角”不知道你听说过吗?我们现在对它进行一些改造。每个位置的数字可以随意填写,经过某个数字只能到达下面一层相邻的两个数字。
假设你站在第一层,往下移动,我们把移动到最底层所经过的所有数字之和,定义为路径的长度。请你编程求出从顶层移动到底层的最短路径长度。
咱们今天通过三种算法思想来解这个题:分别是回溯、动态回归、贪心等算法。
这三种算法思想各有自己适应的场景,简要的说:
贪心:一条路走到黑,就一次机会,只能哪边看着顺眼走哪边。不一定能求得满足要求的值。
回溯:一条路走到黑,无数次重来的机会,还怕我走不出来 (Snapshot View)
动态规划:拥有上帝视角,手握无数平行宇宙的历史存档, 同时发展出无数个未来 (Versioned Archive View)
2、数据结构描述这个三角形
思考:用什么样的数据结构来描述这个三角形呢?
合适的数据结构会放求解算法更便捷、合适。看到这个结构的时候,我一开始想到的是,结构很像二叉树,正准备用二叉树来描述,画了三层之后,发现不合适。思考了一会,用二维数组描述:
// 变形的杨辉三角的数据描述
const triangleElements = [
[5],
[7,8],
[2,3,4],
[4,9,6,1],
[7,8,9,4,5]
];
上述二维数组也就是用行、列来描述,直观图:
3、回溯方式解:
回溯的方式来解,就是把每一种可能的路径都暴力走一遍,记录下最小值。一般情况下,回溯用递归的方式实现。除了叶子节点外,每个节点往下走,都有两个走法:往左走(下一个列column值跟当前节点的列column值一样),往右走:(下一个列column值等于当前节点列值+1)
(没有做“备忘录”缓存优化)
// 变形的杨辉三角的数据描述
const triangleElements = [[5], [7,8], [2,3,4], [4,9,6,1], [7,8,9,4,5] ];
let minLen = Infinity; // 记录最小值,用来比较每种走法的结果,得出最短路径
/**
* 回溯方式解决【变形杨辉三角】最短路径:用递归方式,把每一条可能的路径都走完,找到最短路径
* @param {*} rowIndex, 当前行元素的index,
* @param {*} columnIndex , 当前列元素Index
* @param {*} len , 当前长度总和
* @param {*} route,记录路径
* @returns
*/
function findMinLenBT(rowIndex=0, columnIndex=0, len=triangleElements[0][0], route=[triangleElements[0][0]]){
console.log(`findMinLenBT :: enter, rowIndex = ${rowIndex}, columnIndex = ${columnIndex}, len = ${len}, minLen = ${minLen}, route = ${route}`)
// 退出条件
if(rowIndex === triangleElements.length-1){
if(minLen > len){
minLen = len; // 找到最小值
}
return;
}
const row = triangleElements[rowIndex]; // 当前行
for(let column=0; column<row.length; column++){
const nextRowIndex = rowIndex + 1
if(nextRowIndex < triangleElements.length){
// 这个判断很关键,每次只处理该列元素的值就好,因为每个元素往下只有两个元素可走
if(column === columnIndex){
const nextRow = triangleElements[nextRowIndex];
const leftRote = [...route]; // copy 一份路径记录
leftRote.push(nextRow[columnIndex])
// 走左边,行加1,列不变
findMinLenBT(rowIndex+1, columnIndex, len + nextRow[columnIndex], leftRote)
// 走右边,行加1,列也加1
const rightRote = [...route];
rightRote.push(nextRow[columnIndex+1])
findMinLenBT(rowIndex+1, columnIndex+1, len + nextRow[columnIndex+1], rightRote)
}
}
}
}
// 测试一下上述算法
function test1(){
findMinLenBT()
console.log(`test1 :: minLen = ${minLen}`) // minLen = 22
}
test1()
4、动态规划方式解:
动态规划常用的一个思考方式是:基于回溯暴力搜索(递归)画出递归树(部分即可),分析是否适合用动态规划,然后以表格的方式来记录每一个阶段的状态(当前值)。
画状态转移表:我们以当前走到的行、列来记录状态状态,将走到当前状态节点的路径总和记录在表格中,有部分单元格会计算两次,记录较小值的那一次。直接看状态表:
上述状态转移表画清楚之后,将其翻译成代码就容易一些了:
/**
* 创建一个表格(二维数组),主要用来生成状态二维数组
* @param {*} rows
* @param {*} columns
* @param {*} defaultValue
* @returns
*/
function createTableArr(rows, columns, defaultValue =0){
console.log(`createTableArr :: enter, rows = ${rows}, columns = ${columns}, defaultValue = ${defaultValue}`);
const table = [];
for(let i=0; i<rows; i++){
const rowArr = new Array(columns)
rowArr.fill(defaultValue)
table.push(rowArr)
}
return table;
}
// 变形杨辉三角的数据描述:
const triangleElements = [[5],[7,8],[2,3,4],[4,9,6,1],[7,8,9,4,5]];
/**
* 用动态规划方式求【变形杨辉三角】最短路径
* @param {*} data
* @returns
*/
function findMinLenDP(data) {
const sideCont = data.length;
const states = createTableArr(sideCont, sideCont, 0);
states[0][0] = data[0][0]; // 特殊处理一下开始状态
// 开始动态规划
for(let rowIndex = 0; rowIndex< sideCont -1; rowIndex ++ ){
const row = data[rowIndex];
const nextRowIndex = rowIndex+1;
const nextRow = data[nextRowIndex];
for(let column=0; column<row.length; column++){
const current = states[rowIndex][column];
// 看左下角的值
const left = current + nextRow[column];
if(states[nextRowIndex][column] === 0){
states[nextRowIndex][column] = left
}else{
if(states[nextRowIndex][column] > left){
states[nextRowIndex][column] = left
}
}
// 看右下角的值
const right = current + nextRow[column + 1];
if(states[nextRowIndex][column+1] === 0) {
states[nextRowIndex][column+1] = right
}else{
if(states[nextRowIndex][column+1] > right) {
states[nextRowIndex][column +1] = right
}
}
}
}
const totalMin = Math.min(...states[sideCont -1]); // 找到最短路径 , 值是22
const totalMinIndex = states[sideCont -1].findIndex(v => v===totalMin); // 找到最短路径和的状态索引
console.log(`findMinLenDP :: got result, totalMin = ${totalMin}, totalMinIndex = ${totalMinIndex}`);
console.log(states);
return {
totalMin,
}
}
function test2(){
findMinLenDP(triangleElements)
}
test2()
上面已经计算出最小值,如果要查找出其所走的节点路径要怎么做呢?
这里其实只要用状态表结合原始数据表就很容易算出来。每一个状态都是从上一个状态推导出来的,可以通过最终状态反推路径(可能有多条,这里只找一条):先在状态表(左侧)中找到最优解的列索引,用最优解的值减去数据表(右侧)对应位上的数据,就得到上一个状态,依次类推。需要注意当前已经处于边缘位置的场景。处于边缘位置,上一个状态的列索引就是为0,或者自己的列索引-1(当前状态节点位于三角形的边上)
撸代码(完整代码):
// 变形杨辉三角数据描述
const triangleElements = [[5],[7,8],[2,3,4],[4,9,6,1],[7,8,9,4,5]];
/**
* 用动态规划方式求【变形杨辉三角】最短路径值,同时计算出所经过的路径节点
* @param {*} data
* @returns
*/
function findMinLenDP2(data) {
const sideCont = data.length;
const states = createTableArr(sideCont, sideCont, 0);
states[0][0] = data[0][0];
// 开始动态规划
for(let rowIndex = 0; rowIndex< sideCont -1; rowIndex ++ ){
const row = data[rowIndex];
const nextRowIndex = rowIndex+1;
const nextRow = data[nextRowIndex];
for(let column=0; column<row.length; column++){
const current = states[rowIndex][column];
// 看左下角的值
const left = current + nextRow[column];
if(states[nextRowIndex][column] === 0){
states[nextRowIndex][column] = left
}else{
if(states[nextRowIndex][column] > left){
states[nextRowIndex][column] = left
}
}
// 看右下角的值
const right = current + nextRow[column + 1];
if(states[nextRowIndex][column+1] === 0) {
states[nextRowIndex][column+1] = right
}else{
if(states[nextRowIndex][column+1] > right) {
states[nextRowIndex][column +1] = right
}
}
}
}
const totalMin = Math.min(...states[sideCont -1]); // 找到最短路径,值为22
const totalMinIndex = states[sideCont -1].findIndex(v => v===totalMin); // 找到最短路径和的状态索引,值为3
console.log(`findMinLenDP2 :: got result, totalMin = ${totalMin}, totalMinIndex = ${totalMinIndex}`);
// 倒推该最优解所经过的节点
let min = totalMin;
let minIndex = totalMinIndex;
const route = [ data[sideCont -1][minIndex] ];
for(let rowIndex = sideCont -1; rowIndex > 0; rowIndex --){
let preStateRow = states[rowIndex -1];
let preDataRow = data[rowIndex -1];
let preIndex = 0;
if(minIndex ===0){
preIndex = 0
}else if(minIndex === preDataRow.length){
preIndex = minIndex - 1;
}else{
// 父节点有两个,两个值可能是一样的,为了方便起见,就只找一条路径就好。
let pLeft = minIndex-1, pRight = minIndex;
if(min === preStateRow[pLeft] + data[rowIndex][minIndex]){
preIndex = pLeft
}else{
preIndex = pRight
}
}
min = preStateRow[preIndex];
minIndex = preIndex;
route.push(data[rowIndex -1][minIndex]);
}
// 反转一下路径,从头开始看
route.reverse()
console.log(`findMinLenDP2 :: end, route = ${route}`);
console.log(states);
return {
totalMin,
route,
}
}
function test3(){
findMinLenDP2(triangleElements)
}
最短路径值为22,所经过的节点为:5,8,4,1,4
5、贪心算法解
下面看看贪心算法如何求解。贪心算法比较简单,为啥放到最后来讲,主要是他跟动态规划的逻辑很像。贪心算法就是从头开始,每一步都只取当前阶段的最优解,但是,结果并不一定是整体的最优解。只看眼前利益,不看整体利益。
贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,代码实现也更加简洁。不过,它可以解决的问题也更加有限。它能解决的问题需要满足三个条件,最优子结构、无后效性和贪心选择性(这里我们不怎么强调重复子问题)。
“贪心选择性”的意思是,通过局部最优的选择,能产生全局的最优选择。每一个阶段,我们都选择当前看起来最优的决策,所有阶段的决策完成之后,最终由这些局部最优解构成全局最优解。
贴分析图:
撸代码,求的值为25:
// 变形杨辉骚叫的数据描述:
const triangleElements = [[5],[7,8],[2,3,4],[4,9,6,1],[7,8,9,4,5]];
/**
* 用贪心算法思路求解【变形杨辉三角】最短路径
* @param {*} data
*/
function findMinLenTX(data){
const route = [];
let currentColumn = 0;
let minLen = data[0][0]; // 特殊处理初始值。
route.push(data[0][0]);
for(let rowIndex=0; rowIndex < data.length -1; rowIndex++) {
const nextDataRowIdex = rowIndex + 1;
const nextDataRow = data[nextDataRowIdex];
const left = nextDataRow[currentColumn];
const right = nextDataRow[currentColumn + 1];
// 决策下一步往哪走: 每一个阶段都选小的
if(left > right){
route.push(right);
minLen += right
currentColumn = currentColumn + 1;
}else{
route.push(left);
minLen += left
}
}
// minLen = 25
console.log(`findMinLenTX :: end, minLen = ${minLen}, route = ${route}`)
return minLen
}
function test4(){
findMinLenTX(triangleElements)
}
test4();
6,结果比较:
在该示例中:
回溯的方式求得最短路径为:22
动态规划方式求得最短路径为:22
贪心算法求得最短路径为:25