數據結構與算法之美(二)

一,二分查找

二分查找的非遞歸實現


public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;

  while (low <= high) {
    int mid = (low + high) / 2;
    if (a[mid] == value) {
      return mid;
    } else if (a[mid] < value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }

  return -1;
}
  • 循環退出條件: low<=high
  • mid 的取值:low+(high-low)/2,我們可以將這裏的除以 2 操作轉化成位運算 low+((high-low)>>1),記得要有括號,因爲移位運算的優先級小於+號
  • low 和 high 的更新,low=mid+1,high=mid-1

二分查找的遞歸實現


// 二分查找的遞歸實現
public int bsearch(int[] a, int n, int val) {
  return bsearchInternally(a, 0, n - 1, val);
}

private int bsearchInternally(int[] a, int low, int high, int value) {
  if (low > high) return -1;

  int mid =  low + ((high - low) >> 1);
  if (a[mid] == value) {
    return mid;
  } else if (a[mid] < value) {
    return bsearchInternally(a, mid+1, high, value);
  } else {
    return bsearchInternally(a, low, mid-1, value);
  }
}

二分查找使用要點:

  • 二分查找依賴的是順序表結構,簡單點說就是數組

  • 二分查找針對的是有序數據。

  • 數據量太小不適合二分查找。比如我們在一個大小爲 10 的數組中查找一個元素,不管用二分查找還是順序遍歷,查找速度都差不多。只有數據量比較大的時候,二分查找的優勢纔會比較明顯。

  • 數據量太大也不適合二分查找。二分查找的底層需要依賴數組這種數據結構,而數組爲了支持隨機訪問的特性,要求內存空間連續,對內存的要求比較苛刻。比如,我們有 1GB 大小的數據,如果希望用數組來存儲,那就需要 1GB 的連續內存空間。

如何在 1000 萬個整數中快速查找某個整數?

我們的內存限制是 100MB,每個數據大小是 8 字節,最簡單的辦法就是將數據存儲在數組中,內存佔用差不多是 80MB,符合內存的限制。藉助今天講的內容,我們可以先對這 1000 萬數據從小到大排序,然後再利用二分查找算法,就可以快速地查找想要的數據了。

如何編程實現“求一個數的平方根”?要求精確到小數點後 6 位

#include<iostream>
#include<ctime>
#include<queue>

using namespace std;
#define range 0.0000001

double search(int val,double start, double end)
{
    double mid = start+(end - start)/2;
    if(val - mid*mid < 0)
    {
        return search(val, start, (mid+end)/2);
    }
    else
    {
        if(val - mid*mid >= range)
            return search(val, (start+mid)/2, end);
        else
        {
            return mid;
        }
    }
}
int main()
{
    int num = 8;
    double start = 0.0;
    double end = double(num);
    double ret = search(num, start ,end);

    cout<<ret<<endl;
    return 0;
}

如果數據使用鏈表存儲,二分查找的時間複雜就會變得很高,那查找的時間複雜度究竟是多少呢?

假設鏈表長度爲n,二分查找每次都要找到中間點(計算中忽略奇偶數差異):
第一次查找中間點,需要移動指針n/2次;
第二次,需要移動指針n/4次;
第三次需要移動指針n/8次;
......
以此類推,一直到1次爲值

總共指針移動次數(查找次數) = n/2 + n/4 + n/8 + ...+ 1,這顯然是個等比數列,根據等比數列求和公式:Sum = n - 1.
最後算法時間複雜度是:O(n-1),忽略常數,記爲O(n),時間複雜度和順序查找時間複雜度相同
但是稍微思考下,在二分查找的時候,由於要進行多餘的運算,嚴格來說,會比順序查找時間慢

(1)查找第一個值等於給定值得元素


public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int 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 {
      if ((mid == 0) || (a[mid - 1] != value)) return mid;
      else high = mid - 1;
    }
  }
  return -1;
}

(2)變體二:查找最後一個值等於給定值的元素


public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int 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 {
      if ((mid == n - 1) || (a[mid + 1] != value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

(3)變體三:查找第一個大於等於給定值的元素


public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] >= value) {
      if ((mid == 0) || (a[mid - 1] < value)) return mid;
      else high = mid - 1;
    } else {
      low = mid + 1;
    }
  }
  return -1;
}

(4)變體四:查找最後一個小於等於給定值的元素


public int bsearch7(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid =  low + ((high - low) >> 1);
    if (a[mid] > value) {
      high = mid - 1;
    } else {
      if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
      else low = mid + 1;
    }
  }
  return -1;
}

如何快速定位出一個 IP 地址的歸屬地?

