Tree

树(Tree)

[TOC]

树的基本概念

树是一种非常重要的非线性数据结构,树的一个节点可能会生出多个分支。一般而言,一棵树会包含一个根节点,向下延伸出若干子节点,每个末端的节点被称为叶子节点。

有根树

有根树存在一个根节点Root,如下:

对于图中概念的一些补充:

  • 节点拥有的子节点个数叫做节点的
  • 具有相同深度的节点处于同一层,方便表示。
  • 节点和节点之间的线叫做
  • 路径:指从树上一点到另外一点所经过的不重合的点和边的集合,题目中有时会单指点或边的集合。
  • 一颗 $n$ 个节点的树,一定有 $n-1$ 条边

无根树


二叉树

二叉树是一种特殊的树

  • 所有节点的度都不超过2的树称为二叉树。
  • 因为每个二叉树的节点最多只会有两个子结点,它的两个子节点一般会被称为左、右儿子,两棵子树一般会被称为左、右子树。
  • 左、右儿子甚至根节点本身都有可能缺失(一个节点都没有可以称为空二叉树)。

满二叉树和完全二叉树

二叉树也有两个比较特殊的类型:满二叉树和完全二叉树

  • 满二叉树:所有层的节点全满。
    • 满二叉树的一些规律
      • 第 $n$ 层的节点个数为 $2^{n-1}$
      • 深度为 $n$ 的满二叉树节点数为 $2^0 + 2^1 + 2^2 + \dots + 2^{n-1}= 2^n-1$
  • 完全二叉树:除了最后一层以外,其他层的节点个数全满,而且最后一层的节点从左到右排满直到最后一个节点。
    • 完全二叉树的一些规律
      • 完全二叉树的节点个数不会少于 $(2^{n-1}-1)+1 = 2^{n-1}$
      • 完全二叉树的节点个数不会多于 $2^{n} - 1$
      • 一棵完全二叉树,设当前节点为 $t$,其父节点为 $t/2$,其左儿子为 $2t$,其右儿子为 $2t+1$,借助该规律,我们可以将完全二叉树使用数组进行存储。
  • 完全二叉树的存储
    • 完全二叉树由于它的特性,可以简单用数组来模拟其结构
    • 一般会以数组$[1]$位置为根节点建立二叉树
    • 数组$[t]$位置的左儿子和右儿子对应的位置分别为$[2t]$和$[2t+1]$,父节点的位置为$[t/2]$。
    • 堆、线段树等数据结构的建立也会参考这个方式

完全二叉树的建立(使用数组),使用这种方法建立非完全二叉树,会导致空间的浪费:

1
2
3
4
5
6
7
8
void build(int t) {
// 添加数据
UpdateData(t);

// 如果子节点存在
Build(2 * t);
Build(2 * t + 1);
}

为了解决这个问题,我们可以使用其他方法来完成一般二叉树的存储,可以用数组下标模拟节点编号,用多个数组来记录节点信息。为了方便,我们也可以使用结构体来存储这些信息:

1
2
3
4
5
// 使用结构体来实现上述操作
struct TreeNode {
int value;
int l, r, fa;
}a[100010];

当然,作为一种树形结构,使用指针显然是更合适的方法:

1
2
3
4
5
6
7
8
9
// 使用指针来实现上述操作
struct TreeNode {
int value;
TreeNode* l;
TreeNode* r;
TreeNode* fa;
};

TreeNode* root;

使用指针的一些操作:

  • 新建节点:
    1
    2
    3
    4
    5
    6
    7
    struct TreeNode {
    int value;
    TreeNode *l, *r, *fa; // 初始为 NULL
    TreeNode(int x){ value = x; }
    };

    TreeNode* treeNode = new TreeNode(x);
  • 根节点初始化:
    1
    2
    TreeNode* root;
    root = new TreeNode(v);
  • 插入节点:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    void Insert(TreeNode* fa, TreeNode* p, int flag){
    // flag = 0 插入到左边
    // flag = 1 插入到右边
    if (!flag)
    fa->l = p;
    else
    fa->r = p;
    p->fa = fa;
    }
    TreeNode* treeNode = new TreeNode(v);
    Insert(fa, treeNode, flag);
  • 删除节点
    1
    // 删除节点

