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),不知道是否可以降低。