現在這個問題應該很簡單了。如果 IP 區間與歸屬地的對應關係不經常更新,我們可以先預處理這 12 萬條數據,讓其按照起始 IP 從小到大排序。如何來排序呢?我們知道,IP 地址可以轉化爲 32 位的整型數。所以,我們可以將起始地址,按照對應的整型值的大小關係,從小到大進行排序。然後,這個問題就可以轉化爲我剛講的第四種變形問題“在有序數組中,查找最後一個小於等於某個給定值的元素”了。當我們要查詢某個 IP 歸屬地時,我們可以先通過二分查找,找到最後一個起始 IP 小於等於這個 IP 的 IP 區間,然後,檢查這個 IP 是否在這個 IP 區間內,如果在,我們就取出對應的歸屬地顯示;如果不在,就返回未查找到。

今天的思考題也是一個非常規的二分查找問題。如果有序數組是一個循環有序數組,比如 4,5,6,1,2,3。針對這種情況,如何實現一個求“值等於給定值”的二分查找算法呢?

循環遞增數組有這麼一個性質:以數組中間元素將循環遞增數組劃分爲兩部分,則一部分爲一個嚴格遞增數組,而另一部分爲一個更小的循環遞增數組。
當中間元素大於首元素時,前半部分爲嚴格遞增數組,後半部分爲循環遞增數組;當中間元素小於首元素時,前半部分爲循環遞增數組;後半部分爲嚴格遞增數組。

class Solution {
public:
    int search(vector<int>& nums, int target) 
	{
		int l=0,r=nums.size()-1,mid;
		while(l<=r)
		{
			mid=(l+r)/2;
			if(target==nums[mid])
				return mid;
			else if(nums[l]<=nums[mid])//左邊部分爲有序數組
			{
				if(target>=nums[l]&&target<nums[mid])
					r=mid-1;
				else
					l=mid+1;
			}
			else//右邊部分爲有序數組
			{
				if(target>nums[mid]&&target<=nums[r])
					l=mid+1;
				else
					r=mid-1;
			}
		}
		return -1;
	}
};

三,跳錶

每兩個結點提取一個結點到上一級,我們把抽出來的那一級叫作索引或索引層。這種鏈表加多級索引的結構,就是跳錶。

跳錶的時間複雜度:

我們在跳錶中查詢某個數據的時候,如果每一層都要遍歷 m 個結點,那在跳錶中查詢一個數據的時間複雜度就是 O(m*logn)。按照前面這種索引結構,我們每一級索引都最多隻需要遍歷 3 個結點,也就是說 m=3。所以在跳錶中查詢任意數據的時間複雜度就是 O(logn)。

跳錶的空間複雜度:

跳錶的空間複雜度分析並不難,我在前面說了,假設原始鏈表大小爲 n,那第一級索引大約有 n/2 個結點,第二級索引大約有 n/4 個結點,以此類推。這幾級索引的結點總和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳錶的空間複雜度是 O(n)。

實際上,在軟件開發中,我們不必太在意索引佔用的額外空間。在講數據結構和算法時,我們習慣性地把要處理的數據看成整數,但是在實際的軟件開發中,原始鏈表中存儲的有可能是很大的對象,而索引結點只需要存儲關鍵值和幾個指針,並不需要存儲對象,所以當對象比索引結點大很多時,那索引佔用的額外空間就可以忽略了。

跳錶的高效的動態插入和刪除

支持動態的插入、刪除操作,而且插入、刪除操作的時間複雜度也是 O(logn)。

對於跳錶來說,我們講過查找某個結點的的時間複雜度是 O(logn),所以這裏查找某個數據應該插入的位置,方法也是類似的,時間複雜度也是 O(logn)。

跳錶索引動態更新

當我們不停地往跳錶中插入數據時,如果我們不更新索引,就有可能出現某 2 個索引結點之間數據非常多的情況。極端情況下,跳錶還會退化成單鏈表。

當我們往跳錶中插入數據的時候,我們可以選擇同時將這個數據插入到部分索引層中。如何選擇加入哪些索引層呢?我們通過一個隨機函數,來決定將這個結點插入到哪幾級索引中,比如隨機函數生成了值 K,那我們就將這個結點添加到第一級到第 K 級這 K 級索引中。

爲什麼 Redis 要用跳錶來實現有序集合,而不是紅黑樹?

Redis 中的有序集合是通過跳錶來實現的,嚴格點講,其實還用到了散列表。不過散列表我們後面纔會講到,所以我們現在暫且忽略這部分。如果你去查看 Redis 的開發手冊,就會發現,Redis 中的有序集合支持的核心操作主要有下面這幾個:

  • 插入一個數據;
  • 刪除一個數據;
  • 查找一個數據;
  • 按照區間查找數據(比如查找值在 [100, 356] 之間的數據);
  • 迭代輸出有序序列。

