当前位置: 首页 > news >正文

c++进阶——哈希表的实现

文章目录

  • 哈希表的实现
    • unordered_map和unordered_set
    • 哈希的引入
    • 散列的一些基本概念
      • 将Key转成整形和哈希函数
      • 哈希冲突
      • 负载因子
    • 开放定址法和链地址法
    • 哈希函数的选取
      • 除法散列法/除留余数法
      • 乘法散列法
      • 全域散列法(了解)
      • 其他方法(了解)
    • 针对于开放定址法的哈希冲突解决方案
      • 线性探测
      • 探测的注意事项
      • 二次探测
      • 双重散列(了解)
    • 代码实现部分
      • 开放定址法的代码实现
        • 基本结构和前置准备
        • 插入接口
        • 查找接口
        • 删除接口
        • HashTable完整代码
      • 哈希桶的代码实现

哈希表的实现

本篇文章,我们来学习一下哈希表的一些内容。学习本节课是非常具有意义的,因为c++STL库中的两个系列的容器unordered_setunordered_map都是基于哈希表的思想来实现的。所以我们需要深入了解哈希。

对于这两个系列的容器的使用,其实看名字可以大概知道,这是和mapset相互对应的。只不过有一些区别。基本上就是会使用mapset就会使用unordered_setunordered_map。所以对于这两个容器的使用就不再花开文章进行专门的介绍了。

下面我们来探究一下二者不同的地方。

unordered_map和unordered_set

这两个系列的容器是基于哈希表来实现的。而map和set是基于红黑树这个比较特殊的二叉搜索树来实现的。二者有如下的区别:

  1. 中序遍历的序列不同:
    map和set是二叉搜索树,其天生的性质就决定了其中序遍历得到的序列是一个升序序列(默认比较规则下)。当然也可以是控制降序。但是对于unordered_map和unordered_set来说,这是没有顺序的。在这里不进行演示。后续讲完哈希表的实现后自然能明白。
  2. 哈希表在搜索和插入的效率上会比红黑树的效率高一些。
  3. 哈希表也是和红黑树一样,根据关键字Key来存储。但是哈希表不是直接根据Key来存储的,需要将关键字转化为整型后再操作:
    但是这就导致了一个问题,实现哈希表的时候就得加入一个仿函数。因为有些数据类型不支持直接转化为整形的。如string,如自己实现的一些日期类等。所以需要自行编写仿函数进行转整形操作。在哈希表中通过仿函数调用。
  4. 哈希表的key需要支持相等比较:
    这点其实比较不好理解,因为当前还没有学习哈希表的概念是什么。但是在这里稍微提及一下。因为哈希表是通过关键字转整形后进行操作的。但是最终存储在表中的是什么?还是关键字和映射的数据(如果有)。搜索的时候也是通过这个关键字进行相同的操作,然后找到对应的位置。在不支持冗余数据的情况下,是需要比较一下插入的关键字是否存在。势必要比较两个关键字是否相等。对于内置类型和STL库中的数据类型,其实已经在标准库中重载好了它们对应的operator==函数。但是有一些数据类型,很可能是没有实现的。比如我们自己写的日期类。又或者有时候比较方式不是我们想要的效果(如pair类的比较)。

所以有些情况下我们需要自行实现相等比较。但是对于个别类型来讲已经实现好了,直接使用库里面的重载过的就行。

有两种方法解决:
1.自行编写仿函数,在哈希表的实现中再多加一个模板参数,专门用来判断关键字相等问题。
2.自行重载operator==函数,这样子就不用写仿函数。

标准库里面使用的是第一种。其实这是很合理的。因为有时候传来的数据类型的源码我们不能进行修改的,但是又没有实现重载。所以自然需要自行调用仿函数了。

这两个系列的容器就先讲到这,没看懂不要紧,后续我们再回过头来看。

还需要多说一句的是:在本篇文章中,为了方便实现,默认所有的传入的数据都已支持相等的重载。其实加上也不难,但重点还是在于理解。为了方便所以选择第二种方式。

哈希的引入

哈希(hash)又称散列,是一种组织数据的方式。从译名来看,有散乱排列的意思。本质就是通过哈希函数把关键字Key跟存储位置建立⼀个映射关系,查找时通过这个哈希函数计算出Key存储的位置,进行快速查找。

这大概是什么意思呢?

就是现在有一些数据要进行存储。如果用序列式容器(如list、vector等)存储就会有一个问题,就是查找的效率特别特别低。查找效率是O(N)。当N特别大的时候,效率特别低下。

对此有人发明了各种各样的搜索树,成功地将搜索效率和插入效率都整到了O(LogN)级别,这是质的飞跃。但是能不能更快呢?达到O(1)级别。

所以就有人发明了哈希的概念。对于红黑树或者是AVL树,还是要满足太多规则。特别是搜索树的规则,导致了插入和搜索的时候必须走树高度的对数次才能找到插入。

但是我们更希望的是,直接通过一个映射关系,就像函数那样,传入一个关键字,就能知道对应的位置。这样子不就是O(1)吗?哈希就是这个意思。就是将所有的关键字以一种特殊的映射方式,能够找到对应的位置存储。其实就是将这些数据分散到一个表上。每个关键字都对应表上的一个位置。这就是散列的思想。

散列的一些基本概念

前面我们引入了哈希这个思想。但是在实现哈希表之前,我们还是得知道一些基本概念。

哈希表的本质是一个表。为了实现快速的搜索和插入,对于底层容器的选择就极为重要。必须能够通过某个操作快速定位。需要支持随机访问。有这个思想的容器就是vector或者deque了。因为它们的迭代器都是随机迭代器,支持随机访问。且重载了operator[]

在SGI-STL30版本下选的是vector,其实用vector是最方便的。因为我们更熟悉这个容器,且deque虽然支持随机访问,但是在访问中间数据的时候是远远不如vector的效率的。使用哈希的思想本质上就是为了快速查找和插入。所以这里选择使用vector的适配器模式。

将Key转成整形和哈希函数

现在来解决第一个问题,为什么要将关键字Key转成整形?

哈希表本质上也是一个类模板。里面存储的数据是什么是不知道的。对于关键字的类型更是如此。类模板最重要的就是泛型编程。就是要将所有的不同形式转化为相同形式。

我们再回顾一下哈希的思想:将关键字通过一定的映射方式,在哈希表中找到位置进行存储。其实就是在一个vector中通过下标来找位置。

假设关键字Key通过映射方式F得到下标,下标必然是个整形。所以很自然而然的想到了应该将关键字已某种形式转入为整形后再来通过映射F,这样子不就构成了Key到下标的映射了吗?至于本身就是整形的数据就不需要再转化了。直接映射。这点很简单,通过一个仿函数控制一下就好了。就跟我们在封装map和set的时候一样,为了能够让不同数据比较,通过仿函数KeyOfT来取出数据的关键字。

但是这里需要说明的是,转成整形的方法有很多种。可以自由选择。但是需要尽量选择比较散的映射关系?什么意思呢?比如对于string类,我们可以选择将所有的字符的ACSII码加起来,代表一个string转化的整形。但是对于“abcd”“acbd”这两个来说,使用这个方法会导致它们算出来的整形一样。那通过同一个映射方法必然是会导致二者在同一个位置上的。这一点就是我们后面要讲的哈希冲突概念。这会极大影响效率。这样算出来的整形必然是比较集中的。所以我们尽量需要选择一些算出来比较散的转整形函数。

在此还提一下,一般转整形的函数我们称之为Hash。

第二点:何为哈希函数?
哈希函数其实就是我们上面讲的映射方式F。学过高数的都知道,函数抽象化后其实就是映射关系。通过关键字转化为整形后,我们就是通过哈希函数来将整形转化为一个下标。

大致关系可以如下图所示:
在这里插入图片描述
对于某个数据,我们不知道是什么类型,所以要先取出它的关键字。然后通过Hash转化为整形后,最后通过哈希函数将这些关键字转化后的整形下标尽可能地散开分布在哈希表上。然后通过下标插入数据。这就构成了从数据到位置地映射关系。

只不过这里的映射关系是比较复杂的。可能会有多次映射。

