`
JAVA小韦
  • 浏览: 6847 次
  • 性别: Icon_minigender_1
社区版块
存档分类

数据结构找你妹(一)从二叉树到红黑树的分析实现

 
阅读更多

 

什么是查找?

 

       好了,我们今天的主题是——找你妹。或许应该把话题提升到一个不那么“好听”的层次——查找。但还是从“好听”的讲起吧,我们应该都玩过“找你妹”这个游戏,规则很简单,通过对一个物品描述的词语,在屏幕中寻找相应的物品,点击到对应的物品查找成功,一定时间内查找到的物品越多分数越高,即查找时间越短越好。为了能准确的查找,每一样东西都要有一个唯一的标识,我们称之为关键字或键。在游戏中物品的键就是它的描述词语,我们通过给出的词语(输入),找到某个键是这个词语的物品,这就是查找。概括的说,查找是根据某个给定的值,在查找表中确定一个其关键字等于给定值的数据元素。

 

 

 

如何提高查找效率?

 

       明白了查找的定义后,我们关注的就是怎么得到好的查找。在“找你妹”中,好的查找就是用时短的查找。运用人脑的查找方式会模糊而复杂,足够好的大脑比如最强大脑什么的估计可以建立一个完美的哈希。在这我们就对游戏进行简化并使用像我一样笨的大脑作为模型进行查找,每一次查找,我都需要从头开始逐一查找,而聪明的你看不下去了,给我编了一个游戏外挂,这个外挂可以对物品的位置依照一定的规则重新进行组织。比如要找红色的上衣,外挂已经按规律自动摆好了物品并做好了分隔线,左边的物品是红色的,右边的物品是黑色的,上边的家具,下边是服饰,那我就只用找左下的部分了,查找所用时间变少了。在计算机中,我们提供查找数据结构(查找表)就像上述的外挂,将不存在明显的组织规律的数据通过附加某种条件(也可以说是协议)组织起来,在适用的情况下使查找效率提高。

 

 

 

规则的重要性

 

弄清楚查找表是依据什么规则来组织数据这点非常重要的。第一,要实现这个数据结构就是维护它的规则,以至于后边所述的大部分篇幅都在描述我们如何在保证数据组织规则下进行操作。这决定了查找表的实现。第二,我们要分析在这个规则下的操作的效率。这决定了查找表的优劣。第三,思考是否可以变换一些规则来对性能进行提升。以下的分析和实现都遵照这个思路。

 

 

 

 

 

二叉查找树

 

       为什么要用二叉查找树?如果使用一个排好序的数组进行二分查找不就行了吗?我们这里要讨论的是动态查找,即有对数据进行插入和删除等操作,所以根据数组的二分查找是行不通的。当然我们也可以看到二叉查找就是二分查找的拓展,它的层次结构和链的实现使其便于插入和删除。

 

       规则:二叉查找树中的每个节点XX的左子树的所有键值都小于X的键值,X右子树的所有键值都大于X的键值。

 

       二叉查找树的实现是比较简单的,查找和插入都是一个思路:和当前节点比较,键值比当前节点小的把指针指向当前节点左子树的根节点继续比较,键值比当前节点大的把指针指向当前节点的右子树的根节点继续比较。这里不再赘述。不过对于删除操作,我们就得分情况进行讨论了,这里给出相应的递归形式的代码和注释,以便在实现红黑树的时候进行比较。

 

 

	/**
	 * 任意项删除会包含三种情况:
	 * 1.删除项左右子树都不为空 :将删除项设置为右子树中的最小项,并删除该最小项
	 * 2.只有一边为空 :删除项的父节点直接指向该项的子节点
	 * 3.两边都为空:直接删除
	 * 我们仍然使用递归的方法
	 */
	public BinaryNode<E> remove(E e,BinaryNode<E> t){
		if(t==null){
			System.out.println("未找到该删除项");
			return null;
		}
		if(e.compareTo(t.element)<0)
			//当要删除节点小于当前访问节点时,我们递归地在其左子树进行删除
			t.left = remove(e,t.left);
		else if(e.compareTo(t.element)>0)
			//当要删除节点大于当前访问节点时,我们递归地在其右子树进行删除
			t.right = remove(e,t.right);
		else{
			if(t.left!=null&&t.right!=null){
				//左右节点都不为空的情况,我们把右子树最小项的值赋给删除节点,并物理删除最小项
				t.element = findMin(t.right).element;
				t.right = removeMin(t.right);
			}else
				//这一行代码很巧妙地解决了有一个孩子节点和没有孩子节点的删除(t即为当前节点--删除后删除节点的父节点所指向的节点)
				t = (t.left!=null)?t.left:t.right;
		}
		return t;
	}

      每个二叉查找树的操作的代价与操作期间要访问的节点数成正比。因此,访问树中的每个节点是该节点的深度加1。(节点的深度是根到该节点的路径长度)所以对于输入规模n操作所用的时间取决于树的平衡程度(各叶节点的深度趋同的程度),而通过随机分析我们可以得到合理次数的操作并没有使树失衡,说明平均情况下的操作时间依然是对数的。但如果输入序列是有序的,我们发现二叉查找树退化成了链表,每种操作都是线性时间的。就算不经过严密的分析,我们也知道在完全平衡和最差的情况之间必有一部分非有序列的操作时间是糟糕的。这个问题的解决方案增加一个额外的结构条件——平衡。下边所说的就是这样的一个结构。

 

 

 

 

AVL

 

       为了给二叉查找树的操作时间一个保证,我们需要树是平衡的。而AVL树通过一定的条件限制二叉树的操作从而保持树的平衡。这个条件能够保证树的深度是对数的。

 

规则:对于任何节点,AVL树的左子树和右子树之间的高度最多差1

 

如何维护这样的限制条件呢?我们需要通过旋转来调整维护树。其中有两个基本操作:左旋转,右旋转。

 

    当遇到下图这种情况,k1在插入之前满足AVL性质,但在插入之后,A增长了一层,比C深了两层,这时我们就可以通过对k1进行右旋转,先将k2的右子树赋给k1的左子树,再将k1变成k2的右子树,这样即保持了二叉树的性质,又维护了AVL性质。



    
左旋转就是右旋转的对称。对于AVL树,只要对最早出现不满足性质的节点进行旋转调整,就可以保持性质。当然,有些情况需要两次旋转,但都是这两种旋转的组合,在这里不一一讨论了,但要记住这两个操作,它们是平衡二叉树的基本操作,在讨论红黑树时还会用到。

 

       AVL树对平衡的要求相对严格,所以其插入和删除需要的开销较大,但其有很好的查找性能。而下边所要讨论的红黑树,对平衡要求没有那么严格,插入和删除所需要的旋转少。据说红黑树有更好的统计性能,而这些都是对于具体的实现和输入的数据分布而言,当然只有通过实现和测试,通过实践才能得出结论从而做出选择。

 

 

 

红黑树

 

       红黑树是AVL树最流行的变种,有些资料甚至说自从红黑树出来以后,AVL树就被放到博物馆里了。红黑树是否真的有那么优秀,我们一看究竟。红黑树遵循以下5点规则,需要我们理解并熟记。

 

规则: 

 

1、树节点要么是红的,要么是黑的

 

       2、树的根节点是黑的

 

       3、树的叶节点所链接的空节点都是黑的,即nullNode为黑

 

4、红色节点的左右孩子必须是黑的

 

       5、从某个节点到null节点的所有路径都包含相同数目的黑节点

 

      正是因为作为二叉查找树的红黑树满足这些性质,使得树的节点是相对平衡的。由归纳法得知,如果一颗子树的根到nullNode节点的路径都包含B个黑色节点,则这棵子树含有除nullNode节点外的2^B-1个节点,而由性质4得从根到nullNode节点的路径上至少有一半的节点是黑色的,从而得到树包含的节点数n>=2^(h/2)-1,h是树的深度,从而得到h<=2*log(n+1)。即可以保证红黑树的深度是对数的,可以保证对树的查找、插入删除等操作满足对数级的时间复杂度。下边我们将讨论红黑树最主要的两个算法,插入和删除。

 

 

 

一、红黑树的插入。

 

       为了不违反性质5,所以我们将带插入的节点先染成红色,再进行调整以满足其他性质。为了能够清晰的解决问题,我们可以将红黑树的插入分为以下五种情况:

 

 1、插入空树中,插入的节点是根节点

 

 2、插入的节点的父亲是黑色的

 

 3、插入的节点的父亲是红色的,而父节点的兄弟为黑色,且插入节点为外部节点(要找到它要么一直遍历做节点,要么一直遍历右节点)

 

4、插入的节点的父亲是红色的,而父节点的兄弟为黑色,且插入节点为内部节点(不是外外部节点的节点)

 

 5、插入的节点的父亲是红色的,而父节点的兄弟也是红色的

 

      对于第一种情况,插入后将根节点再染成黑色即可,而第二种情况直接插入依然满足性质。

 

      首先设X是新增节点,P是其父节点,S是其父节点的兄弟节点,G是其的祖父节点

 

      对于第三种情况,违反了性质4,我们可以通过对P进行右旋和节点的重新着色对树进行修复(应对三、四两种情况我们的着色方式都是:在旋转前先将要旋转的根节点染红,然后旋转,最后将新的根节点染黑)见图2。

 

对于第四种情况,亦是如此,只不过我们需要两次旋转,先对P做左旋再对G做右旋,并重新着色,见图3。



 
 对于第五种情况,我们如果按照三四两种情况的修复方式是无法满足性质的,我们就考虑旋转后将新的根节点染红,未插入之前的父节点的兄弟节点染红,新根节点的孩子节点染黑。这样出现的问题是新根节点的父节点可能是红的。此时,我们只能向着根的方向逐步过滤这种情况,不再有连续的两个红色节点或遇到根节点为止(把根重染成黑色)。这种策略自底向上,逐步递归完成。见图4



 
我们还有一种策略是自顶向下,如果遇到一个黑色节点有两个红色节点,我们将进行颜色翻转(如果节点X有两个红色孩子,我们将X染成红色,将它的两个孩子染成黑色),如果X的父节点也是红色的呢?我们这又归于34两种情况。X的父节点的兄弟节点会不会是红色的呢?答案是不会,应为我们自顶向下已经排除了这种情况。此时我们可以排除情况5,将所有情况都转换为情况一到四的处理,除了特殊情况,就剩下情况三和情况四了。下边是具体实现:

 

public void insert(E item){
		current = parent = grand = header;
		nullNode.element = item;
		//当未找到正确插入位置时,一直向下查找,并对树中一个黑节点有两个红节点的情况进行调整
		while(compare(item,current)!=0){
			great = grand;grand = parent;parent = current;
			current = compare(item,current)<0?current.left:current.right;
			
			if(current.left.color==RED&&current.right.color==RED)
				handleReorient(item);
		}
		
		if(current!=nullNode){
			System.out.println("该项已存在");
			return;
		}
		//创建节点并插入修复
		current = new RedBlackNode<E>(item, nullNode, nullNode);
		if(compare(item,parent)<0){
			parent.left = current;
		}
		else{
			parent.right = current;
		}
		current.parent = parent;
		handleReorient(item);
		nullNode.element = null;
	}

	//对要插入的节点的链进行调整修复
	private void handleReorient(E item){
		current.color = RED;
		current.left.color = BLACK;
		current.right.color = BLACK;
		
		if(parent.color == RED){
			grand.color = RED;//先把要旋转的树的根节点染红
			//如果不是外节点,则需要旋转两次,第一次旋转以parent为根,这里传过去的参数树根的父节点
			if((compare(item,parent)<0)!=(compare(item,grand)<0))
				parent = rotate(item,grand);
			current = rotate(item,great);
			
			current.color = BLACK;//将新的根节点染黑
		}
		header.right.color = BLACK;//将整棵树的根节点染黑
	}
	
	/**
	 * 明确旋转树在根的左边还是右边后,我们将旋转树父节点指向根,如果最终项在左边就右旋,在右边就左旋
	 * @param item 最终项 
	 * @param parent 旋转树的根的父节点 
	 * @return 旋转树的根节点
	 */
	private RedBlackNode<E> rotate(E item,RedBlackNode<E> parent){
		if(compare(item,parent)<0){
			parent.left = compare(item,parent.left)<0?
				rotateWithLeftChild(parent.left):rotateWithRightChild(parent.left);
			parent.left.parent = parent;
			return parent.left;
		}
		else{
			parent.right = compare(item,parent.right)<0?
				rotateWithLeftChild(parent.right):rotateWithRightChild(parent.right);
			parent.right.parent = parent;
			return parent.right;
		}
	}
	//以左孩子为支点旋转,即我们所说的右旋(形象理解为以支点为中心向右旋转),t1为旋转树的根.返回新根
	private RedBlackNode<E> rotateWithLeftChild(RedBlackNode t1){
		
		RedBlackNode<E> t2 = t1.left;//得到左孩子
		t1.left = t2.right;//将左孩子的右子树作为原根的左子树
		t2.right = t1;//此时t2作为新根
		
		t1.parent = t2;
		t2.left.parent = t2;
		t1.left.parent = t1;
		t1.right.parent = t1;
		return t2;
	}
	//以右孩子为支点旋转,即左旋
	private RedBlackNode<E> rotateWithRightChild(RedBlackNode t1){
		RedBlackNode<E> t2 = t1.right;
		t1.right = t2.left;
		t2.left = t1;
		
		t1.parent = t2;
		t2.right.parent = t2;
		t1.left.parent = t1;
		t1.right.parent = t1;
		return t2;
	}

 二、红黑树的删除

 

 

红黑树的删除相对复杂些,但只要我们思路明确,问题就迎刃而解。我们先回忆普通二叉树的删除操作,可分为三种情况:1.没有孩子节点:直接删掉该节点2.只有一个孩子节点:将要删除节点的父节点直接与该孩子节点相链3.有两个孩子节点:将中序遍历的后继,即待删除节点的右子树中的最小节点赋给待删除节点,然后将该后继删掉。实际最终都会归于12两种情况。

 

       对于红黑树来说,我们不仅要满足二叉树的性质,而且要满足着色要求,所以讨论的情况会比较多,我们从简单的情况开始讨论。如果待删除的实际节点是红色的,我们可以用普通方法进行删除,因为删除过后树依然满足红黑树的性质。如果待删除的实际节点是黑色的,就会出现三个问题:一、如果删除的节点是根节点,而他的红色孩子成了根节点,这就违反了性质2 二、如果删除的节点的父节点是红色的,而该节点的孩子也是红色的,删除之后就会违反性质4 三、删除了一个黑色节点后,包含该节点的任何路径黑色节点数都会少1,从而违反了性质5。我们的任务就是把这些问题解决。

 

       首先,我们解决问题的总体思路很简单:将节点删除,然后通过旋转和适当的着色来修复树使之重新满足红黑树的性质。我们的入手点就是我们之后所说的当前节点。我们知道实际删除的节点要么只有一个孩子,要么没有孩子,如果该删除节点有一个孩子,则将这个孩子作为当前节点,如果没有孩子,则将nullNode节点作为当前节点。当前节点的父节点就是原删除节点的父节点。我们第一次调整树就是从上边描述的当前节点x开始。(要记住第一个当前节点,要不然后边的描述会变得含糊不清)。

 

       对于问题1我们很好解决,最后再把根节点涂黑即可。而对于问题2,我们可以把当前节点涂黑就可以让树满足红黑树的性质。现在我们需要关注的问题是问题3,即让删除节点后的树依然满足红黑树的性质5——各个节点到根节点到叶节点nullNode所包含的的黑节点数相同。

 

       现在你有什么思路呢?如果像先前我们执行插入的思路那样,自顶向下,保证删除的节点是红色的,这看起来是可行的,但要怎么处理呢?要处理的情况是不是太多了?我们可不可以加上一些条件限制来减少对情况的处理,比如左节点不能是红色的?

 

       这里的处理的思路是:既然删除节点后经过该节点的路径上黑色节点都少一个,我们可不可以将这黑色下推到他的子节点,这样子节点就有了两重颜色。这样就可以满足性质5了。而又违反了性质1,即节点要么是红色的要么是黑色的。我们要做的在保持性质的情况下去掉。(实际上这一层黑色是我们处理问题所转换的标记,并非节点的颜色属性,当前节点指向谁,谁就有了这一层黑色,最终我们在保证基本性质的情况下去掉这一层黑色的影响,问题解决)要想去掉这一层黑色,我们处理的方式有:1.将这层黑色推向一个包含删除节点路径上的一个红色节点,在满足其他性质的情况下将其染成黑色。2.一直推至根节点,减掉这层黑色。(因为我们每次的调整最后都是满足性质的)3.在某些情况下,通过调整和重新着色,我们就可以保住性质,当然这一点有点难以凭空想象,那就看看下边的情况分析吧。

 

       首先我们可以将情况分为两类,即当前节点是其父节点的右节点或左节点,他们是对称的,只需要对一种情况进行详细讨论,另一种情况也就是以此类推了。一般的资料都是对当前节点x是做节点的情况进行分析,在这里我们就先对x是右节点的情况一一画图进行分析。这里先对图中的标记进行解释:x表示当前节点,w表示当前节点的,p表示x的父节点,c表示某个确定的颜色(可能是红,也可能是黑,就看实际情况了)对应于逻辑中的存在,c'表示任意颜色(才不管你是什么颜色咧,对讨论无影响)对应于逻辑中的任意。

 

       情况1:当前节点x的兄弟w是红色的。这种情况我们可以确定x的父节点为黑色,w的两个孩子为黑。如图所示,我们先对p染红,再将w染黑,然后对p进行一次右旋,红黑性质得以保持。而这时新的兄弟节点是黑色的,进而可以将情况一转换成情况234中的一种。



 
情况2:当前节点x的兄弟节点w是黑色的,且w的两个孩子都是黑色的。这种情况我们无法确定父节点p的颜色,所以其颜色标记为c,情况34同。在这个情况下,我们可以将xw同时去掉一层黑色,将这一层黑色指向根节点pp变成新的当前节点x。此时如果该标记颜色c为红色,则可以将节点染成黑色,此时指针x的那一层黑色被去掉,同时红黑性质得到满足,调整完毕;如果c为黑色,则需要对新的当前节点x的情况进行处理,直到调整完毕。



 
情况3:当前节点x的兄弟节点为黑色,且w的左孩子是黑色,右孩子是红色的。在这种情况下,我们将w和其右孩子的颜色交换,并对w进行左转,红黑性质得以保持。此时已将情况3转换成情况4



 
情况4:当前节点x的兄弟节点w为黑色,且w的左孩子是红的,有孩子可以为任意颜色。将p的颜色赋给w,然后将p,和w的左节点染黑,并对p做右旋转。这是可以去掉x的额外的黑色,而且可以保持红黑性质。最后将树的根赋给x后,调整结束。



    
我们发现,情况134最多经过三次旋转调整就可以结束。情况二在最坏的情况下一直向上推最多也是树的层数log(n),这就是红黑树删除操作的性能优势。

 

这样我们就可以根据上边的分析进行代码实现了。

	/**
	 * 1.没有孩子节点:直接删掉该节点
	 * 2.只有一个孩子节点:将要删除节点的父节点直接与该孩子节点相链
	 * 3.有两个孩子节点:将中序遍历的后继,即待删除节点的右子树中的最小节点赋给待删除节点,然后将该后继删掉。
	 * @param e 要删除的元素
	 * @param t 删除元素的树的根节点
	 * @return	被删除的节点
	 */
	private RedBlackNode<E> remove(E e,RedBlackNode<E> t){
		if(t==nullNode)
			return null;
		//找到要删除的节点
		while(compare(e,t)!=0){
			if(compare(e,t)<0&&t.left!=nullNode)
				t = t.left;
			else if(compare(e,t)>0&&t.right!=nullNode)
				t = t.right;
			else
				return null;
		}
		RedBlackNode<E> x;//当前节点
		RedBlackNode<E> y;//要删除的节点
		//如果待删除节点只有一个孩子或没有孩子,则实际删除的节点就是所指节点,如果有两个孩子则实际删除节点是右子树的最小项
		//先确定删除节点
		if(t.left==nullNode||t.right==nullNode)
			y = t;
		else
			y = findMin(t.right);
		//再确定当前节点,如果实际删除节点的左节点不为nullNode,则当前节点为左节点,如果删除节点只有右节点或没有孩子
		//我们都将右孩子赋给他,因为没有孩子时右节点为nullNode
		if(y.left!=nullNode)
			x = y.left;
		else
			x = y.right;
		//当前节点的父亲指向删除节点的父亲
		x.parent = y.parent;
		
		//我们根据删除节点在其父节点的子树方向,将父节点直接链接到当前节点
		//需要注意的是我们引用的伪根节点就是为了减少对特殊情况的讨论,不然有这行代码if(y.parent==header)header.right = x;
		if(y==y.parent.left)
			y.parent.left = x;
		else
			y.parent.right = x;
		//如果实际删除的节点不是我们找到的具有该键的节点,它属于有两个孩子的情况,我们将实际删除的节点的值赋给它
		if(y!=t)
			t.element = y.element;
		if(y.color==BLACK)
			removeFixUp(x);
		return y;
	}

	/**
	 * 删除修复方法
	 * @param x 当前节点
	 */
	private void removeFixUp(RedBlackNode<E> x){
		RedBlackNode<E> w;
		while(x!=header.right&&x.color==BLACK){
			//当前节点是父节点的左节点
			if(x==x.parent.left){
				w = x.parent.right;
				//case1,如上详细分析,如果x的兄弟节点为红色,调整后是兄弟节点为黑后再往下走
				if(w.color==RED){
					x.parent.color = RED;
					w.color = BLACK;
					rightRotate(x.parent,x.parent.parent);
					w = x.parent.right;
				}
				//case2,新的x如果为红色,循环终止,否则继续循环
				if(w.left.color==BLACK&&w.right.color==BLACK){
					w.color = RED;
					x = x.parent;
				}else {
					//case3,兄弟节点的右孩子是黑色的,左孩子时红色的,调整后进入case4
					if(w.right.color==BLACK){
						w.left.color = BLACK;
						w.color = RED;
						rightRotate(w, w.parent);
						w = x.parent.right;
					}
					//case4,调整完成,满足红黑性质
					w.color = x.parent.color;
					x.parent.color = BLACK;
					w.right.color = BLACK;
					leftRotate(x.parent, x.parent.parent);
					x = header.right;
				}
			}else{
				//对称
			}
			x.color = BLACK;
		}
	}

    对红黑树的插入和删除的分析让我回想起高中做数学题的时代,几乎每一次综合考试都有分类讨论的题目吧,只要把问题全集分成便于解决的子集,剩下的就是苦力活了。如果把解决问题当做玩“2014”这类的小游戏,那也是挺有趣的。不过好久没写博客了,表达能力也欠佳,写了大半天,累了还要爱,加油吧。如果刚接触红黑树,上边的图要自己画一画哦,只YY是很困难滴,当然,这是假设你跟我一样sha的前提下,哈哈,多指教指教咯。

 

 

 

  • 大小: 134.5 KB
  • 大小: 170.2 KB
  • 大小: 102.3 KB
  • 大小: 101.2 KB
  • 大小: 96.6 KB
  • 大小: 120.1 KB
  • 大小: 110.7 KB
4
0
分享到:
评论
2 楼 JAVA小韦 2014-05-13  
lianglaiyang 写道
else{  
        if(t.left!=null&&t.right!=null){  
            //左右节点都不为空的情况,我们把右子树最小项的值赋给删除节点,并物理删除最小项  
            t.element = findMin(t.right).element;  
            t.right = removeMin(t.right);  
        }else  
            //这一行代码很巧妙地解决了有一个孩子节点和没有孩子节点的删除(t即为当前节点--删除后删除节点的父节点所指向的节点)  
            t = (t.left!=null)?t.left:t.right;  
    }  


这段没看明白



我也不太清楚在什么地方不理解。首先你得明白使用的这个方法在递归的堆栈中保存了父亲链,就是说直到我们找到要删除的节点,我们就将要删除的节点的子节点(实际删除的节点要么只有一个子节点要么为nullNode节点)返回,通过返回值构成的链最后得到的根节点就是删除节点后的根节点。如果是代码层面的问题,这段代码中的if解决的是有两个孩子节点的情况,其中的findMin和remomveMin没有给出代码,思路也很简单,一直往左链找就行了。else中就是上边删除的思路,等号左边的t代表的是要删除的节点。
1 楼 lianglaiyang 2014-05-13  
else{  
        if(t.left!=null&&t.right!=null){  
            //左右节点都不为空的情况,我们把右子树最小项的值赋给删除节点,并物理删除最小项  
            t.element = findMin(t.right).element;  
            t.right = removeMin(t.right);  
        }else  
            //这一行代码很巧妙地解决了有一个孩子节点和没有孩子节点的删除(t即为当前节点--删除后删除节点的父节点所指向的节点)  
            t = (t.left!=null)?t.left:t.right;  
    }  


这段没看明白

相关推荐

Global site tag (gtag.js) - Google Analytics