【C++】哈希/散列详细解析

前言:上篇文章介绍了unordered_set和unordered_map序列关联式容器,它们之所以效率比较高,是因为其底层使用了哈希结构。,所以这篇文章我们就来详细讲解一下哈希表。有关unordered序列关联式容器的知识,请移步至这篇文章:unordered_map与unordered_set(系列关联式容器)

文章目录

  • 1.哈希概念
  • 2.哈希冲突/碰撞
  • 3.哈希函数
  • 4.解决哈希冲突
    • 4.1闭散列(开放定址法)
      • 4.1.1线性探测
      • 4.1.2负载因子
      • 4.1.3二次探测
    • 4.2开散列(哈希桶,拉链法)
      • 4.2.1开散列的概念
      • 4.2.2开散列的规则与剖析
  • 5.哈希表闭散列的实现
    • 5.1闭散列的结构
    • 5.2闭散列的插入
    • 5.3闭散列的查找
    • 5.4闭散列的删除
  • 6.哈希表开散列的实现
    • 6.1开散列的结构
    • 6.2开散列的插入
    • 6.3开散列的查找
    • 6.4开散列的删除
  • 7.如何解决string类型的哈希映射问题


1.哈希概念

顺序结构以及平衡树中,元素关键码与其存储位置之间没有对应的关系,因此在查找一个元素时,必须要经过多次比较关键码,搜索效率取决于搜索过程中元素的比较次数,因此顺序结构中查找的时间复杂度为O(N),平衡树中查找的时间复杂度为O(logN)

最理想的搜索方法是:可以不经过任何比较,一次直接从表中得到想要搜索的元素,即查找的时间复杂度为O(1)

这种理想的搜索方法是存在的,如果构造一种存储结构,该结构能够通过某种函数使元素的存储位置与它的关键码之间建立一一映射的关系,那么在查找时就能通过该函数很快找到该元素。

当向该结构中:

  • 插入元素
    根据待插入元素的关键码,以此函数计算出该元素的存储位置并按此位置进行存放。
  • 搜索元素
    对元素的关键码进行同样的计算,把求得的函数值当做元素的存储位置,在结构中按此位置取元素比较,若关键码相等,则搜索成功。

该方式即为哈希(散列)方法,哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表或者称散列表(Hash Table)

以上都是一些晦涩难懂的学术语言,接下来我们用一个例子来解释什么是哈希。

例如:数据集合{1,7,6,4,5,9};
哈希函数设置为:hash(key) = key % capacity; capacity为存储元素底层空间总的大小。若我们将该集合存储在capacity为10的哈希表中,则各元素存储位置对应如下:
在这里插入图片描述
用该方法进行搜索直接使用哈希函数就可以定位元素下标,不必进行多次关键码的比较,因此搜索的速度比较快,时间复杂度为O(1)。

2.哈希冲突/碰撞

不同关键字通过相同哈希函数计算出相同的哈希地址,这种现象称为哈希冲突或哈希碰撞,我们把具有不同关键码而具有相同哈希地址的数据元素称为“同义词”。

例如,在上述例子中,再将元素19插入,就会产生哈希冲突,因为元素19通过哈希函数得到的哈希地址与元素9相同,%10后都是下标为9的位置。
在这里插入图片描述

那么发生哈希冲突该如何处理呢?

3.哈希函数

引起哈希冲突的一个原因可能是:哈希函数设计不够合理。
哈希函数设计原则

  • 哈希函数的定义域必须包括需要存储的全部关键码,而如果散列表允许有m个地址时,其值域必须在0到m-1之间
  • 哈希函数计算出来的地址能均匀分布在整个空间中
  • 哈希函数应该比较简单
  • 注意:哈希函数设计的越精妙,产生哈希冲突的可能性就越低,但是无法避免哈希冲突

常见的哈希函数:

1.直接定址法(常用)
取关键字的某个线性函数为散列地址:Hash(key)=A*key+B

