确定所谓的二叉树是否包含循环的高效算法?

Efficient algorithm to determine if an alleged binary tree contains a cycle?

我最喜欢的一个面试问题是

In O(n) time and O(1) space, determine whether a linked list contains a cycle.

这可以使用Floyd的循环查找算法来完成。

我的问题是,在试图检测二叉树是否包含循环时,是否有可能获得如此好的时间和空间保证。也就是说,如果有人按照

1
2
3
4
struct node {
    node* left;
    node* right;
};

您如何有效地验证给定的结构确实是二叉树,而不是DAG或包含循环的图?

是否有一种算法,给定二叉树的根,可以确定该树是否包含O(n)时间内的循环,以及是否比O(n)空间更好?显然,这可以用标准的DFS或BFS来完成,但这需要O(N)空间。可以在O(√n)空间中完成吗?O(对数N)空间?或者(圣杯)在O(1)空间?我很好奇,因为在链表的情况下,这可以在O(1)空间中完成,但是我从来没有见过一个对应的有效算法。


你甚至不能访问一个真实的,对上帝诚实的,在O(1)空间循环自由树的每个节点,所以你所要求的显然是不可能的。(沿途修改树的技巧不是O(1)空间)。

如果您愿意考虑基于堆栈的算法,那么可以根据Floyd的算法很容易地修改常规的树遍历。