哈希冲突

这个是怎么一回事呢?

哈希冲突的本质就是有一些数据会堆积在同一个位置。这是无法避免的。因为映射方式有很多种。但是数据量比较大或者比较巧合的情况下,肯定会有数据挤到同一个位置去。这个时候应该怎么办呢?

首先我们得明白一个道理,就是虽然哈希冲突是不可避免的。但是还是需要尽可能的选取一些比较优秀的哈希函数,使得数据分布在哈希表中是比较分散的。这样子效率较高。但是冲突毕竟是不可避免。所以还是得解决这个问题。

解决方案主要有两种f方法,开放定址法和链地址法。

当然在这里一时半会儿也讲不清楚,这些内容将放在后面部分来讲解。

负载因子

假设哈希表中已经映射存储了N个值,哈希表的大小为M,那么 ,负载因子有些地方也翻译为载荷因子/装载因子等,他的英文为load factor。负载因子越大,哈希冲突的概率越高,空间利用率越高;负载因子越小,哈希冲突的概率越低,空间利用率越低;

注意这里的哈希表大小M其实就是内部vector的size()函数。就是当前表有多少个可用空间。

开放定址法和链地址法

先前我们说过,哈希表的实现有两种方式,一种是开放定址法,另一种是链地址法。

我们先来讲讲什么是开放定址法:
Leetcode 387 找出字符串第一个唯一字符

就以这个题为例,这题的思想其实很简单。字符串里面也就是一些ascii码值。总共是256个,那我们就可以开辟一个256大小的char数组,将ascii值从头到尾遍历一遍,出现一次,就在数组对应的位置记录一次出现。全部记录完后,就再遍历一次字符串,第一个遍历到的字符在数组中对应数字是1的就是字符串的第一个唯一字符。

这其实和我们之前实现的计数排序的很像。其实这种就是开放定址法。就是每个关键字对应着的数组中一个确定的位置。

但是开放定址法会出现哈希冲突的问题。

再来说说何为链地址法。链地址法的哈希表也被称为哈希桶:
在这里插入图片描述
就是一个数组,但是每个数组指向的其实是一个链表。就很像数组中每个位置下面挂着一个桶,然后这个桶里面装的都是链表节点。这样子就不会起到哈希冲突了。算出来如果是一个位置就下面挂着。

但是很多人会说,如果一个链表过长怎么办?这点不需要担心。

在java中是这样解决的:就是当一个链表数据超过8个的时候,会将链表一个个的插入到红黑树中。此时对应的位置放的就不是链表节点的指针了,而是指向红黑树的指针。但是这个方法比较复杂,那是否还有别的解决方式呢?

有的,我们可以手动控制一下。像c++中是这么解决的:即控制负载因子。一个链表比较长的时候必然会导致负载因子过大。对于哈希桶发来说,负载因子是可以大于1的。c++中就是将负载因子控制在1。一旦负载因子到1了就需要对表进行扩容。这个时候就需要对所有的节点进行重新映射。由于表的空位变多,自然会导致数据被分散。所以就解决了这个问题。

本文中就采取第二种方式进行实现。

哈希函数的选取

无论是开放定址法还是链地址法,都面临着哈希函数如何选取的问题。假设已经获取到数据的关键字Key了,应该采用何种映射方式映射到表的不同位置呢?

就算哈希桶能解决哈希冲突的问题,但是我们还是更希望能够设计出将数据较均匀的分布在表中的M个空间当中。虽然这是很困难的,但是我们需要尽可能往这个方面考量。

除法散列法/除留余数法

先来将第一种方法,即除法散列法。其实也叫除留余数法。什么意思呢?

除法散列法也叫做除留余数法,顾名思义,假设哈希表的大小为M,那么通过Key转化的整形除以M(表的空间)的余数作为映射位置的下标,也就是哈希函数为:h(key) = key % M。

这种还是能够较为分散的把数据分散到哈希表中的。这也是实践中用的比较多的方式。本文也是采取这种方式对哈希表和哈希桶进行实现。

但是还是有一些问题需要注意的:

  1. 当使用除法散列法时,要尽量避免M为某些值,如2的幂,10的幂等。如果是 ,那么key%2x的本质相当于保留key的后X位,那么后x位相同的值,计算出的哈希值都是⼀样的,就冲突了。如:{63 , 31}看起来没有关联的值,如果M是16,也就是 ,那么计算出的哈希值都是15,因为63的⼆进制后8位是 00111111,31的⼆进制后8位是 00011111。如果是 ,就更明显了,保留的都是10进值的后x位,如:{112, 12312},如果M是100,也就是 ,那么计算出的哈希值都是12。这种现象是很容易发生的。

我们可以这么理解,因为这样选取的M,会导致Key参与运算的位减少了。比如63和31取模16,真正参与运算的只有后面五个比特位,这样子很容器导致哈希函数映射出来的值会有很大概率会冲突。所以尽量不要选这些数。

  1. 经过研究者的不断探究,发现当使用除法散列法时,建议M取不太接近2的整数次幂的一个质数(素数)。至于这个质数怎么取等一下来说。这个证明需要比较高的数学水准。就和希尔排序一样。证明过程可能会涉及一些当前我们不知道的数学知识。感兴趣的可以了解一下。

  2. 需要说明的是,并不是说不能让M的大小为2的幂或者10的幂。各个语言的实践中也是八仙过海,各显神通,Java的HashMap采用除法散列法时就是2的整数次幂做哈希表的大小M,这样玩的话,就不用取模,而可以直接位运算,相对而言位运算比模更高效一些(在计算机底层种除法和取模效率是比较低的)。

但是java的实现不是单纯的去取模,比如M是2^16次方,本质是取后16位,那么用
key’ = key >> 16 ,然后把 key 和 key’ 异或的结果作为哈希值。也就是说我们映射出的值还是在[0,M)范围内,但是尽量让key所有的位都参与计算,这样映射出的哈希值更均匀⼀些即可。所以我们上面建议M取不太接近2的整数次幂的一个质数的理论是大多数数据结构书籍中写的理论而已。但是实践中可能会不同的实现,我们需要灵活运用,抓住本质去理解,而不能死读书和死记硬背。

本质上哈希函数的选取就是选取让key的更多位参与运算的那一个。

乘法散列法

乘法散列法对哈希表大小M没有要求,他的大思路第一步:用关键字 Key转化的整形 乘上常数 A (0<A<1),抽取出 k*A 的小数部分。第二步:后再用M乘以k *A 的小数部分,再向下取整。

比如M = 53, Key = 15,A = 0.63:得到的下标就是:
(0.6 * 15) = 9.45,取出小数部分0.45再乘以M = 0.45 * M = 23.85。向下取整得到23。
一定是向下取整。向上取整会面临着越界的风险。

所以在此基础上15映射的位置是23。

其实这么做也是有道理的。就是想通过key的乘法操作得到一个值,这个值又必须在[0, M)之间。那得到的值必然是大于0,小于1的数字。因为这样才能保证在表中选的位置是在范围之内的。所以就通过如上的操作,用某种方式对Key操作,得到小数。再用这个小数乘M在表中选取位置,由此构成了从Key到存储位置的映射。

这个A是选取(0, 1)区间的数字。但是我们还是秉承着分散的概念,需要选取一个合适的A。使得从key * A得到的小数再乘M是尽可能分散的。

h(key) = floor(M × ((A × key)%1.0)),floor为向下取整函数。
还是经过研究者的一系列研究,发现A取黄金比例数的时候是最合适的:
即A = ( sqrt(5) − 1)/2 = 0.6180339887,一般来说取个0.618就足够了。

全域散列法(了解)

如果存在一个恶意的对手,他针对我们提供的散列函数,特意构造出一个发生严重冲突的数据集,比如,让所有关键字全部落入同⼀个位置中。这种情况是可以存在的,只要散列函数是公开且确定的,就可以实现此攻击。解决方法自然是见招拆招,给散列函数增加随机性,攻击者就无法找出确定可以导致最坏情况的数据。这种方法叫做全域散列。