优点:简单,均匀
缺点:需要事先知道关键字的分布情况,通常要求数据是整数,范围比较集中。
使用场景:适合查找比较小且连续的情况(范围集中)
2. 除留余数法(常用)
设散列表中允许的地址数为m,取一个不大于m,但最接近或者等于m的质数p作为除数。

按照哈希函数:Hash(Key) = Key % p ( p <= m ) 将关键码转换成哈希地址。

优点:使用场景广泛,不受限制。
缺点:存在哈希冲突,需要解决哈希冲突,冲突多,效率会有所下降。

3.平方取中法(了解)
假设关键字为1234,对它平方就是1522756,抽取中间的3位227作为哈希地址;
再比如关键字为4321,对它平方就是18671041,抽取中间的3位671(或710)作为哈希地址。

平方取中法比较适合:不知道关键字的分布,而位数又不是很大的情况

四、折叠法(了解)
折叠法是将关键字从左到右分割成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按哈希表表长,取后几位作为哈希地址。

使用场景:折叠法适合事先不需要知道关键字的分布,或关键字位数比较多的情况。

五、随机数法(了解)
选择一个随机函数,取关键字的随机函数值为它的哈希地址,即 Hash(Key)=random(Key),其中random为随机数函数。

使用场景:通常应用于关键字长度不等时。

六、数字分析法(了解)
设有n个d位数,每一位可能有r种不同的符号,这r中不同的符号在各位上出现的频率不一定相同,可能在某些位上分布比较均匀,每种符号出现的机会均等,而在某些位上分布不均匀,只有几种符号经常出现。此时,我们可根据哈希表的大小,选择其中各种符号分布均匀的若干位作为哈希地址。

假设要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是 相同的,那么我们可以选择后面的四位作为散列地址,如果这样的抽取工作还容易出现 冲突,还可以对抽取出来的数字进行反转(如1234改成4321)、右环位移(如1234改成4123)、左环移位、前两数与后两数叠加(如1234改成12+34=46)等方法。

使用场景:数字分析法通常适合处理关键字位数比较大的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀的情况

4.解决哈希冲突

解决哈希冲突两种常见的方法是:闭散列和开散列

4.1闭散列(开放定址法)

闭散列:也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。那如何寻找下一个空位置呢?有两种方法:线性探测和二次探测。

4.1.1线性探测

线性探测:从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。

哈希函数:hashi = key % tablesize;

举个栗子:我们用除留余数法将序列{1,4,5,6,7,44,9}插入到表长为10的哈希表中,当发生哈希冲突时我们采用闭散列的线性探测找到下一个空位置进行插入,插入过程如下:
在这里插入图片描述

4.1.2负载因子

随着哈希表中数据的增多,产生哈希冲突的可能性也会随着增加,比如最后在44进行插入的时候连续出现了四次哈希冲突。因此,哈希表当中引入了负载因子(载荷因子)

α(负载因子)=填入表中的元素个数 / 散列表的长度

  • 负载因子越大,冲突的概率越高,查找效率越低,空间利用率越高
  • 负载因子越小,冲突的概率越低,查找效率越高,空间利用率越低

α是散列表装满程度的标志因子。由于表长是定值,a与“填入表中的元素个数”成正比,所以,a越大,表明填入表中的元素越多,产生冲突的可能性就越大,反之,α越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子α的函数,只是不同处理冲突的方法有不同的函数。对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升,因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表

总结
线性探测优点:实现非常简单,

线性探测缺点:一旦发生哈希冲突,所有的冲突连在一起,容易产生数据“堆积”,即:不同关键码占据了可利用的空位置,使得寻找某关键码的位置需要许多次比较,导致搜索效率降低。那么如何缓解呢?下面我们引出二次探测。

4.1.3二次探测

线性探测的缺陷是产生冲突的数据堆积在一块,这与其找下一个空位置有关系,因为找空位置的方式就是挨着往后逐个去找,因此二次探测为了避免该问题,找下一个空位置的方法为:start+i^2(加0,加1,加4,加9)。

