← 返回数据结构总览

🔗 数组、链表与跳表面试题

线性数据结构的原理与应用

📚 数组与链表核心知识

1. 数组和链表有什么区别?各有什么优缺点?简单

数组 vs 链表核心对比

特性数组(Array)链表(Linked List)
内存分配连续内存空间非连续内存空间
随机访问O(1) - 支持下标访问O(n) - 需要遍历
插入/删除O(n) - 需要移动元素O(1) - 只需修改指针
内存利用紧凑,无额外开销需要额外存储指针
缓存友好是 - 连续内存否 - 内存分散
大小固定或需要扩容动态增长

时间复杂度对比

操作数组链表
访问第i个元素O(1)O(n)
头部插入O(n)O(1)
尾部插入O(1)*O(n) 或 O(1)**
中间插入O(n)O(n)
查找元素O(n)O(n)

* 动态数组可能需要扩容
** 双向链表维护尾指针可O(1)

使用场景

  • 数组:频繁随机访问、数据量固定、需要缓存友好
  • 链表:频繁插入删除、数据量动态变化、不需要随机访问
2. 如何实现一个动态数组(ArrayList)?简单

动态数组实现要点

  • 底层使用固定大小数组
  • 维护当前元素数量(size)和容量(capacity)
  • 容量不足时自动扩容(通常扩容为原来的1.5倍或2倍)

Java实现

public class MyArrayList {
    private Object[] elements;
    private int size;
    private static final int DEFAULT_CAPACITY = 10;
    
    public MyArrayList() {
        elements = new Object[DEFAULT_CAPACITY];
    }
    
    public void add(E element) {
        ensureCapacity();
        elements[size++] = element;
    }
    
    public E get(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException();
        }
        return (E) elements[index];
    }
    
    public void remove(int index) {
        if (index < 0 || index >= size) {
            throw new IndexOutOfBoundsException();
        }
        // 将后面的元素前移
        System.arraycopy(elements, index + 1, elements, index, size - index - 1);
        elements[--size] = null;  // 帮助GC
    }
    
    private void ensureCapacity() {
        if (size == elements.length) {
            int newCapacity = elements.length * 2;
            elements = Arrays.copyOf(elements, newCapacity);
        }
    }
    
    public int size() {
        return size;
    }
}

扩容策略

  • Java ArrayList:扩容1.5倍(newCapacity = oldCapacity + (oldCapacity >> 1))
  • C++ vector:扩容2倍
  • Python list:扩容约1.125倍

为什么不是每次扩容1个?

如果每次只扩容1个,插入n个元素需要拷贝1+2+3+...+n = O(n²)次。扩容为2倍时,总拷贝次数为O(n),均摊时间复杂度为O(1)。

3. 如何反转链表?(LeetCode 206)中等

问题描述

给定单链表的头节点head,反转链表并返回反转后的链表。

示例:1 → 2 → 3 → 4 → 5 变为 5 → 4 → 3 → 2 → 1

方法1:迭代法

public ListNode reverseList(ListNode head) {
    ListNode prev = null;
    ListNode curr = head;
    
    while (curr != null) {
        ListNode next = curr.next;  // 保存下一个节点
        curr.next = prev;           // 反转指针
        prev = curr;                // prev前移
        curr = next;                // curr前移
    }
    
    return prev;  // prev是新的头节点
}

// 时间复杂度:O(n)
// 空间复杂度:O(1)

方法2:递归法

public ListNode reverseList(ListNode head) {
    if (head == null || head.next == null) {
        return head;
    }
    
    ListNode newHead = reverseList(head.next);  // 递归反转后面的链表
    head.next.next = head;  // 让下一个节点指向当前节点
    head.next = null;       // 断开当前节点的next
    
    return newHead;
}

// 时间复杂度:O(n)
// 空间复杂度:O(n) - 递归栈

图解过程

初始:1 → 2 → 3 → null
步骤1:null ← 1   2 → 3 → null
步骤2:null ← 1 ← 2   3 → null
步骤3:null ← 1 ← 2 ← 3
4. 如何检测链表中是否有环?(LeetCode 141)中等

快慢指针法(Floyd判圈算法)

