數據結構之查找表

前言

今天學習數據結構看到了一個詞-靜態查找表,還有與之對應的動態查找表,然後我發現。

啊,這是個啥,好像知道又好像不知道,不就是查找嗎,幹嘛弄這些專業的說法,回頭翻了一下數據結構的書,才發現......。

唉,小小的抱怨一下,不過,我從這兩個詞聯想到了一門基礎但是要精通又不簡單的學問,就是查找,然後還有前天被面試官問到的一個查找題,題目很簡單,如何查找單向鏈表中倒數第K個數?當然你先遍歷一遍數組,獲取數組的總長度,然後再遍歷一遍找倒數第K個數,當然可以,但是既然是面試題,這樣的答案基本是涼涼的,我自己都不滿意,更別提人家面試官了,當你拋出這個答案之後,面試官會說如果只遍歷一遍呢?你就懵逼了,如果你和我一樣懵逼的話,那麼恭喜你,你現在還來得及,當然正確方法我就不說了,感興趣的可以去想想,實在想不出來,我相信你會有辦法的,xixixi,也不難。

好了,說了這麼多,今天既然發現了自己的一個薄弱區-查找,指不準以後哪天還會在查找這裏栽倒,於是,何不在這之前好好梳理一下查找這塊呢?

目錄

1.靜態查找表

2.動態查找表

3.哈希表

正文

首先我們需要明白下靜態查找表和動態查找表的定義,所謂的靜態查找表是指在查找過程中只進行查找操作,動態查找表就是在查找過程中還會進行插入和刪除操作,例如,查找某個元素,若查找到了,則刪除。

1.靜態查找表

靜態查找表可以有不同的表示方法,在不同的表示方法中,實現的查找操作方法也不同。

這裏列舉平時遇到的最爲常見的兩種情況

a.以順序表或線性鏈表表示靜態查找表,則查找可用順序查找來實現

b.以有序表(排好順序的順序表) 表示靜態查找表,則查找可用折半查找(二分查找)來實現

第一種,就是一個for循環的事,就不贅述了,這裏看一下第二種情況,也就是二分查找的代碼,思想就是從中間位置起開始查找,如果正好位於正中間,那麼就找到了,如果比正中間數據大,那麼在後半部分查找,假設整個數據是升序,如果比正中間數據小,那麼在前半部分查找,依次類推,遞歸結束的標誌就是左邊界大於右邊界,也就是已經不能再分了,如果此時還沒查找到,那麼就返回未查找到即可

public class BinarySearch {

	public static void main(String[] args) {
		// TODO Auto-generated method stub
		int[] a=new int[]{2,3,5,8,9};
		int index=search(a,50);
		System.out.println(index);
	}
	public static int search(int[] a,int data){
		return binarlSearch(a, 0, a.length-1,data);
	}
	public static int binarlSearch(int[] a,int left,int right,int data){
		if(left>right)
			return -1;
		int mid=(left+right)/2;
		if(a[mid]==data){
			return mid;
		}else if(a[mid]<data){
			return binarlSearch(a, mid+1, right, data);
		}else{
			return binarlSearch(a, left, mid-1, data);
		}
	}
}

上面的只是查找裏的入門知識,學任何東西都像打怪升級一樣,先從小怪開始,由簡變難,下面我們升級來打一個大點的怪!

2.動態查找表

動態查找表的特點是:表結構本身是在查找過程中動態生成的,即對於給定的key值,若表中存在其關鍵字等於key的記錄,則查找成功返回,否則插入關鍵字等於key的記錄。

動態查找表也可有不同的表示方法,這裏主要討論以各種樹結構表示時的實現方法。

a.二叉排序樹(又叫  二叉搜索樹  二叉查找樹)

二叉排序樹的特點是:

1.若它的左子樹不爲空,則左子樹上所有節點的值均小於它的根節點的值

2.若它的右子樹不爲空,則右子樹上所有節點的值均大於它的根節點的值

3.它的左右子樹也分別爲二叉排序樹

