Java学习之路 数据结构(二)基础容器要点复习

本文深入探讨了各种数据结构的关键概念,包括链表、栈、队列、二叉树、AVL树、散列表、B+树及优先队列的原理与应用。详细解析了每种结构的操作时间复杂度,阐述了它们在实际场景中的高效利用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

使用iterator调用集合的方法进行结构上的改变时(add、remove、clear),这个迭代器将立刻变得不合法,必须重新获取。但如果是调用迭代器自身的iterator.remove()却不会导致迭代器失效。

所以实现Iterable接口也许是个不错的选择,既可以使用增强for循环,又可以保持迭代器合法性。当然也可以实现ListIterator。

对于iterable来说,只有hasNext()、next()、remove()三个接口,当然如果只是为了在增强for循环里遍历-----或许还有点删除操作,这完全够用了,但如果想要在迭代器循环中就完成add、set操作,就得实现ListIterator接口了。

接下来的问题:如何实现迭代器的接口?

也许有人会认为,直接在类内部给出接口的具体实现不就行了?

但是List和Iterator是两个概念,所以要在List类内部再添加一个内部类来实现Iterator。

这样做的好处是:外部类的成员对于一个内部类(这里指Iterator)是完全可见的。这就避免破坏外部类成员对于其他顶级类的不可见性,也就是说,外部类成员即使声明为private也没有关系。否则,就需要将成员声明为public以供iterator使用,甚至,某些实现方法还要求传入外部类本身,这就不够简洁和安全。

链表的实现:

以前以为链表的头和尾都要存储数据,当链表有一个元素时同时指向这个元素。

现在才发现大错特错,优雅的实现方法是头尾不存储数据,仅仅作为标志手段,初始状态就是Head->Tail,一旦有新元素A插入,就变成Head->A->Tail。也就是说,插入和删除都发生在Head和Tail节点之间

栈:

栈我们都不陌生,是编译原理中最常见的数据结构,从某种意义上,栈是最高效的数据结构。

栈的实现也非常容易:Stack[++topIndex]=x 这就是入栈,x=Stack[topIndex--],这就是出栈。看起来好像栈的结构、操作简单,想必能够实现的功能也不多,其实不然。

平衡符号:

     最常见的就是括号匹配了,入栈左符号,出栈右符号,概括起来就这么一句话。

后缀表达式(逆波兰):

     其实一般用不到这种表达式,不过如果是在联机环境下,还是有必要实现一个联机算法的。所谓联机算法就是在网络传输中,只根据当前接收到的字节流就能得出一个中间结果。显然一个要求全局信息的算法是不能胜任联机工作的----或者性能不好。逆波兰就是个不错的联机选择:无论你发多少数据,我都能以比全局算法快得多的速度得出一个中间结果存在栈中,这可能有助于我们判断一些事情,比如这个网络连接没必要再持续传输下去了,等等提高服务器响应能力的特性。

如何计算逆波兰我们都清楚:将值压入栈,遇到符号时从栈顶顺序提取若干个值进行计算后将结果压回栈

那么如何设计一个逆波兰表达式呢?

假设有栈和输出流:

遇到值/变量->放到输出流上

遇到符号θ->将栈中所有优先级大于或等于θ的符号弹出并放到输出流上,再将θ入栈 比如 a*b+c  当遇到+,弹出栈中的*放到输出流上,再将+入栈。(尽管左括号'('的优先级最高,然而,除非θ=')',否则其他符号是无权弹出'('的,换句话说,'('会阻塞所有在他之前的符号)。

总而言之,现在我们可以将一些客户端和服务器端实现约定好的函数(或者说操作因子)设定一个优先级序列,然后将符合我们自然认知的计算/操作序列转换为逆波兰序列再进行网络传输,就能够实现一个简单地联机算法了。

再就是我们经常使用的递归,也是用栈来实现的

