基础算法图解

一、算法简介

1.1 二分法
二分查找的工作原理。
我随便想一个1~100的数字。以最少的次数猜到这个数字。你每次猜测后,我会说小了、大了或对了。
假设你从1开始依次往上猜,猜测过程会是这样。
从 50 开始。
若小了,数字不在1~50范围内。接下来,你猜75。
若大了,数字不在1~50范围内。接下来,你猜25。
那余下的数字又排除了一半!使用二分查找时,你猜测的是中间的数字,从而每次都将余下的数字排除一半。
这就是二分查找。

这里写图片描述

一般而言,对于包含n个元素的列表,用二分查找最多需要log2n步,而简单查找最多需要n步。
def binary_search(list, item):
    low = 0
    high = len(list)—1
    while low <= high:
         mid = (low + high)
         guess = list[mid]
        if guess == item:
            return mid
        elif guess > item:
            high = mid - 1
        else:
            low = mid + 1
    return None
my_list = [1, 3, 5, 7, 9]
1.2 二分法运行时间
运行时间:一般而言,应选择效率最高的算法,以最大限度地减少运行时间或占用空间。

◆ 简单查找逐个地检查数字,如果列表包含100个数字,最多需要猜100次。
  即猜测的次数与列表长度相同,这被称为线性时间(linear time)。

◆ 二分查找则不同。如果列表包含100个元素,最多要猜7次;
1.3 大 O 表示法及运行时间
◆ 大O表示法是一种特殊的表示法,衡量算法的速度有多快。

◆ 大O表示法指出了算法有多快。
    例如,假设列表包含n个元素。简单查找需要检查每个元素,因此需要执行n次操作。
    这个运行时间为O(n)。大O表示法指的并非以秒为单位的速度。
    大Ο表示法让你能够比较操作数,它指出了算法运行时间的增速。

                  二分查找需要执行 l o g n 次操作。使用大 O 表示法,这个运行时间就是: O ( l o g n )
                  简单查找找需要执行 n 次操作。运行时间总是为 O ( n )

1.4 常见的大 O 运行时间

                  O ( l o g n ) ,也叫对数时间,这样的算法包括二分查找。
                  O ( n ) ,也叫线性时间,这样的算法包括简单查找。
                  O ( n l o g n ) ,合并排序。包括 快速排序——一种速度较快的排序算法。
                  O ( n 2 ) ,贪婪算法。包括 选择排序——一种速度较慢的排序算法。
                  O ( n ! ) ,阶乘函数。解决旅行商问题的方案——一种非常慢的算法
这里写图片描述

示例:绘制一个包含16格的网格,假设你每秒可执行10次操作,以下5种不同的算法的运行时间:

这里写图片描述

二、选择排序

2.1 数组和链表
◆ 链表:
    假设你与五位朋友去看一部很火的电影。你们六人想坐在一起,但看电影的人较多,没有六个在一起的座位。
    链表说“我们分开来坐”,因此,只要有足够的内存空间,就能为链表分配内存。

    优点:插入元素时,根本就不需要移动元素。
         删除元素时,只需修改前一个元素指向的地址即可,后面的元素都向前移。
         说明:删除元素总能成功。插入元素,如果内存中没有足够的空间,插入操作可能失败。
    缺点:链表只能顺序访问:要读取链表的第十个元素,得先读取前九个元素,并沿链接找到第十个元素。
         需要同时读取所有元素时,链表的效率很高

◆ 数组:
    例如,显示十大电视反派时,整个排行榜分布在不同网页,而是先显示第十大反派(Newman)。
    你必须在每个页面中单击Next,才能看到第一大反派(Gustavo Fring)。

    优点:数组中,你知道每个元素的地址,可直接访问,读取速度很快

这里写图片描述

2.2 选择排序
假设你的计算机存储了很多乐曲。对于每个乐队,你都记录了其作品被播放的次数。
你要将这个列表按播放次数对你喜欢的乐队排序。

办法:遍历这个列表,找出作品播放次数最多的乐队,并将该乐队添加到一个新列表中。
要找出播放次数最多的乐队,必须检查列表中的每个元素,这需要的时间为O(n)。

          因此对于这种时间为 O ( n ) 的操作,你需要执行 n 次。需要的总时间为 O ( n × n ) ,即 O ( n 2 )