public boolean hasCycle(ListNode head) {
    if (head == null || head.next == null) {
        return false;
    }
    
    ListNode slow = head;
    ListNode fast = head;
    
    while (fast != null && fast.next != null) {
        slow = slow.next;        // 慢指针走1步
        fast = fast.next.next;   // 快指针走2步
        
        if (slow == fast) {      // 相遇说明有环
            return true;
        }
    }
    
    return false;
}

// 时间复杂度:O(n)
// 空间复杂度:O(1)

为什么快慢指针一定会相遇?

如果有环,快指针最终会追上慢指针。每次循环,快指针与慢指针的距离减1,所以一定会相遇。

进阶:找到环的入口(LeetCode 142)

public ListNode detectCycle(ListNode head) {
    ListNode slow = head, fast = head;
    
    // 1. 判断是否有环
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
        if (slow == fast) {
            // 2. 找到环的入口
            ListNode ptr = head;
            while (ptr != slow) {
                ptr = ptr.next;
                slow = slow.next;
            }
            return ptr;
        }
    }
    return null;
}

数学证明

设链表头到环入口距离为a,环入口到相遇点距离为b,环长为c。
相遇时:慢指针走了a+b,快指针走了a+b+nc(n圈)
因为快指针速度是慢指针2倍:2(a+b) = a+b+nc
得:a = nc - b = (n-1)c + (c-b)
所以从头和相遇点同时出发,会在环入口相遇。

5. 如何合并两个有序链表?(LeetCode 21)中等

迭代法

public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(0);  // 哨兵节点
    ListNode curr = dummy;
    
    while (l1 != null && l2 != null) {
        if (l1.val < l2.val) {
            curr.next = l1;
            l1 = l1.next;
        } else {
            curr.next = l2;
            l2 = l2.next;
        }
        curr = curr.next;
    }
    
    // 连接剩余部分
    curr.next = (l1 != null) ? l1 : l2;
    
    return dummy.next;
}

// 时间复杂度:O(m + n)
// 空间复杂度:O(1)

递归法

public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
    if (l1 == null) return l2;
    if (l2 == null) return l1;
    
    if (l1.val < l2.val) {
        l1.next = mergeTwoLists(l1.next, l2);
        return l1;
    } else {
        l2.next = mergeTwoLists(l1, l2.next);
        return l2;
    }
}

// 时间复杂度:O(m + n)
// 空间复杂度:O(m + n) - 递归栈

哨兵节点的作用

使用哨兵节点(dummy node)可以简化边界条件处理,避免特殊判断头节点。

6. 如何找到链表的中间节点?(LeetCode 876)中等

快慢指针法

public ListNode middleNode(ListNode head) {
    ListNode slow = head;
    ListNode fast = head;
    
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    
    return slow;  // slow指向中间节点
}

// 时间复杂度:O(n)
// 空间复杂度:O(1)

原理

快指针每次走2步,慢指针每次走1步。当快指针到达末尾时,慢指针正好在中间。

  • 奇数个节点:slow指向正中间
  • 偶数个节点:slow指向中间靠后的节点

应用场景

找中间节点常用于链表的归并排序、判断回文链表等问题。

7. 如何删除链表的倒数第N个节点?(LeetCode 19)中等

双指针法(一次遍历)

public ListNode removeNthFromEnd(ListNode head, int n) {
    ListNode dummy = new ListNode(0);
    dummy.next = head;
    
    ListNode first = dummy;
    ListNode second = dummy;
    
    // first先走n+1步
    for (int i = 0; i <= n; i++) {
        first = first.next;
    }
    
    // first和second一起走,直到first到末尾
    while (first != null) {
        first = first.next;
        second = second.next;
    }
    
    // 删除second的下一个节点
    second.next = second.next.next;
    
    return dummy.next;
}

// 时间复杂度:O(n)
// 空间复杂度:O(1)

为什么first要走n+1步?

因为我们需要找到倒数第n个节点的前一个节点,这样才能删除它。

为什么使用dummy节点?

如果要删除的是头节点,使用dummy可以统一处理,避免特殊判断。

8. 如何判断链表是否为回文链表?(LeetCode 234)困难

O(1)空间复杂度解法

