一、大O表示法
1.1.常见的五种大O运行时间
O(log n),也叫对数时间,这样的算法包括二分查找。
O(n),也叫线性时间,这样的算法包括简单查找。
O(n * log n),这样的算法包括快速排序——一种速度较快的排序算法。
O(n^2),这样的算法包括选择排序——一种速度较慢的排序算法。
O(n!),这样的算法包括接下来的旅行商问题的解决方案——一种非常慢的算法。
1.2.启示
· 算法的速度指的并非时间,而是操作数的增速。
· 谈论算法的速度时,我们说的是随着输入的增加,其运行时间将以什么样的速度增加。
· 算法的运行时间用大O表示法表示。
· O(log n)比O(n)快,当需要搜索的元素越多时,前者比后者快得越多。
二、选择排序
2.1.数组和链表
链表的优势在插入和删除元素方面,数组的优势在可以随机地读取元素。
有一种混合结构:链表数组,数组中的元素时链表。
2.2.选择排序
简单的就是对数组进行排序,假如是从小到大,每一次找到最小的元素,然后数组中拿出来并添加到新的数组中,依次循环最终得到从小到大排序的数组,时间复杂度为O(n^2)。
三、递归
Leigh Caldwell:如果使用循环,程序的性能可能更高;如果使用递归,程序可能更容易理解。如何选择要看什么对你来说更重要。
3.1.基线条件和递归条件
基线条件是指函数不再调用自己,从而避免形成无限循环。
递归条件是指函数调用自己。
def countdown(i):
prin i
if i<=0: # 基线条件
return
else: # 递归条件
countdown(i-1)
3.2.栈
编程的一个重要概念——调用栈。栈是一种简单的数据结构:先进后出。
计算机在内部使用被称为调用栈的栈。实例:
def greet(name):
print "hello"
greet2(name)
print “hello2”
bye()
def greet2(name):
print "greet hello"
def bye():
print "OK bye"
在调用greet("me")时,计算机为该函数分配一块内存,将涉及的变量存储到内存中。当使用greet2()函数时,计算机使用一个栈来表示这些内存块,当greet2函数调用返回时,栈顶的内存块被弹开了也就是greet2函数被释放了。调用另一个函数时,当前函数暂停并处于未完成状态。接着继续调用bye函数,然后在返回,弹开栈顶内存。这个栈用于存储多个函数的变量,被称为调用栈。
3.3.递归调用栈
def fact(x):
if x == 1:
return 1
else:
return x * fact(x-1)
当递归调用栈时,首先每次调用递归时都会在栈顶放一个内存块,不断累加放在栈顶,当触发基线条件时会不断return数值,这时会逐渐从栈顶释放内存块。
虽然栈很方便,但是存储详尽的信息可能占用大量的内存。如果栈很高,就意味着计算机存储了大量的函数调用信息。这种情况下有两种选择:
(1)重新编写代码,使用循环。
(2)使用尾递归。这是一个高级递归主题,暂时没学。
四、快速排序
4.1.D&C算法
快速排序/快速排序是一种排序算法,速度比选择排序快很多,实属优雅代码的典范。
使用D&C算法解决问题有两个步骤:
(1)找出基线条件,这种条件必须尽可能简单。
(2)不断将问题分解,知道附和基线条件。
4.2.快速排序
快速排序也使用了D&C算法,如果要对数组进行排序:
Step1.选取一个基准值,得到两个子数组,一个是比基准值小的元素组成的数组,一个是比基准值大的元素组成的数组。
Step2.只需要对这两个子数组进行快速排序。
// 快速排序代码
void quicksort(vector<int> vect)
{
if(vect.size()<2)
{
return vect;
}
else{
int pivot = vect[0];
vector<int> less, greater;
for(int i=0; i<vect.size(); i++)
{
if(vect[i] < pivot)
less.push_back(vect[i]);
else:
greater.push_back(vect[i]);
}
return quicksort(less) + pivot + quicksort(greater);
}
}
4.3.再谈大O表示法
快速排序的平均时间为O(n log n),在最糟的情况下,其运行时间为O(n^2)。
此外还有一种合并排序的排序算法,其运行时间总是O(n log n)。
在两个算法运行时间都为O(n log n)时,表面上看着是相等的,实际上是有快慢之分的。大O表示法中O(n)其实是这样的c*n,c是固定的时间量,也被称为常量。通常不考虑这个常量,如果两种算法的大O运行时间不同,这种常量将无关紧要,如果大O算法的运行时间相同,则需要考虑这个常量。
最佳情况也是平均情况,而最遭情况有可能是O(n^2)(如果平均是O(n log n))。
五、散列表
* 学习散列表——最有用的基本数据结构之一。散列表用途广泛。
* 学习散列表的内部机制:实现、冲突、散列函数。
5.1.散列函数
散列函数就是一对一映射,x-->f(x),或者说是键值对。
* 散列函数总是将同样的输入映射到相同的索引。
* 散列函数将不同的输入映射到不同的索引。
* 散列函数知道数组有多大,只返回有效的索引。
结合散列函数和数组组成的被称为散列表的数据结构。散列表又称为散列映射、映射、字典和关联数组。
在python中是字典{key:value},在C++中是map<type1, type2> name;
5.2.应用案例
* 将散列表用于查找。
* 防止重复。
* 将散列表用作缓存。缓存是一种常用的加速方式,所有大型网站都使用缓存,而缓存的数据则存储在散列表中!
5.3.冲突
当将两个键值对存放到同一个位置时,会引起冲突。因此:
*** 散列函数很重要。最理想的情况是,散列函数将键均匀地映射到散列表的不同位置。
*** 如果散列表存储的链表很长,散列表的速度将急剧下降,然而,如果使用的散列函数很好,这些列表就不会很长。
5.4.性能
在平均情况下,散列表的的查找速度和数组一样快,而插入和删除速度和链表一样快,因此它兼具两者的优点。
在最糟情况下,散列表所有操作的运行时间都为O(n)——线性时间,这真的很慢。
因此在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:
&&& 较低的填装因子。
&&& 良好的散列函数。
散列表的填装因子很容易计算:散列表包含的元素数 / 位置总数。散列表使用数组来存储数据,因此你需要计算数组中被占用的位置数。
良好的散列函数让数组中的值呈均匀分布。糟糕的散列函数让值扎堆,导致大量的冲突。
5.5.小结
### 可以结合散列函数和数组来创建散列表。
### 冲突很糟糕,应使用可以最大限度减少冲突的散列函数。
### 散列表的查找、插入和删除速度都非常快。
### 散列表适用于模拟映射关系。
### 一旦填装因子超过0.7,就该调整散列表的长度。
### 散列表可用于缓存数据。
### 散列表非常适用于防止重复。
六、广度优先搜索
广度优先搜索可以找出两样东西之间的最短距离。
6.1.图介绍
解决最短路径问题的算法被称为广度优先搜索。要确定最短路径有两个步骤:
(1)使用图来建立问题模型。
(2)使用广度优先搜索解决问题。
什么是图?图模拟一组连接,图是由节点和边组成的。一个节点可能与众多节点直接相连,这些节点被称为邻居。
6.2.广度优先搜索
广度优先搜索是一种用于图的查找算法,可帮助回答两类问题。
*** 第一类问题:从节点A出发,有前往节点B的路径吗?
*** 第二类问题:从节点A出发,前往节点B的哪条路径最短?
需要按照顺序依次检查,有一个可实现这种目的的数据结构——队列(queue)。
6.3.队列(queue)
队列的工作原理与现实生活中的队列完全相同。队列类似于栈,不能随机地访问队列中的元素。队列只支持两种操作:入队和出队。
如果将两个元素假如队列,先加入的元素将在后加入的元素之前出队。因此,可使用队列来表示查找名单。
队列是一种先进先出(FIFO)的数据结构,而栈是一种后进先出(LIFO)的数据结构。
6.4.实现算法
算法工作原理:
也可以使用双端队列(deque)。
简单概要:首先分梯度,你(也就是起点)的“朋友”是第一梯度,你朋友的朋友是第二梯度,依次类推。首先只把你的朋友放在queue里,循环queue如果找到终点则返回,如果在该朋友没找到就把该朋友的朋友放入queue中,一直迭代循环。注:如果某个人已经被循环过了就打上标签记录它已被循环过了,就不在对它进行循环,下次碰到它时直接跳过即可。
6.5.使用时间
使用了一个队列,其中包含检查每个节点。将一个节点添加到队列需要的时间是固定的,即为O(1),依次对每个人都这样做需要总时间为O(节点)。所以广度优先搜索的运行时间为O(节点+边数),通常写成O(V+E)。
6.6.总结
*** 需要按照加入顺序检查搜索列表中的节点,否则找到的就不是最短路径,因此搜索列表必须是队列。
*** 对于检查过的节点,务必不要再去检查,否则可能导致无限循环。
七、狄克斯特拉算法
通过广度优先搜索算法可以找到从A-->B的最短路径,但不一定是最快路径。如果给这个路段加上时间,你将发现有更快的路径。
如果需要找出最快路径,可以使用狄克斯特拉算法。
7.1.使用狄克斯特拉算法
狄克斯特拉算法包含四个步骤:
(1)找出“最便宜”的节点,即可在最短时间内到达的节点。
(2)更新该节点的邻居的开销,其含义将稍后介绍。
(3)重复这个过程,直到对图中的每个节点都这样做(该重复过程是除去已经前往的节点)。
(4)计算最终路径。
狄克斯特拉算法只适用于有向无环图。
7.2.狄克斯特拉算法自述步骤
*1* 首先列出每个节点的开销。
*2* 进行不断循环更新节点开销。
*3* 找出最便宜的节点,然后计算前往该节点的各个邻居的开销。然后再次执行找出除第刚才那个最便宜的节点外最便宜的节点,再次计算前往该节点的各个邻居的开销依次类推。最终找到前往终点的时间最短的路径。
如果有负权边则不可使用狄克斯特拉算法。
7.3.算法实现
int graph[m][n]; // 表示地图
int costs[m][n]; // 表示到某个节点的花费
int parents[m][n]; // 表示存储该节点的父节点
node = find_lowest_cost_node(costs); // 在未处理之前找到开销最小的节点
while(node != nullptr)
{
neighbors = graph[node]; // 得到该节点的所有未遍历过的邻居
for(auto i: neighbors)
{
new_cost = cost + neighbors[n];
if(costs[n] > new_cost)
costs[n] = new_cost; parents[n] = node;
}
// 如果已经遍历过了就标记为处理过
node find(lowest_cost_node(costs);
}
7.3.总结
*** 广度优先搜索用于在非加权图中查找最短路径。
*** 狄克斯特拉算法用于在加权图中查找最短路径。
*** 仅当权重为正时狄克斯特拉算法才管用。
*** 如果图中包含负权边,可使用贝尔曼-福德算法。
八、贪婪算法
对于那些没有快速算法的问题(NP完全问题),可使用贪婪算法。学习近似算法,使它们可快速找到NP完全问题的近似解;学习贪婪策略——一种非常简单的问题解决策略。
贪婪算法很简单:就是每步都采取最优的做法。
8.1.NP 完全问题
为解决集合覆盖问题,你必须计算每个可能的集合。
NP完全问题的简单定义是,以难解著称的问题,如旅行商问题和集合覆盖问题。很多聪明的人都认为,根本不可能编写出可快速解决这些问题的算法。
如何识别NP完全问题呢?要判断问题是不是NP完全问题很难,易于解决的问题和NP完全问题的差别通常很小。
没办法判断问题是不是NP完全问题,但有一些蛛丝马迹可循的。
&&& 元素较少时算法的运行速度非常快,但随着元素数量的增加,速度会变得非常慢。
&&& 涉及“所有组合”的问题通常是NP问题。
&&& 不能将问题分成小问题,必须考虑各种可能的情况。这可能是NP完全问题。
&&& 如果问题涉及序列且难以解决,它可能就是NP完全问题。
&&& 如果问题涉及集合且难以解决,可能就是NP完全问题。
&&& 如果问题可转化为集合覆盖问题或旅行商问题,那它肯定是NP完全问题。
8.2.总结
*** 贪婪算法寻找局部最优解,企图以这种方式获得全局最优解。
*** 对于NP完全问题,还没有找到快速解决方案。
*** 面临NP完全问题时,最佳的做法是使用近似算法。
*** 贪婪算法易于实现、运行速度快,是不错的近似算法。
九、动态规划
学习动态规划,这是一种解决棘手问题的方法,它将问题分成小问题,并先着手解决这些小问题。
9.1.最长公共子串和最长公共子序列
&&& 动态规划可帮助在给定约束条件下找到最优解,
&&& 在问题可分解问彼此独立且离散的子问题时,就可适用动态规划解决。
&&& 每种动态规划解决方案都涉及网格。
如果你不知道该使用什么样的公式。就使用费曼算法:
(1)将问题写下来。
(2)好好思考。
(3)将答案写下来。
9.2.总结
*** 需要在给定约束条件下优化某种指标时,动态规划很有用。
*** 问题可分解为离散子问题时,可使用动态规划来解决。
*** 每种动态规划解决方案都涉及网格。
*** 单元格中的值通常就是你要优化的值。
*** 每个单元格都是一个子问题,因此你需要考虑如何将问题分解为子问题。
*** 没有放之四海而皆准的计算动态规划解决方案的公式。
十、K最近邻算法(KNN)
K最近邻算法能够进行创建分类系统、学习特征抽取、学习回归等。
* 创建分类系统就是编组。依靠特征提取,根据系统参数导入数学模型中,计算得出模型的各个参数。
* 回归就是预测结果。余弦相似度能够在数据相似时解决使用距离公式不能解决的问题。
10.1.特征选取
* 不偏不倚的特征。
* 要与分类参数紧密相关的特征。
10.2.总结
这章后续讲解的是机器学习相关的知识,由于已经学过了就不做过多解释。
十一、接下来如何做
本章将介绍10种算法以及它们的用处。
11.1.树
二分查找仅在数组有序才有用。因此出现了一种名为二叉查找树的数据结构。
在二叉查找树种查找节点时,平均运行时间为O(log n),但在最糟情况虚啊所以需要时间为O(n);而在有序数组种查找时,在最糟情况下所需时间也只有O(log n),因此在查找方面有序数组比二叉查找树更佳。但在插入和删除操作要快得多。
11.2.反向索引
搜索引擎的工作原理:假如有三个网页
我们根据这些内容创建一个散列表。这个表的键为单词,值为包含指定单词的页面。
假设用户搜索hi,在这种情况下,搜索引擎需要检查哪些页面包含hi,搜索引擎发现页面A和B包含hi,将这些页面作为搜索结果呈现给用户。
这种就是一种很有用的数据结构:一个散列表,将单词映射到它的页面。这种被称为反向索引,常用于创建搜索引擎。
11.3.傅里叶变换
绝妙、优雅且应用广泛的算法少之又少,傅里叶就是一个。
傅里叶变换的一个绝佳的比喻:“给它一杯冰沙,它能告诉你其中包含哪些成分”。换言之,给定一首歌,傅里叶变换能将其中的各种频率分离出来。
除此之外,傅里叶变换还用来作地震预测和DNA分析,傅里叶变换的用途极其广泛。
11.4.并行算法
为提高算法的速度,需要让它们能够在多个内核中并行地执行。
并行算法设计卡里很难,因为不能保证实现期望的速度运行和提升。有一点是确定的,那就是速度的提升并非线性的,而这其中的原因有两个。
(1)并行性管理开销。加入要对一个包含1000个元素的数组进行排序,如何在两个内核之间分配这项任务呢?如果让每个内核对500个元素进行排序,再将两个排好序的数组合并成一个数组,那么合并也是需要时间的。
(2)负载均衡。假设需要完成10个任务,因此你给每个内核都分配5个任务。但分配给内核A的任务都很容易,10s就完成了,而分配给内核B的任务都很难,1分钟才完成。这意味着有50s左右,内核B在忙死忙活,而内核A却闲得很!究竟该如何均匀分配工作呢?
要改善性能和可扩展性,并行算法可能是不错的选择。
11.5.MapReduce
分布式算法是一种特殊的并行算法。在并行算法只需两到四个内核时,完全可以在笔记本电脑上运行它,但如果需要数百个内核呢?这种情况下,可让算法在多台计算机上运行。可在开源工具Apache Hadoop使用MapReduce——一种流行的分布式算法。
MapReduce基于两个简单的理念:映射(map)函数和归并(reduce)函数。
映射函数很简单,接受一个数组,并对其中的每个元素执行同样的处理。
归并函数的理念在于将很多项归并为一项。映射是将一个数组转换为另一个数组,而归并是将一个数组转换为一个元素。
MapReduce使用这两个简单概念在多台计算机上执行数据查询。数据集很大,包含数十亿行时,使用MapReduce只需几分钟就可获得查询结果,而传统数据集可能要耗费数小时。
11.6.布隆过滤器和HyperLogLog
布隆过滤器是一种概率型数据结构,它提供的答案有可能不对,但很可能是正确的。当判断网页以前是否已搜集过,可不用散列表,而使用,布隆过滤器。使用散列表时,答案绝对可靠,而使用布隆过滤器时,答案却是很可能是正确的。
(1)可能出现的报错的情况:Google可能指出“这个网站已搜集”,但实际上并没有搜集。
(2)不可能出现漏报的情况:如果布隆过滤器说“这个网站未搜集”,就肯定未搜集。
布隆过滤器的优点在于占用的存储空间很少。
HyperLogLog是一种类似于布隆过滤器的算法。HyperLogLog近似地计算集合中不同的元素数,与布隆过滤器一样,它不能给出准确的答案,但也八九不离十,而占用的内存空间却少得多。
面临海量数据且只要求答案八九不离十时,可考虑使用概率型算法!
11.7.SHA算法
SHA算法是另一种散列函数是安全散列算法函数。给定一个字符串,SHA返回其散列值。SHA是一个散列函数,它生成一个散列值——一个较短的字符串。
其他的散列表生成的是数组索引,这只能进行搜寻,当需要判断连个文件是否相同时很难做到。但可以用SHA来判断这两个文件是否相同,这在比较超大型文件时很有用。可直接计算它们的SHA散列值,再进行结果对比。这样就不需要把原有的超大型文件进行传输,只需要传输SHA散列值文件即可。
此外SHA还让你能在不知道原始字符串的情况下对其进行比较。这样就防止了文件泄漏等问题。
最后SHA实际上是一系列算法:SHA-0、SHA-1、SHA-2和SHA-3。最安全的密码散列函数是bcrypt,但没有任何一种东西是完美的!
11.8.局部敏感的散列算法
SHA有局部不敏感一个重要特征,当修改其中一个字符时,在计算其散列值,结果截然不同,这使得攻击者无法通过比较散列值是否类似来破解密码。
但有时候,你希望结果相反,即希望散列函数是局部敏感的。这种情况下可以使用Simhash。如果你对字符串做细微的修改,Simhash生成的散列值也只存在细微的差别。这让你能够通过比较散列值来判读两个字符串的相似程度,这很有用!
11.9.Diffie-Hellman 密钥交换
Diffie-Hellman算法以优雅的方式解决了一个古老的问题:如何对消息进行加密,以便只有收件人才能看懂呢?
Diffie-Hellman算法解决了两个问题:
(1)双方无需知道加密算法。它们不必会面协商要使用的加密算法。
(2)要破解加密的消息比登天还难。
Diffie-Hellman使用两个密钥:公钥和私钥。有人向你发送消息时,他使用公钥对其进行加密,加密后的消息只有使用私钥才能解密。只要只有你知道私钥,就只有你才能解密消息。
Diffie-Hellman算法及其替代者RSA依然被广泛使用。
11.10.线性规划
线性规划用于给定约束条件下最大限度地改善指定的指标。
这本书很大篇幅在讨论最优化问题,虽然表面上与线性规划无关,但所有的图算法都可使用线性规划来实现!!!
十二、总结
花费了几天看完了这本书,收获颇多,入门算法了,再接再厉我们一起加油!愿今年能找到理想的工作!!!