每一个栈元素都存储了当前栈程序段入口地址和一些局部变量,具体参见编译原理。编译器帮我们做的事就是把递归改写成while循环。注意递归栈元素会存储上下文信息,所以不要在一个函数或程序的结尾使用递归(尾递归):因为我们不需要再回来这个程序段了(已经是尾部了,没有后续代码),所以其入口地址和局部变量我们也就不关心了。如果还是要写成递归形式,可能会导致栈溢出。除非数据结构是一棵树,那么就不用担心了,因为树的算法复杂度平均只有O(\log{N})

队列:

主要讲讲循环算法实现

初始状态:back=front-1

nullnull
BACKFRONT

入队元素4,++back

null4
nullBACK/FRONT

出队元素4,front++

nullnullnull
nullBACKFRONT

此时队列又为空,总结来说,入队操作在back推进后的位置放入商品,而出队操作将当前位置的商品消费后再推进。这样就理解了判断队列为空的方法:

如果back在front前一位,那么front位置就没有元素可以消费,自然队列为空。等等,front处一定没有元素吗?

考虑这样一种情况:当我们不断入队,直到back绕了一圈来到front前,这下不能继续入队了,因为front位置已经有元素,back不能再推进了。这时同样back也在front前一位,糟糕,该怎么区分空和满呢?

三种方法:

  1. 标志位记录最后一个操作是入队还是出队,如果是出队则为空,反之为满
  2. 用一个currentSize实时记录元素的数量,这很直观
  3. 看起来最优雅的方法:预留一个位置不放元素,此时back在front前两位代表队满,back在front前一位代表队空(也是初始状态)

树:

N个节点---N-1条边----根节点没有父节点

深度(depth)从根到某节点的距离

内部路径长(internal path length) 所有节点的深度的和

高(height),节点往下到某个树叶的最长距离

典型的树结构包括:元素值、下一个兄弟节点、第一个子节点,通过后两者链接成了一棵树

二叉树平均深度是O(\sqrt{N}),二叉查找树平均深度是O(\log{N})

Java下的函数参数是传递值的-----无论他是基本数据类型还是对象,也就是说,都是拷贝复制。但是,所谓对象的复制也并非是整个对象的复制,而只是地址的复制,也许说简单点,就是C++里的指针,传入一个指针,当然是拷贝这个指针,改变这个指针的值,并没有什么卵用,外部的对象不会改变,然而,要是改变了指针的成员对象-----这种改变会如实反映到外部的原对象。java中没有指针的概念,只有引用的概念,然而要是把这个引用和C++里的引用弄混,就会产生误解--->其实Java里的引用等于C++里的指针,而Java中并没有引用参数这个概念,所以基础数据类型传进去是没有办法影响到外部的。如果确实想要在一个函数里面交换两个基础数据类型的值,那就只能用反射机制了。

回到二叉查找树,一个不容忽视的问题在于:如何处理重复的值插入/删除?可以使用懒惰策略-->每个节点维护一个count,重复插入就+1,删除就-1。

当重复的值完成处理,下一个问题是如何正确执行删除操作?---->用左孩子的最大节点值代替当前节点值,然后删除左孩子的最大节点。当然右孩子的最小节点也行,可以随机选择一个方向来增强二叉树平衡。这里只有最大/最小节点被删除,而原本位置的结构并不变,只是值被替换了。

因为二叉查找树的平均深度是O(\log{N}),且一个节点有且只有一条从根到达的路径,理所当然的增删改查操作平均复杂度都是O(\log{N})(除了删除操作,因为不确定最大最小节点可能处于什么样的深度)

需要注意的是这种平均复杂度是建立在基本平衡的二叉查找树的基础上的---也就是说,二叉特性的确能减少遍历次数----如果二叉树因为数据事先被排序好导致退化成一边的链表,则复杂度直逼O(N^{2}),为了避免这种情况,我们可以牺牲一些性能,来获取完全平衡的二叉树:如AVL树。

AVL树:

每个节点的左子树和右子树的高度最多差1的二叉查找树,每个节点都要存储高度信息

当插入一个节点导致AVL不平衡,需要进行单旋转/双旋转,这里分两种情况:

不平衡节点A的左-左/右-右子树插入新元素导致过深,此时只要单旋转即可,将A的更的子节点B提上一位,自己顺势下降一位,并将节点B(如原来B是A的左节点,现在A成了B的右节点,那么B原来的右节点就要找个新位置)的原内侧子节点移动到A的左节点上,再将A原本的父节点指向B,这就完成了一次单旋转。

