重拾数据结构(一)找到合适的数据结构之路

背景

在过去学习数据结构的时候,往往得不到要领,总会萌生出为什么会有这么多数据结构,都放在数组里不行吗的疑问,今天我简单地列举了几个数据结构,使用这些结构能让我们更轻松地实现业务逻辑。

以前学习的数据结构

数组

在大学里,我们学习了很多数据结构,开始都很好理解,比如数组,队列。在日常生活中都有具体的例子。
比如,我们的成绩单信息就可以简单地定义为一个数组。

		// 数组地第一项为语文,第二项为数学,第三项为英语,第四项为专业课
		int[] scores = new int[]{90, 89, 70, 80};

		// 总分
		int sum = Arrays.stream(scores).sum();
		// 平均分
		double average = Arrays.stream(scores).average().getAsDouble();
		// 装箱输出
		List<Integer> scoreList = Arrays.stream(scores)
				.boxed()
				.toList();
		System.out.printf("您的各个科目分数(语文,数学,英语,专业课)为:%s, 总分为:%s,平均分为:%s\r\n", scoreList, sum, average);

输出为

您的各个科目分数(语文,数学,英语,专业课)为:[90, 89, 70, 80], 总分为:329,平均分为:82.25

队列

如果我们想定义选课系统已满,想定义候补的位置,可以用队列来实现。

		// 用队列定义一个候补列表
        ArrayDeque<String> waitlist = new ArrayDeque<>(List.of("小张", "小明", "小陈", "小李"));
		// 新的候补, 向队尾新增一个候补人
		waitlist.offerLast("xxx");
		// 候补成功,从队伍前面拿出一个候补人,且从队伍里移除
		String wait = waitlist.pollFirst();
		System.out.printf("恭喜%s同学,你已经候补成功,请查看选好的课。目前该门课程还有如下的人在等待:%s\r\n。", wait, waitlist);

输出为

恭喜小张同学,你已经候补成功,请查看选好的课。目前该门课程还有如下的人在等待:[小明, 小陈, 小李, xxx]。

后面,数据结构提到了树这个概念,当时觉得和文件系统里层层嵌套的文件夹很像,其实背后就是用树的结构实现的。
首先我们定义一棵树

@Data
public class TreeNode {
    /**
     * id
     */
    private Long id;
    /**
     * 父id
     */
    private Long parentId;
    /**
     * 节点名称
     */
    private String name;
    /**
     * 子节点集合
     */
    private List<TreeNode> children;

    public TreeNode(Long id, Long parentId, String name) {
        this.id = id;
        this.parentId = parentId;
        this.name = name;
    }

    /**
     * 新增子节点
     *
     * @param child 子节点
     */
    public void addChild(TreeNode child) {
        if (this.children == null) {
            this.children = new ArrayList<>();
        }

        this.children.add(child);
    }

    /**
     * 更新当前节点的名称
     *
     * @param newName 新名称
     */
    public void updateName(String newName) {
        this.name = newName;
    }

    /**
     * 从当前节点的子节点列表中删除指定的子节点
     *
     * @param id 要删除的子节点id
     * @return 是否删除成功
     */
    public boolean removeChild(Long id) {
        if (this.children == null) {
            return Boolean.FALSE;
        }

        return this.children.removeIf(childrenNode -> Objects.equals(childrenNode.getId(), id));
    }
}

再封装对树的操作

public class TreeUtils {
    /**
     * 将树节点展平为 Stream
     */
    private static Stream<TreeNode> streamOf(TreeNode node) {
        if (node == null) return Stream.empty();
        return Stream.concat(
                Stream.of(node),
                Optional.ofNullable(node.getChildren())
                        .orElse(Collections.emptyList())
                        .stream()
                        .flatMap(TreeUtils::streamOf)
        );
    }

    /**
     * 查找节点:DFS 风格,找到第一个匹配项即停止
     */
    public static TreeNode find(TreeNode root, Predicate<TreeNode> predicate) {
        return streamOf(root)
                .filter(predicate)
                .findFirst()
                .orElse(null);
    }

    /**
     * 通过id查询
     */
    public static TreeNode findById(TreeNode root, Long id) {
        return find(root, node -> Objects.equals(node.getId(), id));
    }

    /**
     * 通过name查询
     */
    public static TreeNode findByName(TreeNode root, String name) {
        return find(root, node -> Objects.equals(node.getName(), name));
    }

