数据结构解决的不是“怎么写一个容器”这么简单的问题,而是:数据应该按什么形态组织,才能让查询、插入、删除、遍历、排序、范围检索这些操作更稳定、更可控。

本文用 Java 讲解 8 类常见数据结构:

  • 栈(Stack)
  • 队列(Queue)
  • 链表(Linked List)
  • 散列表(Hash Table)
  • 排序二叉树,也叫二叉搜索树(Binary Search Tree)
  • 红黑树(Red-Black Tree)
  • B-Tree
  • 位图(Bitmap)

文中的代码以 Java 8 及以上版本为基础。Java 标准库部分以 JDK 官方文档为准:ArrayDeque 适合当作栈和普通队列使用,HashMap 是基于哈希表的 Map 实现,TreeMap 是基于红黑树的有序 NavigableMap 实现,BitSet 是动态增长的位集合。


一、先建立几个复杂度概念

分析数据结构时,最常问的是:

  • 查找一个元素要多久?
  • 插入一个元素要多久?
  • 删除一个元素要多久?
  • 是否保持顺序?
  • 是否支持按范围查询?
  • 内存占用是否可控?
  • 最坏情况下会不会退化?

常见复杂度可以这样理解:

复杂度直观理解常见场景
O(1)数据量增长,操作次数基本不变数组按下标访问、栈顶入栈出栈、哈希表平均查询
O(log n)每次排除一大部分数据平衡树查询、二分查找
O(n)需要线性扫描链表查找、数组遍历
O(n log n)常见高效排序水平归并排序、堆排序、平均快速排序
O(n^2)双层循环级别简单排序、某些暴力比较

还要注意三个词:

  • 平均复杂度:通常情况下的成本,例如 HashMapgetput 平均接近 O(1)
  • 最坏复杂度:极端情况下的成本,例如普通二叉搜索树退化成链表后查询会变成 O(n)
  • 摊还复杂度:把偶发的高成本分摊到多次操作上,例如动态数组扩容虽然一次可能是 O(n),但连续追加的摊还成本通常仍接近 O(1)

二、栈(Stack)

1. 栈的核心思想

栈是一种 后进先出(LIFO,Last In First Out)的线性结构。

可以把它想成一摞盘子:

  • 新盘子只能放到最上面;
  • 拿盘子也只能从最上面拿;
  • 最后放进去的,会最先被拿出来。

栈的核心操作如下:

操作含义复杂度
push入栈,把元素放到栈顶O(1)
pop出栈,移除并返回栈顶元素O(1)
peek查看栈顶元素但不删除O(1)
isEmpty判断栈是否为空O(1)

2. Java 中怎么选栈实现

Java 里有一个老类叫 java.util.Stack,但实际开发更推荐使用 Deque 接口配合 ArrayDeque

Deque<Integer> stack = new ArrayDeque<>();
stack.push(1);
stack.push(2);
System.out.println(stack.pop()); // 2

原因是:

  • Stack 是早期同步容器,API 和继承设计都比较老;
  • ArrayDeque 是可增长数组实现,作为栈使用通常更轻量;
  • ArrayDeque 不允许存放 null,这反而能减少“空值到底是数据还是无数据”的歧义;
  • ArrayDeque 不是线程安全容器,多线程共享时需要外部同步或改用并发容器。

3. 示例:括号匹配

括号匹配是栈最经典的应用。遇到左括号时,把期望匹配的右括号压栈;遇到右括号时,检查它是否等于栈顶。

import java.util.ArrayDeque;
import java.util.Deque;

public class StackParenthesesDemo {

    public static boolean isValid(String text) {
        Deque<Character> stack = new ArrayDeque<>();

        for (int i = 0; i < text.length(); i++) {
            char ch = text.charAt(i);

            if (ch == '(') {
                stack.push(')');
            } else if (ch == '[') {
                stack.push(']');
            } else if (ch == '{') {
                stack.push('}');
            } else if (ch == ')' || ch == ']' || ch == '}') {
                if (stack.isEmpty() || stack.pop() != ch) {
                    return false;
                }
            }
        }

        return stack.isEmpty();
    }