相比线性探测而言,采用二次探测的哈希表中元素的分布会相对稀疏一些,不容易导致数据堆积。

4.2开散列(哈希桶,拉链法)

4.2.1开散列的概念

开散列法又叫链地址法(开链法),首先对关键码集合用散列函数计算散列地址,具有相同地址的关键码归于同一子集合,每一个子集合称为一个桶,各个桶中的元素通过一个单链表链接起来,各链表的头结点存储在哈希表中。

例如,我们用除留余数法将序列{1, 4, 44, 5, 6, 7, 9}插入到表长为10的哈希表中,当发生哈希冲突时我们采用开散列的形式,将哈希地址相同的元素都链接到同一个哈希桶下,插入过程如下:
在这里插入图片描述
从上图可以看出,开散列中每个桶中放的都是发生哈希冲突的元素。

4.2.2开散列的规则与剖析

开散列的主要思想:数组+链表,以上图为例,假设哈希表表长为10,即数组可以存储10个元素,数组的每个空间就相当于一个桶,上述例子就有10个哈希桶。

哈希桶中装的是单链表,准确来讲,每个哈希桶中存储的是单链表头结点的地址,所以开散列解决哈希冲突方式是将冲突元素挂到桶中的单链表中。

  • 开散列负载因子的要求:
    闭散列的开放定址法,建议控制在[0.0, 0.7]之间。
    开散列的哈希桶,负载因子可以超过1,一般建议控制在[0.0, 1.0]之间。

哈希桶的极端情况:所有元素全部产生冲突,最终都放到了同一个哈希桶中,此时该哈希表增删查改的效率就退化成了O(N)。

解决方法:将这个桶中的元素,由单链表结构改为红黑树结构,并将红黑树的根结点存储在哈希表中。在这种情况下,如果有十亿个元素全部冲突到一个哈希桶中,我们也只需要在这个哈希桶中查找30次左右,这就是所谓的“桶里种树”。

5.哈希表闭散列的实现

5.1闭散列的结构

定义数据的存储结构:这里将其命名为HashData:

    //枚举数据的三种状态:存在,删除,空
	enum State
	{
		EMPTY,
		EXIST,
		DELETE
	};

	//哈希表每个位置存储的数据结构
	template<class K, class V>
	struct HashData
	{
		pair<K, V> _kv;
		State _state = EMPTY; //默认状态为空
	};

定义哈希表结构:哈希表的底层是一个线性数组,所以我们的成员变量有vector<HashData<K, V>> _tables,同时我们也要设置一个变量为_n,用于记录哈希表中的有效元素个数,这是用来计算哈希表的负载因子,当负载因子过大,就需要进行扩容。

//哈希表
template<class K, class V>
class HashTable
{
public:
	//...
private:
	vector<HashData<K, V>> _tables; //哈希表
	size_t _n = 0; //哈希表中的有效元素个数
};

5.2闭散列的插入

步骤:

  1. 复用查找函数,查看哈希表中是否已存在该数据,若存在则插入失败。
  2. 计算负载因子,若超过0.7,就对哈希表的大小进行调整。
  3. 将数据插入哈希表。

如何对哈希表的大小进行调整?

  • 若哈希表的大小为0(初始状态),就将哈希表的大小先扩到10。(resize)
  • 若哈希表的负载因子大于0.7,则新创建一个哈希表,采用二倍扩容的方式,再遍历旧表,将原哈希表的数据插入到新的哈希表中。最后交换新旧哈希表。旧表在程序结束值,vector会自动调用其析构函数将其空间释放,所以不用担心这块的内存泄漏。

注意:将旧表的数据插入到新哈希表,不是单纯的照搬旧表的元素所在的位置进行插入,而是需要根据新的哈希表的大小重新计算每个数据在新表的位置,然后再进行插入。这里可以复用哈希表的插入函数。

