数据结构与算法-算法设计

《学习JavaScript数据结构与算法(第3版)》

分而治之

分而治之是算法设计中的一种方法。它将一个问题分成多个和原问题相似的小问题,递归解决小问题,再将解决方式合并以解决原来的问题。

分而治之算法可以分成三个部分。

  1. 分解原问题为多个子问题(原问题的多个小实例)。
  2. 解决子问题,用返回解决子问题的方式的递归算法。递归算法的基本情形可以用来解决子问题。
  3. 组合这些子问题的解决方式,得到原问题的解。

动态规划

动态规划(dynamic programming, DP)是一种将复杂问题分解成更小的子问题来解决的优化技术。

注意,动态规划和分而治之是不同的方法。分而治之方法是把问题分解成相互独立的子问题,然后组合它们的答案,而动态规划则是将问题分解成相互依赖的子问题。

用动态规划解决问题时,要遵循三个重要步骤:

  1. 定义子问题;
  2. 实现要反复执行来解决子问题的部分(递归);
  3. 识别并求解出基线条件。

应用问题:

  • 背包问题:给出一组项,各自有值和容量,目标是找出总值最大的项的集合。这个问题的限制是,总容量必须小于等于“背包”的容量。
  • 最长公共子序列:找出一组序列的最长公共子序列(可由另一序列删除元素但不改变余下元素的顺序而得到)。
  • 矩阵链相乘:给出一系列矩阵,目标是找到这些矩阵相乘的最高效办法(计算次数尽可能少)。相乘运算不会进行,解决方案是找到这些矩阵各自相乘的顺序。
  • 硬币找零:给出面额为d1, …, dn的一定数量的硬币和要找零的钱数,找出有多少种找零的方法。
  • 图的全源最短路径:对所有顶点对*(u, v),找出从顶点u到顶点v*的最短路径。
最少硬币找零问题

最少硬币找零问题是硬币找零问题的一个变种。硬币找零问题是给出要找零的钱数,以及可用的硬币面额d1,…, dn及其数量,找出有多少种找零方法。最少硬币找零问题是给出要找零的钱数,以及可用的硬币面额d1,…, dn及其数量,找到所需的最少的硬币个数。

例如:有面值1美分,5美分,10美分,25美分的硬币,如果要找36美分的零钱,如何找到所需最小硬币数。

// coins代表硬币面额 amount表示总零钱
function minCoinChange(coins, amount) {
    const cache = []; // 更加高效,不重复计算值

    const makeChange = (value) => { // 递归函数
        if (!value) { // value为0 则返回空数组 基线条件
            return [];
        }
        if (cache[value]) { // 检查结果缓存,如果有,则直接返回结果 基线条件
            return cache[value];
        }
        let min = []; // 找零硬币面额
        let newMin;
        let newAmount;
        // 遍历硬币面额,对每个面额都计算剩余价值
        for (let i = 0; i < coins.length; i++) {
            const coin = coins[i]; // 当前硬币面额
            newAmount = value - coin; // 一直减少,直到找到最小钱数
            if (newAmount >= 0) {
                // 进入递归
                newMin = makeChange(newAmount); // 计算找零结果
            }
            // newAmount是否有效,minValue最少硬币数是否最优解,minValue和newAmount是否是合理的值
            if (
                newAmount >= 0 &&
                (newMin.length < min.length - 1 || !min.length) &&
                (newMin.length || !newAmount)
            ) {
                // 则表示当前解为最优解
                min = [coin].concat(newMin);
            }
        }
        // 返回最少硬币结果
        return (cache[value] = min);
    };
    // 调用递归函数
    return makeChange(amount); 
}
背包问题(0-1背包)

背包问题是一个组合优化问题。它可以描述如下:给定一个固定大小、能够携重量W的背包,以及一组有价值和重量的物品,找出一个最佳解决方案,使得装入背包的物品总重量不超过W,且总价值最大。

这项从顶部开始构建解决方案的技术被称为记忆化,而解决方案就在表格或矩阵的右下角。

如题,背包总重量只有5,如何装可以使总价值最大?

背包问题

// 找到解决方案
function findValues(n, capacity, kS) {
    let i = n;
    let k = capacity;
    while (i > 0 && k > 0) { // 迭代二维数组
        if (kS[i][k] !== kS[i - 1][k]) {
            i--;
            k -= kS[i][k];
        } else {
            i--;
        }
    }
}