    public static void main(String[] args) {
        System.out.println(isValid("{[()]}"));   // true
        System.out.println(isValid("{[(])}"));   // false
    }
}

4. 栈适合什么场景

栈适合处理“最近发生的事情要先处理”的问题:

  • 方法调用栈;
  • 表达式求值;
  • 括号匹配;
  • 浏览器前进后退;
  • 编辑器撤销操作;
  • 深度优先搜索(DFS);
  • 单调栈问题,例如下一个更大元素。

栈的关键限制是:它只能高效访问栈顶。你不能指望它像数组一样按下标随机访问中间元素。


三、队列(Queue)

1. 队列的核心思想

队列是一种 先进先出(FIFO,First In First Out)的线性结构。

可以把它想成排队买票:

  • 新来的人排到队尾;
  • 最先来的人从队头离开;
  • 队列保证处理顺序。

队列的核心操作如下:

操作含义复杂度
offer入队,放到队尾O(1)
poll出队,移除并返回队头元素O(1)
peek查看队头元素但不删除O(1)
isEmpty判断队列是否为空O(1)

Java 队列 API 有两组容易混淆的方法:

推荐用于普通业务空队列或失败时另一组方法空队列或失败时
offer(e)返回 falseadd(e)抛异常
poll()返回 nullremove()抛异常
peek()返回 nullelement()抛异常

日常业务里通常优先用 offerpollpeek,因为它们更适合显式处理空队列。

2. Java 中怎么选队列实现

常用选择如下:

场景推荐实现
普通单线程 FIFO 队列ArrayDeque
两端都要插入和删除ArrayDeque
需要阻塞等待ArrayBlockingQueueLinkedBlockingQueue
需要优先级PriorityQueue
需要线程安全且高吞吐非阻塞队列ConcurrentLinkedQueue

注意:PriorityQueue 虽然名字里有 Queue,但它不是严格 FIFO,而是按优先级出队。

3. 示例:用队列实现 BFS

广度优先搜索(BFS)每次先处理离起点最近的一层节点,非常适合用队列。

import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Deque;
import java.util.List;

public class QueueBfsDemo {

    public static List<Integer> bfs(List<List<Integer>> graph, int start) {
        boolean[] visited = new boolean[graph.size()];
        Deque<Integer> queue = new ArrayDeque<>();
        List<Integer> order = new ArrayList<>();

        visited[start] = true;
        queue.offer(start);

        while (!queue.isEmpty()) {
            int current = queue.poll();
            order.add(current);

            for (int next : graph.get(current)) {
                if (!visited[next]) {
                    visited[next] = true;
                    queue.offer(next);
                }
            }
        }

        return order;
    }

    public static void main(String[] args) {
        List<List<Integer>> graph = Arrays.asList(
                Arrays.asList(1, 2),
                Arrays.asList(3),
                Arrays.asList(3, 4),
                Arrays.asList(4),
                Collections.<Integer>emptyList()
        );

        System.out.println(bfs(graph, 0)); // [0, 1, 2, 3, 4]
    }
}

4. 队列适合什么场景

队列适合处理“先来的任务先处理”的问题:

  • 消息队列;
  • 请求排队;
  • 线程池任务队列;
  • BFS;
  • 生产者消费者模型;
  • 缓冲区;
  • 限流削峰。

队列的关键限制是:普通队列只关心队头和队尾,不适合随机访问中间元素。


四、链表(Linked List)

1. 链表的核心思想

链表由一个个节点组成,每个节点保存数据,并通过引用指向下一个节点。

最简单的单向链表节点可以表示为:

class Node {
    int value;
    Node next;
}

链表和数组最大的区别是:

  • 数组在内存中通常是连续空间,适合按下标访问;
  • 链表通过节点引用连接,不要求连续空间;
  • 链表访问第 i 个元素时,通常必须从头节点一步步走过去;
  • 如果已经拿到某个节点的引用,在它后面插入或删除节点可以很快。

2. 链表的类型

类型特点
单向链表每个节点只指向下一个节点
双向链表每个节点同时指向前一个和后一个节点
循环链表尾节点指向头节点,适合环形调度

Java 标准库里的 LinkedList 是双向链表,并且同时实现了 ListDequeQueue 等接口。

