原創公衆號「 bigsai」
文章已收錄在 個人Github bigsai-algorithm
跳錶是面試常問的一種數據結構,它在不少中間件和語言中獲得應用,咱們熟知的就有Redis跳錶。而且在面試的不少場景可能會問到,偶爾還會讓你手寫試一試(跳錶可能會讓手寫,紅黑樹是不可能的),這不,給大夥復原一個場景:java
但你別慌,遇到蘑菇頭這種面試官也別怕,由於你看到這篇文章了(得意😏),不用像熊貓那樣窘迫。node
對於一個數據結構或算法,人羣數量從聽過名稱、瞭解基本原理、清楚執行流程、可以手寫 呈抖降的趨勢。由於不少數據結構與算法其核心原理可能簡單,但清楚其執行流程就須要動腦子去思考想明白,可是若是可以把它寫出來,那就要本身一步步去設計和實現。可能要花好久才能真正寫出來,而且還可能要查閱大量的資料。git
而本文在前面進行介紹跳錶,後面部分詳細介紹跳錶的設計和實現,搞懂跳錶,這一篇真的就夠了。github
跳躍表(簡稱跳錶)由美國計算機科學家William Pugh發明於1989年。他在論文《Skip lists: a probabilistic alternative to balanced trees》中詳細介紹了跳錶的數據結構和插入刪除等操做。面試
跳錶(SkipList,全稱跳躍表)是用於有序元素序列快速搜索查找的一個數據結構,跳錶是一個隨機化的數據結構,實質就是一種能夠進行二分查找的有序鏈表。跳錶在原有的有序鏈表上面增長了多級索引,經過索引來實現快速查找。跳錶不只能提升搜索性能,同時也能夠提升插入和刪除操做的性能。它在性能上和紅黑樹,AVL樹不相上下,可是跳錶的原理很是簡單,實現也比紅黑樹簡單不少。
在這裏你能夠看到一些關鍵詞:鏈表(有序鏈表)、索引、二分查找。想必你的腦海中已經有了一個初略的印象,不過你可能仍是不清楚這個"會跳的鏈表"有多diao,甚至還可能會產生一點疑慮:跟隨機化有什麼關係?你在下文中很快就能獲得答案!算法
回顧鏈表,咱們知道鏈表和順序表(數組)一般都是相愛相殺,成對出現,各有優劣。而鏈表的優點就是更高效的插入、刪除。痛點就是查詢很慢很慢!每次查詢都是一種O(n)複雜度的操做,鏈表估計本身都氣的想哭了😢。數組
這是一個帶頭結點的鏈表(頭結點至關於一個固定的入口,不存儲有意義的值),每次查找都須要一個個枚舉,至關的慢,咱們能不能稍微優化一下,讓它稍微跳一跳呢?答案是能夠的,咱們知道不少算法和數據結構以空間換時間,咱們在上面加一層索引,讓部分節點在上層可以直接定位到,這樣鏈表的查詢時間近乎減小一半,鏈表本身雖然沒有開心起來,但收起了它想哭的臉。微信
這樣,在查詢某個節點的時候,首先會從上一層快速定位節點所在的一個範圍,若是找到具體範圍向下而後查找代價很小,固然在表的結構設計上會增長一個向下的索引(指針)用來查找肯定底層節點。平均查找速度平均爲O(n/2)。可是當節點數量很大的時候,它依舊很慢很慢。咱們都知道二分查找是每次都能折半的去壓縮查找範圍,要是有序鏈表也能這麼跳起來那就太完美了。沒錯跳錶就能讓鏈表擁有近乎的接近二分查找的效率的一種數據結構,其原理依然是給上面加若干層索引,優化查找速度。數據結構
經過上圖你能夠看到,經過這樣的一個數據結構對有序鏈表進行查找都能近乎二分的性能。就是在上面維護那麼多層的索引,首先在最高級索引上查找最後一個小於當前查找元素的位置,而後再跳到次高級索引繼續查找,直到跳到最底層爲止,這時候以及十分接近要查找的元素的位置了(若是查找元素存在的話)。因爲根據索引能夠一次跳過多個元素,因此跳查找的查找速度也就變快了。dom
對於理想的跳錶,每向上一層索引節點數量都是下一層的1/2.那麼若是n個節點增長的節點數量(1/2+1/4+…)<n。而且層數較低,對查找效果影響不大。可是對於這麼一個結構,你可能會疑惑,這樣完美的結構真的存在嗎?大機率不存在的,由於做爲一個鏈表,少不了增刪該查的一些操做。而刪除和插入可能會改變整個結構,因此上面的這些都是理想的結構,在插入的時候是否添加上層索引是個機率問題(1/2的機率),在後面會具體講解。
上面稍微瞭解了跳錶是個啥,那麼在這裏就給你們談談跳錶的增刪改查過程。在實現本跳錶的過程爲了便於操做,咱們將跳錶的頭結點(head)的key設爲int的最小值(必定知足左小右大方便比較)。
對於每一個節點的設置,設置成SkipNode類,爲了防止初學者將next向下仍是向右搞混,直接設置right,down兩個指針。
class SkipNode<T> { int key; T value; SkipNode right,down;//右下個方向的指針 public SkipNode (int key,T value) { this.key=key; this.value=value; } }
跳錶的結構和初始化也很重要,其主要參數和初始化方法爲:
public class SkipList <T> { SkipNode headNode;//頭節點,入口 int highLevel;//當前跳錶索引層數 Random random;// 用於投擲硬幣 final int MAX_LEVEL = 32;//最大的層 SkipList(){ random=new Random(); headNode=new SkipNode(Integer.MIN_VALUE,null); highLevel=0; } //其餘方法 }
不少時候鏈表也可能這樣相連僅僅是某個元素或者key做爲有序的標準。因此有可能鏈表內部存在一些value。不過修改和查詢其實都是一個操做,找到關鍵數字(key)。而且查找的流程也很簡單,設置一個臨時節點team=head。當team不爲null其流程大體以下:
(1) 從team節點出發,若是當前節點的key與查詢的key相等,那麼返回當前節點(若是是修改操做那麼一直向下進行修改值便可)。
(2) 若是key不相等,且右側爲null,那麼證實只能向下(結果可能出如今下右方向),此時team=team.down
(3) 若是key不相等,且右側不爲null,且右側節點key小於待查詢的key。那麼說明同級還可向右,此時team=team.right
(4)(不然的狀況)若是key不相等,且右側不爲null,且右側節點key大於待查詢的key 。那麼說明若是有結果的話就在這個索引和下個索引之間,此時team=team.down。
最終將按照這個步驟返回正確的節點或者null(說明沒查到)。
例如上圖查詢12節點,首先第一步從head出發發現右側不爲空,且7<12,向右;第二步右側爲null向下;第三步節點7的右側10<12繼續向右;第四步10右側爲null向下;第五步右側12小於等於向右。第六步起始發現相等返回節點結束。
而這塊的代碼也很是容易:
public SkipNode search(int key) { SkipNode team=headNode; while (team!=null) { if(team.key==key) { return team; } else if(team.right==null)//右側沒有了,只能降低 { team=team.down; } else if(team.right.key>key)//須要降低去尋找 { team=team.down; } else //右側比較小向右 { team=team.right; } } return null; }
刪除操做比起查詢稍微複雜一丟丟,可是比插入簡單。刪除須要改變鏈表結構因此須要處理好節點之間的聯繫。對於刪除操做你須要謹記如下幾點:
(1)刪除當前節點和這個節點的先後節點都有關係
(2)刪除當前層節點以後,下一層該key的節點也要刪除,一直刪除到最底層
根據這兩點分析一下:若是找到當前節點了,它的前面一個節點怎麼查找呢?這個總不能在遍歷一遍吧!有的使用四個方向的指針(上下左右)用來找到左側節點。是能夠的,可是這裏能夠特殊處理一下 ,不直接判斷和操做節點,先找到待刪除節點的左側節點。經過這個節點便可完成刪除,而後這個節點直接向下去找下一層待刪除的左側節點。設置一個臨時節點team=head,當team不爲null具體循環流程爲:
(1)若是team右側爲null,那麼team=team.down(之因此敢直接這麼判斷是由於左側有頭結點在左側,不用擔憂特殊狀況)
(2)若是team右側不 爲null,而且右側的key等於待刪除的key,那麼先刪除節點,再team向下team=team.down爲了刪除下層節點。
(3)若是team右側不 爲null,而且右側key小於待刪除的key,那麼team向右team=team.right。
(4)若是team右側不 爲null,而且右側key大於待刪除的key,那麼team向下team=team.down,在下層繼續查找刪除節點。
例如上圖刪除10節點,首先team=head從team出發,7<10向右(team=team.right後面省略);第二步右側爲null只能向下;第三部右側爲10在當前層刪除10節點而後向下繼續查找下一層10節點;第四步8<10向右;第五步右側爲10刪除該節點而且team向下。team爲null說明刪除完畢退出循環。
刪除操做實現的代碼以下:
public void delete(int key)//刪除不須要考慮層數 { SkipNode team=headNode; while (team!=null) { if (team.right == null) {//右側沒有了,說明這一層找到,沒有隻能降低 team=team.down; } else if(team.right.key==key)//找到節點,右側即爲待刪除節點 { team.right=team.right.right;//刪除右側節點 team=team.down;//向下繼續查找刪除 } else if(team.right.key>key)//右側已經不可能了,向下 { team=team.down; } else { //節點還在右側 team=team.right; } } }
插入操做在實現起來是最麻煩的,須要的考慮的東西最多。回顧查詢,不須要動索引;回顧刪除,每層索引若是有刪除就是了。可是插入不同了,插入須要考慮是否插入索引,插入幾層等問題。因爲須要插入刪除因此咱們確定沒法維護一個徹底理想的索引結構,由於它耗費的代價過高。但咱們使用隨機化的方法去判斷是否向上層插入索引。即產生一個[0-1]的隨機數若是小於0.5就向上插入索引,插入完畢後再次使用隨機數判斷是否向上插入索引。運氣好這個值多是多層索引,運氣很差只插入最底層(這是100%插入的)。可是索引也不能不限制高度,咱們通常會設置索引最高值若是大於這個值就不往上繼續添加索引了。
咱們一步步剖析該怎麼作,其流程爲
(1)首先經過上面查找的方式,找到待插入的左節點。插入的話最底層確定是須要插入的,因此經過鏈表插入節點(須要考慮是否爲末尾節點)
(2)插入完這一層,須要考慮上一層是否插入,首先判斷當前索引層級,若是大於最大值那麼就中止(好比已經到最高索引層了)。不然設置一個隨機數1/2的機率向上插入一層索引(由於理想狀態下的就是每2個向上建一個索引節點)。
(3)繼續(2)的操做,直到機率退出或者索引層數大於最大索引層。
在具體向上插入的時候,實質上還有很是重要的細節須要考慮。首先如何找到上層的待插入節點 ?
這個各個實現方法可能不一樣,若是有左、上指向的指針那麼能夠向左向上找到上層須要插入的節點,可是若是隻有右指向和下指向的咱們也能夠巧妙的藉助查詢過程當中記錄降低的節點。由於曾經降低的節點倒序就是須要插入的節點,最底層也不例外(由於沒有匹配值會降低爲null結束循環)。在這裏我使用棧這個數據結構進行存儲,固然使用List也能夠。下圖就是給了一個插入示意圖。
其次若是該層是目前的最高層索引,須要繼續向上創建索引應該怎麼辦?
首先跳錶最初確定是沒索引的,而後慢慢添加節點纔有一層、二層索引,可是若是這個節點添加的索引突破當前最高層,該怎麼辦呢?
這時候須要注意了,跳錶的head須要改變了,新建一個ListNode節點做爲新的head,將它的down指向老head,將這個head節點加入棧中(也就是這個節點做爲下次後面要插入的節點),就好比上面的9節點若是運氣夠好在往上創建一層節點,會是這樣的。
插入上層的時候注意全部節點要新建(拷貝),除了right的指向down的指向也不能忘記,down指向上一個節點能夠用一個臨時節點做爲前驅節點。若是層數突破當前最高層,頭head節點(入口)須要改變。
這部分更多的細節在代碼中註釋解釋了,詳細代碼爲:
public void add(SkipNode node) { int key=node.key; SkipNode findNode=search(key); if(findNode!=null)//若是存在這個key的節點 { findNode.value=node.value; return; } Stack<SkipNode>stack=new Stack<SkipNode>();//存儲向下的節點,這些節點可能在右側插入節點 SkipNode team=headNode;//查找待插入的節點 找到最底層的哪一個節點。 while (team!=null) {//進行查找操做 if(team.right==null)//右側沒有了,只能降低 { stack.add(team);//將曾經向下的節點記錄一下 team=team.down; } else if(team.right.key>key)//須要降低去尋找 { stack.add(team);//將曾經向下的節點記錄一下 team=team.down; } else //向右 { team=team.right; } } int level=1;//當前層數,從第一層添加(第一層必須添加,先添加再判斷) SkipNode downNode=null;//保持前驅節點(即down的指向,初始爲null) while (!stack.isEmpty()) { //在該層插入node team=stack.pop();//拋出待插入的左側節點 SkipNode nodeTeam=new SkipNode(node.key, node.value);//節點須要從新建立 nodeTeam.down=downNode;//處理豎方向 downNode=nodeTeam;//標記新的節點下次使用 if(team.right==null) {//右側爲null 說明插入在末尾 team.right=nodeTeam; } //水平方向處理 else {//右側還有節點,插入在二者之間 nodeTeam.right=team.right; team.right=nodeTeam; } //考慮是否須要向上 if(level>MAX_LEVEL)//已經到達最高級的節點啦 break; double num=random.nextDouble();//[0-1]隨機數 if(num>0.5)//運氣很差結束 break; level++; if(level>highLevel)//比當前最大高度要高可是依然在容許範圍內 須要改變head節點 { highLevel=level; //須要建立一個新的節點 SkipNode highHeadNode=new SkipNode(Integer.MIN_VALUE, null); highHeadNode.down=headNode; headNode=highHeadNode;//改變head stack.add(headNode);//下次拋出head } } }
對於上面,跳錶完整分析就結束啦,固然,你可能看到不一樣品種跳錶的實現,還有的用數組方式表示上下層的關係這樣也能夠,但本文只定義right和down兩個方向的鏈表更純正化的講解跳錶。
對於跳錶以及跳錶的同類競爭產品:紅黑樹,爲啥Redis的有序集合(zset) 使用跳錶呢?由於跳錶除了查找插入維護和紅黑樹有着差很少的效率,它是個鏈表,能肯定範圍區間,而區間問題在樹上可能就沒那麼方便查詢啦。而JDK中跳躍表ConcurrentSkipListSet和ConcurrentSkipListMap。 有興趣的也能夠查閱一下源碼。
對於學習,完整的代碼是很是重要的,這裏我把完整代碼貼出來,須要的自取。
import java.util.Random; import java.util.Stack; class SkipNode<T> { int key; T value; SkipNode right,down;//左右上下四個方向的指針 public SkipNode (int key,T value) { this.key=key; this.value=value; } } public class SkipList <T> { SkipNode headNode;//頭節點,入口 int highLevel;//層數 Random random;// 用於投擲硬幣 final int MAX_LEVEL = 32;//最大的層 SkipList(){ random=new Random(); headNode=new SkipNode(Integer.MIN_VALUE,null); highLevel=0; } public SkipNode search(int key) { SkipNode team=headNode; while (team!=null) { if(team.key==key) { return team; } else if(team.right==null)//右側沒有了,只能降低 { team=team.down; } else if(team.right.key>key)//須要降低去尋找 { team=team.down; } else //右側比較小向右 { team=team.right; } } return null; } public void delete(int key)//刪除不須要考慮層數 { SkipNode team=headNode; while (team!=null) { if (team.right == null) {//右側沒有了,說明這一層找到,沒有隻能降低 team=team.down; } else if(team.right.key==key)//找到節點,右側即爲待刪除節點 { team.right=team.right.right;//刪除右側節點 team=team.down;//向下繼續查找刪除 } else if(team.right.key>key)//右側已經不可能了,向下 { team=team.down; } else { //節點還在右側 team=team.right; } } } public void add(SkipNode node) { int key=node.key; SkipNode findNode=search(key); if(findNode!=null)//若是存在這個key的節點 { findNode.value=node.value; return; } Stack<SkipNode>stack=new Stack<SkipNode>();//存儲向下的節點,這些節點可能在右側插入節點 SkipNode team=headNode;//查找待插入的節點 找到最底層的哪一個節點。 while (team!=null) {//進行查找操做 if(team.right==null)//右側沒有了,只能降低 { stack.add(team);//將曾經向下的節點記錄一下 team=team.down; } else if(team.right.key>key)//須要降低去尋找 { stack.add(team);//將曾經向下的節點記錄一下 team=team.down; } else //向右 { team=team.right; } } int level=1;//當前層數,從第一層添加(第一層必須添加,先添加再判斷) SkipNode downNode=null;//保持前驅節點(即down的指向,初始爲null) while (!stack.isEmpty()) { //在該層插入node team=stack.pop();//拋出待插入的左側節點 SkipNode nodeTeam=new SkipNode(node.key, node.value);//節點須要從新建立 nodeTeam.down=downNode;//處理豎方向 downNode=nodeTeam;//標記新的節點下次使用 if(team.right==null) {//右側爲null 說明插入在末尾 team.right=nodeTeam; } //水平方向處理 else {//右側還有節點,插入在二者之間 nodeTeam.right=team.right; team.right=nodeTeam; } //考慮是否須要向上 if(level>MAX_LEVEL)//已經到達最高級的節點啦 break; double num=random.nextDouble();//[0-1]隨機數 if(num>0.5)//運氣很差結束 break; level++; if(level>highLevel)//比當前最大高度要高可是依然在容許範圍內 須要改變head節點 { highLevel=level; //須要建立一個新的節點 SkipNode highHeadNode=new SkipNode(Integer.MIN_VALUE, null); highHeadNode.down=headNode; headNode=highHeadNode;//改變head stack.add(headNode);//下次拋出head } } } public void printList() { SkipNode teamNode=headNode; int index=1; SkipNode last=teamNode; while (last.down!=null){ last=last.down; } while (teamNode!=null) { SkipNode enumNode=teamNode.right; SkipNode enumLast=last.right; System.out.printf("%-8s","head->"); while (enumLast!=null&&enumNode!=null) { if(enumLast.key==enumNode.key) { System.out.printf("%-5s",enumLast.key+"->"); enumLast=enumLast.right; enumNode=enumNode.right; } else{ enumLast=enumLast.right; System.out.printf("%-5s",""); } } teamNode=teamNode.down; index++; System.out.println(); } } public static void main(String[] args) { SkipList<Integer>list=new SkipList<Integer>(); for(int i=1;i<20;i++) { list.add(new SkipNode(i,666)); } list.printList(); list.delete(4); list.delete(8); list.printList(); } }
進行測試一下能夠發現跳錶仍是挺完美的(自詡一下)。
原創不易,bigsai請思否的朋友們幫兩件事幫忙一下:
我們下次再見!