【数据结构与算法】链表实战入门

179 阅读14分钟

1. 哈希表简述

哈希表在使用层面上可以理解为一种集合结构。

HashSet和HashMap都是哈希表的实现,有无伴随数据,是HashSet和HashMap的唯一区别。

  • 如果只有key,没有伴随数据value,可以使用HashSet。
  • 如果既有key,又有伴随数据value,可以使用HashMap。

使用哈希表增,删,改,查的时间复杂度为O(1),但是常数时间较大。

放入哈希表的数据

  • 如果是基础类型(Integer,String),内部按值传递,内部占用就是该基础类型数据的大小。
  • 如果不是基础类型(Student,Node),内部按引用传递,内存占用就是该非基础类型数据的地址大小(8字节)。

注意

因为如果存入哈希表的数据是基础类型,那么哈希表内部采用的是值传递。其实就是哈希表拷贝了一份原数据存入哈希表。如果该基础类型数据非常大,那么会大量占用哈希表的内存。

2. 有序表简述

有序表在使用层面上可以理解为一种集合结构。

TreeSet和TreeMap都是有序表的实现,有无伴随数据,是TreeSet和TreeMap的唯一区别。

  • 如果只有key,没有伴随数据value,可以使用TreeSet。
  • 如果既有key,又有伴随数据value,可以使用TreeMap。

使用哈希表增,删,改,查的时间复杂度为O(logN)。

放入有序表的数据

  • 如果是基础类型(Integer,String),内部按值传递,内部占用就是该基础类型数据的大小。
  • 如果不是基础类型(Student,Node),必须提供比较器,内部按引用传递,内存占用就是该非基础类型数据的地址大小(8字节)。

有序表与哈希表的区别在于,有序表把key按照顺序组织了起来,而哈希表完全不组织key。

红黑树,AVL树,size-balance-tree和跳表都属于有序表结构,只是底层具体实现不同。

3. 链表

3.1 单链表

单链表节点结构:

public class Node<V> {
    V value;
    Node next;
}

由以上结构的节点依次连接所形成的链叫单链表结构。

示意图:

20210411155606.png

3.2 双链表

双链表节点结构:

public class Node<V> {
    V value;
    Node last;
    Node next;
}

由以上结构的节点依次连接所形成的链叫双链表结构。

示意图:

20210411155627.png

3.3 共性

单链表和双链表结构只需要给定一个头部节点head,就可以找到剩下所有节点。

3.4 水题

  1. 反转一个单向链表。要求如果链表长度为N,时间复杂度为O(N),额外空间复杂度为O(1)。

    public static Node<Integer> reverse(Node<Integer> head) {
        if (head == null || head.next == null) {
            return null;
        }
        Node<Integer> traversal = head;
        Node<Integer> front = null;
        Node<Integer> after = null;
        while (traversal != null) {
            // after向后移动
            after = traversal.next;
            // 指向反转
            traversal.next = front;
            // front和traversal向后移动
            front = traversal;
            traversal = after;
        }
        // 最后一轮node和after都为null,只有front指向最后一个节点
        return front;
    }
    

    递归解法:

    public static Node<Integer> reverse(Node<Integer> head) {
        // 递归压栈操作,将temp和head不断向后移动,直到temp指向链表最后一个节点为止,将最后一个节点构建成newHead
        if (head == null || head.next == null) {
            return head;
        }
        Node<Integer> temp = head.next;
        Node<Integer> newHead = reverse(head.next);
        // 递归弹栈操作,每一次弹栈只做三件事:
        // 1. 都将当前栈帧当中的后节点(temp)的next指向前一个节点(head)。
        // 2. 前一个节点的next放空。
        // 3. 返回newHead。
        temp.next = head;
        head.next = null;
        return newHead;
    }
    
  2. 给定两个有序单链表的头指针head1和head2,打印两个链表的公共部分。要求如果两个链表的长度和为N,时间复杂度为O(N),额外空间复杂度为O(1)。

    public static void compare(Node<Integer> head1, Node<Integer> head2) {
        Node<Integer> traversal1 = head1;
        Node<Integer> traversal2 = head2;
        // 同时遍历两条单链表
        while (traversal1 != null && traversal2 != null) {
            // 哪条链表的节点值小,向后移一位
            if (traversal1.value < traversal2.value) {
                traversal1 = traversal1.next;
            } else if (traversal1.value > traversal2.value) {
                traversal2 = traversal2.next;
            } else { // 相等则输出,再全部向后移一位
                System.out.println(traversal1.value);
                traversal1 = traversal1.next;
                traversal2 = traversal2.next;
            }
        }
    }
    