3. 链表复杂度

操作单向链表复杂度说明
头部插入O(1)改一下头指针
头部删除O(1)改一下头指针
尾部插入有尾指针为 O(1),无尾指针为 O(n)是否维护 tail 很关键
查找某个值O(n)需要逐个节点扫描
按下标访问O(n)不能像数组那样直接跳到下标
删除已知后继节点O(1)前提是已经拿到前驱节点

一句话总结:链表擅长“局部插入删除”,不擅长“随机访问”。

4. 示例:实现一个简单单向链表

import java.util.NoSuchElementException;
import java.util.Objects;

public class SinglyLinkedListDemo {

    public static class SinglyLinkedList<E> {
        private Node<E> head;
        private Node<E> tail;
        private int size;

        private static class Node<E> {
            private final E value;
            private Node<E> next;

            private Node(E value) {
                this.value = value;
            }
        }

        public void addFirst(E value) {
            Node<E> node = new Node<>(value);
            node.next = head;
            head = node;
            if (tail == null) {
                tail = node;
            }
            size++;
        }

        public void addLast(E value) {
            Node<E> node = new Node<>(value);
            if (tail == null) {
                head = node;
                tail = node;
            } else {
                tail.next = node;
                tail = node;
            }
            size++;
        }

        public E removeFirst() {
            if (head == null) {
                throw new NoSuchElementException("list is empty");
            }

            E value = head.value;
            head = head.next;
            if (head == null) {
                tail = null;
            }
            size--;
            return value;
        }

        public boolean contains(E target) {
            for (Node<E> current = head; current != null; current = current.next) {
                if (Objects.equals(current.value, target)) {
                    return true;
                }
            }
            return false;
        }

        public int size() {
            return size;
        }

        @Override
        public String toString() {
            StringBuilder builder = new StringBuilder("[");
            for (Node<E> current = head; current != null; current = current.next) {
                if (current != head) {
                    builder.append(", ");
                }
                builder.append(current.value);
            }
            return builder.append(']').toString();
        }
    }

    public static void main(String[] args) {
        SinglyLinkedList<String> list = new SinglyLinkedList<>();
        list.addLast("A");
        list.addLast("B");
        list.addFirst("Start");

        System.out.println(list);              // [Start, A, B]
        System.out.println(list.contains("A")); // true
        System.out.println(list.removeFirst()); // Start
        System.out.println(list.size());        // 2
    }
}

5. 使用链表的常见误区

不要看到“插入删除 O(1)”就默认链表一定比数组好。这个 O(1) 有前提:你已经拿到了要操作位置附近的节点。

如果业务代码经常这样写:

linkedList.get(i);

那就要警惕了。LinkedList 按下标访问需要从头或尾开始遍历,复杂度是 O(n)。如果你主要按下标访问,ArrayList 往往更合适。

链表还有两个实际成本:

  • 每个节点都要额外保存引用,内存开销更大;
  • 节点分散在堆上,CPU 缓存局部性通常不如数组。

五、散列表(Hash Table)

1. 散列表的核心思想

散列表通过哈希函数把 key 映射到数组下标,从而快速定位数据。

简化流程如下:

key -> hashCode -> 扰动/取模 -> bucket 下标 -> 找到对应元素

在 Java 中,最典型的散列表实现就是 HashMapHashSet

HashMap 存的是 key-value:

Map<String, Integer> map = new HashMap<>();
map.put("Java", 1);
System.out.println(map.get("Java"));

HashSet 只关心元素是否存在,本质上也是基于哈希思想:

Set<String> set = new HashSet<>();
set.add("Java");
System.out.println(set.contains("Java"));

2. 哈希冲突

不同 key 经过哈希计算后,可能落到同一个 bucket,这叫哈希冲突。

常见处理方式有:

  • 链地址法:同一个 bucket 后面挂链表或树;
  • 开放寻址法:冲突后继续探测下一个可用位置;
  • 再哈希:换一个哈希函数或计算方式。

Java 的 HashMap 属于链地址法思路。具体实现会随着 JDK 版本演进,不建议把内部阈值当成业务代码的稳定契约。

3. HashMap 复杂度

