如何写出高效的链表代码

本篇文章将介绍下面这些内容,阅读预计15分钟。

链表和数组区别

 我们在问两个事物的区别时,我们实质是在问这两者分别是什么?而非真的关注差别本身。

  • 数组:

    数组是一种线性表数据结构。它用连续的内存空间,存储相同类型的数据

 数组因为申请内的存是连续的内存空间,所以在知道First Index 的内存地址后,可以立即通过位置的偏移计算出其他任何位置(下标)的内存地址。也就是我们常说的随机访问的特性。

例如存储整数的一个Array,每个整数占4字节,在内存中的情况如下:

在知道arr[0]的内存地址是 0x100后,可以通过公式求取任意位置的内存地址:

adrr[n] = 100+4*n

因为java语言特性,不方便打印出数组各个元素的内存地址,用c++创建数组并插入数据后打印各个元素的地址如下:

因为这个特性,可以利用CPU的高速缓存,在读取数组时,预读取一组数据,加快访问效率。

  • 链表:

    链表也是一种线性表数据结构,它通过指针把各个零散的节点串联起来。

 将数组和链表放在一起看,可以发现,链表的内存地址不一定是连续的,系统不会预先分配给链表指定大小的内存空间。当向链表中存放数据,系统会寻找未使用的内存块,存放数据,然后把前一个节点的指针指向该内存。

所以当数组申请一个1G大小的空间时,系统可能因为没有足够的内存空间而创建失败。而链表不存在这样的问题,它使用的是系统中零散的内存。

刚才我们讨论的是单向链表,平时我们使用的LinkedList底层是一个双向链表。每个节点除了指向下一个节点的后继指针,还有一个前驱指针:具体见下图

 在面试时经常会问数组和链表的区别,一般会回答数组支持随机访问,查找元素的时间复杂度是O(1),删除和插入元素的时间复杂度是O(n) ,而链表的插入和删除时间复杂度是O(1)查找元素的时间复杂度是O(n)。

这个表述不是很准确,数组在有序的情况下,即使通过二分法查找,时间复杂度也是O(logn)。而在知道下标的情况下,通过下标访问元素,时间复杂度才是O(1)。另外注意链表的删除和插入操作我们实际的场景往往是删除某个元素,或删除某个位置,插入也是插入到某个具体位置。在插入和删除之前,需要先查找到这个元素或者位置。而查找的时间复杂度是O(n)

面试题常考的链表算法

 链表的编码是面试的一个热点,因为链表的操作涉及很多指针操作,并且有很多编码技巧,所以很容易写错。我自己就深有体会,我去LeetCode刷了几道题,各种打脸。所以将常见的几种链表操作总结如下:其他题目大多是这几种操作的变种。

  • 链表逆序

  • 有序链表合并

  • 链表回路检测

  • 删除链表第N个元素

  • 删除链表倒数第N个元素

  • 求链表的中间节点

数据准备: 节点对象ListNode:

public class ListNode {
     int val;
      ListNode next;
     ListNode(int x) { val = x; }
}

逆序一个链表:

比如:1—>2—>3—>4—>5

逆序后:5—>4—>3—>2—>1

思路:费劲用PPT动画做了一个动图,凑合看,有哪位有做动图的好办法,敬请留言。

代码如下:

public ListNode reverseList(ListNode head){
    ListNode pre = null;
    ListNode cur = head;
    while(cur!=null){
        Node next = cur.next;
        //指针交换
        cur.next = pre
        pre = cur;
        cur= next;
    }
    return pre;
}

合并两个有序链表:

比如:1—>6—>8 和 2—>4—>7 合并结果:1—>2—>4—>6—>7—>8

思路:

用两个指针a,b分别指向两个链表的头结点,然后比较,选择较小的节点,同时向后移动该指针。直到某个链表元素取完。最后将另一个剩余的元素拼接到最后的结果,就完成了合并。

示意图:

代码翻译如下:

public ListNode mergeSortedLinkedList(ListNode a,listNode b){
    //引入一个哑节点(哨兵节点)
    ListNode dummy = new ListNode();
    ListNode tail = dummy;
    while(a!=null && b!=null){
        if(a.val<b.val){
            tail.next = a;
            a = a.next;
        }else{
            tail.next = b;
            b = b.next;
        }
        tail = tail.next;
    }
    
    //将剩余的节点追加到末尾
    tail.next = a == null ? b : a;
    return dummy.next;
}

