堆,优先队列,二叉搜索树,平衡二叉树与并查集: 加工并存储数据的数据结构

能够高效的利用二叉树解决 类似于优先队列操作的问题的数据结构叫 堆

优先队列:

  1. 只能从队尾插入元素,从队首删除元素。
  2. 但是它有一个特性,就是队列中最大的元素总是位于队首,所以出队时,并非按照先进先出的原则进行,而是将当前队列中最大的元素出队。
  3. 这点类似于给队列里的元素进行了由大到小的顺序排序。元素的比较规则默认按元素值由大到小排序,可以重载“<”操作符来重新定义比较规则。

堆:

   1
/     \
2     4 
/ \    / \
7  8  5
  • 推最重要的性质是儿子的值一定不小于父亲的值,除此之外,数的节点从上到下,从左至右的顺序紧凑排列的。
  • 插入一个数值:首先在堆的末尾插入数值,然后不断向上提升直到没有大小颠倒为止。
  • 取出一个最小的数值(并且删除):首先把堆的最后一个节点的数值复制到根节点上,并且删除最后一个节点。然后不断向下交换直到没有大小颠倒为止,在向下交换的过程中,如果有两个儿子,选择数值较小的儿子进行交换。

堆的时间复杂度:

堆的两种操作所花的时间都与数的深度成正比,如果一共有n个元素,每个操作进行都能在 O(logn)的时间内完成。

堆的实现

vector<int> heap;
void push(int x){
	heap.push_back(x);
	int i=heap.size();
	while(i > 0){
		int p=(i-1)/2;
		if(heap.at(p) >= x) break;
		heap.at(i) = heap.at(p);
		i = p;
	}
	heap.at(i)=x;
}
int pop(){
	//最小值
	if(heap.size()==0)  return -1;
	int ret = heap.at(0);
	int x = heap.back();
	heap.erase(heap.cend()-1);
	int i=0;
	while(i*2+1 < heap.size()){
      int l=i*2+1,r=l+1;
      if(r<heap.size() && heap.at(r) < heap.at(l)) l=r;
      //如果没有大小颠倒则退出
      if(x <= heap.at(l)) break;

      //把儿子的数值提升上来
      heap.at(i) = heap.at(l);
      i = l;
	}
	heap.at(i) = x;
	return  ret;
}

标准库优先队列

C++中,STL里的 priority_queue就是优先队列 ,但是取出数值时得到的是最大值。

int main()
{
	//声明并执行默认初始化
	priority_queue<int> pque;

	//插入元素
	pque.push(3);
	pque.push(5);
	pque.push(1);
	while(!pque.empty()){
		cout<<pque.top()<<ends;
		pque.pop();
	}
	return 0;

}

在这里插入图片描述

需要用到优先队列的题目

Expedition(POJ 2431)

描述:你需要驾驶一辆卡车行驶 L 单位距离。 最开始时, 卡车上有 P 单位 的汽油。 卡车每开 1 单位距离需要消耗 1 单位的汽油 。 如果在途中车上的汽油耗尽 , 卡车就无法继续前行, 因而无法到达终点。 在途中一共有 N个加油站。 第 i 个加油站在距离起点 Ai单位距离的地方,最多可以给卡车加 Bi单位汽油,假设卡车的燃料箱的容量是无限大的 , 无论加多少油都没问题 。 那么请问卡车能否到达终点? 如果可以,最少需要加多少次油?如果可以到达终点,输出最少的加油次数,否则输出 -1.。

限制条件:

  • 1 <= N <= 10000
  • 1 <= L <= 1000000, 1<= P <=1000000
  • 1 <= Ai < L , 1 <= Bi <=100

题解:由于加油站的数量N很大,必须想一个高效的算法。
思路:”我们在达到加油站时,就获得了一次在之后的任何时候加 Bi单位油的权力 “,这样的思路,我们将现有的油耗尽,添加已经经历过的加油站所能加的油量,存入优先队列,以从大到小的顺序,保证加油最小次数。

代码:


int Solve(int N,int L,int P,const vector<int> &A,const vector<int> &B){
	// 1.遍历全部途中的加油站,如果当前存放油量大于其位置,就添加该加油站点能加的油存入优先队列中
	// 2.油空了   @1:如果优先队列也是空的,则无法到达终点  @2:否则取出加油。
	
	
	int res = 0;
	priority_queue<int> pque;
	for (int i = 0; i !=N; ++i) {
		if(P < A.at(i) ){
			if(pque.size() == 0) return -1;
			P+=pque.top();
			pque.pop();
			++res;
			if(P>=L) return res;
		}
		pque.push(B.at(i));
	}
    while(pque.size()){
		P+=pque.top();
		pque.pop();
		++res;
		if(P>=L) break;
    }

    return P>=L ? res : -1;

}


