数据结构与算法
@
数据结构与算法图解:
- 不同的数据结构,有不同的操作(如增删改查等),不同的操作有不同的操作速度。
- 方法操作速度比较是比较操作步数,而不是操作时间;操作速度也常称为时间复杂度。
- 影响代码速度的两个因素:数据结构,算法。
- 大O记法:方便表达数据结构和算法的时间复杂度;大O表示的是步数和数量的关系
常用大O记法分类: O(1) O(N) O(logN) O(N²) O(NlogN) O(N^3) O(2^N)
大O记法几条原则:
- 大O记法忽略常数; 如:O(2N)要写成 O(N),O(N / 2)也写成 O(N);
(影响:
- 大O记法只表明,对于不同分类大O记法, 存在一临界点,在这一点之后,一类算法会快于另一类,并永远保持下去
- 具有相同大O记法的算法,可能性能不同,需要具体分析算法具体步骤而选择;大O记法非常适合用于不同大 O 分类下的算法的对比,对于大 O 同类的算法,我们还需要进一步的解析才能分辨出具体差异。
)
- 大O只保留最高阶的N。如:N^4 + N^3 + N^2 + N 要写成N 4,即以 O(N^4)来表示。
(影响:懂得区分最好、平均、最坏情况,是为当前场景选择最优算法以及给现有算法调优以适应环境变化的关键。)
算法——排序
排序算法:主要分为三大类 普通(简单)排序,递归排序,分布式排序
排序算法也可以分为内部排序和外部排序,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。
常见的内部排序算法有:插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序等。
测试用例:
// 排序数组
let arr = [7, 4, 1, 3, 2]
// 交换数组元素
function switchItem(ar, a, b) {
let temp
temp = ar[b]
ar[b] = ar[a]
ar[a] = temp
//ES6 解构赋值交换值
// [ar[a],ar[b]] = [ar[b],ar[a]]
}
普通(简单)排序
冒泡算法
冒泡排序比较所有相邻的两个项,如果第一个比第二个大,则交换它们。元素项向上移动至正确的顺序,就好像气泡升至表面一样,冒泡排序因此得名。
function bubbleSort(arr) {
var len = arr.length;
for (var i = 0; i < len - 1; i++) {
for (var j = 0; j < len - 1 - i; j++) {
if (arr[j] > arr[j+1]) { // 相邻元素两两对比
switchItem(arr, j, j + 1) // 元素交换
}
}
}
return arr;
}
选择排序
数组大致逆序 优先选择(实际时间复杂度始终为N²/2;大O记法表示为:N²)
选择排序算法是一种原址比较排序算法。选择排序大致的思路是找到数据结构中的最小值(最大值)并将其放置在第一位(最后一位),接着找到第二小(大)的值并将其放在第二位(倒数第二位),以此类推。
function selectionSort(arr) {
var len = arr.length;
var minIndex, temp;
for (var i = 0; i < len - 1; i++) {
minIndex = i;
for (var j = i + 1; j < len; j++) {
if (arr[j] < arr[minIndex]) { // 寻找最小的数
minIndex = j; // 将最小数的索引保存
}
}
swap(arr, j, minIndex)
}
return arr;
}
插入排序
数组大致有序 ,小型数组 优先选择 (实际时间复杂度为N² + 2N - 2 ;大O记法表示为:N²)
(在最坏、平均、最好情况,分别需要 N²、N²/2、 N 步;选择排序的实际时间复杂度始终为N²/2,在大致有序的情况下,插入排序更有优势;如果是大致逆序,则选择排序更快;无法确定数据是什么样,那就算是平均情况了,两种都可以。)
插入排序每次排一个数组项,以此方式构建最后的排序数组。假定第一项已经排序了。接着,它和第二项进行比较——第二项是应该待在原位还是插到第一项之前呢?这样,头两项就已正确排序,接着和第三项比较(它是该插入到第一、第二还是第三的位置呢),以此类推。
function insertionSort(array) {
const { length } = array;
let temp;
for (let i = 1; i < length; i++) {
let j = i;
temp = array[i];
while (j > 0 && array[j - 1] > temp) {
array[j] = array[j - 1];
j--;
}
array[j] = temp;
}
return array;
};
希尔排序
希尔排序,也称递减增量排序算法,是插入排序的一种更高效的改进版本。但希尔排序是非稳定排序算法。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率;
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位;
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再
对全体记录进行依次直接插入排序。
function shellSort(arr) {
// 确定分组数
let gap = Math.floor(arr.length / 2)
let tempDate
let tempIndex
// 循环分组行为
for (; gap > 0; gap = Math.floor(gap / 2)) {
// 内循环为插入排序
for (let i = gap; i < arr.length; i++) {
tempDate = arr[i]
tempIndex = i
while (tempIndex > 0 && arr[tempIndex - gap] > tempDate) {
arr[tempIndex] = arr[tempIndex - gap]
tempIndex -= gap
}
arr[tempIndex] = tempDate
}
}
return arr
}
参考地址:JS排序--图解希尔排序
堆排序
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。分为两种方法:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;
堆排序的平均时间复杂度为 Ο(nlogn)。
// 再堆化调整的时候,控制堆化的范围,也是数组堆化的长度
var len;
// 建立大顶堆 堆是一个完全二叉树
function buildMaxHeap(arr) {
// 初始化时len堆化长度为数组长度
len = arr.length;
// 从第一个非叶子节点开始调整堆化; 叶子节点不需要调整
// 第一个非叶子节点必然是最后一个叶子节点的父节点;
// 所以由公式换算成:当最后一个叶子节点为N,它的父节点为:N/2-1
for (var i = Math.floor(len / 2 - 1); i >= 0; i--) {
heapify(arr, i);
}
}
// 堆调整
function heapify(arr, i) {
// 一个节点(N)的左右子节点为:左:2n+1,右:2n+2;
var left = 2 * i + 1,
right = 2 * i + 2,
largest = i;
if (left < len && arr[left] > arr[largest]) {
largest = left;
}
if (right < len && arr[right] > arr[largest]) {
largest = right;
}
if (largest != i) {
swap(arr, i, largest);
heapify(arr, largest);
}
}
function swap(arr, i, j) {
var temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
function heapSort(arr) {
buildMaxHeap(arr);
// 初始化i等于数组长度,然后取出堆顶后,依次减少;
for (var i = arr.length - 1; i > 0; i--) {
swap(arr, 0, i);
// 因为依次取出堆顶,堆化调整的长度也依次减少;
len--;
heapify(arr, 0);
}
return arr;
}
递归排序
归并排序
归并排序 利用递归将数组拆分为最小数组(需要使用临时数组),再合并数组;
归并排序是一种分而治之算法:
其思想是将原始数组切分成较小的数组,直到每个小数组只有一个位置,接着将小数组归并成较大的数组,直到最后只有一个排序完毕的大数组。
function mergeSort(arr, compareFun) {
if (arr.length > 1) {
let meddle = Math.floor(arr.length / 2)
let left = mergeSort(arr.slice(0, meddle), compareFun)
let right = mergeSort(arr.slice(meddle, arr.length), compareFun)
arr = merge(left, right, compareFun)
}
return arr
}
function merge(left, right, compareFun) {
let i = 0
let j = 0
let result = []
while (i < left.length && j < right.length) {
result.push(compareFun(left[i], right[j]) == 1 ? left[i++] : right[j++])
}
return result.concat(i < left.length ? left.slice(i) : right.slice(j))
}
mergeSort(arr, compareFun)
分而治之
- 分阶段可以理解为就是递归拆分子序列的过程
可以看到这种结构很像一棵完全二叉树,本文的归并排序我们采用递归去实现(也可采用迭代的方式去实现)。分阶段可以理解为就是递归拆分子序列的过程,递归深度为log2n。
- 合并相邻有序子序列
再来看看治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。
快速排序
快速排序是一种分而治之算法:
快速排序,利用递归排序,不同于归并排序(没有使用新数组)
快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法。
function quickSort(arr, compareFun) {
return quick(arr, 0, arr.length, compareFun)
}
function quick(arr, left, right, compareFun) {
let index
if (arr.length > 1) {
index = partition(arr, left, right, compareFun)
if (left < index - 1) {
partition(arr, left, index - 1, compareFun)
}
if (index < right) {
partition(arr, index, right, compareFun)
}
}
return arr
}
//划分子数组 且排序
function partition(arr, left, right, compareFun) {
//取中间点为基准点 划分子数组
let index = Math.floor((left + right) / 2) //2
let temp = arr[index] // 1
let i = left
let j = right
while (i <= j) {
while (compareFun(arr[i], temp) === 1) {
i++
}
while (compareFun(arr[j], temp) === 2) {
j--
}
if (i <= j) {
switchItem(arr, i, j)
i++
j--
}
}
return i
}
分布式排序
基于关键词的数字性质的分“桶“排序方法,即:分布排序。(以上的排序方法是基于关键词的比较而进行排序)
分布式排序使用已组织好的辅助数据结构(称为桶),然后进行合并,得到排好序的数组。
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
- 计数排序:每个桶只存储单一键值
- 基数排序:根据键值的每位数字来分配桶
- 桶排序:每个桶存储一定范围的数值
计数排序
计数排序使用一个用来存储每个元素在原始数组中出现次数的临时数组。在所有元素都计数完成后,临时数组已排好序并可迭代以构建排序
后的结果数组。
计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
用来排序整数的优秀算法(它是一个整数排序算法),时间复杂度为 O(n+k),其中 k 是临时计数数组的大小;但是,它确实需要更多的内存来存放临时数组。
function countingSort(arr) {
if (arr.length < 2) {
return arr
}
// 创建临时数组
let maxValue = findMaxValue(arr)
let temArray = new Array(maxValue + 1)
// 通过原数组向临时数组计数
arr.forEach((ele) => {
if (!temArray[ele]) {
temArray[ele] = 0
}
temArray[ele]++
})
// 根据临时数组 返回排序数组
let sortIndex = 0
temArray.forEach((v, i) => {
while (v > 0) {
arr[sortIndex++] = i
v--
}
})
return arr
}
function findMaxValue(arr) {
let maxValue = arr[0]
for (let i = 1; i < arr.length; i++) {
if (arr[i] > maxValue) {
maxValue = arr[i]
}
}
return maxValue
}
桶排序
桶排序(也被称为箱排序)也是分布式排序算法,它将元素分为不同的桶(较小的数组),再使用一个简单的排序算法,例如插入排序(用来排序小数组的不错的算法),来对每个桶进行排序。然后,它将所有的桶合并为结果数组。
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:
- 在额外空间充足的情况下,尽量增大桶的数量
- 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中
同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。
元素分布在桶中:
然后,元素在每个桶中排序:
function bucketSort(arr, bucketSize = 5) {
if (arr.length < 2) {
return arr
}
//创建桶
let buckets = createBuckets(arr, bucketSize)
//桶排序返回排序数组
return sortBuckets(buckets)
}
function createBuckets(arr, bucketSize) {
// 寻找数组最大值和最小值 一次循环
let minV = arr[0]
let maxV = arr[0]
for (let i = 1; i < arr.length; i++) {
if (arr[i] < minV) {
minV = arr[i]
} else if (arr[i] > maxV) {
maxV = arr[i]
}
}
// 确定桶的数量
let bucketCount = Math.floor((maxV - minV) / bucketSize) + 1
let buckets = []
// 初始化桶数据
for (let i = 0; i < bucketCount; i++) {
buckets[i] = []
}
//将数据依次放入桶
for (var i = 0; i < arr.length; i++) {
let index = Math.floor((arr[i] - minV) / bucketSize)
buckets[index].push(arr[i])
}
return buckets
}
function sortBuckets(buckets) {
let sortArray = []
for (var i = 0; i < buckets.length; i++) {
if (buckets[i] != null) {
// 每个桶利用插入排序(简单排序)
insertSort(buckets[i])
sortArray.push(...buckets[i])
}
}
return sortArray
}
基数排序
根据基数分桶,按照每个元素的数位装进不同的桶
基数排序也是一个分布式排序算法,它根据数字的有效位或基数(这也是它为什么叫基数排序)将整数分布到桶中。基数是基于数组中值的记数制的。
比如,对于十进制数,使用的基数是 10。因此,算法将会使用 10 个桶用来分布元素并且首先基于个位数字进行排序,然后基于十位数字,然后基于百位数字,以此类推。
基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
function radix_sort(arr) {
// 取最大值 最大值的位数就是要循环遍历的次数
const max = Math.max(...arr);
// 定义一个桶
const buckets = Array.from({ length: 10 }, () => []);
// 定义当前要遍历的位数 个位 十位 百位...
let m = 1;
while (m <= max) {
// m < 最大值
// 下方m要 m*=10 -> 每次遍历增加一位
// 保证遍历完所有可能的位数
// 放入桶
arr.forEach(number => {
// digit表示某位数的值
const digit = ~~((number % (m * 10)) / m);
// 把该位数的值放到桶buckets中
// 通过索引确定顺序 类比计数排序
buckets[digit].push(number);
});
// 从桶buckets中取值
// 完成此步后 就完成了一次位数排序
let ind = 0;
buckets.forEach(bucket => {
while (bucket.length > 0) {
// shift从头部取值
// 保证按照队列先入先出
arr[ind++] = bucket.shift();
}
});
// 每次最外层while循环后m要乘等10
// 也就是要判断下一位 比如当前是个位 下次就要判断十位
m *= 10;
}
}
说明 : 获取某位的数值 digit = ~~((number % (m * 10)) / m);
首先 ~~ === Math.floor() 向下取整
m首次是1 以后每次乘等10
(%(取余):去掉除数前面的数位;521 % 10,去掉十位以及前面的数字)
( /(除):去掉除数后面的数位;521 / 100,去掉百位后面的数字)
例如:
取 个位1:
a. 取模: 521 % 10 = 1
b. 除以m: 1 / 1 = 1
c. 向下取整: 1
取 十位2:
a. 取模: 521 % 100 = 21
b. 除以m: 21 / 10 = 2.1
c. 向下取整: 2
取 百位5:
a. 取模: 521 % 1000 = 521
b. 除以m: 521 / 100 = 5.21
c. 向下取整: 5
参考:基数排序
算法——搜索
顺序搜索(线性搜索)
顺序搜索适合于存储结构为顺序存储或链接存储的线性表
迭达每个元素与搜索值比较
有序搜索
查找数据需要按序排序,已经是有序状态,然后再查找,三种搜索方法本质是分隔点的选择不同,其他步骤均相同
- 二分搜索
- 插值搜索
- 斐波那契搜索
二分搜索
根据已经排序的数组,进行二分查找,总是在中间位置上查找比较
计算minId公式为 minId = (high+low)/2 变型为: minId = low+(1/2)(high-low)
function BinarySearch(arr, value, compareFun) {
//数组排序
arr = selectSort(arr)
// 求得比较边界下标
let low = 0
let high = arr.length - 1
// 如果 low比 high 大,则意味着该待搜索值不存在并返回
while (lessOrEqual(low, high, compareFun)) {
// 选择比较索引公式
let minId = Math.floor((low + high) / 2)
let ele = arr[minId]
let result = compareFun(ele, value)
if (result === 1) {
low = minId + 1
} else if (result === 2) {
high = minId - 1
} else if (result === 0) {
return true
}
}
return false
}
function lessOrEqual(low, high) {
let temp = compareFun(low, high)
return temp === 0 || temp === 1
}
插值搜索
插值搜索改良版的二分搜索( 替换minId计算公式)
二分搜索总是检查中间位置上的值,而插值搜索可能会根据要搜索的值修改minId的值。
插值搜索更适用被搜索数据较大,且均匀分布。
计算minId公式为 minId = low+(value-arr[low])/(arr[high]-arr[low])(high-low)
function interpolationSearch(arr, value, compareFun) {
//数组排序 选择排序
arr = selectSort(arr)
// 求得比较边界下标
let low = 0
let high = arr.length - 1
while (lessOrEqual(low, high, compareFun)) {
// 选择比较索引公式
let minId = low + (value - arr[low]) / (arr[high] - arr[low])(high - low)
let ele = arr[minId]
let result = compareFun(ele, value)
if (result === 1) {
low = minId + 1
} else if (result === 2) {
high = minId - 1
} else if (result === 0) {
return true
}
}
return false
}
斐波那契搜索
斐波那契搜索也是二分搜索的改良版( 利用黄金比例0.618,在数列中分割进行查找)
比较前提条件: 比较数组 N >= F[K] 当数组不够就扩充数组
具体步骤:
- 扩充数组长度 等于或大于 一个斐波那契数列项 不够就补全数组数组(补全后数组,数组最后一位下标为 F[k]-1,因为数组是从0开始计数)
- 设置分隔点 minID = low + f[k-1]-1 利用黄金分割比例(斐波那契数列前一项和后一项之比,趋近于0.618)分割
- 比较情况(KEY值和数组元素值比较):
- KEY等于元素值 找到元素
- KEY大于元素值 K = k-2;low = minID+1
- KEY小于元素值 K = k-1;high = minID-1
function fibonaccieSerach(arr, value) {
arr = selectSort(arr)
let low = 0
let high = arr.length - 1
let n = arr.length - 1
let k = 0
let minId = 0
// 构建斐波那契数组
let F = []
F[0] = 0
F[1] = 1
// 初始化一个略大于high个元素的斐波那契数组
for (let i = 2; i < high + 5; i++) {
F[i] = F[i - 1] + F[i - 2]
}
// 判断数组元素总数是否满足斐波那契中元素
// 利用原数组high下标,与其扩展后的下标比较,判断是否需要扩展数组
while (high > F[k] - 1) {
k++
}
// 扩充数组
for (let i = high; i <= F[k] - 1; i++) {
arr[i] = arr[high]
}
log(F)
log(arr)
// 循环比较
while (low <= high) {
// 设置分隔点下标: 起点+黄金分割比例点(数组长度:F[K],最后一位数组元素下标:F[k]-1,比例点下标:F[k-1]-1)
minId = low + F[k - 1] - 1
if (value > arr[minId]) {
// 起点增加
low = minId + 1
// 分隔点右边区域比较
k = k - 2
} else if (value < arr[minId]) {
// 上边界减少
high = minId - 1
// 分隔点左边区域比较
k = k - 1
} else {
// minId 在原数组下标中 返回minId 如果不在原数组(扩展的数组其他元素等于原数组最后一个元素),说明在最后一个元素的下标中
if (minId <= n) {
return minId
} else {
return n
}
}
}
return false
}
索引搜索(分块搜索)
它是顺序查找的一种改进方法。
算法思想:将n个数据元素"按块有序"划分为m块(m ≤ n)。
每一块中的结点不必有序,但块与块之间必须"按块有序";
即第1块中任一元素的关键字都必须小于第2块中任一元素的关键字;
而第2块中任一元素又都必须小于第3块中的任一元素,以此类推算法流程:
- 先选取各块中的最大关键字构成一个索引表;
- 查找分两个部分:先对索引表进行二分查找或顺序查找,以确定待查记录在哪一块中;然后,在已确定的块中用顺序法进行查找。
树搜索
将待查数据组织为树结构,然后利用树特性进行查找
根据不同树表类型,可以分类为:
- 二叉树查找
- 平衡查找树之平衡二叉树查找(AVL树)
- 平衡查找树之2-3查找树
- 平衡查找树之红黑树
- B树和B+树(B Tree/B+ Tree)
哈希搜索
算法——随机算法
算法设计思想与技巧
分而治之
分而治之是算法设计中的一种方法。它将一个问题分成多个和原问题相似的小问题,递归解决小问题,再将解决方式合并以解决原来的问题。
分而治之算法可以分成三个部分:
- 分解:分解原问题为多个子问题(原问题的多个小实例)。
- 解决:解决子问题,用返回解决子问题的方式的递归算法。递归算法的基本情形可以用来解决子问题。
- 合并:组合这些子问题的解决方式,得到原问题的解。
动态规划
动态规划( dynamic programming, DP)是一种将复杂问题分解成更小的子问题来解决的优化技术。
注意:
动态规划和分而治之是不同的方法。分而治之方法是把问题分解成相互独立的子问题,然后组合它们的答案,而动态规划则是将问题分解成相互依赖的子问题。
用动态规划解决问题时,要遵循三个重要步骤:
- 定义子问题;
- 实现要反复执行来解决子问题的部分(这一步参考递归的步骤);
- 识别并求解出基线条件。
动态规划解决的一些著名问题:
- 背包问题:给出一组项,各自有值和容量,目标是找出总值最大的项的集合。这个问题的限制是,总容量必须小于等于“背包”的容量。
- 最长公共子序列:找出一组序列的最长公共子序列(可由另一序列删除元素但不改变余下元素的顺序而得到)。
- 矩阵链相乘:给出一系列矩阵,目标是找到这些矩阵相乘的最高效办法(计算次数尽可能少)。相乘运算不会进行,解决方案是找到这些矩阵各自相乘的顺序。
- 硬币找零:给出面额为 d1, …, dn的一定数量的硬币和要找零的钱数,找出有多少种找零的方法。
- 图的全源最短路径:对所有顶点对(u, v),找出从顶点 u 到顶点 v 的最短路径。
最少硬币找零问题
最少硬币找零问题是硬币找零问题的一个变种。硬币找零问题是给出要找零的钱数,以及可用的硬币面额 d1, …, dn及其数量,找出有多少种找零方法。
最少硬币找零问题是给出要找零的钱数,以及可用的硬币面额 d1, …, dn及其数量,找到所需的最少的硬币个数。
例如,美国有以下面额(硬币): d1 = 1, d2 = 5, d3 = 10, d4 = 25;如果要找 36 美分的零钱。
// 递归版本
function minCoinChange(coins, amount) {
const cache = []; // {1}
const makeChange = (value) => { // {2}
if (!value) { // {3}
return [];
}
if (cache[value]) { // {4}
return cache[value];
}
let min = []; // 最小硬币组合
let newMin; // 新硬币组合
let newAmount; // 新总值
for (let i = 0; i < coins.length; i++) { // {5}
const coin = coins[i];
newAmount = value - coin; // {6}
console.log("传入总值:" + value + "-减少值:" + coin + '-最新总值:' + newAmount)
if (newAmount >= 0) {
newMin = makeChange(newAmount); // {7}
}
// console.log(newMin)
if (
newAmount >= 0 && // {8} //差值大于等于0
(newMin.length < min.length - 1 || !min.length) && // {9} //获取到的组合长度小于当前组合,或者当前组合为空
(newMin.length || !newAmount) // {10} //获取到的组合有值或者差值为0
) {
console.log(newAmount)
console.log(newMin)
console.log(coin)
console.log(min)
min = [coin].concat(newMin); // {11}
console.log('------new Min ' + min + ' for ' + amount);
}
}
return (cache[value] = min); // {12}
};
return makeChange(amount); // {13}
}
// console.log(minCoinChange([1, 5, 10, 25], 36));
console.log(minCoinChange([1, 5], 7));
参考:详解动态规划最少硬币找零问题--JavaScript实现
背包问题
背包问题是一个组合优化问题。它可以描述如下:给定一个固定大小、能够携重量 W 的背包,以及一组有价值和重量的物品,找出一个最佳解决方案,使得装入背包的物品总重量不超过W,且总价值最大。
// 迭达版本
function knapSack(capacity, weights, values, n) {
const kS = [];
for (let i = 0; i <= n; i++) { // {1}
kS[i] = [];
}
for (let i = 0; i <= n; i++) {
for (let w = 0; w <= capacity; w++) {
if (i === 0 || w === 0) { // {2}
kS[i][w] = 0;
} else if (weights[i - 1] <= w) { // {3}
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; // {4} max(a,b)
} else {
kS[i][w] = kS[i - 1][w]; // {5}
}
}
}
findValues(n, capacity, kS, weights, values); // {6} 增加的代码
return kS[n][capacity]; // {7}
}
// 列出实际的物品
function findValues(n, capacity, kS, weights, values) {
let i = n;
let k = capacity;
console.log('构成解的物品: ');0
while (i > 0 && k > 0) {
if (kS[i][k] !== kS[i - 1][k]) {
console.log(`物品 ${i} 可以是解的一部分 w,v: ${weights[i - 1]}, ${values[i - 1]}`);
i--;
k -= kS[i][k];
} else {
i--;
}
}
}
const values = [3, 4, 5], //物品价值
weights = [2, 3, 4], //物品重量
capacity = 5, //背包重量
n = values.length;
console.log(knapSack(capacity, weights, values, n)); // 输出 7
最长公共子序列( LCS)
找出两个字符串序列的最长子序列的长度。
最长子序列是指,在两个字符串序列中以相同顺序出现,但不要求连续(非字符串子串)的字符串序列。
如果比较背包问题和 LCS 算法,我们会发现两者非常相似。
这项从顶部开始构建解决方案的技术被称为记忆化,而解决方案就在表格或矩阵的右下角。
// 迭达方案
// 可以用'acbaed'和'abcadf'两个字符串执行下面的算法
function lcs(wordX, wordY) {
const m = wordX.length;
const n = wordY.length;
const l = [];
for (let i = 0; i <= m; i++) {
l[i] = []; // {1}
for (let j = 0; j <= n; j++) {
l[i][j] = 0; // {2}
}
}
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; // {3}
} else {
const a = l[i - 1][j];
const b = l[i][j - 1];
l[i][j] = a > b ? a : b; // {4} max(a,b)
}
}
}
return l[m][n]; // {5}
矩阵链相乘
贪心算法
贪心算法遵循一种近似解决问题的技术,期盼通过每个阶段的局部最优选择(当前最好的、解),从而达到全局的最优(全局最优解)。它不像动态规划算法那样计算更大的格局。
常用贪心算法:Dijkstra 算法(图结构中的最短路径算法)、 Prim 算法(图结构中的最小生成树),Kruskal 算法(图结构中的最小生成树)。
最少硬币找零问题
最少硬币找零问题也能用贪心算法解决。大部分情况下的结果是最优的,不过对有些面额而言,结果不会是最优的。
function minCoinChange(coins, amount) {
const change = [];
let total = 0;
for (let i = coins.length; i >= 0; i--) { // {1}
const coin = coins[i];
while (total + coin <= amount) { // {2}
change.push(coin); // {3}
total += coin; // {4}
}
}
return change;
}
分数背包问题
求解分数背包问题的算法与动态规划版本稍有不同。在 0-1 背包问题中,只能向背包里装入完整的物品,而在分数背包问题中,可以装入分数的物品。
function knapSack(capacity, weights, values) {
const n = values.length;
let load = 0;
let val = 0;
for (let i = 0; i < n && load < capacity; i++) { // {1}
if (weights[i] <= capacity - load) { // {2}
val += values[i];
load += weights[i];
} else {
const r = (capacity - load) / weights[i]; // {3}
val += r * values[i];
load += weights[i];
}
}
return val;
}
回溯算法
回溯是一种渐进式寻找并构建问题解决方式的策略。
我们从一个可能的动作开始并试着用这个动作解决问题。如果不能解决,就回溯并选择另一个动作直到将问题解决。
根据这种行为,回溯算法会尝试所有可能的动作(如果更快找到了解决办法就尝试较少的次数)来解决问题。
函数式编程(利用高阶函数)
定义
与之对应的是命令式编程:在命令式编程中,我们按部就班地编写程序代码,详细描述要完成的事情以及完成的顺序。
打印一个数组中所有的元素;我们可以用命令式编程:
const printArray = function(array) {
for (var i = 0; i < array.length; i++) {
console.log(array[i]);
}
};
printArray([1, 2, 3, 4, 5]);
在函数式编程中,函数就是摇滚明星。
我们关注的重点是需要描述什么,而不是如何描述。
回到这一句:“我们迭代数组,打印每一项。”
那么,首先要关注的是迭代数据,然后进行操作,即打印数组项。
const forEach = function(array, action) {
for (var i = 0; i < array.length; i++) {
action(array[i]);
}
};
const logItem = function(item) {
console.log(item);
};
小结
- 函数式编程的主要目标是描述数据,以及要对数据应用的转换。
- 在函数式编程中,程序执行顺序的重要性很低;而在命令式编程中,步骤和顺序是非常重要的。
- 函数和数据集合是函数式编程的核心。
- 在函数式编程中,我们可以使用和滥用函数和递归;而在命令式编程中,则使用循环、赋值、条件和函数。
- 在函数式编程中,要避免副作用和可变数据,意味着我们不会修改传入函数的数据。如果需要基于输入返回一个解决方案,可以制作一个副本并返回数据修改后的副本。
常用函数式工具箱(高阶函数)
map、 filter 和 reduce 函数是 JavaScript 函数式编程的基础。
- map 函数,把一个数据集合转换或映射成另一个数据集合。
- filter 函数过滤一个集合的值。
- reduce 函数,把一个集合归约成一个特定的值。