十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

算法(28):ordered operations-9.5

算法(28):ordered operations-9.5 Page 18最小键与最大键物理动作找最小键从根开始一直沿left指针走直到碰到null。最后一个非空节点就是最小值。因为根据 BST 性质左子树的所有键都小于根最左端的叶子就是全树的最小值。找最大键方向相反一直沿right指针走到尽头。时间成本等于从根到最左或最右叶子的路径长度平均O(log N)最坏O(N)。Page 19-20向下取整floor和向上取整ceiling物理定义floor(key)小于等于key的最大键前驱。ceiling(key)大于等于key的最小键后继。floor的物理搜索逻辑PPT第20页的三种情况如果当前节点的键等于key直接返回当前节点。如果key小于当前节点的键那么floor一定在左子树中因为右子树所有键都大于当前节点更不可能满足“小于等于 key”。如果key大于当前节点的键那么当前节点本身是一个候选解但右子树中可能有更大的键但仍然小于等于key。所以需要先去右子树查找如果右子树找到了符合条件的节点就返回那个如果右子树没找到就返回当前节点作为兜底。物理指针行为在情况 3 中代码会Node t floor(x.right, key)如果t ! null返回t否则返回x。这正是“先看右子树有没有更接近的没有就用当前节点兜底”。ceiling的逻辑是对称的如果key大于当前节点向左子树找如果key小于当前节点先向右子树找找不到就返回当前节点。Page 22-24rank和select以及子树大小计数物理前置条件Page 22每个节点必须额外维护一个count字段记录以该节点为根的子树中的节点总数。size(x)的实现如果x null返回 0否则返回x.count。根节点的count就是整棵树的大小。select(k)找排名第 k 小的键k 从 0 开始Page 23 的图获取左子树的大小t size(x.left)。如果t k目标在左子树递归select(x.left, k)。如果t k目标在右子树递归select(x.right, k - t - 1)减去左子树节点和根节点。如果t k当前节点就是目标。rank(key)找给定键的排名Page 24 的代码比较key与x.key。如果key x.key递归查左子树。如果key x.key返回1 size(x.left) rank(x.right, key)。如果key x.key返回size(x.left)。Page 25中序遍历物理动作递归调用顺序为inorder(left)→ 处理当前节点 →inorder(right)。BST 性质保证因为左子树的所有键都小于当前节点右子树都大于当前节点所以这种遍历顺序正好输出升序的键序列。时间复杂度遍历所有节点输出成本O(N)无法低于 N因为你要输出 N 个键。Page 26有序操作总结物理对比链表实现中rank和floor需要遍历整个链表O(N)。BST 中这些操作沿着树向下走路径长度等于树高平均O(log N)。关键点select和rank的运行时间依赖于count字段的维护。每次插入/删除时路径上的所有count都必须更新加一或减一。你刚刚在put递归末尾看到的x.count 1 size(left) size(right)正是为了保证这些有序操作能快速运行。这和你之前在堆排序里维护N堆大小的逻辑类似都是通过“额外维护一个整数”来换取“快速查询数量”的能力。如果你对rank代码中1 size(left)的含义还有任何模糊或者想确认select递归中k - t - 1的边界可以直接指出来。如果觉得这一节已经清楚了我们可以直接进入下一节Deletion删除操作这是 BST 中最复杂的一部分。一、查找返回Q我好像有点明白了看这种递归的函数主要是看它的return语句然后通过思考递归调用以后的return都有什么来看它的逻辑例如这个函数看似情况有三个第一个是相等的时候返回该点第二个是小于的时候对左点调用第三个是大于的时候对右点调用。但或许我们真正应该看的是三个有直接值的return语句第一个是放入的参数本身是null时return null第二个是相等的时候直接return x第三个是向右找的时候递归找到了return向右找时获得的节点引用第四个是向右找没找到return x。那么通过上面的分析可以发现如果向左找一路没找到返回的将是空向右找没找到返回的却是兜底节点。针对这里我有三个问题第一个是递归函数的分析方式是这样的吗有没有更好的分析方式第二个是为什么向左找没找到要返回空向右找没找到却返回兜底节点呢第三个是向右找是否也是可以写出一个找不到就为空的代码版本也就是把第四个else情况下的return x给去掉。A分析方式有效但不是最优的。你是在追踪“运行时堆栈的返回值”这对简单递归有用但对更复杂的递归如这里涉及“兜底”的容易漏掉整体逻辑。更好的方式是站在函数定义契约的角度去读代码而不去追踪栈。问题一更好的分析方式读递归函数时不要从return语句往回追溯而是先明确这个函数在“物理上”承诺了什么。floor(x, key)的物理承诺契约是“在以x为根的子树中找到键值不超过key的最大节点。如果找不到返回null。”用这个契约去检查代码而不是追踪return路径if (x null) return null;子树为空没有节点符合契约。if (cmp 0) return x;当前节点就是key它就是“不超过key的最大值”因为正好相等直接返回符合契约。if (cmp 0)key x.key当前节点x的键大于key不满足“不超过”条件。左子树的所有键都小于x.key但只有一部分可能小于key。右子树肯定都大于x.key肯定也大于key可以直接排除。所以floor不可能出现在右子树或当前节点唯一可能在左子树。所以返回floor(x.left, key)符合契约。elsekey x.key当前节点x.key小于key所以x本身已经是一个有效候选解。但右子树里的键可能更大但仍然小于等于key比x更优。所以先查右子树。如果右子树返回非nullt那就是在右子树中找到了更优解返回t。如果右子树返回null说明右子树里没有符合条件的节点那当前节点x就是最优解返回x。这种“从契约出发”的方式比追踪返回值更直接也更不容易出错。问题二为什么向左找没找到返回空向右找没找到返回兜底节点因为物理前提不同。向左走cmp 0当前节点x.key大于key。它本身就不满足“不超过key”的条件。如果左子树里也找不到那整个子树里就没有符合条件的节点了。所以返回null。向右走cmp 0当前节点x.key小于key。它本身满足条件。如果右子树找不到更优的当前节点就是最优解。所以返回当前节点作为“兜底”。核心区别向左时当前节点无效没有“兜底”向右时当前节点本身有效可以作为“兜底”。问题三向右找是否可以写成找不到就返回空的版本可以但会破坏这个函数的直接性并且增加调用者的负担。如果你把最后一段改成javaif (cmp 0) { return floor(x.right, key); }那就意味着当右子树不存在符合条件的节点时直接返回null。此时调用者可能是上一层递归就完全不知道当前节点x是一个合法候选解它只知道“右子树没找到”。要补偿这一点你必须在调用者那里做额外的判断——这相当于把“兜底逻辑”上移了一层导致每层都要重复处理。标准版本在else分支中同时处理了“找更优”和“兜底”两种任务是一次遍历完成的。如果你把它拆成纯查找没找到就返回null你就必须在每一层额外记住“我当前经过的最后一个有效节点是谁”这相当于手动模拟递归栈既冗余又容易出错。标准实现的紧凑写法正是利用了“向右分支当前节点必为候选”这一物理事实从而避免了额外状态。二、递归putQ第23页我觉得有点奇怪。这里我把键值对给put以后例如创造了一个新节点如果接下来再在上面这个创造的新节点下放入别的节点似乎它并不会更新原本节点的count值不似乎不是这样因为每次递归回来跳出if判断以后都具有一个关于count值的计算而如果原本指数中就没有这个键值对则最后返回的键值对引用要么是出现在x.left要么是出现在x.right。实话讲我有点看不懂第23页的这个函数我搞不清楚他这里return的节点x到底是谁以及递归的过程中能不能逐个更新经过的节点按理来说应该是能的但是我无法追踪x的变化。似乎这里每一层的x依旧是它本身而没有被更新而x.left和x.right的更新唯一的作用只是更新count值A你感到困惑是因为你试图“自上而下”追踪x的变化但实际上这个x在每一层是独立的局部变量。第23页的代码以及所有递归put真正的工作发生在“上行回溯”阶段而不是“下行深入”阶段。1. 下行阶段只做比较不做修改假设树里有根S你要插入A。第 1 层根x S比较A和SA S执行x.left put(x.left, A)。此时S的count还没动函数暂停进入下一层。第 2 层x S.left也就是E比较A和EA E执行x.left put(x.left, A)。此时E的count还没动进入下一层。第 3 层x E.left也就是null触发if (x null) return new Node(A, val, 1)。这里返回了一个新节点的内存地址它的count 1。下行阶段结束后堆上已经创建了新节点但还没有任何父节点的左指针指向它。父节点的left字段里还存着指向E的地址——但现在E.left是null所以父节点目前还不知道新节点存在。2. 上行阶段回溯更新指针和count回到第 2 层x Ex.left put(...)这个赋值语句接收到了第 3 层返回的新节点地址。现在E.left才真正指向了新节点A。然后执行x.count 1 size(x.left) size(x.right)。此时size(x.left)看到了新节点因为x.left已经指向它了所以E.count从原来的1只有E自己变成了2EA。最后return x返回E的地址给上一层。回到第 1 层x Sx.left put(...)这个赋值语句接收到了第 2 层返回的E的地址其实就是原来那个地址没变。然后执行x.count 1 size(x.left) size(x.right)。此时size(x.left)看到的是E的count已经是2了所以S.count也增加了 1。最后return x。3. 回答你的具体疑问“这里每一层的x依旧是它本身而没有被更新”正确。x是当前节点的局部引用它指向的对象S、E在堆上没有变。变的是x.left或x.right里的指针而不是x本身。“x.left和x.right的更新唯一的作用只是更新count值”不是。赋值x.left put(...)在物理上有两个作用当插入新节点时把新节点的地址写入父节点的左指针挂接到树中。当键已存在时把原来那个子节点的地址原封不动赋回去虽然看起来没变但这是递归必须有的“路径修补”然后利用更新后的子树count重新计算当前节点的count。如果去掉x.left 只写put(...)新节点就永远不会挂到树上它会变成孤儿。所以这个赋值是为了物理挂接而后续的x.count ...是为了数量更新。两者合在一起才完成了“插入 路径计数刷新”的完整动作。你刚才的推导在物理逻辑上已经完全正确只是把“挂接”和“计数更新”的作用拆开了看才觉得“唯一作用”不太对。它们是一个动作的两面缺一不可。Q这里这个函数怎么没有成功或者失败的判断啊按理来说不应该返回TRUE或者FALSE吗或者干脆不返回任何东西返回x有什么用呢看我们的x到底准不准确吗A这个函数不返回true/false或void是因为它是一个“维护型递归Maintenance Recursion”而不是“查询型”。它的核心任务是“修改子树并把修改后的结果传回给父节点”。如果它返回void父节点就不知道“子节点指针应该指向谁”如果返回boolean父节点知道“插入了”但依然不知道“新节点挂在哪个地址上”。x是当前子树的根节点地址。return x的物理含义是把当前这棵子树的根节点地址返回给上一层调用者。1. 为什么必须返回x看这行关键代码javax.left put(x.left, key, val);put函数返回的是以x.left为根的子树更新后的根节点地址。如果x.left原本是nullput创建了新节点并返回其地址。上层通过x.left ...把这个新地址写入父节点的左指针。如果x.left原本是一个存在的节点put遍历子树的内部最终返回的还是x.left这个地址没变。上层通过x.left ...把原地址再赋回去虽然看起来没变但保持了指针更新。物理事实x.left put(x.left, key, val)这个赋值语句在更新count之前先确保了指针的正确性。如果没有这个赋值新节点就永远挂不上树。2. 为什么没有true/false状态因为在 BST 的插入定义中操作总是成功的如果键存在更新值树结构不变。如果键不存在创建新节点并挂载树结构变大。没有“插入失败”的物理场景除非内存耗尽但那会直接抛异常。所以不需要返回true/false来表示“是否成功”。如果你真的想知道“是不是新插入了”你可以在调用之前先get一下但那是外部逻辑不是put函数内部需要返回的信息。3.return x的第三个作用更新count函数末尾的x.count 1 size(x.left) size(x.right); return x;当子树中插入了一个新节点或更新了值子树内某个节点的count发生了变化。return x把当前节点x的地址返回给上一层让上一层在x.left put(...)赋值后能够通过重新计算1 size(...)来更新父节点的count。return x传递的是“已经更新好count的根节点地址”。你不需要用返回值来判断是否成功因为x本身已经包含了插入动作后的所有结构变化包括count。你需要的是更新后的根节点而不是一个布尔状态。
返回列表