1. 程式人生 > >資料結構與演算法(二)-線性表之單鏈表順序儲存和鏈式儲存

資料結構與演算法(二)-線性表之單鏈表順序儲存和鏈式儲存

前言:前面已經介紹過資料結構和演算法的基本概念,下面就開始總結一下資料結構中邏輯結構下的分支——線性結構線性表

一、簡介

1、線性表定義

  線性表(List):由零個或多個數據元素組成的有限序列;   這裡有需要注意的幾個關鍵地方:     1.首先他是一個序列,也就是說元素之間是有個先來後到的。     2.若元素存在多個,則第一個元素無前驅,而最後一個元素無後繼,其他元素都有且只有一個前驅和後繼。     3.線性表強調是有限的,事實上無論計算機發展到多錢大,他所處理的元素都是有限的。   使用數學語言來表達的話:     a1,…,ai-1,ai,ai+1,…an   表中ai-1領先於ai,ai領先於ai+1,稱ai-1是ai的直接前驅元素,ai+1是ai的直接後繼元素。所以線性表元素的各數n(n>0)定義為線性表的長度,當n=0時,稱為空表。

2、 抽象資料型別

  資料型別:是指一組性質相同的值得集合及定義在此集合上的一些操作的總稱。例如很多程式語言的整型,浮點型,字元型這些指的就是資料型別。   不同的資料結構滿足不同的計算需求,所以出現了各種各樣樣的資料型別,它可以分為兩類    1.原子型別:不可以再分解的基本資料型別,例如整型、浮點型等;     2.結構型別:有若干個型別組合而成,是可以再分解的,例如整型陣列是由若干整型資料組成的;   抽象:是指抽取處事務具有的普遍性的本質。他要求抽出問題的特徵而忽略非本質的細節,是對具體事務的一個概括。抽象是一種思考問題的方式,他隱藏了複雜的細節。   而我們對資料型別進行抽象,就有了抽象資料型別,抽象資料型別是指一個數據模型及定義在該模型上的一組操作。抽象資料型別的定義僅取決於它的一組邏輯特性,而與其在計算機內部如何表示和實現無關。   抽象資料型別表示:
ADT 抽象資料型別
  Data
    資料結構
  Operation
    具體資料操作(例,增刪改查)
  比如1+1=2這樣一個操作,在不同CPU的處理上可能不一樣,但由於其定義的數學特性相同,所以在計算機程式設計者看來,它們都是相同的。“抽象”的意義在於資料型別的數學抽象特性。   而且,抽象資料型別不僅僅指那些已經定義並實現的資料型別,還可以是計算機程式設計者在設計軟體程式時自己定義的資料型別。   例如一個3D遊戲中,要定位角色的位置,那麼總會出現x,y,z三個整型資料組合在一起的座標。我們就可以定義一個point的抽象資料型別,它擁有x,y,z三個整型變數,這樣我們就可以方便的對一個角色的位置進行操作。所以抽象資料型別就是把資料型別和相關操作捆綁在一起。

二、線性表實現及優缺點

  相同的資料邏輯結構可以用不同的儲存結構,所以線性表有兩種物理儲存結構:順序儲存結構鏈式儲存結構

1、 線性表的順序儲存結構

  線性表的順序儲存結構,指的是用一段地址連續的儲存單元一次儲存線性表的資料元素。   線性表(a1,a2,……,an)的順序儲存如下:   上面的線性表的順序儲存結構是不是與陣列一樣樣的?!?!   事實上物理上的儲存方式事實上就是在記憶體中找個初始地址,然後通過佔位的形式,把一定的記憶體空間給佔了,然後把相同資料型別的資料元素依次放在這塊空地中。   總結一下,順序儲存結構封裝需要三個屬性:
  • 儲存空間的起始位置,陣列data,它的儲存位置就是線性表儲存空間的儲存位置;
  • 線性表的最大儲存容量:陣列的長度MaxSize;
  • 線性表的當前長度length;
  注意:陣列的長度與線性表的當前長度需要區分一下。陣列的長度是存放線性表的儲存空間的總長度,一般初始化後不變。而線性表的當前長度是線性表中元素的個數,是會變化的。 在Java中,它的結構可能是這樣的:
public class Linear<T> {

    //儲存線性表資料的陣列
        private Object[] objects;

        //線性表的長度
        private Integer length;

        //陣列的長度
        private Integer objectsLength;

        //初始化
        Linear(Integer objectsLength) {
            objects = new Object[objectsLength];
        }
    
        //
        public T get(Integer index) {
            return (T)objects[index];
        }

        //
        public void add(T t) {
       //增加之前先判斷是否需要擴容
            isFull();
            objects[length+1]=t;
        }
    
        //
        public void remove(Integer index) {
            //迴圈查詢index,移除該元素並將後面元素前移一位
        }
    