4. 面试题

4.1 链表题方法论

  • 在笔试时,一切为了时间复杂度,不用太在乎空间复杂度。
  • 在面试时,时间复杂度依然放在第一位,但是一定要找到空间最省的方法。

牵涉到的重要技巧:

  • 额外数据结构记录(哈希表,栈,队列等)
  • 快慢指针

4.1 判断回文结构

4.1.1 描述

题目:给定一个单链表的头节点head,请判断该链表是否为回文结构。

例子

1 —> 2 —> 1,返回true

1 —> 2 —> 2 —> 1,返回true

15 —> 6 —> 15,返回true

1 —> 2 —> 3,返回false

要求:如果链表长度为N,时间复杂度为O(N),额外空间复杂度为O(1)。

扩展:什么是回文?正着念和反着念内容相同。

4.1.2 笔试写法

笔试写法可以不用考虑空间的占用,直接将链表所有节点的数据压栈,然后在弹栈的过程中和链表的每一个节点从头到尾依次比较。

public static boolean isPalindrome(Node<Integer> head) {
    // 使用栈来辅助判断
    Stack<Integer> stack = new Stack<>();
    // 使用代指针node遍历链表,将所有数据依次入栈
    Node<Integer> node = head;
    while (node != null) {
        stack.push(node.value);
        node = node.next;
    }
    // 弹栈的同时遍历链表,依次进行比较
    while (!stack.empty() && head != null) {
        // 只要有一个对不上,就不是回文
        if (stack.pop() != head.value) {
            return false;
        }
        head = head.next;
    }
    return true;
}

将该方法优化,可以省去一半的额外空间。

思路:把链表右半部分入栈,然后在弹栈的过程中遍历左半部分链表进行依次比对。相当于对折比较。

但是这里有一个难点,由于单链表是无法预知数据状况的。我如何在遍历单链表的过程中确定我已经遍历到链表的右半部分了?这里牵涉到一个重要的技巧叫快慢指针

注意:即使是优化过的笔试写法,额外空间复杂度也是O(N),不符合题目的要求。

4.1.3 快慢指针

快慢指针可以解决遍历链表时确定当前节点在链表中位置的问题。

算法:对于同一个链表,建立一个快指针F,一个慢指针S。F每一次遍历两个节点,S每一次遍历一个节点。这样当F遍历链表结束时,S刚好遍历到链表中间部位。

快慢指针只提供一个算法思想,对于每一个需要使用快慢指针的问题,对于快慢指针的实现都会因为边界问题而稍有不同。

例如

1 —> 2 —> 3 —> 2 —> 1 这个链表,如何控制让F遍历到最后一个1时,S正好遍历到唯一中点的3?

1 —> 2 —> 3 —> 3 —> 2 —> 1 这个链表,如何控制让F遍历到最后一个1时,S正好遍历到偏左中点的3?

4.1.4 面试写法

题目要求额外空间复杂度为O(1),也就意味着不能使用任何数据结构,只用几个有限的变量就可以完成回文结构的判断。

思路

假设判断 1 —> 2 —> 3 —> 2 —> 1 这个链表是否为回文结构。设置快慢指针F和S,F每次遍历两个节点,S每次遍历一个节点。要求在F遍历结束时,S遍历到唯一中点3的位置。然后S从中点之后的遍历将右半边链表指向反转成 1 —> 2 —> 3 <— 2 <— 1。然后链表头尾同时向中间遍历依次比较,当判断是回文结构后,再把右半边链表还原成原链表。

public static boolean isPalindrome(Node<Integer> head) {
    Node<Integer> F = head;
    Node<Integer> S = head;
    // 定位快慢指针(定位规则:F遍历结束后,S在唯一中心或者偏左中心)
    // F.next != null 是针对总结点数为奇数的快慢指针定位
    // F.next.next != null 是针对总结点数为偶数的快慢指针定位
    while (F.next != null && F.next.next != null) {
        F = F.next.next;
        S = S.next;
    }
    // 反转右半边链表
    Node<Integer> front = null;
    Node<Integer> after = null;
    while (S != null) {
        after = S.next;
        S.next = front;
        front = S;
        S = after;
    }
    // 两边向中间遍历,依次比较
    // traversalHead != traversalTail 是针对总结点数为奇数的判定条件
    // traversalHead != null 是针对总结点数为偶数的判定条件
    Node<Integer> traversalHead = head;
    Node<Integer> traversalTail = front;
    while (traversalHead != traversalTail && traversalHead != null) {
        if (traversalHead.value != traversalTail.value) {
            return false;
        }
        traversalHead = traversalHead.next;
        traversalTail = traversalTail.next;
    }
    // 如果左右一样,则恢复右半边链表
    // 这时: front在最后一个节点,S和after都是null
    while (front != null) {
        after = front.next;
        front.next = S;
        S = front;
        front = after;
    }
    return true;
}