public boolean isPalindrome(ListNode head) {
    if (head == null || head.next == null) {
        return true;
    }
    
    // 1. 找到中间节点
    ListNode slow = head, fast = head;
    while (fast.next != null && fast.next.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    
    // 2. 反转后半部分链表
    ListNode secondHalf = reverseList(slow.next);
    
    // 3. 比较前半部分和反转后的后半部分
    ListNode p1 = head, p2 = secondHalf;
    boolean result = true;
    while (p2 != null) {
        if (p1.val != p2.val) {
            result = false;
            break;
        }
        p1 = p1.next;
        p2 = p2.next;
    }
    
    // 4. 恢复链表(可选)
    slow.next = reverseList(secondHalf);
    
    return result;
}

private ListNode reverseList(ListNode head) {
    ListNode prev = null, curr = head;
    while (curr != null) {
        ListNode next = curr.next;
        curr.next = prev;
        prev = curr;
        curr = next;
    }
    return prev;
}

// 时间复杂度:O(n)
// 空间复杂度:O(1)

方法2:使用栈(O(n)空间)

public boolean isPalindrome(ListNode head) {
    Stack stack = new Stack<>();
    ListNode curr = head;
    
    // 将所有值压入栈
    while (curr != null) {
        stack.push(curr.val);
        curr = curr.next;
    }
    
    // 比较
    curr = head;
    while (curr != null) {
        if (curr.val != stack.pop()) {
            return false;
        }
        curr = curr.next;
    }
    
    return true;
}
9. 如何合并K个有序链表?(LeetCode 23)困难

方法1:优先队列(最小堆)

public ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0) {
        return null;
    }
    
    // 最小堆
    PriorityQueue pq = new PriorityQueue<>((a, b) -> a.val - b.val);
    
    // 将所有链表的头节点加入堆
    for (ListNode node : lists) {
        if (node != null) {
            pq.offer(node);
        }
    }
    
    ListNode dummy = new ListNode(0);
    ListNode curr = dummy;
    
    while (!pq.isEmpty()) {
        ListNode node = pq.poll();
        curr.next = node;
        curr = curr.next;
        
        if (node.next != null) {
            pq.offer(node.next);
        }
    }
    
    return dummy.next;
}

// 时间复杂度:O(N log k),N是所有节点总数,k是链表数量
// 空间复杂度:O(k)

方法2:分治合并

public ListNode mergeKLists(ListNode[] lists) {
    if (lists == null || lists.length == 0) {
        return null;
    }
    return merge(lists, 0, lists.length - 1);
}

private ListNode merge(ListNode[] lists, int left, int right) {
    if (left == right) {
        return lists[left];
    }
    
    int mid = left + (right - left) / 2;
    ListNode l1 = merge(lists, left, mid);
    ListNode l2 = merge(lists, mid + 1, right);
    
    return mergeTwoLists(l1, l2);
}

// 时间复杂度:O(N log k)
// 空间复杂度:O(log k) - 递归栈

方法对比

  • 优先队列:代码简洁,适合链表数量多的情况
  • 分治:空间复杂度更低,适合链表数量少的情况
10. 什么是跳表?它的查找效率是多少?中等

跳表(Skip List)定义

跳表是一种基于有序链表的数据结构,通过在链表上建立多级索引来加速查找。

跳表结构

Level 3:  1 -----------------> 7
Level 2:  1 ------> 4 ------> 7 ------> 10
Level 1:  1 -> 3 -> 4 -> 6 -> 7 -> 9 -> 10
Level 0:  1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> 10

时间复杂度

  • 查找:O(log n)
  • 插入:O(log n)
  • 删除:O(log n)

跳表 vs 平衡树

特性跳表红黑树
实现复杂度简单复杂
查找效率O(log n)O(log n)
范围查询简单高效需要中序遍历
并发性容易实现无锁困难

应用场景

  • Redis:有序集合(Sorted Set)使用跳表实现
  • LevelDB/RocksDB:MemTable使用跳表
  • Lucene:倒排索引使用跳表
11. 如何实现一个跳表?困难

跳表节点定义

class SkipListNode {
    int val;
    SkipListNode[] next;  // 每层的下一个节点
    
    public SkipListNode(int val, int level) {
        this.val = val;
        this.next = new SkipListNode[level];
    }
}

跳表实现

class SkipList {
    private static final int MAX_LEVEL = 16;
    private static final double P = 0.5;  // 晋升概率
    private SkipListNode head;
    private int level;
    