将数组元素按从小到大的顺序排列。(不使用排序函数)

def findSmallest(arr):
    smallest = arr[0]          #最小值
    smallest_index = 0         #最小值索引
    for i in range(1, len(arr)):
        if arr[i] < smallest:
        smallest = arr[i]
        smallest_index = i
    return smallest_index

排序算法:

def selectionSort(arr):                #对数组进行排序
    newArr = []
    for i in range(len(arr)):
        smallest = findSmallest(arr)   #找出最小值
        newArr.append(arr.pop(smallest))
    return newArr
print selectionSort([5, 3, 6, 2, 10]) 

三、递归

3.1 递归
盒子里找钥匙:这个盒子里有盒子,而盒子里的盒子又有盒子。钥匙就在某个盒子中。
为找到钥匙,你将使用什么算法?

算法思路 1:
(1) 创建一个要查找的盒子堆。
(2) 从盒子堆取出一个盒子,在里面找。
(3) 如果找到的是盒子,就将其加入盒子堆中,以便以后再查找。
(4) 如果找到钥匙,则大功告成!
(5) 回到第二步。

算法思路 2:
(1) 检查盒子中的每样东西。
(2) 如果是盒子,就回到第一步。
(3) 如果是钥匙,就大功告成!

如果使用循环,程序的性能可能更高;如果使用递归,程序可能更容易理解
3.2 基线条件和递归条件
由于递归函数调用自己,因此编写这样的函数时很容易出错,进而导致无限循环。

因此:编写递归函数时,必须告诉它何时停止递归。每个递归函数都有两部分:
基线条件(base case)和递归条件(recursive case)。
     递归条件指的是函数调用自己,
     基线条件则指的是函数不再调用自己,从而避免形成无限循环。
def countdown(i):
    print i
    if i <= 0:       #基线条件
        return
    else:            #递归条件
        countdown(i-1)
countdown(-2)
3.3 栈
◆ 编程概念——调用栈(call stack)。
    插入的待办事项放在清单的最前面;读取待办事项时,你只读取最上面的那个,并将其删除。
    因此这个待办事项清单只有两种操作:压入(插入)和弹出(删除并读取)。
◆ 调用栈(调用函数)
def greet(name):
    print ("hello, " + name + "!")
    greet2(name)
    print ("getting ready to say bye...")
    bye()

#这个函数问候用户,再调用另外两个函数。这两个函数的代码如下:
def greet2(name):
    print ("how are you, " + name + "?")
def bye():
    print ("ok bye!")

greet('zhangsan')

输出:hello, zhangsan!
     how are you, zhangsan?
     getting ready to say bye...
     ok bye!
3.3.2 递归调用栈
递归函数也使用调用栈!
递归函数factorial的调用栈。factorial(5)写作5!,其定义如下:5! = 5 * 4 * 3 * 2 * 1。
同理,factorial(3)为3 * 2 * 1。下面是计算阶乘的递归函数。
def fact(x):
 if x == 1:
 return 1
 else:
 return x * fact(x-1) 
调用栈可能很长,这将占用大量的内存。

四、快速排序

4.1 分而治之
如何将一块地均匀地分成方块,并确保分出的方块是最大的呢?使用D&C策略!D&C算法是递归的。
使用D&C解决问题的过程包括两个步骤。
    (1) 找出基线条件,这种条件必须尽可能简单。
    (2) 不断将问题分解(或者说缩小规模),直到符合基线条件

这里写图片描述
D&C并非可用于解决问题的算法,而是一种解决问题的思路

你需要将这些数字相加,并返回结果
def sum(arr):
    total = 0
    for x in arr:
        total += x
    return total
print (sum([1, 2, 3, 4]))
4.2 快速排序
快速排序是一种常用的排序算法,比选择排序快得多。
步骤如下。
(1) 选择基准值(pivot)。
(2) 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。
(3) 对这两个子数组进行快速排序,再合并结果,就能得到一个有序数组!

