数据结构与算法(十八) -- 平衡二叉树、散列表

455 阅读13分钟

一、平衡二叉树

当一段数据是有序的情况下, 通过这些数据构建出一个二叉排序树:

这个时候查找的效率会非常的低, 所以对这种有序的二叉排序树进行一次转换, 也就是转换成平衡二叉树.

平衡二叉树是一种二叉排序树, 其中每一个结点的左子树和右子树的高度差至多等于1.

这是由两位俄罗斯数学家 G.M.Adelson-Velskii 和 E.M.Landis 共同发明的一种解决平衡二叉树的算法, 也称之为AVL树.

距离插入结点最近的, 且平衡因子的绝对值大于1的根结为根的子树, 我们称之为最小不平衡树.

1.1、平衡二叉树实现原理

平衡二叉树构建的基本思想: 在构建二叉排序树的过程中, 每当插入一个结点时, 先检查是否因插入而破坏来树的平衡性. 若是, 则找到最小不平衡子树. 在保持二叉排序树特性的前提下, 调整最小不平衡子树中各结点之间的链接关系. 进行相应的旋转, 使之称为新的平衡子树.

例如对数组 a[10] = {3, 2, 1, 4, 5, 6, 7, 10, 9, 8} 构建二叉排序树

如果构建成平衡二叉树则是:

  1. 首先先对 3 2 1 进行构建, 当插入 1 数据后会导致失衡, 此时就应该对它进行右旋转得到一个平衡的二叉树

  1. 继续添加4 5, 当插入5会导致失衡, 此时最小失衡是在3这个结点, 此时对3进行左旋转得到一个平衡的二叉树

  1. 添加6, 导致失衡, 此时最小失衡是在2这个结点,此时对2进行左旋转得到一个平衡的二叉树

  1. 添加7, 导致失衡, 此时最小失衡是在5这个结点,此时对5进行左旋转得到一个平衡的二叉树

  1. 添加10 9, 当插入9会导致失衡, 此时最小失衡是在7这个结点, 但是此时7的值为-2 10为1, 两个是互异的, 所以直接旋转会出错, 这个时候就需要将10这个结点进行右旋, 再对7结点进行左旋得到平衡的二叉树

  1. 最后进行添加8, 此时2与9互异, 对9进行右旋, 再对6进行左旋得到最终的平衡二叉树

1.2、平衡二叉树实现算法

定义二叉树结构:

//二叉树的二叉链表结点结构定义
typedef struct BiTNode{
    //结点数据
    int data;
    //结点的平衡因子
    int bf;
    //结点左右孩子指针
    struct BiTNode *lchild,*rchild;
}BiTNode,*BiTree;

右旋的步骤是:

  1. P为右旋的根节点
  2. L的右子树称为P的左子树
  3. P成为L的右子树
  4. L替换来P, 成为新的根结点

/*
 左旋
 对以P为根的二叉排序树作左旋处理
 处理之后P指向新的树根结点,即旋转处理之前的右子树的根结点
 */
void L_Rotate(BiTree *p){
    BiTree R;
    //R是p的右子树
    R = (*p)->rchild;
    //R的左子树作为R的右子树
    (*p)->rchild = R->lchild;
    //将p作为R的左子树;
    R->lchild = (*p);
    //将R替换原有p的根结点的位置
    *p = R;
}

同理可以写出右旋:


/*
 右旋
 对以p为根的二叉排序树作右旋处理;
 处理之后p指向新的树根结点,即旋转处理之前的左子树的根结点;
 */
void R_Rotate(BiTree *p){
    BiTree L;
    //L是p的左子树;
    L = (*p)->lchild;
    //L的右子树作为p的左子树
    (*p)->lchild =  L->rchild;
    //将p作为L的右子树
     L->rchild = (*p);
    //将L替换原有p的根结点位置
    *p =  L;
}