int main()
{
   int N=4,L=25,P=10;
   vector<int> A{10,14,20,21},B{10,5,2,4};
   cout<<Solve(N,L,P,A,B);
	return 0;

}

Fence Repair

问题描述:农夫约翰为了修理栅栏,要将一块很长的木板切割成 N 块。准备切成的木板长度为L1,L2…LN, 未切割前木板的长度恰好未切割后木板长度的总和。 每次切断木板时 ,需要的开销为这块木板的长度。 例如长度为 21 的木板要切成长度为 5 ,8 ,8 的三块木板。长 21 的木板切成长为 13 和 8 的板时,开销为 21 . 再将长度 为 13 的板切成长度为 5 和 8 的板 时,开销是 13,于是开销就是 34,请求出按照木板要求将木板切割完最小的开销是多少。

限制条件:

  • 1 <= N <= 20000
  • 0 <= Li <= 50000

思路:

//思路:   二叉树的子节点 * 其深度  =  总消耗
//           消耗最小的话:使子节点最小的板长为深度最大的子节点,由于木板是一分为二,可以理解为二叉树
//        这样的话就是: 每次筛选最短板和次短板 并合二为一,每次累加其和值 ,直到仅仅剩下最后一根木板即可。


// 15的木板 分成 {3,4,5,1,2}
//思路:
         //                           3
/*第一步       { 1,2 { 暂定} }        /\          合并后 n=4   ans=3
                                     1  2
                                               6
                                            /      \
  第二步     {3,3 {暂定}}                   3      3           合并后 n=3   ans=3+6=9
                                          /   \
                                          1    2   
                                          
  第三步     {4,5,{6}}                                        合并后 n=2  ans=3+6+9=18
                                                 6        9
                                            /      \     /  \
                                            3      3     4    5  
                                          /   \
                                          1    2   
   最后一步   {6,9}          
                                                     15              合并后n=1   ans=3+6+9+15=33  退出循环
                                                  /      \
                                                6          9
                                            /      \     /  \
                                            3      3     4    5  
                                          /   \
                                          1    2   
                                          
*/


      

解决:
因为我们知道,最小的一定要排列在深度最大的节点,这样计算的消耗才是最小的。符合反序堆原理。
这时我们只需要利用从小到大排列的优先队列将所有的子木板长度添加入内,每次进行取出前两个最小的,之后将其合并后添加入优先队列,直到队列仅仅剩一块木板时停止,取出两块木板,将其累加到木板消耗总和中。

代码:


int Solve(const vector<int> &woodVec){

	//创建从小至大排列的优先队列
	priority_queue<int,vector<int>,greater<int>> pque(woodVec.cbegin(),woodVec.cend());
	int res=0;
	//循环累加合并木板到一块停止
	while(pque.size()>1){
		int firstWood = pque.top();
		pque.pop();
		int secondWood = pque.top();
		pque.pop();
		res+=(firstWood+secondWood);
		pque.push(firstWood+secondWood);
	}

	return  res;
}


int main()
{
	vector<int> woodVec{8,5,8};
	cout<<Solve(woodVec);

}

二叉搜索树

二叉搜索树是能够高效地进行如下操作的数据结构:

  • 插入一个数值
  • 查询是否包含某个数值
  • 删除某个数值

性质

  • 所有的节点,都满足左子树上的所有节点都比自己的小, 而右子树上的所有节点都比自己大这一条件
  • 插入元素,类似于查找,查找到位置插入即可
  • 删除元素:
    1.需要删除的节点没有左儿子,那么就把右儿子提上去。
    2.需要删除的节点的左儿子没有右儿子, 那么就把左儿子提上去。
    3.以上两种情况都不满足的话,就把左儿子的子孙中最大的节点提到需要删除的节点上。

二叉搜索树的复杂度

不论哪一种操作,所花的时间和树的高度成正比。因此,如果共有 n 个元素,那么平均每次操作都需要 O(logn)的时间。

二叉搜索树的实现:

struct node{
	int val;
	node *lch,*rch;
};
node *insert(node* p,int x){
	if(p== nullptr){
		auto q=new node;
		q->val = x;
		q->lch = q->rch = nullptr;
		return q;
	}
	if(x < p->val)  p->lch=insert(p->lch,x);
	else p->rch = insert(p->rch,x);
	return p;
}
bool  find(node *p,int x){
//	while(p!= nullptr){
//		if(p->val == x) return true;
//		else if (p->val > x) p=p->rch;
//		else p = p->rch;
//	}
//	return false;
    if(p == nullptr) return  false;
    if(p->val > x) return find(p->lch,x);
    else if(p->val == x) return true;
	else return find(p->rch,x);
}
/*
 *   1.需要删除的节点没有左儿子,那么就把右儿子提上去。
      2.需要删除的节点的左儿子没有右儿子, 那么就把左儿子提上去。
      3.以上两种情况都不满足的话,就把左儿子的子孙中最大的节点提到需要删除的节点上。
 */
node* remove(node *p,int x){
	if(p == nullptr) return nullptr;
	else if(x < p->val) p->lch = remove(p->lch,x);
	else if(p->lch == nullptr){
		node *q = p->rch;
		delete p;
		return  q;
	}
	else if (p->lch->rch == nullptr){
		node *q = p->lch;
		q ->rch = p->rch;
		delete p;
		return  q;
	}
	else{
		node *q;
		for (q = p->lch ; q->rch->rch != nullptr; q = q->rch);
			node *r = q->rch;
			q->rch = r->lch;
			r->lch = p->lch;
			r->rch = p->rch;
			delete p;
			return  r;
	}

}

标准库中的二叉搜索树

C++中,STL里有 set 和 map容器。 set是像前面所说的一样使用了二叉搜索树维护集合的容器,而 map 则是维护 键和键对应的值的容器。

set 容器操作:

int main()
{
	set<int> s;
	s.insert(1);
	s.insert(3);
	s.insert(5);

	//查找元素
	set<int>::iterator iter;
	iter = s.find(1);
	if(iter == s.end()) puts("not found");
	else puts("found");   //输出found

	iter = s.find(2);
	if(iter == s.end()) puts("not found");
	else puts("found");

	//删除元素
	s.erase(3);
	//其他找元素的方法
	if(s.count(3) !=0 ) puts("found");
	else puts("not found");

	//遍历所有元素

	for (iter  = s.begin(); iter != s.end(); ++iter){
		printf("%d\n", *iter);
	}
	return 0;
}

map容器

{
	//声明(int 为键 , const char *为值)
	map<int,const char *> m;
	m.insert(make_pair(1,"ONE"));
	m.insert(make_pair(10, "TEN"));
	m[100] = "HUNDRED";
	
	//查找元素
	map<int,const char*>::iterator iter;
	iter = m.find(1);
	puts(iter->second);
    
	iter = m.find(2);
	if(iter == m.end()) puts("not found");
	else puts(iter->second);

	puts(m[10]);

	m.erase(10);

	//遍历一遍所有元素
   for ( iter = m.begin(); iter!= m.end(); ++iter)   {
	  printf("%d: %s\n",iter->first ,iter->second);
   }
   return 0;
	
    
}

平衡二叉树

为了避免搜索二叉树出现 操作 O(n)数量级的退化情况出现 ,使用旋转操作保证树的平衡的二叉搜索树为平衡二叉树

二叉搜索树退化的情况:                     逐步旋转        平衡二叉树:     4
              1                                 2                    /     \
               \                              1    4                2        6
                  2                               3   6            / \       /  \  
                    \                                5   7        1   3      5    7
                     3
                      \
                       4
                        \
                          5
                         

我们看到搜索二叉树向平衡二叉树转变的过程中,实现起来十分复杂, 由于我们在标准库中的二叉搜索树都很好地实现了平衡二叉树,因此,我们尽可能使用标准库里的实现。

并查集

介绍:并查集是用来管理元素分组的数据结构

可以很高效的完成如下操作:

  • 查询元素 a 和元素 b 是否属于同一组
  • 合并元素 a 和元素 b 所在的组。

结构:

并查集也是使用树形结构进行实现的(并不是二叉树)

分组: 【1 2 3】 , 【3 】 【4 6 7】

对应的树:

   1               3           4
 /   \                           \
2     3                           6
                                   \
                                    7

每个组对应一棵树,每个组内元素对应一个节点, 整体构成树形(不必关心树的节点规律)

操作:

(1)初始化

我们准备 n 个节点来表示 n 个元素。最开始时没有边。

1 2 3 4 5

(2) 合并

从一个组的根向另一个根连边,这样两棵树就变成了一棵树, 也就把两个组合并成为一个组了。

1】  【2==1
                    |
                    2
 
    1           6                       6
   /  \         |                     /    \
  2    5        4    ==1     4
                |                    /  \     \
                7                   2    5      7

(3) 查询

查询包含这个元素的树的根,节点走到同一个根,即属于同一组。

优化点:

为了避免类似于二叉搜索树中的 退化问题,给出了两个优化点:

优化合并:

  • 对于每棵树,记录这棵树的高度(rank);
  • 合并时如果两棵树的 rank 不同,那么从rank小的向 rank 大的连边。

例如:

 高度12      高度2: 3     
   1             6                        6
  /  \           |                      /    \
  2   5          4          ==1     4
                 |                    /   \     \
                 7                    2    5      7

路径压缩: 一旦向上走到了一次根根节点就把这个点到父亲的边改成直接 连向根。


例子: 47 查到根为 6 时, 直接连向 6 根节点
6
|
4                         6
|                       /   \
7                      4      7

并查集的复杂度

对 n 个元素的并查集进行一次操作的复杂度是 O(a(n))。在这里, a(n) 是阿克曼(Ackermann)函数的反函数。这比 O(log(n))快。

不过这是 “均摊复杂度” 。 也就是说,并不是每一次操作都满足这个复杂度, 而是多次操作之后平均每一次操作的复杂度是 O(a(n))的意思。

并查集的实现:

int par[N];  //父亲
int rank[N];  //树的高度

//初始化n个元素
void init(int n){
	for(size_t i=0;i!=n;++i){
		par[i] = i;
		rank[i] = 0;
	}
}
//查询树的根
int find(int x){
	if(par[x] == x){
		return x;
	}else{
		return par[x] = find(par[x]);
	}
}
//合并 x 和 y 所属的集合
void unite(int x,int y){
    x = find(x);
    y = find(y);
    if(x == y) return;

    if(rank[x] < rank[y]){
        par[x] = y;
    }else{
        par[y] = x;
        if(rank[x] == rank[y]) ++rank[x]; 
    }
}

bool same(int x, int y){
    return find(x) == find(y);
}

并查集例题:

食物链(POJ 1182)

描述:N只动物,分别编号为 1, 2,,,N。所有动物都属于 A,B,C中其中一种。已知 A吃 B,B吃C,C吃A,按顺序给出下面的两条信息共 K条

  • 第一种: x 和 y属于同一种类 。
  • 第二种:x 吃 y。

然而这些信息有可能会出错。有可能有的信息和之前给出的信息矛盾 ,也有的信息可能给出的 x 和 y不在1,2,,,,,N的范围内。求在K条信息中有多少条是不正确的。计算过程中,我们将忽视诸如此类的错误信息。

限制条件:

  • 1 < = N <= 50000
  • 0 < = K <= 100000

输入:


N=100, K =3
信息有下面 3条:
第一种: x =101 , y=1;
第二种, x =1 ,y = 2
第二种, x = 2 ,y = 3


输出: 1 (第1条是错误的)


题解:

const int N = 10;
const int K = 100;
int par[N];  //父亲
int rank1[N];  //树的高度

//初始化n个元素
void init(int n){
	for(size_t i=0;i!=n;++i){
		par[i] = i;
		rank1[i] = 0;
	}
}
//查询树的根
int find(int x){
	if(par[x] == x){
		return x;
	}else{
		return par[x] = find(par[x]);
	}
}
//合并 x 和 y 所属的集合
void unite(int x,int y){
    x = find(x);
    y = find(y);
    if(x == y) return;

    if(rank1[x] < rank1[y]){
        par[x] = y;
    }else{
        par[y] = x;
        if(rank1[x] == rank1[y]) ++rank1[x];
    }
}

bool same(int x, int y){
    return find(x) == find(y);
}


int Msg[K],X[K],Y[K];

void solve(){
	//初始化并查集
	//元素x,x+N,x+2*N 分别代表 x-A,x-B,x-C
	init(N*3);
	int ans = 0;
	for (int i = 0; i !=K; ++i) {
		int t = Msg[i];
		int x = X[i] - 1, y = Y[i]-1; //把输入变成 0 ,....,N-1的范围
		//不正确的编号
		if(x < 0 || N <=x || y<0 ||N <= y){
			++ ans;
			continue;
		}
		if(t == 1){
			//"x 和 y属于同一类“的信息
			if(same(x,y+N) || same(x,y+2 * N)) ++ans;
			else{
				unite(x,y);
				unite(x+N,y+N);
				unite(x+N*2,y+N*2);
			}
		}
		else{
			// "x吃y"的信息
			if(same(x,y) || same(x,y+2*N)){
				++ans;
			} else{
				unite(x,y+N);
				unite(x+N,y+2*N);
				unite(x+2*N,y);
			}
		}
	}
	cout<<ans;
}

猜你喜欢

转载自blog.csdn.net/chongzi_daima/article/details/104549591