在闭散列中,若出现哈希冲突,则从映射的哈希地址处开始,线性探测向后寻找状态为EMPTY或DELETE的位置,所以循环条件为_tables[index]._state == EXIST,当为EMPTY或DELETE就可以跳出循环,进行数据插入了。

bool Insert(const pair<K, V> kv)
{
	//插入前先查找一番,如果数据存在就不插入了(哈希表不允许键值冗余)
	HashData<K, V>* ret = Find(kv.first);
	if (ret)
	{
		return false;
	}
	//引入负载因子,超过0.7就扩容
	if (_tables.size() == 0 || _n * 10 / _tables.size() >= 7)
	{
		//哈希表若大小为0,一开始就开10个空间
		//若负载因子达到0.7,就二倍扩容
	   	size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
		//_tables.resize(newsize);//err.不能在原表上进行扩容,会造成数据覆盖

		HashTable<K, V> newht;//定义一个新的哈希表
		newht._tables.resize(newsize);//扩容

		//将旧表的数据转移到新表有两种方法:
		
		//遍历旧表,重新映射到新表
		for (auto& data : _tables)
		{
			//法二:复用插入函数:如果旧表的数据存在,就插入到新表
			if (data._state == EXIST)
			{
				newht.Insert(data._kv);
			}

			//法一
			//if (data._state == EXIST)
			//{
			//	//重新算在新表的位置
			//	size_t hashi = data.first % newtables.size();

			//	//线性探测
			//	size_t i = 1;
			//	size_t index = hashi;
			//	//当前位置存在数据时,就需要线性探测后面的位置
			//	while (newtables[index]._state == EXIST)
			//	{
			//		index = hashi + i;//在原位置上加i,二次探测的话+i*i
			//		index %= newtables.size();
			//		++i;
			//	}
			//	newtables[index]._kv = kv;
			//	newtables[index]._state = EXIST;
			//}
		}
		_tables.swap(newht._tables);//交换新旧表
	}
	//除留余数法:计算映射的哈希地址
	size_t hashi = kv.first%_tables.size();

	//线性探测
	size_t i = 1;
	size_t index = hashi;
	//当前位置存在数据时,就需要线性探测下面的位置
	while (_tables[index]._state == EXIST)
	{
		index = hashi + i;//在原位置上加i,二次探测的话+i*i
		index %= _tables.size();
		++i;
	}

	//找到空位置或者删除状态插入数据,并把状态置为EXIST
	_tables[index]._kv = kv;
	_tables[index]._state = EXIST;
	_n++;

	return true;
}

5.3闭散列的查找

步骤

  1. 先判断哈希表的大小是否为0,若为0则查找失败。
  2. 通过哈希函数计算出对应的哈希地址。
  3. 从哈希地址处开始,采用线性探测向后向后进行数据的查找,直到找到待查找的元素判定为查找成功,或找到一个状态为EMPTY的位置判定为查找失败。

注意:查找成功的条件一定是该元素和key值匹配,并且状态为EXIST,若key值匹配,但是为DELETE状态,还需要继续向后线性探测,因为DELETE表明该元素已经删除了。

HashData<K, V>* Find(const K& key)
{
	if (_tables.size() == 0) //哈希表大小为0,查找失败,防止除0错误
	{
		return nullptr;
	}

	//我要找key,我就要先映射key在哈希表中所在的位置
	size_t hashi = key % _tables.size();

	size_t i = 1;
	size_t index = hashi;
	while (_tables[index]._state != EMPTY)
	{
		if (_tables[index]._kv.first == key && _tables[index]._state == EXIST)
		{
			return &_tables[index];
		}
		index = hashi + i;
		index = index % _tables.size();
		++i;
		//找了一圈没找到,可能当前哈希表全是存在+删除的情况
		//找不到就跳出循环
		if (index == hashi)
		{
			break;
		}
	}
	return nullptr;
}

5.4闭散列的删除

删除哈希表中的元素非常简单,我们只需要进行伪删除即可,也就是将待删除元素所在位置的状态设置为DELETE。

