概念
- 分治算法不是简单的递归,而是将大的问题递归解决较小的问题,然后从子问题的解构建原问题的解。
比如,快速排序和归并排序算分治算法,及 MapReduce 也是利用了分治思想,而图的递归深度搜索和二叉树的递归遍历则不是分治算法的运用。
- 回溯算法相当于穷举搜索的巧妙实现,对比蛮力的穷举搜索,回溯算法可以对一些不符合要求的或者是重复的情况进行裁剪,不再对其进行搜索,以减少搜索的工作量提高效率。
比如,在图运用回溯算法的深度优先搜索遍历中,会对已搜索遍历过的顶点进行标记,避免下次的回溯搜索中对再次出现的该顶点进行重复遍历。
- 动态规划与分治算法的区别是,两种算法同样是将较大的问题分解成较小问题,而动态规划对这些较小的问题并不是对原问题明晰的分割,其中一部分是被重复求解的,
因此动态规划将较小问题的解记录下来,使得在处理较大问题的时候,可以不用重复去处理较小的问题,而是直接利用所记录的较小问题的答案来求解。
- 贪心算法每次的选择都是局部最优,当在算法结束的时候,其期望是全局最优才是正确的。
不过有时,在不同条件与要求下时,最优解的答案可能不止有一个或不一样,而贪婪算法也可以得出一个近似的答案。
几者之间的关系:
- 递归是实现手段,分治策略是解决问题的思想,递归只是实现分治思想的其中一种手段。
- 用动态规划能解决的问题分治策略肯定能解决,只是运行时间较长。
- 分治策略一般用来解决子问题相互对立的问题,称为标准分治,而动态规划用来解决子问题重叠的问题。动态规划则会保存以前的运算结果。
- 贪心、回溯、动态规划可以归为一类,而分治单独可以作为一类, 前三个算法可以抽象成多阶段决策最优解模型,而分治算法解决的问题尽管大部分也是最优解问题,但是,大部分都不能抽象成多阶段决策模型。
- 基本上能用到动态规划、贪心解决的问题,都可以用回溯算法解决。回溯算法相当于穷举搜索。回溯算法的时间复杂度非常高,是指数级别的。适合解决小规模数据的问题。
- 能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。
- 贪心算法实际上是动态规划算法的一种特殊情况。它解决问题起来更加高效,需要满足三个条件,最优子结构、无后效性和贪心选择性。
分治
分治算法的递归实现中,每一层递归都会涉及这样三个操作:
- 分解:将原问题分解成一系列子问题;
- 解决:递归地求解各个子问题,若子问题足够小,则直接求解;
- 合并:将子问题的结果合并成原问题。
求一组数据的逆序对个数
有序度来表示一组数据的有序程度,逆序度表示一组数据的无序程度。
假设有 n 个数据,期望数据从小到大排列,那完全有序的数据的有序度就是 n(n-1)/2,逆序度等于 0; 相反,倒序排列的数据的有序度就是 0,逆序度是n(n-1)/2 。除了这两种极端情况外,通过计算有序对或者逆序对的个数,来表示数据的有序度或逆序度。
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数P。
解法一:
拿每个数字跟它后面的数字比较,看有几个比它小的。把比它小的数字个数记作 k,通过这样的方式,把每个数字都考察一遍之后,然后对每个数字对应的 k 值求和,最后得到的总和就是逆序对个数。
时间复杂度是O(n^2)。
解法二:
套用分治的思想来求数组 A 的逆序对个数。可以将数组分成前后两半 A1 和 A2,分别计算 A1 和 A2 的逆序对个数 K1 和 K2, 然后再计算 A1 与 A2 之间的逆序对个数 K3。那数组 A 的逆序对个数就等于 K1 + K2 + K3。
时间复杂度O(nlogn),空间复杂度O(n)
function reverseDegree(arr) {
return mergeSortCounting(arr, 0, arr.length - 1, [])
}
function mergeSortCounting(arr, left, right, tmp) {
if (left >= right) return 0
let mid = Math.floor((left + right) / 2)
let l = mergeSortCounting(arr, left, mid, tmp)
let r = mergeSortCounting(arr, mid + 1, right, tmp)
let m = merge(arr, left, mid, right, tmp)
return l + m + r
}
function merge(arr, l, m, r, tmp) {
let count = 0
let i = l, j = m + 1, k = 0
while (i <= m && j <= r) {
if (arr[i] <= arr[j]) {
tmp[k++] = arr[i++]
} else {
count += (m - i + 1) //统计 l到m 之间,比 a[j] 大的元素个数
tmp[k++] = arr[j++]
}
}
while (i <= m) { //处理剩下的
tmp[k++] = arr[i++]
}
while (j <= r) { //处理剩下的
tmp[k++] = arr[j++]
}
for (i = 0; i < r - m; ++i) { // 从tmp拷回arr
arr[l + i] = tmp[i]
}
return count
}
console.log(reverseDegree([2, 4, 3, 1, 5, 6]))
//4
回溯
从解决问题每一步的所有可能选项里系统选择出一个可行的解决方案。
在某一步选择一个选项后,进入下一步,然后面临新的选项。重复选择,直至达到最终状态。
回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。
回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,并不需要穷举搜索所有的情况,从而提高搜索效率。
0-1背包
0-1 背包是非常经典的算法问题,很多场景都可以抽象成这个问题模型。这个问题的经典解法是动态规划,不过还有一种简单但没有那么高效的解法,那就是回溯算法。
0-1 背包问题有很多变体,这里介绍一种比较基础的。有一个背包,背包总的承载重量是 W。现在有 n 个物品,每个物品的重量不等,并且不可分割。 现在期望选择几件物品,装载到背包中。在不超过背包所能装载重量的前提下,如何让背包中物品的总重量最大?
可以把物品依次排列,整个问题就分解为了 n 个阶段,每个阶段对应一个物品怎么选择。先对第一个物品进行处理,选择装进去或者不装进去,然后再递归地处理剩下的物品。
let maxW = 0
/**
* @param i 考察到哪个物品了
* @param curW 当前已经装进去的物品重量和
* @param weight 物品重量数组
* @param n 物品个数
* @param w 背包可承载重量
*/
function knapsack(i, curW, weight, n, w) {
if (curW === w || i === n) {
if (curW > maxW) {
maxW = curW
}
return
}
knapsack(i + 1, curW, weight, n, w) //选择不装第i个物品
if (curW + weight[i] <= w) {
knapsack(i + 1, curW + weight[i], weight, n, w) //选择装第i个物品
}
}
const a = [2, 2, 4, 6, 3]
knapsack(0, 0, a, 5, 9)
console.log(maxW)
//9
n皇后
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
有一个 8x8 的棋盘,希望往里放 8 个棋子(皇后),每个棋子所在的行、列、对角线都不能有另一个棋子。八皇后问题就是期望找到所有满足这种要求的放棋子方式。
步骤:
- 把这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行。
- 在放置的过程中,不停地检查当前的方法,是否满足要求。如果满足,则跳到下一行继续放置棋子;如果不满足,那就再换一种方法,继续尝试。
let counter = 0
function calcQueens(row, result = []) {
if (row === 8) {
counter++
console.log('第' + counter + '种:')
printQueens(result)
console.log('-----------------------')
return
}
for (let col = 0; col < 8; ++col) {
if (isOk(row, col, result)) {
result[row] = col
calcQueens(row + 1, result)
}
}
}
function isOk(row, col, result) {
let leftup = col - 1
let rightup = col + 1
for (let i = row - 1; i >= 0; i--) {
if (result[i] === col) {
return false
}
if (leftup >= 0) {
if (result[i] === leftup) {
return false
}
}
if (leftup < 8) {
if (result[i] === rightup) {
return false
}
}
leftup--
rightup++
}
return true
}
function printQueens(arr, counter = 0) {
counter++
for (let row = 0; row < 8; row++) {
let line = ''
for (let col = 0; col < 8; col++) {
if (arr[row] === col) {
line += ' Q '
} else {
line += ' * '
}
}
console.log(line)
}
return counter
}
calcQueens(0)
// 92种摆法
动态规划
一个模型三个特征:
- 一个模型: 指的是动态规划适合解决的问题的模型。把这个模型定义为“多阶段决策最优解模型"。
- 三个特征:分别是最优子结构、无后效性和重复子问题。
- 最优子结构指的是,问题的最优解包含子问题的最优解。
- 无后效性指的是某阶段状态一旦确定,就不受之后阶段的决策影响。
- 重复子问题指的是不同的决策序列,到达某个相同的阶段时,可能会产生重复的状态
动态规划比较适合用来求解最优问题,比如求最大值、最小值等等。它可以非常显著地降低时间复杂度,提高代码的执行效率。
0-1 背包问题
对于一组不同重量、不可分割的物品,需要选择一些装入背包,在满足背包最大重量限制的前提下,背包中物品总重量的最大值是多少呢?
假设物品重量数组为 [2, 2, 4, 6, 3], 背包最大承重为9.
把整个求解过程分为 n 个阶段,每个阶段会决策一个物品是否放到背包中。每个物品决策(放入或者不放入背包)完之后,背包中的物品的重量会有多种情况,也就是说,会达到多种不同的状态,对应到递归树中,就是有很多不同的节点。
把每一层重复的状态(节点)合并,只记录不同的状态,然后基于上一层的状态集合,来推导下一层的状态集合。可以通过合并每一层重复的状态,这样就保证每一层不同状态的个数都不会超过 w 个(w 表示背包的承载重量),也就是例子中的 9。于是,就成功避免了每层状态个数的指数级增长。
用一个二维数组 states[n][w+1],来记录每层可以达到的不同状态。
- 第 0 个(下标从 0 开始编号)物品的重量是 2,要么装入背包,要么不装入背包,决策完之后,会对应背包的两种状态,背包中物品的总重量是 0 或者 2。用 states[0][0]=true 和 states[0][2]=true 来表示这两种状态。
- 第 1 个物品的重量也是 2,基于之前的背包状态,在这个物品决策完之后,不同的状态有 3 个,背包中物品总重量分别是 0(0+0),2(0+2 or 2+0),4(2+2)。用 states[1][0]=true,states[1][2]=true,states[1][4]=true 来表示这三种状态。
- 以此类推,直到考察完所有的物品后,整个 states 状态数组就都计算好了。如下图所示。图中 0 表示 false,1 表示 true。只需要在最后一层,找一个值为 true 的最接近 w(这里是 9)的值,就是背包中物品总重量的最大值。
/**
* @param weight 物品重量数组
* @param n 物品个数
* @param w 背包可承载重量
*/
function knapsack(weight, n, w) {
let states = new Array(n)
for (let i = 0; i < n; i++) {
states[i] = new Array(w + 1)
}
states[0][0] = true //第一行的数据要特殊处理,可以利用哨兵优化
if (weight[0] <= w) {
states[0][weight[0]] = true
}
for (let i = 1; i < n; ++i) { // 动态规划状态转移
for (let j = 0; j <= w; ++j) { // 不把第 i 个物品放入背包
if (states[i - 1][j] === true) {
states[i][j] = states[i - 1][j]
}
}
for (let j = 0; j <= w - weight[i]; ++j) { // 把第 i 个物品放入背包
if (states[i - 1][j] === true) {
states[i][j + weight[i]] = true
}
}
}
for (let i = w; i >= 0; --i) { // 输出结果
if (states[n - 1][i] == true) return i
}
return 0
}
const a = [2, 2, 4, 6, 3]
console.log(knapsack(a, 5, 9))
// 9
最小路径和
给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。每次只能向下或者向右移动一步。
输入: [ [1,3,1], [1,5,1], [4,2,1] ] 输出: 7 解释: 因为路径 1→3→1→1→1 的总和最小。
function minPathSum(arr) {
const m = arr.length
const n = arr[0].length
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (i === 0 && j !== 0) {
arr[i][j] += arr[i][j - 1]
} else if (j === 0 && i !== 0) {
arr[i][j] += arr[i - 1][j]
} else if (i !== 0 && j !== 0) {
arr[i][j] += Math.min(arr[i - 1][j], arr[i][j - 1])
}
}
}
return arr[m - 1][n - 1]
}
const a = new Array(3)
a[0] = [1, 1, 4]
a[1] = [3, 5, 2]
a[2] = [1, 1, 1]
console.log(minPathSum(a))
//7
打家劫舍
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
输入:[1,2,3,1] 输出:4 (1 + 3) 输入:[2,7,9,3,1] 输出:12 (2 + 9 + 1)
步骤:
f(k) = 从前 k 个房屋中能抢劫到的最大数额,Ai = 第 i 个房屋的钱数。
- 首先看n = 1 的情况,显然 f(1) = A1。
- 再看 n = 2,f(2) = max(A1, A2)。
- 对于 n = 3,有两个选项:
- 抢第三个房子,将数额与第一个房子相加。
- 不抢第三个房子,保持现有最大数额。
显然,你想选择数额更大的选项。于是,可以总结出公式:
f(k) = max(f(k – 2) + Ak, f(k – 1))
function rob(arr) {
const len = arr.length
if (len < 2) {
return arr[len - 1] ? arr[len - 1] : 0
}
let cur = [arr[0], Math.max(arr[0], arr[1])]
for (let k = 2; k < len; k++) {
cur[k] = Math.max(cur[k - 2] + arr[k], cur[k - 1])
}
return cur[len - 1]
}
const a = [2, 7, 9, 3, 1]
console.log(rob(a))
//12
贪心
贪心算法:对问题求解的时候,总是做出在当前看来是最好的做法。
适用贪心算法的场景:问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。这种子问题最优解称为最优子结构。
分糖果
有 m 个糖果和 n 个孩子。现在要把糖果分给这些孩子吃,但是糖果少,孩子多(m<n),所以糖果只能分配给一部分孩子。
每个糖果的大小不等,这 m 个糖果的大小分别是 s1,s2,s3,……,sm。除此之外,每个孩子对糖果大小的需求也是不一样的,只有糖果的大小大于等于孩子的对糖果大小的需求的时候,孩子才得到满足。
假设这 n 个孩子对糖果大小的需求分别是 g1,g2,g3,……,gn。
问题是,如何分配糖果,能尽可能满足最多数量的孩子?
可以把这个问题抽象成,从 n 个孩子中,抽取一部分孩子分配糖果,让满足的孩子的个数(期望值)是最大的。这个问题的限制值就是糖果个数 m。
例如:
输入 [1,1],[1,2,3] //糖果大小分别为:1,1 ,3个孩子的胃口值分别是:1,2,3 输出 1 输入 [1,2,3],[1,2,4,4] //糖果大小分别为:1,2,3 ,4个孩子的胃口值分别是:1,2,4,4 输出 2
/**
*
* @param sArr 糖果大小数组,如果[s1,s2,...sm]
* @param gArr 对糖果的需求数组,如果[g1,g2,...gn]
*/
function distributeCandy(sArr, gArr) {
sArr = sArr.sort((a, b) => a - b)
gArr = gArr.sort((a, b) => a - b)
let num = 0
let sIndex = 0
let gIndex = 0
while (sIndex < sArr.length && gIndex < gArr.length) {
if (gArr[gIndex] <= sArr[sIndex]) {
num++
gIndex++
}
sIndex++
}
return num
}
console.log(distributeCandy([1, 1], [1, 2, 3])) //1
console.log(distributeCandy([1, 2, 3], [1, 2, 4, 4])) //2
钱币找零
假设有 1 元、2 元、5 元、10 元、20 元、50 元、100 元这些面额的纸币,现在要用这些钱来支付 K 元,最少要用多少张纸币呢?
function makeChange(coins, amount, i, min) {
let coin = coins[i]
let tempAmount = Math.floor(amount / coin) // coin额度的需要的张数
if (tempAmount) {
min[coin] = tempAmount
}
if (amount % coin !== 0) {
makeChange(coins, amount - coin * tempAmount, --i, min)
}
}
function minCoinChange(coins, amount) {
coins = coins.sort((a, b) => a - b)
let result = null
if (!amount) return result
const arr = []
for (let i = coins.length - 1; i >= 0; i--) {
const cache = {}
makeChange(coins, amount, i, cache)
arr.push(cache)
}
arr.forEach(item => {
let total = Object.values(item).reduce((acc, cur) => acc + cur)
item.counter = total
})
arr.sort((a, b) => a.counter - b.counter)
console.log(arr)
return arr[0]
}
const result = minCoinChange([1, 20, 10, 5, 50, 2, 100], 136)
console.log(result)
输出结果为:
[
{ '1': 1, '5': 1, '10': 1, '20': 1, '100': 1, counter: 5 },
{ '1': 1, '5': 1, '10': 1, '20': 1, '50': 2, counter: 6 },
{ '1': 1, '5': 1, '10': 1, '20': 6, counter: 9 },
{ '1': 1, '5': 1, '10': 13, counter: 15 },
{ '1': 1, '5': 27, counter: 28 },
{ '2': 68, counter: 68 },
{ '1': 136, counter: 136 }
]
{ '1': 1, '5': 1, '10': 1, '20': 1, '100': 1, counter: 5 }