前端进阶算法 -- 01

218 阅读26分钟

一、 算法的执行效率和资源消耗

1.1 含义:代码执行的时间、执行消耗的存储空间。

1.2 时间复杂度

T(n) = O(f(n))

大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity) ,简称时间复杂度

当 n 无限大时,时间复杂度 T(n) 受 n 的最高数量级影响最大,与f(n) 中的常量、低阶、系数关系就不那么大了。所以我们分析代码的时间复杂度时,仅仅关注代码执行次数最多的那段就可以了。

1.21 常见复杂度(按数量阶递增)

多项式量级:

  • 常量阶: O(1):当算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)
  • 对数阶:O(logn): 简单介绍一下
let i=1;
while (i <= n)  {
  i = i * 2;
}
  • 每次循环 i 都乘以 2 ,直至 i > n ,即执行过程是:20、21、22、…、2k、…、2x、 n
    所以总执行次数 x ,可以写成 2x = n ,则时间复杂度为 O(log2n) 。这里是 2 ,也可以是其他常量 k ,时间复杂度也是: O(log3n) = O(log32 * log2n) = O(log2n)
  • 线性阶:O(n)
  • 线性对数阶:O(nlogn)
  • 平方阶、立方阶、….、k次方阶:O(n2)、O(n3)、…、O(nk)

非多项式量阶:

  • 指数阶:O(2k)
  • 阶乘阶:O(n!)

1.3 空间复杂度

时间复杂度表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度表示算法的存储空间与数据规模之间的增长关系。

二、 JavaScript数组

  • JavaScript 中, JSArray 继承自 JSObject ,或者说它就是一个特殊的对象,内部是以 key-value 形式存储数据,所以 JavaScript 中的数组可以存放不同类型的值。
  • 它有两种存储方式,快数组慢数组,初始化空数组时,使用快数组,快数组使用连续的内存空间,当数组长度达到最大时,JSArray 会进行动态的扩容,以存储更多的元素,相对慢数组,性能要好得多。当数组中 hole 太多时,会转变成慢数组,即以哈希表的方式( key-value 的形式)存储数据,以节省内存空间。

三、 全方位解读前端用到的栈结构(调用栈、堆、垃圾回收等)

3.1 栈

栈是一种遵从后进先出 (LIFO / Last In First Out) 原则的有序集合,它的结构类似如下:

image.png

栈的操作主要有: push(e) (进栈)、 pop() (出栈)、 isEmpty() (判断是否是空栈)、 size() (栈大小),以及 clear() 清空栈

3.2 浏览器中 JS 运行机制

JavaScript 将任务的执行模式分为两种:同步异步

同步任务都在主线程(这里的主线程就是 JavaScript 引擎线程)上执行,会形成一个 调用栈 ,又称 **执行栈 **;

除了主线程外,还有一个任务队列(也称消息队列),用于管理异步任务的 事件回调 ,在 调用栈 的任务执行完毕之后,系统会检查任务队列,看是否有可以执行的异步任务。

注意:任务队列存放的是异步任务的事件回调

3.3 调用栈

两个方面介绍调用栈:

  • 调用栈的用来做什么
  • 在开发中,如何利用调用栈

3.31 调用栈的职责

  • 调用栈就是用来管理函数调用关系的一种栈结构 。

  • 调用栈是 JavaScript 用来管理函数执行上下文的一种数据结构,它记录了当前函数执行的位置,哪个函数正在被执行。 如果我们执行一个函数,就会为函数创建执行上下文并放入栈顶。 如果我们从函数返回,就将它的执行上下文从栈顶弹出。 也可以说调用栈是用来管理这种执行上下文的栈,或称执行上下文栈(执行栈)

3.32 栈溢出

function add() {
  return 1 + add()
}

add()

add 函数不断的递归,不断的入栈,调用栈的容量有限,它就溢出了,所以,在日常的开发中,一定要注意此类代码的出现。

3.32 在浏览器中获取调用栈信息

  • 断点调试
  • console.trace()

4. JS 内存机制:栈(基本类型、引言类型地址)与堆(引用类型数据)

JavaScript 中的内存空间主要分为三种类型:

  • 代码空间:主要用来存放可执行代码
  • 栈空间:调用栈的存储空间就是栈空间。
  • 堆空间

代码空间主要用来存放可执行代码的。栈空间及堆空间主要用来存放数据的。接下来主要介绍栈空间及堆空间。

JavaScript 中的变量类型有 8 种,可分为两种:基本类型、引用类型

基本类型:

  • undefined
  • null
  • boolean
  • number
  • string
  • bigint
  • symbol

引用类型:

  • object

其中,基本类型是保存在栈内存中的简单数据段,而引用类型保存在堆内存中。

4.1. 栈空间

基本类型在内存中占有固定大小的空间,所以它们的值保存在栈空间,可以通过 按值访问 。

一般栈空间不会很大。

4.2. 堆空间

引用类型,值大小不固定,但指向值的指针大小(内存地址)是固定的,所以把对象放入堆中,将对象的地址放入栈中,这样,在调用栈中切换上下文时,只需要将指针下移到上个执行上下文的地址就可以了,同时保证了栈空间不会很大。

当查询引用类型的变量时, 先从栈中读取内存地址, 然后再通过地址找到堆中的值。对于这种,我们把它叫做 按引用访问 。

一般堆内存空间很大,能存放很多数据,但它内存分配与回收都需要花费一定的时间。

基本类型(栈空间)与引用类型(堆空间)的存储方式决定了:基本类型赋值是值赋值,而引用类型赋值是地址赋值。

// 值赋值
var a = 1
var b = a
a = 2
console.log(b) 
// 1
// b 不变

// 地址赋值
var a1 = {name: 'an'}
var b1 = a1
a1.name = 'bottle'
console.log(b1)
// {name: "bottle"}
// b1 值改变

4.3. 垃圾回收

JavaScript 中的垃圾数据都是由垃圾回收器自动回收的,不需要手动释放。

4.4 回收栈空间

在 JavaScript 执行代码时,主线程上会存在 ESP 指针,用来指向调用栈中当前正在执行的上下文,如下图,当前正在执行 foo 函数:

当 foo 函数执行完成后,ESP 向下指向全局执行上下文,此时需要销毁 foo 函数。

怎么销毁呢?

当 ESP 指针指向全局执行上下文,foo 函数执行上下文已经是无效的了,当有新的执行上下文进来时,可以直接覆盖这块内存空间。

即:JavaScript 引擎通过向下移动 ESP 指针来销毁存放在栈空间中的执行上下文。