在瞭解了這個樹的性質之後,我們不難聯想到二分查找的思想,只不過大同小異,首先和根節點比較,如果比根節點大,則在右子樹中查找,如果比根節點小,則在左子樹中查找,如果相等,那麼正好查找到了,如果左子樹也沒有,右子樹也沒有,則查找失敗。

好了,我們掌握了上面的思想,不難寫出下面的代碼

	public boolean contains(Node root, Node node) {
		if (root == null) {
			return false;
		}
		if (node.data == root.data) {
			return true;
		} else if (node.data > root.data) {
			return contains(root.rChild, node);
		} else {
			return contains(root.lChild, node);
		}
	}

代碼不難理解,然後我們再考慮這樣一種情況,就是一個有序的鏈表,是不是也符合二叉排序樹的性質,也就是說我們查找的這棵二叉排序樹是有可能退化爲一個鏈表的,那麼我們再對照着上面這個查找的代碼去看,會發現查找的效率已經退化成了O(n),這顯然不是我們想要看到的結果,我們想要的效果是儘可能的將一組數據分爲等長的兩部分,以達到類似“二分”的效果,只有這樣最終的效率纔可以達到O log(n)的最優效果,於是我們就引出了這樣一個問題,如何保證二叉排序樹查找的效率維持在O log(n)呢?接着往下看

b.平衡二叉樹

在上面,我們發現如果不對二叉排序樹做任何處理,發現查找的效率會有可能退化爲鏈表的查找效率,所以我們期望有一種解決方案能避免效率的降低,現在再來想想,爲什麼我們的查找效率會降低,究其原因就是二叉樹退化成了鏈表,那麼我們必須以某種手段來防止退化,比如強制要求左右子樹的高度差小於某個值等措施,於是我們自然而然想到了平衡二叉樹。

 

所以解決方案就是讓二叉排序樹轉換爲平衡二叉樹,這個就好說了,主要涉及到四個操作,左左旋(LL)、右右旋(RR)、左右旋(LR)、右左旋(RL)。通過在插入一個元素的時候,判斷是否符合平衡二叉樹的性質,也就是左右子樹的高度差是否小於1,如果不符合,那麼根據情況做相應的旋轉處理即可。

由於幾個旋轉基本類似,只要掌握了一個,剩下的依葫蘆畫瓢即可,我們現在來看下左左旋的代碼

	// 左左翻轉 LL
	// 返回值爲翻轉後的根結點
	private Node leftLeftRotation(Node node) {
		// 首先獲取待翻轉節點的左子節點
		Node lNode = node.lChild;

		node.lChild = lNode.rChild;
		lNode.rChild = node;

		node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
		lNode.height = max(getHeight(lNode.lChild), node.height) + 1;

		if (node == root) {
			root = lNode;// 更新根結點
		}
		return lNode;
	}

剩下三個操作就不細說了,接下來我們再在插入和刪除的時候,進行適當的判斷,插入代碼如下

	public void insert(int data) {
		if (root == null) {
			root = new Node(data);

		} else {
			insert(root, new Node(data));
		}
	}

	// node 爲插入的樹的根結點
	// insertNode 爲插入的節點
	private Node insert(Node node, Node insertNode) {
		if (node == null) {
			node = insertNode;
		} else {
			if (insertNode.data < node.data) {// 將data插入到node的左子樹
				node.lChild = insert(node.lChild, insertNode);
				// 如果插入後失衡
				if (getHeight(node.lChild) - getHeight(node.rChild) == 2) {
					if (insertNode.data < node.lChild.data) {// 如果插入的是在左子樹的左子樹上,即要進行LL翻轉
						node = leftLeftRotation(node);
					} else {// 否則執行LR翻轉
						node = leftRightRotation(node);
					}
				}
			} else if (insertNode.data > node.data) {
				// 將data插入到node的右子樹
				node.rChild = insert(node.rChild, insertNode);
				// 如果插入後失衡
				if (getHeight(node.rChild) - getHeight(node.lChild) == 2) {
					if (insertNode.data > node.rChild.data) {
						node = rightRightRotation(node);
					} else {
						node = rightLeftRotation(node);
					}
				}
			} else {
				System.out.println("節點重複啦");
			}
			node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
		}
		return node;
	}