4.2 单链表按值划分

4.2.1 描述

题目

给定一个单链表的头节点head,节点的值类型是整形。再给定一个整数pivot,实现一个调整链表的函数,将链表调整为左部分都是小于pivot的节点,中间部分都是值等于pivot的节点,右边部分都是值大于pivot的节点。

要求

  1. 调整后所有小于,等于,大于pivot的节点之间的相对次序和调整前一样。
  2. 时间复杂度为O(N),额外空间复杂度为O(1)。

4.2.1 笔试写法

笔试写法可以不用考虑空间的占用,直接将链表中所有节点拷贝进一个数组,然后对数组做partition,最后再将partition后的数组重新构造成一个单链表即可。

注意:笔试写法的额外空间复杂度为O(N),且数组partition的方法是没有稳定性的,所以保持不了相对次序。

public static Node divideList(Node head, int pivot) {
    Node traversal = head;
    // 获取辅助数组的长度
    int size = 0;
    while (traversal != null) {
        traversal = traversal.next;
        size ++;
    }
    // 建立辅助数组
    Node[] arr = new Node[size];
    // 将所有节点拷贝进数组,找到需要partition的目标节点
    int index = 0;
    int pivotIndex = 0;
    traversal = head;
    while (traversal != null) {
        if (traversal.value == pivot) {
            // 存储pivot在数组中的下标
            pivotIndex = index;
        }
        arr[index ++] = traversal;
        traversal = traversal.next;
    }
    // 将pivot节点调换到链表末尾
    swap(arr, pivotIndex, size - 1);
    // 对整个数组进行partition操作
    partition(arr, 0, size - 1);
    // 将partition之后的数组再还原成一个链表
    head = arr[0];
    traversal = head;
    for (int i = 1; i < arr.length; i ++) {
        traversal.next = arr[i];
        traversal = traversal.next;
        // 切断链表尾部指针
        if (i == arr.length - 1) {
            traversal.next = null;
        }
    }
    return head;
}

public static void partition(Node[] arr, int left, int right) {
    int less = left - 1;
    int more = right + 1;
    Node target = arr[right];
    int index = 0;
    while (index < more) {
        if (arr[index].value < target.value) {
            swap(arr, index ++, ++ less);
        } else if (arr[index].value > target.value) {
            swap(arr, index, -- more);
        } else {
            index++;
        }
    }
}

4.2.1 面试写法

该题的难点在于如何在保证额外空间复杂度为O(1)的情况下,还能保证相对次序不变。

我们只需要额外设置6个变量:

  • SH和ST:小于部分的头和尾。
  • EH和ET:等于部分的头和尾。
  • BH和BT:大于部分的头和尾。

SH = ST = EH = ET = BH = BT = null

假设单链表为:4 —> 6 —> 3 —> 5 —> 8 —> 5 —> 2 —> 5 —> 9(pivot = 5)

从左往右遍历结果如图所示:

20210411212109.png

每一个部分的头指针不动,尾指针不断下移添加新节点,节点之间相互连接。当单链表遍历结束后,各个部分首尾相接,最后的结果就是既做了划分又保证了相对次序的单链表。

该方法思路很简单,但是代码实现起来尤其要小心。因为假如没有小于5的区域呢?假如没有等于5的区域呢?或者没有大于5的区域呢?假如只有小于5的区域呢?所以在最后三个部分首尾相接的时候,一定要充分讨论边界条件,否则就会出现空指针异常。