// capacity背包容量,weights重量数组,values价值数组,n为物品总数
function knapSack(capacity, weights, values, n) {
    const kS = []; // 结果矩阵
    for (let i = 0; i <= n; i++) { // 初始化寻找方案的二维矩阵
        kS[i] = []; 
    }
    for (let i = 0; i <= n; i++) {
        for (let w = 0; w <= capacity; w++) { // 背包容量迭代
            // 只处理索引不为0的列和行,并且迭代数组中每个可用的项
            if (i === 0 || w === 0) {
                kS[i][w] = 0;
            } else if (weights[i - 1] <= w) { // 物品i的重量小于当前背包容量,背包装得下
                // 计算当前背包价值
                const a = values[i - 1] + kS[i - 1][w - weights[i - 1]];
                // 不含当前物品的最优子集为上一个物品的当前背包容量的价值
                const b = kS[i - 1][w];
                kS[i][w] = a > b ? a : b; // max(a,b)
            } else { 
                // 当前物品大于背包容量,背包装不下,则当前最大价值是上一个物品的最优子集
                kS[i][w] = kS[i - 1][w];
            }
        }
    }
    findValues(n, capacity, kS);
    return kS[n][capacity];
}


// --------------------------------------------------
// 递归版本
function knapSack(capacity, weights, values, n) {
    if (n === 0 || capacity === 0) {
        return 0;
    }
    if (weights[n - 1] > capacity) {
        return knapSack(capacity, weights, values, n - 1);
    }
    const a = values[n - 1] + knapSack(capacity - weights[n - 1], weights, values, n - 1);
    const b = knapSack(capacity, weights, values, n - 1);
    return a > b ? a : b;
}
最长公共子序列(LCS)

找出两个字符串序列的最长子序列的长度。最长子序列是指,在两个字符串序列中以相同顺序出现,但不要求连续(非字符串子串)的字符串序列。

function lcs(wordX, wordY) {
    const m = wordX.length;
    const n = wordY.length;
    const l = [];
    for (let i = 0; i <= m; i++) {
        l[i] = [];
        for (let j = 0; j <= n; j++) {
            l[i][j] = 0;
        }
    }
    for (let i = 0; i <= m; i++) {
        for (let j = 0; j <= n; j++) {
            if (i === 0 || j === 0) {
                l[i][j] = 0;
            } else if (wordX[i - 1] === wordY[j - 1]) {
                l[i][j] = l[i - 1][j - 1] + 1;
            } else {
                const a = l[i - 1][j];
                const b = l[i][j - 1];
                l[i][j] = a > b ? a : b; // max(a,b)
            }
        }
    }
    return l[m][n];
}

// --------------------------------------
// 输出算法结果
function printSolution(solution, wordX, m, n) {
    let a = m;
    let b = n;
    let x = solution[a][b];
    let answer = '';
    while (x !== '0') {
        if (solution[a][b] === 'diagonal') {
            answer = wordX[a - 1] + answer;
            a--;
            b--;
        } else if (solution[a][b] === 'left') {
            b--;
        } else if (solution[a][b] === 'top') {
            a--;
        }
        x = solution[a][b];
    }
    return answer;
}
function lcs(wordX, wordY) {
    const m = wordX.length;
    const n = wordY.length;
    const l = [];
    const solution = [];
    for (let i = 0; i <= m; i++) {
        l[i] = [];
        solution[i] = [];
        for (let j = 0; j <= n; j++) {
            l[i][j] = 0;
            solution[i][j] = '0';
        }
    }
    for (let i = 0; i <= m; i++) {
        for (let j = 0; j <= n; j++) {
            if (i === 0 || j === 0) {
                l[i][j] = 0;
            } else if (wordX[i - 1] === wordY[j - 1]) {
                l[i][j] = l[i - 1][j - 1] + 1;
                solution[i][j] = 'diagonal';
            } else {
                const a = l[i - 1][j];
                const b = l[i][j - 1];
                l[i][j] = a > b ? a : b; // max(a,b)
                solution[i][j] = l[i][j] === l[i - 1][j] ? 'top' : 'left';
            }
        }
    }
    return printSolution(solution, wordX, m, n);
}
矩阵链相乘

