數據結構與算法--線性數據結構

目錄

數組

鏈表

隊列

跳錶

散列表

散列表+鏈表

參考


 

數組

數組是一種線性表數據結構,它用一組連續的內存空間,來存儲一組具有相同類型的數據
數組,鏈表,隊列,棧都是線性表結構
非線性表結構有 樹,二叉樹,堆,圖等


數組下標從0開始,確切定義是偏移offset,用a來表示數組首位地址,a[0]就是偏移爲0的位置
a[k]表示k個type_size 位置,計算a[k]內存地址的公式

a[k]_address = hbase_address + k * type_size

如果下標從1開始,則計算a[k]內存地址就變爲

a[k]_address = hbase_address + (k-1)*type_size

數組的 O(1)插入
如果數組不要求有序,假設插入到第k個位置,可以先將第k位的元素移到數組最後,
再將新元素插入到第k位
假設數組中有a,b,c,d,e幾個元素,將x插入到第三個位置,只需要將c移到a[5],結果就是
a,b,x,d,e,c

如果刪除時不要求數據一定連續,可以將多次的刪除操作合併到一起執行,提高效率
這就是JVM標記清除垃圾算法的核心

一段死循環代碼

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[]) {
    int i = 0;
    int arr[3] = {0};
    for(;i<=3;i++) {
        arr[i] = 0;
        printf("hello world\n");
    }
    return 0;
}

棧是從高到低增長的,所以棧中的元素順序是i,a[2],a[1],a[0]對如下代碼

int i = 0;
int j = 1;
int k = 2;
int arr[3] = {0}; 
cout<<"i-"<<&i<<endl; 
cout<<"j-"<<&j<<endl; 
cout<<"k-"<<&k<<endl; 
cout<<"arr-"<<&arr<<endl;
cout<<"arr3-"<<&arr[3]<<endl;

運行結果:
i-0x28ff0c
j-0x28ff08
k-0x28ff04
arr-0x28fef8
arr3-0x28ff04

 

 

 

鏈表

  • 單鏈表
  • 循環單鏈表(約瑟夫環問題)
  • 雙鏈表
  • 雙向循環鏈表

幾個寫鏈表的技巧

  • 理解指針或引用的含義
  • 警惕指針丟失和內存泄露
  • 利用哨兵建好實現難度
  • 重點留意邊界條件處理
  • 舉例畫圖輔助思考

5個常見的鏈表操作

  • 單鏈表反轉
  • 鏈表中的循環檢測
  • 兩個有序的鏈表合併
  • 刪除鏈表倒數第n個節點
  • 求鏈表的中間節點

其他
一個字符串中是否有迴文字符串
單鏈表存儲的字符串,如何判斷迴文

 

  • 順序棧
  • 鏈式棧
  • 支持動態擴容的順序棧

根據均攤分析動態擴容的順序棧時間複雜度是O(1)
棧的實際應用

  • 函數調用棧
  • 表達式求值
  • 括號匹配中的應用

模擬瀏覽器前進後退功能

  • 使用兩個棧X,Y
  • 首次瀏覽的頁面壓入X棧
  • 點擊後退時,依次從X棧彈出放到Y棧中
  • 通過頁面b又跳轉到新頁面d,頁面C就無法通過前進後退按鈕重複查看了,要清空棧Y

 

 

隊列

也是一種操作受限的線性表數據結構

  • 順序隊列
  • 鏈式隊列
  • 循環隊列
  • 阻塞隊列
  • 併發隊列

順序隊列當tail指針移動到數組最右邊後,如有新數據入隊,可以將head到tail之間的數據
整體搬移到數組中0到tail-head的位置
循環隊列實現的關鍵,確定好隊空和隊滿的條件

 

 

跳錶

對鏈表的改造,可以支持二分查找的鏈表
可以替代紅黑樹的動態數據結構
在原始的節點之上,增加了一層,兩層,多層的索引,提高搜索效率

下圖是一個64個節點的鏈表,有5層索引,如果搜索62個節點需要遍歷62次,現在只需要11次

這種鏈表加多級索引的結構,就是跳錶
假設第一級索引是n/2,第二級是n/4,第k級索引節點是n/(2^k)
假設每一層要遍歷m次,跳錶的時間複雜度是O(m*logn)
可以算出每一層只需要遍歷三次,也就是m=3,所以時間複雜度是O(logn)

跳錶的空間複雜度爲O(n)

如果每3個或者5個節點,抽一個節點到上級索引,其空間複雜度大概能降低一半