4.5 回收堆空间

V8 中把堆分成新生代与老生代两个区域:

  • 新生代:用来存放生存周期较短的小对象,一般只支持1~8M的容量
  • 老生代:用来存放生存周期较长的对象或大对象

V8 对这两块使用了不同的回收器:

  • 新生代使用副垃圾回收器
  • 老生代使用主垃圾回收器

其实无论哪种垃圾回收器,都采用了同样的流程(三步走):

  • 标记:  标记堆空间中的活动对象(正在使用)与非活动对象(可回收)
  • 垃圾清理:  回收非活动对象所占用的内存空间
  • 内存整理:  当进行频繁的垃圾回收时,内存中可能存在大量不连续的内存碎片,当需要分配一个需要占用较大连续内存空间的对象时,可能存在内存不足的现象,所以,这时就需要整理这些内存碎片。

副垃圾回收器与主垃圾回收器虽然都采用同样的流程,但使用的回收策略与算法是不同的。

副垃圾回收器

它采用 Scavenge 算法及对象晋升策略来进行垃圾回收

所谓 Scavenge 算法,即把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域。

新加入的对象都加入对象区域,当对象区满的时候,就执行一次垃圾回收,执行流程如下:

  • 标记:首先要对区域内的对象进行标记(活动对象、非活动对象)
  • 垃圾清理:然后进行垃圾清理:将对象区的活动对象复制到空闲区域,并进行有序的排列,当复制完成后,对象区域与空闲区域进行翻转,空闲区域晋升为对象区域,对象区域为空闲区域

翻转后,对象区域是没有碎片的,此时不需要进行第三步(内存整理了)

但,新生代区域很小的,一般1~8M的容量,所以它很容易满,所以,JavaScript 引擎采用对象晋升策略来处理,即只要对象经过两次垃圾回收之后依然继续存活,就会被晋升到老生代区域中。

主垃圾回收器

老生代区域里除了存在从新生代晋升来的存活时间久的对象,当遇到大对象时,大对象也会直接分配到老生代。

所以主垃圾回收器主要保存存活久的或占用空间大的对象,此时采用 Scavenge 算法就不合适了。V8 中主垃圾回收器主要采用标记-清除法进行垃圾回收。

主要流程如下:

  • 标记:遍历调用栈,看老生代区域堆中的对象是否被引用,被引用的对象标记为活动对象,没有被引用的对象(待清理)标记为垃圾数据。
  • 垃圾清理:将所有垃圾数据清理掉
  • 内存整理:标记-整理策略,将活动对象整理到一起

增量标记

V8 浏览器会自动执行垃圾回收,但由于 JavaScript 也是运行在主线程上的,一旦执行垃圾回收,就要打断 JavaScript 的运行,可能会或多或少的造成页面的卡顿,影响用户体验,所以 V8 决定采用增量 标记算法回收:

即把垃圾回收拆成一个个小任务,穿插在 JavaScript 中执行。

4.6 总结

  • 从栈结构开始介绍,满足后进先出 (LIFO) 原则的有序集合,然后通过数组实现了一个栈。

  • 接着介绍浏览器环境下 JavaScript 的异步执行机制,即事件循环机制, JavaScript 主线程不断的循环往复的从任务队列中读取任务(异步事件回调),放入调用栈中执行。调用栈又称执行上下文栈(执行栈),是用来管理函数执行上下文的栈结构。

  • JavaScript 的存储机制分为代码空间栈空间以及堆空间,代码空间用于存放可执行代码,栈空间用于存放基本类型数据和引用类型地址,堆空间用于存放引用类型数据,当调用栈中执行完成一个执行上下文时,需要进行垃圾回收该上下文以及相关数据空间,存放在栈空间上的数据通过 ESP 指针来回收,存放在堆空间的数据通过副垃圾回收器(新生代)与主垃圾回收器(老生代)来回收。

5. 队列及配套算法题

常用的数据结构:

  • 数据结构:队列(Queue)
  • 双端队列(Deque)
  • 双端队列的应用:翻转字符串中的单词
  • 滑动窗口
  • 滑动窗口应用:无重复字符的最长公共子串
  • 最后来一道 leetcode 题目:滑动窗口最大值问题

5.1 数据结构:队列

队列和栈类似,不同的是队列是先进先出 (FIFO) 原则的有序集合,它的结构类似如下:

常见队列的操作有: enqueue(e) 进队、 dequeue() 出队、 isEmpty() 是否是空队、 front() 获取队头元素、clear() 清空队,以及 size() 获取队列长度。

5.2 双端队列(Deque)

5.21. 什么是 Deque

Deque 在原有队列的基础上扩充了:队头、队尾都可以进队出队,它的数据结构如下:

function Deque() {
  let items = []
  this.addFirst = function(e) {
    items.unshift(e)
  }
  this.removeFirst = function() {
    return items.shift()
  }
  this.addLast = function(e) {
    items.push(e)
  }
  this.removeLast = function() {
    return items.pop()
  }
  this.isEmpty = function() {
    return items.length === 0
  }
  this.front = function() {
    return items[0]
  }
  this.clear = function() { 
    items = [] 
  }
  this.size = function() {
    return items.length
  }
}

5.22. 字节&leetcode151:翻转字符串里的单词

给定一个字符串,逐个翻转字符串中的每个单词。

var reverseWords = function(s) {
    let left = 0
    let right = s.length - 1
    let queue = []
    let word = ''
    while (s.charAt(left) === ' ') left ++
    while (s.charAt(right) === ' ') right --
    while (left <= right) {
        let char = s.charAt(left)
        if (char === ' ' && word) {
            queue.unshift(word)
            word = ''
        } else if (char !== ' '){
            word += char
        }
        left++
    }
    queue.unshift(word)
    return queue.join(' ')
};

5.3 滑动窗口

5.31. 什么是滑动窗口

这是队列的另一个重要应用

顾名思义,滑动窗口就是一个运行在一个大数组上的子列表,该数组是一个底层元素集合。

假设有数组 [a b c d e f g h ],一个大小为 3 的 滑动窗口在其上滑动,则有:

[a b c]
  [b c d]
    [c d e]
      [d e f]
        [e f g]
          [f g h]

一般情况下就是使用这个窗口在数组的 合法区间 内进行滑动,同时 动态地 记录一些有用的数据,很多情况下,能够极大地提高算法地效率。

5.32. 字节&Leetcode3:无重复字符的最长子串

给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