这种方法了解一下即可:
h(key) = ((a × key + b)%P )%M,假设哈希函数是这一个。我们将这个函数公布出去。我们对于P的选取必定是一个大于M的质数。为什么?

因为任何数字对M取余,得到的答案只可能是[0, M - 1],如果P小于M,(a × key + b)%P 得到的答案一定 <= P - 1 < M。

但是对M取余的数字P如果一定是小于M的,那就会导致算出来的结果就是P。那么这就出现问题了。上面的那个哈希函数算出来的下标值 = P,小于M,那就会导致表中大于P的位置的下标是永远无法被映射到的。这是很浪费的。所以P的选取需要是大于M的质数,又因为希望能够更加的散,所以M还是得选取一个质数。

这个时候,P需要选⼀个足够大的质数,a可以随机选[1,P-1]之间的任意整数,b可以随机选[0,P-1]之间的任意整数,这些函数构成了⼀个P*(P-1)组全域散列函数组。假设P=17,M=6,a = 3, b = 4, 则 h34 (8) = ((3 × 8 + 4)%17)%6 = 5。

但是这样子怎么样做到防止别人攻击呢?难道是每次对Key转化的整形选取不同的哈希函数来映射吗?当然不是。同一个表里面哈希函数必须相同,要不然映射关系就全乱了,就没有什么所谓的哈希的概念了。

这里的随机是指系统的启动的时候。数据可能是存储再磁盘或者数据库中。假设系统启动后要把数据全部导入到哈希表上,那就要确定好这一次系统启动后的哈希函数。
比如第一次启动选取P = 17, M = 6, a = 3, b = 4
但是关了之后下一次选取的时候就可能是P = 25, M = 15, a = 5, b = 2

这里的随机是指每次系统启动的时候对于这个哈希函数有随机选取的操作,只不过在确定选好后,只要系统不关闭,就不会再改变哈希函数了。因为每次运行的时候必须遵循同一个哈希函数。这样子就很难再被外界进行攻击了。因为这四个项可以任意取,想要针对每一种情况进行设计攻击数据集基本上是没什么可能。

其他方法(了解)

上面的几种方法是《算法导论》书籍中讲解的方法。
《殷人昆 数据结构:用面向对象方法与C++语言描述 (第二版)》和 《[数据结构(C语言版)].严蔚敏_吴伟民》等教材型书籍上面还给出了平方取中法、折叠法、随机数法、数学分析法等,这些方法相对更适用于一些局限的特定场景,有兴趣可以去看看这些书籍。

针对于开放定址法的哈希冲突解决方案

前文提到,对于开放定址法必然会存在哈希冲突的问题。因为哈希函数不可能完全做到能够把关键字非常均匀的映射到表中的每一个位置。必然是会有映射到同一个地方的。但是链地址法不存在这个问题。我们这个部分就是需要来重点讲一下,针对于开放定址法如何解决。

线性探测

针对于开放定址法,哈希冲突的时候肯定是没办法在表中一个位置存储多个值的。这个时候可以采用第一种方法——线性探测:

线性探测就是,从发生冲突的第一个位置开始,依次向后探测位置,直到找到空的位置再来存入。如果走到哈希表的尾部了还没有找到空的位置,就需要绕到表头找。

h(key) = hash0 = key % M,hash0位置冲突了,则线性探测公式为:
hc(key,i) = hashi = (hash0 + i) % M。i = {1, 2, 3, …, M − 1}

一般来说,对于开放定址法的哈希表的负载因子,我们是不会让它等于1的时候才扩容的。一般是在0.7左右就需要进行扩容了。插入数据的时候一定是保证有空位的。所以最多探测M - 1次就能找到空位进行插入。只不过这种情况不太好。所以在负载因子达到一定程度的时候就进行扩容,这样就能减少线性探测的次数。

下面演示 {19,30,5,36,13,20,21,12} 等这一组值映射到M=11的表中:
在这里插入图片描述
注:哈希函数选择除法散列法

h(19) = 8,h(30) = 8,h(5) = 5,h(36) = 3,
h(13) = 2,h(20) = 9,h(21) = 10,h(12) = 1

在这里插入图片描述
由图得:
插入19的时候直接放在下标8的位置。
插入30的时候算出来是也是8,但是由于已经被19占位,所以向后探测了一次就发现有空位,所以30放在的是下标为9的位置。
以此类推…

但是有人会疑问,这样子好像不方便通过关键字Key来查找了。这是不需要担心的。查找的时候其实也是一样的,先算出正常应该放在的位置。如果那个位置与搜索的Key不同,也是进行线性探测就可以。直到遇到空的位置都没找到,那就是没有这个关键字。

探测的注意事项

现在我们已经大致掌握线性探测的方法了,但是还是有一些细节我们需要注意一下:

哈希表自然会有插入、删除等接口。但是学过顺序表的删除的都知道,其实顺序表的删除不是真的说把那个空间释放掉。

在栈里面直接让栈顶位置向栈底移动一个位置就可以,如果是vector,就是让表示数据结尾的位置向前移动一个位置。可以理解为间接删除。

但是在这个哈希表里,存储的顺序可不是线性的,这是一个关联式容器。这怎么处理呢?删除也没办法真的释放掉那个空间。所以我们需要对每个位置设定状态。

enum Status{EMPTY,EXIST,DELETE
}

所以我们需要确定一下当前位置是什么状态。如果是空的就可以插入。

这点是必须要做的。我们举一个例子就知道:
在这里插入图片描述
假设不设立状态,我们现在将30删掉。如果不设立状态,这个30必然还是在这个表里面的。如果现在插入一个41,经过哈希函数映射后得到:h(41) = 8,那就需要进行线性探测。如果不设立状态,按照正常的逻辑去比较是否为空,必然是探测到下标为4的位置。但问题是在我们认知的状态下,30是已经被删除的。这个位置是可以被插入的。所以应该插入到下标为9的位置才对。所以从这里来看,设立状态是非常有必要的。

又或者是查找的时候,如果不设立状态,那也是会被误导。因为数据不是真的被删除了,只是设立状态而已。如果判断一下key是否等于对应位置二段关键字,这当然会错。所以需要设立状态。在查找的时候,如果碰到空就停下,其他情况下还得继续往后找。因为没办法保证所要找的数据是不是给挤到后面的位置去了。

所以最后哈希表每个位置存储的就不只是数据了,应该是一个类,类里面有对应得数据,和当前位置在哈希表中的状态:

template<class T>
struct HashData{//数据T _data;//状态Status _state;
}

由于线性探测实现比较简单,本篇文章的开放定址法的代码将采用这个方式解决哈希冲突。

二次探测

但是线性探测会有这么一个问题:
线性探测的比较简单且容易实现,线性探测的问题假设,hash0位置连续冲突,hash0,hash1,hash2位置已经存储数据了,后续映射到hash0,hash1,hash2,hash3的值都会争夺hash3位置,这种现象叫做群集/堆积。下⾯的⼆次探测可以一定程度改善这个问题。

因为线性探测得本身就是一个一个向后找位置插入,如果连续几个Key映射的位置都是一样的。这会堆积在一起。然后需要不断地线性探测。一旦聚集在一起,那么哈希表的各种操作效率就低得多了。最理想的状况就是不经过线性探测或者比较少的线性探测就能找到位置。

对此提出了二次探测的方法:
线性探测其实也叫一次探测,这是步长i的幂。一个一个找这太集中了。哈希表最大的特点就是散列,所以探测方式可以再散一点。所以有人提出了二次探测的方法:

从发生冲突的位置开始,依次左右按二次方跳跃式探测,直到寻找到下⼀个没有存储数据的位置为止,如果往右走到哈希表尾,则回绕到哈希表头的位置;如果往左走到哈希表头,则回绕到哈希表尾的位置。

h(key) = hash0 = key % M , hash0位置冲突了,则二次探测公式为:
hc(key,i) = hashi = (hash0 ± i2 ) % M, i = {1, 2, 3, …, }。

但是这里和线性探测有点点区别。线性探测是单向查找,超过表尾就要回到表头。
但是二次探测是双向查找,对于超出表头的情况,需要回到表尾。

