树5,二叉树的特例——二叉搜索树

背景知识:
##############################################################
运算符重载:
在类中调用一个符号时,这个符号是有特定的函数名称的,我们可以在类中对此函数进行定义,那么在遇到这个符号调用时,python会自动调用这个方法。
比如,进行索引运算时,出现符号“[ ]”,就会自动调用 getitem方法,返回的就是index的平方。

class Indexer(object):
        def __getitem__(self,index):
                return index ** 2
X = Indexer()
X[2]

同样,setitem代表的运算操作,就是对item进行赋值操作。有点类似于字典,只要类中定义了这个方法,那么这个类对象就可以进行这个操作,通过重载的运算符实现。

##############################################################

一、二叉搜索树的应用

在二叉搜索树中,左子节点总是小于或等于根节点,而右子节点总是大于或等于根节点。我们可以平均在O(logn)的时间内根据数值在二叉搜索树中找到一个节点。

可见,二叉搜索树,就是搜索数据,根据key找到value。

我们之前已经学习过了两种ADT MAP的实现方式,一种是列表的二分查找,一种是散列表。

同样是搜索,根据键key来搜索值value。这里我们将要学习第三种方式:二叉搜索树。

注意,二叉搜索树也是一种数据结构。这种数据结构跟字典非常相似,所以也是三步走:

  • 二叉搜索树的操作
  • 二叉搜索树的结构
  • 二叉搜索树的操作的代码实现

二、二叉搜索树的操作

  • Map() 建立一个新的空map。
  • put(key,val) 在map中增加了一个新的键值对。如果这个键已经在这个map中了, 那么就用新的数据来代替旧的数据。
  • get(key) 提供一个键,返回map中保存的数据,否则返回None。
  • del 用del map[key]这种形式从map中删除键值对。
  • len() 返回map中保存的键值对的数目.
  • in 对给定的键是否存在map中进行判断,如果所给的键在map中,返回True

三、二叉搜索树的结构

一个二叉搜索树,如果左子树中键值Key都小于父节点,而右子树中键值Key都大于父节点,我们将这种树称为BST搜索树。

四、二叉搜索树的实现

实现二叉搜索树,我们将使用节点和引用方法,这类似于一个我们用来实现链表和表达式树的过程。但是,因为我们必须能够创建和使用一个空二叉搜索树,所以我们的实现将使用两个类: 第一个类我们称为BinarySearchTree,第二个类我们称之为TreeNode。

这两个类的实现代码如下:

class BinarySearchTree:

    def __init__(self):
        self.root = None
        self.size = 0

    def length(self):
        return self.size

    def __len__(self):
        return self.size

    def __iter__(self):
        return self.root.__iter__()
class TreeNode:
   def __init__(self,key,val,left=None,right=None,
                                       parent=None):
        self.key = key
        self.payload = val
        self.leftChild = left
        self.rightChild = right
        self.parent = parent

    def hasLeftChild(self):
        return self.leftChild

    def hasRightChild(self):
        return self.rightChild

    def isLeftChild(self):
        return self.parent and self.parent.leftChild == self

    def isRightChild(self):
        return self.parent and self.parent.rightChild == self

    def isRoot(self):
        return not self.parent

    def isLeaf(self):
        return not (self.rightChild or self.leftChild)

    def hasAnyChildren(self):
        return self.rightChild or self.leftChild

    def hasBothChildren(self):
        return self.rightChild and self.leftChild

    def replaceNodeData(self,key,value,lc,rc):
        self.key = key
        self.payload = value
        self.leftChild = lc
        self.rightChild = rc
        if self.hasLeftChild():
            self.leftChild.parent = self
        if self.hasRightChild():
            self.rightChild.parent = self

4.1、put()方法

对put方法的解读

  • 如果根节点存在,则递归调用_put方法
  • 如果根节点不存在,则直接调用treenode类,作为根节点。
    由构造函数添加一个树节点,并且会输入此节点的key与val。

对_put方法的解释:

  • 如果新的值小于当前节点,那就对左子树进行搜索
    1、如果有左子树,那么再递归调用_put,直到新节点放到正确的位置为止。
    2、如果没有左子树,则直接将点调用treenode,并且赋予key和val。
  • 如果新的值大于当前节点,那么就对右子树进行搜索
    下面执行与上述1、2相同的步骤
def put(self,key,val):
    if self.root:
        self._put(key,val,self.root)
    else:
        self.root = TreeNode(key,val)
    self.size = self.size + 1

def _put(self,key,val,currentNode):
    if key < currentNode.key:
        if currentNode.hasLeftChild():
               self._put(key,val,currentNode.leftChild)
        else:
               currentNode.leftChild = TreeNode(key,val,parent=currentNode)
    else:
        if currentNode.hasRightChild():
               self._put(key,val,currentNode.rightChild)
        else:
               currentNode.rightChild = TreeNode(key,val,parent=currentNode)

