【Algorithm&DataStructure】極客時間-資料結構與演算法之美專欄筆記I

TypantK發表於2019-03-01

以下內容均來自本人學習專欄時的個人筆記、總結,侵權即刪

專欄地址:https://time.geekbang.org/column/126

希望看到本文章的,可以去支援一下老師,講的很好!!


 

目錄

時間複雜度為O(n)=logn的程式碼

沒有頭結點要多判斷什麼?-->哨兵結點作用

陣列和連結串列的區別

容器(ArrayList)和陣列的選擇

 

佇列

阻塞佇列 

併發佇列

佇列的應用場景和實現方式選擇

 

遞迴

遞迴需要滿足的三個條件

如何編寫遞迴

遞迴注意事項

警惕堆疊溢位(空間複雜度高)

警惕重複計算

 

排序

O(n^2)的排序(基於比較)

氣泡排序

插入排序

選擇排序

O(nlogn)的排序(基於比較)

歸併排序(分治思想=大問題化成小問題)(遞迴程式設計技巧)

快排

O(n)的排序(非基於比較,對資料要求苛刻,複雜度n-》線性排序)

桶排序

計數排序(桶排序的一種特殊情況)

基數排序(排序低位-->排序高位)

 

二分查詢(O(logn))

二分查詢變體

思考題1(LeetCode33)

思考題2(求一個數平方根,小數點精確到後六位)

 

跳錶(區間查詢)[連結串列中的二分查詢]{Redis-->雜湊表+跳錶}

|S:O(logn) K:O(n)|

跳錶索引的動態更新:

為什麼Redis用跳錶不用紅黑樹

 

雜湊表(高效的CRUD)

核心

雜湊表雜湊衝突的解決方法

開放定址法(不允許在同一個結點)

連結串列法(允許在同一個結點)

如何設計一個雜湊表

為什麼雜湊表和連結串列經常一塊使用(順序遍歷)

 

雜湊演算法的分散式應用

負載均衡

資料分片

分散式儲存

 


 

 

 

 

 

時間複雜度為O(n)=logn的程式碼

i = 1;
while(n<i){
    i = i*2;
}

變數 i 的取值就是一個等比數列。如果我把它一個一個列出來,就應該是這個樣子的:

所以,只需要知道x的值,就可以知道這段程式碼執行的次數了,也就是log2n

而對於 i = i × x 的情況(x是一個常數,可以想成3),也可以得知時間複雜度是logxn

而所有對數階的時間複雜度一般都表示為logn,因為可以通過換底公式,logxn = logx2 × log2n(x是一個常數)

 

 

平均時間複雜度=單一情況發生的概率 × 這種情況的時間複雜度

--->>>均攤時間複雜度(思維角度):O(1)->O(1)->O(1)->O(1)->...n次...->O(n) :執行n次O(1)後會有一次O(n)的操作

可以將O(n)分成n次均攤到每個O(1)的操作,這樣算下來整個程式碼的平均時間複雜度也就是O(1)了

 

 

 

沒有頭結點要多判斷什麼?-->哨兵結點作用

/*
*    沒有頭結點的插入、刪除
*/


//一般插入結點
new_node->next = p->next;
p->next = new_node;

//空連結串列第一個結點
if (head == null) {
  head = new_node;
}

//刪除結點
p->next = p->next->next;

//空連結串列最後一個結點刪除
if (head->next == null) {
   head = null;
}

如果有頭結點(不存資料的結點),不管有沒有結點都可以使用同一個邏輯了,不用再根據特殊情況來判斷

 

陣列和連結串列的區別

這裡我要特別糾正一個“錯誤”。我在面試的時候,常常會問陣列和連結串列的區別,很多人都回答說,“連結串列適合插入、刪除,時間複雜度 O(1);陣列適合查詢,查詢時間複雜度為 O(1)”。實際上,這種表述是不準確的。陣列是適合查詢操作,但是查詢的時間複雜度並不為 O(1)。即便是排好序的陣列,你用二分查詢,時間複雜度也是 O(logn)。所以,正確的表述應該是,陣列支援隨機訪問,根據下標隨機訪問的時間複雜度為 O(1)

 

 