如果是不平衡节点A的左-右/右-左子树插入新元素导致过深,此时单旋转是不够的,因为我们知道单旋转之后内侧子节点会移动到另一边,可是如果移动过去在那边也不平衡呢?这时单旋转并没有解决问题,只是转移了问题,所有我们要使用双旋转

双旋转:假设B是A的左节点,C是B的右节点,结构是A->B->C,如果是单旋转,就是B以A为支点往上旋转升高了,但如果是双旋转,就是C先以B为支点往上旋转升高了,再以A为支点继续往上旋转升高。这样导致最后的结构是B<-C->A,简单来说,就是C取代了A原本的位置。那么显然C的左右子树被B、A代替了,要分别放到B的右边和A的左边。再把A的父节点指向C,这样一次双旋转就完成了。

编程:

插入:照常递归插入,但是在最后调用一个balance,所以平衡操作是自底向上的。

balance:判断是四种不平衡状况的哪一种,调用单旋转/双旋转函数,更新高度信息

单旋转:简单交换子树结构,返回子树的新根,供balance返回给插入,在插入里面改变根的父节点指向。

双旋转:直接分别调用左单旋转和右单旋转即可完成,也就是先旋转子节点,再旋转自己。

删除:和二叉树一样删除,最后调用balance即可。

特殊情况:插入的情况已经说的很清楚,根的子节点要么是外侧要么是内侧子节点重了一点。然而存在一种情况,外侧和内侧子节点一样重却依然不平衡,这是因为在根的另一半子树进行了删除操作。这时候依然可以通过单旋转完成平衡,只需在平衡函数里这样写:height(t.left.left)>=height(t.left.right) -> 单旋转。就能正确处理删除导致的不平衡。

伸展树:

伸展树类似哈夫曼编码的思想,频繁使用的节点会被移动更靠近根节点来提高平均访问效率。

展开:以访问的节点A为起点,不断向上旋转,如果A是内侧节点,则做双旋转,否则从X开始往上的节点都进行一次单旋转,从结果来看,这导致X及其上方的所有节点都变成各自原父亲的父节点。

B树(B+树):

这里终于和数据库接轨上了。首先明确一点,cpu运行速度比磁盘运转快好几个数量级,所以万不得已不要去不停地磁盘读写,最好一个盘就能读到想要的。然而计算机的内存不是无限的,在面对数据库级别时必须要去磁盘进行一些读写,现在任务就变成了如何减少磁盘访问,最直接的想法就是建立索引,将数据分割成许多的区块,只在必要时进行尽可能少的磁盘扫描。

B+树的结构:

1.数据项只存在树叶上(如果是B树,也可以存在非树叶上)

2.非叶子节点存关键字,比如18,23,25 ,就将该节点的孩子分为四块:<18,(18,23),(23,25),>25。这样就建立起了索引,并且能够根据查询值大小循序渐进地搜索直到叶子节点。

3.非叶子节点的儿子数在[M/2]到M之间,这里注意,M=3时也叫做2-3树

4.叶子节点的儿子数在[L/2]到L之间

接下来要确定M和L的取值

假设一个磁盘能存K字节,又假设一个关键字(即索引项的类型)要T字节,非叶子节点的M个儿子需要M-1个关键字来划分,所以需要T(M-1)个字节,除此之外还要M个分支,意思就是指向M个分支磁盘的指针地址,假设指针要4字节,那么总共就是(T+4)M - T 个字节,通过(T+4)M - T < =K 得出M。

假设一个记录(即数据库里的一行)需要H字节,则每个叶子节点最多能存K/H个记录,所以LH<=K。

最坏情况的访问次数由\log_{M/2}{N}给出

考虑插入情况:

一旦某个叶子满了,有两种选择:①分裂成两个叶子②交给下一个叶子领养

删除情况:

从邻叶子领养一个孩子,如果领叶子已经是最低限度(L/2),那就合并为一个节点。这可能导致父节点少一个孩子而少于M/2,那么继续向上重复领养或合并的过程直到根节点。