    /**
     * 根据节点id移除节点
     */
    public static void removeTreeNode(TreeNode root, TreeNode removeNode) {
        Objects.requireNonNull(root, "根节点不能为空");
        Objects.requireNonNull(removeNode, "待删除的节点不能为空");
        // 待移除的节点
        Long id = removeNode.getId();
        Objects.requireNonNull(id, "待删除的节点id不能为空");
        if (Objects.equals(root.getId(), id)) {
            throw new IllegalArgumentException("根节点不能删除");
        }

        // 查询节点的父节点
        Long parentId = removeNode.getParentId();
        Objects.requireNonNull(parentId, "父节点不能为空");

        // 通过父节点删除子节点
        TreeNode parent = findById(root, parentId);
        if (parent == null) {
            throw new IllegalArgumentException("父节点不在树中: " + parentId);
        }

        boolean removed = parent.removeChild(id);
        if (!removed) {
            throw new IllegalArgumentException("节点不在指定父节点下: " + id);
        }
    }

    /**
     * 展平树:获取所有节点列表
     */
    public static List<TreeNode> flatten(TreeNode root) {
        return streamOf(root).collect(Collectors.toList());
    }

    /**
     * 统计节点数
     */
    public static long count(TreeNode root) {
        return streamOf(root).count();
    }

    /**
     * 计算最大深度
     */
    public static int depth(TreeNode node) {
        if (node == null) return 0;
        return 1 + Optional.ofNullable(node.getChildren())
                .orElse(Collections.emptyList())
                .stream()
                .mapToInt(TreeUtils::depth)
                .max()
                .orElse(0);
    }

    /**
     * 构建根节点的孩子节点
     */
    public static List<TreeNode> buildChild(List<TreeNode> nodes, Long rootId) {
        if (nodes == null || nodes.isEmpty()) {
            return Collections.emptyList();
        }

        Objects.requireNonNull(rootId, "根节点不能为空");

        // 清空原有子节点,避免重复
        nodes.forEach(n -> n.setChildren(new ArrayList<>()));
        Map<Long, TreeNode> map = new HashMap<>();
        for (TreeNode n : nodes) {
            if (map.put(n.getId(), n) != null) {
                throw new IllegalArgumentException("存在重复的节点 id: " + n.getId());
            }

            if (n.getParentId() == null) {
                throw new IllegalArgumentException("存在没有父节点的 id: " + n.getId());
            }
        }

        List<TreeNode> roots = new ArrayList<>();
        for (TreeNode n : nodes) {
            Long pid = n.getParentId();
            TreeNode parent = map.get(pid);
            if (parent != null) {
                parent.addChild(n);
            } else {
                // 父节点不存在,视为根节点
                if (Objects.equals(pid, rootId)) {
                    roots.add(n);
                } else {
                    throw new IllegalArgumentException("存在异常节点:" + formatNode(n));
                }
            }
        }

        return roots;
    }

    /**
     * 打印树
     */
    public static void print(TreeNode root) {
        if (root == null) {
            return;
        }

        buildPrintLines(root, "", true)
                .forEach(System.out::println);
    }

    private static List<String> buildPrintLines(TreeNode node, String prefix, boolean isTail) {
        List<String> lines = new ArrayList<>();
        if (node == null) {
            return lines;
        }

        lines.add(prefix + (isTail ? "└─ " : "├─ ") + formatNode(node));
        List<TreeNode> children = Optional.ofNullable(node.getChildren())
                .orElse(Collections.emptyList());
        String newPrefix = prefix + (isTail ? "   " : "│  ");
        for (int i = 0; i < children.size(); i++) {
            boolean isLast = (i == children.size() - 1);
            lines.addAll(buildPrintLines(children.get(i), newPrefix, isLast));
        }

        return lines;
    }

    public static String formatNode(TreeNode treeNode) {
        Objects.requireNonNull(treeNode, "节点不能为空");
        return String.format("%s(%s)", treeNode.getName(), treeNode.getId());
    }