容器(ArrayList)和陣列的選擇

我個人覺得,ArrayList 最大的優勢就是可以將很多陣列操作的細節封裝起來。比如前面提到的陣列插入、刪除資料時需要移其他資料等。另外,它還有一個優勢,就是支援動態擴容。

  • Java ArrayList 無法儲存基本型別,比如 int、long,需要封裝為 Integer、Long 類,而 Autoboxing、Unboxing 則有一定的效能消耗,所以如果特別關注效能,或者希望使用基本型別,就可以選用陣列。
  • 如果資料大小事先已知,並且對資料的操作非常簡單,用不到 ArrayList 提供的大部分方法,也可以直接使用陣列。
  • 還有一個是我個人的喜好,當要表示多維陣列時,用陣列往往會更加直觀。比如 Object[][] array;而用容器的話則需要這樣定義:ArrayList<ArrayList> array

 

 

佇列


阻塞佇列 

其實就是在佇列基礎上增加了阻塞操作。簡單來說,就是在佇列為空的時候,從隊頭取資料會被阻塞。因為此時還沒有資料可取,直到佇列中有了資料才能返回;如果佇列已經滿了,那麼插入資料的操作就會被阻塞,直到佇列中有空閒位置後再插入資料,然後再返回。

你應該已經發現了,上述的定義就是一個“生產者 - 消費者模型”!是的,我們可以使用阻塞佇列,輕鬆實現一個“生產者 - 消費者模型”!

 

併發佇列

前面我們講了阻塞佇列,在多執行緒情況下,會有多個執行緒同時操作佇列,這個時候就會存線上程安全問題,那如何實現一個執行緒安全的佇列呢?

最簡單直接的實現方式是直接在 enqueue()、dequeue() 方法上加鎖,但是鎖粒度大併發度會比較低,同一時刻僅允許一個存或者取操作。實際上,基於陣列的迴圈佇列,利用 CAS 原子操作,可以實現非常高效的併發佇列。這也是迴圈佇列比鏈式佇列應用更加廣泛的原因。在實戰篇講 Disruptor 的時候,我會再詳細講併發佇列的應用。

 

佇列的應用場景和實現方式選擇

佇列的知識就講完了,我們現在回過來看下開篇的問題。執行緒池沒有空閒執行緒時,新的任務請求執行緒資源時,執行緒池該如何處理?各種處理策略又是如何實現的呢?我們一般有兩種處理策略。

第一種是非阻塞的處理方式,直接拒絕任務請求;另一種是阻塞的處理方式,將請求排隊,等到有空閒執行緒時,取出排隊的請求繼續處理。那如何儲存排隊的請求呢?我們希望公平地處理每個排隊的請求,先進者先服務,所以佇列這種資料結構很適合來儲存排隊請求。我們前面說過,佇列有基於連結串列和基於陣列這兩種實現方式。

這兩種實現方式對於排隊請求又有什麼區別呢?基於連結串列的實現方式,可以實現一個支援無限排隊的無界佇列(unbounded queue),但是可能會導致過多的請求排隊等待,請求處理的響應時間過長。所以,針對響應時間比較敏感的系統,基於連結串列實現的無限排隊的執行緒池是不合適的。而基於陣列實現的有界佇列(bounded queue),佇列的大小有限,所以執行緒池中排隊的請求超過佇列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設定一個合理的佇列大小,也是非常有講究的。佇列太大導致等待的請求太多,佇列太小會導致無法充分利用系統資源、發揮最大效能實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上都可以通過“佇列”這種資料結構來實現請求排隊。

 

 

遞迴