操作平均复杂度最坏复杂度
putO(1)可能退化
getO(1)可能退化
removeO(1)可能退化
遍历O(capacity + size)受容量和元素数量共同影响

散列表的性能依赖两个条件:

  • hash 分布要尽量均匀;
  • 冲突不能过于集中。

4. equals 与 hashCode 契约

在 Java 中,只要对象要当作 HashMap 的 key,就必须遵守:

  • 如果 a.equals(b) == true,那么 a.hashCode() 必须等于 b.hashCode()
  • 如果两个对象 hashCode 相同,它们不一定 equals
  • 作为 key 的字段最好不可变,否则放入 HashMap 后字段变化,会导致后续查不到。

5. 示例:词频统计与自定义 key

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class HashTableWordCountDemo {

    private static final class UserKey {
        private final long tenantId;
        private final long userId;

        private UserKey(long tenantId, long userId) {
            this.tenantId = tenantId;
            this.userId = userId;
        }

        @Override
        public boolean equals(Object other) {
            if (this == other) {
                return true;
            }
            if (!(other instanceof UserKey)) {
                return false;
            }
            UserKey that = (UserKey) other;
            return tenantId == that.tenantId && userId == that.userId;
        }

        @Override
        public int hashCode() {
            return Objects.hash(tenantId, userId);
        }
    }

    public static void main(String[] args) {
        String text = "java hash map java tree map java";
        Map<String, Integer> wordCount = new HashMap<>();

        for (String word : text.split("\\s+")) {
            wordCount.merge(word, 1, Integer::sum);
        }

        System.out.println(wordCount); // {java=3, tree=1, hash=1, map=2}

        Map<UserKey, String> users = new HashMap<>();
        users.put(new UserKey(1001L, 42L), "Alice");

        System.out.println(users.get(new UserKey(1001L, 42L))); // Alice
    }
}

6. 散列表适合什么场景

散列表适合通过 key 快速定位 value:

  • 缓存;
  • 字典;
  • 去重;
  • 计数;
  • 索引;
  • 对象映射;
  • 判断元素是否存在。

不适合的场景:

  • 需要按 key 排序:用 TreeMap
  • 需要稳定插入顺序:用 LinkedHashMap
  • 需要范围查询:用 TreeMap 或数据库索引;
  • 多线程高并发写:用 ConcurrentHashMap 或外部同步。

六、排序二叉树(Binary Search Tree)

1. 排序二叉树的核心思想

排序二叉树更常见的名字是 二叉搜索树(BST,Binary Search Tree)。

它满足一个关键性质:

  • 左子树所有节点都小于当前节点;
  • 右子树所有节点都大于当前节点;
  • 左右子树本身也都是二叉搜索树。

例如:

        8
      /   \
     3     10
    / \      \
   1   6      14
      / \     /
     4   7   13

如果按中序遍历(左 -> 根 -> 右)访问这棵树,会得到有序序列:

1, 3, 4, 6, 7, 8, 10, 13, 14

2. BST 复杂度

操作平均复杂度最坏复杂度
查询O(log n)O(n)
插入O(log n)O(n)
删除O(log n)O(n)
中序遍历O(n)O(n)

最坏情况发生在树严重倾斜时,例如按递增顺序插入:

1
 \
  2
   \
    3
     \
      4

这时 BST 退化成链表,查询就变成 O(n)

3. 示例:实现插入、查询、删除、中序遍历

下面示例采用“重复值忽略”的策略。实际业务里也可以改成保存计数,或者把重复值放到固定一侧。

import java.util.ArrayList;
import java.util.List;

public class BinarySearchTreeDemo {

    private Node root;

    private static class Node {
        private int value;
        private Node left;
        private Node right;

        private Node(int value) {
            this.value = value;
        }
    }

    public void insert(int value) {
        root = insert(root, value);
    }

    private Node insert(Node node, int value) {
        if (node == null) {
            return new Node(value);
        }
        if (value < node.value) {
            node.left = insert(node.left, value);
        } else if (value > node.value) {
            node.right = insert(node.right, value);
        }
        return node;
    }