public static Node divideList(Node head, int pivot) {
    // SH,ST是小于pivot子链表的头和尾
    Node SH = null;
    Node ST = null;
    // EH,ET是等于pivot子链表的头和尾
    Node EH = null;
    Node ET = null;
    // BH,BT是大于pivot子链表的头和尾
    Node BH = null;
    Node BT = null;
    // 遍历链表,同时构建三个子链表
    Node traversal = head;
    Node traversalS = null;
    Node traversalE = null;
    Node traversalB = null;
    while (traversal != null) {
        // 构建小于pivot子链表
        if (traversal.value < pivot) {
            // 小于pivot子链表为空
            if (SH == null) {
                SH = traversal;
                traversalS = traversal;
            } else {
                traversalS.next = traversal;
                traversalS = traversalS.next;
            }
            ST = traversal;
        }
        // 构建等于pivot子链表
        if (traversal.value == pivot) {
            // 等于pivot子链表为空
            if (EH == null) {
                EH = traversal;
                traversalE = traversal;
            } else {
                traversalE.next = traversal;
                traversalE = traversalE.next;
            }
            ET = traversal;
        }
        // 构建大于pivot子链表
        if (traversal.value > pivot) {
            // 大于pivot子链表为空
            if (BH == null) {
                BH = traversal;
                traversalB = traversal;
            } else {
                traversalB.next = traversal;
                traversalB = traversalB.next;
            }
            BT = traversal;
        }
        traversal = traversal.next;
    }
    // 将三个子链表首尾相接
    // 小于pivot子链表为空
    if (SH == null && EH != null && BH != null) {
        ET.next = BH;
        return ET;
    }
    // 等于pivot子链表为空
    else if (SH != null && EH == null && BH != null) {
        ST.next = BH;
        return SH;
    }
    // 大于pivot子链表为空
    else if (SH != null && EH != null && BH == null) {
        ST.next = EH;
        return SH;
    }
    // 小于,等于pivot子链表为空
    else if (SH == null && EH == null && BH != null) {
        return BH;
    }
    // 小于,大于pivot子链表为空
    else if (SH == null && EH != null && BH == null) {
        return EH;
    }
    // 等于,大于pivot子链表为空
    else if (SH != null && EH == null && BH == null) {
        return SH;
    }
    // 小于,等于和大于pivot子链表都为空
    else if (SH == null && EH == null && BH == null) {
        return null;
    }
    // 小于,等于和大于pivot子链表都不为空
    else {
        ST.next = EH;
        ET.next = BH;
        return SH;
    }
}

4.3 复制有随机指针的链表

4.3.1 描述

题目:一种特殊的单链表节点类如下

class Node {
    int value;
    Node next;
    Node random;
    Node(int value) {
	this.value = value;
    }
}

random指针是该单链表节点结构中新增的指针,random可能指向链表中任意一个节点(包括自己和null)。给定一个由该节点组成的无环单链表的头节点head,请实现一个函数完成该链表的复制,并返回复制的新链表的头节点。

要求:时间复杂度为O(N),额外空间复杂度为O(1)。

4.3.2 笔试写法

笔试写法可以不用考虑空间的占用,直接通过HashMap即可解决问题。

第一次遍历链表,A节点作为HashMap的key,再根据A.value克隆出来一个新节点A+作为HashMap的value。B节点,C节点以此类推。遍历结束,构建了一个新老节点一一对应的HashMap,此时新节点的next和random全都是null。

第二次遍历链表,A节点通过get方法从HashMap中找到A+节点。然后通过get方法将A.next和A.random传入找到其分别指向的节点(假设为B和C)的克隆节点B+和C+,然后让A+.next指向B+,让A+.random指向C+。其他节点以此类推。遍历结束,克隆出来的新节点完全复制了原节点的指向关系,最后返回A+节点即可。

public Node cloneList(Node head) {
    Node traversal = head;
    HashMap<Node, Node> map = new HashMap<>();
    // 构建新老节点一一对应的HashMap
    while (traversal != null) {
        map.put(traversal, new Node(traversal.value));
        traversal = traversal.next;
    }
    traversal = head;
    // 给新节点的next和random赋值,构建出克隆链表
    while (traversal != null) {
        map.get(traversal).next = map.get(traversal.next);
        map.get(traversal).random = map.get(traversal.random);
        traversal = traversal.next;
    }
    // 返回第一个克隆节点
    return map.get(head);
}

4.3.3 面试写法

该题目的关键要点在于,如何让原节点和克隆节点实现一一对应。也就是说,如何通过原节点能够直接找到该节点克隆出来的新节点。

在笔试写法中,使用HashMap键值对一一对应的特性实现了能够通过原节点直接找到克隆节点。但是如何不使用额外辅助空间就能够通过原节点直接找到克隆节点呢?

image.png

我们只能从原链表本身入手,拆分原链表的结构,将每一个原节点直接指向该原节点的克隆节点,克隆节点再指向该原节点按照原次序应该指向的下一个原节点。以此类推,形成一条原节点和克隆节点混合但是相对次序没有改变的链表。在该链表中,原节点的下一个就是克隆节点,从而形成一一对应,且原节点可以直接通过next指针找到克隆节点。然后再通过原节点的random的next给每一个克隆节点的random赋值,最后将混合链表按照原节点和克隆节点的位置规律将克隆链表从原链表中分离出来,即可得到最终和原链表完全相同的克隆链表。