二叉树的遍历

二叉树的遍历可分为先序遍历、中序遍历和后序遍历,这三种方式以访问根节点的时间来区分。
先序遍历(Degree-Left-Right, DLR):根→左→右
中序遍历(Left-Degree-Right, LDR):左→根→右
先序遍历(Left-Right-Degree, LRD):左→右→根

在该图中,先序遍历的结果为 1 2 4 5 3 6 7,先序遍历代码如下:

1
2
3
4
5
6
7
void preOrder(TreeNode* treeNode) {
cout << p->value << endl;
if(treeNode->l) preOrder(treeNode->l);
if(treeNode->r) preOrder(treeNode->r);
}

preOrder(root);

在该图中,中序遍历的结果为 4 2 5 1 6 3 7,中序遍历代码如下:

1
2
3
4
5
6
7
void inOrder(TreeNode* treeNode) {
if(treeNode->l) inOrder(treeNode->l);
cout << p->value << endl;
if(treeNode->r) inOrder(treeNode->r);
}

inOrder(root);

在该图中,后序遍历的结果为 4 5 2 6 7 3 1,后序遍历代码如下:

1
2
3
4
5
6
7
void postOrder(TreeNode* treeNode) {
if(treeNode->l) postOrder(treeNode->l);
if(treeNode->r) postOrder(treeNode->r);
cout << p->value << endl;
}

postOrder(root);

除了上述的几种遍历方式,还有层级遍历(BFS)方式对树进行遍历。层级遍历是借助队列(Queue)来实现的,其过程可以描述如下:

层级遍历的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TreeNode* q[N];
void bfs(TreeNode* root) {
int front = 1, rear = 1;
q[1] = root;
while (front <= rear) {
TreeNode* p = q[front]; // 选取队列中最前面的节点
front++;
cout << p->value <<endl;
if(p->l) q[++rear] = p->l;
if(p->r) q[++rear] = p->r;
}
}

bfs(root);

计算节点的深度

我们可以在遍历树的时候同时进行节点深度的记录,简单来讲就是:
$$depth_{儿子} = depth_{父亲} + 1$$

有根树(Tree)

这里不再是二叉树这种特殊的树,而是一般意义的树。