找出一组矩阵相乘的最佳方式(顺序)。

n行m列的矩阵A和m行p列的矩阵B相乘,结果是n行p列的矩阵C。相乘的顺序不一样,要进行的乘法运算总数也有很大差异。那么,要如何构建一个算法,求出最少的乘法运算次数。

function printOptimalParenthesis(s, i, j) {
    if (i === j) {
        // console.log('A[' + i + ']');
    } else {
        // console.log('(');
        printOptimalParenthesis(s, i, s[i][j]);
        printOptimalParenthesis(s, s[i][j] + 1, j);
        // console.log(')');
    }
}

export function matrixChainOrder(p) {
    const n = p.length;
    const m = [];
    const s = [];
    for (let i = 1; i <= n; i++) {
        m[i] = [];
        m[i][i] = 0;
    }
    for (let i = 0; i <= n; i++) {
        // to help printing the optimal solution
        s[i] = []; // auxiliary
        for (let j = 0; j <= n; j++) {
            s[i][j] = 0;
        }
    }
    for (let l = 2; l < n; l++) {
        for (let i = 1; i <= (n - l) + 1; i++) {
            const j = (i + l) - 1;
            m[i][j] = Number.MAX_SAFE_INTEGER;
            for (let k = i; k <= j - 1; k++) {
                // q = cost/scalar multiplications
                // 计算给定括号顺序的乘法运算次数,并将值保存在辅助矩阵m中
                const q = m[i][k] + m[k + 1][j] + ((p[i - 1] * p[k]) * p[j]);
                if (q < m[i][j]) {
                    m[i][j] = q;
                    s[i][j] = k; // s[i,j] = Second auxiliary table that stores k
                }
            }
        }
    }
    // console.log(m);
    // console.log(s);
    printOptimalParenthesis(s, 1, n - 1);
    return m[1][n - 1];
}
面试题

按摩师(不相邻的数最大值)– leetcode

连续子数组最大和 – leetcode

求路径 – 牛客网

最长递增子序列 – leetcode

贪心算法

贪心算法遵循一种近似解决问题的技术,期盼通过每个阶段的局部最优选择(当前最好的解),从而达到全局的最优(全局最优解)。

最少硬币找零问题

贪心算法,从最大面额的硬币开始,拿尽可能多当前面额的硬币找零。当无法再拿更多时,继续迭代,拿第二大面额的硬币,依次继续。

function minCoinChange(coins, amount) {
    const change = [];
    let total = 0;
    for (let i = coins.length; i >= 0; i--) { // 迭代硬币面额
        const coin = coins[i];
        while (total + coin <= amount) { // 相加后的总值小于总金额,就继续迭代
            change.push(coin);
            total += coin;
        }
    }
    return change;
}
分数背包问题

在分数背包问题中,可以装入分数的物品。

function knapSack(capacity, weights, values) {
    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;
}
最长公共子序列
function lcs(wordX, wordY, m = wordX.length, n = wordY.length) {
    if (m === 0 || n === 0) {
        return 0;
    }
    if (wordX[m - 1] === wordY[n - 1]) {
        return 1 + lcs(wordX, wordY, m - 1, n - 1);
    }
    const a = lcs(wordX, wordY, m, n - 1);
    const b = lcs(wordX, wordY, m - 1, n);
    return a > b ? a : b;
}
矩阵链相乘
function matrixChainOrder(p, i = 1, j = p.length - 1) {
    if (i === j) {
        return 0;
    }
    let min = Number.MAX_SAFE_INTEGER;
    for (let k = i; k < j; k++) {
        const count =
              matrixChainOrder(p, i, k) + matrixChainOrder(p, k + 1, j) + ((p[i - 1] * p[k]) * p[j]);
        if (count < min) {
            min = count;
        }
    }
    return min;
}

回溯算法

回溯是一种渐进式寻找并构建问题解决方式的策略。我们从一个可能的动作开始并试着用这个动作解决问题。如果不能解决,就回溯并选择另一个动作直到将问题解决。根据这种行为,回溯算法会尝试所有可能的动作(如果更快找到了解决办法就尝试较少的次数)来解决问题。

迷宫老鼠问题

假设我们有一个大小为N × N的矩阵,矩阵的每个位置是一个方块。每个位置(或块)可以是空闲的(值为1)或是被阻挡的(值为0),如下图所示,其中S是起点,D是终点。