    // --- 测试主方法 ---
    public static void main(String[] args) {
        // 传统构建
        TreeNode root = new TreeNode(1L, 0L, "用户根文件夹");
        TreeNode n2 = new TreeNode(2L, 1L, "工作文件夹");
        TreeNode n3 = new TreeNode(3L, 1L, "休闲文件夹");
        TreeNode n4 = new TreeNode(4L, 2L, "工程文件夹");
        TreeNode n5 = new TreeNode(5L, 2L, "需求文件夹");

        root.addChild(n2);
        root.addChild(n3);
        n2.addChild(n4);
        n2.addChild(n5);
        System.out.println("--- 传统构建方式 ---");
        print(root);
        // 通过父节点从扁平列表组装
        TreeNode root2 = new TreeNode(1L, 0L, "用户根文件夹");
        List<TreeNode> childNode = buildChild(List.of(
                new TreeNode(2L, 1L, "工作文件夹"),
                new TreeNode(3L, 1L, "休闲文件夹"),
                new TreeNode(4L, 2L, "工程文件夹"),
                new TreeNode(5L, 2L, "需求文件夹")), 1L);
        root2.setChildren(childNode);
        System.out.println("--- 从扁平列表组装 ---");
        print(root2);

        // 测试查询能力
        System.out.println("--- 查找 ID=4的文件夹 ---");
        Optional.ofNullable(findById(root, 4L))
                .ifPresentOrElse(foundDfs -> System.out.println(formatNode(foundDfs)),
                        () -> System.out.println("Not Found"));

        System.out.println("--- 查找 name=工程文件夹的文件夹 ---");
        TreeNode treeNode = findByName(root, "工程文件夹");
        Optional.ofNullable(treeNode)
                .ifPresentOrElse(foundDfs -> System.out.println(formatNode(foundDfs)),
                        () -> System.out.println("Not Found"));

        System.out.println("--- 将 name=工程文件夹的文件夹名称改为交付文件夹 ---");
        Optional.ofNullable(treeNode).ifPresent(foundDfs -> {
            foundDfs.updateName("交付文件夹");
        });

        System.out.println("--- 删除需求文件夹 ---");
        Optional.ofNullable(findByName(root, "需求文件夹")).ifPresent(foundDfs -> {
            removeTreeNode(root, foundDfs);
        });

        System.out.println("--- 打印(将工程文件夹改为交付文件夹,将需求文件夹删除) ---");
        print(root);

        System.out.println("--- 计算文件夹有多少层(树的深度) ---");
        System.out.println(depth(root));

        System.out.println("--- 计算有多少个文件夹(统计节点总数) ---");
        System.out.println(count(root));

        System.out.println("--- 扁平化列表 ---");
        List<TreeNode> flatList = flatten(root);
        System.out.println(flatList.stream().map(TreeUtils::formatNode).collect(Collectors.joining(" -> ")));
    }
}

我们实现了对文件夹的创建,查询,重命名,删除,也统计了文件夹的层数,文件夹的个数。
输出

--- 传统构建方式 ---
└─ 用户根文件夹(1)
   ├─ 工作文件夹(2)
   │  ├─ 工程文件夹(4)
   │  └─ 需求文件夹(5)
   └─ 休闲文件夹(3)
--- 从扁平列表组装 ---
└─ 用户根文件夹(1)
   ├─ 工作文件夹(2)
   │  ├─ 工程文件夹(4)
   │  └─ 需求文件夹(5)
   └─ 休闲文件夹(3)
--- 查找 ID=4的文件夹 ---
工程文件夹(4)
--- 查找 name=工程文件夹的文件夹 ---
工程文件夹(4)
--- 将 name=工程文件夹的文件夹名称改为交付文件夹 ---
--- 删除需求文件夹 ---
--- 打印(将工程文件夹改为交付文件夹,将需求文件夹删除) ---
└─ 用户根文件夹(1)
   ├─ 工作文件夹(2)
   │  └─ 交付文件夹(4)
   └─ 休闲文件夹(3)
--- 计算文件夹有多少层(树的深度) ---
3
--- 计算有多少个文件夹(统计节点总数) ---
4
--- 扁平化列表 ---
用户根文件夹(1) -> 工作文件夹(2) -> 交付文件夹(4) -> 休闲文件夹(3)

开发时遇到的数据结构

在现实情况下,我们很少用一种数据结构,通常是多个数据结构结合使用。

B+树

最典型的是数据库索引常用的B+树(平衡树+链表)。
B+树在树的基础上,做了如下改动:

  • 多路分支降低树高,减少磁盘IO
  • 非叶子节点只存储索引信息,减少数据存储
  • 所有真实数据只存放在叶子节点,且叶子节点大小与磁盘页对齐
  • 叶子节点之间用双向链表连接,范围查询和顺序扫描高效

跳表

