赞
踩
哈希表是一种非常重要的数据结构。几乎所有的编程语言都有直接或者间接的应用这种数据结构.
哈希表通常是基于数组进行实现的, 但是相对于数组, 它也很多的优势:可以提供非常快速的插入-删除-查找操作
无论多少数据, 插入和删除值需要接近常量的时间: 即O(1)的时间级,实际上, 只需要几个机器指令即可
哈希表的速度比树还要快, 基本可以瞬间查找到想要的元素
哈希表相对于树来说编码要容易很多.
哈希表相对于数组的一些不足:
哈希表中的数据是没有顺序的, 所以不能以一种固定的方式(比如从小到大)来遍历其中的元素.
通常情况下, 哈希表中的key是不允许重复的, 不能放置相同的key, 用于保存不同的元素.
它的结构就是数组, 但是它神奇的地方在于对下标值的一种变换, 这种变换称之为哈希函数, 通过哈希函数可以获取到HashCode.
案例一:
假如一家公司有1000个员工, 现在需要将这些员工的信息使用某种数据结构来保存起来
方案一: 数组,每个员工的信息都保存在数组的某个位置上,查看某个具体员工的信息,通过下标值去获取信息,需要给员工信息中添加一个员工编号(下标值)。
方案二: 链表:插入和删除数据有一定的优势.但是对于获取员工的信息, 每次都必须从头遍历到尾。
最终方案:
数组还是有缺点:如果想查看一下张三这位员工的信息, 但是不知道张三的员工编号。使用哈希函数,让张三的名字和它的员工编号产生直接的关系,通过张三这个名字, 就能获取到它的索引值, 而再通过索引值我就能获取到张三的信息。.让某个key的信息和索引值对应起来。
案例二: 设计一个数据结构, 保存联系人和电话.
可以将联系人和数组的下标值对应,让联系人的名字作为下标值, 来获取这个联系人对应的电话.但是联系人的名字(字符串)可以作为下标值是不可以的,需要一种方案将字符串转成下标值。
案例三: 使用一种数据结构存储单词信息, 比如有50000个单词. 找到单词后每个单词有自己的翻译&读音&应用等等
如果单词转成数组的下标, 那么以后要查找某个单词的信息, 直接按照下标值一步即可访问到想要的元素。
字母转数字
计算机中有很多的编码方案就是用数字代替单词的字符.
比如ASCII编码: a是97, b是98, 依次类推122代表z
可以设计一个自己的编码系统, 比如a是1, b是2, c是3, 依次类推, z是26。
方案一: 数字相加:单词cats转成数字: 3+1+20+19=43容易重复。产生的数组下标太少.。
方案二: 幂的连乘产生的数组下标又太多.
认识哈希化
需要一种压缩方法, 把幂的连乘方案系统中得到的巨大整数范围压缩到可接受的数组范围中.
如果只有50000个单词, 可能会定义一个长度为50000的数组.但是实际情况中, 往往需要更大的空间来存储这些单词. 因为不能保证单词会映射到每一个位置. (比如两倍的大小: 100000).
找一种方法, 把0到超过7000000000000的范围, 压缩为从0到100000.有一种简单的方法就是使用取余操作符, 它的作用是得到一个数被另外一个数整除后的余数…
哈希化: 将大数字转化成数组范围内下标的过程,
哈希函数: 将单词转成大数字, 大数字在进行哈希化的代码实现放在一个函数中, 这个函数成为哈希函数.
哈希表: 最终将数据插入到的这个数组, 我们就称之为是一个哈希表
地址的冲突:比如melioration这个单词, 通过哈希函数得到它数组的下标值后, 发现那个位置上已经存在一个单词demystify, 因为它经过哈希化后和melioration得到的下标是相同的.
解决冲突有两种方案.
链地址法..(也称为拉链法)
每个数组单元中存储的不再是单个数据, 而是一个链条.
这个链条使用的数据结构:常见的是数组或者链表,效率上也差不多
每个数组单元中存储着一个链表. 一旦发现重复, 将重复的元素插入到链表的首端或者末端即可.
当查询时, 先根据哈希化后的下标值找到对应的位置, 再取出链表, 依次查询找寻找的数据.
当然在某些实现中, 会将新插入的数据放在数组或者链表的最前面, 因为觉得心插入的数据用于取出的可能性更大.这种情况最好采用链表, 因为数组在首位插入数据是需要所有其他项后移的, 链表就没有这样的问题.
开放地址法.
寻找空白的单元格来添加重复的数据
探索这个空白位置的方式不同, 有三种方法:
线性探测(从index位置+1开始一点点查找合适的位置来放置,查询过程有一个约定, 就是查询到空位置, 就停止,通常删除一个位置的数据项时, 不可以将这个位置下标的内容设置为null,将它进行特殊处理,比如设置为-1)。
二次探测(对步长做了优化, 比如从下标值x开始, x+1², x+2², x+3²,一次性探测比较远的距离)。
再哈希法(最常用的解决方案,把关键字用另外一个哈希函数, 再做一次哈希化, 用这次哈希化的结果作为步长.对于指定的关键字, 步长在整个探测中是不变的, 不过不同的关键字使用不同的步长.)。
第二次哈希化需要具备如下特点::和第一个哈希函数不同;不能输出为0。
哈希函数:
stepSize = constant - (key - constant)
其中constant是质数, 且小于数组的容量.
例如: stepSize = 5 - (key % 5), 满足需求, 并且结果不可能为0.
哈希表中执行插入和搜索操作可以达到O(1)的时间级,如果没有发生冲突,只需要使用一次哈希函数和数组的引用,就可以插入一个新数据项或找到一个已经存在的数据项。
链地址法相对来说效率是好于开放地址法的.所以在真实开发中, 使用链地址法的情况较多
哈希函数应具备的特点:
快速的计算:让哈希函数中尽量少的有乘法和除法提高速度,采用霍纳法则(秦九韶算法)优化多项式。
Pn(x)= anx n+a(n-1)x(n-1)+…+a1x+a0=((…(((anx +an-1)x+an-2)x+ an-3)…)x+a1)x+a0
复杂度直接从O(N²)降到了O(N)。
均匀的分布:让数据在哈希表中均匀分布提供效率,需要在使用常量的地方, 尽量使用质数。哈希表的长度和N次幂的底数使用质数,采用质数的原因是为了产生的数据不按照某种规律递增。
质数的特点:
质数也称为素数.
质数表示大于1的自然数中, 只能被1和自己整除的数.
图是一种与树有些相似的数据结构,图的样子举例:人与人之间的关系网;互联网中的网络关系;北京地铁图。
图的特点:
一组顶点:通常用 V (Vertex) 表示顶点的集合
一组边:通常用 E (Edge) 表示边的集合
边是顶点和顶点之间的连线
边可以是有向的, 也可以是无向的.(比如A — B, 通常表示无向. A --> B, 通常表示有向)
顶点、边、相邻顶点(由一条边连接在一起的顶点称为相邻顶点)、度(一个顶点的度是相邻顶点的数量)、路径(简单路径、回路)、无向图、有向图、无权图(边没有携带权重)和带权图。
一张有向和带权的图
图的应用:对现实建模。
对交通流量建模
顶点可以表示街道的十字路口, 边可以表示街道.
加权的边可以表示限速或者车道的数量或者街道的距离.
建模人员可以用这个系统来判定最佳路线以及最可能堵车的街道.
对飞机航线建模
航空公司可以用图来为其飞行系统建模.
将每个机场看成顶点, 将经过两个顶点的每条航线看作一条边.
加权的边可以表示从一个机场到另一个机场的航班成本, 或两个机场间的距离.
建模人员可以利用这个系统有效的判断从一个城市到另一个城市的最小航行成本.
图的表示
1.顶点表示
顶点的表示相对简单
上面的顶点抽象成了1 2 3 4, 也可以抽象成A B C D.
那么这些A B C D可以使用一个数组来存储起来(存储所有的顶点)
当然, A, B, C, D有可能还表示其他含义的数据(比如村庄的名字), 这个时候, 可以另外创建一个数组, 用于存储对应的其他数据.
因为边是两个顶点之间的关系, 所以表示起来会稍微麻烦一些.
邻接矩阵
一种比较常见的表示图的方式: 邻接矩阵.
邻接矩阵让每个节点和一个整数向关联, 该整数作为数组的下标值.
用一个二维数组来表示顶点之间的连接.
在二维数组中, 0表示没有连线, 1表示有连线.
另外, A - A, B - B(也就是顶点到自己的连线), 通常使用0表示.
如果是一个无向图, 邻接矩阵展示出来的二维数组, 其实是一个对称图.也就是A -> D是1的时候, 对称的位置 D -> 1一定也是1.
这种情况下会造成空间的浪费。
邻接矩阵还有一个比较严重的问题就是如果图是一个稀疏图
那么矩阵中将存在大量的0, 这意味着浪费了计算机存储空间来表示根本不存在的边,而且即使只有一个边, 我们也必须遍历一行来找出这个边, 也浪费很多时间。
2.邻接表
另外一种常用的表示图的方式,邻接表由图中每个顶点以及和顶点相邻的顶点列表组成.这个列表有很多种方式来存储:数组/链表/字典(哈希表)都可以.。
表示和A顶点有关联的顶点(边), A和B/C/D有边, 可以通过A找到对应的数组/链表/字典, 再取出其中的内容。
邻接表计算"出度"是比较简单的(出度: 指向别人的数量, 入度: 指向自己的数量)
邻接表如果需要计算有向图的"入度", 是一件非常麻烦的事情.
它必须构造一个"“逆邻接表", 才能有效的计算"入度". 而临街矩阵会非常简单。
通过某种算法来遍历图结构中每一个数据,通过某种算法来遍历结构中每一个数据。
图的遍历思想
在于必须访问每个第一次访问的节点, 并且追踪有哪些顶点还没有被访问到.
有两种算法可以对图进行遍历
广度优先搜索(Breadth-First Search, 简称BFS)
深度优先搜索(Depth-First Search, 简称DFS)
两种遍历算法, 都需要明确指定第一个被访问的顶点.
遍历的注意点:
完全探索一个顶点要求我们便查看该顶点的每一条边.
对于每一条所连接的没有被访问过的顶点, 将其标注为被发现的, 并将其加进待访问顶点列表中.
为了保证算法的效率: 每个顶点至多访问两次
两种算法的思想:
BFS: 基于队列, 入队列的顶点先被探索.
DFS: 基于栈, 通过将顶点存入栈中, 顶点是沿着路径被探索的, 存在新的相邻顶点就去访问.
为了记录顶点是否被访问过, 可以使用三种颜色来反应它们的状态:(或者两种颜色也可以)
白色: 表示该顶点还没有被访问.
灰色: 表示该顶点被访问过, 但并未被探索过.
黑色: 表示该顶点被访问过且被完全探索过.
广度优先搜索思路:
从指定的第一个顶点开始遍历图, 先访问其所有的相邻点, 就像一次访问图的一层,换句话说, 就是先宽后深的访问顶点。
广度优先搜索的实现:
创建一个队列Q.
将v标注为被发现的(灰色), 并将v将入队列Q
如果Q非空, 执行下面的步骤:
将v从Q中取出队列.
将v标注为被发现的灰色.
将v所有的未被访问过的邻接点(白色), 加入到队列中.
将v标志为黑色.
深度优先搜索思路:
将会从第一个指定的顶点开始遍历图, 沿着路径知道这条路径最后被访问了,接着原路回退并探索下一条路径。
深度优先搜索算法的实现:
使用栈完成, 也可以使用递归,方便代码书写, 使用递归(递归本质上就是函数栈的调用)。
排序算法有很多: 冒泡排序/选择排序/插入排序/归并排序/计数排序(counting sort)/基数排序(radix sort)/希尔排序/堆排序/桶排序.
简单排序: 冒泡排序 - 选择排序 - 插入排序
高级排序: 希尔排序 - 快速排序
简单算法的主要操作:比较两个数据项;交换两个数据项, 或者复制其中一项。但是, 每种算法具体实现的细节有所不同。
相对其他排序运行效率较低, 但是在概念上它是排序算法中最简单的。
冒泡排序的思路
1.对未排序的各元素从头到尾依次比较相邻的两个元素大小关系
2.如果左边的队员高, 则两队员交换位置,向右移动一个位置, 比较下面两个队员
3.当走到最右端时, 最高的队员一定被放在了最右边
4.按照这个思路, 从最左端重新开始, 这次走到倒数第二个位置的队员即可.依次类推, 就可以将数据排序完成。
思路再分析:
第一次找出最高人放在最后, 需要两个两个数据项进行比较, 那么这个应该是一个循环操作.
第二次将次高的人找到放在倒数第二个位置, 也是两个比较, 只是不要和最后一个比较(少了一次), 但是前面的两个两个比较也是一个循环操作.
第三次…第四次…
这应该是一个循环中嵌套循环, 并且被嵌套的循环次数越来越少的.
冒泡排序的比较次数(N个数据项): (N - 1) + (N - 2) + (N - 3) + … + 1 = N * (N - 1) / 2。
冒泡排序的大O表示法为O(N²)。
冒泡排序的交换次数:如果有两次比较才需要交换一次(不可能每次比较都交换一次.), 那么交换次数为N² / 4。
交换次数的大O表示也是O(N²)。
改进了冒泡排序, 将交换的次数由O(N²)减少到O(N), 但是比较的次数依然是O(N²)。
选择排序的思路:
1.选定第一个索引位置,然后和后面元素依次比较
2.如果后面的队员, 小于第一个索引位置的队员, 则交换位置,经过一轮的比较后, 可以确定第一个位置是最小的
3.然后使用同样的方法把剩下的元素逐个比较即可
4.可以看出选择排序,第一轮会选出最小值,第二轮会选出第二小的值,直到最后
思路再分析:
选择排序第一次将第0位置的人取出, 和后面的人(1, 2, 3…)依次比较, 如果后面的人更小, 那么就交换,这样经过一轮之后, 第一个肯定是最小的人.
第二次将第1位置的人取出, 和后面的人(2, 3, 4…)依次比较, 如果后面的人更小, 那么就交换,这样经过第二轮后, 第二个肯定是次小的人.
第三轮…第四轮…直到最后就可以排好序了.
外层循环依次取出0-1-2…N-2位置的人作为index(其中N-1不需要取了, 因为只剩它一个了肯定是排好序的
内层循环从index+1开始比较, 直到最后一个.
选择排序和冒泡排序的比较次数都是N*(N-1)/2, 也就是O(N²).
选择排序的交换次数只有N-1次, 用大O表示法就是O(N),所以选择排序通常认为在执行效率上是高于冒泡排序的。
插入排序是简单排序中效率最好的一种.
插入排序也是学习其他高级排序的基础, 比如希尔排序/快速排序, 所以也非常重要.
插入排序的思路:局部有序:
核心是局部有序:比如在一个队列中的人, 我们选择其中一个作为标记的队员. 这个被标记的队员左边的所有队员已经是局部有序的,这意味着, 有一部门人是按顺序排列好的. 有一部分还没有顺序.
插入排序的思路:
1.从第一个元素开始,该元素可以认为已经被排序
2.取出下一个元素,在已经排序的元素序列中从后向前扫描,如果该元素(已排序)大于新元素,将该元素移到下一位置
3.重复上一个步骤,直到找到已排序的元素小于或者等于新元素的位置
4.将新元素插入到该位置后, 重复上面的步骤.
思路再分析:
插入排序应该从下标值1开始(因为0位置默认可以被认为是有序的)
从1位置开始取出元素, 并且判断该元素的大小和0位置进行比较, 如果1位置元素小于0位置元素, 那么交换, 否则不交换.
上面步骤执行完成后, 0 - 1位置已经排序好,取出2位置的元素, 和1位置进行比较:
如果2位置元素大于1位置元素, 说明2位置不需要任何动作. 0 - 1 - 2已经排序好.
如果2位置元素小于1位置元素, 那么将1移动到2的位置, 并且2继续和0进行比较.
如果2位置元素大于0位置的元素, 那么将2位置放置在1的位置, 排序完成. 0 - 1 - 2搞定.
如果2位置元素小于1位置的元素, 那么将0位置的元素移动到1位置, 并且将2位置的元素放在0位置, 0 - 1 - 2搞定.
按照上面的步骤, 依次找到最后一个元素, 整个数组排序完成.
插入排序的比较次数:
第一趟时, 需要的最多次数是1, 第二趟最多次数是2, 依次类推, 最后一趟是N-1次,因此是1 + 2 + 3 + … + N - 1 = N ** (N - 1) / 2.*
*然而每趟发现插入点之前, 平均只有全体数据项的一半需要进行比较.
可以除以2得到 N * (N - 1) / 4. 所以相对于选择排序, 其他比较次数是少了一半的.
插入排序的复制次数:
第一趟时, 需要的最多复制次数是1, 第二趟最多次数是2, 依次类推, 最后一趟是N-1次.
因此是1 + 2 + 3 + … + N - 1 = N * (N - 1) / 2.
对于基本有序的情况
对于已经有序或基本有序的数据来说, 插入排序要好很多.
当数据有序的时候, while循环的条件总是为假, 所以它变成了外层循环中的一个简单语句, 执行N-1次.
在这种情况下, 算法运行至需要N(N)的时间, 效率相对来说会更高.
另外别忘了, 比较次数是选择排序的一半, 所以这个算法的效率是高于选择排序的。
希尔排序是插入排序的一种高效的改进版, 并且效率比插入排序要更快.
插入排序的问题:
假设一个很小的数据项在很靠近右端的位置上, 这里本来应该是较大的数据项的位置.
把这个小数据项移动到左边的正确位置, 所有的中间数据项都必须向右移动一位.
如果每个步骤对数据项都进行N次复制, 平均下来是移动N/2, N个元素就是 N*N/2 = N²/2.
所以通常认为插入排序的效率是O(N²)
如果有某种方式, 不需要一个个移动所有中间的数据项, 就能把较小的数据项移动到左边, 那么这个算法的执行效率就会有很大的改进.
希尔排序的做法:
比如下面的数字, 81, 94, 11, 96, 12, 35, 17, 95, 28, 58, 41, 75, 15.
先让间隔为5, 进行排序. (35, 81), (94, 17), (11, 95), (96, 28), (12, 58), (35, 41), (17, 75), (95, 15)
排序后的新序列, 一定可以让数字离自己的正确位置更近一步.
再让间隔位3, 进行排序. (35, 28, 75, 58, 95), (17, 12, 15, 81), (11, 41, 96, 94)
排序后的新序列, 一定可以让数字离自己的正确位置又近了一步.
最后, 让间隔为1, 也就是正确的插入排序. 这个时候数字都离自己的位置更近, 那么需要复制的次数一定会减少很多.在希尔排序的原稿中, 建议的初始间距是N / 2, 简单的把每趟排序分成两半.
也就是说, 对于N = 100的数组, 增量间隔序列为: 50, 25, 12, 6, 3, 1.
这个方法的好处是不需要在开始排序前为找合适的增量而进行任何的计算.
使用希尔排序大多数情况下效率都高于简单排序。
快速排序几乎可以说是目前所有排序算法中, 最快的一种排序算法.
当然, 没有任何一种算法是在任意情况下都是最优的, 比如希尔排序确实在某些情况下可能好于快速排序. 但是大多数情况下, 快速排序还是比较好的选择。
快速排序可以说是排序算法中最常见的
希尔排序相当于插入排序的升级版, 快速排序其实是最慢的冒泡排序的升级版.
冒泡排序需要经过很多次交换, 才能在一次循环中, 将最大值放在正确的位置.
而快速排序可以在一次循环中(其实是递归调用)找出某个元素的正确位置, 并且该元素之后不需要任何移动.
快速排序的思想:
最重要的思想是分而治之.
比如下面有这样一顿数字需要排序:
第一步: 从其中选出了65. (其实可以是选出任意的数字, 以65举个栗子)
第二步: 我们通过算法: 将所有小于65的数字放在65的左边, 将所有大于65的数字放在65的右边.
第三步: 递归的处理左边的数据.(比如你选择31来处理左侧), 递归的处理右边的数据.(比如选择75来处理右侧, 当然选择81可能更合适)
最终: 排序完成
和冒泡排序不同之处:
选择的65可以一次性将它放在最正确的位置, 之后不需要任何移动.
冒泡排序需要从开始位置两个两个比较, 如果第一个就是最大值, 它需要一直向后移动, 直到走到最后.
也就是即使已经找到了最大值, 也需要不断继续移动最大值. 而插入排序对数字的定位是一次性的.
快速排序的枢纽
有一个很重要的步骤就是选取枢纽(pivot也人称为主元).
一种方案是直接选择第一个元素作为枢纽.但第一个作为枢纽在某些情况下, 效率并不是特别高.
另一种方案是使用随机数,但是随即函数本身就是一个耗性能的操作.。
另一种比较优秀的解决方案: 取头、中、尾的中位数。
快速排序的最坏情况效率
什么情况下会有最坏的效率呢? 就是每次选择的枢纽都是最左边或者最后边的,那么效率等同于冒泡排序.
选择三个值的中位值是不可能最坏的效率
快速排序的平均效率:
快速排序的平均效率是O(N * logN).
虽然其他某些算法的效率也可以达到O(N * logN), 但是快速排序是最好的.
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。