目錄
一、鏈表Linked List
動態數組、棧和隊列底層是依託靜態數組實現的,靠resize()解決固定容量問題,而鏈表是一種真正的動態數據結構。
鏈表的數據存儲在節點(Node)中。
如下所示代碼,節點包含兩部分的內容,一部分是存儲真正的數據E e;另一部分就是節點本身,代表的是當前節點的下一個節點。
public class Node {
E e;
Node next;
}
就好比火車,存儲內容的節點就如同一個車廂,而車廂纔是內容的真正載體,車廂與車廂之間又進行關聯,從而構成一個整體。
鏈表的優點:不需要處理固定容量的問題(動態)。
缺點:喪失了隨機訪問的能力。數組具備隨機訪問能力的原因是數組開闢的空間在內存裏是連續的,所以可以通過索引快速的進行元素訪問,而鏈表的存儲位置是不連續的,它是必須通過節點一個一個串聯起來查找。
因此我們可以發現:數組適合查找,而鏈表適合增刪。
如下是在代碼中維護一個內部節點的示例:
public class LinkedList<E> {
// 內部使用私有內部類維護一個節點
private class Node{
public E e;
public Node next;
public Node(E e,Node next){
this.e = e;
this.next = next;
}
public Node(E e){
this(e,null);
}
public Node(){
this(null,null);
}
@Override
public String toString(){
return e.toString();
}
}
}
1、往鏈表中添加元素
在鏈表中,我們維護一個head,用來指示鏈表的第一個節點,維護一個size,用來指示鏈表中存儲的元素個數。設計圖示如下所示:
(1)往鏈表頭部添加元素
在這樣的鏈表當中,如果我們想要往鏈表中添加一個元素,會發現我們有一個頭來跟蹤鏈表的頭,但是沒有相應的元素來跟蹤鏈表的尾,所以這個時候,我們去往鏈表的頭添加元素會比較方便。
如圖,我們需要把一個節點666添加到鏈表中。首先,我們需要把這個node的next指向這個鏈表的頭:node.next = node;然後維護一下head,使他指向新的頭節點666:head = node。完成結果圖示如下:
代碼實現如下(在上邊的代碼中添加):
private Node head;
private int size;
// 初始化鏈表
public LinkedList(){
head = null;
size = 0;
}
// 獲取鏈表中的元素個數
public int getSize(){
return size;
}
// 返回鏈表是否爲空
public boolean isEmpty(){
return size == 0;
}
// 在鏈表頭添加新的元素
public void addFirst(E e){
// 實現步驟
// Node node = new Node(e);
// node.next = head;
// head = node;
// 也可以寫成這樣
head = new Node(e,head);
size ++;
}
(2)往鏈表中間和末尾添加元素
我們如果想要往鏈表的某兩個節點中間插入一個數據,比如,在鏈表中2的位置插入一個新的節點666。這個時候,我們需要找到2這個節點之前的那個節點prev;然後把prev節點的next賦值給node.next;再把prev的next更新成node;這樣操作之後,便成功的把666這個節點加入了這兩個節點之間。
注意:以上操作的順序很重要。即不能先把把prev的next更新成node;然後再把prev節點的next賦值給node.next。這是因爲prev的next在前邊的賦值中就已經被替換掉了。
同樣,如果是往鏈表的末尾添加數據,就更簡單了,只需要把操作節點的位置鎖定在最後一個節點的前一個節點上就可以了。
以上思路的代碼實現如下:
// 在鏈表的index(0-size)位置添加新的元素
// 在鏈表中不是一個常用操作,練習用
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed,Illegal index");
}
if (index == 0) {
addFirst(e);
} else {
// 從第一個頭節點開始遍歷
Node prev = head;
// 尋找插入節點之前的前一個位置的節點
for (int i = 0; i < index - 1; i++) {
// 不斷覆蓋下一個節點的連接,直到目標節點的前一個節點
prev = prev.next;
}
// 創建節點/進行交換操作
// Node node = new Node(e);
// node.next = prev.next;
// prev.next = node;
prev.next = new Node(e, prev.next);
size++;
}
}
// 在鏈表的末尾添加新的元素e
public void addLast(E e) {
add(size, e);
}
(3)使用虛擬頭節點
在上邊的操作中,我們需要對在鏈表頭部添加元素做特殊的處理,即if(index == 0) addFirst(e)。現在我們換一種思路,即在初始化鏈表的時候,就創建一個虛擬的頭節點dummyHead;這個虛擬頭節點處在圖中0位置的前一個位置(也就是佔據了鏈表的第一個位置,只是這個位置的節點存儲的是空值),如下所示:
根據上述思路,代碼的實現如下:
private Node dummyHead;
private int size;
// 初始化鏈表
public LinkedList(){
// 創建虛擬頭節點
dummyHead = new Node(null,null);
size = 0;
}
// 獲取鏈表中的元素個數
public int getSize(){
return size;
}
// 返回鏈表是否爲空
public boolean isEmpty(){
return size == 0;
}
// 在鏈表的index(0-size)位置添加新的元素
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed,Illegal index");
}
// 從第一個頭節點開始遍歷,此時是虛擬頭節點佔據了第一個位置
Node prev = dummyHead;
// 尋找插入節點之前的前一個位置的節點
for (int i = 0; i < index; i++) {
// 不斷覆蓋下一個節點的連接,直到目標節點的前一個節點
prev = prev.next;
}
// 創建節點/進行交換操作
prev.next = new Node(e, prev.next);
size++;
}
// 在鏈表頭添加新的元素
public void addFirst(E e) {
add(0, e);
}
// 在鏈表的末尾添加新的元素e
public void addLast(E e) {
add(size, e);
}
2、鏈表的查詢和修改
鏈表的查詢和修改,都是圍繞着特定的節點操作而展開。如上,增加操作是操作指定位置的前一個節點,那麼修改和查詢操作就是操作索引當前位置的節點了,只不過,我們設立了虛擬頭節點,所以,這個節點就是當前節點的下一個節點。
注:鏈表沒有索引的概念,這裏爲了表述,所以叫做索引。
接下來,看一下具體代碼的實現。在前面代碼的基礎上添加如下代碼:
// 獲取鏈表中index(0-size)位置的元素
// 在鏈表中不是一個常用操作,練習用
public E get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Get failed , Illegal index");
}
// 還是使用虛擬頭節點
// 從當前位置的下一個節點的位置進行遍歷,跟插入元素選擇節點不同
Node cur = dummyHead.next;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
return cur.e;
}
// 獲取鏈表的第一個元素
public E getFirst() {
return get(0);
}
// 獲取鏈表的最後一個元素
public E get() {
return get(size - 1);
}
// 修改鏈表中index(0-size)位置的元素e
// 在鏈表中不是一個常用操作,練習用
public void set(int index, E e) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("set failed , Illegal index");
}
// 還是使用虛擬頭節點
// 從當前位置的下一個節點的位置進行遍歷,跟插入元素選擇節點不同
Node cur = dummyHead.next;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
cur.e = e;
}
// 查找鏈表中是否有元素e
public boolean contains(E e) {
Node cur = dummyHead.next;
// 當節點爲空時,說明已經遍歷到了鏈表的結尾
while (cur != null) {
if (cur.e.equals(e)) {
return true;
}
cur = cur.next;
}
return false;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
// Node cur = dummyHead;
// while (cur != null) {
// sb.append(cur + "->");
// cur = cur.next;
// }
// 循環還可以這樣寫——>開始條件;結束條件;中間操作
for(Node cur = dummyHead.next;cur != null;cur = cur.next){
sb.append(cur + " -> ");
}
sb.append("NULL");
return sb.toString();
}
public static void main(String[] args) {
LinkedList<Integer> list = new LinkedList<Integer>();
for(int i =0;i<5;i++){
// 使用addFirst,因爲它是O(1)級別的操作
list.addFirst(i);
System.out.println(list);
}
// 在索引爲2的位置添加新的元素666
list.add(2,666);
System.out.println(list);
}
綜合上述代碼的執行結果如下:
3、從鏈表中刪除元素
從鏈表中刪除元素,我們也需要找到刪除節點前的那個節點,比如刪除2這個位置的元素,我們就需要把2這個位置的節點的next賦值到1這個位置的next上,實現下圖所示操作。當然,爲了java能及時進行垃圾回收,我們還需要把刪除節點的元素進行置空。
以下是刪除的代碼邏輯實現
// 從鏈表中刪除index(0-size)位置的元素e
// 在鏈表中不是一個常用操作,練習用
public E remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("remove failed , Illegal index");
}
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
// 被刪除的節點
Node retNode = prev.next;
prev.next = retNode.next;
// 被刪除的節點不再跟其他節點來連接,徹底跟鏈表脫離關係
retNode.next = null;
size --;
return retNode.e;
}
// 從鏈表中刪除第一個元素
public E removeFirst() {
return remove(0);
}
// 從鏈表中刪除最後一個元素
public E removeLast() {
return remove(size - 1);
}
二、鏈表的時間複雜度分析
1、添加操作
2、刪除操作
3、查找操作
在鏈表中沒有設計跟數組一樣的find(e),這是因爲即便是拿到了元素的索引,也不能快速的進行訪問,所以這個方法是沒有意義的。
4、修改操作
5、時間複雜度總結
如上分析,鏈表的時間複雜度,增、刪、改、查都是O(n)級別的,所以它的整體性能要比數組差,因爲鏈表不能像數組那樣,可以使用索引進行快速的數據訪問。但是,當我們只在鏈表的頭進行增、刪、查操作時,它的時間複雜度是O(1)級別的,又因爲鏈表整體是動態的,所以不會大量的浪費內存空間,因此具備有一定的優勢。
三、使用鏈表實現棧
如上所述,對於我們的自定義鏈表來說,如果只對鏈表頭進行操作,這樣的操作級別是O(1)的。這種只對頭部元素進行操作的數據結構跟棧比較相似,下邊,我們通過自定義鏈表來實現一個棧。
在實現棧之前,我們繼續實現通過自定義數組來實現棧的那個接口:Stack,然後來對比一下兩個不同底層實現之間所帶來的性能差異。
Stack接口:
public interface Stack<E> {
// 獲取棧中數據量
int getSize();
// 判斷棧是否爲空
boolean isEmpty();
// 向棧頂推送元素
void push(E e);
// 從棧中取出元素
E pop();
// 查看棧頂元素
E peek();
}
通過鏈表實現的棧的代碼:
public class LinkedListStack<E> implements Stack {
private LinkedList<E> list;
// 鏈表沒有容量這個概念,因此不需要設置容量
public LinkedListStack() {
list = new LinkedList<E>();
}
@Override
public int getSize() {
return list.getSize();
}
@Override
public boolean isEmpty() {
return list.isEmpty();
}
@Override
public void push(Object e) {
// 向鏈表頭部添加元素是O(1)級別的,頭部爲棧頂
list.addFirst((E) e);
}
@Override
public Object pop() {
return list.removeFirst();
}
@Override
public Object peek() {
return list.getFirst();
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
sb.append("Stack: top ");
sb.append(list);
return sb.toString();
}
public static void main(String[] args) {
int opCount = 1000000;
ArrayStack<Integer> arrayStack = new ArrayStack<Integer>();
double time1 = testStack(arrayStack, opCount);
System.out.println("arrayStack :" + time1 + " s");
// linkedListStack包含更多new操作
LinkedListStack<Integer> linkedListStack = new LinkedListStack<Integer>();
double time2 = testStack(linkedListStack, opCount);
System.out.println("linkedListStack :" + time2 + " s");
}
private static double testStack(Stack<Integer> stack, int count) {
long startTime = System.nanoTime();
Random random = new Random();
for (int i = 0; i < count; i++) {
// 插入一個從0到int最大數之間的一個隨機值
stack.push(random.nextInt(Integer.MAX_VALUE));
}
for (int i = 0; i < count; i++) {
stack.pop();
}
long endTime = System.nanoTime();
// 以秒爲單位
return (endTime - startTime) / 1000000000.0;
}
}
上述代碼中,在main函數內部寫了一個ArrayStack和LinkedListStack的方法,兩者的測試結果如下:
可以看到LinkedListStack在10萬次的運行中會比ArrayStack相對快那麼一點,但是差距不會很大,因爲這兩者的操作級別都是O(1)的,並不會像O(1)和O(n)對比的差距那麼明顯。不過,在這裏,其實這個結論也是站不住腳的,當執行次數達到100萬級別後,ArrayStack反而會比LinkedListStack用的操作時間要少,這是因爲在LinkedListStack的操作中,需要不斷的去new一個對象,不斷的去內存開闢空間,因此當執行量比較大時,這樣反而會更加損耗性能,讀者可自行嘗試。
四、使用鏈表實現隊列
上邊,我們使用鏈表實現了棧這種數據結構,接下來,我們使用鏈表來實現隊列。因爲隊列是操作數據結構的兩端,而鏈表只有在操作頭部節點的時候,它的操作級別纔是O(1)的。那麼就隊列來說,同時操作頭和尾,是不是必有一種操作時O(n)級別的呢?
爲了使我們通過鏈表實現的隊列也具備良好的性能,我們稍微改變了一下鏈表的維護方式。如下圖,我們使用tail用來指示鏈表的尾部,當我們需要向鏈表尾部添加元素時,就不需要再去循環遍歷整個鏈表了。不過,當我們需要刪除鏈表尾部的數據時,我們仍然不得不去進行整個鏈表的遍歷。正因爲如此,在通過鏈表實現隊列時,我們選擇在head端刪除元素,在tail端添加元素,這樣兩個操作就都是O(1)級別的了。
同樣的,我們需要繼承Queue接口
public interface Queue<E> {
int getSize();
boolean isEmpty();
// 放入元素
void enqueue(E e);
// 拿出元素
E dequeue();
// 查看隊列元素
E getFornt();
}
具體代碼實現如下:
public class LinkedListQueue<E> implements Queue{
// 內部使用私有內部類維護一個節點
private class Node{
public E e;
public Node next;
public Node(E e, Node next){
this.e = e;
this.next = next;
}
public Node(E e){
this(e,null);
}
public Node(){
this(null,null);
}
@Override
public String toString(){
return e.toString();
}
}
private Node head,tail;
private int size;
public LinkedListQueue(){
head = null;
tail = null;
size = 0;
}
@Override
public int getSize(){
return size;
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public void enqueue(Object e) {
if(tail == null){
tail = new Node((E) e);
head = tail;
}else{
tail.next = new Node((E) e);
tail = tail.next;
}
size ++;
}
@Override
public E dequeue() {
if(isEmpty()){
throw new IllegalArgumentException("Cannot dequeue from an empty queue.");
}
Node retNode = head;
head = head.next;
retNode.next = null;
if(head == null){
// 刪除的元素時鏈表中唯一的元素,需要維護下tail
tail = null;
}
size --;
return retNode.e;
}
@Override
public E getFornt() {
if(isEmpty()){
throw new IllegalArgumentException("Queue is empty.");
}
return head.e;
}
@Override
public String toString(){
StringBuilder sb = new StringBuilder();
sb.append("Queue: front");
Node cur = head;
while(cur != null){
sb.append(cur + "->");
cur = cur.next;
}
sb.append("NULL tail");
return sb.toString();
}
public static void main(String[] args) {
int opCount = 100000;
LoopQueue<Integer> loopQueue = new LoopQueue<Integer>();
double time1 = testQueue(loopQueue, opCount);
System.out.println("LoopQueue :" + time1 + " s");
ArrayQueue<Integer> arrayQueue = new ArrayQueue<Integer>();
double time2 = testQueue(arrayQueue, opCount);
System.out.println("arrayQueue :" + time2 + " s");
LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<Integer>();
double time3 = testQueue(linkedListQueue, opCount);
System.out.println("linkedListQueue :" + time3 + " s");
}
private static double testQueue(Queue<Integer> queue, int count) {
long startTime = System.nanoTime();
Random random = new Random();
for (int i = 0; i < count; i++) {
// 插入一個從0到int最大數之間的一個隨機值
queue.enqueue(random.nextInt(Integer.MAX_VALUE));
}
for (int i = 0; i < count; i++) {
queue.dequeue();
}
long endTime = System.nanoTime();
// 以秒爲單位
return (endTime - startTime) / 1000000000.0;
}
}
以上代碼中,增加了隊列三種實現方式的性能比較,執行結果如下
其中,我們發現LoopQueue和linkedListQueue執行消耗的時間基本上都差不多,但是arrayQueue消耗的時間遠遠超過了前兩者。這是因爲前兩者的時間複雜度是O(1)級別的,而arrayQueue的時間複雜度是O(n)級別的。從這個對比中,我們可以看出,不同時間複雜度的操作在性能上的差距。