//删除函数
bool Erase(const K& key)
{
	//1、查看哈希表中是否存数据
	HashData<K, V>* ret = Find(key);
	if (ret)
	{
		//2、若存在,则将该键值对所在位置的状态改为DELETE
		ret->_state = DELETE;
		//3、哈希表中的有效元素个数减一
		_n--;
		return true;
	}
	return false;
}

6.哈希表开散列的实现

6.1开散列的结构

定义数据的存储结构

//每个哈希桶中存储数据的结构
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)//一开始初始化为空
	{}
};

定义哈希表结构:利用了vector存储单链表头结点的地址,定义了_n记录有效元素个数(用于计算负载因子)。开散列不需要我们手动实现一个构造函数,因为系统会自动调vector的构造函数。

注意:开散列需要实现析构函数:程序结束时,vector会自动释放_tables中存储的结点,但是!!!,并不会自动释放挂在头结点下面的结点(单链表),所以我们要自己实现一个析构函数,取释放单链表所用的空间。

template <class K, class V>
class HashTable
{
	typedef HashNode<K, V> Node;
public:
	//析构函数
	~HashTable()
	{
		//遍历哈希表“横向”
		for (auto& cur : _tables)
		{
			//“纵向”遍历哈希桶
			//如果cur为空就说明当前位置没有结点了
			while (cur)
			{
				Node* next = cur->_next;
				delete cur;
				cur = next;
			}
			cur = nullptr;
		}
	}
	//...
private:
	vector<Node*> _tables;//存储数据的类型是Node*哦
	size_t _n;//依然要考虑负载因子扩容的问题,_n(表示存储的有效数据)
};

6.2开散列的插入

步骤

  1. 查看哈希表中是否已经存在这个数据,若存在则插入失败。
  2. 判断是否需要调整哈希表的大小,若为0,则扩容到10。若负载因子为1,则进行二倍扩容。
  3. 将数据插入哈希表。(这里采用的是头插,这样就不用再遍历一遍链表,插入到尾部了)

特别注意:遍历原表,将原表数据插入到新哈希表的过程中,不要通过复用插入函数,因为复用插入函数的过程中,我们需要创建相同数据的结点插入到新哈希表中,并且还要释放原表的数据。实际上,我们只需要遍历原表的每个哈希桶,通过哈希函数找到对应数据,然后将原数据挪动到新表中即可,这样就不用再进行结点的创建与释放了。(可以直接挪动原表数据的方法是:利用引用&)

bool Insert(const pair<K, V>& kv)
{
	if (Find(kv.first))
	{
		return false;
	}
	//当负载因子为1时,对哈希表进行扩容
	if (_n == _tables.size())
	{
		size_t newsize = _tables.size() == 0 ? 10 : _tables.size() * 2;
		vector<Node*> newtable(newsize, nullptr);

		//将原表的数据【挪动】到新表中,注意不是重新new结点,重新插入一遍

		//遍历每个桶所存储的链表的头结点
		for (auto& cur : _tables)
		{
			//遍历某个桶中的单链表
			while (cur)
			{
				Node* next = cur->_next;

				size_t hashi = cur->_kv.first%newtable.size();

				//头插
				cur->_next = newtable[hashi];
				newtable[hashi] = cur;

				cur = next;
			}
		}
		_tables.swap(newtable);
	}
	//头插
	size_t hashi = kv.first%_tables.size();
	Node* newnode = new Node(kv);
	newnode->_next = _tables[hashi];
	_tables[hashi] = newnode;
	++_n;

	return true;
}

6.3开散列的查找

//查找函数
Node* Find(const K& key)
{
	if (_table.size() == 0) //哈希表大小为0,查找失败,也防止了%0错误
	{
		return nullptr;
	}

	size_t index = key % _table.size(); //通过哈希函数计算出对应的哈希桶编号index(除数不能是capacity)
	//遍历下标为index的哈希桶
	Node* cur = _table[index];
	while (cur) //直到将该桶遍历完为止
	{
		if (cur->_kv.first == key) //key值匹配,则查找成功
		{
			return cur;
		}
		cur = cur->_next;
	}
	return nullptr; //直到该桶全部遍历完毕还没有找到目标元素,查找失败
}

