贪心和动态规划算法 在 JavaScript 中的应用

471 阅读7分钟
  1. 什么是大O符号表示法?
  2. 常用的数据结构有哪些?
  3. 什么是贪心算法?
  4. 什么是动态规划?
  5. QA && 总结

我们应该如何去衡量不同算法之间的优劣?

主要还是从算法所占用的「时间」和「空间」两个维度去考量。

  • 时间维度:是指执行当前算法所消耗的时间,我们通常用「时间复杂度」来描述。
  • 空间维度:是指执行当前算法需要占用多少内存空间,我们通常用「空间复杂度」来描述。

大O表示法:算法的时间复杂度通常用大O符号表述,定义为T[n] = O(f(n))。其中f(n) 表示每行代码执行次数之和,而 O 表示正比例关系。当输入量n逐渐加大时,时间复杂度的极限情形称为算法的“渐近时间复杂度”。

渐进分析法最常用的表示方法是用于描述函数渐近行为的数学符号,更确切地说,它是用另一个函数来描述一个函数数量级的渐近上界。大O符号是由德国数论学家保罗·巴赫曼在其1892年的著作《解析数论》首先引入的。

时间复杂度的公式是: T[n] = O(f(n))

空间复杂度的公式是: O(1)

一、时间复杂度

举个栗子:

for(i=1; i<=n; ++i) {
   j = i;
   j++;
}

通过「 大O符号表示法 」,这段代码的时间复杂度为:O(n),为什么呢?

假设每行代码的执行时间都是一样的,我们用 1颗粒时间 来表示,那么这个例子的第一行耗时是1个颗粒时间,第二行的执行时间是 n个颗粒时间,第三行的执行时间也是 n个颗粒时间,那么总时间就是 1颗粒时间 + n颗粒时间 + n颗粒时间 ,即 (1+2n)个颗粒时间,即: T(n) = (1+2n)*颗粒时间,从这个结果可以看出,这个算法的耗时是随着n的变化而变化,因此,我们可以简化的将这个算法的时间复杂度表示为:T(n) = O(n)

为什么可以这么去简化呢,因为大O符号表示法并不是用于来真实代表算法的执行时间的,它是用来表示代码执行时间的增长变化趋势的,所以大家简化了其表示方式,常见的时间复杂度量级有:

  • 常数阶O(1)
  • 对数阶O(logN)
  • 线性阶O(n)
  • 线性对数阶O(nlogN)
  • 平方阶O(n²)
  • 立方阶O(n³)
  • K次方阶O(n^k)
  • 指数阶(2^n)

二、空间复杂度

既然时间复杂度不是用来计算程序具体耗时的,那么我也应该明白,空间复杂度也不是用来计算程序实际占用的空间的。

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,同样反映的是一个趋势,我们用 S(n) 来定义。

空间复杂度比较常用的有:

  • O(1)
  • O(n)
  • O(n²)

O(1)

大部分程序的大部分指令之执行一次,或者最多几次。如果一个程序的所有指令都具有这样的性质,我们说这个程序的执行时间是常数。

(value = 1) => {
  value = (2 * 100 * value) % 29
  value = (2 * 100 * value) % 29
  value = (2 * 100 * value) % 29
  return value * 2 + 1680 / 3
}

O(logN) 

如果一个程序的运行时间是对数级的,则随着N的增大程序会渐渐慢下来,如果一个程序将一个大的问题分解成一系列更小的问题,每一步都将问题的规 模缩减成几分之一 ,一般就会出现这样的运行时间函数。

在我们所关心的范围内,可以认为运行时间小于一个大的常数。对数的基数会影响这个常数,但改变不会太 大:当N=1000时,如果基数是10,logN等于3;如果基数是2,logN约等于10.当N=1 00 000,logN只是前值的两倍。

当N时原来的两倍,logN只增长了一个常数因子:仅当从N增长到N平方时,logN才会增长到原来的两倍。

(value = 1) => {
  let i = value
  while(i < 100) {
    i += 10
  }
  
  return i
}

O(N)

如果程序的运行时间的线性的,很可能是这样的情况:

对每个输入的元素都做了少量的处理。当N=1 000 000时,运行时间大概也就是这个数值; 当N增长到原来的两倍时,运行时间大概也增长到原来的两倍。

如果一个算法必须处理N个输入(或者产生N个输出), 那么这种情况是最优的。

(value = []) => {
  return value.map((item, index) => ({
  	...item,
    index,
  }))
}

O(NlogN)

如果某个算法将问题分解成更小的子问题,独立地解决各个子问题,最后将结果综合起来 (如归并排序,堆排序),运行时间一般就是NlogN。

我们找不到一个更好的形容, 就暂且将这样的算法运行时间叫做NlogN。

当N=1 000 000时,NlogN大约是20 000 000。

当N增长到原来的两倍,运行时间超过原来的两倍,但超过不是太多。