public static Node cloneList(Node head) {
    // 构建原节点和克隆节点的混合链表
    Node traversal = head;
    while (traversal != null) {
        // 构建克隆节点,并插入原链表
        Node clone = new Node(traversal.value);
        clone.next = traversal.next;
        traversal.next = clone;
        traversal = traversal.next.next;
    }
    // 给每一个克隆节点的random赋值
    traversal = head;
    while (traversal != null) {
        traversal.next.random = traversal.random.next;
        traversal = traversal.next.next;
    }
    // 将克隆链表从混合链表中分离出来
    traversal = head;
    Node cloneHead = head.next;
    Node cloneTraversal = cloneHead;
    // traversal.next.next != null是节点总数为奇数的结束条件
    // cloneTraversal.next != null是节点总数为偶数的结束条件
    while (traversal.next.next != null && cloneTraversal.next != null) {
        traversal.next = cloneTraversal.next;
        cloneTraversal.next = traversal.next.next;
        traversal = traversal.next;
        cloneTraversal = cloneTraversal.next;
    }
    // 返回克隆链表的head
    return cloneHead;
}

4.4 判断链表是否为环

4.4.1 描述

题目:判断一个单链表是否为环或者存在环,如果是,返回第一个入环节点;如果不是,返回null。

要求:时间复杂度为O(N),额外空间复杂度为O(1)。

20210416160046.png

4.4.2 笔试写法

笔试写法可以不用考虑空间的占用,可以通过利用HashSet不可重复的特性解决问题。

创建一个HashSet辅助判断,同时遍历链表。每遍历到一个节点时,先访问HashSet看看是否存在该节点,如果存在,则直接返回,该节点就是第一个入环节点;如果不存在,则继续遍历。遍历到最后一个还是没有发现HashSet中有重复,则返回null。

public static Node isLoop(Node head) {
    HashSet<Node> set = new HashSet<>();
    Node traversal = head;
    while (traversal != null) {
        // 判断HashSet中是否已存在此节点
        if (set.contains(traversal)) {
            return traversal;
        }
        set.add(traversal);
        traversal = traversal.next;
    }
    return null;
}

4.4.3 面试写法

如果不使用额外辅助空间来解这道题,可以通过快慢指针来解决该道题,但是过程有些魔性。

首先我们要明确一点,就是这道题目是"判断单链表是否为环或者构成环"。当你读到这句话时,一定脑补出了很多复杂结构的链表。例如下图所示:

20210416162043.png

但是你仔细观察会发现,如果这种结构存在,那么5这个节点就必然存在2个next指针,这个链表就不是单链表了,违反了题目的要求。

这样看来,单链表的结构其实非常局限,只有纯链表、纯环、链表中包含一个环(如题目所示)这三种形式。纯环和链表中包含一个环其实可以合并考虑,因为只要单链表中有环,遍历时必然会进入环,而且一定出不来,所以单链表中的环一定是个死环

使用快慢指针来解决这个问题的思路是:快慢指针同时遍历链表,如果该链表没有环,那么快指针一定先指向null,返回null结束;如果该链表有环,那么快慢指针一定能相遇。相遇时,快指针回到链表头部,慢指针留在原地,接下来两个指针都每次走一个节点。快慢指针一定会在第一个入环节点处相遇,返回第一个入环节点结束

需要清楚的是,快指针每次走两个节点,慢指针每次走一个节点。如果链表中有环,无论环中有多少个节点,是奇数个还是偶数个,快指针和慢指针都一定会相遇。并且相遇时,快指针和慢指针走的圈数一定不会大于两圈以上。这样以来,确定了最多遍历的圈数是有限几圈,是一个常数。那么这种解法在额外空间复杂度保持很小的情况下,时间复杂度也不会大量的提升,是一个非常优秀的解法。

public static Node isLoop(Node head) {
    Node F = head.next.next;
    Node S = head.next;
    // F和S开始遍历链表,确定链表中是否有环
    while (F != S) {
        // 如果链表没有环,直接返回null
        if (F.next == null || F.next.next == null) {
            return null;
        }
        F = F.next.next;
        S = S.next;
    }
    // 如果存在环,F和S一定会相遇,相遇时将F指向链表头部
    F = head;
    // F从头遍历链表,S原地开始遍历链表,找到第一个入环节点
    while (F != S) {
        F = F.next.next;
        S = S.next;
    }
    // 第一个入环节点就是F和S再次相遇的节点
    return F;
}