其中,插入、刪除、查找以及迭代輸出有序序列這幾個操作,紅黑樹也可以完成,時間複雜度跟跳錶是一樣的。但是,按照區間來查找數據這個操作,紅黑樹的效率沒有跳錶高。對於按照區間查找數據這個操作,跳錶可以做到 O(logn) 的時間複雜度定位區間的起點,然後在原始鏈表中順序往後遍歷就可以了。這樣做非常高效。

當然,Redis 之所以用跳錶來實現有序集合,還有其他原因,比如,跳錶更容易代碼實現。雖然跳錶的實現也不簡單,但比起紅黑樹來說還是好懂、好寫多了,而簡單就意味着可讀性好,不容易出錯。還有,跳錶更加靈活,它可以通過改變索引構建策略,有效平衡執行效率和內存消耗。

四,散列表

1,散列函數

  • 散列函數計算得到的散列值是一個非負整數;
  • 如果 key1 = key2,那 hash(key1) == hash(key2);
  • 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。

2,散列衝突

(1)開放尋址法

  • 線性探測
  • 二次探測

  • 雙重散列

(2)鏈表法

3,Word 文檔中單詞拼寫檢查功能是如何實現的?

常用的英文單詞有 20 萬個左右,假設單詞的平均長度是 10 個字母,平均一個單詞佔用 10 個字節的內存空間,那 20 萬英文單詞大約佔 2MB 的存儲空間,就算放大 10 倍也就是 20MB。對於現在的計算機來說,這個大小完全可以放在內存裏面。所以我們可以用散列表來存儲整個英文單詞詞典。當用戶輸入某個英文單詞時,我們拿用戶輸入的單詞去散列表中查找。如果查到,則說明拼寫正確;如果沒有查到,則說明拼寫可能有誤,給予提示。藉助散列表這種數據結構,我們就可以輕鬆實現快速判斷是否存在拼寫錯誤。

4,思考題

(1)假設我們有 10 萬條 URL 訪問日誌,如何按照訪問次數給 URL 排序?

遍歷 10 萬條數據,以 URL 爲 key,訪問次數爲 value,存入散列表,同時記錄下訪問次數的最大值 K,時間複雜度 O(N)。
如果 K 不是很大,可以使用桶排序,時間複雜度 O(N)。如果 K 非常大(比如大於 10 萬),就使用快速排序,複雜度 O(NlogN)。

(2)有兩個字符串數組,每個數組大約有 10 萬條字符串,如何快速找出兩個數組中相同的字符串?

以第一個字符串數組構建散列表,key 爲字符串,value 爲出現次數。再遍歷第二個字符串數組,以字符串爲 key 在散列表中查找,如果 value 大於零,說明存在相同字符串。時間複雜度 O(N)。

5,如何打造一個工業級水平的散列表?

我們知道,散列表的查詢效率並不能籠統地說成是 O(1)。它跟散列函數、裝載因子、散列衝突等都有關係。如果散列函數設計得不好,或者裝載因子過高,都可能導致散列衝突發生的概率升高,查詢效率下降。

在極端情況下,有些惡意的攻擊者,還有可能通過精心構造的數據,使得所有的數據經過散列函數之後,都散列到同一個槽裏。如果我們使用的是基於鏈表的衝突解決方法,那這個時候,散列表就會退化爲鏈表,查詢的時間複雜度就從 O(1) 急劇退化爲 O(n)。

(1)如何設計散列函數

  • 散列函數的設計不能太複雜
  • 散列函數生成的值要儘可能隨機並且均勻分佈

(2)裝載因子過大了怎麼辦?

  • 當散列表的裝載因子超過某個閾值時,就需要進行擴容。裝載因子閾值需要選擇得當。如果太大,會導致衝突過多;如果太小,會導致內存浪費嚴重。
  • 裝載因子閾值的設置要權衡時間、空間複雜度。如果內存空間不緊張,對執行效率要求很高,可以降低負載因子的閾值;相反,如果內存空間緊張,對執行效率要求又不高,可以增加負載因子的值,甚至可以大於 1。

(3)如何避免低效地擴容?

  • 爲了解決一次性擴容耗時過多的情況,我們可以將擴容操作穿插在插入操作的過程中,分批完成。當裝載因子觸達閾值之後,我們只申請新空間,但並不將老的數據搬移到新散列表中。
  • 當有新數據要插入時,我們將新數據插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。每次插入一個數據到散列表,我們都重複上面的過程。經過多次插入操作之後,老的散列表中的數據就一點一點全部搬移到新散列表中了。這樣沒有了集中的一次性數據搬移,插入操作就都變得很快了。
  • 通過這樣均攤的方法,將一次性擴容的代價,均攤到多次插入操作中,就避免了一次性擴容耗時過多的情況。這種實現方式,任何情況下,插入一個數據的時間複雜度都是 O(1)。

(4)如何選擇衝突解決方法?

  • 開放尋址法

