数据结构与算法学习笔记——3.链表

该文章为极客时间小争哥的数据结构与算法之美的学习笔记整理

学习课程链接: https://time.geekbang.org/column/intro/100017301

  • 数组和链表的内存分布

数组需要一块连续的内存空间来存储,对内存的要求比较高。如果我们申请一个 100MB 大小的数组,当内存中没有连续的、足够大的存储空间时,即便内存的剩余总可用空间大于 100MB,仍然会申请失败。

而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用,所以如果我们申请的是 100MB 大小的链表,根本不会有问题。

 

链表结构五花八门,但是有三种最常见的链表结构,它们分别是:单链表、双向链表和循环链表

  • 单链表

从单链表图中,你应该可以发现,其中有两个结点是比较特殊的,它们分别是第一个结点最后一个结点。我们习惯性地把第一个结点叫作头结点,把最后一个结点叫作尾结点

头节点: 头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。

尾结点: 指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点

单链表的插入和删除

链表要想随机访问第 k 个元素,就没有数组那么高效了。因为链表中的数据并非连续存储的,所以无法像数组那样,根据首地址和下标,通过寻址公式就能直接计算出对应的内存地址,而是需要根据指针一个结点一个结点地依次遍历,直到找到相应的结点。

  • 循环链表

循环链表是一种特殊的单链表。

实际上,循环链表也很简单。它跟单链表唯一的区别就在尾结点。我们知道,单链表的尾结点指针指向空地址,表示这就是最后的结点了。而循环链表的尾结点指针是指向链表的头结点。从我画的循环链表图中,你应该可以看出来,它像一个环一样首尾相连,所以叫作“循环”链表。

  • 双向链表

单向链表只有一个方向,结点只有一个后继指针 next 指向后面的结点。而双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点

对于执行较慢的程序,可以通过消耗更多的内存(空间换时间)来进行优化;而消耗过多内存的程序,可以通过消耗更多的时间(时间换空间)来降低内存的消耗。

  • 双向循环链表

  • 链表 VS 数组性能

数组和链表的对比,并不能局限于时间复杂度。而且,在实际的软件开发中,不能仅仅利用复杂度分析就决定使用哪个数据结构来存储数据。

数组简单易用,在实现上使用的是连续的内存空间,可以借助 CPU 的缓存机制,预读数组中的数据,所以访问效率更高。而链表在内存中并不是连续存储,所以对 CPU 缓存不友好,没办法有效预读。

数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。

你可能会说,我们 Java 中的 ArrayList 容器,也可以支持动态扩容啊?我们上一节课讲过,当我们往支持动态扩容的数组中插入一个数据时,如果数组中没有空闲空间了,就会申请一个更大的空间,将数据拷贝过去,而数据拷贝的操作是非常耗时的。

我举一个稍微极端的例子。如果我们用 ArrayList 存储了了 1GB 大小的数据,这个时候已经没有空闲空间了,当我们再插入数据的时候,ArrayList 会申请一个 1.5GB 大小的存储空间,并且把原来那 1GB 的数据拷贝到新申请的空间上。听起来是不是就很耗时

除此之外,如果你的代码对内存的使用非常苛刻,那数组就更适合你。因为链表中的每个结点都需要消耗额外的存储空间去存储一份指向下一个结点的指针,所以内存消耗会翻倍。而且,对链表进行频繁的插入、删除操作,还会导致频繁的内存申请和释放,容易造成内存碎片,如果是 Java 语言,就有可能会导致频繁的 GC(Garbage Collection,垃圾回收)。

所以,在我们实际的开发中,针对不同类型的项目,要根据具体情况,权衡究竟是选择数组还是链表。

写链表的技巧

  • 技巧一:理解指针或引用的含义

  • 技巧二:警惕指针丢失和内存泄漏

  • 技巧三:利用哨兵简化实现难度

  • 技巧四:重点留意边界条件处理

经常用来检查链表代码是否正确的边界条件有这样几个:

1.如果链表为空时,代码是否能正常工作?

2.如果链表只包含一个结点时,代码是否能正常工作?

3.链表只包含两个结点时,代码是否能正常工作?

4.代码逻辑在处理头结点和尾结点的时候,是否能正常工作?

  • 技巧五:举例画图,辅助思考

举例法和画图法。

你可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作,我一般都是把各种情况都举一个例子,画出插入前和插入后的链表变化,如图所示:

  • 多写多练

5 个常见的链表操作。你只要把这几个操作都能写熟练,不熟就多写几遍,我保证你之后再也不会害怕写链表代码。

leetcode对应题目

单链表反转 206

链表中环的检测 141

两个有序的链表合并 21

删除链表倒数第 n 个结点 19

求链表的中间结点 876

1.单链表反转 206

/**
* 通过双指针反转单链表
*/
class Solution {
  public ListNode reverseList(ListNode head) {
        ListNode cur = head, pre = null, tmp = null;
        // 指针向后扫描, 每次走一步
        while (cur != null) {
            tmp = cur.next;
            cur.next = pre;
            pre = cur;
            cur = tmp;
        }
        return pre;
  }
}

2.链表中环的检测 141

/**
 * 通过快慢指针判断是否循环链表
 * @param head
 * @return
 */
public boolean hasCycle1(ListNode head) {
    if (head == null || head.next == null) {
        return false;
    }
​
    ListNode slow = head,fast = head.next;
    while (slow != fast) {
        if (fast == null || fast.next == null) {
            return false;
        }
        slow = slow.next;
        fast = fast.next.next;
    }
    return true;
}

3.两个有序的链表合并 21

/**
 * 合并两个有序列表——通过哨兵+遍历两个链表的方式
 * 加个哨兵节点方便返回链表
 * @param l1
 * @param l2
 * @return
 */
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
    ListNode preHead = new ListNode(-1);
​
    ListNode pre = preHead;
    while (l1 != null && l2 != null) {
        if (l1.val < l2.val) {
            pre.next = l1;
            l1 = l1.next;
        } else {
            pre.next = l2;
            l2 = l2.next;
        }
        pre = pre.next;
    }
​
    pre.next = l1 == null ? l2 : l1;
    return preHead.next;
}

4.删除链表倒数第 n 个结点 19

/**
 * 删除倒数第n个节点—利用快慢指针
 *
 * @param n
 * @param head
 * @return
 */
public ListNode removeNthFromEnd(int n, ListNode head) {
    ListNode preNode = new ListNode(0);
    preNode.next = head;
​
    ListNode slow = preNode;
    ListNode fast = head;
    // 使fast节点比slow节点多n个
    for (int i = 0; i < n; i++) {
        fast = fast.next;
    }
    // 当fast到链表尾时候, slow处于被删除节点的前驱
    while (fast != null) {
        slow = slow.next;
        fast = fast.next;
    }
​
    slow.next = slow.next.next;
    return preNode.next;
}

5.求链表的中间节点 876

/**
 * 寻找一个链表的中间节点——利用快慢指针
 *
 * @param head
 * @return
 */
public ListNode middleNode(ListNode head) {
    if (head == null) {
        return null;
    }
    ListNode slow = head, fast = head;
​
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    return slow;
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值