定义三个宏用来记录是 左高 等高 右高三个情况, 表示当前结点需要左旋还是右旋

  1. 首先需要判断最小根节点T与T的左子树L的BF值是否是同符号(判定旋转方向)
  2. 将T的BF值与L的BF值更新为平衡后的BF值 等于0
  3. 将最小不平衡子树进行旋转

对当前失衡结点(左结点)进行旋转处理:

#define LH +1 /*  左高 */
#define EH 0  /*  等高 */
#define RH -1 /*  右高 */

void LeftBalance(BiTree *T)
{
    BiTree L,Lr;
    //L指向T的左子树根结点
    L=(*T)->lchild;
    
    //检查T的左子树的平衡度,并作相应平衡处理
    switch(L->bf)
    {
        //新结点插入在T的左孩子的左子树上,要作单右旋处理(如图1-平衡二叉树右旋解释图)
        case LH:
            //L的平衡因子为LH,即为1时,表示它与根结点BF符合相同,则将它们(T,L)的BF值都改为EH(0)
            (*T)->bf=L->bf=EH;
            //对最小不平衡子树T进行右旋;
            R_Rotate(T);
            break;
            
        //LH的平衡因子为RH(-1)时,它与跟结点的BF值符合相反.此时需要做双旋处理(2次旋转处理)
        //   新结点插入在T的左孩子的右子树上,要作 双旋处理
        case RH:
            
            //Lr指向T的左孩子的右子树根
            Lr=L->rchild;
            
            //修改T及其左孩子的平衡因子
            switch(Lr->bf)
            {
                case LH:
                    (*T)->bf=RH;
                    L->bf=EH;
                    break;
                    
                case EH:
                    (*T)->bf=L->bf=EH;
                    break;
                    
                case RH:
                    (*T)->bf=EH;
                    L->bf=LH;
                    break;
             }
            Lr->bf=EH;
            //对T的左子树作左旋平衡处理
            L_Rotate(&(*T)->lchild);
            //对T作右旋平衡处理
            R_Rotate(T);
    }
}

对当前失衡结点(右结点)进行旋转处理:

void RightBalance(BiTree *T)
{
    BiTree R,Rl;
    //R指向T的右子树根结点
    R=(*T)->rchild;
    
    //检查T的右子树的平衡度,并作相应平衡处理
    switch(R->bf)
    {
        //新结点插入在T的右孩子的右子树上,要作单左旋处理
        case RH:
            (*T)->bf=R->bf=EH;
            L_Rotate(T);
            break;
        //新结点插入在T的右孩子的左子树上,要作双旋处理
        case LH:
            //Rl指向T的右孩子的左子树根
            Rl=R->lchild;
            //修改T及其右孩子的平衡因子
            switch(Rl->bf)
                {
                    case RH:
                        (*T)->bf=LH;
                        R->bf=EH;
                        break;
                    case EH:
                        (*T)->bf=R->bf=EH;
                        break;
                    case LH:
                        (*T)->bf=EH;
                        R->bf=RH;
                        break;
                }
            Rl->bf=EH;
            //对T的右子树作右旋平衡处理
            R_Rotate(&(*T)->rchild);
            //对T作左旋平衡处理
            L_Rotate(T);
    }
}

接下来就是为平衡二叉树进行数据插入:

  1. 如果T为空, 则创建一个新结点.
  2. 如果T不为空, 判断是否存在相同的结点. 如果二叉树中存在相同的结点, 则不需要插入.
  3. 如果新结点e小于T的根节点值, 则在T的左子树查找.
    1. 如果能在左子树中查找到, 则不插入进去, 返回false; 如果没有找到则插入
    2. 如果插入成功, 此时需要判断T的平衡因子
    3. 如果平衡因子是1, 则说明左子树高于右子树, 那么需要调用leftBalance进行左平衡旋转处理
    4. 如果为0或者-1, 则说明新插入的结点没有让平衡二叉树失去平衡, 只需要修改BF值即可
  4. 如果新结点e大于T的根节点值, 则在T的右子树查找
    1. 如果能在左子树中查找到, 则不插入进去, 返回false; 如果没有找到则插入
    2. 如果插入成功, 此时需要判断T的平衡因子
    3. 如果平衡因子是1, 则说明左子树高于右子树, 那么需要调用leftBalance进行左平衡旋转处理
    4. 如果为0或者-1, 则说明新插入的结点没有让平衡二叉树失去平衡, 只需要修改BF值即可