那怎么样回到表头/表尾呢?

假设当前hashi = hash0 + i2 > M,这个时候回到表头就很简单了,取模就可以,直接让hashi = hashi % M。但是可以不用特殊判断。因为即使没超出表尾来取余,那次是的hashi一定小于M,对M取模一定是hashi。所以无论如何都直接取余就可以了。

但是针对于超出表头的情况怎么办呢?直接让当前位置 + M作为下一个坐标。其实这里就是和循环队列的时候是一样的。特殊判断一下就可以。

当然不太清楚的话可以举个例子:
假设某个表M = 11,现在hash0 = 2,向左边探测第二次的时候,hashi = hash0 - 4 = -2。
表头的坐标为0,也就是说,现在超出表头两个位置。所以hashi = -2的时候应该是表尾的倒数第二个数据才对。这个时候让 -2 + M = 9,这正好是倒数第二个位置的坐标。

这里我们就通过举一个实例来进行验证,感兴趣的可以多举几个例子。

二次探测的思想其实和线性探测差不多。只不过探测空位置更加分散而已。只需要注意上面说的探测的时候表尾和表头之间的位置转换即可。

双重散列(了解)

前面学的探测都是给定具体的步长的。双重散列就不一样了,在发生冲突的时候探测方式就不是通过i的幂次方来探测了。而是通过另一个哈希函数进行探测。

第一个哈希函数计算出的值发生冲突,使用第二个哈希函数计算出一个跟key相关的偏移量值,不断往后探测,直到寻找到下一个没有存储数据的位置为止。
h1 (key) = hash0 = key % M , hash0位置冲突了,则双重探测公式为:
hc(key,i) = hashi = (hash0 + i ∗ h2 (key)) % M, i = {1, 2, 3, …, M}。

但是这个是有要求的:
要求h2 (key) < M且 和M互为质数,有两种简单的取值方法:

  1. 当M为2整数幂时,从[0,M-1]任选⼀个奇数;
  2. 当M为质数时,h2 (key) = key % (M − 1) + 1

保证h2 (key)与M互质是因为根据固定的偏移量所寻址的所有位置将形成⼀个群,若最大公约数p = gcd(M, h1 (key)) > 1,那么所能寻址的位置的个数为M/P < M,使得对于⼀个关键字来
说无法充分利用整个散列表。

举例来说,若初始探查位置为1,偏移量为3,整个散列表大小为12,那么所能寻址的位置为{1, 4, 7, 10},寻址个数为12/gcd(12, 3) = 4。

在这里插入图片描述
这背后蕴含着非常多的数学证明知识。可以只做了解。因为实际应用中很少这么用。

代码实现部分

上面都是哈希表的理论知识,我们还是应该着手实现一下。也方便后续实现STL库中的那两个容器。在这里先声明一些细节:

  1. 本篇文章重点是掌握哈希表的思想和简单的实现,了解原理。所以对于存储的数据直接就让它为pair类型。对类型的泛化和红黑树一样,放在封装容器来实现。
  2. 哈希函数选取的是除法散列法,当然可以选择其他。这个由实现者自行决定。
  3. 对于开放定址法的实现部分,解决哈希冲突直接采用线性探测,方便实现。
  4. 实现的哈希表不支持数据的冗余。
  5. 对于哈希表的实现要多加一个仿函数,用来将Key转化为整形。这个我们下面来说。

开放定址法的代码实现

现在我们开始开放定址法的实现。

基本结构和前置准备

我们来直接看结构:

enum Status {EMPTY,EXIST,DELETE
};template<class K, class V>
struct HashData {pair<K, V> _kv;//这个状态是必须存在的 重点放在博客上去讲解Status _state = EMPTY;
};template<class K>
struct HashFunc {size_t operator()(const K& key) {return (size_t)key;}
};template<>
struct HashFunc<string> {//由于字符串在hash内用的是很多的 直接特化一个版本//BKDR哈希算法size_t operator()(const string& str) {size_t hash = 0;for (auto ch : str) {hash += ch;hash *= 131;}return hash;}
};template<class K, class V, class Hash = HashFunc<K>>
class HashTable {
public:private:vector<HashData<K, V>> _table;size_t _DataNum = 0;//存储数据个数
};

这是和适配器模式,底层存一个vector。这个vector就来存放数据和状态。

这里我们面临着第一个问题:就是传入的Key很可能不是整形,有可能是string,也有可能是自己实现的日期类等。这些类型是没有办法直接转为整形的。所以我们需要一个仿函数对传入的Key进行转整形操作。也就是模板参数里面的Hash。

对于这个转整形函数,如果编译器是支持直接走隐式类型转换变整形的,那就直接强转整形的数据。但是有一些自定义类型是没办法直接强制转换的。

一般来说,可以让一个类里面自己实现好转整形操作,然后传入调用就好了。但是不能完全指望类里面有完成这些事情。所以还是需要自行写仿函数。针对于string这种用的比较多的数据类型,我们可以考虑对他的转整形仿函数进行一个特化。

对于string我们使用的是BKDR算法,就是认为字符串是一个很长的整形。每一位上都有权重。我们常见的二进制的权重是2的幂,十进制是10的幂。对于string来讲,把它认为是131是比较合理的。当然这也是通过数学证明的。感兴趣的可以去看一下。

对于其它的类的转整形函数,可以自行实现一下。尽量能够让数据较为分散就可以了。

我们采用的是除法散列法,前面提到过,尽可能地将表的空间M设置为一个比较原理2地整数次幂地一个质数。这个应该怎么操作呢?
在这里插入图片描述
打开SGI_STL30版本地源码,发现库中是提供一个质数表的。我们只需要传入一个数,就能返回表中第一个大于等于这个数的质数。

也就是说,刚开始传一个0进去,返回的就是53。
当需要扩容的时候,就传当前空间个数 + 1进去,就能返回下一个质数。

这样子就很好的解决了这个问题,在本文的实现中也会以这个为标准实现:

public://为了保证表的容量数尽量为一个整数,且每次扩容尽可能的达到二倍扩容//这里采用和c++STL库中一样的方式,列一个质数表inline unsigned long __stl_next_prime(unsigned long n){// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317, 196613, 393241, 786433,1572869, 3145739, 6291469, 12582917, 25165843,50331653, 100663319, 201326611, 402653189, 805306457,1610612741, 3221225473, 4294967291};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list +__stl_num_primes;const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}//当然也可以写其它的构造HashTable():_table(__stl_next_prime(0)),_DataNum(0){}

对于此类型的哈希表来讲,底层只有以恶搞vector和一个表示数据个数的变量。其实是不需要写析构等函数的。因为内置类型没有指向资源,自定义类型vector是已经实现了析构等操作,编译器会自己调用。所以写一个哈希表的默认构造就可以了。

const size_t Size() const{return _DataNum;
}const size_t Capacity() const {return _table.size();  
}

当然可以把当前数据个数和表的空间个数的接口给出来。

最后实现哈希表最重要的操作:增、删、查。修改其实不是在哈希表这一层来使用的,这点我们在后面封装unordered_set和unordered_map的时候再来细说。

插入接口

插入操作十分简单:

//实现insert
bool Insert(const pair<K, V>& kv) {//查找逻辑的使用if (Find(kv.first)) return false;Hash hash;size_t hashNum = hash(kv.first);//如果负载因子(存储数据量/空间数)比较大的时候,是需要进行扩容的。//Capacity()接口就是表的当前空间个数if (_DataNum * 10 / Capacity() >= 7) {//扩容HashTable<K, V, Hash> newTable;  newTable._table.resize(__stl_next_prime(Capacity() + 1)); for (size_t i = 0; i < Capacity(); ++i) {newTable.Insert(_table[i]._kv);}_table.swap(newTable._table);}//取模散列size_t hash0 = (hashNum % Capacity());//可能会有hash冲突的问题 ——》线性探测size_t i = 1; size_t hashi = hash0;while (_table[hashi]._state == EXIST) {hashi = (hash0 + i) % Capacity();++i;}_table[hashi]._kv = kv;_table[hashi]._state = EXIST; ++_DataNum;return true;
}