迷宫老鼠问题

矩阵就是迷宫,“老鼠”的目标是从位置[0][0]开始并移动到[n-1][n-1](终点)。老鼠可以在垂直或水平方向上任何未被阻挡的位置间移动。

// 判断当前位置是否是空闲的
function isSafe(maze, x, y) {
    const n = maze.length;
    if (x >= 0 && y >= 0 && x < n && y < n && maze[x][y] !== 0) {
        return true;
    }
    return false;
}

// 对于老鼠采取的每步行动,将路径标记为1。返回查找结果
// 递归函数
function findPath(maze, x, y, solution) {
    const n = maze.length;
    // 验证老鼠是否到达终点
    if (x === n - 1 && y === n - 1) {
        solution[x][y] = 1;
        return true;
    }
    // 不是最后一步,验证老鼠能否安全移动到该位置
    if (isSafe(maze, x, y) === true) {
        solution[x][y] = 1; // 当前位置加入路径
        // 水平向右移动到下一个位置
        if (findPath(maze, x + 1, y, solution)) {
            return true;
        }
        // 如果水平不可以,垂直向下移动到下一个位置
        if (findPath(maze, x, y + 1, solution)) {
            return true;
        }
        // 如果水平和垂直都不可以移动,则当前位置从路径中移除并回溯
        solution[x][y] = 0;
        return false;
    }
    return false;
}

function ratInAMaze(maze) {
    const solution = [];
    // 将每个位置初始化为0
    for (let i = 0; i < maze.length; i++) {
        solution[i] = [];
        for (let j = 0; j < maze[i].length; j++) {
            solution[i][j] = 0;
        }
    }
    // 如果能够找到解,就返回矩阵
    if (findPath(maze, 0, 0, solution) === true) {
        return solution;
    }
    // 无解
    return 'NO PATH FOUND';
}
数独解题器

目标是用数字1~9填满一个9×9的矩阵,要求每行和每列都由这九个数字构成。矩阵还包含了小方块(3×3矩阵),它们同样需要分别用这九个数字填满。谜题在开始给出一个已填了部分数字的矩阵,如下图所示。

数独

数独解题器的回溯算法会尝试在每行每列中填入每个数字。

const UNASSIGNED = 0;

// 判断数字在当前行是否出现过
function usedInRow(matrix, row, num) {
    for (let col = 0; col < matrix.length; col++) {
        if (matrix[row][col] === num) {
            return true;
        }
    }
    return false;
}

// 判断数字在当前列是否出现过
function usedInCol(matrix, col, num) {
    for (let row = 0; row < matrix.length; row++) {
        if (matrix[row][col] === num) {
            return true;
        }
    }
    return false;
}

// 判断数字在当前3*3矩阵是否出现过
function usedInBox(matrix, boxStartRow, boxStartCol, num) {
    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;
}

// 检查数字是否符合规则
function isSafe(matrix, row, col, num) {
    return (
        !usedInRow(matrix, row, num) &&
        !usedInCol(matrix, col, num) &&
        !usedInBox(matrix, row - (row % 3), col - (col % 3), num)
    );
}

function solveSudoku(matrix) {
    let row = 0;
    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] === UNASSIGNED) {
                // 有空白行 跳出循环
                checkBlankSpaces = true;
                break;
            }
        }
        // 跳出两个循环
        if (checkBlankSpaces === true) {
            break;
        }
    }
    // 表示问题已被完成
    if (checkBlankSpaces === false) {
        return true;
    }
	// 开始解题,尝试用1-9填写当前位置,进行迭代
    for (let num = 1; num <= 9; num++) {
        // 检查添加的数字是否符合规则
        if (isSafe(matrix, row, col, num)) {
            matrix[row][col] = num; // 填入数字
            if (solveSudoku(matrix)) { // 递归进行填写下一个数字
                return true;
            }
            // 如果不能填写下一个数字,则重新填写当前位置,进行回溯,填写下一个数字
            matrix[row][col] = UNASSIGNED;
        }
    }
    return false;
}

export function sudokuSolver(matrix) {
    if (solveSudoku(matrix) === true) {
        return matrix;
    }
    // 问题无解
    return 'NO SOLUTION EXISTS!';
}