遞迴需要滿足的三個條件

  • 一個問題的解可以分解為幾個子問題的解
  • 這個問題與分解之後的子問題,除了資料規模不同,求解思路完全一樣
  • 存在遞迴終止條件

 

 

如何編寫遞迴

寫出遞迴公式,找到終止條件,將兩者轉換成程式碼

 

 

遞迴注意事項

警惕堆疊溢位(空間複雜度高)

遞迴呼叫一次就會在記憶體棧中儲存一次現場資料,所以在分析遞迴複雜度的時候要考慮這個部分,同時也要考慮遞迴層數過深導致的堆疊溢位。

解決方法:

  • 方法①:在比如說遞迴深度超過1000層的時候就丟擲異常,不再進行遞迴(規模小的時候適用,因為實時計算棧的剩餘空間過於複雜)
  • 方法②:自己模擬一個棧,用非遞迴程式碼實現

 

警惕重複計算

 

 

 

 

 

排序


 

O(n^2)的排序(基於比較)

氣泡排序

/**
	 * 氣泡排序
	 * @param a
	 */
	private static void bubbleSortLineryArray(int[] a) {
		
		for(int i=0;i<a.length;i++) {
			boolean flag = false;	//標記一輪冒泡中是否有交換資料,沒有就直接break
			for(int j=0;j<a.length-i-1;j++) {
				if(a[j] > a[j+1]) {
					int tmp = a[j];
					a[j] = a[j+1];
					a[j+1] = tmp;
					flag = true;
				}
			}
			if(!flag)break;
		}
	}

 

有序度、逆序度

滿有序度:完全有序的陣列的有序度

逆序度的定義正好跟有序度相反(預設從小到大為有序)

 

插入排序

將資料分為兩個區間:已排序區間和未排序區間

/**
	 * 插入排序
	 * @param a
	 */
	private static void insertSortLineryArray(int[] a) {
		
		for(int i=1;i<a.length;i++) {
			int value = a[i];
			int j = i - 1;
			//查詢插入的位置
			for(;j>=0;j--) {
				if(a[j] > value) {
					a[j+1] = a[j];
				}else {
					break;	//此時a[j+1]就是value要放的地方,a[j+1]的值在上個迴圈已經移動到a[j+2]了
				}
			}
			a[j+1] = value;
		}
	}

 

選擇排序

/**
	 * 選擇排序,每次選擇最小的交換位置
	 * @param a
	 */
	private static void selectSortLineryArray(int[] a) {
		for(int i=0;i<a.length-1;i++) {
			int min = a[i];
			int j = i;
			int b = i;
			for(;j<a.length;j++) {
				if(min>a[j]) {
					min = a[j];
					b = j;	//要記錄最小值的位置,然後來交換
				}
			}
			int tmp = a[i];
			a[i] = min;
			a[b] = tmp;
		}
	}

 

 

 

O(nlogn)的排序(基於比較)


歸併排序(分治思想=大問題化成小問題)(遞迴程式設計技巧)

如果要排序一個陣列,我們先把陣列從中間分成前後兩部分,然後對前後兩部分分別排序,再將排序好的兩部分合並在一起。就可以得到一個有序的陣列了。

 

*時間複雜度求解:

我們假設對 n 個元素進行歸併排序需要的時間是 T(n),那分解成兩個子陣列排序的時間都是 T(n/2)。我們知道,merge() 函式合併兩個有序子陣列的時間複雜度是 O(n)。所以,套用前面的公式,歸併排序的時間複雜度的計算公式就是:

T(1) = C;   n=1 時,只需要常量級的執行時間,所以表示為 C。
T(n) = 2*T(n/2) + n; n>1

通過這個公式,如何來求解 T(n) 呢?還不夠直觀?那我們再進一步分解一下計算過程。

T(n) = 2*T(n/2) + n
     = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
     = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
     = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
     ......
     = 2^k * T(n/2^k) + k * n
     ......

通過這樣一步一步分解推導,我們可以得到 T(n) = 2^kT(n/2^k)+kn。