當數據量比較小、裝載因子小的時候,適合採用開放尋址法。這也是 Java 中的ThreadLocalMap使用開放尋址法解決散列衝突的原因。

  • 鏈表法

基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表,而且,比起開放尋址法,它更加靈活,支持更多的優化策略,比如用紅黑樹代替鏈表。

6,工業級散列表舉例分析

剛剛我講了實現一個工業級散列表需要涉及的一些關鍵技術,現在,我就拿一個具體的例子,Java 中的 HashMap 這樣一個工業級的散列表,來具體看下,這些技術是怎麼應用的。

  • 初始大小

HashMap 默認的初始大小是 16,當然這個默認值是可以設置的,如果事先知道大概的數據量有多大,可以通過修改默認初始大小,減少動態擴容的次數,這樣會大大提高 HashMap 的性能。

  • 裝載因子和動態擴容

最大裝載因子默認是 0.75,當 HashMap 中元素個數超過 0.75*capacity(capacity 表示散列表的容量)的時候,就會啓動擴容,每次擴容都會擴容爲原來的兩倍大小。

  • 散列衝突解決方法

HashMap 底層採用鏈表法來解決衝突。即使負載因子和散列函數設計得再合理,也免不了會出現拉鍊過長的情況,一旦出現拉鍊過長,則會嚴重影響 HashMap 的性能。

於是,在 JDK1.8 版本中,爲了對 HashMap 做進一步優化,我們引入了紅黑樹。而當鏈表長度太長(默認超過 8)時,鏈表就轉換爲紅黑樹。我們可以利用紅黑樹快速增刪改查的特點,提高 HashMap 的性能。當紅黑樹結點個數少於 8 個的時候,又會將紅黑樹轉化爲鏈表。因爲在數據量較小的情況下,紅黑樹要維護平衡,比起鏈表來,性能上的優勢並不明顯。

  • 散列函數

散列函數的設計並不複雜,追求的是簡單高效、分佈均勻。我把它摘抄出來,你可以看看。


int hash(Object key) {
    int h = key.hashCode();
    return (h ^ (h >>> 16)) & (capitity -1); //capicity表示散列表的大小
}

其中,hashCode() 返回的是 Java 對象的 hash code。比如 String 類型的對象的 hashCode() 就是下面這樣:


public int hashCode() {
  int var1 = this.hash;
  if(var1 == 0 && this.value.length > 0) {
    char[] var2 = this.value;
    for(int var3 = 0; var3 < this.value.length; ++var3) {
      var1 = 31 * var1 + var2[var3];
    }
    this.hash = var1;
  }
  return var1;
}

7,總結

何爲一個工業級的散列表?工業級的散列表應該具有哪些特性?

結合已經學習過的散列知識,我覺得應該有這樣幾點要求:

  • 支持快速的查詢、插入、刪除操作;
  • 內存佔用合理,不能浪費過多的內存空間;
  • 性能穩定,極端情況下,散列表的性能也不會退化到無法接受的情況。

如何實現這樣一個散列表呢?

根據前面講到的知識,我會從這三個方面來考慮設計思路:

  • 設計一個合適的散列函數;
  • 定義裝載因子閾值,並且設計動態擴容策略;
  • 選擇合適的散列衝突解決方法。

8,思考題

(1)今天講的幾個散列表和鏈表結合使用的例子裏,我們用的都是雙向鏈表。如果把雙向鏈表改成單鏈表,還能否正常工作呢?爲什麼呢?

在刪除一個元素時,雖然能 O(1) 的找到目標結點,但是要刪除該結點需要拿到前一個結點的指針,遍歷到前一個結點複雜度會變爲 O(N),所以用雙鏈表實現比較合適。(但其實硬要操作的話,單鏈表也是可以實現 O(1) 時間複雜度刪除結點的)。

(2)假設獵聘網有 10 萬名獵頭,每個獵頭都可以通過做任務(比如發佈職位)來積累積分,然後通過積分來下載簡歷。假設你是獵聘網的一名工程師,如何在內存中存儲這 10 萬個獵頭 ID 和積分信息,讓它能夠支持這樣幾個操作:

  • 根據獵頭的 ID 快速查找、刪除、更新這個獵頭的積分信息;

  • 查找積分在某個區間的獵頭 ID 列表;

  • 查找按照積分從小到大排名在第 x 位到第 y 位之間的獵頭 ID 列表。

以積分排序構建一個跳錶,再以獵頭 ID 構建一個散列表。

1)ID 在散列表中所以可以 O(1) 查找到這個獵頭;
2)積分以跳錶存儲,跳錶支持區間查詢;
3)這點根據目前學習的知識暫時無法實現,老師文中也提到了。

五,哈希算法

