链表入门指北

数组的短板

先来回忆下数组的几个特点:

  • 可以存储若干份类型一样的元素
  • 每个元素占用的内存大小相同
  • 所有元素都连续的存储于一段内存中

因为所有元素存储在一段连续的内存中,使得某些操作变得很费时:

  • 修改数组的长度:需要先申请一段新的内存,然后进行数据拷贝。
    数组扩容
  • 删除指定位置插入元素:先删除指定位置的数据,然后移动后面的数据。
    数组删除元素
  • 向指定位置插入数据:先将指定位置及其后的元素向后移动,再插入元素。考虑到长度问题,可能需要先扩容再插入。

不难发现,本来只想操作其中一个元素,但是为了保证「元素在内存中的位置连续」,不得不移动非常多的元素。

总结一下就是,数组的优点在于随机读写,缺点在于增、删、扩容的效率很低。接下来看看链表如何解决上述问题,以及链表又有哪些短板呢?

链表——碎裂的数组

链表,由若干个结点组成,每个结点包含数据域和指针域。在实现上,结点的类型一般由一个类描述,比如:

//定义一个结点模板
template<typename T>
struct Node {
	T data; // 数据域
	Node *next; // 指针域
	Node() : next(nullptr) {}
	Node(const T &d) : data(d), next(nullptr) {}
};

按上述定义,一个结点存储于一段连续的内存中。在用途上,这段内存被分为数据域和指针域:

  • 数据域,顾名思义,用来存放数据的区域。
  • 指针域,存储(在逻辑上相邻的)结点的内存地址。

在链表中,逻辑上相邻的两个结点,也无需保证在内存中相邻。只需保证每一个结点的指针域存储了相邻结点的地址即可。

一般来讲,链表中有一个结点的指针域为空,该结点为尾结点,其他结点的指针域都会存储一个结点的内存地址。

链表中也会有一个结点的内存地址,没有存储在其他结点的指针域中,该结点称为头结点

如下图所示,一条以"赵二"为头结点的,长度为四的链表。为了直观,我们用箭头表示指针域中的值,表示其中存储了箭头指向节点的地址。另外约定 ‘N’ 代表空指针。

因此,只要拿到头节点的地址,就可顺着指针域依次找到所有节点了。

因为无需保证结点在内存中的位置关系,因此插入或者删除结点时无需移动其他结点。比如要在结点 p 之后,增加结点 q,整个过程总共分三步:

  1. 申请一段内存用以存储 q。
  2. 将 p 的指针域数据复制到 q 的指针域。
  3. 更新 p 的指针域为 q 的地址。

比如要在 “张三” 之后插入 “钱六”,过程如下:

删除结点 p 之后的结点 q 总共分两步:

  1. 将 q 的指针域复制到 p 的指针域。
  2. 释放 q 结点的内存,即将内存归还操作系统。

比如删除"赵二"之后的"张三":

而且链表根本没有长度的概念,只要内存足够就可增加新节点。

链表的短板

链表松散的存储方式,使其可以快速增删指定节点。但这使得链表无法通过下标快速访问指定节点。

回忆一下,数组中所有元素存储在一段连续内存中,且每个元素所占字节数相同。因此,数组操作指定下标元素的只需三步:

  • 计算偏移量:下标 × 单个元素所占字节数
  • 计算内存地址:首地址 + 偏移量
  • 操作内存地址的数据

不难发现,仅需一次乘法和一次加法即可找到目标元素在内存中的位置。

但是,链表中节点的内存地址没有规律可循,无法通过算术运算获得指定下标的位置。因此,如果想操作指定次序(比如第100个)的元素,只能从头结点开始依次寻找(从头结点指针向后跳99次),非常笨重。

另外,因为多了指针域,内存的开销也比数组要多一些。比如在 64 位的系统上,存储一个 char,数组仅需一个字节,而链表需要九个字节。

孰优孰劣

说了这么多,那么链表与数组孰优孰劣呢?其实两者没有绝对的优劣,只是适应场景不同,毕竟存在即合理嘛。下面从以下几个角度分别比较下。

  • 插入元素
    链表优于数组。数组要移动若干个元素,给待插入元素腾出位置,而链表只需修改两个指针。
  • 删除元素
    链表优于数组。数组在删除元素后,需要移动若干个元素,以填补删除元素的位置,而链表只需修改一个指针。
  • 修改元素
    链表和数组的性能相同。
  • 查找元素
    数组和链表性能相当,但考虑到内存局部性原理,数组可能稍优于链表。
  • 长度限制
    数组存在长度限制,插入元素时可能需要重新分配内存。但链表没有这个限制,只要内存够用,可以一直插入新元素。

做题技巧

无法根据下标访问元素,是链表的劣势。然而面试的时候经常碰见诸如获取倒数第k个元素获取中间位置的元素判断链表是否存在环判断环的长度等和长度与位置有关的问题。这些问题都可以通过灵活运用双指针来解决。

倒数第k个元素的问题

设有两个指针 p 和 q,初始时均指向头结点。首先,先让 p 沿着 next 移动 k 次。此时,p 指向第 k+1个结点,q 指向头节点,两个指针的距离为 k 。然后,同时移动 p 和 q,直到 p 指向空,此时 q 即指向倒数第 k 个结点。可以参考下图来理解:
移动过程中保持距离为 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;
}

最后

上文中的链表只有一个指针,我们称之为单链表。在此基础上,衍生出了双链表,十字链表,跳表,舞蹈链等数据结构。这些后面有机会再和大家一起探讨。

好了朋友们,链表就先讲到这里啦,希望对大家有帮助。有不足或者错误的地方,欢迎大家指出。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值