當 T(n/2^k)=T(1) 時,也就是 n/2^k=1,我們得到 k=log2n 。

我們將 k 值代入上面的公式,得到 T(n)=Cn+nlog2n 。如果我們用大 O 標記法來表示的話,T(n) 就等於 O(nlogn)。所以歸併排序的時間複雜度是 O(nlogn)。

 

 

快排

快排的思想是這樣的:

  • 如果要排序陣列中下標從 p 到 r 之間的一組資料,我們選擇 p 到 r 之間的任意一個資料作為 pivot(分割槽點)。
  • 我們遍歷 p 到 r 之間的資料,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot 放到中間。經過這一步驟之後,陣列 p 到 r 之間的資料就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,後面的 q+1 到 r 之間是大於 pivot 的
  • 根據分治、遞迴的處理思想,我們可以用遞迴排序下標從 p 到 q-1 之間的資料和下標從 q+1 到 r 之間的資料,直到區間縮小為 1,就說明所有的資料都有序了。

private static void quickSortLineryArray(int[] a, int start, int end) {
		if(a.length == 0 || a.length == 1) {
			return ;
		}
		int i = start;
		int j = start;	//j負責檢查小於key的
		int key = a[end];
		while(j < end){
			if(a[j] < key) {
				int tmp = a[i];
				a[i] = a[j];
				a[j] = tmp;
				i++;
			}
			j++;
		}
		int tmp = a[i];
		a[i] = a[end];
		a[end] = tmp;
		if(i > start)quickSortLineryArray(a, start, i-1);
		if(i < end)quickSortLineryArray(a, i+1, end);
	}

 

*快排的優化:

快排在最壞的條件下(每次分割槽點都選擇最後一個資料),時間複雜度會退化O(n^2)

所以快排的優化核心就在對於分割槽點的選擇

**常用、簡單的分割槽演算法:

  • 三數取中法:從區間的首、尾、中間分別取出一個數,然後比較大小,取這三個數的中間值作為分割槽點。可以根據排序陣列的規模來上升為“五數取中”或者“十數取中”
  • 隨機法:隨機選擇一個元素作為分割槽點,看運氣

 

 

O(n)的排序(非基於比較,對資料要求苛刻,複雜度n-》線性排序)


桶排序

*資料要求:

  • 首先,要排序的資料需要很容易就能劃分成 m 個桶,並且,桶與桶之間有著天然的大小順序。這樣每個桶內的資料都排序完之後,桶與桶之間的資料不需要再進行排序。
  • 其次,資料在各個桶之間的分佈是比較均勻的。如果資料經過桶的劃分之後,有些桶裡的資料非常多,有些非常少,很不平均,那桶內資料排序的時間複雜度就不是常量級了。在極端情況下,如果資料都被劃分到一個桶裡,那就退化為 O(nlogn) 的排序演算法了。

*適用場景:

桶排序比較適合用在外部排序中。所謂的外部排序就是資料儲存在外部磁碟中,資料量比較大,記憶體有限,無法將資料全部載入到記憶體中。

 