这里写图片描述
将任何元素用作基准值都可行,都是可以通过子数组再排序得到有序数组

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)
array = [10, 5, 2, 3]       # 待排序数组
print (quicksort(array))
4.3 再谈大 O 表示法
快速排序的独特之处在于,其速度取决于选择的基准值。
最常见的大O运行时间。

这里写图片描述

合并排序(merge sort)的排序算法,其运行时间总是为O(n log n),比选择排序快得多!

快速排序的情况比较棘手,
    在平均情况下,运行时间为O(n log n)。
    在最糟情况下,运行时间为O(n2)。
4.3.1 比较合并排序和快速排序
定义函数遍历列表中的每个元素并将其打印出来。它迭代整个列表一次,因此运行时间为O(n)。
为方便观察,使其在打印每个元素前都休眠1秒钟。
from time import sleep
def print_items2(list1):
    for item in list:
        sleep(1)
    print item
list1 = [2,4,6,8,10] 
print_items2(list1)
4.3.2 平均情况和最糟情况
快速排序的性能高度依赖于你选择的基准值。

假设你总是将第一个元素用作基准值,且要处理的数组是有序的。由于快速排序算法不检查输入数组是否有序,
    因此它依然尝试对其进行排序。
假设你总是将中间的元素用作基准值,调用栈短得多!

这里写图片描述

在这个示例中,层数为O(log n)(用技术术语说,调用栈的高度为O(log n)),而每层需要的时间为O(n)。
因此整个算法需要的时间为O(n) * O(log n) = O(n log n)。这就是最佳情况。
在最糟情况下,有O(n)层,因此该算法的运行时间为O(n) * O(n) = O(n2)。

五、散列表

5.1 散列函数(映射)
散列函数:一个元素映射到另一个唯一元素”。

    ◆ 散列函数总是将同样的输入-------映射------->相同的索引。
    ◆ 散列函数将不同的输入映射到不同的索引。散列函数来确定元素的存储位置。
5.2 散列表(hash table)
结合散列函数 和 数组创建的了一种数据结构。
    散列表:也被称为散列映射、映射、字典和关联数组。获取元素的速度与数组一样快。 
    Python提供的散列表实现为字典,你可使用函数dict来创建散列表。 
    散列表与字典一样 是无序的,因此添加键—值对的顺序无关紧要。

散列表使用:
    用于查找(与数组速度一样快)
    防止重复(防止投票作弊)
    用作缓存(缓存/记住数据,以免服务器再通过处理来生成它们)
5.3 冲突
例如:key1 ---> value1
     key2 ---> value1
     key3 ---> value1
     不同的key值映射的值相同(它们的地址相同)

这里的经验教训有两个。
◆ 最理想的情况是,散列函数将键均匀地映射到散列表的不同位置。
◆ 如果散列表存储的链表很长,散列表的速度将急剧下降。如果使用的散列函数很好,
  这些链表就不会很长!
5.4 性能
不同查找方法的操作时间:

这里写图片描述

在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,
因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。
因此,在使用散列表时,避开最糟情况至关重要。
避免冲突:
    较低的填装因子;
    良好的散列函数
填装因子

=

假设你要在散列表中存储100种商品的价格,而该散列表包含100个位置。那么在最佳情况下,
每个商品都将有自己的位置。这个散列表的填装因子为1。

如果这个散列表只有50个位置呢?填充因子将为2。不可能让每种商品都有自己的位置,
因为没有足够的位置!填装因子大于1意味着商品数量超过了数组的位置数。需要在散列表中添加位置,

调整长度(resizing)。------> 一旦填装因子超过0.7,就该调整散列表的长度。

调整散列表长度的工作需要很长时间!调整长度的开销很大,因此你不会希望频繁地这样做。但平均而言,
即便考虑到调整长度所需的时间,散列表操作所需的时间也为O(1)。
良好的散列函数
良好的散列函数让数组中的值呈均匀分布。

六、广度优先搜索

假设你居住在旧金山,要从双子峰前往金门大桥。你想乘公交车前往,并希望换乘最少。可乘坐的公交车如下。

这里写图片描述

解决最短路径问题的算法被称为广度优先搜索。

查找最短路径
    例如,朋友是一度关系,朋友的朋友是二度关系。

