首页 > 编程语言 > 详细

算法图解

时间:2020-04-28 01:05:39      阅读:71      评论:0      收藏:0      [点我收藏+]

1.算法基础

算法是一组完成任务的指令,任何代码片段都可以视为算法。

二分查找

二分查找是一种算法,其输入是一个有序的元素列表。如果要查找的元素找到,返回其位置,否则返回None.

如果是顺序查找,取决于有序列表中的元素数量n;如果是二分查找,则取决于有序列表数量n的公式 $log2^n$.

二分查找的python代码类似下面:

def binary_search(list, item):
    low = 0
    high = len(list)
    
    while low <= high:
        mid = (low + high) / 2
        guess = list[mid]
        if guess == item :
            return mid
        if guess > item :
            high = mid - 1
        else :
            low = mid + 1
    return None

大O表示法

大O表示法是一种特殊的表示法,指出了算法的运行速度有多快,它并非以时间(例如秒、毫秒)为单位的速度,而是让你能够比较操作数,它指出了算法运行时间的增速。

使用大O表示法,会指出最糟糕情况下的运行时间,下面是一些蟾宫的大O时间:

大O时间 解释 举例
$O(log2^n)$ 对数时间 二分查找
$O(n)$ 线性时间 简单查找
$O(nlog2^n)$ 快速排序
$O(n^2)$ 选择排序
$O(n!)$ 旅行商问题,非常慢的算法

将其进行从快到慢的依次排序:

技术分享图片

通过大O表示法和上面的图,我们可以得到以下结论:

  • 算法的速度指的并非时间,而是操作数的增速。
  • 谈论算法的速度时,我们说的是随着输入的增加,其运行时间将以什么样的速度增加。
  • 算法的运行时间用大O表示法表示。
  • $O(log2^n)$比$O(n)$快,当需要搜索的元素越多时,前者比后者快得越多。

2.选择排序

内存的工作原理

技术分享图片

当你需要将数据存储到内存中时,请求计算机提供存储空间,计算机会给你一个存储地址。需要存储多项数据时,有两种基本方式——数据和链表。但它们不都适用于所有的情形,因此知道他们之间的差别很重要。

内存与硬盘的工作原理是有着巨大差别的,因此其排序方式也会有所不同,如果排序的文件处于磁盘上,且并不能一次性加载到内存中(往往内存较小),则需要考虑使用外排序。

数组和链表

对于数组和链表这两种非常简单的数据结构,可参考 Java数据结构与算法一篇文章。

二者常用的操作事件可以参考下面的表格:

技术分享图片

3.递归

递归描述的问题,通常是一类组合性质的问题,比如下面这种盒子套盒子,子盒子其中还可能发生嵌套的问题。

技术分享图片

虽然这类问题可以用非递归的方式来实现(while或者无限的for循环),但更加清晰、易理解的方式其实是使用递归 —— 无限调用自己。StackOverflow上有一句话:

如果使用循环,程序的性能可能会更高;如果使用递归,程序可能更容易理解。如何选择要看什么对你更加重要”。

递归的要素:基线条件和递归条件

在递归中,为了避免调用自己时会发生无限死循环,程序必须要知道什么时候该停止,因此每个递归程序都包含两部分:

  • 基线条件(base case):函数不再调用自己,从而避免形成无限循环;
  • 递归条件(recursive case): 函数调用自己;

数据结构:栈

栈是一种简单的数据结构,存在着限定的访问数据方式:LIFO(Last In First Out)。

计算机在内部使用被称为调用栈的栈,在进行方法调用时,计算机会将函数调用所涉及到的所有变量的值存储到内存中(当然也会涉及到内存分配),在调用另一个函数时,当前函数暂停并处于未完成状态,所有变量值都还在内存中,直到另一个函数执行完成再回到上层的函数。

技术分享图片

栈在递归调用中扮演着重要的角色,就像是方法调用一样。