計數排序(桶排序的一種特殊情況

*原理:

①準備:兩個陣列:

  • A:存放資料在不同下標(可以看做桶,下標值是資料的值)對應的個數(所謂“計數”)(陣列大小是原陣列資料數值個數)

==轉換成==> 當前桶在已排好序陣列中的位置(下標號),用求和的方式(A[k]儲存小於等於數值k的資料個數)

  • B:用於存放已排好序的資料的陣列(同原陣列大小相同)

②移動:每向B新增一個資料,A中對應下標資料上的值就-1(也就是個數少了一個,因為被分配了)

 

*適用場景&限制:

計數排序只能用在資料範圍(陣列A的大小)不大的場景,如果資料範圍 k 比要排序的資料 n 大很多,就不適合用計數排序了。

而且,計數排序只能給非負整數排序,如果要排序的資料時其他型別的,要將其不改變相對大小的情況下,轉換成非負整數。

 

基數排序(排序低位-->排序高位)

*資料要求:

資料需要可以分割出獨立的“位”來比較(比如十分位、百分位),而且位之間有遞進的關係(十分位比百分位弱),如果a的資料的高位比b資料大,那剩下的低位就不用比較了。除此之外,每一位的資料範圍不能太大,要可以用線性排序演算法來排序(演算法必須是穩定的,否則低位的排序就沒有意義了),否則基數排序的時間複雜度就無法做到O(n)了。

 

 

排序演算法的實現

O(n^2)不一定就是比O(nlogn)執行的時間要長,因為大O時間複雜度一般都省去了某些引數,而這些引數在小規模資料時的作用是不同的。所以對於小規模的資料,有時可以考慮一下複雜度O(n^2)的排序演算法

 

【Java】Collection.sort()的實現:

 

 

 

二分查詢(O(logn))


著重掌握它的三個容易出錯的地方:

  • 迴圈退出條件[ while( low <= high) , <的話可能會錯過最後一次迴圈時low或者high等於value]
  • mid的取值
  • low 和 high 的更新

 

 

實際上,mid=(low+high)/2 這種寫法是有問題的。因為如果 low 和 high 比較大的話,兩者之和就有可能會溢位。改進的方法是將 mid 的計算方式寫成 low+(high-low)/2。更進一步,如果要將效能優化到極致的話,我們可以將這裡的除以 2 操作轉化成位運算 low+((high-low)>>1) [外層括號不能省,優先順序問題]。因為相比除法運算來說,計算機處理位運算要快得多。

 

那二分查詢能否依賴其他資料結構呢?比如連結串列。答案是不可以的,主要原因是二分查詢演算法需要按照下標隨機訪問元素。我們在陣列和連結串列那兩節講過,陣列按照下標隨機訪問資料的時間複雜度是 O(1),而連結串列隨機訪問的時間複雜度是 O(n)。所以,如果資料使用連結串列儲存,二分查詢的時間複雜就會變得很高。

 

雖然大部分情況下,用二分查詢可以解決的問題,用雜湊表、二叉樹都可以解決。但是,我們後面會講,不管是雜湊表還是二叉樹,都會需要比較多的額外的記憶體空間。如果用雜湊表或者二叉樹來儲存這 1000 萬的資料,用 100MB 的記憶體肯定是存不下的。而二分查詢底層依賴的是陣列,除了資料本身之外,不需要額外儲存其他資訊,是最省記憶體空間的儲存方式,所以剛好能在限定的記憶體大小下解決這個問題。

 

二分查詢變體

①查詢第一個值等於給定值的元素

/**
	 * 查詢第一個值等於給定定值的元素
	 */
	private static int FirstEqualConst(int[] a, int value) {
		int low = 0;
		int high = a.length-1;
		while(low <= high) {
			int mid = low + ((high - low) >> 1);
			if(a[mid] > value) {
				high = mid - 1;
			}else if(a[mid] < value) {
				low = mid + 1;
			}else {
				if((mid == 0) || (a[mid - 1] != value))return mid;
				high = mid - 1;
			}
		}
		return -1;
	}

②查詢最後一個值等於給定值的元素

/**
	 * 查詢最後一個值等於給定定值的元素
	 */
	private static int LastEqualConst(int[] a, int value) {
		int low = 0;
		int high = a.length - 1;
		while(low <= high) {
			int mid = low + ((high - low) >> 1);
			if(a[mid] > value) {
				high = mid - 1;
			}else if(a[mid] < value) {
				low = mid + 1;
			}else {
				if(mid == a.length-1 || a[mid + 1] != value)return mid;
				low = mid + 1;
			}
		}
		return -1;
	}

③查詢第一個大於等於給定值的元素

/**
	 * 查詢第一個值大於等於給定定值的元素
	 */
	private static int FirstGreaterEquConst(int[] a, int value) {
		int low = 0;
		int high = a.length - 1;
		while(low <= high) {
			int mid = low + ((high - low) >> 1);
			if(a[mid] >= value) {
				if(mid == 0 || a[mid - 1] < value)return mid;
				high = mid - 1;
			}else {
				low = mid + 1;
			}
		}
		return -1;
	}

④查詢最後一個小於等於給定值的元素

/**
	 * 查詢最後一個小於等於(最靠近)給定定值的元素
	 */
	private static int LastLessEquConst(int[] a, int value) {
		int low = 0;
		int high = a.length - 1;
		while(low <= high) {
			int mid = low + ((high - low) >> 1);
			if(a[mid] <= value) {
				if(mid == a.length - 1 || a[mid + 1] > value)return mid;
				low = mid + 1;
			}else {
				high = mid - 1;
			}
		}
		return -1;
	}

 

思考題1(LeetCode33)

如果有序陣列是一個迴圈有序陣列,比如 4,5,6,1,2,3。針對這種情況,如何實現一個求“值等於給定值”的二分查詢演算法?

/**
	 * LeetCode-33-迴圈有序陣列的二分查詢
	 */
	private static int CircleArray(int[] a, int value) {
		/*
		 * 想法:一次迴圈找到中間點,判斷在哪個區間,複雜度O(n)
		 */
		int n = a.length;
		//flag=0代表value在中間點左邊,flag=1代表value在中間點右邊
		int flag = value >= a[0]? 0 : 1;	
		int max = n - 1 ;	//max是陣列最大值下標
		int high,low;
		for(int i=1;i<n-1;i++) {
			if(a[i-1]>a[i]) {
				max = i-1;
				break;
			}
		}
		if(flag == 0) {
			low = 0;
			high = max;
			while(low <= high){
				int mid = low + ((high - low) >> 1);
				if(a[mid] > value) {
					high = mid - 1;
				}else if(a[mid] < value) {
					low = mid + 1;
				}else {
					return mid;
				}
			}
			
		}else if(flag == 1) {
			low = max + 1;
			high = n - 1;
			while(low <= high){
				int mid = low + ((high - low) >> 1);
				if(a[mid] > value) {
					high = mid - 1;
				}else if(a[mid] < value) {
					low = mid + 1;
				}else {
					return mid;
				}
			}
		}
		return -1;
	}

 

思考題2(求一個數平方根,小數點精確到後六位)

/**
	 * 精確到後六位的平方根
	 */
	private static Double sqrt(double x, double precision) {
		double low = 0;
		double high = x;
		double mid = low + (high - low)/2;
		while(high - low > precision) {	//precision=0.00001
			if(mid * mid > x) {
				high = mid;
			}else if(mid * mid < x) {
				low = mid;
			}else {
				return mid;
			}
			mid = low + (high - low)/2;
		}
		return mid;	//取出來的值還要進行額外處理,將六位後的資料消掉
	}

 

跳錶(區間查詢)[連結串列中的二分查詢]{Redis-->雜湊表+跳錶}

|S:O(logn) K:O(n)|


具體實現:建立“索引”,以空間換時間

普通單連結串列查詢一個資料的時間複雜度O(n)

 

而如果跳錶每兩個結點就抽出一個結點作為上一級索引的結點,第一級索引個數為n/2,第二級索引個數為n/4,排下來第k級索引個數就是n/2^k,而最高階的索引個數有兩個結點,就可以推出級數k=log2n-1,而每一層最多遍歷n+1個結點,所以在跳錶中查詢一個數的時間複雜度就是O(logn)

 

跳錶索引的動態更新:

插入資料時,如果不更新索引,就可能出現兩個索引結點之間資料非常多的情況,極端一點,可能退化成單連結串列

解決:在插入結點的同時將這個資料插入到部分索引層

 

為什麼Redis用跳錶不用紅黑樹

Redis中有序集合支援的核心操作:

  • 插入一個資料
  • 刪除一個資料
  • 查詢一個資料
  • 按照區間查詢資料
  • 迭代輸出有序佇列

其他四個的操作,紅黑樹也可以完成,時間複雜度和跳錶一樣。

但是按照區間查詢資料,跳錶可以做到O(logn)的時間複雜度來定位區間的起點,然後在原始連結串列中往後遍歷就可以了

其次,跳錶相比紅黑表容易實現+好懂(但是一般程式語言中Map型別都是通過紅黑樹實現)

 

雜湊表(高效的CRUD)


核心

雜湊函式設計雜湊衝突解決

 

雜湊表雜湊衝突的解決方法

開放定址法(不允許在同一個結點)

  • 線性探測:衝突後+一個常量,如果為空就插入,如果不為空就再加,一直到尾然後在從頭加
  • 二次探測:衝突後+一個常量^2,……
  • 雙重雜湊:衝突後換一個雜湊函式計算

連結串列法(允許在同一個結點)

 

如何設計一個雜湊表

  • 一個合適的雜湊函式
  • 裝載因子閾值,設計動態擴容策略

過了閾值才擴容:使某次插入時間複雜度上升到O(n),因為要從原來的雜湊表搬移到新的雜湊表

一邊插入一邊搬:每插入一個資料就搬運一個原資料到新雜湊表

  • 合適的雜湊衝突解決方法

開放定址法:資料量、裝載因子小(Java中的ThreadLocalMap)

連結串列法:資料量大,存放的物件大(這樣就忽略連結串列中指標的記憶體消耗了)

 

 

為什麼雜湊表和連結串列經常一塊使用(順序遍歷)

雜湊表支援非常高效的資料插入、刪除、查詢操作(O(1)),但雜湊表中的資料都是通過雜湊函式打亂之後無規律儲存的。所以如果我們希望按照順序來遍歷資料,就要先取出資料到陣列然後排序,這樣效率就很低。但是如果結合連結串列的話,順序問題就可以通過維護連結串列結點的next指標來實現了。

其中next是指向插入順序上的下一個結點,而hnext是指向雜湊衝突的下一個結點

Java中的LinkedHashMap也是這種結構(Linked並不是指連結串列法解決雜湊衝突,而是雙向連結串列)

 

 

雜湊演算法的分散式應用


負載均衡

實現會話粘滯(SessionSticky)[同一個客戶端上,再一次會話中的所有請求都路由到同一個伺服器上]

如果靠維護一張(客戶端ip地址[會話id]+伺服器編號對映)的表來實現的話,如果客戶端很多會浪費記憶體空間。如果客戶端下線、上線、伺服器擴容、縮容都會導致對映失效,維護表成本很高。

我們可以通過雜湊演算法,對客戶端 IP 地址或者會話 ID 計算雜湊值,將取得的雜湊值與伺服器列表的大小進行取模運算,最終得到的值就是應該被路由到的伺服器編號。這樣,我們就可以把同一個 IP 過來的所有請求,都路由到同一個後端伺服器上。

 

資料分片

將資料分成好幾片存到不同機器

將每個資料中的關鍵字通過雜湊函式計算雜湊值,再跟機器的數量n取模,得到的值就是應存機器的編號(MapReduce基本思想)

 

分散式儲存

資料分片後,機器數量不夠需求,需要擴容時,所有資料關鍵字就要重新計算雜湊值,然後來搬到新的機器=快取中資料全部失效,客戶端就是請求到資料庫-->雪崩效應

解決:一開始設計的時候,將所有的快取槽連成一個環(一致性雜湊--環形儲存)[Redis叢集]

https://www.sohu.com/a/158141377_479559 

node相當於機器,key所在位置就是算出來的雜湊值。key順時針歸屬於node

為了防止,大部分key歸屬到一個node,提出了“虛擬結點”

簡單來說就是將node拆分成好幾個

 

 

 

 

 

 

 

 

 

 

 

 

相關文章