(value = []) => {
  return value.map((item, index) => {
    const value = []
    while (value.length < index) {
      value.push(index)
    }

    return {
      key: item,
      value,
    }
  })

O(N²)

如果一个算法的运行时间是二次的(quadratic),那么它一般只能用于一些规模较小的问题。

这样的运行时间通常存在于需要处理每一对输入 数据项的算法(在程序中很可能表现为一个嵌套循环)中,当N=1000时,运行时间是1 000 000;

如果N增长到原来的两倍,则运行时间将增长到原来的四倍。

const TEST_LIST = [1,2,3,4,5]
(value = []) => {
  return value.map(item => {
    return TEST_LIST.includes(item)
  })
}

O(N³) 

类似的,如果一个算法需要处理输入数据想的三元组(很可能表现为三重嵌套循环),其运行时间一般就是三次的,只能用于一些规模较小的问题。

当N=100时,运行时间就是1 000 000;如果N增长到原来的两倍,运行时间将会增长到原来的八倍。

const TEST_LIST = ['id', 'value', 'test']

(value = []) => {
  return value.map(item => {
     return TEST_LIST.filter(test => {
        return test.split('').includes(item)
     })
  })
}

常用的数据结构

  1. 数组
new Array()

栈是一种特殊的线性表,仅能在线性表的一端操作,栈顶允许操作,栈底不允许操作。 栈的特点是:先进后出,或者说是后进先出,从栈顶放入元素的操作叫入栈,取出元素叫出栈。

class Stack {
  constructor() {
    this.list = []
  }
  push(...item) {
    return this.list.push(...item)
  }
  pop() {
    return this.list.pop()
  }
}
  1. 队列

队列与栈一样,也是一种线性表,不同的是,队列可以在一端添加元素,在另一端取出元素,也就是:先进先出。从一端放入元素的操作称为入队,取出元素为出队。

class Queue {
  constructor() {
    this.list = []
  }

  enqueue(...item) {
    return this.list.unshift(...item)
  }

  dequeue() {
    return this.list.pop()
  }
  1. 链表

链表是物理存储单元上非连续的、非顺序的存储结构,数据元素的逻辑顺序是通过链表的指针地址实现,每个元素包含两个结点,一个是存储元素的数据域 (内存空间),另一个是指向下一个结点地址的指针域。根据指针的指向,链表能形成不同的结构,例如单链表,双向链表,循环链表等。

class LinkList {
  constructor() {
    this.head = null
  }

  find(value) {
    let curNode = this.head
    while (curNode.value !== value) {
      curNode = curNode.next
    }
    return curNode
  }

  findPrev(value) {
    let curNode = this.head
    while (curNode.next!==null && curNode.next.value !== value) {
      curNode = curNode.next
    }
    return curNode
  }

  insert(newValue, value) {
    const newNode = new Node(newValue)
    const curNode = this.find(value)
    newNode.next = curNode.next
    curNode.next = newNode
  }

  delete(value) {
    const preNode = this.findPrev(value)
    const curNode = preNode.next
    preNode.next = preNode.next.next
    return curNode
  }
}

class Node {
  constructor(value, next) {
    this.value = value
    this.next = null
  }
}
  1. [树]
  2. [图]
  3. [哈希表]

贪心算法

贪心算法在求解某个问题时,总是做出眼前的最大利益,也就是说只顾眼前不顾大局,所以他是局部最优解。

贪心算法不是对所有问题都能得到整体最好的解决办法,关键是贪心策略的选择,选择的贪心策略必须具备无后效性,即某个状态以前的状态不会影响以后的状态,只与当前状态有关。

贪心的思考过程类似动态规划,依旧是两步:大事化小小事化了

大事化小:

一个较大的问题,通过找到与子问题的重叠,把复杂的问题划分为多个小问题;

小事化了:

从小问题找到决策的核心,确定一种得到最优解的策略,比如跳一跳中的向右能到达的最远距离

贪心算法两个重要的特点是:

(1)贪心策略

(2)通过局部最优解能够得到全局最优解

解题:leecode 135. 分发糖果
地址:leetcode-cn.com/problems/ca…

const candy = ratings => {
  if(!ratings || !ratings.length) {
    return 0
  }

  let len = ratings.length
  let res = 1
  let cur = 1
  let pre = 0
  let dec = 0
  
  for(let i=1; i<len; i++) {
    if(ratings[i] > ratings[i-1]) {
      cur++
      res += cur
      dec = 0
    } else if(ratings[i] === ratings[i-1]) {
      cur = 1
      res += cur
      dec = 0
    } else {
      if(!dec) {
        pre = cur
      }
      dec++
      cur = 1
      if(pre > dec) {
        res += dec + cur - 1
      } else {
        res += dec + cur
      }
    }
  }
  return res
}

动态规划

动态规划把原问题分解为相对简单的子问题的方式求解复杂问题的方法。 动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。

动态规划核心思想:分解子问题,通过局部最大值得到全局最大,需要用到表格的分析。

解题:leecode 213. 打家劫舍 II

地址:leetcode-cn.com/problems/ho…

const robMax = (nums) => {
  const len = nums.length
  if (!len) {
    return 0
  }

  let robLast = nums[0]
  let passLast = 0
  let robCur = 0
  let passCur = 0


  for (let i = 1; i < len; i++) {
    robCur = nums[i] + passLast;
    passCur = Math.max(robLast, passLast);
    robLast = robCur;
    passLast = passCur;
  }

  return Math.max(robLast, passLast);
}

const rob = (nums) => {
  if (!nums || !nums.length) {
    return 0
  }
  if (nums.length < 3) {
    // 如果位数小于3为,直接求值即可
    return Math.max(...nums)
  } 
  const firstHousePrice = robMax(nums.slice(0, nums.length - 1))
  const lastHousePrice = robMax(nums.slice(1))
  return Math.max(firstHousePrice, lastHousePrice)
}

写在最后最后

  1. 通常来说,贪心解决不了就用动态规划来解决,一般贪心算法的时间复杂度为O(nlgn),动态规划为O(n^2),能用贪心解决就不用动态规划。

  2. 贪心得到的结果不一定是最优解。

  3. 动态规划在分析子问题的时候,会使用前面子问题的最优结果,并且前面子问题的最后结果不受后面的影响,最后一个子问题即最优解。