刪除代碼如下

	public void remove(int data) {
		Node removeNode = new Node(data);
		if (contains(root, removeNode)) {
			remove(root, removeNode);
		} else {
			System.out.println("節點不存在,無法刪除"+data);
		}
	}

	// 刪除節點
	private Node remove(Node node, Node removeNode) {
		if (node == null) {
			return null;
		}
		// 待刪除節點在node的左子樹中
		if (removeNode.data < node.data) {
			node.lChild = remove(node.lChild, removeNode);
			// 刪除節點後,若失去平衡
			if (getHeight(node.rChild) - getHeight(node.lChild) == 2) {
				Node rNode = node.rChild;// 獲取右節點
				// 如果是左高右低
				if (getHeight(rNode.lChild) > getHeight(rNode.rChild)) {
					node = rightLeftRotation(node);
				} else {
					node = rightRightRotation(node);
				}
			}
		} else if (removeNode.data > node.data) {// 待刪除節點在node的右子樹中
			node.rChild = remove(node.rChild, removeNode);
			// 刪除節點後,若失去平衡
			if (getHeight(node.lChild) - getHeight(node.rChild) == 2) {
				Node lNode = node.lChild;// 獲取左節點
				// 如果是右高左低
				if (getHeight(lNode.rChild) > getHeight(lNode.lChild)) {
					node = leftRightRotation(node);
				} else {
					node = leftLeftRotation(node);
				}
			}
		} else {// 待刪除節點就是node
				// 如果Node的左右子節點都非空
			if (node.lChild != null && node.rChild != null) {
				// 如果左高右低
				if (getHeight(node.lChild) > getHeight(node.rChild)) {
					// 用左子樹中的最大值的節點代替node
					Node maxNode = maxNode(node.lChild);
					node.data = maxNode.data;
					// 在左子樹中刪除最大的節點
					node.lChild = remove(node.lChild, maxNode);
				} else {// 二者等高或者右高左低
					// 用右子樹中的最小值的節點代替node
					Node minNode = minNode(node.rChild);
					node.data = minNode.data;
					// 在右子樹中刪除最小的節點
					node.rChild = remove(node.rChild, minNode);
				}
				
			} else {
				// 只要左或者右有一個爲空或者兩個都爲空,直接將不爲空的指向node
				// 兩個都爲空的話,想當於最後node也指向了空,邏輯仍然正確
				node = node.lChild == null ? node.rChild : node.lChild;// 賦予新的值
			}
		}
		if(node!=null) {
			node.height = max(getHeight(node.lChild), getHeight(node.rChild)) + 1;
		}
		return node;
	}

 每次在插入和刪除時進行這樣的操作之後,我們的二叉排序樹終於變成一顆平衡二叉樹啦,這樣我們再執行上面一模一樣的查找代碼時,查找的效率就可以穩定在O log(n),完美!

 

其實仔細想想,這個問題,也是一個取捨的問題,雖然我們最後讓二叉排序樹的查找效率穩定爲O log(n),但是我們卻付出了不小的代價,就是每次插入以及刪除的時候,都要進行大量的判斷以及節點轉換,這肯定會大大降低插入和刪除的效率,與之帶來的收益就是查找效率高,這樣再一想,發現有點類似數組和鏈表的優缺點了,hhhh,事物總是有兩面性,所以我們在實際使用時,也要根據場景來適當的做出取捨。

3.哈希表

在說哈希表之前,先回憶一下兩個最簡單的容器,一個是數組,一個是鏈表,這二者的優缺點我就不囉嗦了,之前的博客我也說過這樣一個問題,既然數組查詢快,鏈表插入刪除快,就不能發明一種容器能兼具這二者的優點嗎?這樣豈不時省事多了,答案是能,沒錯,這個神奇的容器就是HashMap,而其核心就是哈希表,這時候,你興沖沖的去查詢哈希表,可能會遇到很多晦澀難懂的概念,什麼關鍵字衝突,線性探測再散列,鏈地址法,再哈希等等,一下子頭就大了,放心,我不會去給你灌輸這些概念,我喜歡以實際使用的東西,或者叫“成品”來學習某個新的東西,然後再反過來看這些概念,這樣就自然而然懂了,所以我們先簡單瞭解HashMap的實現原理,再來看哈希表,就自然而然懂了。