    public SkipList() {
        head = new SkipListNode(Integer.MIN_VALUE, MAX_LEVEL);
        level = 0;
    }
    
    // 查找
    public boolean search(int target) {
        SkipListNode curr = head;
        for (int i = level; i >= 0; i--) {
            while (curr.next[i] != null && curr.next[i].val < target) {
                curr = curr.next[i];
            }
        }
        curr = curr.next[0];
        return curr != null && curr.val == target;
    }
    
    // 插入
    public void add(int num) {
        SkipListNode[] update = new SkipListNode[MAX_LEVEL];
        SkipListNode curr = head;
        
        // 找到每层的插入位置
        for (int i = level; i >= 0; i--) {
            while (curr.next[i] != null && curr.next[i].val < num) {
                curr = curr.next[i];
            }
            update[i] = curr;
        }
        
        // 随机生成层数
        int newLevel = randomLevel();
        if (newLevel > level) {
            for (int i = level + 1; i <= newLevel; i++) {
                update[i] = head;
            }
            level = newLevel;
        }
        
        // 插入节点
        SkipListNode newNode = new SkipListNode(num, newLevel + 1);
        for (int i = 0; i <= newLevel; i++) {
            newNode.next[i] = update[i].next[i];
            update[i].next[i] = newNode;
        }
    }
    
    // 删除
    public boolean erase(int num) {
        SkipListNode[] update = new SkipListNode[MAX_LEVEL];
        SkipListNode curr = head;
        
        for (int i = level; i >= 0; i--) {
            while (curr.next[i] != null && curr.next[i].val < num) {
                curr = curr.next[i];
            }
            update[i] = curr;
        }
        
        curr = curr.next[0];
        if (curr == null || curr.val != num) {
            return false;
        }
        
        // 删除节点
        for (int i = 0; i <= level; i++) {
            if (update[i].next[i] != curr) {
                break;
            }
            update[i].next[i] = curr.next[i];
        }
        
        // 更新level
        while (level > 0 && head.next[level] == null) {
            level--;
        }
        
        return true;
    }
    
    private int randomLevel() {
        int lvl = 0;
        while (Math.random() < P && lvl < MAX_LEVEL - 1) {
            lvl++;
        }
        return lvl;
    }
}

为什么使用随机层数?

通过随机决定节点的层数,可以保持跳表的平衡性,避免退化为链表。概率P通常设为0.5或0.25。

12. 如何实现LRU缓存?(LeetCode 146)中等

LRU(Least Recently Used)缓存

使用哈希表 + 双向链表实现O(1)的get和put操作。

实现思路

  • 哈希表:快速定位节点
  • 双向链表:维护访问顺序(头部最新,尾部最旧)
class LRUCache {
    class Node {
        int key, value;
        Node prev, next;
        Node(int k, int v) { key = k; value = v; }
    }
    
    private Map map;
    private Node head, tail;
    private int capacity;
    
    public LRUCache(int capacity) {
        this.capacity = capacity;
        map = new HashMap<>();
        head = new Node(0, 0);
        tail = new Node(0, 0);
        head.next = tail;
        tail.prev = head;
    }
    
    public int get(int key) {
        if (!map.containsKey(key)) {
            return -1;
        }
        Node node = map.get(key);
        remove(node);
        addToHead(node);
        return node.value;
    }
    
    public void put(int key, int value) {
        if (map.containsKey(key)) {
            Node node = map.get(key);
            node.value = value;
            remove(node);
            addToHead(node);
        } else {
            if (map.size() == capacity) {
                map.remove(tail.prev.key);
                remove(tail.prev);
            }
            Node node = new Node(key, value);
            map.put(key, node);
            addToHead(node);
        }
    }
    
    private void remove(Node node) {
        node.prev.next = node.next;
        node.next.prev = node.prev;
    }
    
    private void addToHead(Node node) {
        node.next = head.next;
        node.prev = head;
        head.next.prev = node;
        head.next = node;
    }
}

// 时间复杂度:O(1)
// 空间复杂度:O(capacity)

为什么使用双向链表?

双向链表可以O(1)删除节点(需要知道前驱节点),单向链表需要O(n)。

13. 如何对链表进行排序?(LeetCode 148)困难

归并排序(O(n log n)时间,O(1)空间)