Set:

     Set不允许重复元,通过实现SortedSet能保证各项处于有序状态,比如TreeSet,这要求Set项实现Comparable或者实例Set的时候传入Comparator。

Map:

    Map就是键值对,一般只要用containsKey检查要查询的Key的合法性后再进行put或者get就没什么大问题,问题在于不知道Key的情况下想遍历Map,由于Map并没有提供迭代器,需要我们折中遍历,将Map转换成Collection返回,Collection是有迭代器的,所以满足我们的需求,有三种方法:

1.Set<KeyType> keySet()
2.Collection<ValueType> values()
3.Set<Map.Entry<KeyType,ValueType>> entrySet()

第一个方法返回Key集合,拿到Key我们就可以用Map的get函数遍历。

第二个方法返回value集合,这里就是直接检查value里面有没有我们想要的值。

第三个方法返回Map.Entry集合,Entry是Map内部实现的一个数据单元,他包含着键值对,通过访问这个集合我们就能拿到一个个键值对。

TreeSet和TreeMap都要求在对数时间内完成,所以最基本的实现就是AVL,但其实经常都是用红黑树实现。其难点同样在于迭代器的实现,可能考虑线索树。

散列:

     散列和Map都是键值对,然而Map的值在插入时主动给出,而散列的值却是在插入时根据散列实现里的hash函数被动给出。正因如此,我们需要一个不错的hash函数来提供均匀的分布避免碰撞。

     对于整数而言,计算散列值只需要对表容量求余即可,为了增强分布,表的容量一般是素数。https://blog.youkuaiyun.com/zhishengqianjun/article/details/79087525

     对于字符串而言,比较好的实践方式是用到字符串A的每一个字符a的ascii值来进行计算,简单的代码如下

for( char a : A ){
    hashVal = 37 * hashVal + a;
}
hashVal %= tableSize;
hashVal = hashVal >= 0 ? hashVal : hashVal + tableSize;

这里的hashVal可能会溢出,所以要判断正负

因为涉及到碰撞,一个不可避免的问题就是如何判断两个项是否相等。有两种比较方式:equal()和==。

==操作符只会返回变量的值,因此对于基础数据类型,==比较足矣。

然而以对象为单位进行比较时,我们知道Java的引用只是一个指针,其值是对象在堆上实际的内存地址,这时==操作的结果就跟比较是不是堆上同一个地址是一样的。

超类Object因为没有额外的信息,equal()的实现就是判断是否==,这是足够的。

可惜,任何Object的子类都应该存储了额外信息,就好比物品有自己的功能,我们在判断两个物品是否相同时可能只在乎他们的功能是否相同,而根本不在意他们是不是同一件物品。这时==是无法满足需求的,只能重写equal()。

接下来说说每个object都有的hashcode函数。object的hashcode()被native修饰,也就是说其实现由外部语言实现(如C/C++),这是最基本的实现,我们根据需要可以去重写其作用主要是减少equal的调用。因为我们知道一个对象的功能指标可能有很多,每次都在equal里面一个个去比较或许有些费时(比如对字符串的每一个字符进行比较),而hashcode()能辅助我们减少一些结果肯定不等的判断,该函数将对象映射成一个值,即hashcode。

对于hashcode和equal有如下的设计(重写)法则:

equal是绝对正确的判断,而hashcode是不确定的辅助判断。即hashcode如果不相等,那么这两个object必不相等,然而hashcode若相等----并不代表这两个object必相等-----还需要调用equal继续验证。而且,如果两个object equal为真,那么hashcode肯定相等。所以先equal判断再hashcode是没意义的,只有先hashcode再equal才能减少判断的运行时间。

重写出来的hashcode和equal一定要满足上述的特性。

接下来说说如何解决碰撞问题

有两种方案:

1.分离链接法

   意思就是散列表的每一项都延伸出一条链表,在这里面存放冲突的节点,使用链表是因为一般我们不会让一个表项存放太多节点,更复杂的数据结构也就没有尝试的必要,链表足矣。

   一个重要概念是装填因子\lambda,其定义是散列表元素个数和表大小的比值。为什么要引入这个概念呢?

  已知\lambda,则链表的平均长度显然也为\lambda。现在假如我们进行一次不成功的查找,因为要找的项不存在,所以我们要把一条链的所有节点遍历,也就是\lambda个节点。如果是成功的查找,我们估计这条链找一半就查找到了,也就是1+2/\lambda,因为是成功的查找,还有匹配项本身这个节点,所以要加1,也就是至少要搜一个节点。

   所以在设计链表时,如果我们让\lambda大致维持在1左右,那么每次查找----不成功只要搜一个节点------成功也只要搜1.5个节点,这个效率就很高了。随着数值的插入,\lambda会慢慢变大可能超过1,这时就要重新建一个更大的散列表了。

2.开放地址法

   意思是冲突就换个地方存,地址是相对开放的。最简单的就是线性探测,每当冲突就前往下一个表项看还冲突与否。然而线性函数很容易导致一次聚集,也就是说一个局部区域都是冲突插入,导致后来者必须按顺序一个个确认过这些聚集点才能找到聚集区域后面的空位。为了避免聚集,同样需要控制\lambda的取值,一般在线性探测中这个值为0.5比较好。

  一种改进聚集问题的方法是平方探测法,比线性探测要大胆很多,这有助于快速绕过一些聚集区。缺点是可能永远不会光顾某些角落的点,这将会导致插入失败,而线性聚集不可能插入失败(除非表满了)。为了防止插入失败,我们根据以下定理要将\lambda严格控制在0.5以下

如果使用平方探测,且表的大小是素数,那么当表至少有一半是空的时候,总能够插入一个新的元素 

 证明的理解比较复杂:

  考虑最坏的情况:我们从头到尾都只插入一个常量a,这会导致不停冲突,什么时候插入可能会失败呢?假设冲突函数T=hash(a)+k^2,如果T(k1)=T(k2),插入就可能会失败了,也就是说,当我们插入第k2个常量a时,冲突函数把我们又映射回之前的位置了,当然了,再往下探测一次可能就不会冲突了,但这是一个标志->一个可能失败的标志,而在出现T(k1)=T(k2)这个事件之前,我们都有信心绝对能在有限次探测内找到一个空位供插入。 

  现在证明转换成了当表至少有一半为空时,T(k1)不等于T(k2),也就是这两个k值经过冲突函数计算的位置不一样。

假设0 <= i, j <= TableSize/2,使用反证法,我们假设i,j不相等,可是T(i)=T(j)

于是  hash(a) + i^2 = hash(a) + j^2     (mod TableSize)

    =>                 i^2 = j^2

    =>          (i - j)(i + j) = 0      

注意表大小是素数,所以想要求余得0必须是素数的倍数,形式为n*TableSize,我们注意到i,j的取值范围是小于TableSize/2,所以(i+j)<TableSize是可以确定的,(i-j)当然也是,因此这个等式是无法成立的。矛盾,得出我们想要的结论。

因为i,j的取值,我们可以断言,当已插入的点数少于表大小的一半时(j遇到空位就会停止并插入,所以不可能大于TableSize/2),也就是空位数多于表大小的一半时,我们按顺序计算冲突函数就一定能找到一个空位供插入。

 一个开放地址的链表是没法正常删除的->否则本来以该被删除节点为起点进行探测的查询都会报错,这里只能懒惰删除:并不使节点为空,只是额外设置一个标志位记录节点是否存活->如果不存活,那么还是可以插入当前位置和查找,如果存活就只能插入下一个空位和查找、删除。

二次探测缓解了聚集的问题,但不能解决聚集。下面介绍一种双散列方法,可以有效解决聚集问题。

双散列:

     双散列就是在第一次hash之后冲突->那么就计算第二次hash值a(这两次的hash函数是不同的),然后不停往前推进a个位置直到找到空位。也就是说,冲突函数为:f(i)=i * hash2(x).

    hash2的实现一般以 hash2(x) = R - (x mod R) 比较好,R是一个小于TableSize的素数。

     从形式上来说,可以发现双散列的冲突函数和线性探测是一样的,不同之处在于双散列的探测步伐由插入项的hash2决定,这样就避免了聚集的产生----->因为每次插入的项基本都不同,迈的步伐也不一样。这里的hash2既非常接近随机数的性质,又使后面的查询有据可循(如果是完全从系统获取一个随机数进行探测,当然效果很好,但是后面查找是没法进行的)。