用运算符重载的方法,使类对象可以进行类似于字典的赋值操作。

def __setitem__(self,k,v):
    self.put(k,v)

随着put方法的定义,我们可以很容易地重载[ ]作为操作符,通过添加 setitem的方法来调用put方法。

定义这个方法以后,这使我们能够编写比如myZTiptree['plymouth’] = 55446一样的python语句,这看上去就像Python字典。

但是上面的put方法还是有一个bug。
bug是因为,若是出现重复的key,那么在_put中,key就不满足小于当前的key,而是else条件语句的大于等于当前节点,这样新的键值对就会被添加在当前节点的右子树上。这样在根据key查找数据是,永远只会找到最上面的key对应的value。因此正确的做法应该是有相同的key时,应该去覆盖这个key对应的value。

4.2 、get()方法

树已经被构建成功。接下来任务是给定键,实现对值的检索。(二叉搜索树的意义)

get方法比put更容易,因为它递归地搜索子树,直到发现无匹配的叶节点或找到一个匹配的键。当一个匹配的键被发现后,存储在节点的值被返回。

get方法

  • 若根节点为空,则返回None,不为空,则递归调用_get进行查找操作。若找到key,则返回val。

_get方法
递归调用自身,若找到了key键,就返回对应的val,找不到就返回None。

def get(self,key):
    if self.root:
        res = self._get(key,self.root)
        if res:
               return res.payload
        else:
               return None
    else:
        return None

def _get(self,key,currentNode):
    if not currentNode:
        return None
    elif currentNode.key == key:
        return currentNode
    elif key < currentNode.key:
        return self._get(key,currentNode.leftChild)
    else:
        return self._get(key,currentNode.rightChild)

def __getitem__(self,key):
    return self.get(key)

通过实施getitem方法,我们可以写若干Python语句,使它们看起来就像我们访问字典一样,而事实上我们只是在用一个二叉搜索树,例如Z = myziptree [ 'fargo’]。如你可以看到,所有的getitem方法都是调用get。

调用get函数时,我们可以为BinarySearchTree写contains方法从而能够使用操作符in。contains会简单的调用get,当get返回值的时候就返回True,如果get返回None就返回False。

def __contains__(self,key):
    if self._get(key,self.root):
        return True
    else:
        return False

4.3 、删除节点

删除节点的步骤:

  • 1、找到要删除的搜索树的节点。
    a、如果树有一个以上的节点,我们使用_get方法搜索找到需要删除的TreeNode;
    b、如果树只有一个节点,这意味着我们要移除树的根,但是我们仍然必须检查以确保根的键是否匹配要删除的键。
  • 2、在以上两种情况下,如果未发现键值,del操作符就会报错。
    以下是删除节点的主函数的代码:
def delete(self,key):
   if self.size > 1:
      nodeToRemove = self._get(key,self.root)
      if nodeToRemove:
          self.remove(nodeToRemove)
          self.size = self.size-1
      else:
          raise KeyError('Error, key not in tree')
   elif self.size == 1 and self.root.key == key:
      self.root = None
      self.size = self.size - 1
   else:
      raise KeyError('Error, key not in tree')

def __delitem__(self,key):
    self.delete(key)

而对于其中的nodeToRemove函数的具体操作,因为要删除的节点有三种不同的情况,因此对应的操作也不尽相同。
下面是,要删除的节点要有的三种情况:

  • 1、要删除的节点没有子树
  • 2、要删除的节点只有左子树(或只有右子树)
  • 3、要删除的节点,左子树和右子树都有。

接下来对于三种情况再做具体的分析:

第一种情况:要删除的节点没有子树

第一种情况是简单的。如果当前节点没有子树,所有我们需要做的是删除该节点并把指向该节点的引用移动给其父节点。

if currentNode.isLeaf():
    if currentNode == currentNode.parent.leftChild:
        currentNode.parent.leftChild = None
    else:
        currentNode.parent.rightChild = None

例子:

二叉搜索树是通过引用来实现的,所以删除一个节点,不能只是单纯的删除数据就行了,还要对引用做出修改。
代码解释,若当前节是叶节点,如果是父节点的左子节点,那么就把父节点的左子节点引用改为None。同理于右子节点。

第二种情况:要删除的节点只有左子树(或只有右子树)

else: # this node has one child
   if currentNode.hasLeftChild():
      if currentNode.isLeftChild():
          currentNode.leftChild.parent = currentNode.parent
          currentNode.parent.leftChild = currentNode.leftChild
      elif currentNode.isRightChild():
          currentNode.leftChild.parent = currentNode.parent
          currentNode.parent.rightChild = currentNode.leftChild
      else:
          currentNode.replaceNodeData(currentNode.leftChild.key,
                             currentNode.leftChild.payload,
                             currentNode.leftChild.leftChild,
                             currentNode.leftChild.rightChild)
   else:
      if currentNode.isLeftChild():
          currentNode.rightChild.parent = currentNode.parent
          currentNode.parent.leftChild = currentNode.rightChild
      elif currentNode.isRightChild():
          currentNode.rightChild.parent = currentNode.parent
          currentNode.parent.rightChild = currentNode.rightChild
      else:
          currentNode.replaceNodeData(currentNode.rightChild.key,
                             currentNode.rightChild.payload,
                             currentNode.rightChild.leftChild,
                             currentNode.rightChild.rightChild)

例子(当前节点是右节点,并且只有右子树的情况下):

第三种情况:要删除的节点有左、右两个节点

如果一个节点有两个子节点,我们不可能简单地其中一个作为提升至父节点的位置,这就需要寻找一个节点,用来代替一个计划删除的节点,我们需要的这个节点,需要保存现有的左、右子树以及二叉搜索树关系。我们称这个节点为继任者。

如何找到这个继任者呢?
按照上面所说,有一个原则:需要保存现有的左、右子树以及二叉搜索树关系。

那么怎样能保证上述原则呢?
我们要找到,与当前节点相比,key值只是稍微大一点点的节点。再具体些,就是,比如一共有10个key,当前节点的key第5大,那么删除掉第5大的节点以后,只有用第4大的节点代替当前节点,才能保证二叉搜索树的各节点关系不变。

比如例子中的5:

上面说的有些笼统,具体的怎么去确定这个继任者呢?
有三种情况需要考虑:

    1. 如果节点有右子节点,那么继任者是在右子树中最小的键。
    1. 如果节点没有右子节点,是其父节点的左子节点,那么父节点是继任者。
    1. 如果节点是其父节点的右子节点,而本身无右子节点,那么该节点的继任者是其父节点的继任者,不包括这个节点。

其中比较常用的是第三种情况中的第一种情况。
删除第三种情况的节点(有两个子节点)的代码分为两步:

  • 1、找到继任者(findSuccessor和findMin)
  • 2、根据找到点以后,确认继任者的几种情况(继任者节点是否具有子节点,有几个子节点等)再进行相应的操作。spliceOut
elif currentNode.hasBothChildren(): #interior
        succ = currentNode.findSuccessor()
        succ.spliceOut()
        currentNode.key = succ.key
        currentNode.payload = succ.payload

上述代码中用到的方法的代码:

def findSuccessor(self):
    succ = None
    if self.hasRightChild():
        succ = self.rightChild.findMin()
    else:
        if self.parent:
               if self.isLeftChild():
                   succ = self.parent
               else:
                   self.parent.rightChild = None
                   succ = self.parent.findSuccessor()
                   self.parent.rightChild = self
    return succ

def findMin(self):
    current = self
    while current.hasLeftChild():
        current = current.leftChild
    return current

def spliceOut(self):
    if self.isLeaf():
        if self.isLeftChild():
               self.parent.leftChild = None
        else:
               self.parent.rightChild = None
    elif self.hasAnyChildren():
        if self.hasLeftChild():
               if self.isLeftChild():
                  self.parent.leftChild = self.leftChild
               else:
                  self.parent.rightChild = self.leftChild
               self.leftChild.parent = self.parent
        else:
               if self.isLeftChild():
                  self.parent.leftChild = self.rightChild
               else:
                  self.parent.rightChild = self.rightChild
               self.rightChild.parent = self.parent

4.4 、iter:按顺序简单地遍历树上所有的键值。

最后的一个接口方法,就是按顺序简单的遍历书上所有的键值。

这是我们用字典所做能的事,为什么不在树也实现呢?我们已经知道如何使用中序遍历二叉树,然而,写一个迭代器需要更多一点的工作,因为每次调用迭代器时,一个迭代器只返回一个节点。

Python提供了一个非常强大的函数yield,使用时创建一个迭代器。yield和return相似,它也返回一个值给调用者。yield也有额外的步骤来记住函数运行目前的状态,以便下次调用函数时,继续从当前状态执行。创建可迭代对象的函数被成为生成器。

二叉树的中序迭代器代码如下所示。仔细看看这个代码:乍一看,你可能会认为代码是非递归的。但是请记住,iter重写for x in的操作符来实现迭代,所以它确实是递归!因为它是对TreeNode进行递归,iter_在TreeNode类中定义。

def __iter__(self):
   if self:
      if self.hasLeftChild():
             for elem in self.leftChiLd:
                yield elem
      yield self.key
      if self.hasRightChild():
             for elem in self.rightChild:
                yield elem

至此,基本的二叉搜索树的结构与性质已经实现了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容