var lengthOfLongestSubstring = function(s) {
    let arr = [], max = 0
    for(let i = 0; i < s.length; i++) {
        let index = arr.indexOf(s[i])
        if(index !== -1) {
            arr.splice(0, index+1);
        }
        arr.push(s.charAt(i))
        max = Math.max(arr.length, max) 
    }
    return max
};

6. 数据结构与算法中的字符串

6.1 String 类型

String(字符串)数据类型表示零或多个16位Unicode字符序列。字符串可以使用双引号(")、单引号(')或反引号(``)标示。

6.12 字符串的特点

ECMAScript中的字符串是不可变的(immutable)

6.13 转换为字符串

几乎所有值都有toString()方法。这个方法唯一的用途就是返回当前值的字符串等价物。

6.14 模板字面量

模板字面量可以跨行定义字符串:

let myNultiLineTemplateLiteral = `fisrt line  
second line`  
console.log(myNultiLineTemplateLiteral)  
// first line  
// second line

顾名思义,模板字面量在定义模板时特别有用,比如下面这个HTML模板:

let pageHTML = `  
<div>  
  <a href="#">  
 <span>Jake</span>  
  </a>  
</div>`

模板字面量会保持反引号内部的空格,因此在使用时要格外注意。

6.15 字符串插值

模板字面量最常用的一个特性是支持字符串插值,也就是可以在一个连续定义中插入一个或多个值。

字符串插值通过在${}中使用一个JavaScript表达式实现:

let value = 5  
let exponent = 'second'  
let interpolatedTemplateLiteral = `${ value } to thr ${ exponent } power is ${ value * value }`  
console.log(interpolatedTemplateLiteral) // 5 to the second power is 25

所有插入的值都会使用toString()强制转型为字符串。任何JavaScript表达式都可以用于插值。

6.16 模板字面量标签函数

模板字面量也支持定义标签函数(tag function) ,而通过标签函数可以自定义插值行为。

标签函数会接收被插值记号分隔后的模板和对每个表达式求值的结果。

6.17 原始字符串

使用模板字面量也可直接获取原始的模板字面量内容(如换行符或Unicode字符),而不是被转换后的字符表示。

console.log(`\u00A9`)      // ©  
console.log(String.raw`\u00A9`)  // \u00A9

6.2 加深

6.21 翻转字符串里的单词

给定一个字符串,逐个翻转字符串中的每个单词。

解法一:正则 + JS API

var reverseWords = function(s) {
    return s.trim().replace(/\s+/g' ').split(' ').reverse().join(' ')
};

解法二:双端队列(不使用 API)

双端队列,故名思义就是两端都可以进队的队列

解题思路:

  • 首先去除字符串左右空格
  • 逐个读取字符串中的每个单词,依次放入双端队列的对头
  • 再将队列转换成字符串输出(已空格为分隔符)
var reverseWords = function(s) {  
    let left = 0  
    let right = s.length - 1  
    let queue = []  
    let word = ''  
    while (s.charAt(left=== ' 'left ++  
    while (s.charAt(right=== ' 'right --  
    while (left <= right) {  
        let char = s.charAt(left)  
        if (char === ' ' && word) {  
            queue.unshift(word)  
            word = ''  
        } else if (char !== ' '){  
            word += char  
        }  
        left++  
    }  
    queue.unshift(word)  
    return queue.join(' ')  
};

6.22 最长公共前缀(LCP)

编写一个函数来查找字符串数组中的最长公共前缀。

如果不存在公共前缀,返回空字符串 ""

逐个比较

解题思路: 从前往后依次比较字符串,获取公共前缀

var longestCommonPrefix = function(strs) {  
    if (strs === null || strs.length === 0) return "";  
    let prevs = strs[0]  
    for(let i = 1; i < strs.length; i++) {  
        let j = 0  
        for(; j < prevs.length && j < strs[i].length; j++) {  
            if(prevs.charAt(j) !== strs[i].charAt(j)) break  
        }  
        prevs = prevs.substring(0, j)  
        if(prevs === "") return ""  
    }  
    return prevs  
};

解法二:仅需最大、最小字符串的最长公共前缀

解题思路: 获取数组中的最大值及最小值字符串,最小字符串与最大字符串的最长公共前缀也为其他字符串的公共前缀,即为字符串数组的最长公共前缀

var longestCommonPrefix = function(strs) {  
    if (strs === null || strs.length === 0) return "";  
    if(strs.length === 1) return strs[0]  
    let min = 0, max = 0  
    for(let i = 1; i < strs.length; i++) {  
        if(strs[min] > strs[i]min = i  
        if(strs[max] < strs[i]max = i  
    }  
    for(let j = 0; j < strs[min].length; j++) {  
        if(strs[min].charAt(j) !== strs[max].charAt(j)) {  
            return strs[min].substring(0, j)  
        }  
    }  
    return strs[min]  
};

解法三:分治策略 归并思想

分治,顾名思义,就是分而治之,将一个复杂的问题,分成两个或多个相似的子问题,在把子问题分成更小的子问题,直到更小的子问题可以简单求解,求解子问题,则原问题的解则为子问题解的合并。

这道题就是一个典型的分治策略问题:

  • 问题:求多个字符串的最长公共前缀
  • 分解成多个相似的子问题:求两个字符串的最长公共前缀
  • 子问题可以简单求解:两个字符串的最长公共前缀求解很简单
  • 原问题的解为子问题解的合并:多个字符串的最长公共前缀为两两字符串的最长公共前缀的最长公共前缀,我们可以归并比较两最长公共前缀字符串的最长公共前缀,直到最后归并比较成一个,则为字符串数组的最长公共前缀:LCP(S1, S2, ..., Sn) = LCP(LCP(S1, Sk), LCP(Sk+1, Sn))
var longestCommonPrefix = function(strs) {  
    if (strs === null || strs.length === 0) return "";  
    return lCPrefixRec(strs)  
};  
  
// 若分裂后的两个数组长度不为 1,则继续分裂  
// 直到分裂后的数组长度都为 1,  
// 然后比较获取最长公共前缀  
function lCPrefixRec(arr) {  
  let length = arr.length  
  if(length === 1) {  
    return arr[0]  
  }  
  let mid = Math.floor(length / 2),  
      left = arr.slice(0, mid),  
      right = arr.slice(mid, length)  
  return lCPrefixTwo(lCPrefixRec(left), lCPrefixRec(right))  
}  
  
// 求 str1 与 str2 的最长公共前缀  
function lCPrefixTwo(str1, str2) {  
    let j = 0  
    for(; j < str1.length && j < str2.length; j++) {  
        if(str1.charAt(j) !== str2.charAt(j)) {  
            break  
        }  
    }  
    return str1.substring(0, j)  
}

6.23 实现一个函数,判断输入是不是回文字符串

解法一:使用API

function isPlalindrome(input) {
  if (typeof input !== 'string'return false;
  return input.split('').reverse().join('') === input;
}

解法二:不使用API

function isPlalindrome(input) {
  if (typeof input !== 'string'return false;
  let i = 0, j = input.length - 1
  while(i < j) {
      if(input.charAt(i) !== input.charAt(j)) return false
      i ++
      j --
  }
  return true
}

6.24 无重复字符的最长子串

给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

解法一:维护数组

解题思路: 使用一个数组来维护滑动窗口

遍历字符串,判断字符是否在滑动窗口数组里

  • 不在则 push 进数组
  • 在则删除滑动窗口数组里相同字符及相同字符前的字符,然后将当前字符 push 进数组
  • 然后将 max 更新为当前最长子串的长度

遍历完,返回 max 即可

var lengthOfLongestSubstring = function(s) {  
    let arr = [], max = 0  
    for(let i = 0; i < s.length; i++) {  
        let index = arr.indexOf(s[i])  
        if(index !== -1) {  
            arr.splice(0, index+1);  
        }  
        arr.push(s.charAt(i))  
        max = Math.max(arr.length, max)   
    }  
    return max  
};

解法二:维护下标

解题思路: 使用下标来维护滑动窗口

代码实现:

var lengthOfLongestSubstring = function(s) {
    let index = 0, max = 0
    for(let i = 0, j = 0; j < s.length; j++) {
        index = s.substring(i, j).indexOf(s[j]) 
        if(index !== -1) { 
            i = i + index + 1 
        } 
        max = Math.max(max, j - i + 1) 
    }
    return max
};

解法三:优化的Map

解题思路:

使用 map 来存储当前已经遍历过的字符,key 为字符,value 为下标

使用 i 来标记无重复子串开始下标,j 为当前遍历字符下标

遍历字符串,判断当前字符是否已经在 map 中存在,存在则更新无重复子串开始下标 i 为相同字符的下一位置,此时从 i 到 j 为最新的无重复子串,更新 max ,将当前字符与下标放入 map 中

最后,返回 max 即可

代码实现:

var lengthOfLongestSubstring = function(s) {
    let map = new Map(), max = 0
    for(let i = 0, j = 0; j < s.length; j++) {
        if(map.has(s[j])) {
            i = Math.max(map.get(s[j]) + 1, i)
        }
        max = Math.max(max, j - i + 1)
        map.set(s[j], j)
    }
    return max
};

6.25 字符串相加

给定两个字符串形式的非负整数 num1 和 num2 ,计算它们的和。

unction add(str1, str2) {  
  let result = ''  
  let tempVal = 0  
  let arr1 = str1.split('')  
  let arr2 = str2.split('')  
  
  while (arr1.length || arr2.length || tempVal) {  
    tempVal += ~~arr1.pop() + ~~arr2.pop()  
    result = tempVal % 10 + result  
    tempVal = ~~(tempVal / 10)  
  }  
  
  return result.replace(/^0+/, '')  
}

6.26 字符串相乘

给定两个以字符串形式表示的非负整数 num1 和 num2,返回 num1 和 num2 的乘积,它们的乘积也表示为字符串形式。

解法一:常规解法

从右往左遍历乘数,将乘数的每一位与被乘数相乘得到对应的结果,再将每次得到的结果累加

另外,当乘数的每一位与被乘数高位(非最低位)相乘的时候,注意低位补 '0'

let multiply = function(num1, num2) {  
    if (num1 === "0" || num2 === "0") return "0"  
      
    // 用于保存计算结果  
    let res = "0"  
          
    // num2 逐位与 num1 相乘  
    for (let i = num2.length - 1; i >= 0; i--) {  
        let carry = 0  
        // 保存 num2 第i位数字与 num1 相乘的结果  
        let temp = ''  
        // 补 0   
        for (let j = 0; j < num2.length - 1 - i; j++) {  
            temp+='0'  
        }  
        let n2 = num2.charAt(i) - '0'  
              
        // num2 的第 i 位数字 n2 与 num1 相乘  
        for (let j = num1.length - 1; j >= 0 || carry != 0; j--) {  
            let n1 = j < 0 ? 0 : num1.charAt(j) - '0'  
            let product = (n1 * n2 + carry) % 10  
            temp += product   
            carry = Math.floor((n1 * n2 + carry) / 10)  
        }  
        // 将当前结果与新计算的结果求和作为新的结果  
        res = addStrings(res, Array.prototype.slice.call(temp).reverse().join(""))  
    }  
    return res  
}  
  
let addStrings = function(num1, num2) {  
    let a = num1.length, b = num2.length, result = '', tmp = 0  
    while(a || b) {  
        a ? tmp += +num1[--a] : ''  
        b ? tmp +=  +num2[--b] : ''  
          
        result = tmp % 10 + result  
        if(tmp > 9) tmp = 1  
        else tmp = 0  
    }  
    if (tmp) result = 1 + result  
    return result  
}

解法二:竖式相乘(优化)

两个数M和N相乘的结果可以由 M 乘上 N 的每一位数的和得到

  • 计算 num1 依次乘上 num2 的每一位的和
  • 把得到的所有和按对应的位置累加在一起,就可以得到 num1 * num2 的结果
let multiply = function(num1, num2) {  
    if(num1 === '0' || num2 === '0') return "0"  
      
    // 用于保存计算结果  
    let res = []  
      
    // 从个位数开始逐位相乘  
    for(let i = 0 ; i < num1.length; i++){  
        // num1 尾元素  
        let tmp1 = +num1[num1.length-1-i]  
          
        for(let j = 0; j < num2.length; j++){  
            // num2尾元素  
            let tmp2 = +num2[num2.length-1-j]  
              
            // 判断结果集索引位置是否有值  
            let pos = res[i+j] ? res[i+j]+tmp1*tmp2 : tmp1*tmp2  
            // 赋值给当前索引位置  
            res[i+j] = pos%10  
            // 是否进位 这样简化res去除不必要的"0"  
            pos >=10 && (res[i+j+1]=res[i+j+1] ? res[i+j+1]+Math.floor(pos/10) : Math.floor(pos/10));  
        }  
    }  
    return res.reverse().join("");  
}

7. 限制并发

7.1 引入p-limit处理并发

import pLimit from 'p-limit';  
  
const limit = pLimit(2);  
  
const input = [  
    limit(() => fetchSomething('foo')),  
    limit(() => fetchSomething('bar')),  
    limit(() => doSomething())  
];  
  
const result = await Promise.all(input);  
console.log(result);

7.2 封装pLimit方法以及使用

const pLimit = (concurrency) => {  
    if (!((Number.isInteger(concurrency) || concurrency === Infinity) && concurrency > 0)) {  
      throw new TypeError('Expected `concurrency` to be a number from 1 and up');  
    }  
    
    const queue = [];  
    let activeCount = 0;  
    
    const next = () => {  
      activeCount--;  
    
      if (queue.length > 0) {  
        queue.shift()();  
      }  
    };  
    
    const run = async (fn, resolve, ...args) => {  
      activeCount++;  
    
      const result = (async () => fn(...args))();  
  
      resolve(result);  
    
      try {  
        await result;  
      } catch {}  
  
      next();  
    };  
    
    const enqueue = (fn, resolve, ...args) => {  
      queue.push(run.bind(null, fn, resolve, ...args));  
    
      (async () => {  
        await Promise.resolve();  
    
        if (activeCount < concurrency && queue.length > 0) {  
          queue.shift()();  
        }  
      })();  
    };  
    
    const generator = (fn, ...args) =>  
      new Promise((resolve) => {  
        enqueue(fn, resolve, ...args);  
      });  
    
    Object.defineProperties(generator, {  
      activeCount: {  
        get() => activeCount  
      },  
      pendingCount: {  
        get() => queue.length  
      },  
      clearQueue: {  
        value() => {  
          queue.length = 0;  
        }  
      }  
    });  
    
    return generator;  
  };  
    
const limit = pLimit(2);  
    
function asyncFun(value, delay) {  
    return new Promise((resolve) => {  
        console.log('start ' + value);  
        setTimeout(() => resolve(value), delay);  
    });  
}  
  
(async function () {  
    const arr = [  
        limit(() => asyncFun('aaa'2000)),  
        limit(() => asyncFun('bbb'3000)),  
        limit(() => asyncFun('ccc'1000)),  
        limit(() => asyncFun('ccc'1000)),  
        limit(() => asyncFun('ccc'1000))  
    ];  
    
    const result = await Promise.all(arr);  
    console.log(result);  
})();

8. 九大排序策略

8.1 冒泡排序

原理:

从左到右,相邻元素进行比较,如果前一个元素值大于后一个元素值(正序),则交换,这样一轮下来,将最大的数在最右边冒泡出来。这样一轮一轮下来,最后实现从小到大排序。

function bubbleSort(arr) {  
    for (let i = 0; i < arr.length; i++) {  
        for (let j = 0; j < arr.length - i - 1; j++) {  
            if (arr[j] > arr[j + 1]) {  
                const temp = arr[j];  
                arr[j] = arr[j + 1];  
                arr[j + 1] = temp;  
            }  
        }  
    }  
}  
  
// 改进冒泡排序  
function bubbleSort1(arr) {  
    for (let i = 0; i < arr.length; i++) {  
        // 提前退出冒泡循环的标识位  
        let flag = false;  
        for (let j = 0; j < arr.length - i - 1; j++) {  
            if (arr[j] > arr[j + 1]) {  
                const temp = arr[j];  
                arr[j] = arr[j + 1];  
                arr[j + 1] = temp;  
                flag = true;  
                // 表示发生了数据交换  
            }  
        }  
        // 没有数据交换  
        if(!flag) break  
    }  
}  
  
  
// 测试  
let arr = [13254]  
bubbleSort(arr)  
console.log(arr) // [1, 2, 3, 4, 5]  
  
let arr1 = [13254]  
bubbleSort1(arr1)  
console.log(arr1) // [1, 2, 3, 4, 5]

复杂度分析:

  • 时间复杂度:最好时间复杂度 O(n),平均时间复杂度 O(n^2^)
  • 空间复杂度:O(1)

8.2 选择排序

原理

从未排序的序列中找到最大(或最小的)放在已排序序列的末尾(为空则放在起始位置),重复该操作,知道所有数据都已放入已排序序列中。

function selectionSort(arr) {
  let length = arr.length,
      indexMin
  for(let i = 0; i < length - 1; i++) {
    indexMin = i
    for(let j = i; j < length; j++) {
      if(arr[indexMin] > arr[j]) {
        indexMin = j
      }
    }
    if(i !== indexMin) {
      let temp = arr[i]
      arr[i] = arr[indexMin]
      arr[indexMin] = temp
    }
  }
}

// 测试
let arr = [13254]
selectionSort(arr)
console.log(arr) // [1, 2, 3, 4, 5]

复杂度分析

时间复杂度: O(n^2^)

空间复杂度: O(1)

8.3 归并排序

原理

它采用了分治策略,将数组分成2个较小的数组,然后每个数组再分成两个更小的数组,直至每个数组里只包含一个元素,然后将小数组不断的合并成较大的数组,直至只剩下一个数组,就是排序完成后的数组序列。

实现步骤:

  • 将原始序列平分成两个小数组
  • 判断小数组长度是否为1,不为1则继续分裂
  • 原始数组被分称了长度为1的多个小数组,然后合并相邻小数组(有序合并)
  • 不断合并小数组,直到合并称一个数组,则为排序后的数组序列
function mergeSort(arr) {
  let array = mergeSortRec(arr)
  return array
}

// 若分裂后的两个数组长度不为 1,则继续分裂
// 直到分裂后的数组长度都为 1,
// 然后合并小数组
function mergeSortRec(arr) {
  let length = arr.length
  if(length === 1) {
    return arr
  }
  let mid = Math.floor(length / 2),
      left = arr.slice(0, mid),
      right = arr.slice(mid, length)
  return merge(mergeSortRec(left), mergeSortRec(right))
}

// 顺序合并两个小数组left、right 到 result
function merge(left, right) {
  let result = [],
      ileft = 0,
      iright = 0
  while(ileft < left.length && iright < right.length) {
    if(left[ileft] < right[iright]){
      result.push(left[ileft ++])
    } else {
      result.push(right[iright ++])
    }
  }
  while(ileft < left.length) {
    result.push(left[ileft ++])
  }
  while(iright < right.length) {
    result.push(right[iright ++])
  }
  return result
}

// 测试
let arr = [1, 3, 2, 5, 4]
console.log(mergeSort(arr)) // [1, 2, 3, 4, 5]

复杂度分析

**时间复杂度:**O(nlog2n)

**空间复杂度:**O(n)

8.4 快速排序

原理

和归并排序一致,它也使用了分治策略的思想,它也将数组分成一个个小数组,但与归并不同的是,它实际上并没有将它们分隔开。

快排使用了分治策略的思想,所谓分治,顾名思义,就是分而治之,将一个复杂的问题,分成两个或多个相似的子问题,在把子问题分成更小的子问题,直到更小的子问题可以简单求解,求解子问题,则原问题的解则为子问题解的合并。

快排的过程简单的说只有三步:

  • 首先从序列中选取一个数作为基准数
  • 将比这个数大的数全部放到它的右边,把小于或者等于它的数全部放到它的左边 (一次快排 partition
  • 然后分别对基准的左右两边重复以上的操作,直到数组完全排序

具体按以下步骤实现:

  • 1,创建两个指针分别指向数组的最左端以及最右端
  • 2,在数组中任意取出一个元素作为基准
  • 3,左指针开始向右移动,遇到比基准大的停止
  • 4,右指针开始向左移动,遇到比基准小的元素停止,交换左右指针所指向的元素
  • 5,重复3,4,直到左指针超过右指针,此时,比基准小的值就都会放在基准的左边,比基准大的值会出现在基准的右边
  • 6,然后分别对基准的左右两边重复以上的操作,直到数组完全排序
let quickSort = (arr) => {
  quick(arr, 0 , arr.length - 1)
}

let quick = (arr, left, right) => {
  let index
  if(left < right) {
    // 划分数组
    index = partition(arr, left, right)
    if(left < index - 1) {
      quick(arr, left, index - 1)
    }
    if(index < right) {
      quick(arr, index, right)
    }
  }
}

// 一次快排
let partition = (arr, left, right) => {
  // 取中间项为基准
  var datum = arr[Math.floor(Math.random() * (right - left + 1)) + left],
      i = left,
      j = right
  // 开始调整
  while(i <= j) {
    
    // 左指针右移
    while(arr[i] < datum) {
      i++
    }
    
    // 右指针左移
    while(arr[j] > datum) {
      j--
    }
    
    // 交换
    if(i <= j) {
      swap(arr, i, j)
      i += 1
      j -= 1
    }
  }
  return i
}

// 交换
let swap = (arr, i , j) => {
    let temp = arr[i]
    arr[i] = arr[j]
    arr[j] = temp
}

// 测试
let arr = [1, 3, 2, 5, 4]
quickSort(arr)
console.log(arr) // [1, 2, 3, 4, 5]
// 第 2 个最大值
console.log(arr[arr.length - 2])  // 4

快排是从小到大排序,所以第 k 个最大值在 n-k 位置上

复杂度分析

  • 时间复杂度:O(nlog2n)
  • 空间复杂度:O(nlog2n)

8.5 希尔排序

1959年Shell发明,第一个突破 O(n^2^) 的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。

插入排序

插入排序的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入

function insertionSort(arr) {
    let n = arr.length;
    let preIndex, current;
    for (let i = 1; i < n; i++) {
        preIndex = i - 1;
        current = arr[i];
        while (preIndex >= 0 && arr[preIndex] > current) {
            arr[preIndex + 1] = arr[preIndex];
            preIndex--;
        }
        arr[preIndex + 1] = current;
    }
    return arr;
}

插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。

复杂度分析:

  • 时间复杂度:O(n^2^)
  • 空间复杂度:O(1)

希尔排序

function shellSort(arr) {
    let n = arr.length;
    for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) {
        for (let i = gap; i < n; i++) {
            let j = i;
            let current = arr[i];
            while (j - gap >= 0 && current < arr[j - gap]) {
                 arr[j] = arr[j - gap];
                 j = j - gap;
            }
            arr[j] = current;
        }
    }
    return arr;
}

复杂度分析:

  • 时间复杂度:O(nlogn)
  • 空间复杂度:O(1)

8.6 计数排序

原理

计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。

作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。它是一种典型的拿空间换时间的排序算法

function countingSort(arr, maxValue) => {
    // 开辟的新的数组,用于将输入的数据值转化为键存储
    var bucket = new Array(maxValue + 1),
        sortedIndex = 0,
        arrLen = arr.length,
        bucketLen = maxValue + 1

    // 存储
    for (var i = 0; i < arrLen; i++) {
        if (!bucket[arr[i]]) {
            bucket[arr[i]] = 0
        }
        bucket[arr[i]]++
    }

    // 将数据从bucket按顺序写入arr中
    for (var j = 0; j < bucketLen; j++) {
        while(bucket[j]-- > 0) {
            arr[sortedIndex++] = j
        }
    }
    return arr
}

复杂度分析

  • 时间复杂度:O(n+k)
  • 空间复杂度:O(n+k)

8.7 桶排序

原理

桶排序是计数排序的升级版。它也是利用函数的映射关系。

桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。

完整步骤:

  • 首先使用 arr 来存储频率
  • 然后创建一个数组(有数量的桶),将频率作为数组下标,对于出现频率不同的数字集合,存入对应的数组下标(桶内)即可。
// 桶排序
let bucketSort = (arr) => {
    let bucket = [], res = []
    arr.forEach((value, key) => {
        // 利用映射关系(出现频率作为下标)将数据分配到各个桶中
        if(!bucket[value]) {
            bucket[value] = [key]
        } else {
            bucket[value].push(key)
        }
    })
    // 遍历获取出现频率
    for(let i = 0;i <= bucket.length - 1;i++){
        if(bucket[i]) {
            res.push(...bucket[i])
        }
 }
 return res
}

复杂度分析:

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

8.8 基数排序

原理

基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

完整步骤:

  • 取得数组中的最大数,并取得位数;
  • arr为原始数组,从最低位开始取每个位组成radix数组;
  • 对radix进行计数排序(利用计数排序适用于小范围数的特点);
//LSD Radix Sort
var counter = [];
function radixSort(arr, maxDigit) {
    var mod = 10;
    var dev = 1;
    for (var i = 0; i < maxDigit; i++, dev *= 10, mod *= 10) {
        for(var j = 0; j < arr.length; j++) {
            var bucket = parseInt((arr[j] % mod) / dev);
            if(counter[bucket]==null) {
                counter[bucket] = [];
            }
            counter[bucket].push(arr[j]);
        }
        var pos = 0;
        for(var j = 0; j < counter.length; j++) {
            var value = null;
            if(counter[j]!=null) {
                while ((value = counter[j].shift()) != null) {
                      arr[pos++] = value;
                }
          }
        }
    }
    return arr;
}

复杂度分析

  • 时间复杂度:基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的
  • 空间复杂度:O(n+k),其中k为桶的数量。一般来说n>>k,因此额外空间需要大概n个左右

基数排序 vs 计数排序 vs 桶排序

这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值;

8.9 堆排序

原理

堆是一棵完全二叉树,它可以使用数组存储,并且大顶堆的最大值存储在根节点(i=1),所以我们可以每次取大顶堆的根结点与堆的最后一个节点交换,此时最大值放入了有效序列的最后一位,并且有效序列减1,有效堆依然保持完全二叉树的结构,然后堆化,成为新的大顶堆,重复此操作,知道有效堆的长度为 0,排序完成。

完整步骤为:

  • 将原序列(n个)转化成一个大顶堆
  • 设置堆的有效序列长度为 n
  • 将堆顶元素(第一个有效序列)与最后一个子元素(最后一个有效序列)交换,并有效序列长度减1
  • 堆化有效序列,使有效序列重新称为一个大顶堆
  • 重复以上2步,直到有效序列的长度为 1,排序完成
function heapSort(items) {
    // 构建大顶堆
    buildHeap(items, items.length-1)
    // 设置堆的初始有效序列长度为 items.length - 1
    let heapSize = items.length - 1
    for (var i = items.length - 1; i > 1; i--) {
        // 交换堆顶元素与最后一个有效子元素
        swap(items, 1, i);
        // 有效序列长度减 1
        heapSize --;
        // 堆化有效序列(有效序列长度为 currentHeapSize,抛除了最后一个元素)
        heapify(items, heapSize, 1);
    }
    return items;
}

// 原地建堆
// items: 原始序列
// heapSize: 有效序列长度
function buildHeap(items, heapSize) {
    // 从最后一个非叶子节点开始,自上而下式堆化
    for (let i = Math.floor(heapSize/2); i >= 1--i) {    
        heapify(items, heapSize, i);  
    }
}
function heapify(items, heapSize, i) {
    // 自上而下式堆化
    while (true) {
        var maxIndex = i;
        if(2*i <= heapSize && items[i] < items[i*2] ) {
            maxIndex = i*2;
        }
        if(2*i+1 <= heapSize && items[maxIndex] < items[i*2+1] ) {
            maxIndex = i*2+1;
        }
        if (maxIndex === i) break;
        swap(items, i, maxIndex); // 交换 
        i = maxIndex; 
    }
}  
function swap(items, i, j) {
    let temp = items[i]
    items[i] = items[j]
    items[j] = temp
}

// 测试
var items = [,1, 9, 2, 8, 3, 7, 4, 6, 5]
heapSort(items)
// [empty, 1, 2, 3, 4, 5, 6, 7, 8, 9]

测试成功

复杂度分析

  • **时间复杂度:**建堆过程的时间复杂度是 O(n) ,排序过程的时间复杂度是 O(nlogn) ,整体时间复杂度是 O(nlogn)
  • 空间复杂度: O(1)

10. 95% 的算法基于这 6 种算法思想

10.1 递归算法

10.11 算法策略

递归算法是一种直接或者间接调用自身函数或者方法的算法。

递归算法的实质是把问题分解成规模缩小的同类问题的子问题,然后递归调用方法来表示问题的解。递归算法对解决一大类问题很有效,它可以使算法简洁和易于理解。

优缺点:

  • 优点:实现简单易上手
  • 缺点:递归算法对常用的算法如普通循环等,运行效率较低;并且在递归调用的过程当中系统为每一层的返回点、局部量等开辟了栈来存储,递归太深,容易发生栈溢出(尾递归可以避免)

10.12 适用场景

递归算法一般用于解决三类问题:

  • 数据的定义是按递归定义的。(斐波那契数列)
  • 问题解法按递归算法实现。(回溯)
  • 数据的结构形式是按递归定义的。(树的遍历,图的搜索)

递归的解题策略:

  • 第一步:明确你这个函数的输入输出,先不管函数里面的代码什么,而是要先明白,你这个函数的输入是什么,输出是什么,功能是什么,要完成什么样的一件事。
  • 第二步:寻找递归结束条件,我们需要找出什么时候递归结束,之后直接把结果返回
  • 第三步:明确递归关系式,怎么通过各种递归调用来组合解决当前问题

10.13 使用递归算法求解的一些经典问题

  • 斐波那契数列
  • 汉诺塔问题
  • 树的遍历及相关操作

斐波那契数列

普通递归

function fibonacci (n) {
 if ( n <= 1 ) {return 1};
 return fibonacci(n - 1) + fibonacci(n - 2);
}

尾递归:

function fibonacci(n, ac1=1,ac2=1){
  if(n<=1){return ac2}
 return fibonacci(n-1, ac2, ac1 + ac2)
}

尾递归(Generator 函数和for...of循环)

function* fibonacci() {
 let [prev, curr] = [0, 1];
  // foo(;;)相当于死循环 等于while(1)
 for (;;) {
  yield curr;
  [prev, curr] = [curr, prev + curr];
 }
}
for (let n of fibonacci()) {
 if (n > 1000) break;
 console.log(n);
}

10.2 分治算法

10.21 算法策略

在计算机科学中,分治算法是一个很重要的算法,快速排序、归并排序等都是基于分治策略进行实现的,所以,建议理解掌握它。

分治,顾名思义,就是 分而治之 ,将一个复杂的问题,分成两个或多个相似的子问题,在把子问题分成更小的子问题,直到更小的子问题可以简单求解,求解子问题,则原问题的解则为阿子问题解的合并。

10.22 适用场景

当出现满足以下条件的问题,可以尝试只用分治策略进行求解:

  • 原始问题可以分成多个相似的子问题
  • 子问题可以很简单的求解
  • 原始问题的解是子问题解的合并
  • 各个子问题是相互独立的,不包含相同的子问题

分治的解题策略:

  • 第一步:分解,将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题
  • 第二步:解决,解决各个子问题
  • 第三步:合并,将各个子问题的解合并为原问题的解

10.23 使用分治法求解的一些经典问题

  • 二分查找
  • 归并排序
  • 快速排序
  • 汉诺塔问题
  • React 时间分片

二分查找

也称折半查找算法,它是一种简单易懂的快速查找算法。例如我随机写0-100之间的一个数字,让你猜我写的是什么?你每猜一次,我就会告诉你猜的大了还是小了,直到猜中为止。

第一步:分解

每次猜拳都把上一次的结果分出大的一组和小的一组,两组相互独立

  • 选择数组中的中间数
function binarySearch(items, item) {
    // low、mid、high将数组分成两组
    var low = 0,
        high = items.length - 1,
        mid = Math.floor((low+high)/2),
        elem = items[mid]
    // ...
}

第二步:解决子问题

查找数与中间数对比

  • 比中间数低,则去中间数左边的子数组中寻找;
  • 比中间数高,则去中间数右边的子数组中寻找;
  • 相等则返回查找成功
while(low <= high) {
 if(elem < item) { // 比中间数高
  low = mid + 1
 } else if(elem > item) { // 比中间数低
  high = mid - 1
 } else { // 相等
     return mid
 }
}

第三步:合并

function binarySearch(items, item) {
    var low = 0,
        high = items.length - 1,
        mid, elem
    while(low <= high) {
        mid = Math.floor((low+high)/2)
        elem = items[mid]
        if(elem < item) {
            low = mid + 1
        } else if(elem > item) {
            high = mid - 1
        } else {
            return mid
        }
    }
    return -1
}

二分法只能应用于数组有序的情况,如果数组无序,二分查找就不能起作用了

var quickSort = function(arr) {

  if (arr.length <= 1) { return arr; }

  var pivotIndex = Math.floor(arr.length / 2);

  var pivot = arr.splice(pivotIndex, 1)[0];

  var left = [];

  var right = [];

  for (var i = 0; i < arr.length; i++){

    if (arr[i] < pivot) {

      left.push(arr[i]);

    } else {
    
      right.push(arr[i]);
    }
  }
  return quickSort(left).concat([pivot], quickSort(right));
};

function binarySearch(items, item) {
    // 快排
    quickSort(items)
    var low = 0,
        high = items.length - 1,
        mid, elem
    while(low <= high) {
        mid = Math.floor((low+high)/2)
        elem = items[mid]
        if(elem < item) {
            low = mid + 1
        } else if(elem > item) {
            high = mid - 1
        } else {
            return mid
        }
    }
    return -1
}

// 测试
var arr = [2,3,1,4]
binarySearch(arr, 3)
// 2

binarySearch(arr, 5)
// -1

10.3 贪心算法

10.31 算法策略

贪心算法,故名思义,总是做出当前的最优选择,即期望通过局部的最优选择获得整体的最优选择。

10.32 适用场景

当满足一下条件时,可以使用:

  • 原问题复杂度过高
  • 求全局最优解的数学模型难以建立或计算量过大
  • 没有太大必要一定要求出全局最优解,“比较优”就可以

如果使用贪心算法求最优解,可以按照以下 步骤求解 :

  • 首先,我们需要明确什么是最优解(期望)

  • 然后,把问题分成多个步骤,每一步都需要满足:

    • 可行性:每一步都满足问题的约束
    • 局部最优:每一步都做出一个局部最优的选择
    • 不可取消:选择一旦做出,在后面遇到任何情况都不可取消
  • 最后,叠加所有步骤的最优解,就是全局最优解

10.33 经典案例:活动选择问题

使用贪心算法求解的经典问题有:

  • 最小生成树算法
  • 单源最短路径的 Dijkstra 算法
  • Huffman 压缩编码
  • 背包问题
  • 活动选择问题等

10.4 回溯算法

10.41 算法策略

回溯算法是一种搜索法,试探法,它会在每一步做出选择,一旦发现这个选择无法得到期望结果,就回溯回去,重新做出选择。深度优先搜索利用的就是回溯算法思想。

10.42 适用场景

回溯算法很简单,它就是不断的尝试,直到拿到解。它的这种算法思想,使它通常用于解决广度的搜索问题,即从一组可能的解中,选择一个满足要求的解。

10.43 使用回溯算法的经典案例

  • 深度优先搜索
  • 0-1背包问题
  • 正则表达式匹配
  • 八皇后
  • 数独
  • 全排列

10.5 动态规划

10.51 算法策略

动态规划也是将复杂问题分解成小问题求解的策略,与分治算法不同的是,分治算法要求各子问题是相互独立的,而动态规划各子问题是相互关联的。

使用动态规划求解问题时,需要遵循以下几个重要步骤:

  • 定义子问题
  • 实现需要反复执行解决的子子问题部分
  • 识别并求解出边界条件

10.52 使用动态规划求解的一些经典问题

  • 爬楼梯问题:假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
  • 背包问题:给出一些资源(有总量及价值),给一个背包(有总容量),往背包里装资源,目标是在背包不超过总容量的情况下,装入更多的价值
  • 硬币找零:给出面额不定的一定数量的零钱,以及需要找零的钱数,找出有多少种找零方案
  • 图的全源最短路径:一个图中包含 u、v 顶点,找出从顶点 u 到顶点 v 的最短路径
  • 最长公共子序列:找出一组序列的最长公共子序列(可由另一序列删除元素但不改变剩下元素的顺序实现)

爬楼梯问题

这里以动态规划经典问题爬楼梯问题为例,介绍求解动态规划问题的步骤。

第一步:定义子问题

如果用 dp[n] 表示第 n 级台阶的方案数,并且由题目知:最后一步可能迈 2 个台阶,也可迈 1 个台阶,即第 n 级台阶的方案数等于第 n-1 级台阶的方案数加上第 n-2 级台阶的方案数

第二步:实现需要反复执行解决的子子问题部分

dp[n] = dp[n−1] + dp[n−2]

第三步:识别并求解出边界条件

// 第 0 级 1 种方案 
dp[0]=1 
// 第 1 级也是 1 种方案 
dp[1]=1

最后一步:把尾码翻译成代码,处理一些边界情况

let climbStairs = function(n) {
    let dp = [11]
    for(let i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2]
    }
    return dp[n]
}

复杂度分析:

  • 时间复杂度:O(n)
  • 空间复杂度:O(n)

优化空间复杂度:

let climbStairs = function(n) {
    let res = 1, n1 = 1, n2 = 1
    for(let i = 2; i <= n; i++) {
        res = n1 + n2
        n1 = n2
        n2 = res
    }
    return res
}

空间复杂度:O(1)

10.6 枚举算法

10.61 算法策略

枚举算法的思想是:将问题的所有可能的答案一一列举,然后根据条件判断此答案是否合适,保留合适的,丢弃不合适的。

10.62 解题思路

  • 确定枚举对象、枚举范围和判定条件。
  • 逐一列举可能的解,验证每个解是否是问题的解。