既然是瞭解HashMap的實現原理,最最正確的方式就是直接打開jdk的源碼去看,重點看核心方法put和remove即可,限於篇幅,我就說下大致思想。

我們首先需要知道的是HashMap存儲的數據是鍵值對的形式,也就是key-value形式,然後就是HashMap裏的一些重要成員變量及類,其中最重要的就是Node對象,每個Node對象含有key和value字段,用於保存插入的key和value值,Node數組的默認長度是16,當插入一個元素的時候,首先計算key的hash值,然後直接和數組長度-1做與運算,這樣就定位到一個具體的下標,然後判斷下標處是否有元素值存在,如果有,則以尾插法在該處形成一個鏈表,否則,就直接放入這個插入元素即可,所以最終效果就是這樣的

看了這個結構圖後,我們再回到我們的主題--查找,我們再來看看HashMap是如何查找的,首先拿到key值,計算key的hash值,然後同樣的方法,和長度減1做與運算,得到下標,如果該下標處爲空,則返回找不到,如果不爲空,則從鏈表頭開始,逐個遍歷該鏈表,直到找到對應的value值與給定value值相等,若鏈表遍歷完了仍然沒有找到,則返回找不到。

我們現在再來看看這個設計有什麼巧妙的地方,當我們在查詢一個元素時,發現,對於哈希表來說,首先會根據key值來定位一個下標,這個巧妙利用了數組的優勢,這樣就不用去逐個遍歷所有元素,然後如果發現該下標處已經存在了元素,則形成一個鏈表,而在形成一個鏈表之後,對同一個下標處的元素來說,插入刪除的效率也變高了。或者換種通俗的話就是,使用數組將一個鏈表分割成了多個小段。總的來說這種設計就是結合了數組和鏈表,利用了二者的優勢所在,完美結合!

好了,到這裏,其實就已經學習了哈希表的一種,如上圖,就是鏈地址法解決衝突的哈希表,相信到這裏你也明白了,所謂的鏈地址法的具體含義,就是形成一個鏈表來解決衝突問題。

鏈地址法也是最常見的一種設計哈希表的方法,我們現在再來看看另外的兩種。

線性探測再散列法,這種設計方式設計的哈希表的原理就是,當插入一個元素的時候,同樣的先定位到一個下標,然後如果該下標處已經存在了元素,則判斷下標+1的地方是否有元素存在,同樣的如果仍然有元素存在,則下標繼續+1,直到對應下標處沒有元素存在時,則將元素插在這個地方。

再哈希法,這個設計方式就比較簡單粗暴了,直接在計算key的hash值的方法時,也就是在定位具體的下標時,用兩次hash函數來計算,這樣原本一次hash計算到相同地方的元素,因爲有第二次hash計算,所以會在二次hash函數處理之後,再次判斷是否定位到了同一下標,若還是定位在統一下標,則繼續hash函數處理,直到衝突不再發生。

我們仍然回到我們的主旨--查找上來,對這兩種方法設計的哈希表,我們在查找時就是先定位,然後如果不存在元素,則“查找返回空”,否則比對對應的value值,如果不相等,則根據設計方法(線性探測再散列或再哈希)得到“下一個地址”,然後判斷“下一個地址”是否爲要查找的元素。

好了,到這裏,基本說完了三種設計哈希表的方式以及對應的查找方法,個人覺得每一種方式都有自己的特點和適用場景,沒有孰好孰壞,只有當我們都掌握了之後,我們纔可以去選擇用哪種方式來實現哈希表,完成業務要求。

結語

這次說的主要是查找,內容相對簡單,但是查找這個東西,一但結合實際場景之後,還是有很多需要注意和深究的地方,比如海量數據排序和查找,所以只有會了基礎,纔能有選擇的餘地,以後實際場景中若是遇到了相關的問題,再總結歸納一篇“實際場景版”的,今天就到這裏了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章