数组的短板
先来回忆下数组的几个特点:
- 可以存储若干份类型一样的元素
- 每个元素占用的内存大小相同
- 所有元素都连续的存储于一段内存中
因为所有元素存储在一段连续的内存中,使得某些操作变得很费时:
- 修改数组的长度:需要先申请一段新的内存,然后进行数据拷贝。
- 删除指定位置插入元素:先删除指定位置的数据,然后移动后面的数据。
- 向指定位置插入数据:先将指定位置及其后的元素向后移动,再插入元素。考虑到长度问题,可能需要先扩容再插入。
不难发现,本来只想操作其中一个元素,但是为了保证「元素在内存中的位置连续」,不得不移动非常多的元素。
总结一下就是,数组的优点在于随机读写,缺点在于增、删、扩容的效率很低。接下来看看链表如何解决上述问题,以及链表又有哪些短板呢?
链表——碎裂的数组
链表,由若干个结点组成,每个结点包含数据域和指针域。在实现上,结点的类型一般由一个类描述,比如:
//定义一个结点模板
template<typename T>
struct Node {
T data; // 数据域
Node *next; // 指针域
Node() : next(nullptr) {}
Node(const T &d) : data(d), next(nullptr) {}
};
按上述定义,一个结点存储于一段连续的内存中。在用途上,这段内存被分为数据域和指针域:
- 数据域,顾名思义,用来存放数据的区域。
- 指针域,存储(在逻辑上相邻的)结点的内存地址。
在链表中,逻辑上相邻的两个结点,也无需保证在内存中相邻。只需保证每一个结点的指针域存储了相邻结点的地址即可。
一般来讲,链表中有一个结点的指针域为空,该结点为尾结点,其他结点的指针域都会存储一个结点的内存地址。
链表中也会有一个结点的内存地址,没有存储在其他结点的指针域中,该结点称为头结点。
如下图所示,一条以"赵二"为头结点的,长度为四的链表。为了直观,我们用箭头表示指针域中的值,表示其中存储了箭头指向节点的地址。另外约定 ‘N’ 代表空指针。
因此,只要拿到头节点的地址,就可顺着指针域依次找到所有节点了。
因为无需保证结点在内存中的位置关系,因此插入或者删除结点时无需移动其他结点。比如要在结点 p 之后,增加结点 q,整个过程总共分三步:
- 申请一段内存用以存储 q。
- 将 p 的指针域数据复制到 q 的指针域。
- 更新 p 的指针域为 q 的地址。
比如要在 “张三” 之后插入 “钱六”,过程如下:
删除结点 p 之后的结点 q 总共分两步:
- 将 q 的指针域复制到 p 的指针域。
- 释放 q 结点的内存,即将内存归还操作系统。
比如删除"赵二"之后的"张三":
而且链表根本没有长度的概念,只要内存足够就可增加新节点。
链表的短板
链表松散的存储方式,使其可以快速增删指定节点。但这使得链表无法通过下标快速访问指定节点。
回忆一下,数组中所有元素存储在一段连续内存中,且每个元素所占字节数相同。因此,数组操作指定下标元素的只需三步:
- 计算偏移量:下标 × 单个元素所占字节数
- 计算内存地址:首地址 + 偏移量
- 操作内存地址的数据
不难发现,仅需一次乘法和一次加法即可找到目标元素在内存中的位置。
但是,链表中节点的内存地址没有规律可循,无法通过算术运算获得指定下标的位置。因此,如果想操作指定次序(比如第100个)的元素,只能从头结点开始依次寻找(从头结点指针向后跳99次),非常笨重。
另外,因为多了指针域,内存的开销也比数组要多一些。比如在 64 位的系统上,存储一个 char,数组仅需一个字节,而链表需要九个字节。
孰优孰劣
说了这么多,那么链表与数组孰优孰劣呢?其实两者没有绝对的优劣,只是适应场景不同,毕竟存在即合理嘛。下面从以下几个角度分别比较下。
- 插入元素
链表优于数组。数组要移动若干个元素,给待插入元素腾出位置,而链表只需修改两个指针。 - 删除元素
链表优于数组。数组在删除元素后,需要移动若干个元素,以填补删除元素的位置,而链表只需修改一个指针。 - 修改元素
链表和数组的性能相同。 - 查找元素
数组和链表性能相当,但考虑到内存局部性原理,数组可能稍优于链表。 - 长度限制
数组存在长度限制,插入元素时可能需要重新分配内存。但链表没有这个限制,只要内存够用,可以一直插入新元素。
做题技巧
无法根据下标访问元素,是链表的劣势。然而面试的时候经常碰见诸如获取倒数第k个元素,获取中间位置的元素,判断链表是否存在环,判断环的长度等和长度与位置有关的问题。这些问题都可以通过灵活运用双指针来解决。
倒数第k个元素的问题
设有两个指针 p 和 q,初始时均指向头结点。首先,先让 p 沿着 next 移动 k 次。此时,p 指向第 k+1个结点,q 指向头节点,两个指针的距离为 k 。然后,同时移动 p 和 q,直到 p 指向空,此时 q 即指向倒数第 k 个结点。可以参考下图来理解:
class Solution {
public:
ListNode* getKthFromEnd(ListNode* head, int k) {
ListNode *p = head, *q = head; //初始化
while(k--) { //将 p指针移动 k 次
p = p->next;
}
while(p != nullptr) {//同时移动,直到 p == nullptr
p = p->next;
q = q->next;
}
return q;
}
};
获取中间元素
设有两个指针 fast 和 slow,初始时指向头节点。每次移动时,fast 向后走两次,slow 向后走一次,直到 fast 无法向后走两次。这使得在每轮移动之后。fast 和 slow 的距离就会增加一。
设链表有 n 个元素,那么最多移动
n
2
\frac{n}{2}
2n 轮。当 n 为奇数时,slow 恰好指向中间结点,当 n 为 偶数时,slow 恰好指向中间两个结点的靠前一个。
class Solution {
public:
ListNode* middleNode(ListNode* head) {
if (head == nullptr) {
return nullptr;
}
ListNode *p = head, *q = head;
while(q->next != nullptr && q->next->next != nullptr) {
p = p->next;
q = q->next->next;
}
return p;
}
};
是否存在环
将尾结点的 next 指针指向任意一个结点,链表就存在了一个环。
当一个链表有环时,快慢指针必然会进入到环中。想象一下在操场跑步的场景,只要一直跑下去,快的总会追上慢的(也就是套了一圈)。
当两个指针都进入环后,每轮移动使得慢指针到快指针的距离增加一,同时快指针到慢指针的距离也减少一,只要一直移动下去,快指针总会追上慢指针。
根据上述表述得出,如果一个链表存在环,那么快慢指针必然会相遇。实现代码如下:
class Solution {
public:
bool hasCycle(ListNode *head) {
ListNode *slow = head;
ListNode *fast = head;
while(fast != nullptr) {
fast = fast->next;
if(fast != nullptr) {
fast = fast->next;
}
if(fast == slow) {
return true;
}
slow = slow->next;
}
return nullptr;
}
};
还有一个问题:如果存在环,如何判断环的长度呢?方法是,快慢指针在第一次相遇后继续移动,直到第二次相遇。两次相遇间的移动次数即为环的长度。
仅用一个指针判环及环的长度
这里介绍一种比较 hack 的做法,仅在 Linux 下用 C++ 验证过,不确定能否在其他操作系统及编程语言下实现。
上图描述了 32/64 位系统对内存地址的划分,不难发现,用户空间地址的最高位全部为 0。我们可利用这一点表示某个节点是否被访问过:
- 节点指针域的最高位为 0,表示该节点未被访问过。
- 节点指针域的最高位为 1,表示该节点已经被访问过了。
利用上述标记方法,可以用一个指针判断是否有环。下述代码可在 64 位系统上正确运行。
class Solution {
public:
bool hasCycle(ListNode *pHead) {
const uint64_t mask = 0x8000000000000000;
while (pHead != nullptr && pHead->next != nullptr) {
uint64_t &adr = *(uint64_t*)(&(pHead->next));
if (adr & mask) {
return true;
}
pHead = pHead->next;
adr |= mask;
}
return false;
}
};
链表的主要代码
#include <bits/stdc++.h>
using namespace std;
//定义一个结点模板
template<typename T>
struct Node {
T data;
Node *next;
Node() : next(nullptr) {}
Node(const T &d) : data(d), next(nullptr) {}
};
//删除 p 结点后面的元素
template<typename T>
void Remove(Node<T> *p) {
if (p == nullptr || p->next == nullptr) {
return;
}
auto tmp = p->next->next;
delete p->next;
p->next = tmp;
}
//在 p 结点后面插入元素
template<typename T>
void Insert(Node<T> *p, const T &data) {
auto tmp = new Node<T>(data);
tmp->next = p->next;
p->next = tmp;
}
//遍历链表
template<typename T, typename V>
void Walk(Node<T> *p, const V &vistor) {
while(p != nullptr) {
vistor(p);
p = p->next;
}
}
int main() {
auto p = new Node<int>(1);
Insert(p, 2);
int sum = 0;
Walk(p, [&sum](const Node<int> *p) -> void { sum += p->data; });
cout << sum << endl;
Remove(p);
sum = 0;
Walk(p, [&sum](const Node<int> *p) -> void { sum += p->data; });
cout << sum << endl;
return 0;
}
最后
上文中的链表只有一个指针,我们称之为单链表。在此基础上,衍生出了双链表,十字链表,跳表,舞蹈链等数据结构。这些后面有机会再和大家一起探讨。
好了朋友们,链表就先讲到这里啦,希望对大家有帮助。有不足或者错误的地方,欢迎大家指出。