为了表示每个数据元素ai与其直接后继数据元素ai+1之间的逻辑关系,对数据元素ai来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称做指针或链。这两部分信息组成数据元素ai的存储映像,称为结点(Node)。
n个结点(ai的存储映像)链结成一个链表,即为线性表(a1,a2,...,an)的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。
头指针与头结点
头指针
链表中第一个结点的存储位置叫做头指针,如果有头结点则指向头结点。最后一个节点的指针指向空。
头结点
在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息。也可以存储如线性表的长度等附加信息。
异同
头指针:
- 头指针具有标识作用,所以常用头指针冠以链表的名字。
头结点:
- 头结点不是必须的。
- 有了头节点,第一个节点的插入、删除等操作和其他节点一样。
空表
线性表为空,若有头结点,则头结点指向第一个节点的指针为空。
若没有头结点,则头指针为空。
节点
节点里面存储了数据域和指向下一个节点的指针。用一个类来表示。
/**
* 节点
*/
private static class Node<T> {
private T data; // 节点的数据
private Node<T> next; // 指向下一个节点的指针
public Node() {}
public Node(T data, Node<T> next) {
this.data = data;
this.next = next;
}
}
复制代码
单链表的初始化
单链表需要一个头指针和尾指针,尾指针一定是 null,可以不声明。可以选择有无头结点。还需要一个计数器来计数。
初始化时,有头结点的话需要把头指针指向头结点,头节点的指针指向 null。没有头结点的话头指针指向 null。
private Node<T> head; // 头指针
private Node<T> tail; // 尾指针
private int size; // 节点的个数
private boolean hasHeadNode = false; // 是否有头节点
/**
* 初始化一个空表没有头结点
*/
public MyLinkedList() {
this.size = 0;
head = null; // 头指针指向空
tail = null; // 尾指针指向空
hasHeadNode = false;
}
/**
* 初始化一个带头结点的空表
*
* @param data 头结点的数据域,可以为 null
*/
public MyLinkedList(@Nullable T data) {
Node<T> headNode = new Node<>(); // 头节点
headNode.data = data;
headNode.next = null;
// 头指针指向头结点
head = headNode;
tail = null; // 尾指针指向空
this.size = 0;
hasHeadNode = true;
}
复制代码
查找
在顺序表查找是很容易的。单链表中没有计数,只存储了下一个节点的位置,所以我们每次都要从头遍历查找并计数。 因为单链表没有定义表厂,所以不可以用 for 循环来查找,核心思想是 工作指针后移,这也是很多算法的常用技术。 最坏的时间复杂度是O(n)。
查找第 i 个节点的步骤:
- 声明一个指针 p 指向链表第一个结点,初始化 j 从 1 开始;
- 当 j < i 时,就遍历链表,让 p 的指针向后移动,不断指向下一结点,j 累加 1;
- 若到链表末尾 p 为空,则说明第i个结点不存在;
- 否则查找成功,返回结点 p 的数据。
/**
* 查找某个节点的指针
*
* @param index 节点索引
* @return 节点指针
*/
private Node<T> getNode(int index) throws Exception {
if (size == 0) {
throw new Exception("表为空");
}
// 检查 index 是否合法
checkInvalid(index);
Node<T> p = head; // 初始化指针 p 指向第一个节点,即将头指针赋值给 p。
if (hasHeadNode) { // 有头结点
for (int i = 0; i <= index; i++) {
p = p.next;
}
} else { // 没有头结点, 相比有头结点就要少循环一次
for (int i = 0; i < index; i++) {
p = p.next;
}
}
return p;
}
复制代码
添加
要把 s 插入到 p 的后面,只需要把 p 中的指针指向 s,将 s 的指针指向 p.next。
在表头和表尾插入:
在第 i 个数据后面插入节点的步骤:
- 声明一个指针 P 指向链表的头结点,初始化 j = 1。
- 当 j < i 时,就遍历链表,让 p 的指针向后移动,不断指向下一个节点,j 累加 1。
- 若到链表末尾 p 为空,则说明第 i 个结点不存在;
- 否则查找成功,在系统中生成一个空结点 s;
- 将数据元素 e 赋值给 s->data;
- 单链表的插入标准语句 s->next=p->next; p->next=s;
- 返回成功。
头插法和尾插法
不需要再特定位置插入的话,有头插法和尾插法 2 种方式,即在头部插入和在尾部插入。插入之前都需要先查找。
/**
* 默认是尾插法
*
* @param t 元素
* @throws Exception
*/
@SuppressWarnings("unchecked") @Override public void add(T t) throws Exception {
Node<T> node = new Node();
node.data = t;
node.next = tail;
if (size == 0) { // 空表时从第一个插入
if (hasHeadNode) {
head.next = node;
} else {
head = node;
}
} else {
// 得到最后一个节点
Node<T> lastNode = getNode(size - 1);
// 指向新插入的节点
lastNode.next = node;
}
size++;
}
/**
* 头插法
*
* @param t 元素
*/
@SuppressWarnings("unchecked") public void addAfterHead(T t) throws Exception {
Node<T> node = new Node();
node.data = t;
node.next = tail;
Node<T> p = head; // 将头指针赋值给临时指针 p
if (size == 0) { // 空表时从第一个插入
if (hasHeadNode) {
head.next = node;
} else {
head = node;
}
} else {
// 有头结点时,将头结点指针指向新插入的节点,将新插入节点的指针指向原头节点指针指向的节点
if (hasHeadNode) {
head.next = node;
node.next = p.next;
} else { // 没有头结点时,头指针指向新插入的节点,新插入的节点指向第一个节点
head = node;
node.next = p;
}
}
size++;
}
复制代码
在指定位置插入
想要在第 i 个位置插进去,就需要改变第 i-1 个位置节点的指针,新插进的节点的指针指向原来第 i 个位置的节点。
/**
* 指定插在某个位置
*
* @param index 元素索引
* @param obj 元素实例
* @throws Exception
*/
@Override public void insert(int index, T obj) throws Exception {
//检查 index 合法
checkInvalid(index);
Node<T> newNode = new Node<>();
newNode.data = obj;
if (index == 0) { // 如果要插入到第一个位置
if (hasHeadNode) {
newNode.next = head.next;
head.next = newNode;
} else {
newNode.next = head;
head = newNode;
}
}else {
Node<T> node = getNode(index - 1);// 得到前一个节点
newNode.next = node.next;
node.next = newNode;
}
size++;
}
复制代码
批量添加
上述每次添加一个都需要重新查找一遍节点,这样时间复杂度就是 O(n的平方),很费时。如果批量插入的话,只需要在插入第一个数据的时候查找一次,后续的节点直接接着插在末尾,时间复杂度只有0(n)。
这里我只实现了尾插法的批量添加:
/**
* 批量从尾部加入数据
*/
@SuppressWarnings("unchecked") public void add(T... data) throws Exception {
Node<T> p = null; // 临时指针 p 指向最后一个节点
for (int i = 0; i < data.length; i++) {
Node<T> node = new Node();
node.data = data[i];
node.next = tail;
if (i == 0) {
Log.d(TAG, "批量插入- 第一条开始");
// 第一个先查找再插入,后续直接一个一个插到后面。
if (size == 0) { // 空表时从第一个插入
if (hasHeadNode) {
head.next = node;
} else {
head = node;
}
} else {
// 得到最后一个节点
Node<T> lastNode = getNode(size - 1);
// 指向新插入的节点
lastNode.next = node;
}
size++;
p = node;
Log.d(TAG, "批量插入- 第一条结束");
} else {
Log.d(TAG, "批量插入- 第" + (i + 1) + "条开始");
p.next = node; // 最后一个节点指针指向新插入的节点
size++;
p = node;
Log.d(TAG, "批量插入- 第" + (i + 1) + "条结束");
}
}
}
复制代码
删除
要把 p 节点后面的节点删除,只需要将 p 节点的指针指向 p->next->next,即 p 的后面第二个节点。再将 p-> next 指向 null,删除 p->next 节点。
删除第 i 个节点的步骤:
- 声明一个指针 P 指向链表的头结点,初始化 j = 1。
- 当 j < i 时,就遍历链表,让 p 的指针向后移动,不断指向下一个节点,j 累加 1。
- 若到链表末尾 p 为空,则说明第 i 个结点不存在;
- 否则查找成功,将欲删除的结点 p->next 赋值给 p 的前一个节点 q->next;
- 删除第 i 个节点。
/**
* 删除某个位置的节点
*
* @param index 元素的索引
* @throws Exception
*/
@Override public void delete(int index) throws Exception {
if (size == 0) {
throw new Exception("表为空");
}
checkInvalid(index);
if (index == 0) { // 如果是删除第一个位置
if (hasHeadNode) {
if (head.next.next == null) { // 如果索引为 1 的位置的节点为空,代表只有一个节点
head.next = null; // 直接将头节点的指针指向空
} else {
head.next = head.next.next; // 直接头结点的指针指向索引为 1 的位置的节点
}
} else {
if (head.next == null) { // 如果索引为 1 的位置的节点为空,代表只有一个节点
head = null; // 直接将头指针指向空
} else {
head = head.next; // 直接头指针指向索引为 1 的位置的节点
}
}
} else {
Node<T> preNode = getNode(index - 1);// 得到前一个节点的指针
preNode.next = preNode.next.next; // 将前一个节点的指针指向后一个
}
size--;
}
复制代码
清空整张表
清空的只要把头结点指向空就行。
/**
* 清空整张表
*/
public void clear() {
this.size = 0;
if (hasHeadNode) {
head.next = null; // 头结点的指针指向空
} else {
head = null; // 头指针指向空
}
}
复制代码
和顺序表的比较
总结
从整个算法来说,我们很容易推导出:单链表的插入和删除的时间复杂度都是 O(n)。如果在我们不知道第i个结点的指针位置,单链表数据结构在插入和删除操作上,与线性表的顺序存储结构是没有太大优势的。但如果,我们希望从第i个位置,插入 10 个结点,对于顺序存储结构意味着,每一次插入都需要移动 n-i 个结点,每次都是 O(n)。而单链表,我们只需要在第一次时,找到第i个位置的指针,此时为 O(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是O(1)。
显然,对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显。