处理大量数据时,排序可能会花费大量时间。如果不是每次都对数据进行排序,而是直接将它们写入内存中已排序的正确位置,那就太好了。这将使我们能够始终提前知道在哪里搜索它们,例如,从中心开始。
我们会确切地知道去哪里;向左,丢弃右侧的一半数据,或向右,丢弃左侧的一半数据。这意味着每次搜索操作的元素数量将减半。这只会给我们带来快速的 对数复杂度。
但是如果我们真的想把它插入到正确的位置,它会很慢。毕竟,我们首先需要在内存中为所需数量的元素分配新的空间。然后把一部分旧数据复制到那里,然后放新数据,再把剩下的所有元素复制到新的位置。换句话说,我们得到了一个快速的搜索但很慢的插入。
在这种情况下,链表更合适。
其中的插入确实在 O(1) 时间内立即发生,无需任何复制。然而,在我们这样做之前,我们将不得不遍历链表的大部分节点来找到插入的位置,这将再次导致我们的线性复杂度为O(N)。
为了使这些操作更快,发明了二叉搜索树。它是由节点组成的分层数据结构,每个节点存储数据,一个键值对,最多可以有两个孩子。
在简单的二叉树中,数据可以按任意顺序存储。然而,在二叉搜索树中,只能根据允许上述操作快速进行的特殊规则添加数据。
为了理解这些规则,让我们首先为这棵树及其节点创建一个基础。
C#
1个
公共 类 BinarySearchTree < T > 其中 T : IComparable < T >
2个
{
3个
私人 节点? _头;
4个
的
5个
私有 类 节点
6个
{
7
公共 只读 T 值;
8个
私人 节点? _左;
9
私人 节点? _对了;
10
的
11
公共 节点(T 值)
12
{
13
价值 = 价值;
14
}
15
}
16
}
插入新值
那么,让我们来实现一个新值的插入。首先,如果根最初不存在,即树完全是空的,我们简单地添加一个新节点,它成为根。接下来,如果添加的新元素小于我们所在的当前节点,我们递归地向左移动。如果它大于或等于当前节点,我们递归地向右移动。
当我们找到child的parent为NULL的地方,也就是指向NULL的时候,我们就进行插入。
C#
1个
公共 类 BinarySearchTree < T > 其中 T : IComparable < T >
2个
{
3个
…
4个
的
5个
public void 添加(T 值)
6个
{
7
如果(_head 为 空)
8个
{
9
_head = 新 节点(值);
10
}
11
别的
12
{
13
_头。插入(值);
14
}
15
}
16
的
17
…
18
}
19
的
20
的
21
私有 类 节点
22
{
23
…
24
25
public void 插入(T 值)
26
{
27
ref var branch = ref 值。比较对象(值)< 0
28
? 参考 _left
29
:参考 _right ;
30
的
31
如果(分支 为 空)
32
{
33
分支 = 新 节点(值);
34
}
35
别的
36
{
37
分支机构。插入(值);
38
}
39
}
40
}
由于递归,我们在搜索正确位置时遍历的所有元素都存储在堆栈中。一旦添加了新节点并且递归开始展开,我们将通过每个祖先节点上升,直到到达根。这个特性大大简化了以后的代码。因此,请记住这一点。
如果我们继续以这种方式插入新元素,我们会注意到它们最终都会被排序。小节点总是在左边,而大节点总是在右边。多亏了这一点,我们总能在不影响树中其他节点的情况下快速找到插入新元素和搜索的正确路径。
所以现在我们在 O(log(N)) 时间内进行了搜索和插入。这也让我们可以快速找到树的最小和最大元素。最小值将始终是左侧最低的元素,而最大值将是右侧最低的元素。
因此,在第一种情况下,我们总是递归地向左下降,直到遇到 NULL。在第二种情况下,它是相似的,但我们向右下降。理解这样的节点不能有一个以上的孩子是很重要的。否则,它们将不是最小值或最大值。
C#
1个
公共 类 BinarySearchTree < T > 其中 T : IComparable < T >
2个
{
3个
…
4个
的
5个
公共 T 最小()
6个
{
7
如果(_head 为 空)
8个
{
9
throw new InvalidOperationException ( “树是空的。” );
10
}
11
的
12
返回 _head。最小值()。价值;
13
}
14
15
公共 T 最大()
16
{
17
如果(_head 为 空)
18
{
19
throw new InvalidOperationException ( “树是空的。” );
20
}
21
的
22
返回 _head。最大值()。价值;
23
}
24
的
25
…
26
}
27
的
28
的
29
私有 类 节点
30
{
31
…
32
33
公共 节点 最小值()
34
{
35
返回 _left 为 空 ? 这个:_left。最小值();
36
}
37
的
38
公共 节点 最大值()
39
{
40
返回 _right 为 空 ? 这个:_对。最大值();
41
}
42
}
项目搜索
我们将创建一个简单的搜索函数,以根据其值在树中查找特定节点。由于树的结构,搜索过程相对简单。如果当前节点包含给定值,我们将返回它。如果要查找的值小于当前节点的值,我们将在左子树中递归查找。相反,如果搜索值大于当前节点的值,我们将在右子树中查找。如果我们到达一个空子树,即它指向 NULL,那么具有搜索值的节点不存在。该算法类似于插入算法,我们将实现树的 Contains 方法和节点的 Find 方法。
C#
1个
公共 类 BinarySearchTree < T > 其中 T : IComparable < T >
2个
{
3个
…
4个
的
5个
public bool 包含(T 值)
6个
{
7
返回 _head ?. Find ( value )不 为 空;
8个
}
9
的
10
…
11
}
12
的
13
的
14
私有 类 节点
15
{
16
…
17
18
公共 节点? 求(T 值)
19
{
20
变量 比较 = 值。比较对象(值);
21
的
22
如果(比较 == 0)
23
{
24
返回 这个;
25
}
26
27
返回 比较 < 0
28
? _左?. 查找(值)
29
:_对吗?. 查找(值);
30
}
31
}
删除值
现在,为了让我们的树在我们想要从中删除一些元素时不被破坏,我们需要一些特殊的删除规则。首先,如果要删除的元素是叶节点,那么我们只需将其替换为 NULL。
如果这个元素有一个孩子,那么我们用那个孩子而不是 NULL 替换被删除的节点。
因此,在这两种情况下,删除简单地归结为用其子节点替换已删除的节点,该子节点可以是常规现有节点或 NULL。因此,我们只需检查父节点肯定没有两个子节点,并用它的一个子节点覆盖已删除的节点,我再说一遍,这可能是现有节点或 NULL。
最后一种情况是删除的节点有两个孩子。在这种情况下,将代替已删除节点的节点必须大于已删除节点左子树中的所有节点并小于已删除节点右子树中的所有节点。
因此,首先,我们需要找到左子树中最大的元素或右子树中的最小元素。找到后,我们覆盖删除节点的数据,并递归删除移动到删除节点位置的节点。它将根据相同的规则被删除:它将被替换为 NULL 或其唯一的孩子。
C#
1个
公共 类 BinarySearchTree < T > 其中 T : IComparable < T >
2个
{
3个
…
4个
5个
public bool Remove ( T 值)
6个
{
7
返回 _head ?. 删除(值,出 _head)?? 假的;
8个
}
9
的
10
…
11
}
12
的
13
的
14
私有 类 节点
15
{
16
…
17
18
public bool Remove ( T value , out Node ? root )
19
{
20
变量 比较 = 值。比较对象(值);
21
的
22
如果(比较 < 0)
23
{
24
根 = 这个;
25
返回 _left ?. 删除(值,出 _left)?? 假的;
26
}
27
的
28
如果(比较 > 0)
29
{
30
根 = 这个;
31
返回 _right ?. 删除(值,出 _right)?? 假的;
32
}
33
的
34
如果(_left 为 空 || _right 为 空)
35
{
36
root = _left ?? _对了;
37
返回 真;
38
}
39
40
变种 leftMax = _left。最大值();
41
_离开。移除( leftMax.Value , out _left ) ; _
42
值 = 左最大值。价值;
43
根 = 这个;
44
返回 真;
45
}
46
}
树遍历
为了检查删除是否成功以及所有节点的顺序是否已保留,有一种特殊的树遍历方法可以让我们按升序输出所有节点。这种方法称为中序遍历。它涉及递归地首先输出左孩子,然后是父母,然后是右孩子。让我们使用中序遍历将树转换为常规列表。
C#
1个
公共 类 BinarySearchTree < T > 其中 T : IComparable < T >
2个
{
3个
…
4个
5个
public List ToList ( ) _
6个
{
7
var list = new List ( ) ;
8个
_头?. 添加到(列表);
9
的
10
返回 列表;
11
}
12
的
13
…
14
}
15
的
16
的
17
私有 类 节点
18
{
19
…
20
21
public void AddTo ( ICollection list ) _ _
22
{
23
_左?. 添加到(列表);
24
列表。添加(值);
25
_对吗?. 添加到(列表);
26
}
27
}
现在我们有一种将树输出到控制台的简单方法。让我们这样做并确保删除工作正常。
C#
1个
var tree = new BinarySearchTree < int > ();
2个
树。添加( 50 );
3个
树。添加( 40 );
4个
树。添加( 30 );
5个
树。添加( 45 );
6个
树。添加( 35 );
7
Print (树.ToList ( ));
8个
树。移除( 35 );
9
树。移除( 40 );
10
Print (树.ToList ( ));
11
的
12
void Print (列表< int > 列表)
13
{
14
foreach(列表中的变量 值 )
15
{
16
控制台。写入(值);
17
控制台。写( " " );
18
}
19
20
控制台。写行();
21
}
另一种类型的树遍历称为先序遍历。它涉及首先输出父母,然后是左孩子,然后是右孩子。这可能很有用,例如,在内存中复制树时,因为我们按照节点从上到下放置在树中的顺序完全相同地遍历节点。
还有其他类型的二叉搜索树遍历,但它们的实现差别不大。
结论
最后,我们有了一个可以快速完成所有事情的数据结构。让我们花点时间思考并创建一棵从元素 1 到 5 的树。如果每个后续节点总是大于或总是小于前一个节点,那么我们将再次得到一个操作复杂度为 O(N) 的普通链表。
因此,我们的树完全没有用。幸运的是,人们很快意识到了这一点,并提出了一种更高级的树,称为 AVL,具有自平衡功能,这将再次使我们能够实现对数复杂度,而不管输入数据如何。