public ListNode sortList(ListNode head) {
    if (head == null || head.next == null) {
        return head;
    }
    
    // 1. 找到中间节点
    ListNode slow = head, fast = head.next;
    while (fast != null && fast.next != null) {
        slow = slow.next;
        fast = fast.next.next;
    }
    
    // 2. 分割链表
    ListNode mid = slow.next;
    slow.next = null;
    
    // 3. 递归排序
    ListNode left = sortList(head);
    ListNode right = sortList(mid);
    
    // 4. 合并
    return merge(left, right);
}

private ListNode merge(ListNode l1, ListNode l2) {
    ListNode dummy = new ListNode(0);
    ListNode curr = dummy;
    
    while (l1 != null && l2 != null) {
        if (l1.val < l2.val) {
            curr.next = l1;
            l1 = l1.next;
        } else {
            curr.next = l2;
            l2 = l2.next;
        }
        curr = curr.next;
    }
    
    curr.next = (l1 != null) ? l1 : l2;
    return dummy.next;
}

// 时间复杂度:O(n log n)
// 空间复杂度:O(log n) - 递归栈

为什么不用快速排序?

快速排序需要随机访问,链表不支持O(1)随机访问,效率低。归并排序只需要顺序访问,更适合链表。

14. 如何旋转链表?(LeetCode 61)中等

问题描述

给定链表,将链表向右旋转k个位置。

示例:1→2→3→4→5,k=2 变为 4→5→1→2→3

解法

public ListNode rotateRight(ListNode head, int k) {
    if (head == null || head.next == null || k == 0) {
        return head;
    }
    
    // 1. 计算链表长度并连成环
    ListNode tail = head;
    int len = 1;
    while (tail.next != null) {
        tail = tail.next;
        len++;
    }
    tail.next = head;  // 连成环
    
    // 2. 找到新的尾节点(第 len - k % len 个节点)
    k = k % len;
    int stepsToNewTail = len - k;
    ListNode newTail = head;
    for (int i = 1; i < stepsToNewTail; i++) {
        newTail = newTail.next;
    }
    
    // 3. 断开环
    ListNode newHead = newTail.next;
    newTail.next = null;
    
    return newHead;
}

// 时间复杂度:O(n)
// 空间复杂度:O(1)

关键点

  • k可能大于链表长度,需要取模
  • 先连成环,再在合适位置断开
15. 如何复制带随机指针的链表?(LeetCode 138)困难

问题描述

链表节点包含next和random指针,random可能指向链表中任意节点或null。要求深拷贝链表。

方法1:哈希表

public Node copyRandomList(Node head) {
    if (head == null) return null;
    
    Map map = new HashMap<>();
    
    // 第一遍:复制所有节点
    Node curr = head;
    while (curr != null) {
        map.put(curr, new Node(curr.val));
        curr = curr.next;
    }
    
    // 第二遍:设置next和random指针
    curr = head;
    while (curr != null) {
        map.get(curr).next = map.get(curr.next);
        map.get(curr).random = map.get(curr.random);
        curr = curr.next;
    }
    
    return map.get(head);
}

// 时间复杂度:O(n)
// 空间复杂度:O(n)

方法2:原地复制(O(1)空间)

public Node copyRandomList(Node head) {
    if (head == null) return null;
    
    // 1. 在每个节点后插入复制节点
    Node curr = head;
    while (curr != null) {
        Node copy = new Node(curr.val);
        copy.next = curr.next;
        curr.next = copy;
        curr = copy.next;
    }
    
    // 2. 设置random指针
    curr = head;
    while (curr != null) {
        if (curr.random != null) {
            curr.next.random = curr.random.next;
        }
        curr = curr.next.next;
    }
    
    // 3. 分离两个链表
    Node dummy = new Node(0);
    Node copyCurr = dummy;
    curr = head;
    while (curr != null) {
        copyCurr.next = curr.next;
        curr.next = curr.next.next;
        curr = curr.next;
        copyCurr = copyCurr.next;
    }
    
    return dummy.next;
}

// 时间复杂度:O(n)
// 空间复杂度:O(1)

图解

原链表:A → B → C
步骤1:A → A' → B → B' → C → C'
步骤2:设置A'.random, B'.random, C'.random
步骤3:分离为 A → B → C 和 A' → B' → C'