1. 簡介
鏈表也是最基礎的數據結構,屬於線性表。鏈表就像火車一樣,每一個車廂互相連接,這些車廂就是一個個結點(Node)。鏈表就是通過這些結點的連接形成的。
對比於數組,鏈表不支持隨機訪問,所以數組的訪問速度非常快,而鏈表就慢了。但是鏈表的長度是動態的,這一點比數組好,不會浪費空間。
2. 創建鏈表
把結點Node封裝在類中,因爲用戶是不需要知道有Node結點這些概念。
public class LinkedList<E> {
// 結點
private class Node{
public E data;
public Node next;
public Node(E data, Node next) {
this.data = data;
this.next = next;
}
public Node(E data) {
this(data, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return data.toString();
}
}
}
3. 鏈表的添加
在添加之前,我們需要去訪問,因爲鏈表中沒有索引這種概念,那訪問就需要一個頭結點head,從頭結點開始訪問。
public class LinkedList<E> {
// 結點
private class Node{
...
}
// 新增
private Node head;
// 鏈表長度
private int size;
public LinkedList() {
head = null;
size = 0;
}
/**
* 獲取鏈表中的元素個數
* @return
*/
public int getSize() {
return size;
}
/**
* 判斷鏈表是否爲空
* @return
*/
public boolean isEmpty() {
return size == 0;
}
}
如圖:主要有三步:
- 第一步先創建一個node結點並把值傳進去;
- 第二步把新創建的結點的next指針指向head;
- 第三步把head指向node結點。
/**
* 添加操作
* @param data
*/
public void addFirst(E data){
// Node node = new Node(data);
// node.next = head;
// head = node;
// 前面三條語句可以結合成一條
head = new Node(data, head);
size++;
}
有些書上的頭結點不存儲值。其實頭結點可以存儲值也可不存儲,無論如何就是一個標記,根據該標記方便我們可以操作鏈表。當然頭結點不存值的情況代碼需要修改,下面會說。
4. 鏈表的插入操作
現在假設鏈表從頭結點到尾,可用索引0,1,2…表示,那麼假如要把一個結點node插入到索引2,則需要怎麼操作。
注意:鏈表中沒有索引的,這裏只是爲了演示插入操作,因爲該操作是一個非常重要的思維。
首先能想到的是,先去查詢該位置,查詢是利用頭結點head,但必須創建一個頭結點head的副本來查詢,因爲頭結點head只能一直標記頭結點。我們需要查詢出該索引的前一個位置的結點,記爲prev。
然後將node的next指向prev:
最後將prev的next指向node,就成功插入了:
這兩條順序的順序不可換,可以試試換了兩條語句順序後的結果,就是錯誤的:
需要注意,當如果要從索引0插入時,要怎麼辦,頭結點可沒有前一個結點。這可以調用addFirst方法。
/**
* 插入操作
* index 範圍爲0到size
* 鏈表中是沒有索引的概念,該操作只能理解思維
* @param index
* @param data
*/
public void insert(int index, E data){
if(index < 0 || index > size){
throw new IllegalArgumentException("Insert failed. Illegal index.");
}
if(index == 0){
addFirst(data);
} else {
Node prev = head;
for(int i = 0; i < index - 1; i++){
prev = prev.next;
}
// Node node = new Node(data);
// node.next = prev.next;
// prev.next = node;
// 另一種寫法,就是上面三句的結合
prev.next = new Node(data, prev.next);
size++;
}
}
上面的代碼還可以修改,比如如果超出size,那麼可以把插入的結點添加到鏈表尾。
現在寫個末尾添加結點的方法:
/**
* 添加到尾部
* @param data
*/
public void addLast(E data){
insert(size, data);
}
這些東西都是涉及到引用的知識,比如查詢:
// 創建一個head副本
Node prev = head;
for(int i = 0; i < index - 1; i++){
// 此時改變引用指向,並不會影響到head
prev = prev.next;
}
如果這樣寫:
// 不創建head副本
for(int i = 0; i < index - 1; i++){
// 此時改變引用指向,那就影響到了head的指向,即前面的結點會丟失,永遠找不回來。
head = head.next;
}
5. 鏈表改爲使用虛擬頭結點
有些書用的頭結點不放任何東西時,每次添加結點是添加到頭節點後面的,該頭結點稱爲虛擬頭結點,記爲dummyHead,比如:
思路都是一樣的,其實這是一個插入操作。因爲每次都知道要插入的位置的前一個結點,所以完全不需要索引的概念,現在可以來寫下,另一種添加操作:(把剛剛的head改成dummyHead)
// 把 head名稱改爲 dummyHead
private Node dummyHead;
// 修改構造函數
public LinkedList() {
// 創建虛擬頭結點
dummyHead = new Node(null);
size = 0;
}
/**
* 插入操作
* index 範圍爲0到size
* 鏈表中是沒有索引的概念,該操作只能理解思維
* 插入操作的關鍵點在於:找到目標位置的前一個位置
* @param index
* @param data
*/
public void insert(int index, E data){
if(index < 0 || index > size){
throw new IllegalArgumentException("Insert failed. Illegal index.");
}
// 此時head是虛擬頭結點
Node prev = dummyHead;
// 注意邊界
for(int i = 0; i < index; i++){
prev = prev.next;
}
// Node node = new Node(data);
// node.next = prev.next;
// prev.next = node;
// 另一種寫法
prev.next = new Node(data, prev.next);
size++;
}
/**
* 添加頭結點操作
* @param data
*/
public void addFirst(E data){
insert(0, data);
}
跟前面的添加操作對比看看:
- 前面的添加操作,每次添加的結點都當成頭結點head。
- 這裏的添加操作,每次添加的結點都放在頭結點head後面。
兩種方法都可以使用任意一種。現在我們把前面的代碼換成使用虛擬頭結點來做。
5. 鏈表的查詢
爲了練習還是引入索引。
注意查詢是要查詢哪個結點,像前面的插入操作是爲了查詢某位置的前一個結點。下面的查詢是爲了查詢某位置的結點。
查詢有兩種方式,一種使用while,一種使用for。
/**
* 獲取鏈表第index個元素(0~size)
* @param index
* @return
*/
public E get(int index) {
if(index < 0 || index >= size) {
throw new IllegalArgumentException("Get failed. Illegal index.");
}
// 查找第index位置的結點
Node cur = dummyHead.next;
for(int i = 0; i < index; i++) {
cur = cur.next;
}
return cur.data;
}
/**
* 獲取首結點的值
* @return
*/
public E getFirst() {
return get(0);
}
/**
* 獲取尾結點的值
* @return
*/
public E getLast() {
return get(size - 1);
}
/**
* 查詢鏈表中是否包含元素data
* @param data
* @return
*/
public boolean contains(E data) {
Node cur = dummyHead.next;
while(cur != null) {
if(cur.data.equals(data)) {
return true;
}
cur = cur.next;
}
return false;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
// 第一種,使用while
// Node cur = dummyHead.next;
// while(cur != null) {
// sb.append(cur + "->");
// cur = cur.next;
// }
// 第二種,使用for
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> linkedList = new LinkedList<>();
for(int i = 0; i < 5; i++) {
linkedList.addFirst(i+1);
System.out.println(linkedList);
}
System.out.println("新添加:");
linkedList.insert(2, 5);
linkedList.addLast(6);
System.out.println(linkedList);
System.out.println("首結點元素:" + linkedList.getFirst());
System.out.println("尾結點元素:" + linkedList.getLast());
System.out.println("是否包含元素1:" + linkedList.contains(1));
}
6. 鏈表的修改
無非還是先查詢,在修改。
/**
* 爲了練習還是引入索引。
* 表示修改index位置的結點的元素
* @param index 索引
* @param data 要修改的值
*/
public void set(int index, E data) {
if(index < 0 || index >= size) {
throw new IllegalArgumentException("Set failed. Index is illegal");
}
// 查詢
Node cur = dummyHead.next;
for(int i = 0; i < index; i++) {
cur = cur.next;
}
cur.data = data;
}
測試:
public static void main(String[] args) {
LinkedList<Integer> linkedList = new LinkedList<>();
for(int i = 0; i < 5; i++) {
linkedList.addFirst(i+1);
System.out.println(linkedList);
}
System.out.print("修改索引爲2的元素,改爲10:");
linkedList.set(2, 10);
System.out.println(linkedList);
}
8. 鏈表的刪除
還是引入索引方便演示,假設要刪除索引爲2的結點。
每個結點都有一個next,在查詢時是根據這個next來找到下一個結點。所以通過索引1結點的next就可以找到索引2的結點,以此爲前提,那麼只要讓索引1結點的next不指向索引2的結點,不就找不到索引2的結點了嗎,這不就是刪除了嗎。所以我們得先找到要刪除的結點的前一個結點,記爲prev。
但是我們還得保證索引2的結點後面的結點不丟失,所以可以把索引1結點的next指向索引2結點的next。這就刪除了。
看看圖:
第一步:初始狀態
第二步:查詢,找到delNode的前一個結點
第三步:刪除
小優化:可以看到delNode雖然是刪了,但是還沒有被垃圾揮手器回收,因爲delNode還是有引用。所以我們主動把delNode指向null。
代碼:
/**
* 刪除
* @param index
* @return 返回刪除元素
*/
public E remove(int index) {
if(index < 0 || index >= size) {
throw new IllegalArgumentException("delete failed. Index is illegal");
}
// 待刪除的結點之前的結點
Node prev = dummyHead;
for(int i = 0; i < index; i++) {
prev = prev.next;
}
// 要刪除的結點
Node delNode = prev.next;
// 刪除
prev.next = delNode.next;
delNode.next = null;
size--;
return delNode.data;
}
/**
* 刪除首結點
* @return 返回刪除元素
*/
public E removeFirst() {
return remove(0);
}
/**
* 刪除尾結點
* @return 返回刪除元素
*/
public E removeLast() {
return remove(size - 1);
}
/**
* 從鏈表中刪除元素e
* @param data
*/
public void removeElement(E data){
Node prev = dummyHead;
while(prev.next != null){
if(prev.next.data.equals(data))
break;
prev = prev.next;
}
if(prev.next != null){
Node delNode = prev.next;
prev.next = delNode.next;
delNode.next = null;
}
}
測試:
public static void main(String[] args) {
LinkedList<Integer> linkedList = new LinkedList<>();
for(int i = 0; i < 5; i++) {
linkedList.addFirst(i+1);
System.out.println(linkedList);
}
System.out.println("新添加:");
linkedList.insert(2, 5);
linkedList.addLast(6);
System.out.println(linkedList);
System.out.println("首結點元素:" + linkedList.getFirst());
System.out.println("尾結點元素:" + linkedList.getLast());
System.out.println("是否包含元素1:" + linkedList.contains(1));
System.out.print("修改索引爲2的元素,改爲10:");
linkedList.set(2, 10);
System.out.println(linkedList);
// 刪除
System.out.println("刪除索引爲2的元素" + linkedList.remove(2));
System.out.println("刪除首結點" + linkedList.removeFirst());
System.out.println("刪除尾結點" + linkedList.removeLast());
System.out.println(linkedList);
}
9. 複雜度分析
-
添加操作:總體爲O(n)
- addFirst():O(1)
- addLast():O(n)
- insert(index):平均情況下index可能在前半部分也可能在後半部分,所以均攤起來爲:O(n/2)=O(n)
-
刪除操作:總體爲O(n)
- removeFirst():O(1)
- removeLast():O(n)
- remove(index):平均情況下index可能在前半部分也可能在後半部分,所以均攤起來爲:O(n/2)=O(n)
-
修改操作:總體爲O(n)
- set(idnex, data):O(n)
-
查找操作:總體爲O(n)
- getFirst(index):O(1)
- getLast():O(n)
- get(index):O(n)
- contains():O(n)
相對於數組來說,鏈表的總體時間複雜度確實是比數組差,因爲在知道索引的情況下,數組支持隨機訪問。
但是,我們可以發現,如果鏈表只在頭結點操作,那麼對於增,刪,查的操作都是O(1),而且可以知道鏈表其實不去修改好。
所以現在如果只對頭結點操作,那麼鏈表的總體複雜度跟數組差不多,而且比數組好的就是,鏈表是動態的,不會浪費空間。
10. 鏈表的全部代碼
public class LinkedList<E> {
// 結點
private class Node{
public E data;
public Node next;
public Node(E data, Node next) {
this.data = data;
this.next = next;
}
public Node(E data) {
this(data, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return data.toString();
}
}
private Node dummyHead;
private int size;
public LinkedList() {
// 創建虛擬頭結點
dummyHead = new Node(null);
size = 0;
}
/**
* 獲取鏈表中的元素個數
* @return
*/
public int getSize() {
return size;
}
/**
* 判斷鏈表是否爲空
* @return
*/
public boolean isEmpty() {
return size == 0;
}
/**
* 插入操作
* index 範圍爲0到size
* 鏈表中是沒有索引的概念,該操作只能理解思維
* 插入操作的關鍵點在於:找到目標位置的前一個位置
* @param index
* @param data
*/
public void insert(int index, E data) {
if(index < 0 || index > size){
throw new IllegalArgumentException("Insert failed. Illegal index.");
}
// 此時head是虛擬頭結點,查找index位置的前一個結點
Node prev = dummyHead;
// 注意邊界
for(int i = 0; i < index; i++){
prev = prev.next;
}
// Node node = new Node(data);
// node.next = prev.next;
// prev.next = node;
// 另一種寫法
prev.next = new Node(data, prev.next);
size++;
}
/**
* 添加到虛擬頭結點的下一個位置
* @param data
*/
public void addFirst(E data) {
// Node node = new Node(data);
// node.next = head;
// head = node;
// 前面三條語句可以結合成一條
// head = new Node(data, head);
//
// size++;
insert(0, data);
}
/**
* 添加到尾部
* @param data
*/
public void addLast(E data){
insert(size, data);
}
/**
*
* @param index 索引
* @return 獲取鏈表第index個元素(0~size)
*/
public E get(int index) {
if(index < 0 || index >= size) {
throw new IllegalArgumentException("Get failed. Illegal index.");
}
// 查找第index位置的結點
Node cur = dummyHead.next;
for(int i = 0; i < index; i++) {
cur = cur.next;
}
return cur.data;
}
/**
* @return 獲取首結點的值
*/
public E getFirst() {
return get(0);
}
/**
* @return 獲取尾結點的值
*/
public E getLast() {
return get(size - 1);
}
/**
* 查詢鏈表中是否包含元素data
* @param data
* @return
*/
public boolean contains(E data) {
Node cur = dummyHead.next;
while(cur != null) {
if(cur.data.equals(data)) {
return true;
}
cur = cur.next;
}
return false;
}
/**
* 爲了練習還是引入索引。
* 表示修改index位置的結點的元素
* @param index 索引
* @param data 要修改的值
*/
public void set(int index, E data) {
if(index < 0 || index >= size) {
throw new IllegalArgumentException("Set failed. Index is illegal");
}
// 查詢
Node cur = dummyHead.next;
for(int i = 0; i < index; i++) {
cur = cur.next;
}
cur.data = data;
}
/**
* 刪除
* @param index
* @return 返回刪除元素
*/
public E remove(int index) {
if(index < 0 || index >= size) {
throw new IllegalArgumentException("delete failed. Index is illegal");
}
// 待刪除的結點之前的結點
Node prev = dummyHead;
for(int i = 0; i < index; i++) {
prev = prev.next;
}
// 要刪除的結點
Node delNode = prev.next;
// 刪除
prev.next = delNode.next;
delNode.next = null;
size--;
return delNode.data;
}
/**
* 刪除首結點
* @return 返回刪除元素
*/
public E removeFirst() {
return remove(0);
}
/**
* 刪除尾結點
* @return 返回刪除元素
*/
public E removeLast() {
return remove(size - 1);
}
/**
* 從鏈表中刪除元素e
* @param data
*/
public void removeElement(E data){
Node prev = dummyHead;
while(prev.next != null){
if(prev.next.data.equals(data))
break;
prev = prev.next;
}
if(prev.next != null){
Node delNode = prev.next;
prev.next = delNode.next;
delNode.next = null;
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
// 第一種,使用while
// Node cur = dummyHead.next;
// while(cur != null) {
// sb.append(cur + "->");
// cur = cur.next;
// }
// 第二種,使用for
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> linkedList = new LinkedList<>();
for(int i = 0; i < 5; i++) {
linkedList.addFirst(i+1);
System.out.println(linkedList);
}
System.out.println("新添加:");
linkedList.insert(2, 5);
linkedList.addLast(6);
System.out.println(linkedList);
System.out.println("首結點元素:" + linkedList.getFirst());
System.out.println("尾結點元素:" + linkedList.getLast());
System.out.println("是否包含元素1:" + linkedList.contains(1));
System.out.print("修改索引爲2的元素,改爲10:");
linkedList.set(2, 10);
System.out.println(linkedList);
// 刪除
System.out.println("刪除索引爲2的元素" + linkedList.remove(2));
System.out.println("刪除首結點" + linkedList.removeFirst());
System.out.println("刪除尾結點" + linkedList.removeLast());
System.out.println(linkedList);
}
}
11. 使用鏈表來實現棧
Stack接口:
public interface Stack<E> {
int getSize();
boolean isEmpty();
void push(E e);
E pop();
E peek();
}
使用鏈表實現:
public class LinkedListStack<E> implements Stack<E> {
private LinkedList<E> linkList;
public LinkedListStack() {
linkList = new LinkedList<>();
}
@Override
public int getSize() {
return linkList.getSize();
}
@Override
public boolean isEmpty() {
return linkList.isEmpty();
}
@Override
public void push(E e) {
// 從鏈表頭添加
linkList.addFirst(e);
}
@Override
public E pop() {
// 從鏈表頭刪除
return linkList.removeFirst();
}
@Override
public E peek() {
// 從鏈表頭查看
return linkList.getFirst();
}
@Override
public String toString() {
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("Stack top :").append(linkList.toString());
return stringBuilder.toString();
}
public static void main(String[] args) {
LinkedListStack<Integer> stack = new LinkedListStack<>();
for (int i = 0; i < 5; i++) {
stack.push(i);
System.out.println(stack);
}
stack.pop();
System.out.println(stack);
}
}
雖然基於鏈表實現的棧在頭結點入棧和出棧的時間複雜度都是O(1)。但是鏈表是需要創建節點的,如果這兩個操作的次數很大很大,比如入棧和出棧各1000000次,那麼是需要很久的。
12. 使用鏈表實現隊列
我們知道鏈表如果操作尾結點,那麼時間複雜度爲O(n)。如果在尾結點加個標記,那麼每次操作就不用去找尾節點。該標記跟head一樣,我們記爲tail。
但是如果要刪除尾結點,必須遍歷一次鏈表,因爲要找到刪除尾結點的前一個結點,即使有tail也無法改變。
所以我們可以在鏈表首做隊列頭,而在鏈表尾做隊列尾。這樣,我們在入隊和出隊的兩個操作的時間複雜度都是O(1)。
比如添加操作:
- 先讓head和tail初始化爲null。
- 只有一個結點是特殊情況:此時tail和head共同指向同一個結點。
- 當添加第二個結點時,規定是在tail後面添加的,並且此時就要改變tail的指向。
代碼:
public interface Queue<E> {
int getSize();
boolean isEmpty();
void enqueue(E data);
E dequeue();
E getFront();
}
public class LinkedListQueue<E> implements Queue<E> {
// 結點
private class Node{
public E data;
public Node next;
public Node(E data, Node next) {
this.data = data;
this.next = next;
}
public Node(E data) {
this(data, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return data.toString();
}
}
private Node head, tail;
private int size;
public LinkedListQueue() {
this.head = null;
this.tail = null;
this.size = 0;
}
@Override
public int getSize() {
return size;
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public void enqueue(E data) {
if(tail == null) {
tail = new Node(data);
head = tail;
} else {
tail.next = new Node(data);
tail = tail.next;
}
size++;
}
@Override
public E dequeue() {
if(isEmpty()) {
throw new IllegalArgumentException("Cannot dequeue from an empty queue.");
}
Node ret = head;
head = head.next;
// 如果隊列只有一個元素,刪除後就沒了,要修改tail
if(head == null) {
tail = null;
}
size--;
return ret.data;
}
@Override
public E getFront() {
if(isEmpty()) {
throw new IllegalArgumentException("Queue is Empty");
}
return head.data;
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
Node cur = head;
sb.append("Queue top: ");
while(cur != null) {
sb.append(cur + "->");
cur = cur.next;
}
sb.append("NULL tail");
return sb.toString();
}
public static void main(String[] args) {
LinkedListQueue<Integer> queue = new LinkedListQueue<>();
for(int i = 0; i < 5; i++){
queue.enqueue(i);
System.out.println(queue);
if(i % 3 == 2){
queue.dequeue();
System.out.println(queue);
}
}
}
}
使用鏈表實現的隊列比使用數組實現的隊列性能更好。因爲數組一定有一頭要O(n)。當然數組可以修改成循環數組來解決此問題。