使用栈虽然简单,但是也要付出代价:存储详尽的信息可能占用大量的内存,每个函数调用都要占用一定的内存,如果栈很高,就意味着计算机存储了大量函数调用的信息(StackOverflowError风险),有两种选择方案可以解决这个问题:

  • 重新编写代码,转而使用循环,尽管这可能造成一定的复杂性;
  • 使用尾递归(但不是所有的编程语言都支持尾递归);

4.快速排序:分治

分而治之 - Divide & Conquer,是一种著名的递归式解决问题的方法,引入快速排序的目的,就是说明一下如何对问题进行分治处理,在这里分治是策略,递归是手段。

D&C的工作原理:

  • 找出简单的基线条件;
  • 确定如何缩小问题的规模,使其符合基线条件;

D&C并非可用于解决问题的算法,而是一种解决问题的思路,举个例子,为了要计算 SUM(2,4,6) 的结果,可以使用递归的方式将问题进行分解,并确定基线条件:

技术分享图片

注意:编写涉及数组的递归函数时,基线条件通常是数组为空或只包含一个元素,陷入困境时,请检查基线条件是不是这样。

快速排序

快速排序是一种常见的排序算法,要比前面介绍的选择排序快得多,C语言中的排序函数qsort()使用的就是快速排序。

其基本算法结构为:

  • 选择基准值;
  • 将数组分成两个子数组:小于基准值的元素和大于基准值的元素;
  • 对这两个子数组进行快速排序;

快速排序算法实现的正确性取决于归纳证明原理,这是一种证明算法行之有效的方式,分为两步:基线条件和归纳条件。

假如,我要证明我能够爬到梯子的最上面:

  • 基线条件中,我已经站在了第一个横档上;
  • 归纳条件中,如果我站在一个横档上,就能将脚放在第二个横档上;

下面就是快速排序的算法实现:

def quicksort(array):
    if len(array) < 2:
        return array
    else:
        pivot = array[0]
        less = [i for i in array[1:] if i <= pivot]
        greater = [i for i in array[1:] if i>= pivot]
        return quicksort(less) + [pivot] + quicksort(greater)
print quicksort(...)

既然快速排序在最糟糕的情况下,算法复杂度退化到$O(N^2)$, 为什么不采用时间复杂度同样为$O(Nlog2^N)$的归并排序(merge sort)呢?

原因就在于算法复杂度的常量表示会有所不同,快速排序的常量要比归并排序的常量小得多,而且如果它们的运行速度都是$O(Nlog2^N)$,那么快速查找就会比归并排序快得多。

5.散列表

散列表是最有用的数据结构之一,用途非常广泛。

散列函数

散列函数是这样的函数,无论你给他什么样的数据,都会直接返回给你一个数字,必须满足下面的要求:

  • 必须是一致的:每次计算返回的结果都一样;
  • 应该将不同的输入映射到不同的数字上,这是最理想的情况;

散列表适合的场景:

  • 模拟映射关系;
  • 防止重复;
  • 缓存/记住数据,以免服务器再通过处理来生成它们;

冲突和性能

散列表存放的数据量是有限的,尤其是当数据量比较大的情况,会发现散列后对应的数据块已经被占用,这就称为冲突:

  • 散列函数很重要,理想情况下将键均匀地映射到散列表的不同位置;
  • 如果链表存储很长,速度/性能将会下降地非常明显;

平均情况下,散列表执行各种操作的时间均为$O(1)$;但在最糟糕的情况下,散列表的所有操作运行时间为$O(N)$,在使用散列表时,避开最糟情况至关重要,要避免冲突需要:

  • 较低的装填因子,装填因子 = 散列表中包含的元素数/位置总数
  • 良好的散列函数,可以参考 SHA函数

6.广度优先搜索

如下图,为找出换乘最少的路径,你该使用什么算法?

技术分享图片

这种类型问题被称为最短路径问题(shortest-path problem),解决最短路径问题的算法被称为广度优先算法,一般此类问题需要两个步骤:

  • 使用图来建立问题模型;
  • 使用广度优先搜索来解决问题;