    public boolean contains(int value) {
        Node current = root;
        while (current != null) {
            if (value == current.value) {
                return true;
            }
            current = value < current.value ? current.left : current.right;
        }
        return false;
    }

    public void delete(int value) {
        root = delete(root, value);
    }

    private Node delete(Node node, int value) {
        if (node == null) {
            return null;
        }

        if (value < node.value) {
            node.left = delete(node.left, value);
        } else if (value > node.value) {
            node.right = delete(node.right, value);
        } else {
            if (node.left == null) {
                return node.right;
            }
            if (node.right == null) {
                return node.left;
            }

            Node successor = min(node.right);
            node.value = successor.value;
            node.right = delete(node.right, successor.value);
        }

        return node;
    }

    private Node min(Node node) {
        while (node.left != null) {
            node = node.left;
        }
        return node;
    }

    public List<Integer> inOrder() {
        List<Integer> values = new ArrayList<>();
        inOrder(root, values);
        return values;
    }

    private void inOrder(Node node, List<Integer> values) {
        if (node == null) {
            return;
        }
        inOrder(node.left, values);
        values.add(node.value);
        inOrder(node.right, values);
    }

    public static void main(String[] args) {
        BinarySearchTreeDemo tree = new BinarySearchTreeDemo();
        int[] values = {8, 3, 10, 1, 6, 14, 4, 7, 13};

        for (int value : values) {
            tree.insert(value);
        }

        System.out.println(tree.contains(6)); // true
        System.out.println(tree.inOrder());   // [1, 3, 4, 6, 7, 8, 10, 13, 14]

        tree.delete(3);
        System.out.println(tree.inOrder());   // [1, 4, 6, 7, 8, 10, 13, 14]
    }
}

4. BST 适合什么场景

BST 适合理解有序结构和树形查找,但普通 BST 在生产里有明显风险:数据插入顺序不理想时会退化。

因此实际开发中,如果你需要有序映射或范围查询,通常直接使用基于平衡树的 TreeMapTreeSet,而不是手写普通 BST。


七、红黑树(Red-Black Tree)

1. 为什么需要红黑树

普通 BST 的问题是可能退化成链表。红黑树就是为了解决这个问题:它通过颜色规则和旋转操作,让树在插入、删除后仍然保持近似平衡。

红黑树不是“绝对平衡”的树。它不像 AVL 树那样严格要求左右子树高度差很小,而是用相对宽松的规则换取更少的旋转成本。

2. 红黑树的核心规则

红黑树是一棵二叉搜索树,同时满足以下规则:

  • 每个节点要么是红色,要么是黑色;
  • 根节点是黑色;
  • 所有叶子空节点可以看作黑色;
  • 红色节点不能有红色子节点,也就是不能出现连续两个红节点;
  • 从任意节点到其所有后代叶子空节点的路径上,黑色节点数量相同。

这些规则保证红黑树不会过度倾斜,因此查询、插入、删除都能维持 O(log n)

3. 红黑树如何保持平衡

插入或删除节点后,红黑树可能破坏规则。修复手段主要有两类:

  • 变色:调整节点颜色;
  • 旋转:改变局部父子关系,包括左旋和右旋。

左旋示意:

    x                 y
     \               / \
      y     ->      x   c
     / \             \
    b   c             b

右旋示意:

      y             x
     /             / \
    x      ->     a   y
   / \               /
  a   b             b

旋转不会破坏二叉搜索树的有序性,只是改变树的形态。

4. Java 中的红黑树:TreeMap 与 TreeSet

Java 标准库中的 TreeMap 是基于红黑树的 NavigableMap 实现。它按 key 的自然顺序或构造时传入的 Comparator 排序,并保证 containsKeygetputremove 等操作的 log(n) 时间成本。

TreeSet 底层也可以理解为基于有序树结构来维护元素顺序。

5. 示例:用 TreeMap 做范围查询

import java.util.Map;
import java.util.NavigableMap;
import java.util.TreeMap;

public class TreeMapRedBlackTreeDemo {