6.4开散列的删除

步骤

  1. 通过哈希函数计算出对应的哈希桶下标。
  2. 遍历对应的哈希桶,寻找待删除结点。
  3. 若找到了待删除结点,则将该结点从单链表中移除并释放。(注意:分为头删和其它位置的删除)
bool Erase(const K& key)
{
	//1、通过哈希函数计算出对应的哈希桶编号index(除数不能是capacity)
	size_t index = key % _table.size();
	//2、在下标为index的哈希桶中寻找待删除结点
	Node* prev = nullptr;
	Node* cur = _table[index];
	while (cur) 
	{
		if (cur->_kv.first == key) //key值匹配,找到要删除的结点
		{
			if (prev == nullptr) //头删
			{
				//直接将头结点置为该删除结点的下一个结点
				_table[index] = cur->_next; 
			}
			else //待删除结点不是哈希桶的第一个结点
			{
				prev->_next = cur->_next; //将该结点从哈希桶中移除
			}
			delete cur; //释放该结点
			_n--;
			return true; 
		}
		prev = cur;
		cur = cur->_next;
	}
	return false; //直到该桶全部遍历完毕还没有找到待删除元素,删除失败
}

7.如何解决string类型的哈希映射问题

在上面的讲述中,我们都是以在哈希表中存储整数为例,那如果我们要让字符串作为键值key,映射到哈希表中进行存储,该怎么实现呢?

取字符串的首字符(ASCII码)进行映射可以吗?类似这样:

size_t hashi = cur->_kv.first[0] % newtables.size();

答案是:不可以。因为若我们取字符串的首元素计算哈希地址,这种代码满足了字符串作键值的情况,那么整数怎么办呢?我们要同时考虑到整型和字符串,这也是泛型编程的思想。并且这种解决方法,是将字符串的首元素映射到哈希表中,会存在大量的哈希冲突,非常不建议。

解决方法:将字符串转换成整型。哈希映射的关键思想就是取模,所以键值key需要能被取模,一般来说,哈希的键值key都是整型或字符串,因此我们这里利用了仿函数的特性,将键值key转换成size_t类型。如果key是string类型就会去调用HashFunc<string这个仿函数,如果是其它类型就会去调用默认的仿函数,将key值转换成无符号整数类型。

template<class K>
//将key键值转换成整型——仿函数
struct HashFunc
{
	size_t operator()(const K& key)
	{
		return key;
	}
};

//string类型转换成整型——模板特化
template<>
struct HashFunc<string>
{
	//BKDR
	size_t operator()(const string& s)
	{
		//不能将字符串每个字符的ASCII码值求和作为hashi,
		//因为“abc”,“acb”...求和后的ASCII总值是一样的,哈希冲突的概率也比较高
		//for (auto& ch : key)
		//{
		//	hash += ch;
		//}
		size_t hashi = 0;
		for (auto& ch : s)
		{
			hashi = hashi * 31 + ch;也可以乘131,1313,13131,131313
		}
		return hashi;
	}
};

使用方法:

size_t hashi = hash(key) % _tables.size();

在这里插入图片描述

若想更深入的了解字符串哈希函数,可以看大佬的博客:各种字符串哈希函数
在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/20783.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

python3+pytest+requests+allure+yaml测试框架搭建

目录 设计框架的原则 1.框架整体结构 2.框架各个模块说明 3.示例 3.1 先写一个测试用例 3.2 对上面的用例进行分层封装&#xff08;可根据业务复杂度分两层或者三层&#xff0c;此处演示分三层&#xff09; 3.3生成allure测试报告并查看 设计框架的原则 封装基类方法 对…

山东移动:全业务域核心系统升级,实现大幅降本增效