實際開發中,鏈表中的數據可能很大,索引節點存儲的只是指針,所以空間可以忽略

跳錶中刪除/插入操作,需要先找到這個節點/或者前驅節點,再執行操作
找到某個節點的操作時間是O(logn),插入和刪除的操作時間是O(1),所以總的時間就是O(logn)
如果不停的往跳錶中插入數據,不更新索引,可能出現2個索引節點直接數據非常多,極端情況下就退化成了單鏈表

類似AVL樹和紅黑樹的左右旋轉操作,跳錶是通過一個隨機函數,來決定這個節點插入到哪幾級索引中
如果隨機函數生產了值K,就將這個節點添加到第一級到第K級索引中

這裏的隨機函數選擇就很有講究了,需要從概率上保證跳錶索引大小和數據大小平衡,不至於性能過度退化
Redis中的有序集合是通過跳錶來實現的(還用到了散列表):

  1. 插入一個數據
  2. 刪除一個數據
  3. 查找一個數據
  4. 按區間查找數據(如查到[100,356]之間的數據)
  5. 迭代輸出有序數據

紅黑樹可以完成1,2,3,5但是第4點就不行了
跳錶可以用O(logn)時間定位到一個指定的值如100,然後遍歷這個鏈表後續的值就可以了

 

 

散列表

  1. 散列函數的設計,不能太複雜,生成的值要隨機均勻
  2. 裝載因子過大後支持動態擴容(小於某個閾值可以縮容)
  3. 散列衝突,開放尋址 和 鏈表

開放尋址vs鏈表

  • 數據量小時可以採用開放尋址法,Java的ThreadLocalMap使用了,同一個數組中利用cpu緩存
  • 鏈表法適合存儲大對象,大數據量,當鏈>8啓動紅黑樹,當鏈<8退回爲鏈表


開放地址法
包括普通的線性探測
二次探測
雙重散列
當刪除一個元素時,不能直接刪除,否則線性探測發現這個位置爲空就會判斷失敗,得加上deleted標誌

Java的HashMap散列函數

int hash(Object key) {
    int h = key.hashCode();
    //這裏使用了異或,和位移,計算出來的具有高低位性質,同時用 & 模擬取餘運行達到均勻分佈
    return (h ^ (h >>> 16)) & (capitity -1);
}

 避免低效擴容
如果散列表已經有1G了,此時空間不夠再擴容一倍變成2G,而且所有的key都需要重新計算散列函數
可以先申請2G的空間,但不做搬移操作,新的key插入到新的散列表中,再從老表中拿一個key重新計算後放入新表
查詢時爲了兼容老的,需要先從新的中查詢如果沒有再去老的中查詢
這樣的均攤方法,將一次性擴容的代價,均攤到多次插入操作中

 

散列表+鏈表

LRU緩存淘汰機制,需要用到散列表+鏈表的方式,結構如下

鏈表中的數據節點data,還有前驅節點prev,後驅節點next,新增了一個特殊節點hnext
前驅和後驅針織是爲了將節點串在雙向鏈表中
hnext指針是爲了將節點串在散列表的拉鍊中
按照這個圖的原理,查找,刪除,增加都是O(1)時間複雜度

Redis有序集合中,有兩個重要屬性key鍵值 score分值
可以通過用戶ID來查找積分信息,也可以通過積分區間來查找用戶ID或者姓名
Redis有序集合包括如下操作

  • 添加一個成員對象
  • 按照鍵值來刪除一個成員對象
  • 按照鍵值來查找一個成員對象
  • 按照分值區間查找數據,如查找積分在[100,356]之間的成員對象
  • 按照分值從小到大排序成員變量

以上需求如果只是用跳錶就不行了,需要用鏈表+散列表的方式纔可以

Java的LinkedHashMap 也是類似的散列表+鏈表的實現方式
底層就是HashMap,又加了一個雙向鏈表,通過雙向鏈表維持插入順序
LinedHashMap也支持按訪問順序來操作元素,當一個元素被訪問時,就將其放到鏈表末尾,但散列表中的位置不動

下面是操作Java的LinkedHashMap的三個操作

  • 第一次將4個元素put到map中,鏈表的結構如下
  • 第二次修改key爲3的值,於是將key爲3的元素放到鏈表末尾
  • 第三次訪問key爲5的元素,於是將key爲5的元素放到鏈表末尾

 

 

 

 

 

參考

跳錶的實現

Redis源碼學習跳錶

圖解LinkedHashMap原理

 

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