    public static void main(String[] args) {
        TreeMap<Integer, String> events = new TreeMap<>();
        events.put(900, "start work");
        events.put(930, "daily meeting");
        events.put(1030, "code review");
        events.put(1400, "release");

        Map.Entry<Integer, String> floor = events.floorEntry(1000);
        Map.Entry<Integer, String> ceiling = events.ceilingEntry(1000);
        NavigableMap<Integer, String> morning = events.subMap(900, true, 1200, true);

        System.out.println(floor);   // 930=daily meeting
        System.out.println(ceiling); // 1030=code review
        System.out.println(morning); // {900=start work, 930=daily meeting, 1030=code review}
    }
}

6. 红黑树适合什么场景

红黑树适合既要查询,又要维护顺序的场景:

  • 有序 Map;
  • 有序 Set;
  • 排行榜;
  • 时间线索引;
  • 范围查询;
  • 查找小于等于某值的最大 key;
  • 查找大于等于某值的最小 key。

如果只需要根据 key 精确查询,HashMap 通常更快;如果还需要有序遍历和范围查询,TreeMap 更合适。


八、B-Tree

1. B-Tree 的核心思想

B-Tree 是一种多路平衡搜索树。它不是二叉树,一个节点可以保存多个 key,也可以有多个子节点。

一个 B-Tree 节点大致长这样:

[10 | 20 | 30]
 /     |     |     \
<10  10-20 20-30  >30

为什么要这么设计?

因为在磁盘、SSD、数据库页、文件系统索引这类场景中,一次读取通常不是只读一个整数,而是读取一整页数据。B-Tree 让一个节点容纳多个 key,可以降低树高,从而减少 I/O 次数。

2. B-Tree 的性质

以最小度数 t 的 B-Tree 为例:

  • 每个节点最多有 2t - 1 个 key;
  • 每个非根节点至少有 t - 1 个 key;
  • 每个内部节点的子节点数量等于 key 数量加 1;
  • 节点内部 key 保持有序;
  • 所有叶子节点位于同一层;
  • 查找时根据 key 所在区间决定进入哪个子节点。

3. B-Tree 复杂度

操作复杂度说明
查询O(log n)更准确地说和树高有关,分支越多树越矮
插入O(log n)可能触发节点分裂
删除O(log n)可能触发借位或合并
范围遍历O(log n + k)先定位起点,再顺序取 k 个元素

B-Tree 的价值不是把理论复杂度从 O(log n) 变成 O(1),而是显著降低树高,减少磁盘或页读取次数。

4. 插入和删除的基本动作

B-Tree 插入时:

  • 先找到应该插入的叶子节点;
  • 如果节点未满,直接插入并保持节点内有序;
  • 如果节点已满,就把中间 key 提升到父节点,把原节点拆成左右两个节点;
  • 分裂可能继续向上传播,甚至导致根节点分裂。

B-Tree 删除时更复杂:

  • 如果删除叶子节点中的 key,直接删除或做局部修复;
  • 如果删除内部节点中的 key,通常用前驱或后继替换;
  • 如果某个节点 key 数量太少,需要向兄弟节点借 key;
  • 如果兄弟也不够借,就合并节点。

5. 示例:B-Tree 节点内查找核心逻辑

完整 B-Tree 插入和删除实现较长,业务开发一般不建议手写。下面只展示“节点内查找 + 选择子树”的核心逻辑,用于理解 B-Tree 如何定位 key。

public class BTreeSearchDemo {

    private static final class Node {
        private final int[] keys;
        private final Node[] children;
        private final boolean leaf;

        private Node(int[] keys, boolean leaf, Node... children) {
            this.keys = keys;
            this.leaf = leaf;
            this.children = children;
        }

        private boolean contains(int target) {
            int index = 0;
            while (index < keys.length && target > keys[index]) {
                index++;
            }

            if (index < keys.length && target == keys[index]) {
                return true;
            }

            if (leaf) {
                return false;
            }

            return children[index].contains(target);
        }
    }

    public static void main(String[] args) {
        Node left = new Node(new int[]{1, 5, 8}, true);
        Node middle = new Node(new int[]{12, 17}, true);
        Node right = new Node(new int[]{21, 25, 30}, true);
        Node root = new Node(new int[]{10, 20}, false, left, middle, right);

        System.out.println(root.contains(17)); // true
        System.out.println(root.contains(19)); // false
    }
}