图模拟一组连接,由节点和边组成,一个节点可能会与众多节点直接相连,这些节点被称为邻居,所以图一般是用来模拟不同的东西是如何相连的。

广度优先搜索

广度优先搜索是一种用于图的查找,方便解决下面的两类问题:

  • 从节点A出发,有到节点B的路径吗?
  • 从节点A出发到节点B的最短路径是什么?

队列:与现实生活中的队列原理类似,与栈比较相近,你不能随机地访问队列中的元素,它只支持两种操作:入队和出队;

队列是一种先进先出(FIFO)的数据结构,而栈是一种后进先出(LIFO)的数据结构。

假设你经营着一个芒果农场,需要寻找芒果销售商,以便将芒果卖给他。在Facebook,你与 芒果销售商有联系吗?为此,你可在朋友中查找。

借助队列实现的算法思路为:

技术分享图片

def search(name):
    search_queue = deque()
    search_queue += graph[name]
    searched = []
    while search_queue:
        person = search_queue.popleft()
        if not person in searched:
            if person_is_seller(person):
                print person + " is a mongo seller!"
                return True
            else
                search_queue += graph[person]
                searched.append(person)
    return False

这个算法将不断执行,直到满足以下条件之一:

  • 找到一位芒果销售商;
  • 队列为空;

7.迪克斯特拉算法

前一章中的图是否存在路径,采用的是广度优先搜索算法,它找出的是段数最少的路径。如果你想要找到最快的路径,可以考虑使用另外一种算法 —— 迪克斯特拉算法。

技术分享图片

这种带有权重边的图被称为加权图(不带权重的图被称为非加权图),其中包含4个主要步骤:

  • 找出最便宜的节点,即可在最短时间内到达的节点;
  • 更新该节点的邻居的开销,其含义稍后介绍;
  • 重复这个过程,直到对图中的所有节点都这么做了;
  • 计算最终路径;

要计算加权图的最短路径,使用广度优先搜索;要计算非加权图的最短路径,使用迪克斯特拉算法。当然,迪克斯特拉只适用于有向无环图。

总体算法如下:

技术分享图片

以伪代码方式:


node = find_lowest_cost_node(costs)
while node is not None: //结束条件:所有节点都被处理过
    cost = costs[node]
    neighbours = graph[node]
    for n in neighbours.keys():
        new_cost = cost + neighbours[n]
        if costs[n] > new_cost:
            costs[n] = new_cost
            parents[n] = n
    proceeded.append(node)
    node = find_lowest_code_node(costs)     

def find_lowest_cost_node(costs):
    lowest_cost = float("inf")
    lowest_cost_node = None
    for node in costs:  // 遍历所有节点
        cost = costs[node]
        if cost < lowest_code and node not in processed: //如果当前节点成本更低且没有被使用过
            lowest_cost = cost  //将其设置成开销最低的节点
            lowest_cost_node = node
    return lowest_cost_node

8.贪婪算法

教室调度问题

假如有如下问题,你希望将尽可能多的课程安排在某间教室,如何选出尽可能多且时间不冲突的课程呢?

技术分享图片

具体做法可能比较简单:

1.选出最早结束的课,这就是在这间教室上的第一节课;
2.接下来,选择第一节课结束后开始的最早结束的课,这是第二节课,以此类推;

从算法实现和原理上来看,贪婪算法太容易、太显而易见,这正是贪婪算法的优点:简单易行!贪婪算法很简单:每步都采用最优的做法,用专业术语来说,就是每步都选择局部最优解,最终得到的就是全局最优解。

显然,贪婪算法并不是在所有情况下都行之有效,但它简单易行!

背包问题

对于不可切分物品的背包问题,贪婪算法显然不能获得最优解,但非常接近。这给我们一个启示:在有些情况下,完美是优秀的敌人,有时候你只需要找到一个能够大致解决的算法,此时,贪婪算法就可以派上用场了,因为他们实现起来很容易,得到的结果又与正确结果大致接近。