        //判斷當新增一個元素時,是否會陣列溢位,若溢位則新建陣列並擴大陣列長度
        private void isFull() {
        
        } 
}
  線上性表的順序儲存結構中,它具有隨機儲存結構的特點,即直接通過下標獲取資料或儲存,那儲它的時間複雜度為O(1)。而當該結構的資料型別做插入操作時,就不能只插入而不管後面的元素,所以插入操作,也要考慮清楚。 插入演算法的思路:
  • 如果插入位置不合理,丟擲異常;
  • 如果線性表長度大於等於陣列長度,則丟擲異常活動太增加陣列容量;
  • 從最後一個元素開始向前遍歷到第i個位置,分別將它們都向後移動一個位置;
  • 將要插入元素填入位置i處;
  • 線性表長+1;
Java程式碼實現:
public void add(Integer index,T t) throws Exception {
        isFull();
        if (index<0 || index>length)
            throw new Exception("index outof length");
        for (int i = length; i >= index; i--) {
            objects[length+1] = objects[length];
        }
        objects[index] = t;
 }
  同理,刪除元素,也需要將刪除元素後的元素依次前移一位;   現在分析一下,插入和刪除的時間複雜度。   最好情況:插入和刪除操作剛好要求在最後一個位置操作,因為不需要移動任何元素,所以此時的時間複雜度為O(1)。   最壞情況:如果要插入和刪除的位置是第一個元素,那就意味著要移動所有的元素向後或者向前,所以這個時間複雜度為O(n)。   平均情況,就取中間值O((n-1)/2)。   這樣來看,平均情況複雜度簡化後還是O(n)總結:   線性表的順序儲存結構,在存、讀資料時,不管是哪個位置,時間複雜度都是O(1)。而在插入或刪除時,時間複雜度都是O(n)。   這就說明,它比較適合元素個數比較穩定,不經常插入和刪除元素,而更多的操作是存取資料的應用。   我們接下來給大家簡單總結下線性表的順序儲存結構的優缺點: 優點:
  • 無需為表中元素之間的邏輯關係而增加額外的儲存空間;
  • 可以快速地存取表中任意位置的元素;
缺點:
  • 插入和刪除操作需要移動大量元素;
  • 當線性表長度變化較大時,難以確定儲存空間的容量;
  • 容易造成儲存空間的“碎片”;

2 、 線性表的鏈式儲存結構

上一小節介紹了線性表的順序結構,它最大的缺陷是插入和刪除時需要移動大量元素,這是非常耗時的。那我們怎麼才能解決這個缺陷呢?這就需要找到原因了。 原因在於順序儲存結構在記憶體中的位置是連續的、無縫隙的,相鄰的儲存位置也具有鄰居關係,所以當插入和刪除時,需要移動大量的元素來保證記憶體地址的順序; 而鏈式的儲存結構就不會受記憶體的儲存順序影響,它可以在任意位置儲存,只需要指定元素的後繼元素就可以了,也就是說除了儲存其本身的資訊外,還需儲存一個指示其後繼的儲存位置的資訊。 鏈式儲存結構的特點:用一組任意的儲存單元儲存線性表的資料元素,這組儲存單元可以存在記憶體中未被佔用的任意位置。 我們把儲存資料元素資訊的域稱為資料域,把儲存直接後繼位置的域稱為指標域。指標域中儲存的資訊稱為指標或鏈。這兩部分資訊組成資料型別稱為儲存映像,也成為結點(Node) Java程式碼來表示結點:
class Node<T> {

  private Object object;

  private Node next;

  public Object getObject() {
    return object;
  }

  public void setObject(Object object) {
    this.object = object;
  }

  public Node getNext() {
    return next;
  }

  public void setNext(Node next) {
    this.next = next;
  }
}
單鏈表結構圖:   對於線性表來說,總得有個頭有個尾,連結串列也不例外。我們把連結串列中的第一個結點的儲存位置叫做頭指標,最後一個結點指標為空(NULL)。 頭指標
  • 頭指標是指連結串列指向第一個節點的指標,若連結串列有頭結點,則是指向頭結點的指標;
  • 頭指標具有表示作用,所以常用頭指標冠以連結串列的名字(指標變數的名字);
  • 無論連結串列是否為空,頭指標均不為空;
  • 頭指標是連結串列的必要元素
頭結點
  • 頭結點是為了操作的統一和方便而設立的,放在第一個元素的結點之前,其資料域一般無意義(但也可以用來存放連結串列的長度);
  • 有了頭結點,對在第一元素結點前插入結點和刪除第一結點起操作與其它結點的操作就統一了;
  • 頭節點不一定是連結串列的必要元素;
Java程式碼:
public class Chain<T> {

  //頭結點直接引用
  private Node<T> head;

  //初始化
  Chain() {
    head = new Node<T>();
    head.setNext(null);
  }

  class Node<T> {

    ...
  }

}
  在順序儲存結構中,有隨機儲存結構的特點,計算任意一個元素的儲存位置是很容易的,但是在單鏈表中,想知道其中一個元素的位置,就得從第一個結點開始遍歷,因此,對於單鏈表實現獲取第i個元素的資料的操作,在演算法上較為複雜。   獲取連結串列第i個數據的演算法思路
  • 宣告一個節點p指向連結串列第一個節點,初始化j從1開始;
  • 當j<i時,酒便利連結串列,讓p的指標向後移動,不斷指向下一個節點,j+1;
  • 若到連結串列末尾p為空,則說明第i個元素不存在;
  • 否則查詢成功,返回節點p的資料;
