数据结构与算法知识点总结(3)树、图与并查集

  一般地二叉树多用链式存储结构来描述元素的逻辑关系。通常情况下二叉树中的结点定义如下:

typedefstruct btree_node {void *item;struct btree_node *left;struct btree_node *right; } btree_node_t;

  在一些不同的实际应用中,还可以增加某些指针域或者线索化标志,例如增加指向父结点的指针、左右线索化的标志。

  另外如果你想区分二叉树结点和二叉树这种结构,不妨定义如下的二叉树结构(类似于STL中分离定义数据结构和元素结点的方法):

typedefstruct  {     btree_node_t*root;int n;int (*comp)(constvoid *,constvoid *); } btree_t;  typedefvoid (*cb)(btree_node_t *);//定义访问结点的函数指针

  其中n表示二叉树结点的个数,comp表示函数指针。使用函数指针comp因为数据类型使用的是通用指针,在进行查找等比较数据大小的操作时需要定义一个比较函数,在泛型数据结构的C实现中应用非常广泛。

1.1 二叉树的遍历

  二叉树常见的遍历次序有先序、中序、后序三种,其中序表示根结点在何时被访问。每种遍历算法都有对应的递归解法和非递归解法。它的非递归解法中使用了栈这种数据结构。每个遍历都有自身的特点:

  • 先序遍历序列的第一个结点和后序遍历的最后一个结点一定是根结点
  • 在中序遍历序列中,根结点将序列分成两个子序列: 根结点左子树的中序序列和根结点右子树的中序序列
  • 先序序列或者后序序列或者层次序序列结合中序序列可以唯一确定一棵二叉树

  二叉树还有一种层次序遍历,它是按自顶向下、自左向右的访问顺序来访问每个结点,它的实现使用了队列这种数据结构。

  此外二叉树还有一种Morris遍历方法,和上面使用O(n)空间复杂度的方法不同,它只需要O(1)的空间复杂度。这个算法跟线索化二叉树很像,不过Morris遍历是一边建立线索一边访问数据,访问完后直接销毁线索,保持二叉树的不变。Morris算法的原则比较简单:借助所有叶结点的右指针(空指针)指向其后继节点,组成一个环,但由于第二次遍历到这个结点时,由于左子树已经遍历完了,就访问该结点。

  总结下来,遍历是二叉树各种操作的基础,可以在遍历的过程中对结点进行各种操作,例如求二叉树的深度(高度)、二叉树的叶子结点个数、求某层结点个数(或者树的最大宽度、分层输出结点)、判断二叉树是否相同或者是否为完全二叉树或者二叉排序树、求二叉树中结点的最大距离、由遍历序列重建二叉树等。

1.2遍历的递归解法

  递归代码比较简单,就不具体解释了,实现如下:

/*先序遍历,递归*/void bt_preorder(btree_t *t, cb visit){      bt_preorder_rec(t->root,visit);  }void bt_preorder_rec(btree_node_t *cur, cb visit) {if(cur==NULL)return ;     visit(cur);     bt_preorder_rec(cur->left,visit);     bt_preorder_rec(cur->right,visit); }/*中序遍历,递归*/void bt_inorder(btree_t *t, cb visit) {     bt_inorder_rec(t->root,visit); }void bt_inorder_rec(btree_node_t *cur, cb visit) {if(cur==NULL)return ;     bt_inorder_rec(cur->left,visit);     visit(cur);         bt_inorder_rec(cur->right,visit); }/*后序遍历,递归*/void bt_postorder(btree_t *t, cb visit) {     bt_postorder_rec(t->root,visit); }void bt_postorder_rec(btree_node_t *cur, cb visit) {if(cur==NULL)return ;     bt_postorder_rec(cur->left,visit);     bt_postorder_rec(cur->right,visit);     visit(cur); }

1.3基于栈或队列的非递归解法

  A 基于栈的VLR先序遍历
  整体思路:先入栈根结点,然后再判断栈是否为空:不为空,出栈当前元素,并按照右左子树顺序分别入栈。该方法可借助栈的操作,如下的方法采用了类似于栈的实现方式,注意入栈顺序: VRL。

  实现如下:

void bt_preorder_iter(btree_t *t, cb visit){if(t->root){         btree_node_t**stack=malloc(sizeof(btree_node_t *)*(t->n));         stack[0]=t->root;int top=1;while(top>0){//只要栈不为空             btree_node_t *cur=stack[--top];//出栈            visit(cur);if(cur->right)                 stack[top++]=cur->right;if(cur->left)                 stack[top++]=cur->left;          }free(stack);     } }

  B 基于栈的LVR中序遍历
  整体思路:判断条件有两个:栈是否为空或当前结点cur是否为空。根据中序遍历顺序LVR

  • 如果栈为空,需不断压栈当前每个非空结点一直遍历到第一个没有左孩子的根结点
  • 此时cur为空,top(栈中的元素大小)只要大于0,开始进行出栈,访问当前结点,再遍历右子树

  实现如下:

void bt_inorder_iter(btree_t *t, cb visit){     btree_node_t**stack=malloc(sizeof(btree_node_t *)*(t->n));     btree_node_t*cur=t->root;int top=0;while(top>0|| cur!=NULL){if(cur !=NULL){             stack[top++]=cur;             cur=cur->left;         }else{             cur=stack[--top];//出栈当前栈顶元素            visit(cur);             cur=cur->right;         }     }free(stack); }

  C 基于栈的LRV后序遍历
  整体思路: 用栈存储结点时,必须分清返回根结点时:是从左子树返回的还是从右子树的返回的。这里用一个pre指针记录最近刚访问过的结点。当一直往左直到左孩子为空时,判断当前结点的右孩子是否为空或者是否已访问过

  • 若右孩子为空或者已被访问过(LV或者LRV),则访问当前结点,并更新最近访问的结点,并重置当前指针为NULL
  • 否则遍历右孩子(压栈),再转向左

  实现如下:

void bt_postorder_iter(btree_t *t, cb visit){     btree_node_t**stack=malloc(sizeof(btree_node_t *)*(t->n));     btree_node_t*cur=t->root;     btree_node_t*pre=NULL;//指向最近访问过的结点int top=0;while(cur!=NULL||top>0){//当前结点不为空或者栈不为空if(cur){//压栈,一直往左走             stack[top++]=cur;              cur=cur->left;         }else {             cur=stack[top-1];//取栈顶元素if(cur->right&&cur->right!=pre){//如果右子树存在,且未被访问过                 cur=cur->right;                 stack[top++]=cur;//转向右,压栈                 cur=cur->left;//再走向最左,始终保持LRV的遍历顺序             }else{//要么右孩子为空,要么右孩子已经被访问过,弹出当前结点                 cur=stack[--top];                 visit(cur);                 pre=cur;//记录最近访问过的结点,结点访问完重置cur指针                 cur=NULL;             }         }     }free(stack); }

  D 基于队列的层次序遍历
  和先序遍历很类似,区别就是栈换成了队列。实现如下:

void bt_levelorder(btree_t *t,cb visit){if(t->root){int maxsize=t->n+1;//使用循环队列浪费1个空间         btree_node_t **queue=malloc(sizeof(btree_node_t *)*maxsize);         btree_node_t*cur;int front=0;int rear=0;         rear=(rear+1)%maxsize;         queue[rear]=t->root;while(front!=rear){//判断队列是否为空             front=(front+1)%maxsize;             cur=queue[front];//出队            visit(cur);if(cur->left){                 rear=(rear+1)%maxsize;                 queue[rear]=cur->left;//入队            }if(cur->right){                 rear=(rear+1)%maxsize;                 queue[rear]=cur->right;//入队            }         }free(queue);     } }

1.4Morris遍历

  A Morris中序遍历
  步骤如下: 初始化当前节点cur为root节点

  1. 若当前cur没有左孩子,直接访问当前结点,cur转向右孩子
  2. 若cur有左孩子,先寻找到cur的前驱节点
  • 如果前驱节点右孩子为空,记录前驱节点右孩子为当前结点,cur转向左孩子
  • 如果前驱节点右孩子为当前节点,表明左孩子已被访问,将前驱节点右孩子重设为空;直接访问当前结点,cur转向右孩子

  B Morris先序遍历
  步骤如下: 初始化当前节点cur为root节点

  1. 若当前cur没有左孩子,直接访问当前结点,cur转向右孩子
  2. 若cur有左孩子,先寻找到cur的前驱节点
  • 如果前驱节点右孩子为空,记录前驱节点右孩子为当前结点,访问当前节点,并将当前结点设置为已访问过的节点,cur转向左孩子
  • 如果前驱节点右孩子为当前节点,表明左孩子已被访问,将前驱节点右孩子重设为空,cur转向右孩子

  C Morris后序遍历
  Morris后续遍历稍微麻烦点:它必须保证在访问某个当前节点时,左右子树的所有左孩子必须先被访问;而右孩子的输出从底部往顶部逆向访问就行

  步骤如下:设置一个虚拟根结点,记它的左孩子为root,即当前cur为该虚拟根结点

  1. 如果cur的左孩子为空,先记录会被访问的当前节点再转向右孩子分支
  2. 如果cur的左孩子不为空,找到cur的前驱
  • 如果前驱的右孩子为空,建立线索化,记录会被访问的当前节点再转向左孩子分支
  • 如果前驱的右孩子为当前节点,表示已经线索化,因而逆向输出当前节点左孩子到该前驱节点路径上的所有节点,转向当前节点右孩子分支

  实现如下:

void bt_morris_inorder(btree_t *t, cb visit){if(t->root){         btree_node_t*cur=t->root;         btree_node_t*pre;//前驱线索while(cur){if(cur->left==NULL){                 visit(cur);                 pre=cur;//记录已被访问的前驱                 cur=cur->right;             }else{/*先找到cur的前驱节点*/                 btree_node_t*tmp=cur->left;while(tmp->right&&tmp->right!=cur)                     tmp=tmp->right;if(tmp->right==NULL){//表明左子树未访问,先建立线索再访问左子树                     tmp->right=cur;                      cur=cur->left;//没有访问,无需记录pre指针                 }else {//左子树已被访问,则访问当前节点,恢复二叉树,遍历右子树                    visit(cur);                     tmp->right=NULL;                     pre=cur;                     cur=cur->right;                 }             }         }     } }void bt_morris_preorder(btree_t *t, cb visit){if(t->root){         btree_node_t*cur=t->root;         btree_node_t*pre;//前驱线索while(cur){if(cur->left==NULL){                 visit(cur);                 pre=cur;                 cur=cur->right;//记录直接前驱,转向右孩子             }else{                 btree_node_t*tmp=cur->left;while(tmp->right&&tmp->right!=cur)                     tmp=tmp->right;if(tmp->right==NULL){//表明右子树未被访问,访问当前节点,更新线索,转向左孩子                     visit(cur);//仅这一行位置与中序不同                     tmp->right=cur;                     pre=cur;//标记当前节点被访问过(这个与visit函数在同一个代码段内)                     cur=cur->left;                 }else {//表明左子树已被访问,重置线索,转向右孩子                     tmp->right=NULL;/*pre=cur; 不能有这句,因为cur早早被访问*/                     cur=cur->right;                 }             }         }     } }void bt_morris_postorder(btree_t *t, cb visit){if(t->root){         btree_node_t*rec=malloc(sizeof(btree_node_t));         rec->left=t->root;//创建一个dummy结点,它的左孩子指向根结点         btree_node_t *cur=rec;//从虚拟根结点开始遍历         btree_node_t *pre;while(cur){if(cur->left==NULL){                 pre=cur;//和前两个morris遍历不同,这种方法是先线索化后保证一侧子树都被访问完后直接逆向输出                 cur=cur->right;//一般是先访问后再记录被访问的节点,这次相反先记录将被访问的节点后再访问             }else {                 btree_node_t*tmp=cur->left;while(tmp->right&&tmp->right!=cur)                     tmp=tmp->right;if(tmp->right==NULL){//还未线索化,未被访问,先建立线索                     tmp->right=cur;//保证下一次循环时回到后继节点,此时已被线索化                     pre=cur;//必须要有,先记录                     cur=cur->left;                 }else{//已建立线索                     reverse_branch(cur->left,tmp,visit);                     pre->right=NULL;                     pre=cur;//必须要有                     cur=cur->right;                 }              }         }free(rec);     } }

2. 1位示图相关操作

  一个unsigned int数能表示32个整数,整数从0开始,针对整数i:

  • i对应的无符号数组下标索引slot:i/32
  • i对应的索引内容里面的比特位数:i%32(1<<(i&0x1F)

  因而在位数组中,必须提供三个重要操作:

  • 清除位图某位:bm->bits[i/32] &= ~(1<<(i%32))
  • 设置位图某位:bm->bits[i/32] |= (1<<(i%32))
  • 测试位图某位:bm->bits[i/32] & (1<<(i%32))

  这里采用位掩码运算的方法完成这些操作,避免取模和除数运算,效率更高。

2. 2位示图的数据结构定义

  位示图的数据结构定义如下:

/*位示图数据结构定义*/ typedefstruct bitmap{     unsignedint *bits;int size;//整数个数大小 } bitmap_t;

  位示图函数的功能测试如下:

#include"bitmap.h" #include<stdio.h> #include<stdlib.h>#define MAX 10000#define RAD_NUM 100int main(){/*接受大小为MAX的参数,创建一个位图*/     bitmap_t*bm=bitmap_alloc(MAX);      unsignedint i;     unsignedint arr[RAD_NUM];for(i=0;i< RAD_NUM;i++){         arr[i]=RAD_NUM-i;     }/**      * 设置某些数在位图的位表示为1      * 生成RAD_NUM个不相同的随机数据      * 插入位图中,对应位则设置为1*/     printf("原始无序的数据:\n");for(i=0;i< RAD_NUM;i++){         unsignedint j=i+rand()%(RAD_NUM-i);int temp=arr[i];         arr[i]=arr[j];         arr[j]=temp;         printf("%d",arr[i]);if(i%10==0&&i!=0) printf("\n");         bitmap_set(bm,arr[i]);     }     printf("\n");          printf("使用位图排序后的数据:\n");/*查询该数是否在数组中,很容易保证有序输出*/for(i=0;i< MAX;i++){if(bitmap_query(bm,i)){             printf("%d",i);if(i%10==0&&i!=0) printf("\n");         }     }     printf("\n");/*释放位图的内存*/     bitmap_free(bm);return0; }

2. 3位示图的核心实现

  核心的实现还是清除、设置和测试某个位,代码如下:

/*分配指定size大小的位示图内存*/ bitmap_t*bitmap_alloc(int size){     bitmap_t*bm=malloc(sizeof(bitmap_t));     bm->bits=malloc(sizeof(bm->bits[0])*(size+BITS_LENGTH-1)/BITS_LENGTH);//计算合适的slot个数     bm->size=size;     memset(bm->bits,0,sizeof(bm->bits[0])*(size+BITS_LENGTH-1)/BITS_LENGTH);return bm; }/*释放位图内存*/void bitmap_free(bitmap_t *bm){free(bm->bits);free(bm); }/**  * 一个unsigned int数能表示32个整数,整数从0开始,针对整数i:  * i对应的无符号数组下标索引slot: i/32  * i对应的索引内容里面的比特位数: i%32(1<<(i&MASK)  *   * 清除位图某位: bm->bits[i/32] &= ~(1<<(i%32))  * 设置位图某位: bm->bits[i/32] |= (1<<(i%32))  * 测试位图某位: bm->bits[i/32] & (1<<(i%32))  * 这里采用位掩码运算的方法完成这些操作,避免取模和除数运算,效率更高*//**/void bitmap_clear(bitmap_t *bm,unsigned i){if(i>=bm->size){         printf("Invalid integer\n");return ;     }     bm->bits[i>>SHIFT] &= ~(1<<(i & MASK)); }/*设置位图中的某一位*/void bitmap_set(bitmap_t *bm,unsigned i){if(i>=bm->size){         printf("Invalid integer\n");return ;     }     bm->bits[i>>SHIFT] |= (1<<(i & MASK)); }/*查询位图中的某一位,该位为1,返回true;否则返回false*/bool bitmap_query(bitmap_t *bm,unsigned i){if(i>= bm->size)returnfalse;if( (bm->bits[i>>SHIFT]) & (1<<(i & MASK)))returntrue;returnfalse; }

  在某些应用中,要将n个不同的元素分成一组不相交的集合。在这个集合上,有两个重要的操作: 找出给定的元素所属的集合和合并两个集合。再例如处理动态连通性问题,假定从输入中读取了一系列整数对,如果已知的数据可说明当前整数对是相连的,则忽略输出,因而需要设计一个数据结构来保存足够的的整数对信息,并用它们来判断一对新对象是否相连。

  解决这种问题的数据结构称为并查集。下面我们将介绍4种不同的算法实现,它们均以对象标号为索引的id数组来确定两对象是否处在同一个集合中

3.1 quick-find和quick-union算法

  A quick-find算法策略

  find操作实现很快速,只需返回对象所在的集合标识;union操作即要遍历整个数组使得p所在的集合分量值都设置为q所在的集合分量值。