前面提到要控制\lambda,为此需要重建散列表,即再散列,再散列要求一个大约两倍于原来大小的散列表,也就是原表大小两倍的下一个素数。

闪存散列代码:

      对一个复杂对象计算hashcode是很麻烦的,比如String,为了避免每次都去计算哈希值,我们在这种对象内部缓存计算过的hashCode供后面使用。主要用在不经常发生状态变化的对象内部,众所周知,String不可修改。这是以空间为代价换取时间的典型例子。

以上提到的经典哈希实现都乐观地判断每次操作花费O(1)时间,然而事情并不永远这么美好,冲突会引起开销。下面介绍能够做出这种保证的哈希实现。

完美散列:

     考虑到有时候我们基本不对哈希表进行改变,而更多地是去查询,为了优化查询,可以舍弃一些插入/删除运行效率。完美散列就是这种思想:通过较长的插入(创建/重构)过程,去获得查询的稳定O(1)。其结构是一个二级散列,也就是说,该散列表包含的元素是子散列表,在这些子散列表里面再进行具体的数据存储。父子散列表分别使用不同的散列函数。

     子散列表的大小是映射到该表的元素平方。比如有3个值经过父散列指向这个子散列,那么就将该子表的大小初始化为9。这用到下面这个定理:

将N个球随机放入N^2个盒子中,有1/2以上的概率----->一个冲突也没有

 这里可能有三个疑问:

1.为什么不用一级散列构造一个表而要二级:因为一级散列的平方表会非常庞大(N^2),而即使表大如此,根据上面定理我们也只有一半以上的几率不用重构散列表,一旦重新构造起来就非常不灵活。如果有3个元素,一级散列的大小为3^2,然而二级散列可能只需包含3个大小为1^2的子散列表。

2.只有1/2以上的概率不冲突----还是会冲突呀?1/2以上的概率不冲突告诉我们,平均两次重构就可以保证表没有冲突,没有冲突就意味着O(1)访问,因此1/2是可以接受的。

3.重构子散列表时发生冲突该怎么办?----换一个散列函数,一般来说,两次更换就能获取一个没有冲突的表。如果换了很多个还是不能获得无冲突表,那就增加父散列表的大小,重新映射所有值。

可以看出,要想使用完美散列,比较好的时机是事先拿到一堆数据建表后不再更改,比如字典。次一点的时机是只有少量修改操作,只要有限次的重构子散列表即可,但如果有大量修改操作,这种结构就不太适合了-----因为我们可能迫不得已要去再散列甚至重构父散列表了,而这要求我们先迭代一次散列表把元素都暂时存起来再重新映射,代价实在太高昂了。而且,为了维护完美散列,我们还需要设计多个备选散列函数供再散列/重构使用。

布谷鸟散列:

    完美散列的思想是尽可能减少冲突,布谷鸟也是类似的,而且不需要事先拿到所有数据。其做法是建立两个并行的散列表

A和B:一个元素被插入时,会分别计算出两个表的对应哈希值对(a,b),如果A的a处无元素,插入,若有,则踢掉原有元素后来居上,让原有元素去另一个表存(从原有元素的哈希值对中我们知道要去另一个表的哪里放它),如果另一个表还冲突,就重复以上过程。毫无疑问,这种踢来踢去的方法存在无限循环的可能,一个实践性的解决方法是控制\lambda小于0.5,\lambda过大就换一个哈希函数重构表,而且一旦插入过程执行了太久(可能循环),就换散列函数,如果还不行,就重构表。

布谷鸟在不发生循环的情况下效率比传统散列要好,其思想是在上面放球定理的基础上延伸开来,如果在放球时随机选择两个盒子,放进更空的那一个盒子,似乎效率会更好。事实也的确如此。况且,多个表能够并行计算,也比传统的探测来得更快。

