`
ZeaLoVe
  • 浏览: 91596 次
  • 性别: Icon_minigender_1
  • 来自: 武汉
社区版块
存档分类
最新评论

外排序的摸索

 
阅读更多

   今天晚上的目标就是实现一个外排序的算法,最近几天多多少少的看了点这方面的文章,还有一些实现,之前对这个概念十分不清晰,其实现在想来,外排序的操作文件,其实和操作内存一样,只不过它的速度实在是太慢了。但在代码上几乎没有区别,把内存上的定义数据,转变成对文件的读入读出。

   其实现在还在继续研究中,打算先把完成的一部分贴出来,然后全部完成后,再拿出完整版,这样有个思考的过程。不过说时候现在完成的这部分我都不好意思拿出来。。。还是皮厚点吧。。上代码

   这个函数是分割文件的,把一个大文件分割成N个小文件,从大文件中读取的数据量需指定,还要分割成的碎片文件的大小,当然得到的碎片文件是通过快排排序好的(随机法改进的快排)。

 

 

int dataCut(char* filename,int numSize,int totalSize)
{
	int i;
	int cutnum = ceil((double)totalSize/numSize);//计算切割成多少个文件
	int curNum=1;
	int numleft=totalSize%numSize;//计算最后一个文件的数字
	cout<<cutnum<<"  "<<numleft<<endl;

	ifstream input(filename);//输入流

	int* a=(int*)malloc(numSize*sizeof(int));
	while(1)
	{
		ofstream output(getFileName(curNum,0));
		if(curNum==cutnum)
   	    {
	  	    for(i=0;i<numleft;++i)
	  	    {
	            input>>a[i];
	        }
	        quickSort(a,0,numleft-1);

   	    	for(i=0;i<numleft;++i)
   	    	{
   	    		output<<a[i]<<" ";
   	    	    output.flush();
   	    	}
   	    	output.close();
   	    	break;
   	    }
  	    for(i=0;i<numSize;++i)
  	    {
            input>>a[i];
        }
        quickSort(a,0,numSize-1);

   	    for(i=0;i<numSize;++i)
        {
   	        output<<a[i]<<" ";
   	        output.flush();
        }
   	    output.close();

   	    curNum++;
	}
    input.close();
    free(a);
    return cutnum;//返回文件数目
}
 

 

 快排的代码其实之前的日志里有,不过这个版本是修改过的,也贴出来。

 

int qsort_partion(int* a,int start,int end
{
	int i=start,j=end;
	int judge = a[ end ];//选择最后一个数字作为比较值
	while(1)
	{
		while( a[i] < judge ) ++i;
		--j;
		while( a[j] > judge ) --j;
		if( i >= j ) break;
		else
		{
			swap(a[i],a[j]);//交换需要调整的值
			++i;
		}
	}
	swap(a[end],a[i]);//将当前值于比较值对换,然后就完成以i 为界,一边的值都小于a[i],另一边都大于a[i]
	return i;
}

int rand_partion(int *a,int start,int end)//这个就是修改的部分
{
	int t= rand()%(end-start);
	swap(a[start+t],a[end]);
	return qsort_partion(a,start,end);
}

void quickSort(int* a, int start ,int end)
{
	if(start <end )
	{
		int q = rand_partion(a,start,end);
		quickSort(a,start,q-1);
		quickSort(a,q+1,end);
	}
}

 

   外排序的核心部分就是用归并排序合并得到最后的目标,我还没完成,但实现了一个合并两个文件的,思想上可以渐进吧,正在努力中,先把合并两个的贴出来。。这里默认每块文件都含有1024个整数,实际情况当然没这么简单。。

 

   ifstream input1(".//data1.txt");
   ifstream input2(".//data2.txt");

   ofstream output(".//dataDst.txt");
   int a[1024];
   int b[1024];

   for(i=0;i<1024;++i)
   {
	   input1>>a[i];
	   input2>>b[i];
   }

   int j,k;
   j=0;k=0;
   while(k<1024 && j<1024)
   {
	   if( a[j]>b[k] )
	   {
		   output<<b[k]<<" ";
		   ++k;
	   }
	   else
	   {
           output<<a[j]<<" ";
           ++j;
	   }
   }
   input1.close();
   input2.close();
   output.close();

  然后继续努力!!

 

思路卡壳了,还是打会儿星际睡觉吧。。改日继续

 

11.26 早上又写了一会儿,发现卡壳的位置依旧没变,后来决定先看看别人怎么做的,参考了下JULY的博客,发现他里面的做法效率其实不高,仅仅维护一个整形数组保持每个文件的第一个数字,然后找到最小的一个写入目标文件,再从文件中更新下一个。这样需要频繁的读取文件,速度会比较慢。我自己的目标是实现一个 一次从每个文件读取一个小片段(加起来低于内存限制),然后在对各个小片段进行处理,这样的效率会高些。但我还想同时实现自动化的完成从目标文件到最终文件的排序。。然后这些组合一起导致了无比的复杂性。。。反正我是扛不住了,所以昨天到现在除了上面的成果外,无任何进展。

    所以现在决定,还是一步步来把,写代码也需要循序渐进,从简单到复杂嘛,今天先完成July博客的那种做法(N个小文件一次合并成一个目标文件),然后继续摸索高级的(N个文件多次合并成一个目标文件)。

    因为需要考虑效率问题,所以先贴一个计算时间的简单函数,C++真是很尴尬很多比较重要的东西都要没有,都需要用C版本的或者用系统的库,比如线程UI,还有时间统计。。。伤不起啊,用C的写了一个。

 

void showTimeUsed(time_t start)
{
	time_t end=time(NULL);
	cout<<"Use:"<<(end-start)<<"s"<<endl;
}

   在需要打印时间的地方调用,在计时开始的地方,需要有这句配合

time_t start=time(NULL);

 

  11.26晚上,初步完成了K路合并为一路,测试了1000000数据量,大约运行时间3S。

  现在将把各个部分的内容一一列出。

 

 

char* getFileName(int num,int times)
{
     char* filename=(char*)malloc(sizeof(char)*50);
     sprintf(filename,".//data%d-%d.txt",num,times);
     return filename;
}

void dataInit()//生成数据
{
	int i;
	ofstream file(".//data.txt");
    if(!file.is_open())
    {
    	cout<<"NO File!"<<endl;
    }
    srand((int)time(0));
	for(i=0;i<1000000;++i)//生成的整数数量
	{
		file << rand()%1000000<<" ";//控制数据的范围
	}
	file.close();
}

   getFileName是用来获取随机生成文件的名字,通过两个参数方便了解情况,第一个是索引,第二个是处理的次序。比如第一次DataCut切割完产生的文件就是data1-0.txt data2-0.txt以此类推。

   dataInit()是用来随机生成测试数据的,数据量和数据范围可以指定。。手动指定。。恩。

 

   然后就是重点了,K路合并,已经后面一个日志里说到的,封转从文件读取操作的AutoBuf类,就是尽量一次读入一块而不是每次都从文件里直接获取,这样的处理可以很大的提高速度(文件读入是系统的瓶颈)。代码如下。先是一些基本的定义。

 

 

const int K=1000;// 定义合并的K值
const int memoryLimit=100000000;//单位Byte
const int bufSize=memoryLimit/K/sizeof(int);//每一路可以开辟的缓存大小 即每一组缓存最多有  bufSize 个整型
 

然后AutoBuf类

 

struct AutoBuf
{
	int cur;//当前数据的索引
	int* buf;//数据缓存
	int size;//缓存区现有数据大小
	bool isLast;//是否已经从文件读完
	ifstream in;
	AutoBuf()
	{
		cur=0;
		size=0;
		isLast=false;
		buf=(int*)malloc(sizeof(int)*bufSize);
	}
	~AutoBuf()
	{
		in.close();
		free(buf);
	}
	void AttachFile(const char* filename)
	{
		in.open(filename);
	}
	int read()//从指定文件中读取
	{
		if( !in.is_open() ){cout<<"file not open!"<<endl;return -1;}
		int i=0;
		while( i<bufSize && in>>buf[i] )
		{
			++i;
		}
		size=i;//buf中有多少个数据
		cout<<"read size:"<<size<<endl;
		cur=0;
		if( size < bufSize ) isLast=true;
		return size;
	}
	int autoGet()
	{
		int tmp=buf[cur++];
		if( cur>=size )
		{
			if( isLast )
			{
				//已经没数据了
				return -1;
			}
			else//还有,可以继续读
			{
				if( read()==-1 )//读取失败
				{
					return -1;
				}
			}
		}
		return tmp;
	}
};
  

   这个类其实就是一个中间作用,减少文件读写的次数,基本一次读入可以用好久了。本来还想写一个 减少文件写入的类,发现相似点太多了,改进也挺容易,就先留着这个问题。

 

    K路合并代码,目前只能处理一次合并的,递归多次合并即如何自动化完成全套流程,后面再想。

 

 

void K_merge(int K,const char* targetFile)//K路归并成一路
{
    int i,j;
    int min;//记录遍历过程的最小值
    bool hasNext[K];
    int Knum[K];
    ofstream out( targetFile );//存入改文件中
    AutoBuf buf[K];
    for(i=0;i<K;++i)
    {
          buf[i].AttachFile(getFileName(i+1,0));//将自动缓存和文件绑定
          buf[i].read();// 读取文件的其中一块
          hasNext[i]=true;//设置文件未读完
          Knum[i]=buf[i].autoGet();//获取第一个数字
    }
    while(true)
    {
          j=0;
    	  while(j<K && !hasNext[j] ) ++j;//跳过已经读完的部分

    	  if(j>=K) break;//已经读完了,退出
          min=Knum[j];
          if(min == -1) break;
    	  for(i=j+1;i<K;++i)//需找K组中的最小值
    	  {
    		  if( hasNext[i] && Knum[i] < min )
    		  {
    			  min=Knum[i];//记录最小值
    			  j=i;//记录最小值所在的索引
    		  }
    	  }
//    	  cout<<min<<endl;
    	  out<<min<<" "<<flush;//写入目标文件

    	  Knum[j]=buf[j].autoGet();  	  //继续读入一个整形

    	  if( Knum[j] == -1 ) //读入-1说明已经结束了
    		  hasNext[j]=false;
    }
    out.close();
}
 

   out<<min<<" ";这里可以添加所说的缓冲类,来减少写文件的次数,先把排序好的数字收集在缓冲中,然后达到一定数量后一次性写入,这样就完美了。。

   最后补充main函数,这样一个基本的版本就成型了。

 

 

int main()
{
	 srand((int)time(0));
	 time_t start=time(NULL);

	 dataInit();
         int k=dataCut(".//data.txt",1024,1000000);
	 K_merge(k,".//dst.txt");

     showTimeUsed(start);
	 return 0;
}
 

   好吧,我承认我最后虐待了下电脑,生成了1亿个数字的文件,然后切割排序,但只归并一百路,用了93S。。

   最后检查数据的时候发现几个文件间歇性的出现乱码,想到下午那个问题,一查,果然是处理文件流的时候忘记flush了,吃一见长一智啊。这次加上就完全没问题了。

 

   11.26最终测试后发现,有很多Bug,比较严重的是,结果中居然出现-1.这个问题比较严重,然后就是效率偏低,直接归并法果然复杂度很高,数据量大的情况会越发明显。得引入其他的处理方法,比如败者树或者最小堆,今天就到这里了,改日慢慢改进。

 

   11.27日,实现使用维护K大的最小堆,来进行归并。

   补充一部分维护堆的代码,还有修改K_merge函数。

 

 

struct heapNode
{
	AutoBuf* data;//数据源
	int Knum;//当前读入数值
};

typedef heapNode* Point;

void adjust_heap(Point* a , int i)//调节该点 OK
{
	Point tmp=a[i];
	while( i > 1 )
	{
		if( a[i>>1]->Knum > tmp->Knum)
		{
			a[i]=a[i>>1];
			i>>=1;
		}
		else
		{
			break;
		}
	}
    a[i]=tmp;
}
void make_heap( Point* heap , int size )// OK
{
	int i;
	for(i=1;i<=size;i++)
	{
		adjust_heap(heap, i);
	}

}
void adjustDown_heap(Point* a ,int size)
{
	Point tmp = a[1];
	int i=1;
    while( i<= size )//有孩子
    {
    	if( 2*i > size ) break;//越界判断!!!!!
    	if( (2*i+1) > size)//只有一个孩子
    	{
    		if( tmp->Knum < a[2*i]->Knum )
    		{
    			break;
    		}
    		else
    		{
    			a[i]=a[2*i];
    			i=2*i;
    			continue;
    		}
    	}
    	if( tmp->Knum < a[2*i]->Knum && tmp->Knum < a[(2*i+1)]->Knum )//两个孩子
    	{
    		break;
    	}
    	else//选择和较小的孩子节点替换
    	{
    		if( a[2*i]->Knum < a[(2*i+1)]->Knum )
    		{
    			a[i]=a[2*i] ;
    			i*=2;
    		}
    		else
    		{
    			a[i]=a[(2*i+1)];
    			i=2*i+1;
    		}
    	}
    }
    a[i] = tmp;
}


void K_merge(int K,const char* targetFile)//K路归并成一路
{
    int i;
    int size = K;
    Point node[K+1];//这是一个指向heapNode对象的指针数组
    Point tmp;
    for(i=0;i<=K;++i)//必须分配heapNode 对象的空间
    	node[i]=new heapNode;

    AutoBuf buf[K+1];
    ofstream out( targetFile );//存入改文件中

    for(i=1;i<=K;++i)
    {
          node[i]->data = buf+i;
          node[i]->data->AttachFile(getFileName(i,0));//将自动缓存和文件绑定
          node[i]->data->read();// 读取文件的其中一块
          node[i]->Knum=node[i]->data->autoGet();//获取第一个数字
    }

    make_heap(node,size);
    while( size > 0 )
    {
    	 out<<node[1]->Knum<<" "<<flush;//输出堆顶
//    	 cout<<node[1]->Knum<<" ";

    	 node[1]->Knum=node[1]->data->autoGet();

    	 if( node[1]->Knum !=-1)
    	 {
    		 adjustDown_heap(node ,size);
    	 }
    	 else//堆大小减1,同时将其塞入废弃区
    	 {
    		 tmp=node[1];
    		 node[1]=node[size];
    		 node[size]=tmp;
    		 --size;
    		 adjustDown_heap(node ,size);
    	 }
    }
    for(i=1;i<=K;++i) delete node[i];
    out.close();
}
   

   经测试发现这种方法比以前的快些,尤其是K的值比较大的时候,但如果K值很小,或者读写的文件块很大的时候,效率一般。相对朴素算法只是稍有提高而已。

 

   继续修改,因为感觉效率实在太差了,所以做了进一步的尝试,由于最终结果是得到一个数字写入一个,所以我觉得这里会有瓶颈,还是决定添加一个作为中间缓存的类AutoWriter,这个和AutoBuf的相当,只是负责写入端,没想到加入以后修改完,效果特别好。一下子把10的7次方数据的排序,从57S降到了34S,降低了23S,几乎是一大半的时间啊,原来文件读写的速度如此之慢。仅仅是块读取和一次一次读就差距这么大。

   先上代码,K_merge又改了。。这篇文章显得很乱,但正显示了一次一次的思考过程。

 

 

   首先是AutoWriter类

 

 

const int W_BUF=4096;//存满W_BUF个整数后写入文件
struct AutoWriter
{
	int *buf;
	int size;//缓存中的整形数
	ofstream out;
	AutoWriter()
	{
		size=0;
		buf=new int[W_BUF];
	}
	~AutoWriter()
	{
		Flush();
		out.close();
		delete[] buf;
	}
	void AttachFile(const char * filename)
	{
		out.open(filename);
	}
	void put(int num)
	{
//		cout<<num<<" ";
		if( size < W_BUF )
		{
			buf[size++]=num;
		}
		else
		{
	         Flush();
	         buf[size++]=num;
		}
	}
	void Flush()
	{
		for(int i=0;i<W_BUF;++i)//输出文件
		{
			out<<buf[i]<<" ";
			size=0;
		}
		out.flush();
	}
};
 

   然后是K_merge

 

 

void K_merge(int K,const char* targetFile)//K路归并成一路
{
    int i;
    int size = K;
    Point node[K+1];//这是一个指向heapNode对象的指针数组
    Point tmp;
    for(i=0;i<=K;++i)//必须分配heapNode 对象的空间
    	node[i]=new heapNode;

    AutoBuf buf[K+1];
    AutoWriter w;
    w.AttachFile(targetFile);

    for(i=1;i<=K;++i)
    {
          node[i]->data = buf+i;
          node[i]->data->AttachFile(getFileName(i,0));//将自动缓存和文件绑定
          node[i]->data->read();// 读取文件的其中一块
          node[i]->Knum=node[i]->data->autoGet();//获取第一个数字
    }

    make_heap(node,size);
    while( size > 0 )
    {
    	 w.put(node[1]->Knum);

    	 node[1]->Knum=node[1]->data->autoGet();

    	 if( node[1]->Knum !=-1)
    	 {
    		 adjustDown_heap(node ,size);
    	 }
    	 else//堆大小减1,同时将其塞入废弃区
    	 {
    		 tmp=node[1];
    		 node[1]=node[size];
    		 node[size]=tmp;
    		 --size;
    		 adjustDown_heap(node ,size);
    	 }
    }
    for(i=1;i<=K;++i) delete node[i];
}

 

   现在就差最后两个尝试了,败者树可能比最小堆效率好些,然后就是能一次性的自动化完成多次K路归并。

   今天先到这里了。。

 

11.29 在摸索了一段时间后,终于着手写败者树实现的外部排序了。开始对败者树理解不到位,还对于完全二叉树的理解也不够,其实败者树说简单点就是:一堆记录比赛结果的节点(K)个,第0个位置记录的是最后的胜利者,每个节点的值是该节点子节点比赛的失败者。然后是K个选手,所以这个树的结构其实就是ls[0-k] players[0-k],然后只要你知道某个选手的索引,如s,那么他的父亲节点(即比赛结果记录的败者)的索引就是(2+k)/2 。每次数据更新,只需要和父亲节点记录的索引的选手比一次 ,然后记录败者,胜者沿树上升,直到根节点,这就是一轮调整。而调整的过程使用于建树,只要初始化一个最小值,然后对每一个选手节点进行调整就好,但不一定总知道所排序的数字的最小值和最大值嘛,那就随便找一个不冲突的作为该节点未比赛的记录,只要遇到这个值,就默认为胜者就成了。

 

这部分代码不贴出来了,占空间,就放在我的代码里。

 

分享到:
评论

相关推荐

    C算法 第一卷 基础、数据结构、排序和搜索(第三版)

    《C算法》介绍了当今最重要的算法,共分3卷,《C算法(第1卷):基础、数据结构、排序和摸索》是第1卷。第1卷分4部分、共16章。第一部分“基础知识”(第1~2章)介绍了基本算法分析原理。第二部分“数据结构”(第3~5...

    淘宝搜索负责人谈淘宝搜索排序算法

    目前网上有很多介绍淘宝搜索排序的文章,大多是淘宝卖家们根据自己经验摸索整理的,里面提到的很多办法也很正确,知识搜索排序算法不是固定不变的,几乎每天都在变化,与时俱进。在这里告诉大家排序的主要原则,告诉...

    C算法(第1卷)(第三版)

    《C算法》介绍了当今最重要的算法,共分3卷,《C算法(第1卷):基础、数据结构、排序和摸索》是第1卷。第1卷分4部分、共16章。第一部分“基础知识”(第1~2章)介绍了基本算法分析原理。第二部分“数据结构”(第3~5...

    [C算法(第1卷)].(美国)Robert.Sedgewick.清晰版(djvu版)

    《C算法》介绍了当今最重要的算法,共分3卷,《C算法(第1卷):基础、数据结构、排序和摸索》是第1卷。第1卷分4部分、共16章。第一部分“基础知识”(第1~2章)介绍了基本算法分析原理。第二部分“数据结构”(第3~5...

    常用文体写作答案[字母排序].doc

    文档“常用文体写作答案[字母排序].doc”涵盖了多种文体写作的知识点,涉及科技论文、调查报告、计划、总结、合同、简报等多种常见文体。以下是这些知识点的详细解析: 1. **检索途径**:按学科分类与体系排列是...

    算法与数据结构课后答案

    这份《算法与数据结构课后答案》作为学习辅助工具,将帮助学生更有效地学习,减少摸索时间,增强对复杂问题的理解。无论是为了应对课堂作业,还是准备面试,都是极好的参考资料。所以,标签“很好的资源哦”无疑是对...

    数据结构上机试题及答案

    对于初学者来说,参照答案进行学习可以减少摸索的时间,更快地进入状态。 在学习数据结构时,重要的是理解每个数据结构的特性以及它们在实际问题中的应用。例如,数组提供随机访问但插入和删除操作较慢,而链表则...

    数据结构 严蔚敏版答案

    5. **排序**:包括冒泡排序、选择排序、插入排序、快速排序、归并排序、堆排序等,这些排序算法各有优缺点,适用于不同场景。 6. **文件结构**:如顺序文件、索引文件和直接存取文件,这些都是在磁盘等外部存储设备...

    史上最强最全数据结构课设题目及模板

    数据结构是计算机科学中的核心课程,它探讨了如何在计算机中高效地组织和管理数据,以便进行快速...对于初次接触数据结构课程设计的学生,这是一个极其宝贵的起点,可以节省大量摸索的时间,更好地专注于理解和创新。

    c算法程序源程序(CH01——CH17)

    《C语言算法程序源代码详解》 C语言作为计算机科学中的基础编程语言,因其简洁、高效而被广泛应用。本资源包含从CH01到CH17的C算法程序源代码,旨在帮助C语言初学...在实践中不断摸索和改进,是成为优秀程序员的关键。

    专题资料(2021-2022年)Excel实战培训表格类模板表格模板实用文档.ppt

    1. **排序**:介绍如何按照不同标准进行排序,如汉字笔画、制作工资条、按行排序、合并单元格数据排序,以及按单元格颜色排序(适用于Excel 2007以上版本)。 2. **筛选**:讲解如何使用自动筛选功能处理双行标题...

    初探《C程序设计》与《数据结构》课程的整合.pdf

    例如,在讲解数组时,可以在介绍冒泡排序和简单选择排序后,进一步讲解数据结构中常用的快速排序、插入排序和堆排序等方法。 在教学实践中,除了课程内容的整合,还应考虑课程整合中的相关问题,如学生的接受能力、...

    cxGrid模板程序

    【cxGrid模板程序】是一个基于Delphi开发的项目,旨在简化cxGrid组件的使用。...在实践中不断摸索,将有助于你成为一名熟练的cxGrid开发者,更好地利用这个强大的控件来提升你的应用程序的功能和用户体验。

    中国科学技术大学 计算机 考研复试详解.zip

    1. 数据结构与算法:这是计算机科学的基础,复试中可能要求考生现场编程解决数据结构问题,如链表、树、图、栈、队列等,以及经典的排序和搜索算法,如冒泡排序、快速排序、二分查找等。 2. 计算机网络:考生需要...

    2017_2018学年高中语文第一单元我思故我在课时跟踪检测一在马克思墓前的讲话语文版必修4

    1. 词语选择题考察了近义词的辨析,如“探索”与“摸索”,“停滞”与“停止”,以及“忌恨”与“记恨”的区别。正确答案的选取需要理解每个词语的含义和用法,以及它们在特定语境下的适用性。 2. 成语使用题则让...

    Jquery 插件 tablesorter

    最近在公司的项目中需要对表格进行排序,上网找了一下,感觉感觉tablesorter不错,但官网上的介绍很少,而且没有中文手册,很多地方都不明不白。。。结合官网的例子,自己摸索了一下,还真整出来了

    Java知识+算法+数据结构

    这些题目涉及算法领域的各个方面,从基础的排序算法、搜索算法,到高级的动态规划、贪心算法,再到复杂的图论算法等,一应俱全。每一道题目都精心设计,旨在帮助学生深入理解算法的原理和应用,锻炼他们的逻辑思维和...

    浅析快捷建库与数据处理系统的作用和意义.pdf

    国内在这方面的数据库建设尚处于自行摸索阶段,各生产单位没有统一的建库标准。国外虽然有数据库建设软件,但这些软件涉及的范围太广,并不适用于特定行业的便利和快捷。因此,急需开发一种能够满足国家最新的数据...

    WAP掌库3G导航程序 v1.17

    掌库3G导航v1.17 ==================== ============= 后台地址:/admin/login.asp ...=============== 注意:请设置手机接受cookices功能!...1.链入排行 2....5.百度搜索模块 6....更多功能需管理员摸索!

    程序优化介绍(c语言).ppt

    例如,根据具体问题特点,选择冒泡排序还是快速排序。 3. 表驱动状态机优化:这是一种更高层次的优化,通过抽象问题并构建状态机模型,寻找最佳的执行路径。在这个级别,程序员需要忘掉传统的编程概念,如变量、...

Global site tag (gtag.js) - Google Analytics