在Redis中,常用跳表+哈希表来作为zset的数据结构。
在其中,跳表是一个简单且高效的数据结构,在Redis的实现为多层的链表。

  • 最底层是一个包含所有元素的有序双向链表。
  • 每个节点以 1/4 的概率向上增加层数,层数越高节点越少,形成多级索引。
  • 查询时,通常从上往下,向右移动对比直到下一个节点大于目标,然后下降一层,重复直到找到目标。

最大/小堆

  • 堆是一种部分有序的完全二叉树,通常用数组实现。
  • 当用数组时:下标关系:父节点:(i - 1) / 2,左孩子:2 * i + 1,右孩子:2 * i + 2。
  • 最大堆中任意节点 ≥ 子节点;最小堆中任意节点 ≤ 子节点。
  • 上滤(siftUp) / 下滤(siftDown) 是堆的两个核心操作。通过这两个操作可以将堆转化为最大/小堆。上滤主要用于插入,下滤主要用于删除堆顶和建堆。

遇到 Top K 问题时,最有效的数据结构是最大/小堆。
比如要筛选出1000万数据中,排名最高的10个人,那么,可以维护一个大小为10的最小堆,新元素大于堆顶就进行替换,最后堆中就是最大的10个。
非常适用于总量很大,但是需要求的最大/小值很小的情况。

public class TopK {
    /**
     * 在数组中查询k个最大值
     * @param array 数组
     * @param k TopK
     * @return K个最大值
     */
    public static int[] topK(int[] array, int k) {
        // 小顶堆,堆里始终保留当前最大的 k 个数
        PriorityQueue<Integer> heap = new PriorityQueue<>(k);
        for (int v : array) {
            if (heap.size() < k) {
                heap.offer(v);              // 不足 k 个,直接入堆
            } else if (v > heap.peek()) {   // 比堆顶大,替换掉最小值
                heap.poll();
                heap.offer(v);
            }
        }
        // 堆中即为最大的 k 个数
        return heap.stream().mapToInt(Integer::intValue).toArray();
    }

    public static void main(String[] args) {
        int[] a = {5, 3, 8, 1, 9, 2, 7, 4, 6};
        System.out.println(java.util.Arrays.toString(topK(a, 3)));  // [7, 8, 9]
    }
}

堆排序先原地建最大堆,然后不断把堆顶(当前最大值)与末尾交换,并缩小堆的有效范围,再对堆顶下沉恢复堆序,最终数组升序。

public class HeapSort {
    /**
     * 堆排序
     * @param array
     */
    public static void sort(int[] array) {
        int n = array.length;
        // 建最大堆
        for (int i = n / 2 - 1; i >= 0; i--) {
            siftDown(array, i, n);
        }

        // 循环将堆顶最大值放到末尾,恢复剩余元素的堆序
        for (int end = n - 1; end > 0; end--) {
            swap(array, 0, end);
            siftDown(array, 0, end);
        }
    }

    /**
     * 下滤
     * @param array
     * @param i
     * @param size
     */
    private static void siftDown(int[] array, int i, int size) {
        int v = array[i];
        while (i < size / 2) {
            int child = 2 * i + 1;
            if (child + 1 < size && array[child + 1] > array[child]) child++;
            if (array[child] <= v) break;
            array[i] = array[child];
            i = child;
        }

        array[i] = v;
    }

    /**
     * 交换数组里下标i和下标j的位置
     */
    private static void swap(int[] array, int i, int j) {
        int t = array[i]; array[i] = array[j]; array[j] = t;
    }

    public static void main(String[] args) {
        int[] a = {5, 3, 8, 1, 9, 2, 7, 4, 6};
        sort(a);
        System.out.println(Arrays.toString(a)); // [1, 2, 3, 4, 5, 6, 7, 8, 9]
    }

一览图

数据结构 查找 插入 删除 范围查询 取极值
数组 O(1) 随机访问 O(n) O(n) O(n) O(n)
队列 O(n) O(1) O(1) - -
O(n) O(1) 加子节点 O(n) O(n) -
B+ 树 O(log n) O(log n) O(log n) O(log n + m) -
跳表 O(log n) O(log n) O(log n) O(log n + m) -
O(n) O(log n) O(log n) - O(1)

总结

数据结构是一门平衡的艺术,现实中我们找不到完美的结构,但可以根据现实情况,找到/构造合适的数据结构。

posted @ 2026-09-21 23:27  Kwanwooo  阅读(9)  评论(0)    收藏  举报