鏈表與數(shù)組在數(shù)據(jù)結構的江湖上被并稱為南數(shù)組、北鏈表,其江湖地位可見一斑
概念
鏈表作為最基礎的通用存儲結構,它的作用和數(shù)組是一樣的,但存儲數(shù)據(jù)的方式略有不同。數(shù)組需要預先獲取一定的內存空間,且內存的地址是連續(xù)的,在存儲數(shù)據(jù)的時候,插入和刪除都需要遍歷數(shù)組,還需要移動部分數(shù)據(jù),是十分低效的。而鏈表能夠解決以上問題,下面來看一下鏈表的結構。
鏈表的數(shù)據(jù)項存儲在鏈表節(jié)點中,一個節(jié)點可以叫Node也可以叫Link,它包含數(shù)據(jù)項和一個引用,數(shù)據(jù)項存儲數(shù)據(jù)本身,通常是一個包含各種數(shù)據(jù)的類的對象,而引用則是引用下一個節(jié)點,一般記為next,鏈表的核心實現(xiàn)就是這個next,它標記了每個節(jié)點之間的聯(lián)系,像一根線一樣把這些散落在內存各個角落的節(jié)點對象串了起來。下面通過代碼來直觀的理解一下:
/**
* 鏈表的節(jié)點,用來存放數(shù)據(jù)
*/
public class Node {
// 數(shù)據(jù)變量
private String data;
// next引用
public Node next;
// 構造函數(shù)
public Node(String str) {
data = str;
}
// getter
public String getData() {
return data;
}
// Node的打印方法
public void displayNode() {
System.out.print("{ Node: " + data + " }..");
}
}
可以看到,鏈表節(jié)點的關鍵兩個屬性,一個是數(shù)據(jù)項(這里簡單的表示為String類型的data),一個是指向下一個鏈表節(jié)點的next引用(這里為了訪問簡單設置為public),雖然還不知道下一個節(jié)點是誰,但在鏈表的數(shù)據(jù)數(shù)據(jù)結構中,將數(shù)據(jù)插入時,就會建立這個聯(lián)系。
單向鏈表
以上說明了鏈表的重要概念,節(jié)點與引用,下面來展示如何構成一個鏈表,首先從最簡單的單向鏈表開始,單向鏈表顧名思義,只有一端可以插入和訪問的鏈表,鏈表類LinkList值僅維護第一個鏈表節(jié)點first的信息,其它的節(jié)點通過next引用,以下是實現(xiàn)代碼:
/**
* 單向鏈表:維護頭節(jié)點的信息,對鏈表的操作都從頭部開始
*/
public class LinkList {
// 頭節(jié)點的引用
Node first;
// 構造方法
public LinkList() {
first = null;
}
// 插入方法,頭插法
public void insertFirst(String str) {
Node newNode = new Node(str);
newNode.next = first;
first = newNode;
}
// 刪除方法,刪除頭部節(jié)點
public Node deleteFirst() {
Node temp = first;
first = first.next;
return temp;
}
// 打印鏈表方法
public void display() {
System.out.print("LinkList: ");
Node current = first;
while (current != null) {
current.displayNode();
current = current.next;
}
System.out.println();
}
// 判斷鏈表是否為空的方法
public Boolean isEmpty() {
return first == null;
}
// 在鏈表中查找的方法
public Node find(String str) {
Node current = first;
while (current != null) {
if (current.getData() != str)
current = current.next;
return current;
}
System.out.println("could not find Node :" + str);
return null;
}
// 從鏈表中查找元素刪除方法
public Node delete(String str) {
Node current = first;
Node previous = first;
while (current != null) {
if (current.getData() != str) {
previous = current;
current = current.next;
} else {
previous.next = current.next;
return current;
}
}
System.out.println("cloud not find Node:" + str + ", failed to delete it");
return null;
}
}
代碼中定義了唯一一個變量:頭節(jié)點的引用first,還有一些鏈表的常用方法:插入、刪除、查找、展示等,可以發(fā)現(xiàn)這些方法都是由頭節(jié)點first展開的,在表達這些方法的時候需要注意邊界和特殊情況first的分析。
雙端鏈表
注意是雙端列表,不是雙向鏈表,在上面的單向鏈表中,鏈表類僅維護了一個頭節(jié)點的引用first,那么如果要在鏈表尾部插入一個數(shù)據(jù),需要遍歷整個鏈表,這種效率很低。雙端鏈表不僅維護了first頭節(jié)點的引用,而且還維護了last尾節(jié)點的引用,這樣訪問尾節(jié)點就像訪問頭節(jié)點一樣方便,這種特性使得雙端鏈表比起單向鏈表在一些場合更有效率,比如說隊列。
以下展示雙端鏈表的實現(xiàn)代碼,節(jié)點還是引用上面的Node類
public class DoubleSideLinkList {
// 首節(jié)點和尾節(jié)點的引用
Node first;
Node last;
// constructor
public DoubleSideLinkList() {
first = null;
last = null;
}
// isEmpty
public boolean isEmpty() {
return first == null;
}
// insert first
public void insertFirst(String str) {
Node newNode = new Node(str);
if (isEmpty()) {
last = newNode;
}
newNode.next = first;
first = newNode;
}
// insert last
public void insertLast(String str) {
Node newNode = new Node(str);
if (isEmpty()) {
first = newNode;
}
last.next = newNode;
last = newNode;
}
// delete first
public Node deleteFirst() {
Node temp = first;
// 僅有一個元素
if (first.next == null)
last = null;
first = first.next;
return temp;
}
// display
public void displayList() {
System.out.println("List (first-->last): ");
Node current = first;
while(current != null) {
current.displayNode();
current = current.next;
}
System.out.println();
}
}
可以看出,雙端鏈表可以很方便的從尾節(jié)點插入數(shù)據(jù),這里需要說明的是,這個鏈表依然是單向的,每個節(jié)點也僅有一個引用next指向下一個節(jié)點,所以遍歷和查找的方法與單向鏈表沒什么差別,在此就不做展示。在實現(xiàn)雙端鏈表的insert與delete方法的時候需要注意特殊情況:列表為空或者列表將要為空時,first與last的處理。
有序鏈表
有序鏈表中,數(shù)據(jù)是按照關鍵值的有序排列的,有序鏈表的插入速度優(yōu)于有序數(shù)組,而且可以擴展到所有有效的內存,所以在很多場合可以使用有序鏈表,在以下的實現(xiàn)中,我們還是引用上面的Node類,因為數(shù)據(jù)項data為String類型,我們以數(shù)據(jù)項的hashcode為序來實現(xiàn)有序鏈表:
public class SortedLinkList {
// filed
Node first;
// constructor
public SortedLinkList() {
first = null;
}
public boolean isEmpty() {
return (first==null);
}
public void insert(String key) {
Node current = first;
Node previous = null;
Node newNode = new Node(key);
while (current != null && current.getData().hashCode() < key.hashCode()) {
previous = current;
current = current.next;
}
if (previous == null) {
first = newNode;
} else {
previous.next = newNode;
}
newNode.next = current;
}
public Node remove() {
Node temp = first;
first = first.next;
return temp;
}
public void display() {
System.out.print("LinkList: ");
Node current = first;
while (current != null) {
current.displayNode();
current = current.next;
}
System.out.println();
}
}
有序鏈表主要是insert方法比較難實現(xiàn),也是需要考慮特殊情況,即鏈表為空。
雙向鏈表
雙向鏈表與雙端鏈表不同,雙端鏈表雖然可以從尾部插入節(jié)點,但是每個節(jié)點引用的還是其下一個節(jié)點,遍歷也是單向的,而雙向鏈表則可以從頭部或者從尾部開始遍歷,為了實現(xiàn)這一特性,我們需要修改一下節(jié)點類Node,為了區(qū)分,我們以Link為節(jié)點命名,在Link中,不僅實現(xiàn)對下一節(jié)點的引用next,而且還實現(xiàn)了對上一個節(jié)點的引用previous,如下:
public class Link {
private int data;
public Link next;
public Link previous;
public Link(int data) {
this.data = data;
}
public int getData() {
return data;
}
public void displayLink() {
System.out.print(data + " ");
}
}
唯一不同的點就是指向上一個節(jié)點的引用previous,雖然僅在節(jié)點中加了一個引用,但是雙向鏈表的實現(xiàn)卻麻煩了很多,其中各種引用關系特別容易混亂,而且還有一些特殊情況需要考慮,下面一起來感受一下:
public class DoublyLinkList {
// 頭節(jié)點和尾節(jié)點的引用,也是雙端的
Link first;
Link last;
// constructor
public DoublyLinkList() {
first = null;
last = null;
}
// isEmpty
public boolean isEmpty() {
return first == null;
}
// insert first
public void insertFirst(int data) {
Link newLink = new Link(data);
if (isEmpty()) {
last = newLink;
} else {
first.previous = newLink;
}
newLink.next = first;
first = newLink;
}
// insert last
public void insertLast(int data) {
Link newLink = new Link(data);
if (isEmpty()) {
first = newLink;
} else {
last.next = newLink;
newLink.previous = last;
}
last = newLink;
}
// insert after
public boolean insertAfter(int key, int data) {
Link newLink = new Link(data);
Link current = first;
// find
while(current != null) {
if (current.getData() != key) {
current = current.next;
} else {
if (current == last) {
newLink.next = null;
last = newLink;
} else {
newLink.next = current.next;
current.next.previous = newLink;
}
current.next = newLink;
newLink.previous = current;
return true;
}
}
System.out.println("cloud not find the key: " + data);
return false;
}
// delete first
public Link deleteFirst() {
Link temp = first;
if (first.next == null) {
last = null;
} else {
first.next.previous = null;
}
first = first.next;
return temp;
}
// delete last
public Link deleteLast() {
Link temp = last;
if (first.next == null) {
first = null;
} else {
last.previous.next = null;
}
last = last.previous;
return temp;
}
// delete key
public Link deleteKey(int data) {
Link current = first;
while (current != null) {
if (current.getData() != data) {
current = current.next;
} else {
if (current == first) {
first = first.next;
} else {
current.previous.next = current.next;
}
if (current == last) {
last = last.previous;
} else {
current.next.previous = current.previous;
}
return current;
}
}
System.out.println("cloud not find the key: " + data + ", delete failed!");
return null;
}
// display forward
public void displayForward() {
System.out.println("List (first-->last): ");
Link current = first;
while (current != null) {
current.displayLink();
current = current.next;
}
System.out.println();
}
// display backward
public void displayBackward() {
System.out.println("List (last-->first): ");
Link current = last;
while (current != null) {
current.displayLink();
current = current.previous;
}
System.out.println();
}
}
這是一個雙向鏈表,同時也是雙端列表,可以從前向后或者從后向前遍歷,也可以在鏈表的任何地方插入或是刪除節(jié)點,在實現(xiàn)這些方法時,需要修改與插入或刪除節(jié)點有關系的前后四個引用,且還需要考慮first與last、列表為空或者僅有一個元素的特殊情況,可以通過畫圖來加深理解。
效率與總結
以上我們介紹并實現(xiàn)了單向鏈表、雙端鏈表、有序鏈表和雙向鏈表,接下來我們來討論一下這些鏈表的效率。
鏈表與數(shù)組作為通用數(shù)據(jù)結構經(jīng)常被拿來做對比,以鏈表和數(shù)組這兩個通用數(shù)據(jù)結構為基礎的一些抽象數(shù)據(jù)模型,比如java中的ArrayList與LinkedList,也經(jīng)常被拿出來作比較,總的來說主要有兩大區(qū)別:
鏈表插入和刪除的效率高于數(shù)組,鏈表插入、刪除頭節(jié)點、尾節(jié)點的時間效率為O(1),而在鏈表中間插入、刪某一節(jié)點的時間復雜度為O(N),雖然與數(shù)組相同操作的時間復雜度相同,可是數(shù)組需要復制移動元素位置來填補空洞,所以鏈表的效率還是要優(yōu)于數(shù)組的,特別是復制時間占比較重的情況中。
數(shù)組則是隨機訪問元素的效率要高于鏈表,可以通過數(shù)組下標直接訪問的到。鏈表比數(shù)組優(yōu)越的另一個重要因素就是,鏈表可以擴展到所有可用的內存空間,且不用像數(shù)組一樣初始化容量,也不需要可以擴容。另外當一個數(shù)組對象需要一個連續(xù)的大內存空間時,會有幾率觸發(fā)jvm的GC去整理內存空間以獲取一片連續(xù)的內存,而鏈表則沒有這方面的限制,所以在內存的使用效率上,鏈表優(yōu)于數(shù)組。
正如以上所述,一般來說,鏈表如果采用頭插法(或刪除),則時間復雜度均為O(1),而如果是隨機插入或者向有序鏈表中插入的時間效率均為O(N),因為需要找到合適的位置;
有序鏈表可以在O(1)的時間返回或刪除最小或最大值,如果一個應用需要頻繁的返回最小值且不需要快速的插入時間,則可以選擇有序鏈表來實現(xiàn),如優(yōu)先級隊列;
雙向鏈表由于兩端都可以插入、刪除和遍歷,所以可以用來做雙端隊列。
參考文章
《Data Structures & Algorithms in Java》 Robert Lafore著