重拾数据结构(一)找到合适的数据结构之路
背景
在过去学习数据结构的时候,往往得不到要领,总会萌生出为什么会有这么多数据结构,都放在数组里不行吗的疑问,今天我简单地列举了几个数据结构,使用这些结构能让我们更轻松地实现业务逻辑。
以前学习的数据结构
数组
在大学里,我们学习了很多数据结构,开始都很好理解,比如数组,队列。在日常生活中都有具体的例子。
比如,我们的成绩单信息就可以简单地定义为一个数组。
// 数组地第一项为语文,第二项为数学,第三项为英语,第四项为专业课
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) |
总结
数据结构是一门平衡的艺术,现实中我们找不到完美的结构,但可以根据现实情况,找到/构造合适的数据结构。

浙公网安备 33010602011771号