加强布谷鸟的方法有使用更多表、并行处理、允许单张表更多关键字,可以自由组合。事实上,只要允许一个单元存储两项,对\lambda的控制要求就放宽到了0.86,上述的二表一项只是最基本的布谷鸟,扩展能够大幅提升其性能。

跳房子散列:

     跳房子算法是线性探测的改进,它保证算法在有限个探测步内找到目标。它要求每个散列位置t维护一个Hop值,这个Hop值代表了Hashcode = t 的元素分布在哪些格子上。比如我们要求算法在4个探测步内必须找到目标,那么Hop初始就为0000,如果插入一个值在t处,Hop值变为1000,再插入一个相同值,根据线性探测,元素会被放到位置t+1上,Hop变为1100。这就告诉我们,t位置上的元素并不一定就是hashcode=t,可能只是线性探测放过来的。以这个例子来说,我们从位置t开始往下探测,在4步内(包括位置t)有空位就能正确插入------如果没有呢?我们一定要在4步内探测到,所以只能替换这4步内的某个元素了。在那之前,我们先找到第一个探测到的空位k:k可能已经离t非常地远了,我们从Hop(k-3)入手,看看有没有hashcode=k-3的元素能被放到位置k,如果没有,就看看(k-2)、(k-1),如果都没有,插入失败,需要再散列或者重构表,如果有,那么把元素下移,这时k-3/k-2/k-1中的某个元素下移了,这个位置被空了出来,我们又往回走3步,看看能不能下移。如此循环直到t/t+1/t+2/t+3 这四个位置存放的某个元素被下移了,此时终于有空位给一开始的新元素插入。如果一个位置的Hop为全1,那么自然插入失败。

通用散列法:

    通用散列函数有如下形式:H_{a,b}(x) = ((ax+b) \mod p)\mod M , 1\leq a\leq p-1,0\leq b\leq p-1

    这里的M是表大小,p是一个比M大的素数,如梅森素数p=2^31-1.确定p和M之后,通过选择不同的a,b我们能得到一个散列函数族,这可以用在很多的散列表实现中。

    卡特-韦格曼绝招:假设我们要求a%b,可以先求a/(b+1)得到的商q和余r,则a%b=q+r.这样做的目的是如果p为梅森素数,则b+1的形式为2^n,可以用位操作来模拟除和余从而大幅提升计算效率。

     放到通用散列里面,代码如下