树的存储方式

  • vector/链表
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // vector 方式
    vector<int> nodes[N + 1];
    int n, father[N + 1];

    // 在 x 和 y之间构建一条边
    void addEdge(int x, int y) {
    nodes[x].push_back(y);
    }

    // 遍历 x 的所有儿子
    int l = nodes[x].size();
    for (int i = 0; i < l; i++) {
    nodes[x][i];
    }

    for (auto i: nodes[x]) {
    ...
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    // 链表方式
    struct Node {
    int where;
    Node *next;
    } *head[N + 1], a[M];

    int n, father[N + 1], l = 0;

    void addEdge(int x, int y) {
    a[++i].where = y;
    a[l].next = head[x];
    head[x] = &a[l];
    }

    // 遍历 x 的所有儿子
    for (Node* p = head[x]; p; p->next) {
    p->where;
    }

有根树遍历

遍历一棵树一般有 DFS 和 BFS 两种方式。
DFS:深度优先搜索,从一个节点开始,选择一条路径并走到底,并通过回溯来访问所有节点。
BFS:广度优先搜索,也称层级顺序探索,从一个节点开始,遍历该节点的所有子节点,或称按照深度从小到大的顺序依次遍历所有点。

  • 有根树的DFS序
    有根树的 DFS 序是指,从根节点开始的深度优先搜索过程中,依次记录的点所生成的序列。
    对于上图,所生成的 DFS 序即为 ABCDEFGHIJKLMN。当然这个只是其中一种 DFS 序,因为 A 可以走向 B,也可以走向 E,当然也可以走向 F。不同的走向会有不同的 DFS 序。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    vector<int> dfn;      // 用于存储 DFS 序, 常用 DFN 表示 DFS 序
    // dfn 中的元素即为 DFS 序

    void dfs(int x) {
    dfn.push_back(x);
    // for x的所有儿子y { dfs(y); }
    //
    for (Node* p = x; p; p->next){ dfs(p->next) }
    }

    dfs(root);
  • 有根树的BFS序
    有根树的 BFS 序是指,从根节点开始的广度优先搜索过程中,依次记录的点所生成的序列。
    对于上图,所生成的 BFS 序即为 ABENCDFMGJHIKL。当然这个只是其中一种 BFS 序,因为同一深度可能会有不同的遍历顺序,如深度为 $2$ 时,BENBNEEBN、…都是可能出现的顺序,不同的顺序会有不同的 BFS 序。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    void bfs(int root) {
    // 将 root 加入队列 q;
    q.push(root);

    // 遍历队列 q
    while(队列 q 非空) {
    x = q.top(); // 取队首元素
    q.pop(); // x 出队
    for x的所有儿子y {
    y 入队;
    }
    }
    }

无根树(Unrooted Tree)

无根树即没有固定根结点的树,树中的节点只有相邻关系而没有父子关系。无根树有几种等价的形式化定义(建议搭配图论一起学习):

  • 有 $n$ 个结点, $n−1$ 条边的连通无向图
  • 无向无环的连通图
  • 任意两个结点之间有且仅有一条简单路径的无向图
  • 任何边均为桥的连通图
  • 没有圈,且在任意不同两点间添加一条边之后所得图含唯一的一个圈的图

如下图所示,即一棵无根树:

无根树中的任意一个节点可以被指定为根,变成一棵有根树。

无根树的遍历

遍历一棵无根树一般也有 DFS 和 BFS 两种方式。
遍历无根树时,可以从任意一个节点开始,以类似有根树的方式,遍历整棵树。唯一的区别是在进入一个新节点时,需要记录这个节点的来源节点,在遍历新节点的相邻节点时,避免重复访问来源节点即可。

  • 无根树的 DFS
    1
    2
    3
    4
    5
    6
    7
    8
    9
    void dfs(int from, int x) {
    for x的所有响铃节点y {
    if (y != from) {
    dfs(x, y);
    }
    }
    }

    dfs(-1, x);
  • 无根树的 BFS
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    void bfs(int x) {
    // 将 x 加入队列 q,x 的来源为空
    while (队列 q 非空) {
    x = q.top();
    from = x的来源节点;
    q.pop;
    for x的所有相邻节点 y {
    if (y != from) {
    y 入队;
    记录 y 的来源节点为 x;
    }
    }
    }
    }

    bfs(x);

树的直径

  • 树的直径是指树上任意两个节点之间最长(路径的长度一般指的是路径经过的边的数量)的路径。
  • 一棵树可以存在很多条直径,他们的长度相等。
  • 树的直径的中间节点被称为树的中心(图中C节点),如果直径上有偶数个节点,那么中间的两个节点都可以是树的中心。
  • 树的中心到其它点的最长路径最短。

Hash

哈希(Hash)

[TOC]

哈希的基本概念

  • 哈希(Hash)在我的理解中是一种映射关系,例如,将学生映射到学号上、将口红的颜色映射到一个色号上。
  • 哈希函数(Hash Function)就是将一种你想要查询的关键字,比如说姓名、手机号码、数字或者字符串等,映射成便于查找的东西(一般来说是一个数字)的函数。
  • 一般而言,一个好的哈希函数可以帮我们将我们想要查找的东西,从一个较大集合映射到一个较小集合,然后我们可以将这个较小集合中的信息存储下来,例如存入一个数组,这个用于存储较小集合的数组就称之为哈希表
    • 一张格式如下的表:
      学号 姓名
      0001 张三
      0002 李四
      0003 王五
      0004 赵六
    • 这张表就可以理解为一个姓名和学号的哈希表,我们通过学号就可以获得学号对应的人的姓名,即:学号[0001] -> "张三",反映到代码中,可以理解为一个一维数组通过下标直接访问。
  • 而在一些加密工作中,可能需要将要简单的组合复杂化,比如密码组合,这时会有一种类似反向哈希(加密)的过程。
  • 比较常见的哈希函数的例子:
    $$H(x) = x ; mod ; 11$$
  • 这个函数可以让我们将任意的整数映射到 0 ~ 10 的范围中

哈希的基本操作

  • 定义哈希函数
    1
    2
    3
    4
    5
    6
    7
    const int modnum = 11;
    int hashTable[modnum];

    // 定义哈希函数
    int hash(int x) {
    return x % modnum;
    }
  • 在哈希表中插入元素
    1
    2
    3
    4
    5
    // 在哈希表中插入元素
    void insert(int x) {
    int addr = hash(x);
    hashTable[addr] = x;
    }
  • 在哈希表中查找元素
    1
    2
    3
    4
    5
    // 在哈希表中查找元素
    bool isExist(int x) {
    int addr = hash(x);
    return hashTable[addr] == x;
    }

哈希表中解决冲突的方式

不同关键字通过相同哈希函数计算出相同的哈希地址,该种现象称为哈希冲突或哈希碰撞,或者称为哈希冲突。
哈希表在存放数据时有可能出现冲突的情况,以上文中的哈希函数为例我们分别向其中插入元素,如下:

因为希望哈希表底层数组的容量小于实际要存储的关键字的数量,这就会导致一个问题:冲突的发生是必然的,我们能做的是尽量的降低冲突率

冲突的解决方式一般有以下两种:

方式 1:顺延

一种很好想到的解决方式是将哈希表中插入时产生冲突的元素向后顺延,直到找到一个空位置再进行插入。这种方式称为顺延,也有说法称之为线性探测

此时插入和查找的代码也要发生相应的改变,插入时需要我们需要找到一个空位置来执行插入操作;相对应的查找方式也要做出改变,当我们查询一个数时,也要查询哈希函数对应的位置,并依次比较连续的非空的哈希表中的值:

  • 插入操作
    1
    2
    3
    4
    5
    6
    7
    void insert(int x) {
    int addr = hash(x);
    while(hashTable[addr] NOT NULL) { // 当哈希表的插入位置不为空
    addr = (addr + 1) % modnum;
    }
    hashTable[addr] = x;
    }
  • 查找操作
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void isExist(int x) {
    int addr = hash(x);
    while(hashTable[addr] NOT NULL) { // 当哈希表的查询位置不为空(查询一段连续的哈希表)
    if(hashTable[addr] == x) { // 如果查询到指定元素, 返回 true
    return true;
    }
    addr = (addr + 1) % modnum;
    }
    return false;
    }
    可以定义多种解决冲突的顺延函数,即addr = (addr + 1) % modnum,实际使用中可以是每次$+k$,或者$+1^2$,$+2^2$,$+3^2$,…等。

但是这种顺延方式会存在一定的问题:插入时可能会出现多次冲突,当哈希表即将满的时候,插入操作的冲突可能会出现的更多,此时插入和查询操作都会变成一个非常耗时的操作。

方式 2:哈希链表

我们可以通过链表的方式,来实现在一个位置放置多个元素的操作。在 C++ 的 STL 库中,我们可以使用 STL 库中提供的 vector 来简单的替代链表。
通过这种方式,每次查找元素时,先找到对应的链表头,然后遍历这个位置的整张链表即可。

此时的哈希表的定义、插入操作和查询操作要发生相应的变化:

  • 定义哈希函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #include <iostream>
    #include <vector>

    const int modnum = 11;
    vector<int> hashTable[modnum];

    // 定义哈希函数
    int hash(int x) {
    return x % modnum;
    }
  • 插入操作

    1
    2
    3
    4
    void insert(int x) {
    int addr = hash(x);
    hashTable[addr].push_back(x);
    }
  • 查询操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    void isExist(int x) {
    int addr = hash(x);
    int tableSize = hashTable[addr].size();
    // 这里不使用 for(int i = 0; i < hashTable[addr].size(); i++) 的写法, 而是首先计算出 hashTable[addr].size()
    // 因为 vector 的 size() 是一个比较耗时的操作, 他是通过将 vector 按照一个一个数出来的方式来进行计数的
    // 在数据量小的时候可能并不明显, 当数据量大的时候可能就会出现较为严重的耗时问题
    for(int i = 0; i < tableSize; i++) {
    if(hashTable[addr][i] == x) {
    return true;
    }
    }
    return false;
    }

    但是这种方式还是不能彻底解决我们的问题。对于插入操作来说,时间复杂度可以看作是$O(1)$,对于查询操作来说,时间复杂度和其冲突次数相关联。

哈希函数的设计

面对上面的问题,设计好哈希函数才是解决问题的关键。哈希函数在设计的时候,一般要注意几个原则:

  • 设计哈希函数时,我们需要尽可能让查询的值均匀地存储在哈希表中,或者说尽量分散再哈希表中。
  • 在手搓哈希函数时,我们会要求 $H(x) = x ; mod; p$,其中的 $p$ 为素数
  • 哈希函数中的对 $p$ 取摸的操作,会使得哈希值落在 $0 <= value <= p-1$ 的范围内,这个哈希表的长度 $p$,一般被称为哈希表的容量(Capacity)
  • 插入哈希表的元素总数除以哈希表的容量得到的数,称为负载因子,这里可以用 $\alpha$ 表示,即:
    $$\alpha = ElumNum \div p$$
  • 当负载因子$\alpha$达到一定程度时(一般认为是$0.7\sim0.8$),则说明再对哈希表继续进行操作,就会面临大量冲突的情况,这时就要考虑增大哈希表的容量以避免出现更多的冲突。
  • 哈希函数的冲突率和负载因子的关系一般如下:

字符串哈希

字符串哈希是学习或者工作中经常遇到的一种操作,常用于比较两组字符串的内容等操作。通过比较两个字符串的哈希值就可以完成字符串的比较。当然这个操作也可以用于数组的哈希,因为字符串本质就是一个字符数组。
$$s = s_1s_2s_3 \dots s_n\qquad s_i \in a, b \dots z$$

一个字符串 $s$ 由 $n$ 个字符组成,每个字符 $s_i$ 属于 $a \sim z$。
其哈希函数为:
$$\begin{aligned}H(S)
&=(\sum_{i=1}^{n} c_i × base^{n-i});mod ;p\
&=(c_1 × base ^ {n-1} + c_2 × base ^ {n-2} + \dots + c_{n-1} × base ^ {1}) ; mod ; p\
&=base^{n-(n-1)}(base\dots(base(base × c_1 + c_2)));mod ;p\
&=base^{1}(base\dots(base(base × c_1 + c_2)+c_3)+\dots + c_n)\end{aligned};mod ;p$$

其中 $c_i$ 是一个和 $s_i$ 有关的数字,我们可以将字符映射到数字,例如:$a → 1$、$b → 2$ 等。这里不将 a 映射为 0,因为如果将 a 映射为 0,字符串 aab 的哈希值是相等的。
$base$ 是一个可以自己指定的数字,其值一般是大于字符集中的字符数量($c_i$的最大值)的素数,这里可以取 31,常见的选择是 9999971
$p$ 是一个素数,常见的选择是 101137

用代码实现为:

1
2
3
4
5
6
7
8
int hash(char s[], int n) {
int res = 0;
for(int i = 0; i < n; i++) {
// 为什么是 res * base, 见上文的描述的公式推导
res = (res * base + (s[i] - 'a' + 1)) % p;
}
return res;
}

当 $base$ 为 31,$p$ 为 101 时。
当 $s$ 为 $a$ 时,hash(char s[], int n) 的值为 1。
过程可以描述如下:

1
2
[1] res = (0 * base + ('a' - 'a' + 1)) % p
>>> res = 1

当 $s$ 为 $ab$ 时,hash(char s[], int n) 的值为 1。
过程可以描述如下:

1
2
3
4
[1] res = (0 * base + ('a' - 'a' + 1)) % p
>>> res = 1
[2] res = (1 * base + ('b' - 'a' + 1)) % p
>>> res = 33

当我们定义好一个良好的哈希函数之后,因为哈希函数的值相等的概率比较小,当两个字符串的哈希值相同的时候,我们可以认为两个字符串也相同,从而避免使用按位比较,节约时间。

$base$ 为什么一般是一个大于字符集中的字符数量($c_i$的最大值)的素数呢?
当 $base$ 值过小时,此处假定为 $11$,会出现有可能两个字符运算完后还没有超过字母表的范围。如:字母表的范围为 $1 \sim 26$,字符串 ba 运算完成后的结果为 $23$,两个字符串的运算结果小于 $26$,字符串w运算完成后的结果为 $23$,可以发现 baw 出现了冲突。

我们字符串的定义方式有一种好处,可以使得我们借助类似前缀和的思想,快速得到任意一段字符串 $s$ 的字串的哈希值。

  • 我们可以使用一个数组 $a$ 来记录我们计算 hash(s) 的中间过程,即:
    1
    2
    3
    4
    a[1] = hash(c1)
    a[2] = hash(c1c2)
    ...
    a[i] = hash(c1c2...ci)
    那么字符串 $s$ 的任意子串 $s_{l, r} = s_ls_{l+1}\dots s_r$ 的哈希值为:
    $$hash(s_{l, r}) = (a[r] - a[l-1] × base^{r - l + 1});mod;p$$

双哈希

冲突往往是不可避免的,纵使我们的哈希函数再好,也有可能会出现冲突的情况,那么我们如何尽可能避免这个问题呢?这时候,我们可以使用双哈希的方法来避免冲突。
在上文字符串哈希中,我们的字符串哈希函数为:

$$H(S) = (\sum_{i=1}^{n} c_i × base^{n-i});mod ;p$$

那么如何实现双哈希呢?我们可以选取两组 $base$ 和 $p$,然后使用这分别使用这两组 $base$ 和 $p$,分别来求取哈希值。如果这两组参数得到的哈希值都相同,我们则认为两个字符串相等。在竞赛中,最好书写双哈希,甚至三哈希,因为这样出错误的概率会呈指数级的降低。

STL中的哈希 unordered_map

在 C++ 的STL库中,有一个非常好用的数据结构 unordered_map,可以帮助我们实现大多数哈希表的操作。

1
unordered_map<K, V> hash_table;

其中 K 为我们想要存储的关键字信息,V 表示与他关联的一些信息。例如:

1
unordered_map<string, int> hash_table;

这个表会以string作为Kint作为V进行存储,基于此我们可以很方便的实现一些功能。例如我们现在要保存一个班级上学生的年龄信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <iostream>

using namespace std;

int query(string name) {
if(hash_table.find(name) == hash_table.end()) {
cout << "Cannot Find..." << endl;
}
return hash_table[name];
}

int main(void) {
unordered_map<string, int> hash_table;
hash_table["zhangsan"] = 2000;
hash_table["lisi"] = 2001;
query("zhangsan");

return 0;
}

栈(Stack)

栈(Stack)

栈的基本概念

  • 栈是一种 先进后出(First in Last Out, FILO) 的数据结构,其类似于我们生活中的衣娄,衣服只能从最顶部放入,也只能从最顶部拿出,要想拿出中间的某件衣服,就需要将顶部的衣服全部拿出,再进行后续的操作。

  • 衣娄对应到栈上,就是以下的概念:

    • 栈(Stack) 是一种类似于衣娄的数据结构,我们可以向其内部存入或者取出数据
    • 栈按照 先进后出 的原则存储数据,每次新进入的数据都会被放在最上面,越先进入的越靠下,越后进入的数据越靠上。
    • 我们只能对最上面的数据进行操作
    • 栈的两大元素:栈的大小和栈顶指针Top(该指针指向栈最顶部的位置)

栈的基本操作

  • 新建栈(题目简单时可以用数组模拟栈)
  • 插入数据
  • 删除栈顶数据
  • 查询栈顶数据
  • 清空栈

复习数据结构/算法清单

复习数据结构清单

有日期则表示在该日期已经复习完毕,或者表示复习后的最新一次更新

数据结构 链接 备用链接(Backup Link) 日期
栈(Stack) - - -
队列(Queue) - - -
链表(Link List) - - -
二叉树(Binary Tree) https://hello-nilera.com/2024/05/07/Tree/ https://blog.csdn.net/NilEra/article/details/138624929?spm=1001.2014.3001.5501 2024.05.09
哈希(Hash) - - -

算法复习清单

算法 链接 备用链接(Backup Link) 日期