將任意長度的二進制值串映射爲固定長度的二進制值串,這個映射的規則就是哈希算法,而通過原始數據映射之後得到的二進制值串就是哈希值。需要滿足的幾點要求:

  1. 從哈希值不能反向推導出原始數據(所以哈希算法也叫單向哈希算法);
  2. 對輸入數據非常敏感,哪怕原始數據只修改了一個 Bit,最後得到的哈希值也大不相同;
  3. 散列衝突的概率要很小,對於不同的原始數據,哈希值相同的概率非常小;
  4. 哈希算法的執行效率要儘量高效,針對較長的文本,也能快速地計算出哈希值。

哈希算法的應用非常非常多,我選了最常見的七個,分別是安全加密、唯一標識、數據校驗、散列函數、負載均衡、數據分片、分佈式存儲。

1,應用一:安全加密

最常用於加密的哈希算法是 MD5(MD5 Message-Digest Algorithm,MD5 消息摘要算法)和 SHA(Secure Hash Algorithm,安全散列算法)。除了這兩個之外,當然還有很多其他加密算法,比如 DES(Data Encryption Standard,數據加密標準)、AES(Advanced Encryption Standard,高級加密標準)。

爲什麼哈希算法無法做到零衝突?

我們知道,哈希算法產生的哈希值的長度是固定且有限的。比如前面舉的 MD5 的例子,哈希值是固定的 128 位二進制串,能表示的數據是有限的,最多能表示 2^128 個數據,而我們要哈希的數據是無窮的。基於鴿巢原理,如果我們對 2^128+1 個數據求哈希值,就必然會存在哈希值相同的情況。這裏你應該能想到,一般情況下,哈希值越長的哈希算法,散列衝突的概率越低。

2,應用二:唯一標識

如果要在海量的圖庫中,搜索一張圖是否存在,我們不能單純地用圖片的元信息(比如圖片名稱)來比對,因爲有可能存在名稱相同但圖片內容不同,或者名稱不同圖片內容相同的情況。那我們該如何搜索呢?

我們可以給每一個圖片取一個唯一標識,或者說信息摘要。比如,我們可以從圖片的二進制碼串開頭取 100 個字節,從中間取 100 個字節,從最後再取 100 個字節,然後將這 300 個字節放到一塊,通過哈希算法(比如 MD5),得到一個哈希字符串,用它作爲圖片的唯一標識。通過這個唯一標識來判定圖片是否在圖庫中,這樣就可以減少很多工作量。

如果還想繼續提高效率,我們可以把每個圖片的唯一標識,和相應的圖片文件在圖庫中的路徑信息,都存儲在散列表中。當要查看某個圖片是不是在圖庫中的時候,我們先通過哈希算法對這個圖片取唯一標識,然後在散列表中查找是否存在這個唯一標識。

3,應用三:數據校驗

我們知道,網絡傳輸是不安全的,下載的文件塊有可能是被宿主機器惡意修改過的,又或者下載過程中出現了錯誤,所以下載的文件塊可能不是完整的。如果我們沒有能力檢測這種惡意修改或者文件下載出錯,就會導致最終合併後的電影無法觀看,甚至導致電腦中毒。現在的問題是,如何來校驗文件塊的安全、正確、完整呢?

我們通過哈希算法,對 100 個文件塊分別取哈希值,並且保存在種子文件中。我們在前面講過,哈希算法有一個特點,對數據很敏感。只要文件塊的內容有一丁點兒的改變,最後計算出的哈希值就會完全不同。所以,當文件塊下載完成之後,我們可以通過相同的哈希算法,對下載好的文件塊逐一求哈希值,然後跟種子文件中保存的哈希值比對。如果不同,說明這個文件塊不完整或者被篡改了,需要再重新從其他宿主機器上下載這個文件塊。

4,應用四:散列函數

我們前兩節講到,散列函數是設計一個散列表的關鍵。它直接決定了散列衝突的概率和散列表的性能。不過,相對哈希算法的其他應用,散列函數對於散列算法衝突的要求要低很多。即便出現個別散列衝突,只要不是過於嚴重,我們都可以通過開放尋址法或者鏈表法解決。

不僅如此,散列函數對於散列算法計算得到的值,是否能反向解密也並不關心。散列函數中用到的散列算法,更加關注散列後的值是否能平均分佈,也就是,一組數據是否能均勻地散列在各個槽中。除此之外,散列函數執行的快慢,也會影響散列表的性能,所以,散列函數用的散列算法一般都比較簡單,比較追求效率。

5,應用五:負載均衡

負載均衡算法有很多,比如輪詢、隨機、加權輪詢等。那如何才能實現一個會話粘滯(session sticky)的負載均衡算法呢?也就是說,我們需要在同一個客戶端上,在一次會話中的所有請求都路由到同一個服務器上。

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

6,應用六:數據分片

(1) 如何統計“搜索關鍵詞”出現的次數?