6. B-Tree 与 B+Tree 的区别

数据库索引里更常听到的是 B+Tree。二者常见区别如下:

对比项B-TreeB+Tree
数据保存位置内部节点和叶子节点都可能保存数据数据通常集中在叶子节点
范围查询可以做,但叶子不一定天然串联叶子节点通常通过链表连接,更适合范围扫描
内部节点既做索引,也可能保存数据主要做索引
常见使用文件系统、索引结构数据库索引更常见

实际数据库实现会有页结构、压缩、MVCC、锁、日志、缓存等额外设计,不能把教科书 B+Tree 和某个数据库实现简单画等号。

7. B-Tree 适合什么场景

B-Tree 适合大规模、有序、面向块存储的数据:

  • 数据库索引;
  • 文件系统索引;
  • 存储引擎;
  • 磁盘页索引;
  • 需要范围扫描的大数据量有序结构。

在普通 Java 应用层,如果只是维护内存里的有序 Map,优先用 TreeMap。如果需要持久化索引,通常交给数据库、搜索引擎或成熟存储库。


九、位图(Bitmap)

1. 位图的核心思想

位图用一个 bit 表示一个整数是否存在。

例如,要表示用户 ID 是否出现过:

bit[0] 表示 ID 0 是否存在
bit[1] 表示 ID 1 是否存在
bit[2] 表示 ID 2 是否存在
...

如果某个 ID 出现,就把对应 bit 置为 1;没有出现就是 0。

2. 位图为什么省内存

如果用 boolean[] 存 1 亿个状态,理论上每个 boolean 至少占 1 字节级别空间,还不算对象头等开销。

如果用位图,1 个状态只需要 1 bit:

100,000,000 bit / 8 = 12,500,000 byte,约 11.92 MiB

这就是位图的优势:当 key 是非负整数,并且范围相对可控时,它非常省内存。

3. Java 中的 BitSet

Java 标准库提供了 BitSet

  • bit 下标必须是非负整数;
  • 默认所有 bit 都是 false
  • 可以动态增长;
  • 支持 setgetclearandorxor 等操作;
  • 不是线程安全容器,多线程共享时需要外部同步。

4. 示例:去重、成员判断、交集

import java.util.BitSet;

public class BitSetDemo {

    public static void main(String[] args) {
        BitSet activeUsers = new BitSet();
        int[] loginUserIds = {1, 2, 5, 7, 7, 9};

        for (int userId : loginUserIds) {
            activeUsers.set(userId);
        }

        System.out.println(activeUsers.get(7));     // true
        System.out.println(activeUsers.get(8));     // false
        System.out.println(activeUsers.cardinality()); // 5

        BitSet paidUsers = new BitSet();
        paidUsers.set(2);
        paidUsers.set(7);
        paidUsers.set(10);

        BitSet activeAndPaid = (BitSet) activeUsers.clone();
        activeAndPaid.and(paidUsers);

        for (int userId = activeAndPaid.nextSetBit(0);
             userId >= 0;
             userId = activeAndPaid.nextSetBit(userId + 1)) {
            System.out.println(userId); // 2, 7
        }
    }
}

5. 位图适合什么场景

位图适合处理整数集合:

  • 用户签到;
  • 活跃用户标记;
  • 去重;
  • 黑名单、白名单;
  • 权限位;
  • 标签集合;
  • 交集、并集、差集;
  • 大范围整数是否存在。

6. 位图的限制

位图不是万能的:

  • 只适合整数下标,字符串需要先映射成整数;
  • 只适合范围相对可控的数据,如果最大 ID 极大但实际数据很稀疏,可能浪费空间;
  • 默认只能表示“有没有”,不能表示次数;
  • 负数需要做偏移映射;
  • 位图是精确结构,和 Bloom Filter 不同。Bloom Filter 更省空间,但存在误判概率。

十、八种数据结构对比速查