插入的实现非常简单。首先查找一下这个Key是否存在,如果存在时没办法进行插入操作的。虽然当前没有实现查找接口Find,但是逻辑不会变。先认为已经实现好了。后面再补就好。

将关键字Key通过仿函数Hash转化为一个整形变量HashNum。这个时候再来对HashNum进行取模操作得到hash0的位置。如果这个位置是EXIST状态,就需要进行线性探测。否则直接插入就好。然后让数据个数_DataNum自增就可以了。

但是也不是能够直接一上来就插入。还需要判断是否需要扩容。我们这里规定负载因子大于等于0.7就进行扩容操作。扩容后就会导致表的空间个数M改变,那么除法散列法的哈希函数也会跟着改变。也就是所有旧表中的数据都需要进行重新映射到新表中。

当然可以一个一个遍历原来的表,然后进行新的映射。走的逻辑就会下面部分的插入逻辑代码一样了。但是这样写代码太冗余了。既然逻辑一样就进行复用。所以开一个新的哈希表newTable,遍历旧表,将数据传给新表的Insert接口。最后交换两个表的vector就好了。数据个数大小不用交换。因为扩容的过程不会改变存储数据个数。

很多人会认为这是递归。注意这里不是。递归是自己调用自己。这里是自己的Insert接口调用自己的吗?很明显不是。这里是this->Insert()内调用newTable->Insert()。这是两个不同的类在调用Insert()接口。当然不是递归。这样子我们就完成了代码的复用,也完成了插入逻辑。

查找接口

直接来看代码:

HashData<K, V>* Find(const K& key) {Hash hash;size_t hashNum = hash(key);size_t hash0 = hashNum % Capacity();//线性探测size_t hashi = hash0;size_t i = 0;while (_table[hashi]._state != EMPTY) {//但是注意 这个时候key不知道传入的是什么,所以得自己支持重载 =//或者加一个仿函数(不方便改别人代码的情况下)//但是这里我的所有测试都是自己支持 重载的= if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key){//找到了return &(_table[hashi]);}++i;hashi = (hash0 + i) % Capacity();}return nullptr;
}

查找是根据关键字Key来找,也是按照哈希的思想查找。
先对Key转整形,再通过哈希函数获取位置。最后线性探测。

查找的时候,如果碰到空的,就说明找不到了。但是如果是碰到存在或者删除状态,那就不好说,因为不确定要找的数据是不是因为这些值给顶到后面部分去了。

查找的时候涉及一个问题,就是Key是否支持相等比较。我们在这里默认是支持的(也就是需要依靠类本身去重载operator==函数)。但是库里面是多一个模板参数的。也就是Equal,这是用来自行写支持Key的相等比较的仿函数。但是这里以了解原理为主,所以就不写这个仿函数了。需要自行对数据类型进行相等比较的重载。

还有一个就是我们前面说到的,这里的删除并不是真正意义上的删除。只不过是把哈希表那个位置的状态设定为DELETE。但是数据还是在表中的。所以不能单单认为Key和表中的关键字一样就认为是找到了,得判断一下状态。

删除接口
bool Erase(const K& key) {HashData<K, V>* ptr = Find(key);if (ptr == nullptr) {cout << "不存在可删除项" << endl;return false;}ptr->_state = DELETE;--_DataNum;return true;
}

删除操作就简单太多了。先判断一下是否能删除。如果有这个数据就将状态设置为DELETE就可以了,同时再让表中数据个数自减。这样子就完成了删除操作。

HashTable完整代码
enum Status {EMPTY,EXIST,DELETE
};template<class K, class V>
struct HashData {pair<K, V> _kv;//这个状态是必须存在的 重点放在博客上去讲解Status _state = EMPTY;
};template<class K>
struct HashFunc {size_t operator()(const K& key) {return (size_t)key;}
};template<>
struct HashFunc<string> {//由于字符串在hash内用的是很多的 直接特化一个版本//BKDR哈希算法size_t operator()(const string& str) {size_t hash = 0;for (auto ch : str) {hash += ch;hash *= 131;}return hash;}
};template<class K, class V, class Hash = HashFunc<K>>
class HashTable {
public://为了保证表的容量数尽量为一个整数,且每次扩容尽可能的达到二倍扩容//这里采用和c++STL库中一样的方式,列一个质数表inline unsigned long __stl_next_prime(unsigned long n){// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317, 196613, 393241, 786433,1572869, 3145739, 6291469, 12582917, 25165843,50331653, 100663319, 201326611, 402653189, 805306457,1610612741, 3221225473, 4294967291};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list +__stl_num_primes;const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}HashTable():_table(__stl_next_prime(0)),_DataNum(0){}//其它的构造template<class K, class V>HashTable(const initializer_list<pair<K, V>>& itl) {auto Iit = itl.begin();while (Iit != itl.end()) {Insert(*Iit);++Iit;}}//实现insertbool Insert(const pair<K, V>& kv) {//理解hash表插入的本质是什么——博客上写if (Find(kv.first)) return false;Hash hash;size_t hashNum = hash(kv.first);//如果负载因子(存储数据量/空间数)比较大的时候,是需要进行扩容的。if (_DataNum * 10 / Capacity() >= 7) {//扩容HashTable<K, V, Hash> newTable;  newTable._table.resize(__stl_next_prime(Capacity() + 1)); for (size_t i = 0; i < Capacity(); ++i) {newTable.Insert(_table[i]._kv);}_table.swap(newTable._table);}//取模散列size_t hash0 = (hashNum % Capacity());//可能会有hash冲突的问题 ——》线性探测size_t i = 1; size_t hashi = hash0;while (_table[hashi]._state == EXIST) {hashi = (hash0 + i) % Capacity();++i;}_table[hashi]._kv = kv;_table[hashi]._state = EXIST; ++_DataNum;return true;}HashData<K, V>* Find(const K& key) {Hash hash;size_t hashNum = hash(key);size_t hash0 = hashNum % Capacity();//线性探测size_t hashi = hash0;size_t i = 0;while (_table[hashi]._state != EMPTY) {//但是注意 这个时候key不知道传入的是什么,所以得自己支持重载 =//或者加一个仿函数(不方便改别人代码的情况下)//但是这里我的所有测试都是自己支持 重载的= if (_table[hashi]._state == EXIST && _table[hashi]._kv.first == key) {//找到了return &(_table[hashi]);}++i;hashi = (hash0 + i) % Capacity();}return nullptr;}bool Erase(const K& key) {HashData<K, V>* ptr = Find(key);if (ptr == nullptr) {cout << "不存在可删除项" << endl;return false;}ptr->_state = DELETE;--_DataNum;return true;}const size_t Size() const{return _DataNum;}const size_t Capacity() const {return _table.size();  }private:vector<HashData<K, V>> _table;size_t _DataNum = 0;
};

哈希桶的代码实现

上面是通过开放定址法来实现的。但是开放定址法无论怎么解决哈希冲突的问题,本质都是内卷的。因为总是会有映射到同一个位置的多个数据。一旦出现这个情况,必然要进行探测位置。这就是你挤我我挤我的感觉。实际应用中,更常用的是链地址法:

template<class K, class V>
struct HashNode {pair<K, V> _kv;HashNode<K, V>* _next;HashNode(const pair<K, V>& kv):_kv(kv),_next(nullptr){}
};template<class K>
struct HashFunc{size_t operator()(const K& key) {return (size_t)key;}
};template<>
struct HashFunc<string> {size_t operator()(const string& str) {size_t hash = 0;for (auto ch : str) {hash += ch;hash *= 131;}return hash;}
};template<class K, class V, class Hash = HashFunc<K>>
class HashTable {
public:using Node = HashNode<K, V>;inline unsigned long __stl_next_prime(unsigned long n){// Note: assumes long is at least 32 bits.static const int __stl_num_primes = 28;static const unsigned long __stl_prime_list[__stl_num_primes] ={53, 97, 193, 389, 769,1543, 3079, 6151, 12289, 24593,49157, 98317, 196613, 393241, 786433,1572869, 3145739, 6291469, 12582917, 25165843,50331653, 100663319, 201326611, 402653189, 805306457,1610612741, 3221225473, 4294967291};const unsigned long* first = __stl_prime_list;const unsigned long* last = __stl_prime_list +__stl_num_primes;const unsigned long* pos = lower_bound(first, last, n);return pos == last ? *(last - 1) : *pos;}HashTable() :_table(__stl_next_prime(0)), _NodeNum(0) {}  bool Insert(const pair<K, V>& kv) {if (Find(kv.first))return false;Hash hash;size_t hashNum = hash(kv.first);//负载因子为1的时候就进行扩容if (_NodeNum == Capacity()) {//扩容逻辑vector<Node*> newtables(__stl_next_prime(Capacity() + 1), nullptr);for (size_t i = 0; i < Capacity(); i++){Node* cur = _table[i];while (cur){Node* next = cur->_next;// 旧表中节点,挪动新表重新映射的位置size_t New_hashi = hash(cur->_kv.first) % newtables.size();// 头插到新表cur->_next = newtables[New_hashi];newtables[New_hashi] = cur;cur = next;}_table[i] = nullptr;}_table.swap(newtables);}size_t hashi = hashNum % Capacity();//头插Node* newnode = new Node(kv);newnode->_next = _table[hashi];_table[hashi] = newnode;++_NodeNum;return true;}Node* Find(const K& key) {Hash hash;size_t hashi = hash(key) % Capacity();Node* cur = _table[hashi];while (cur) {if (cur->_kv.first == key) return cur;cur = cur->_next;}return nullptr;}bool Erase(const K& key) {if (Find(key) == nullptr)return false;Hash hash;size_t hashi = hash(key) % Capacity();Node* prev = nullptr;Node* cur = _table[hashi];while (cur) {if (cur->_kv.first == key) {//删除操作if (prev == nullptr) //头删_table[hashi] = cur->_next;else prev->_next = cur->_next;delete cur;--_NodeNum;return true;}prev = cur;cur = cur->_next;}return false;}size_t Capacity() const {return _table.size();}size_t Size() const {return _NodeNum;}private:vector<Node*> _table;size_t _NodeNum = 0;
};

哈希桶的实现是非常简单的。只需要把vector中每个链表存储的数据改成节点的指针就可。
和前面的开放定址法的区别也就是在于增删查的操作不太一样而已。

我们稍微讲解一下就可以:

  1. 对于插入接口:
    使用了哈希桶的方法后,就不怕哈希冲突的问题了。直接插入到对应位置指向的链表就可以了。这里涉及一个问题:头插还是尾插?

为了实现插入到效率为O(1),必然是头插。头插是十分方便的。对于插入的时候的扩容逻辑,这个是需要讲一下:会发现代码实现中并没有复用插入的逻辑。而是遍历旧表一个个的映射到新表去。为什么?

因为如果是复用逻辑,那就又要开新节点。在堆上开辟空间,特别是在数据量比较大的情况下,是很影响效率的。开放定址法敢这么用是因为它们的数据存储在栈区中,栈区对这些操作是高效多得多。但是当前我们所有的节点都是在堆区上存储的。所以最好的办法就是将旧表中的节点一个个的根据新的映射方式,挂到新的表上。也就是上面写的逻辑。

  1. 对于查找接口:
    查找接口就很轻松了。找到对应映射的位置,对链表进行搜索就好了。也不用担心状态什么东西。直接比较Key是否相等就好。这也能看得出来哈希桶的效率是十分高的。