假如我們有 1T 的日誌文件,這裏面記錄了用戶的搜索關鍵詞,我們想要快速統計出每個關鍵詞被搜索的次數,該怎麼做呢?

我們來分析一下。這個問題有兩個難點,第一個是搜索日誌很大,沒辦法放到一臺機器的內存中。第二個難點是,如果只用一臺機器來處理這麼巨大的數據,處理時間會很長。

針對這兩個難點,我們可以先對數據進行分片,然後採用多臺機器處理的方法,來提高處理速度。具體的思路是這樣的:爲了提高處理的速度,我們用 n 臺機器並行處理。我們從搜索記錄的日誌文件中,依次讀出每個搜索關鍵詞,並且通過哈希函數計算哈希值,然後再跟 n 取模,最終得到的值,就是應該被分配到的機器編號。

這樣,哈希值相同的搜索關鍵詞就被分配到了同一個機器上。也就是說,同一個搜索關鍵詞會被分配到同一個機器上。每個機器會分別計算關鍵詞出現的次數,最後合併起來就是最終的結果。實際上,這裏的處理過程也是 MapReduce 的基本設計思想。

(2)如何快速判斷圖片是否在圖庫中?

假設現在我們的圖庫中有 1 億張圖片,很顯然,在單臺機器上構建散列表是行不通的。因爲單臺機器的內存有限,而 1 億張圖片構建散列表顯然遠遠超過了單臺機器的內存上限。

我們同樣可以對數據進行分片,然後採用多機處理。我們準備 n 臺機器,讓每臺機器只維護某一部分圖片對應的散列表。我們每次從圖庫中讀取一個圖片,計算唯一標識,然後與機器個數 n 求餘取模,得到的值就對應要分配的機器編號,然後將這個圖片的唯一標識和圖片路徑發往對應的機器構建散列表。

當我們要判斷一個圖片是否在圖庫中的時候,我們通過同樣的哈希算法,計算這個圖片的唯一標識,然後與機器個數 n 求餘取模。假設得到的值是 k,那就去編號 k 的機器構建的散列表中查找。

現在,我們來估算一下,給這 1 億張圖片構建散列表大約需要多少臺機器。散列表中每個數據單元包含兩個信息,哈希值和圖片文件的路徑。假設我們通過 MD5 來計算哈希值,那長度就是 128 比特,也就是 16 字節。文件路徑長度的上限是 256 字節,我們可以假設平均長度是 128 字節。如果我們用鏈表法來解決衝突,那還需要存儲指針,指針只佔用 8 字節。所以,散列表中每個數據單元就佔用 152 字節(這裏只是估算,並不準確)。

假設一臺機器的內存大小爲 2GB,散列表的裝載因子爲 0.75,那一臺機器可以給大約 1000 萬(2GB*0.75/152)張圖片構建散列表。所以,如果要對 1 億張圖片構建索引,需要大約十幾臺機器。在工程中,這種估算還是很重要的,能讓我們事先對需要投入的資源、資金有個大概的瞭解,能更好地評估解決方案的可行性。

7,應用七:分佈式存儲

現在互聯網面對的都是海量的數據、海量的用戶。我們爲了提高數據的讀取、寫入能力,一般都採用分佈式的方式來存儲數據,比如分佈式緩存。我們有海量的數據需要緩存,所以一個緩存機器肯定是不夠的。於是,我們就需要將數據分佈在多臺機器上。該如何決定將哪個數據放到哪個機器上呢?我們可以借用前面數據分片的思想,即通過哈希算法對數據取哈希值,然後對機器個數取模,這個最終值就是應該存儲的緩存機器編號。

但是,如果數據增多,原來的 10 個機器已經無法承受了,我們就需要擴容了,比如擴到 11 個機器,這時候麻煩就來了。因爲,這裏並不是簡單地加個機器就可以了。原來的數據是通過與 10 來取模的。比如 13 這個數據,存儲在編號爲 3 這臺機器上。但是新加了一臺機器中,我們對數據按照 11 取模,原來 13 這個數據就被分配到 2 號這臺機器上了。

因此,所有的數據都要重新計算哈希值,然後重新搬移到正確的機器上。這樣就相當於,緩存中的數據一下子就都失效了。所有的數據請求都會穿透緩存,直接去請求數據庫。這樣就可能發生雪崩效應,壓垮數據庫。

所以,我們需要一種方法,使得在新加入一個機器後,並不需要做大量的數據搬移。這時候,一致性哈希算法就要登場了。假設我們有 k 個機器,數據的哈希值的範圍是 [0, MAX]。我們將整個範圍劃分成 m 個小區間(m 遠大於 k),每個機器負責 m/k 個小區間。當有新機器加入的時候,我們就將某幾個小區間的數據,從原來的機器中搬移到新的機器中。這樣,既不用全部重新哈希、搬移數據,也保持了各個機器上數據數量的均衡。