数据结构核心顺序查询插入删除典型 Java 实现适合场景
后进先出只能看栈顶 O(1)栈顶 O(1)栈顶 O(1)ArrayDeque撤销、括号匹配、DFS
队列先进先出只能看队头 O(1)队尾 O(1)队头 O(1)ArrayDeque、阻塞队列任务排队、BFS、生产者消费者
链表节点连接顺序O(n)已知位置 O(1)已知位置 O(1)LinkedList局部插入删除、双端队列
散列表无稳定排序保证平均 O(1)平均 O(1)平均 O(1)HashMapHashSetkey-value 查询、缓存、去重
排序二叉树key 有序平均 O(log n),最坏 O(n)平均 O(log n)平均 O(log n)通常手写用于学习理解树形查找、中序排序
红黑树key 有序O(log n)O(log n)O(log n)TreeMapTreeSet有序 Map、范围查询
B-Tree多路有序O(log n)O(log n)O(log n)通常由数据库/存储库实现数据库索引、文件系统索引
位图整数下标顺序O(1)O(1)O(1)BitSet整数集合、状态标记、集合运算

十一、怎么选择合适的数据结构

可以按问题特征来选:

问题特征推荐数据结构
最近加入的先处理
先来的任务先处理队列
需要频繁在头尾操作ArrayDeque 或链表
需要按下标随机访问数组或 ArrayList
需要根据 key 快速查 valueHashMap
需要去重且不关心顺序HashSet
需要按 key 排序遍历TreeMap
需要范围查询TreeMap、数据库索引
数据量大且在磁盘/页上组织B-Tree 或 B+Tree
整数范围可控,只判断存在性位图

再给几个判断口径:

  • 如果只要精确 key 查询,优先考虑散列表;
  • 如果既要查询又要有序,优先考虑红黑树实现,例如 TreeMap
  • 如果只在一端进出,考虑栈;
  • 如果一端进另一端出,考虑队列;
  • 如果要处理密集整数集合,考虑位图;
  • 如果数据在数据库里,不要把应用层集合当数据库索引用。

十二、常见面试追问

1. HashMap 为什么平均是 O(1)

因为哈希函数能把 key 均匀分散到不同 bucket 时,定位 bucket 的成本接近常数,bucket 内需要比较的元素也很少。

但这个结论依赖哈希分布。大量 key 落到同一个 bucket 时,性能会下降。

2. HashMap 和 TreeMap 怎么选

只需要按 key 精确查找,通常选 HashMap

需要 key 有序、范围查询、找最近的较大或较小 key,选 TreeMap

3. ArrayDeque 和 LinkedList 都能做队列,怎么选

普通队列优先选 ArrayDeque。它基于数组,缓存局部性好,额外对象少。

LinkedList 每个元素都要一个节点对象,内存和 GC 成本更高。只有在你确实需要链表节点特性时,才优先考虑链表。

4. 红黑树和普通 BST 的区别

普通 BST 不保证平衡,最坏会退化成链表。

红黑树通过颜色规则、变色和旋转保持近似平衡,能保证查询、插入、删除是 O(log n)

5. B-Tree 为什么适合数据库索引

因为 B-Tree 是多路树,一个节点能容纳多个 key,树高低,磁盘页读取次数少。数据库更关心 I/O 次数,而不仅仅是 CPU 比较次数。

6. 位图和 HashSet 有什么区别

HashSet<Integer> 保存的是整数对象或包装后的结构,通用但内存更重。

位图用一个 bit 表示一个整数是否存在,内存非常省,但要求整数范围相对可控,且默认只能表达存在与否。


十三、总结

数据结构的选择,本质上是在时间、空间、顺序、范围查询、实现复杂度之间做权衡。

  • 栈和队列是最基础的访问顺序控制;
  • 链表强调节点连接,适合局部插入删除,但随机访问弱;
  • 散列表追求平均 O(1) 的 key 查询;
  • 排序二叉树用于理解有序树,但生产中容易被平衡树替代;
  • 红黑树在有序性和更新成本之间取得平衡;
  • B-Tree 面向页和磁盘,是数据库索引的重要基础;
  • 位图用极低空间成本表达整数集合。

实际 Java 开发中,不要急着手写复杂结构。优先掌握标准库的能力和边界:ArrayDequeHashMapHashSetLinkedListTreeMapTreeSetBitSet。真正需要自研数据结构时,再根据读写比例、数据规模、并发要求和内存预算做设计。


参考资料

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