Status InsertAVL(BiTree *T,int e,Status *taller)
{
    if(!*T)
    {   //1.插入新结点,树“长高”,置taller为TRUE
        //开辟一个新结点T;
        *T=(BiTree)malloc(sizeof(BiTNode));
        //对新结点T的data赋值,并且让其左右孩子指向为空,T的BF值为EH;
        (*T)->data=e;
        (*T)->lchild=(*T)->rchild=NULL;
        (*T)->bf=EH;
        // 新结点默认"长高"
        *taller=TRUE;
    }
    else
    {
        if (e==(*T)->data)
        {  //2.树中已存在和e有相同关键字的结点则不再插入
            *taller=FALSE;
            return FALSE;
        }
        if (e<(*T)->data)
        {
           //3.应继续在T的左子树中进行搜索
            if(!InsertAVL(&(*T)->lchild,e,taller))
                //未插入
                return FALSE;
            
            //4.已插入到T的左子树中且左子树“长高”
            if(*taller)
                //5.检查T的平衡度
                switch((*T)->bf)
            {
                case LH:
                    //原本左子树比右子树高,需要作左平衡处理
                    LeftBalance(T);
                    *taller=FALSE;
                    break;
                case EH:
                    //原本左、右子树等高,现因左子树增高而使树增高
                    (*T)->bf=LH;
                    *taller=TRUE;
                    break;
                case RH:
                    //原本右子树比左子树高,现左、右子树等高
                    (*T)->bf=EH;
                    *taller=FALSE;
                    break;
            }
        }
        else
        { //6.应继续在T的右子树中进行搜索
            //未插入
            if(!InsertAVL(&(*T)->rchild,e,taller))
                return FALSE;
            //已插入到T的右子树且右子树“长高”
            if(*taller)
                // 检查T的平衡度
                switch((*T)->bf)
            {
                //原本左子树比右子树高,现左、右子树等高
                case LH:
                    (*T)->bf=EH;
                    *taller=FALSE;
                    break;
                //原本左、右子树等高,现因右子树增高而使树增高
                case EH:
                    (*T)->bf=RH;
                    *taller=TRUE;
                    break;
                // 原本右子树比左子树高,需要作右平衡处理
                case RH:
                    RightBalance(T);
                    *taller=FALSE;
                    break;
            }
        }
    }
    return TRUE;
}

二、散列表

散列技术是记录的存储位置和它的关键字之间建立一个确定的对应关系f, 使得每个关键字key对应一个存储位置f(key). 查找时, 根据这个对应关系找到给定值key的映射下标index. 若查找集合中存在这个记录, 则必定在key 的位置上.

index = f(key)

散列表的操作:

  1. 存储时, 通过散列函数计算记录散列地址
  2. 查找时, 通过散列函数计算, 找到散列地址, 通过散列地址得到需要的数据信息

散列表并不适合大范围的查找, 它针对的是定向查找.

2.1、散列表的几种方法

2.1.1、直接定址法

统计一个一下每个年龄的人数, 在列表中每一个年龄是不会重复出现的, 相当于key, 那么在这张表f中, 直接可以通过f(key)找到对应的内存地址, 在从地址中读取出数据. 例如: f(2) = 50w;

地址 年龄 人数
0x00 0 60W
0x01 1 45W
0x02 2 50W
0x03 3 10W
0x04 4 70W
0x05 5 50W
... ... ...

所以就有如下公式:

f(key) = a * key + b (a,b 为常数)

在简单、均匀、不会容易出现冲突, 并且知道关键字分布情况、查找表比较小并且连续, 这样的方式比较适合用直接定址法.

2.1.2、数字分析法

如果关键字是位数较多的数字, 比如手机号, 各家的手机号都以前面3位数来区分, 中间4位则是手机号地区.

如果用手机号作为关键字, 那么极有可能前7位数字都是相同的. 这个时候就选择用后4位作为散列地址. 并且可以对数组进行反转、位移、相加等操作. 合理的将关键字均匀的分配到散列表.

2.1.3、平方取中法

假设关键字是 1234, 那么它的平方就是1522756, 再抽取中间的3位就是227, 用作散列地址. 在加入关键字是4321, 平方就是18671041, 抽取中间三位可以是671 可以是710用作散列地址, 平方取中法比较适合于不知道关键字的分布, 而位数又不是很大的情况.

2.1.4、折叠法

折叠法就是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够可以稍微短些), 然后将几部分叠加求和, 并按散列表表长, 取后几位作为散列地址.

例如:

9876543210 分割为 987 654 321 0四组. 相加得到1962. 取后三位962就是散列地址.

2.1.5、除留余数法

f(key) = key\mod p (p <= m)

例如需要找到29在哪个地址, 那么只需要 29 % 12 = 5, 那么就可以得到f(5)

但是, 当key = 18、30、42的时候, 得到的地址都为6, 所以就需要来解决一下冲突. 例如, 若散列表表长为m, 通常p为小于等于表长的最小质数或者不包含小于20质因子的合数.

2.1.6、随机数法

选择一个随机数, 取关键字的随机函数值为它的散列地址, 也就是f(key) = random(key), ramdom就是随机函数.当关键字长度不等时, 采用这个方法构造散列函数是比较合适的.

2.2、处理散列表冲突的方法

从上面可以看出, 设计得再好的散列函数也不可能完全避免冲突, 这就像人再健康也只能预防疾病不能完全避免疾病.

2.2.1、开放定址法

f(key) = (f(key) + d_i)\mod m 注:(d_i = 1, 2, 3, ..., m-1)

例如: 关键字集合为: {12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34}, 表长为12, 那么就用散列函数 f(key) = key \mod 12

前5个数据存储完成后:

当存储第6个数据, 37 mod 12 = 1, 此时与f(1) = 25存在冲突. 于是应用公式f(37) = (f(37) + 1) mod 12 = 2, 发现2位置是空, 于是将37存入2位置中.

继续存储剩余数据:

碰到48, f(48) = 0, 与12冲突, f(48) = (f(48) + 1) mod 12 = 1, 与 25冲突, 继续 f(48) = (f(48) + 2) mod 12 = 2... 直到找到6位置为空存放进去

这种不断求余都会争夺一个地址, 占用这个地址附近的地址, 会造成一个数据堆积在此区域. 所以可以增加平方运算来不让关键字都聚集在某一个区域:

f_i(key) = (f(key) + d_i) \mod m 注:(d_i = 1^2, -1^2, 2^2 ....q^2, -q^2, q<=m/2)

2.2.2、再散列函数法

对于散列表来说, 事先准备多个散列函数:

f_i(key) = RH_i(key), (i = 1, 2, 3 ..., k)

这里的RH_i就是不同的散列函数. 第一套函数找到的地址冲突了, 那么就进行第二套函数进行地址计算.

2.2.3、链地址法

将所有的冲突的关键字都存在一个地方, 用链表的结构来记录所有同义的数据

例如: 关键字集合为: {12, 67, 56, 16, 25, 37, 22, 29, 15, 47, 48, 34}

2.2.4、公共益处法

凡是冲突的数据, 都将这个冲突的数据存储起来, 建立一个公共的溢出区来存放. 先去基本表查找, 冲突后再去溢出表顺序查找.