  2. 对于删除接口:
    删除也简单,先判断是否存在。不存在删除失败。
    若是存在,就找到那个位置,但是删除链表的节点的时候需要注意,头删和其他位置的删除是不一样的,所以需要进行特殊的判断处理。

最后再来返回看unordered_set和unordered_map的使用:
会发现每一点涉及到的都进行讲解过了。如为什么Key要自行支持转整形,为什么Key要支持相等比较。这些下面都讲过。然后只需要根据这些基础的支持和适当查阅文档就可以学会使用了。但是这两个容器和set和map的差别不是很大,特别是在用法这一块。所以会使用map和set就会使用这两个容器。

相关文章:

c++进阶——哈希表的实现

文章目录 哈希表的实现unordered_map和unordered_set哈希的引入散列的一些基本概念将Key转成整形和哈希函数哈希冲突负载因子 开放定址法和链地址法哈希函数的选取除法散列法/除留余数法乘法散列法全域散列法(了解)其他方法&#xff08;了解&#xff09; 针对于开放定址法的哈希…...

visual studio生成动态库DLL

visual studio生成动态库DLL 创建动态库工程 注意 #include “pch.h” 要放在上面 完成后点击生成 创建一个控制台项目 设置项目附加目录为刚才创建的动态库工程Dll1&#xff1a; 配置附加库目录&#xff1a; 配置动态库的导入库&#xff08;.lib&#xff09;&#xff1a;链…...

逆强化学习IRL在医疗行为模式研究中的应用

逆强化学习(Inverse Reinforcement Learning, IRL)通过从专家行为中推断潜在奖励函数,近年来在医疗领域的患者行为模式分析中展现出重要价值。 以下是相关研究的具体分析: 1. 脓毒症治疗策略优化 研究背景:脓毒症治疗依赖复杂的临床决策,但传统强化学习需预先定义奖励…...

niushop单商户V5多门店版V5.5.0全插件+商品称重、商家手机端+搭建环境教程

一.系统介绍 【全开源】niushop单商户V5多门店版V5.5.0版本&#xff0c;我看很多人都想要 商品称重、商家手机端等插件这套是全插件版本&#xff0c;整合起来本博主也花了不少啦~ Niushop系统是应用thinkphp6开发的完善的电商系统&#xff0c;拥有完善的商品机制&#xff0c;…...

Kafka Go客户端--Sarama

Kafka Go客户端 在Go中里面有三个比较有名气的Go客户端。 Sarama:用户数量最多&#xff0c;早期这个项目是在Shopify下面&#xff0c;现在挪到了IBM下。segmentio/kafka-go:没啥大的缺点。confluent-kafka-go&#xff1a;需要启用cgo,跨平台问题比较多&#xff0c;交叉编译也…...

Python打卡 DAY 24

知识点回顾&#xff1a; 1. 元组 2. 可迭代对象 3. os模块 作业&#xff1a;对自己电脑的不同文件夹利用今天学到的知识操作下&#xff0c;理解下os路径。 OS 模块 import os # os是系统内置模块&#xff0c;无需安装 获取当前工作目录 os.getcwd() # get current working…...

为什么hadoop不用Java的序列化?

Java的序列化是一个重量级序列化框架&#xff08;Serializable&#xff09;&#xff0c;一个对象被序列化后&#xff0c;会附带很多额外的信息&#xff08;各种校验信息&#xff0c;Header&#xff0c;继承体系等&#xff09;&#xff0c;不便于在网络中高效传输。所以&#xf…...

《类和对象(下)》

引言&#xff1a; 书接上回&#xff0c;如果说类和对象&#xff08;上&#xff09;是入门阶段&#xff0c;类和对象&#xff08;中&#xff09;是中间阶段&#xff0c;那么这次的类和对象&#xff08;下&#xff09;就可以当做类和对象的补充及收尾。 一&#xff1a;再探构造…...

基于STM32、HAL库的TLV320AIC3101IRHBR音频接口芯片驱动程序设计

一、简介: TLV320AIC3101IRHBR 是 Texas Instruments 推出的高性能、低功耗音频编解码器,专为便携式和电池供电设备设计。它集成了立体声 ADC、DAC、麦克风前置放大器、耳机放大器和数字信号处理功能,支持 I2S/PCM 音频接口和 I2C 控制接口,非常适合与 STM32 微控制器配合…...

EDR与XDR如何选择适合您的网络安全解决方案

1. 什么是EDR&#xff1f; 端点检测与响应&#xff08;EDR&#xff09; 专注于保护端点设备&#xff08;如电脑、服务器、移动设备&#xff09;。通过在端点安装代理软件&#xff0c;EDR实时监控设备活动&#xff0c;检测威胁并快速响应。 EDR核心功能 实时监控&#xff1a;…...

Vue2 elementUI 二次封装命令式表单弹框组件

需求&#xff1a;封装一个表单弹框组件&#xff0c;弹框和表单是两个组件&#xff0c;表单以插槽的形式动态传入弹框组件中。 使用的方式如下&#xff1a; 直接上代码&#xff1a; MyDialog.vue 弹框组件 <template><el-dialog:titletitle:visible.sync"dialo…...

jenkins流水线常规配置教程!

Jenkins流水线是在工作中实现CI/CD常用的工具。以下是一些我在工作和学习中总结出来常用的一些流水线配置&#xff1a;变量需要加双引号括起来 "${main}" 一 引用无账号的凭据 使用变量方式引用&#xff0c;这种方式只适合只由密码&#xff0c;没有用户名的凭证。例…...

设计模式系列(02):设计原则(一):SRP、OCP、LSP

本文为设计模式系列第2篇,聚焦面向对象设计的三大核心原则:单一职责、开放封闭、里氏替换,系统梳理定义、实际业务场景、优缺点、最佳实践与常见误区,适合系统学习与团队协作。 目录 1. 引言2. 单一职责原则(SRP)3. 开放封闭原则(OCP)4. 里氏替换原则(LSP)5. 常见误区…...

【日常】AI 工作流

AI 工作流 名称使用场景产品形态其他ChatGPT网页LLMGemini可以生成一份深度研究的文档并保存到Google Docs网页LLM白嫖了一年会员Kimi日常网页LLMDeepSeek深度思考网页LLMGrok3Deep Research 深度搜索网页LLMQwen3网页LLM元宝可免费使用DS的深度思考&#xff08;满血DS R1版&a…...

问题及解决02-处理后的图像在坐标轴外显示

一、问题 在使用matlab的appdesigner工具来设计界面&#xff0c;可以通过点击处理按钮来处理图像&#xff0c;并将处理后的图像显示在坐标轴上&#xff0c;但是图像超出了指定的坐标轴&#xff0c;即处理后的图像在坐标轴外显示。 问题图如下图所示。 原来的坐标轴如下图所…...

Spark基础介绍

Spark是一种基于内存的快速、通用、可拓展的大数据分析计算引擎。 起源阶段 Spark 最初是在 2009 年由加州大学伯克利分校的 AMP 实验室开发。当时&#xff0c;Hadoop 在大数据处理领域占据主导地位&#xff0c;但 MapReduce 在某些复杂计算场景下&#xff0c;如迭代计算和交互…...

Oracles数据库通过存储过程调用飞书接口推送群组消息

在Oracle数据库中,可以通过存储过程调用外部接口来实现推送消息的功能。以下是一个示例,展示如何通过存储过程调用飞书接口推送群组消息。 创建存储过程 首先,创建一个存储过程,用于调用飞书接口。该存储过程使用UTL_HTTP包来发送HTTP请求。 CREATE OR REPLACE PROCEDUR…...

ubuntu22.04编译PX4无人机仿真实践

克隆PX4源码,并且更新子模块 git clone https://github.com/PX4/PX4-Autopilot.git --recursive git submodule update --init --recursive # 强制同步所有子模块 接着安装相关依赖: bash ./PX4-Autopilot/Tools/setup/ubuntu.sh 运行以下命令进行编译: cd ~/PX4-Autop…...

MySQL基础入门:MySQL简介与环境搭建

引言 在数字化转型浪潮中&#xff0c;MySQL作为数据存储的"基石引擎"&#xff0c;支撑着从电商交易到金融风控的各类核心业务。其高并发处理能力、灵活的架构设计及跨平台兼容性&#xff0c;使其成为开发者技术栈中的"常青树"。本章节将通过历史溯源、技术…...

无人机避障——(运动规划部分)深蓝学院动力学kinodynamic A* 3D算法理论解读(附C++代码)

开源代码链接&#xff1a;GitHub - Perishell/motion-planning 效果展示&#xff1a; ROS 节点展示全局规划和轨迹生成部分&#xff1a; Kinodynamic A*代码主体&#xff1a; int KinoAstar::search(Eigen::Vector3d start_pt, Eigen::Vector3d start_vel,Eigen::Vector3d en…...

电脑声音小怎么调大 查看声音调整方法

电脑是我们工作学习经常需要用到的工具&#xff0c;同时电脑也可以播放音乐、视频、游戏等&#xff0c;享受声音的效果。但是&#xff0c;有些电脑的声音很小&#xff0c;即使把音量调到最大&#xff0c;也听不清楚&#xff0c;这让我们很苦恼。那么&#xff0c;电脑声音小怎么…...

无人机信号监测系统技术解析

一、模块技术要点 1. 天线阵列与信号接收模块 多频段自适应切换&#xff1a;采用天线阵列模块&#xff0c;根据复杂地形和不同频段自动切换合适的天线&#xff0c;提升信号接收灵敏度。 双天线测向技术&#xff1a;通过双天线的RSSI&#xff08;信号接收强度&#xff09;差值…...

Excel的详细使用指南

### **一、Excel基础操作** #### **1. 界面与基本概念** - **工作簿&#xff08;Workbook&#xff09;**&#xff1a;一个Excel文件&#xff08;扩展名.xlsx&#xff09;。 - **工作表&#xff08;Worksheet&#xff09;**&#xff1a;工作簿中的单个表格&#xff08;默认名…...

基于SSM实现的健身房系统功能实现十六

一、前言介绍&#xff1a; 1.1 项目摘要 随着社会的快速发展和人们健康意识的不断提升&#xff0c;健身行业也在迅速扩展。越来越多的人加入到健身行列&#xff0c;健身房的数量也在不断增加。这种趋势使得健身房的管理变得越来越复杂&#xff0c;传统的手工或部分自动化的管…...

序列化和反序列化(hadoop)

1.先将上一个博客的Student复制粘贴后面加上H 在StudentH中敲下面代码 package com.example.sei; import org.apache.hadoop.io.Writable; import java.io.DataInput; import java.io.DataOutput; import java.io.IOException; //学生类&#xff0c;姓名&#xff0c;年龄 //支…...

大模型MCP_MCP从流式SSE到流式HTTP_1.8.0支持流式HTTP交互_介绍_从应用到最优--人工智能工作笔记0245

从最开始的大模型时代,到现在MCP,大模型技术,人工智能技术迭代真的非常快 之前的大模型更像一个大脑,能帮大家出点子,然后告诉你思路,你去解决问题,但是 一直不能自己解决问题,后来出来了通用的manus智能体,声称可以解决很多问题.直接操作 一个自带的电脑,但是也有局限性,还…...

docker大镜像优化实战

在 Docker 镜像优化方面&#xff0c;有许多实战技巧可以显著减小镜像体积、提高构建效率和运行时性能。以下是一些实用的优化策略和具体操作方法&#xff1a; 1. 选择合适的基础镜像 策略 使用 Alpine 版本&#xff1a;Alpine 镜像通常只有 5-10MB&#xff0c;比 Ubuntu/Deb…...

【25软考网工】第六章(5)应用层安全协议

博客主页&#xff1a;christine-rr-CSDN博客 ​​专栏主页&#xff1a;软考中级网络工程师笔记 ​​​ 大家好&#xff0c;我是christine-rr !目前《软考中级网络工程师》专栏已经更新三十篇文章了&#xff0c;每篇笔记都包含详细的知识点&#xff0c;希望能帮助到你&#xff…...

RevIN(Reversible Instance Normalization)及其在时间序列中的应用

详细介绍 RevIN&#xff08;Reversible Instance Normalization&#xff09;及其在时间序列中的应用 1. RevIN 的定义与背景 RevIN&#xff08;可逆实例归一化&#xff09;是一种专门为时间序列预测设计的归一化方法&#xff0c;旨在处理非平稳数据&#xff08;non-stationar…...

JSON 和 cJSON 库入门教程

第一部分&#xff1a;了解 JSON (JavaScript Object Notation) 什么是 JSON&#xff1f; JSON 是一种轻量级的数据交换格式。它易于人阅读和编写&#xff0c;同时也易于机器解析和生成。 JSON 基于 JavaScript 编程语言的一个子集&#xff0c;但它是一种独立于语言的文本格式…...

Unity 2D 行走动画示例工程手动构建教程-AI变成配额前端UI-完美游戏开发流程

&#x1f3ae; Unity 2D 行走动画示例工程手动构建教程 ✅ 1. 新建 Unity 项目 打开 Unity Hub&#xff1a; 创建一个新项目&#xff0c;模板选择&#xff1a;2D Core项目名&#xff1a;WalkAnimationDemo ✅ 2. 创建文件夹结构 在 Assets/ 目录下新建以下文件夹&#xff1a…...

[Java][Leetcode middle] 45. 跳跃游戏 II

这题没做出来&#xff0c;看的答案解析 可以理解为希望采用最少得跳槽次数跳到最高级别的公司。 下标i为公司本身的职级&#xff0c;每个公司可以提供本身等级nums[i]的职级提升。 每次从这些选择中选择自己能够达到最大职级的公司跳槽。 public int jump(int[] nums) {if(nu…...

leetcode 3335. 字符串转换后的长度 I

给你一个字符串 s 和一个整数 t&#xff0c;表示要执行的 转换 次数。每次 转换 需要根据以下规则替换字符串 s 中的每个字符&#xff1a; 如果字符是 z&#xff0c;则将其替换为字符串 "ab"。否则&#xff0c;将其替换为字母表中的下一个字符。例如&#xff0c;a 替…...

Leetcode 3542. Minimum Operations to Convert All Elements to Zero

Leetcode 3542. Minimum Operations to Convert All Elements to Zero 1. 解题思路2. 代码实现 题目链接&#xff1a;3542. Minimum Operations to Convert All Elements to Zero 1. 解题思路 这一题的处理方法其实还是挺好想明白的&#xff0c;其实就是从小到大依次处理各个…...

如何使用C51的Timer0实现定时功能

在C51单片机中&#xff0c;使用定时器0&#xff08;Timer0&#xff09;实现定时功能需要以下步骤&#xff1a; 1. 定时器基础知识 时钟源&#xff1a;C51的定时器时钟来源于晶振&#xff08;如12MHz&#xff09;。机器周期&#xff1a;1个机器周期 12个时钟周期&#xff08;1…...

Day1 时间复杂度

一 概念 在 C 中&#xff0c;时间复杂度是衡量算法运行时间随输入规模增长的趋势的关键指标&#xff0c;用于评估算法的效率。它通过 大 O 表示法&#xff08;Big O Notation&#xff09; 描述&#xff0c;关注的是输入规模 n 趋近于无穷大时&#xff0c;算法时间增长的主导因…...

PostgreSQL 配置设置函数

PostgreSQL 配置设置函数 PostgreSQL 提供了一组配置设置函数&#xff08;Configuration Settings Functions&#xff09;&#xff0c;用于查询和修改数据库服务器的运行时配置参数。这些函数为数据库管理员提供了动态管理数据库配置的能力&#xff0c;无需重启数据库服务。 …...

美学心得(第二百七十六集) 罗国正

美学心得&#xff08;第二百七十六集&#xff09; 罗国正 &#xff08;2025年4月&#xff09; 3275、人类将迎来真、善、美快速发展的时期&#xff0c;人‐机合一的天人合一&#xff08;可简称为“天人机合一”&#xff09;的境界已渐露头角&#xff0c;在优秀的人群中迅猛地…...

描述性统计工具 - AxureMost 落葵网

描述性统计工具是用于汇总和分析数据&#xff0c;以更好地了解数据特征的工具1。以下是一些常见的描述性统计工具简介&#xff1a; 描述性统计工具 Excel 基本统计函数&#xff1a;提供了丰富的函数用于计算描述性统计量。例如&#xff0c;AVERAGE 函数用于计算平均值&#xf…...

mybatis中${}和#{}的区别

先测试&#xff0c;再说结论 userService.selectStudentByClssIds(10000, "wzh or 11");List<StudentEntity> selectStudentByClssIds(Param("stuId") int stuId, Param("field") String field);<select id"selectStudentByClssI…...

【OpenCV】网络模型推理的简单流程分析(readNetFromONNX、setInput和forward等)

目录 1.模型读取&#xff08;readNetFromONNX()&#xff09;1.1 初始化解析函数&#xff08;parseOperatorSet()&#xff09;1.2 提取张量&#xff08;getGraphTensors()&#xff09;1.3 节点处理&#xff08;handleNode()&#xff09; 2.数据准备&#xff08;blobFromImage() …...

代码随想录算法训练营第三十九天

LeetCode题目: 115. 不同的子序列583. 两个字符串的删除操作72. 编辑距离 其他: 今日总结 往期打卡 115. 不同的子序列 跳转: 115. 不同的子序列 学习: 代码随想录公开讲解 问题: 给你两个字符串 s 和 t &#xff0c;统计并返回在 s 的 子序列 中 t 出现的个数。 测试用例保…...

InternVL3: 利用AI处理文本、图像、视频、OCR和数据分析

InternVL3推动了视觉-语言理解、推理和感知的边界。 在其前身InternVL 2.5的基础上,这个新版本引入了工具使用、GUI代理操作、3D视觉和工业图像分析方面的突破性能力。 让我们来分析一下是什么让InternVL3成为游戏规则的改变者 — 以及今天你如何开始尝试使用它。 InternVL…...

力扣刷题Day 48:盛最多水的容器(283)

1.题目描述 2.思路 学习了Krahets佬的双指针思路&#xff0c;初始化两个边界作为容器边界&#xff0c;然后逐个向数组内遍历&#xff0c;直到左右两指针相遇。 3.代码&#xff08;Python3&#xff09; class Solution:def maxArea(self, height: List[int]) -> int:left,…...

基于单应性矩阵变换的图像拼接融合

单应性矩阵变换 单应性矩阵是一个 3x3 的可逆矩阵&#xff0c;它描述了两个平面之间的投影变换关系。在图像领域&#xff0c;单应性矩阵可以将一幅图像中的点映射到另一幅图像中的对应点&#xff0c;前提是这两幅图像是从不同视角拍摄的同一平面场景。 常见的应用场景&#x…...

《驱动开发硬核特训 · 专题篇》:深入理解 I2C 子系统

关键词&#xff1a;i2c_adapter、i2c_client、i2c_driver、i2c-core、platform_driver、设备树匹配、驱动模型 本文目标&#xff1a;通过实际代码一步步讲清楚 I2C 子系统的结构与运行机制&#xff0c;让你不再混淆 platform_driver 与 i2c_driver 的职责。 &#x1f9e9; 一、…...

Spark缓存-cache

一、RDD持久化 1.什么时候该使用持久化&#xff08;缓存&#xff09; 2. RDD cache & persist 缓存 3. RDD CheckPoint 检查点 4. cache & persist & checkpoint 的特点和区别 特点 区别 二、cache & persist 的持久化级别及策略选择 Spark的几种持久化…...

tails os系统详解

一、起源与发展背景 1. 项目初衷与历史 创立时间&#xff1a;Tails 项目始于 2004 年&#xff0c;最初名为 “Anonymous Live CD”&#xff0c;2009 年正式更名为 “Tails”&#xff08;The Amnesic Incognito Live System&#xff0c;“健忘的匿名实时系统”&#xff09;。核…...

[洛谷刷题9]

P2376 [USACO09OCT] Allowance G&#xff08;贪心&#xff09; https://www.luogu.com.cn/problem/P2376 题目描述 作为创造产奶纪录的回报&#xff0c;Farmer John 决定开始每个星期给Bessie 一点零花钱。 FJ 有一些硬币&#xff0c;一共有 N ( 1 ≤ N ≤ 20 ) N (1 \le …...

数据挖掘入门-二手车交易价格预测

一、二手车交易价格预测 1-1 项目背景 随着二手车市场的快速发展&#xff0c;二手车交易价格的预测成为了一个热门研究领域。精准的价格预测不仅能帮助买卖双方做出更明智的决策&#xff0c;还能促进市场的透明度和公平性。对于买家来说&#xff0c;了解合理的市场价格可以避免…...