本文介绍了山东移动引入 OceanBase 到山东省 BOSS/CRM 核心系统领域的相关情况。欢迎访问 OceanBase 官网获取更多信息&#xff1a;https://www.oceanbase.com/ 中国移动通信集团山东有限公司&#xff08;以下简称"山东移动"&#xff09; 隶属于中国移动通信集团公司…

发现一个国产BI软件,做财务数据分析效果绝了

如果是一般的财务数据分析&#xff0c;BI软件们都能做&#xff0c;但如果真要深入了解财务痛点&#xff0c;逐个击破财务数据分析难点&#xff0c;实现多维立体自助式的财务数据分析&#xff0c;那就难。就目前而言&#xff0c;财务数据分析做得好的国产BI软件也就一个奥威BI软…

java版Spring Cloud+SpringBoot+mybatis+uniapp b2b2c 多商户入驻商城 直播 电子商务

J2EE企业分布式微服务云快速开发架构 Spring CloudSpring Boot2MybatisOauth2ElementUI 前后端分离 1. 鸿鹄Cloud架构清单 2. Commonservice&#xff08;通用服务&#xff09; 通用服务&#xff1a;对spring Cloud组件的使用&封装&#xff0c;是一套完整的针对于分布式微…

原神QQ机器人BOT搭建教程Ubuntu系统

原神QQ机器人BOT搭建教程Ubuntu系统 大家好我是艾西&#xff0c;今天跟大家分享的是YUAN神qi鹅群机器人bot搭建方式以及详细的操作步骤。跟上艾西的节奏准备发车啦&#xff01; 前言&#xff1a;&#xff08;xxxx即为xxxx&#xff09;&#xff08;zzz即为zzz&#xff09; qi…

RK 平台MIPI 点屏注意事项

转自&#xff1a;https://www.cnblogs.com/chorm590/p/11658360.html rk 平台关于 MIPI 屏幕的点屏流程已经非常完善了&#xff0c;基本上只要确定了硬件没问题、接线没问题、屏幕没问题&#xff0c;再稍稍配置一下 dtsi 里的参数就可以的了。 MIPI 点屏流程大致可以概括为以下…

i春秋 Misc Web 爆破-1

打开链接是PHP源码 代码审计&#xff1a; include "flag.php"; 表示文件中包含flag.php文件&#xff0c;即根目录下存在flag.php $a $_REQUEST[hello]; 命名一个变量a来接收超全局变量$_REQUEST&#xff08;接收表单’hello’数据&#xff0c;请求一个为hello的参…

顶级白帽黑客必备的十大黑客技术

1.熟悉Linux系统和命令行操作&#xff1a; Linux是黑客的基石&#xff0c;几乎所有黑客工具和技术都是在Linux平台上运行的&#xff0c;熟悉Linux系统和命令行操作是必须的。 2.掌握网络协议和TCP/IP模型&#xff1a; 了解TCP/IP模型、网络协议和通信流程是黑客攻击的基础&a…

Java并发编程:并发问题和多线程技术的应用和优化

章节一&#xff1a;引言 在当今的软件开发领域中&#xff0c;多线程编程是一项至关重要的技术。随着处理器核心数量的增加和计算机系统的并行性的不断提高&#xff0c;充分利用多核心处理器的能力已成为现代软件开发的关键要素之一。Java作为一种强大的编程语言&#xff0c;在…

世界超高清大会发布重大技术成果:博冠自主创新推动8K摄像机攻关

一、世界超高清大会背景介绍&#xff1a; 近日&#xff0c;由工业和信息化部、国家广播电视总局、中央广播电视总台、广东省人民政府主办的2023世界超高清视频产业发展大会在广州越秀国际会议展览中心盛大召开。自2018年创办以来&#xff0c;大会已成功举办四届&#xff0c;成…

安装Ubuntu18.04双系统、干净卸载,并在Ubuntu系统中安装CARLA模拟器