如果图的两个顶点属于同一连接分量,则可以在对数空间中进行测试(Reingold,Omer(2008),"日志空间中的无向连接",《ACM 55期刊》(4):第17条,24页,doi:10.1145/1391289.1391291)。连接的组件是循环的;因此,如果在一个图中可以找到属于同一连接组件的两个顶点,则该图中存在一个循环。Reingold在其存在问题首次提出26年后出版了该算法(见http://en.wikipedia.org/wiki/connected-component_u28graph-theory%29)。有一个O(1)空间算法听起来不太可能,因为它花了25年的时间找到了一个日志空间解决方案。注意,从一个图中选取两个顶点并询问它们是否属于一个循环等同于询问它们是否属于一个连接的组件。

该算法可以扩展到带out degree 2(op:"trees")的图的日志空间解,因为它足以检查节点及其一个直接同级节点的每对是否属于同一个连接组件,并且可以使用标准递归树下降在O(log n)空间中枚举这些对。


如果假设循环指向"树"中相同深度或更小深度的节点,则可以使用两个堆栈进行BFS(迭代版本),一个堆栈用于海龟(x1),另一个堆栈用于兔子(x2速度)。在某个时刻,兔子的堆栈要么是空的(没有循环),要么是乌龟堆栈的一个子集(发现了循环)。所需时间为o(n k),空间为o(lg n),其中n为已用节点数,k为检查可由lg(n)上界的子集条件所需的时间。请注意,关于循环的初始假设并不约束原始问题,因为它被假定为一棵树,但是对于与以前的节点形成循环的有限数量的弧;到树中较深节点的链接将不会形成循环,而是破坏树结构。

如果可以进一步假设循环指向祖先,那么可以通过检查两个堆栈是否相等来更改子集条件,这会更快。


访问意识

您需要这样重新定义结构(我将不再使用指针):

1
2
3
4
5
class node {
    node left;
    node right;
    bool visited = false;
};

并使用以下递归算法(如果您的树足够大,显然需要重新处理它以使用自定义堆栈):

1
2
3
4
5
6
7
8
9
10
11
12
bool validate(node value)
{
   if (value.visited)
      return (value.visited = false);
   value.visited = true;
   if (value.left != null && !validate(value.left))
      return (value.visited = false);
   if (value.right != null && !validate(value.right))
      return (value.visited = false);
   value.visited = false;
   return true;
}

注释:从技术上讲,它确实有O(N)空间;因为结构中有额外的字段。如果所有的值都在树的单侧,并且每个值都在循环中,那么最坏的情况也是O(n+1)。

深度感知

当插入到树中时,可以跟踪最大深度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct node {
  node left;
  node right;
};
global int maximumDepth = 0;

void insert(node item) { insert(root, item, 1); }
void insert(node parent, node item, int depth)
{
    if (depth > maximumDepth)
        maximumDepth = depth;
    // Do your insertion magic, ensuring to pass in depth + 1 to any other insert() calls.
}

bool validate(node value, int depth)
{
    if (depth > maximumDepth)
        return false;
    if (value.left != null && !validate(value.left, depth + 1))
        return false;
    if (value.right != null && !validate(value.right, depth + 1))
        return false;
    return true;
}

注释:存储空间是O(n+1),因为我们在堆栈上存储深度(以及最大深度);时间仍然是O(n+1)。这对无效的树会更好。


设法弄好了!

  • 运行时间:O(N)。我怀疑它最多会经过几次边缘。没有正式的证据。
  • 空间:O(1)。只存储几个节点。不创建新节点或边,只重新排列它们。
  • 破坏性:是的。它使树变平,每个节点的右子节点都有无序的后续节点,左子节点则为空。

该算法通过将当前节点的整个左子树移动到其上,使其成为子树的最右侧节点,然后更新当前节点,以在新发现的节点中找到更多的左子树,从而使二叉树变平。如果我们既知道当前节点的左子节点又知道当前节点的前置节点,那么我们可以在几个操作中移动整个子树,类似于将列表插入到另一个操作中。这样的移动保持了树的顺序,它总是使树更向右倾斜。

根据当前节点周围的本地配置,有三种情况:左子节点与前置节点相同,左子节点与前置节点不同,或者没有左子树。第一种情况是微不足道的。第二种情况需要查找前置任务,第三种情况需要查找具有左子树的右侧节点。图形表示有助于理解它们。

在后两种情况下,我们可以进入循环。因为我们只遍历一个正确的子对象列表,所以我们可以使用Floyd的循环检测算法来查找和报告循环。每个周期迟早都会旋转成这样的形状。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#include <cstdio>
#include <iostream>
#include <queue>

#define null NULL
#define int32 int

using namespace std;

/**
*   Binary tree node class
**/

template <class T>
class Node
{

    public:

    /*  Public Attributes   */

        Node* left;
        Node* right;
        T value;

};

/**
*   This exception is thrown when the flattener & cycle detector algorithm encounters a cycle
**/

class CycleException
{

    public:

    /*  Public Constructors */

        CycleException () {}
        virtual ~CycleException () {}

};

/**
*   Biny tree flattener and cycle detector class.
**/

template <class T>
class Flattener
{

    public:

    /*  Public Constructors */

        Flattener () :
            root (null),
            parent (null),
            current (null),
            top (null),
            bottom (null),
            turtle (null),
        {}

        virtual ~Flattener () {}

    /*  Public Methods  */

        /**
        *   This function flattens an alleged binary tree, throwing a new CycleException when encountering a cycle. Returns the root of the flattened tree.
        **/

        Node<T>* flatten (Node<T>* pRoot)
        {
            init(pRoot);
            //  Loop while there are left subtrees to process
            while( findNodeWithLeftSubtree() ){
                //  We need to find the topmost and rightmost node of the subtree
                findSubtree();
                //  Move the entire subtree above the current node
                moveSubtree();
            }
            //  There are no more left subtrees to process, we are finished, the tree does not contain cycles
            return root;
        }

    protected:

    /*  Protected Methods   */

        void init (Node<T>* pRoot)
        {
            //  Keep track of the root node so the tree is not lost
            root = pRoot;
            //  Keep track of the parent of the current node since it is needed for insertions
            parent = null;
            //  Keep track of the current node, obviously it is needed
            current = root;
        }

        bool findNodeWithLeftSubtree ()
        {
            //  Find a node with a left subtree using Floyd's cycle detection algorithm
            turtle = parent;
            while( current->left == null and current->right != null ){
                if( current == turtle ){
                    throw new CycleException();
                }
                parent = current;
                current = current->right;
                if( current->right != null ){
                    parent = current;
                    current = current->right;
                }
                if( turtle != null ){
                    turtle = turtle->right;
                }else{
                    turtle = root;
                }
            }
            return current->left != null;
        }

        void findSubtree ()
        {
            //  Find the topmost node
            top = current->left;
            //  The topmost and rightmost nodes are the same
            if( top->right == null ){
                bottom = top;
                return;
            }
            //  The rightmost node is buried in the right subtree of topmost node. Find it using Floyd's cycle detection algorithm applied to right childs.
            bottom = top->right;
            turtle = top;
            while( bottom->right != null ){
                if( bottom == turtle ){
                    throw new CycleException();
                }
                bottom = bottom->right;
                if( bottom->right != null ){
                    bottom = bottom->right;
                }
                turtle = turtle->right;
            }
        }

        void moveSubtree ()
        {
            //  Update root; if the current node is the root then the top is the new root
            if( root == current ){
                root = top;
            }
            //  Add subtree below parent
            if( parent != null ){
                parent->right = top;
            }
            //  Add current below subtree
            bottom->right = current;
            //  Remove subtree from current
            current->left = null;
            //  Update current; step up to process the top
            current = top;
        }

        Node<T>* root;
        Node<T>* parent;
        Node<T>* current;
        Node<T>* top;
        Node<T>* bottom;
        Node<T>* turtle;

    private:

        Flattener (Flattener&);
        Flattener& operator = (Flattener&);

};

template <class T>
void traverseFlat (Node<T>* current)
{
    while( current != null ){
        cout << dec << current->value <<" @ 0x" << hex << reinterpret_cast<int32>(current) << endl;
        current = current->right;
    }
}

template <class T>
Node<T>* makeCompleteBinaryTree (int32 maxNodes)
{
    Node<T>* root = new Node<T>();
    queue<Node<T>*> q;
    q.push(root);
    int32 nodes = 1;
    while( nodes < maxNodes ){
        Node<T>* node = q.front();
        q.pop();
        node->left = new Node<T>();
        q.push(node->left);
        nodes++;
        if( nodes < maxNodes ){
            node->right = new Node<T>();
            q.push(node->right);
            nodes++;
        }
    }
    return root;
}

template <class T>
void inorderLabel (Node<T>* root)
{
    int32 label = 0;
    inorderLabel(root, label);
}

template <class T>
void inorderLabel (Node<T>* root, int32& label)
{
    if( root == null ){
        return;
    }
    inorderLabel(root->left, label);
    root->value = label++;
    inorderLabel(root->right, label);
}


int32 main (int32 argc, char* argv[])
{
    if(argc||argv){}

    typedef Node<int32> Node;

    //  Make binary tree and label it in-order
    Node* root = makeCompleteBinaryTree<int32>(1 << 24);
    inorderLabel(root);

    //  Try to flatten it
    try{
        Flattener<int32> F;
        root = F.flatten(root);
    }catch(CycleException*){
        cout <<"Oh noes, cycle detected!" << endl;
        return 0;
    }

    //  Traverse its flattened form
//  traverseFlat(root);

}


好吧,我想我找到了办法,只要你

  • 提前知道节点数
  • 可以修改二叉树

基本思想是用Morris Inorder树遍历来遍历树,并计算访问阶段和单个前置查找阶段中访问的节点数。如果这些节点中的任何一个超过了节点的数量,那么您肯定有一个循环。如果你没有一个循环,那么它就相当于普通的莫里斯遍历,你的二叉树将被恢复。

不过,我不确定在事先不知道节点数量的情况下是否可行。会考虑更多。


我不认为有一种算法可以在O(n)空间以下的树上行走。而且,对于(所谓的)二叉树来说,检测循环所花费的空间/时间(以"顺序"的术语)并不比它在树上行走所花费的空间/时间多。我相信DFS会在O(N)时间内走到树上,所以O(N)可能是这两个度量的极限。


乍一看,你会发现这个问题可以通过弗洛伊德算法的非确定性应用来解决。那么,如果我们把Floyd的方法拆分成分支,会发生什么呢?

我们可以从基节点使用Floyd,然后在每个分支添加一个附加的Floyd。因此,对于每个终端路径,我们都有一个Floyd算法的实例,它在这里结束。如果出现了一个循环,就一定有一只乌龟在追它。所以算法完成了。(作为一个副作用,每个终端节点仅由一对兔子/乌龟到达,因此有O(N)次访问,因此有O(N)次访问。(存储从中分支的节点,这不会增加内存的数量级,并防止在循环的情况下内存耗尽)此外,这样做可以确保内存占用量与终端节点的数量相同。在最坏的情况下,终端节点的数量是O(log n),而O(n)。

TL;DR:每次您有选择时,请使用Floyd's和Branch:时间:O(N)空间:O(对数N)


正如chingping所提到的,一个简单的dfs应该可以做到这一点。您需要将每个节点标记为已访问(需要执行一些从节点引用到整数的映射),如果在已访问的节点上尝试重新进入,则意味着存在一个循环。

不过,这在内存中是O(N)。


正如卡尔的定义所说,"树"没有周期。但我仍然能理解提问的精神。为什么你需要复杂的算法来检测任何图形中的循环?您可以简单地运行bfs或dfs,如果您访问的节点已经被访问,则意味着一个循环。这将在O(n)时间内运行,但空间复杂性也为O(n),不知道是否可以降低。