集合覆盖问题

又假设你办了个广播节目,想让全美50个州的听众都能够收听地到,为此你决定要在哪些广播台中播出。因为每个广播台都需要费用,你需要力图在尽可能少的广播台播出。

每个广播台都有其覆盖的区域,不同广播台之间可能会有所交叉。

技术分享图片

简单的方案可能如下:

  1. 列出所有可能的广播台集合,这被称为幂集,可能的子集会有$2^N$个;
  2. 在这些集合中,选出覆盖全美50个州的最小集合;

由于可能的子集会有$2N$个,所以运行时间为$O(2N)$,这个部分的工作量非常大,有没有合适的算法来解决这个问题呢?借助于近似算法。

使用下面的贪婪算法可以得到非常接近的解:

  1. 选出这样的一个广播台,即它覆盖了最多的未覆盖州,即便有冲突也没有关系;
  2. 重复第一步,直到覆盖了所有的州;

这就是一种近似算法,在获得精确解需要的时间太长时,可使用近似算法,判断其优劣的标准为:

  • 速度有多快;
  • 得到的近似解与最优解的接近程度;

在这里例子中,贪婪算法的运行时间为$O(N^2)$.

NP完全问题

旅行商问题,所有可能的路线,被称为阶乘函数,其复杂度为$O(N!)$,涉及到的城市非常多的时候,根本没有办法解决。

对于旅行商问题,什么样的近似算法是不错的呢?能找到较短路径的算法就不错。NP完全问题的简单定义是,以难解著称的问题,很多聪明人都认为,根本不可能写出可以快速解决这些问题的方法。

但要判断出某一问题属于NP完全问题就比较难,易于解决的问题和NP完全问题的差别通常很小。虽然很难,但还是有一些蛛丝马迹可循的:

  • 元素较少时运行速度非常快,但随着元素数量的增加,速度会变得很慢;
  • 涉及“所有组合”的问题通常是NP完全问题;
  • 不能将问题分解成小问题,必须可能考虑所有的情况,这些都属于NP完全问题;
  • 问题涉及到序列(如旅行商问题中的城市序列)且难以解决;
  • 问题涉及到集合(如广播集合)且难以解决;

小结

  • 贪婪算法寻找局部最优解,企图用这种方式来获得全局最优解;
  • 对于NP完全问题,还没有找到快速解决办法;
  • 面临NP完全问题时,最佳的做法是采用近似算法;
  • 贪婪算法易于实现,运算速度快,是不错的近似算法;

9.动态规划

还是背包问题

动态规划算法的工作原理:先解决子问题,再逐步解决大问题。

技术分享图片

在解决该问题时,动态规划逐步计算最大价值,其转移公式类似下面:

技术分享图片

动态规划是从小问题入手,逐步解决大问题,这里解决的小问题会帮助解决大问题。

可以偷商品的一部分吗

假设行窃时可以偷走的是成袋的米和面,整袋装不下可以将包装打开,再将背包填满,这种情况是无法用动态规划来实现的。

但是使用贪婪算法可以轻松地解决这个问题!首先,先尽可能多地拿价值最高的产品;然后,再去拿价值次高的产品;以此类推即可。

小结

  • 需要在给定约束条件下优化某个指标时,动态规划很有用;
  • 问题可以分解为离散子问题时,可以使用动态规划解决;
  • 每种动态规划问题,都会涉及到网格,单元格中的值通常就是你要优化的值;
  • 每个单元格都是一个子问题,因此你需要考虑的是如何将问题分解为子问题;
  • 没有放之四海而皆准的的计算动态规划解决方案的公式;

算法图解

原文:https://www.cnblogs.com/zhiqiangma/p/12790914.html

(0)
(0)
   
举报
评论 一句话评论(0
关于我们 - 联系我们 - 留言反馈 - 联系我们:wmxa8@hotmail.com
© 2014 bubuko.com 版权所有
打开技术之扣,分享程序人生!