白話解析:一致性哈希算法

https://www.cnblogs.com/xhb-bky-blog/p/9095688.html

CSDN 網站被黑客攻擊,超過 600 萬用戶的註冊郵箱和密碼明文被泄露,很多網友對 CSDN 明文保存用戶密碼行爲產生了不滿。如果你是 CSDN 的一名工程師,你會如何存儲用戶密碼這麼重要的數據嗎?僅僅 MD5 加密一下存儲就夠了嗎?

我們可以通過哈希算法,對用戶密碼進行加密之後再存儲,不過最好選擇相對安全的加密算法,比如 SHA 等(因爲 MD5 已經號稱被破解了)。

不過僅僅這樣加密之後存儲就萬事大吉了嗎?字典攻擊你聽說過嗎?如果用戶信息被“脫庫”,黑客雖然拿到是加密之後的密文,但可以通過“猜”的方式來破解密碼,這是因爲,有些用戶的密碼太簡單。比如很多人習慣用 00000、123456 這樣的簡單數字組合做密碼,很容易就被猜中。

針對字典攻擊,我們可以引入一個鹽(salt),跟用戶的密碼組合在一起,增加密碼的複雜度。我們拿組合之後的字符串來做哈希算法加密,將它存儲到數據庫中,進一步增加破解的難度。不過我這裏想多說一句,我認爲安全和攻擊是一種博弈關係,不存在絕對的安全。所有的安全措施,只是增加攻擊的成本而已。

加salt,也可理解爲爲密碼加點佐料後再進行hash運算。比如原密碼是123456,不加鹽的情況加密後假設是是xyz。 黑客拿到脫機的數據後,通過彩虹表匹配可以輕鬆破解常用密碼。如果加鹽,密碼123456加鹽後可能是12ng34qq56zz,再對加鹽後的密碼進行hash後值就與原密碼hash後的值完全不同了。而且加鹽的方式有很多種,可以是在頭部加,可以在尾部加,還可在內容中間加,甚至加的鹽還可以是隨機的。這樣即使用戶使用的是最常用的密碼,黑客拿到密文後破解的難度也很高。

區塊鏈是一個很火的領域,它被很多人神祕化,不過其底層的實現原理並不複雜。其中,哈希算法就是它的一個非常重要的理論基礎。你能講一講區塊鏈使用的是哪種哈希算法嗎?是爲了解決什麼問題而使用的呢?

區塊鏈是一塊塊區塊組成的,每個區塊分爲兩部分:區塊頭和區塊體。

區塊頭保存着 自己區塊體 和 上一個區塊頭 的哈希值。

因爲這種鏈式關係和哈希值的唯一性,只要區塊鏈上任意一個區塊被修改過,後面所有區塊保存的哈希值就不對了。

區塊鏈使用的是 SHA256 哈希算法,計算哈希值非常耗時,如果要篡改一個區塊,就必須重新計算該區塊後面所有的區塊的哈希值,短時間內幾乎不可能做到。

總結

  1. 第一個應用是唯一標識,哈希算法可以對大數據做信息摘要,通過一個較短的二進制編碼來表示很大的數據。
  2. 第二個應用是用於校驗數據的完整性和正確性。
  3. 第三個應用是安全加密,我們講到任何哈希算法都會出現散列衝突,但是這個衝突概率非常小。越是複雜哈希算法越難破解,但同樣計算時間也就越長。所以,選擇哈希算法的時候,要權衡安全性和計算時間來決定用哪種哈希算法。
  4. 第四個應用是散列函數,這個我們前面講散列表的時候已經詳細地講過,它對哈希算法的要求非常特別,更加看重的是散列的平均性和哈希算法的執行效率。
  5. 在負載均衡應用中,利用哈希算法替代映射表,可以實現一個會話粘滯的負載均衡策略。
  6. 在數據分片應用中,通過哈希算法對處理的海量數據進行分片,多機分佈式處理,可以突破單機資源的限制。
  7. 在分佈式存儲應用中,利用一致性哈希算法,可以解決緩存等分佈式系統的擴容、縮容導致數據大量搬移的難題。

六,二叉樹

1,如何表示(或者存儲)一棵二叉樹?

想要存儲一棵二叉樹,我們有兩種方法,一種是基於指針或者引用的二叉鏈式存儲法,一種是基於數組的順序存儲法。

(1)鏈式存儲法

(2)順序存儲法

如果節點 X 存儲在數組中下標爲 i 的位置,下標爲 2 * i 的位置存儲的就是左子節點,下標爲 2 * i + 1 的位置存儲的就是右子節點。反過來,下標爲 i/2 的位置存儲就是它的父節點。