DIGS = 31;
mersennep = (1<<DIGS) - 1;
hashVal = A * x + B;
hashVal = ( ( hashVal >> DIGS ) + ( hashVal & mersennep );
if ( hashVal >= mersennep )
     hashVal -= mersennep;
return (int) hashVal % M;

可扩散列 :

      如果使用散列表时,其元素也多到内存放不下怎么办呢?第一反应是改用B树,回想B树的高性能取决于其M的取值比一般二叉树高得多而且带有索引信息,所以树的高度很低而查找很快,如果我们把M取到无穷大,那么理论上只要遍历一层树即可。但是遍历也是需要时间的,他可能会退化为一个队列,需要O(n)时间。正巧,散列是O(1)的,而且是自然索引地,我们将输入散列到一个位置上,这个位置指向一个磁盘区块(类似B树中的叶子节点)。譬如输入是6bits宽的二进制串,初始可以前缀0,1两个位置作为散列表节点,当磁盘区块满,可以考虑扩展成00/01/10/11四个位置,指向四个磁盘区块,当然也可以一开始就这样分。

考虑一个经常会问到的概率问题,池中金鱼比例为α,鲨鱼比例为(1-α),每次落网捞起一只鱼,问捞到一只鲨鱼的期望次数是多少。

这是一个无穷级数问题,根据概率分布表,我们得出计算期望的公式为

1*(1-\alpha )+2*(1-\alpha )*(\alpha )+3*(1-\alpha )*(\alpha )^{2}+...

换个表达就是 S=(1-\alpha )\sum_{n=1}^{\infty }n\cdot\alpha ^{n-1}

乘以一个系数α后的序列为

                       1 * (1 - α) * (α) + 2 * (1 - α) * (α)^2 + ....

对齐之后相减得 S-αS=(1-α)*(1+α+α^2+...α^n)

所以S=(1+α+α^2+...α^n)

等比数列之和如下

所以S=(1-α^n)/(1-α)

因为α显然小于1,当n趋于无穷时α^n=0.

所以最终S=\frac{1}{1-\alpha }   (当n趋于无穷时)

 优先队列(堆)

     堆是这样一个二叉树(可以用数组实现,假设n为某节点位置,2n/(2n+1)是其子节点):每个节点都比他的所有子节点小,其特点是能快速找到最小值(根节点)。主要操作是上滤、下滤

     上滤:往上遍历直到根节点,若比父节点小就交换。

     下滤:往下遍历,若比父节点小就交换。

     插入:在当前堆的最后一位创造空穴,将插入项放入,并开始上滤

     删除:将根节点置为空穴,随便放一个堆中的值进去,从更小的子节点开始下滤

插入在最后放入并调整堆,删除在根节点取出并调整堆,如果忽略调整操作,这其实就是一个队列。其实,堆的应用就跟改进队列有关。

考虑操作系统中常见的线程调度器,必须从线程队列里找出一个忍耐度最小的线程放到cpu/io去执行。这时堆的性质就非常好,因为取出最小线程只需访问根节点,这是O(1)操作。

如果还要根据等待时间降低线程容忍度或者管理员手动提高/降低某个线程的优先级呢-----我们还要支持对某个节点降低或提高忍耐度的方法。降低实现是先降值后上滤,反之就先升值后下滤。如果一个线程被用户终止----我们要把他从堆中移去,其实现是先降值到下限,这时就到根节点了,然后调用删除函数。

假如我们拿到一个数据集要去建立堆(比如突然加入一批线程),首先,我们以任意顺序把数据放入堆中,假设有T个数据,现在我们从最后一个非叶节点开始进行下滤。注意,对于一个正常插入得到的堆,通过下滤操作我们的确能够得到一个保持特性的堆,然而考虑下面这种情况:

2<-5->1 ,这显然不可能出现在正常堆中,因为2/1的插入都会进行上滤,可惜在随意放入堆的过程中是会出现这种情况的。

下滤一次之后,变成5<-2->1,可惜,依然不平衡,所以我们在设计下滤函数的时候,一定要注意把两个子节点的值进行判断,取最小那个值,提高下滤函数的复用能力。

堆的其他应用:

       考虑选择问题,从N个数里找出第K大的数。如果简单实现,很可能要O(n^2)进行排序后返回第K位上的数。

      一种改进的方法是维持K个降序排序好的数,每当下一个数读入,我们把他和第K位的数比较,如果更大就替换并重新排序。这种算法显然大部分情况下要好于O(N^2).

      使用堆有两种方法改进选择算法:

      1.建堆并删除k次,第k次获得的数就是第K小的数,反过来说,删除(N-K+1)次,就能获得第K大的数,这就是堆排序的雏形

      2.考虑上面那种改进的方法,第K位数放在堆结构里面就是根元素,所以用堆来维持K个数就很自然了,每次插入和根元素比较,然后下滤。最后根元素就是第K大的元素。

d-堆

    d-堆的好处不必说,降低高度,缺点是下滤时增加比较次数(要找出最小子节点)

堆合并       

左式堆

    不完全节点:没有2个/d个子节点

    零路径长:从X开始到一个不完全节点的最短路径长

    定义:所有节点的左节点的零路径长大于等于右节点,这导致在右节点处的插入受阻->因为一旦插入,右节点可能就成了完全节点,导致其零路径由0(到自己)变为1(到子节点)。长期来看,这导致左链越来越长。做成这样不平衡的意义在于,合并两个左式堆的难度比普通堆低。

斜堆

二项队列

(等遇到要合并再更新)

 

总结

         

 平均操作时间
链表O(d)
O(1)
队列O(1)
二叉树O(logN) (平衡状态)
AVLO(logN)
伸展树O(M logN)
B+树O(log_{M/2}N)
散列O(1) (散列函数良好)
O(logN)

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值