Ubuntu系统安装 Ubuntu系统安装参照流程 Ubuntu 双系统安装流程_ubuntu双系统_地球被支点撬走啦的博客-CSDN博客 Ubuntu系统卸载 1.将开机启动项设置默认为Windows&#xff0c;进入BIOS设置界面调整BootDevice中Windows和Ubuntu的顺序&#xff0c;将Windows调整在Ubuntu前边…

PMP课堂模拟题目及解析(第11期)

101. 一家咨询公司的负责人启动一个项目来扩大公司提供的服务数量&#xff0c;这公司具有竞争优势、出色的企业知识以及卓越的声誉&#xff0c;高管团队担心与增加新服务相关的负面业务结果的可能性。若要评估负面业务结果的可能性和影响&#xff0c;项目经理应该使用什么&…

Protell99SE祭文

Protell99SE祭文 大概是在21年前的今天&#xff0c;我和你结合在一起&#xff0c;陪伴走过无数的设计。 我的感觉&#xff0c;大概是在2021年吧&#xff0c;你逐渐离我远去。啊&#xff0c;Protel99SE时代一去不复返了。 我用了你21年&#xff0c;虽着AD软件的到来&#xff…

Word控件Spire.Doc 【文本框】教程(5): 插入、读取和删除表格

Spire.Doc for .NET是一款专门对 Word 文档进行操作的 .NET 类库。在于帮助开发人员无需安装 Microsoft Word情况下&#xff0c;轻松快捷高效地创建、编辑、转换和打印 Microsoft Word 文档。拥有近10年专业开发经验Spire系列办公文档开发工具&#xff0c;专注于创建、编辑、转…

Linux基本指令和操作(3)

目录 一. date指令 -- 显示时间 二. cal指令 -- 日历打印指令 三. find指令 -- 查找文件 四. grep指令 -- 行过滤指令 五. zip/unzip指令 -- 压缩和解压缩 六. tar指令 -- 解压/打包 或 查看压缩包内文件 七. bc指令 -- 计算器 八. uname指令 -- 获取电脑和操作系统相关…

JAVA - 字符串工具类StringBuilder和StringBuffer

文章目录 目录 文章目录 前言 二.常用方法演示 1.append()用于将指定的字符串添加到当前StringBuilder对象的末尾 2.delete()&#xff1a;用于删除StringBuilder对象中指定位置的字符。 3.insert()&#xff1a;用于在指定位置插入指定字符串。 4.replace()&#xff1a;用于替换…

中睿天下成为国家信息安全漏洞库(CNNVD)一级技术支撑单位

近日&#xff0c;中国信息安全测评中心公布2023年度国家信息安全漏洞库技术支撑单位名单&#xff0c;中睿天下荣获中国信息安全测评中心颁发的“国家信息安全漏洞库&#xff08;CNNVD&#xff09;技术支撑单位等级&#xff08;一级&#xff09;证书”&#xff0c;成为该领域最高…

提高运算放大器输出功率

运算放大器的串联&#xff1a;如何同时实现高精度和高输出功率 复合放大器 复合放大器由两个单独放大器组合而成&#xff0c;分别具有不同的特性。 图1所示就是这种结构。放大器1为低噪声精密放大器ADA4091-2。 在本例中&#xff0c;放大器2为AD8397,具有高输出功率&#xff…

【游戏逆向】某某游戏邮件遍历分析

邮件常常用来远程交易&#xff0c;这样可以节省交易时间&#xff0c;并且降低数据的需求。邮件遍历的分析&#xff0c;一般是以邮件名字&#xff0c;邮件数量等为突破口。不过有些游戏的邮件名字并不存放在邮件对象中&#xff0c;或者在对象中也不会改变邮件的本地显示&#xf…

全网独家首发最牛最全面的JMeter使用BeanShell断言

BeanShell简介 BeanShell是使用Java语法的一套脚本语言&#xff0c;在JMeter的多种组件中都有BeanShell的身影&#xff0c;如&#xff1a; 定时器&#xff1a;BeanShell Timer前置处理器&#xff1a;BeanShell PreProcessor采样器&#xff1a;BeanShell Sampler后置处理器&am…