这里写图片描述

队列
队列是一种先进先出(First In First Out,FIFO)的数据结构,
栈是一种后进先出(Last InFirst Out,LIFO)的数据结构。

这里写图片描述

实现图
散列表让你能够将键映射到值。
在这里,你要将节点映射到其所有邻居:

这里写图片描述

实现算法
找到一位芒果销售商
伪代码:
from collections import deque

def person_is_seller(name):
    return name[-1] == 'm'

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 mango seller!")
                return True
            else:
                search_queue += graph[person]
                searched.append(person)
    return False

graph = ["alice", "bob","claire"]        #邻居列表
search("you")

七、狄克斯特拉算法

从A点到B点是最快路径。

这里写图片描述

7.1 使用狄克斯特拉算法
狄克斯特拉算法
你知道:
    前往节点B需要2分钟;
    前往节点A需要5分钟;
    前往终点需要6分钟。
狄克斯特拉算法中,你给每段都分配了一个数字或权重,因此狄克斯特拉算法找出的是总权重最小的路径。

这里写图片描述

狄克斯特拉算法包含4个步骤。
    (1) 找出最便宜的节点,即可在最短时间内前往的节点。
    (2) 对于该节点的邻居,检查是否有前往它们的更短路径,如果有,就更新其开销。
    (3) 重复这个过程,直到对图中的每个节点都这样做了。
    (4) 计算最终路径
狄克斯特拉算法只适用于有向无环图(directed acyclicgraph,DAG)。
将狄克斯特拉算法不能用于包含负权边的图。

在包含负权边的图中,要找出最短路径,可用——贝尔曼-福德算法(Bellman-Fordalgorithm)
#找出最低消耗的节点

#开销表
infinity = float("inf")
costs = {}
costs["a"] = 6
costs["b"] = 2
costs["fin"] = infinity

#存储父节点的散列表
parents = {}
parents["a"] = "start"
parents["b"] = "start"
parents["fin"] = None

processed = []   #处理过的节点

def find_lowest_cost_node(costs):   #找出开销最低的节点函数
    lowest_cost = float("inf")
    lowest_cost_node = None
    for node in costs:
        cost = costs[node]
        if cost < lowest_cost and node not in processed:
            lowest_cost = cost       #开销最低的节点
            lowest_cost_node = node
    return lowest_cost_node

while node is not None:
    cost = costs[node]
    neighbors = graph[node]      #graph?
    for n in neighbors.keys():   #遍历当前节点的所有邻居
        new_cost = cost + neighbors[n]
        if costs[n] > new_cost:
            costs[n] = new_cost  #更新该邻居的开销
            parents[n] = node    #同时将该邻居的父节点设置为当前节点
    processed.append(node)
    node = find_lowest_cost_node(costs)
广度优先搜索用于在非加权图中查找最短路径。
狄克斯特拉算法用于在加权图中查找最短路径。
仅当权重为正时狄克斯特拉算法才管用。
如果图中包含负权边,请使用贝尔曼福德算法。

八、贪婪算法

8.1 教室调度问题
假设有如下课程表,你希望将尽可能多的课程安排在某间教室上。

这里写图片描述

8.2 背包问题
假设你是个贪婪的小偷,背着可装35磅(1磅≈0.45千克)重东西的背包.
在商场伺机盗窃各种可装入价值最高的商品

这里写图片描述

8.3 集合覆盖问题
假设你办了个广播节目,要让全美50个州的听众都收听得到。为此,你需要决定在哪些广播台播出。
在每个广播台播出都需要支付费用,因此你力图在尽可能少的广播台播出
(1) 列出每个可能的广播台集合,这被称为幂集(power set)。可能的子集有2^n个。
(2) 在这些集合中,选出覆盖全美50个州的最小集合。
    由于可能的集合有2^n个,因此运行时间为O(2^n)。

这里写图片描述

近似算法(贪婪算法)
贪婪算法可化解危机!使用下面的贪婪算法可得到非常接近的解。
(1) 选出这样一个广播台,它覆盖了最多的未覆盖州。
(2) 重复第一步,直到覆盖了所有的州。