如果某棵二叉樹是一棵完全二叉樹,那用數組存儲無疑是最節省內存的一種方式。因爲數組的存儲方式並不需要像鏈式存儲法那樣,要存儲額外的左右子節點的指針。如果是非完全二叉樹,其實會浪費比較多的數組存儲空間。

二叉樹既可以用鏈式存儲,也可以用數組順序存儲。數組順序存儲的方式比較適合完全二叉樹,其他類型的二叉樹用數組存儲會比較浪費存儲空間

七,二叉查找樹

1,二叉查找樹的插入操作

如果要插入的數據比節點的數據大,並且節點的右子樹爲空,就將新數據直接插到右子節點的位置;如果不爲空,就再遞歸遍歷右子樹,查找插入位置。同理,如果要插入的數據比節點數值小,並且節點的左子樹爲空,就將新數據插入到左子節點的位置;如果不爲空,就再遞歸遍歷左子樹,查找插入位置。


public void insert(int data) {
  if (tree == null) {
    tree = new Node(data);
    return;
  }

  Node p = tree;
  while (p != null) {
    if (data > p.data) {
      if (p.right == null) {
        p.right = new Node(data);
        return;
      }
      p = p.right;
    } else { // data < p.data
      if (p.left == null) {
        p.left = new Node(data);
        return;
      }
      p = p.left;
    }
  }
}

2,二叉查找樹的刪除操作

  • 第一種情況是,如果要刪除的節點沒有子節點,我們只需要直接將父節點中,指向要刪除節點的指針置爲 null。
  • 第二種情況是,如果要刪除的節點只有一個子節點(只有左子節點或者右子節點),我們只需要更新父節點中,指向要刪除節點的指針,讓它指向要刪除節點的子節點就可以了。
  • 第三種情況是,如果要刪除的節點有兩個子節點,這就比較複雜了。我們需要找到這個節點的右子樹中的最小節點,把它替換到要刪除的節點上。然後再刪除掉這個最小節點,因爲最小節點肯定沒有左子節點(如果有左子結點,那就不是最小節點了),所以,我們可以應用上面兩條規則來刪除這個最小節點。


public void delete(int data) {
  Node p = tree; // p指向要刪除的節點,初始化指向根節點
  Node pp = null; // pp記錄的是p的父節點
  while (p != null && p.data != data) {
    pp = p;
    if (data > p.data) p = p.right;
    else p = p.left;
  }
  if (p == null) return; // 沒有找到

  // 要刪除的節點有兩個子節點
  if (p.left != null && p.right != null) { // 查找右子樹中最小節點
    Node minP = p.right;
    Node minPP = p; // minPP表示minP的父節點
    while (minP.left != null) {
      minPP = minP;
      minP = minP.left;
    }
    p.data = minP.data; // 將minP的數據替換到p中
    p = minP; // 下面就變成了刪除minP了
    pp = minPP;
  }

  // 刪除節點是葉子節點或者僅有一個子節點
  Node child; // p的子節點
  if (p.left != null) child = p.left;
  else if (p.right != null) child = p.right;
  else child = null;

  if (pp == null) tree = child; // 刪除的是根節點
  else if (pp.left == p) pp.left = child;
  else pp.right = child;
}

我們在散列表那節中講過,散列表的插入、刪除、查找操作的時間複雜度可以做到常量級的 O(1),非常高效。而二叉查找樹在比較平衡的情況下,插入、刪除、查找操作時間複雜度纔是 O(logn),相對散列表,好像並沒有什麼優勢,那我們爲什麼還要用二叉查找樹呢?

  • 第一,散列表中的數據是無序存儲的,如果要輸出有序的數據,需要先進行排序。而對於二叉查找樹來說,我們只需要中序遍歷,就可以在 O(n) 的時間複雜度內,輸出有序的數據序列。
  • 第二,散列表擴容耗時很多,而且當遇到散列衝突時,性能不穩定,儘管二叉查找樹的性能不穩定,但是在工程中,我們最常用的平衡二叉查找樹的性能非常穩定,時間複雜度穩定在 O(logn)。
  • 第三,籠統地來說,儘管散列表的查找等操作的時間複雜度是常量級的,但因爲哈希衝突的存在,這個常量不一定比 logn 小,所以實際的查找速度可能不一定比 O(logn) 快。加上哈希函數的耗時,也不一定就比平衡二叉查找樹的效率高。
  • 第四,散列表的構造比二叉查找樹要複雜,需要考慮的東西很多。比如散列函數的設計、衝突解決辦法、擴容、縮容等。平衡二叉查找樹只需要考慮平衡性這一個問題,而且這個問題的解決方案比較成熟、固定。
  • 最後,爲了避免過多的散列衝突,散列表裝載因子不能太大,特別是基於開放尋址法解決衝突的散列表,不然會浪費一定的存儲空間。

 

 

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