回路检测:

思路:用快慢两个指针,快指针每次移动两步,慢指针每次移动一步,如果快指针到链表结尾时,仍然没有和慢指针重合,说明没有回路。如果有回路,则会产生死循环,快指针总会有和慢指针重合的时候。

在这里插入图片描述

代码翻译:

public boolean detectLoop(ListNode head){
    if(head==null) return false;
    ListNode slow = head;
    ListNode fast = head;
    while(fast!=null &&fast.next!=null){
        slow = slow.next;
        fast = fast.next.next;
        if(slow==fast){
            return true;
        }
    }
    return false;
}

删除链表第N个元素:

例如:1—>2—>3—>4—>5 删除第2个元素后为:1—>3—>4—>5

思路:遍历链表,找到n-1位置的节点,将其next节点指向下下个节点。为了避免删除的是第一个节点等特殊情况,引入一个哑节点简化编程。

public ListNode deleteNthNode(ListNode head,int n){
    if(n<1) return head;
    
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode p = dummy;
    int k = 0;
    while(p.next!=null){
    	++k;
        if(k==n){
            p.next = p.next.next;
            break;
        }
        p=p.next;
    }
    
    return dummy.next;
    
}

删除链表倒数第N个元素:

例如:1—>2—>3—>4—>5 删除倒数第2个元素后为:1—>2—>3—>5

思路1:最直接的就是先逆序,按照删除第n个元素的方式删除,然后再逆序。

思路2:删除倒数第n个节点,如果链表长度是L,则问题转换为:删除第L-n+1个节点。

所以先求链表的长度L,再删除。

public ListNode deletLastNth(ListNode head,int n){
    if(n<1) return head;
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    
    //1.求链表长度L
    int L = 0;
    ListNode p = dummy;
    while(p.next!=null){
        ++L;
        p = p.next;
    }
    //如果超出范围
    if(n>L) return head;
    
    //2.移动到L-n个节点
    L-=n;
    p = dummy;
    while(L>0){
        p=p.next;
        --L;
    }
    //删除节点
    p.next = p.next.next;
    reutrn dummy.next;
    
}

思路3:

用两个指针,先让第一个指针移动到n+1 处,第二个指针从链表头部出发,这时两个链表中间始终相距n个节点,等第一个节点移动到链表尾部,则第二个节点刚好在第n-1处,此时改变该节点的next节点即可完成删除倒数第n个节点。

图片来源:LeetCode

代码翻译:

public ListNode deleteLastNth(ListNode head,int n){
    if(n<1) return head;
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    ListNode fast = dummy;
    ListNode slow = dummy;
    for(int i=1;i<=n+1;++i){
        fast = fast.next;
    }
    
    while(fast!=null){
        fast = fast.next;
        slow = slow.next;
    }
    
    slow.next = slow.next.next;
    
    return dummy.next;
    
}

求链表的中间节点:

例如:1—>2—>3—>4—>5 中间节点是 3

思路:用两个指针,开始指向头节点,快指针每次移动两步,慢指针每次移动一步。当快指针到尾部时,慢指针刚好在中间节点。

public ListNode findMiddleNode(ListNode head){
    if(head==null) return null;
    
    ListNode slow = head;
    ListNode fast = head;
    while(fast!=null && fast.next!=null){
        fast = fast.next.next;
        slow = slow.next;
    }
    
    return slow;
}

小结:

 链表的题目要先看明(听懂)题目(面试官),编写代码前先思考一下,不要着急下笔。要注意边界条件的验证,可以用笔画一些示意图,帮助理解。

LeetCode练习题索引

为了方便针性对练习,这里列出了链表相关的题目编号。

题目编号描述
19删除链表的倒数第N个节点
21合并两个有序链表
82删除有序链表中的重复元素
86分隔链表
92反转链表
141环形链表
148排序链表
206反转链表
876求链表的中间节点
总结:

 文章讨论了链表和数组的相关概念和区别,链表的编码逻辑性很强,理解概念并不一定能写出代码,特别是手写出bug-free的代码更难。方法就是多写几遍。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值