單鏈表資料的獲取:
public T get(Integer index) throws Exception {
    return (T)getNode(index).getObject();
}

private Node<T> getNode(Integer index) throws Exception {
    if (index > size || index < 0)
        throw new Exception("index outof length");
    Node<T> p = head;
    for (int i = 0; i < index; i++)
        p = p.next;
    return p;
}        
  由於這個演算法的時間複雜度取決於i的位置,當i=1時,則不需要遍歷,而i=n時則遍歷n-1次才可以。因此最壞情況的時間複雜度為O(n)。   在Java中有運用到線性的鏈式儲存結構的類LinkedList。檢視原始碼,在該類中的獲取節點的方法比較巧妙:
Node<E> node(int index) {
    //通過比較下標在list中的位置,來決定是從前往後還是從後往前遍歷,以提高效率
    if (index < (size >> 1)) {
        Node<E> x = first;
        for (int i = 0; i < index; i++)
            x = x.next;
        return x;
    } else {
        Node<E> x = last;
        for (int i = size - 1; i > index; i--)
            x = x.prev;
        return x;
    }
}            
  Java中的演算法需要Node類中有前置節點(也就是雙向連結串列,後面會介紹),這樣可以從後面向前便利,提升效能,它的時間複雜度為O(size-n)或O(n),也就是空間換時間。 單鏈表的插入:
  • 建立新的結點p;
  • 將新節點p的next指向當前節點s的next;
  • 將當前結點s的next指向新節點p;
  • 完成插入;
Java程式碼:
public void add(T t,Integer index) throws Exception {
    //獲取該位置的上一個節點
    Node<T> s = getNode(index - 1);
    //建立新節點
    Node<T> p = new Node<>();    p.setObject(t);
    //將本節點的next節點放入新節點的next節點
    p.setNext(s.getNext());
    //將新節點放入本節點的next節點位置
    s.setNext(p);
}
而單鏈表的刪除與插入本質是相同的,無非一個是放,一個是拿,所以直接上程式碼:
public Node<T> remove(Integer index) throws Exception {
    //獲取該位置的上一個節點
    Node<T> s = getNode(index - 1);
    //獲取該位置節點的下一個節點
    Node<T> next = getNode(index).getNext();
    //將本節點的next節點放在本節點的前一個節點的next節點位置
    s.setNext(next.getNext());
    return next;
}
我們發現無論是單鏈表插入還是刪除演算法,他們其實都是由兩部分組成:
  • 遍歷查詢第i個元素;
  • 實現插入和刪除元素;
  從整個演算法來說,我們很容易可以推算出他們的時間複雜度都是O(n)。再詳細點分析:     如果在我們不知道第i個元素的指標位置,單鏈表資料結構在插入和刪除操作上,與線性表的順序儲存結構是沒有太大優勢的。   但是,如果我們從第i個位置連續插入10個、100個等等很多元素時,對於順序儲存結構意味著,每一次插入都需要移動n-i個位置,所以每次都是O(n)。而單鏈表,我們只需要在第一次時,找到第i個位置的指標,此時為O(n),接下來只是簡單地通過賦值移動指標而已,時間複雜度都是O(1)。   顯然,對於插入或刪除資料越紛繁複雜的操作越多,單鏈表的效能優勢就越是明顯。 頭插法:   頭插法從一個空表開始,生成新結點,讀取資料存放到新結點的資料域中,然後將新結點插入到當前連結串列的表頭上,直到結束為止。   簡單來說,就是把新加進的元素放在表頭後的第一個位置:
  • 先讓新節點的next指向頭結點之後;
  • 然後讓表頭的next指向新節點;
  這樣插入的時間複雜度為O(1)。 尾插法:   需要一個引用時刻記錄連結串列的尾節點,這樣插入時直接插入,以提升效能。

3 、 總結

儲存分配方式:
  • 順序儲存結構用一段連續的儲存單元依次儲存線性表的資料元素;
  • 單鏈表採用鏈式儲存結構,用一組任意的儲存單元存放線性表的元素;
時間效能:
  • 查詢
    • 順序儲存結構O(1);
    • 單鏈表O(n);
  • 插入和刪除
    • 順序儲存結構需要平均移動表唱一半的元素,時間為O(n);
    • 單鏈表在計算出某位置的指標後,插入和刪除時間僅為O(1);
空間效能:
  • 順序儲存結構需要預分配儲存空間,分多了,容易造成空間浪費,分少了,容易溢位;
  • 單鏈表不需要分配儲存空間,只要有就可以分配,元素個數也不瘦限制;
結論:
  • 若需要頻繁查詢,很少進行插入和刪除操作時,宜採用順序儲存結構;
  • 若需要頻繁插入和刪除時,宜採用單鏈表結構;
說句題外話,Java中的ArrayListLinkedList分別對應的儲存結構為順序鏈式,所以這兩個類也適用這個結論。

 本系列參考書籍:

  《寫給大家看的演算法書》

  《圖靈程式設計叢書 演算法 第4版》