贪婪算法是不错的选择,它们不仅简单,而且通常运行速度很快。
在这个例子中,贪婪算法的运行时间为O(n^2),其中n为广播台数量。

1. 准备工作
#首先,创建一个列表,其中包含要覆盖的州。
states_needed = set(["mt", "wa", "or", "id", "nv", "ut","ca", "az"])   #转成集合去重

#广播台清单,使用散列表来表示它
stations = {}
stations["kone"] = set(["id", "nv", "ut"])
stations["ktwo"] = set(["wa", "id", "mt"])
stations["kthree"] = set(["or", "nv", "ca"])
stations["kfour"] = set(["nv", "ut"])
stations["kfive"] = set(["ca", "az"])

final_stations = set()    #最终选择的电台

#你不断地循环,直到states_needed为空。这个循环的完整代码如下。
while states_needed:
    best_station = None
    states_covered = set()

    # 遍历所有广播台,从中选择覆盖了最多的未覆盖州的广播台。将其存储在best_station中。
    for station, states in stations.items():
        covered = states_needed & states  #取交集

        # 检查该广播台覆盖的州是否比best_station多。
        if len(covered) > len(states_covered):
            best_station = station
            states_covered = covered

        # 更新states_needed。由于该广播台覆盖了一些州,因此不用再覆盖这些州。
        states_needed -= states_covered
    final_stations.add(best_station)
print (final_stations)
输出:
{'kone', 'kfive', 'ktwo', 'kthree'}

这里写图片描述

8.4 NP 完全问题
旅行商问题详解

这里写图片描述
这里写图片描述

这被称为阶乘函数(factorial function),5! = 120。
假设有10个城市,可能的路线有条 10! = 3 628 800 呢!

旅行商问题和集合覆盖问题有一些共同之处:你需要计算所有的解,并从中选出最小/最短的那个。
这两个问题都属于NP完全问题。

NP完全问题判别方法:
    元素较少时算法的运行速度非常快,但随着元素数量的增加,速度会变得非常慢。
    涉及“所有组合”的问题通常是NP完全问题。
    不能将问题分成小问题,必须考虑各种可能的情况。这可能是NP完全问题。
    如果问题涉及序列(如旅行商问题中的城市序列)且难以解决,它可能就是NP完全问题。
    如果问题涉及集合(如广播台集合)且难以解决,它可能就是NP完全问题。
    如果问题可转换为集合覆盖问题或旅行商问题,那它肯定是NP完全问题。 
8.5 小结
贪婪算法寻找局部最优解,企图以这种方式获得全局最优解。
对于NP完全问题,还没有找到快速解决方案。
面临NP完全问题时,最佳的做法是使用近似算法。
贪婪算法易于实现、运行速度快,是不错的近似算法。

九、动态规划

9.1 背包问题

这里写图片描述

每增加一件商品,需要计算的集合数都将翻倍!这种算法的运行时间为 O ( 2 n ) ,真的是慢如蜗牛。

动态规划
动态规划先解决子问题,再逐步解决大问题

这里写图片描述

十、其它算法

10.1 反向索引
简而言之:value----->key(通过值寻找键)
10.2 并行算法
并行性管理开销。
    假设你要对一个包含1000个元素的数组进行排序,如果让每个内核对其中500个元素进行排序,
    再将两个排好序的数组合并成一个有序数组,那么合并也是需要时间的 
负载均衡。
    假设你需要完成10个任务,因此你给每个内核都分配5个任务。但分配给内核A的任务都很容易,
    10秒钟就完成了,而分配给内核B的任务都很难,1分钟才完成。这意味着有那么50秒,
    内核B在忙死忙活,而内核A却闲得很!你如何均匀地分配工作,让两个内核都一样忙呢? 
10.3 MapReduce
分布式算法。
例如:在并行算法只需两到四个内核时,完全可以在笔记本电脑上运行它,但如果需要数百个内核呢?
在这种情况下,可让算法在多台计算机上运行。MapReduce是一种流行的分布式算法,你可通过流行的开源工具Apache Hadoop来使用它。

猜你喜欢

转载自blog.csdn.net/wsp_1138886114/article/details/80880528